From 1dc5eac7bca3dbda1d3e1c01478893878a473a23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Andr=C3=A9?= Date: Tue, 21 Jul 2026 16:50:35 +0200 Subject: [PATCH 1/2] Shut down the Node bridge when its parent dies A PHP process killed with SIGTERM or SIGKILL never runs its shutdown functions, so nothing terminates the bridge. Its stdin closes, but the server registered no lifecycle handler and kept waiting on input that would never come, holding every browser it had open. Chromium is a child of the bridge rather than of PHP, so it was reparented to init and no PHP-side cleanup could reach it. Observed outcome: 175 headless browsers alive for up to three days, and the macOS code-signing clones pinned by them filling the disk. Exit on stdin end or close, which is the one signal available in every case a PHP shutdown hook cannot cover, and on SIGTERM and SIGHUP. Guard against re-entry and arm an unref'd watchdog so a browser that refuses to close cannot wedge the process: exiting without cleanup still lets Chromium see the broken pipe, hanging does not. The regression test kills a probe process outright and asserts that the bridge and browser pids it captured beforehand are gone. It watches captured pids rather than a pgrep count, since any machine already running Playwright would break a count, and compares command lines rather than bare pid existence, since a recycled pid would otherwise read as a survivor. Reading parentage and force-killing differ per platform, so each variant is gated with RequiresOperatingSystemFamily. --- bin/playwright-server.js | 27 ++ tests/Fixtures/orphan-probe.php | 44 +++ .../Transport/OrphanedBrowserTest.php | 348 ++++++++++++++++++ 3 files changed, 419 insertions(+) create mode 100644 tests/Fixtures/orphan-probe.php create mode 100644 tests/Integration/Transport/OrphanedBrowserTest.php diff --git a/bin/playwright-server.js b/bin/playwright-server.js index ffcc750..2cc60d1 100644 --- a/bin/playwright-server.js +++ b/bin/playwright-server.js @@ -389,3 +389,30 @@ process.stdin.on('data', ErrorHandler.wrapHandler(async (chunk) => { sendFramedResponse({ error: 'LSP framing error: ' + error.message }); } })); + +// The PHP process owns this server. Once it is gone, nothing will ever arrive on +// stdin again and every browser we hold becomes an orphan reparented to init. +// stdin closing covers the cases no PHP-side shutdown hook can reach: SIGKILL, +// a fatal error, or SIGTERM, none of which run register_shutdown_function. +let shuttingDown = false; + +async function shutdown(reason) { + if (shuttingDown) return; + shuttingDown = true; + + // A browser that refuses to close must not wedge the process: exiting without + // cleanup still lets Chromium notice the broken pipe, hanging here does not. + const watchdog = setTimeout(() => process.exit(1), 5000); + watchdog.unref(); + + try { + logger.info('Parent gone, shutting down', { reason }); + } catch {} + + await server.exit(); +} + +process.stdin.on('end', () => shutdown('stdin end')); +process.stdin.on('close', () => shutdown('stdin close')); +process.on('SIGTERM', () => shutdown('SIGTERM')); +process.on('SIGHUP', () => shutdown('SIGHUP')); diff --git a/tests/Fixtures/orphan-probe.php b/tests/Fixtures/orphan-probe.php new file mode 100644 index 0000000..3cfadab --- /dev/null +++ b/tests/Fixtures/orphan-probe.php @@ -0,0 +1,44 @@ + + */ + +require dirname(__DIR__, 2).'/vendor/autoload.php'; + +use Playwright\Playwright; + +$readyFile = $argv[1] ?? null; + +if (!is_string($readyFile) || '' === $readyFile) { + fwrite(\STDERR, "usage: orphan-probe.php \n"); + exit(1); +} + +$context = Playwright::chromium(['headless' => true]); +$context->newPage(); + +// Announce readiness only once the browser is actually up, so the test never +// races against a half-started process tree. +file_put_contents($readyFile, (string) getmypid()); + +sleep(300); diff --git a/tests/Integration/Transport/OrphanedBrowserTest.php b/tests/Integration/Transport/OrphanedBrowserTest.php new file mode 100644 index 0000000..160041e --- /dev/null +++ b/tests/Integration/Transport/OrphanedBrowserTest.php @@ -0,0 +1,348 @@ + */ + private array $watchedPids = []; + + protected function tearDown(): void + { + // A failed assertion must not leak the very orphans this test is about. + foreach ($this->watchedPids as $pid) { + if ('Windows' === \PHP_OS_FAMILY) { + $this->killWithTaskkill($pid); + } elseif (\function_exists('posix_kill')) { + @posix_kill($pid, 9); + } + } + + $this->probe?->stop(0); + $this->probe = null; + $this->watchedPids = []; + + if (null !== $this->readyFile && file_exists($this->readyFile)) { + @unlink($this->readyFile); + } + $this->readyFile = null; + + parent::tearDown(); + } + + #[RequiresOperatingSystemFamily('Darwin')] + #[RequiresPhpExtension('posix')] + public function testBridgeAndBrowserDieWithAKilledPhpProcessOnMacOs(): void + { + $this->assertProcessTreeDiesWithItsParent( + $this->childrenFromPs(...), + $this->commandLineFromPs(...), + $this->killWithSigkill(...), + ); + } + + #[RequiresOperatingSystemFamily('Linux')] + #[RequiresPhpExtension('posix')] + public function testBridgeAndBrowserDieWithAKilledPhpProcessOnLinux(): void + { + $this->assertProcessTreeDiesWithItsParent( + $this->childrenFromProc(...), + $this->commandLineFromProc(...), + $this->killWithSigkill(...), + ); + } + + #[RequiresOperatingSystemFamily('Windows')] + public function testBridgeAndBrowserDieWithAKilledPhpProcessOnWindows(): void + { + $this->assertProcessTreeDiesWithItsParent( + $this->childrenFromWmi(...), + $this->commandLineFromWmi(...), + $this->killWithTaskkill(...), + ); + } + + /** + * @param callable(int): list $children resolves the direct children of a pid + * @param callable(int): ?string $commandLine resolves a pid's command line, null when it is gone + * @param callable(int): void $forceKill kills a pid outright, no chance to clean up + */ + private function assertProcessTreeDiesWithItsParent(callable $children, callable $commandLine, callable $forceKill): void + { + $phpPid = $this->startProbe(); + + $bridgePids = $children($phpPid); + self::assertNotSame([], $bridgePids, 'the probe should have spawned a Node bridge'); + + $browserPids = []; + foreach ($bridgePids as $bridgePid) { + $browserPids = [...$browserPids, ...$children($bridgePid)]; + } + self::assertNotSame([], $browserPids, 'the Node bridge should have spawned a browser'); + + // Remember each command line: a pid alone would give false positives once + // the OS recycles it, and this test polls for several seconds. + $watched = []; + foreach ([...$bridgePids, ...$browserPids] as $pid) { + $watched[$pid] = $commandLine($pid); + } + $this->watchedPids = array_keys($watched); + + $forceKill($phpPid); + + try { + $this->probe?->wait(); + } catch (ProcessSignaledException) { + // Being signaled is the whole point of this test. + } + + $survivors = $this->waitForDeath($watched, $commandLine); + + self::assertSame([], $survivors, sprintf( + 'killing the PHP process left %d orphan(s) behind: %s', + \count($survivors), + implode(', ', array_map( + static fn (int $pid): string => $pid.' ('.($watched[$pid] ?? '?').')', + $survivors, + )), + )); + } + + /** + * Launches the probe and returns its pid once the browser is actually up. + */ + private function startProbe(): int + { + $this->readyFile = sys_get_temp_dir().'/playwright-php-orphan-'.bin2hex(random_bytes(8)); + + $probe = new Process([\PHP_BINARY, __DIR__.'/../../Fixtures/orphan-probe.php', $this->readyFile]); + $probe->setTimeout(null); + $probe->start(); + $this->probe = $probe; + + $pid = $probe->getPid(); + if (null === $pid) { + self::markTestSkipped('could not start the probe process'); + } + + $deadline = microtime(true) + self::READY_TIMEOUT_SECONDS; + while (!file_exists($this->readyFile)) { + if (!$probe->isRunning()) { + self::markTestSkipped('probe could not launch a browser (missing Node or browser binaries): '.trim($probe->getErrorOutput())); + } + if (microtime(true) > $deadline) { + self::markTestSkipped(sprintf('probe did not report ready within %ds', self::READY_TIMEOUT_SECONDS)); + } + usleep(200_000); + } + + return $pid; + } + + /** + * @param array $watched pid to the command line it had when captured + * @param callable(int): ?string $commandLine + * + * @return list pids still alive when the deadline expired + */ + private function waitForDeath(array $watched, callable $commandLine): array + { + $deadline = microtime(true) + self::DEATH_TIMEOUT_SECONDS; + + do { + $survivors = []; + foreach ($watched as $pid => $capturedCommandLine) { + if ($commandLine($pid) === $capturedCommandLine) { + $survivors[] = $pid; + } + } + + if ([] === $survivors) { + return []; + } + + usleep(250_000); + } while (microtime(true) < $deadline); + + return $survivors; + } + + /** + * macOS has no /proc, so the whole table has to come from `ps`. + * + * @return list + */ + private function childrenFromPs(int $parentPid): array + { + $ps = new Process(['ps', '-eo', 'pid=,ppid=']); + $ps->run(); + + $children = []; + foreach (explode("\n", $ps->getOutput()) as $line) { + $fields = preg_split('/\s+/', trim($line), -1, \PREG_SPLIT_NO_EMPTY); + if (!\is_array($fields) || 2 !== \count($fields)) { + continue; + } + if ((int) $fields[1] === $parentPid) { + $children[] = (int) $fields[0]; + } + } + + return $children; + } + + private function commandLineFromPs(int $pid): ?string + { + $ps = new Process(['ps', '-p', (string) $pid, '-o', 'command=']); + $ps->run(); + + $commandLine = trim($ps->getOutput()); + + return '' === $commandLine ? null : $commandLine; + } + + /** + * Linux exposes parentage in /proc, no subprocess needed. + * + * @return list + */ + private function childrenFromProc(int $parentPid): array + { + $entries = glob('/proc/[0-9]*'); + if (false === $entries) { + return []; + } + + $children = []; + foreach ($entries as $entry) { + $status = @file_get_contents($entry.'/status'); + if (false === $status) { + continue; + } + if (1 === preg_match('/^PPid:\s+(\d+)$/m', $status, $matches) && (int) $matches[1] === $parentPid) { + $children[] = (int) basename($entry); + } + } + + return $children; + } + + private function commandLineFromProc(int $pid): ?string + { + $raw = @file_get_contents('/proc/'.$pid.'/cmdline'); + if (false === $raw || '' === $raw) { + return null; + } + + return trim(str_replace("\0", ' ', $raw)); + } + + /** + * Windows has neither /proc nor ps; parentage lives in WMI. + * + * A PowerShell that will not run is an unusable environment, not a passing + * test: skip loudly instead of reporting an empty tree, which the caller + * would otherwise have to tell apart from a genuinely childless process. + * + * @return list + */ + private function childrenFromWmi(int $parentPid): array + { + $output = $this->runPowerShell(sprintf( + 'Get-CimInstance Win32_Process -Filter "ParentProcessId=%d" | ForEach-Object { $_.ProcessId }', + $parentPid, + )); + + $children = []; + foreach (explode("\n", $output) as $line) { + $line = trim($line); + if ('' !== $line && ctype_digit($line)) { + $children[] = (int) $line; + } + } + + return $children; + } + + private function commandLineFromWmi(int $pid): ?string + { + $commandLine = trim($this->runPowerShell(sprintf( + '(Get-CimInstance Win32_Process -Filter "ProcessId=%d").CommandLine', + $pid, + ))); + + return '' === $commandLine ? null : $commandLine; + } + + private function runPowerShell(string $command): string + { + $powerShell = new Process(['powershell', '-NoProfile', '-NonInteractive', '-Command', $command]); + $powerShell->run(); + + if (!$powerShell->isSuccessful()) { + self::markTestSkipped('cannot inspect the process tree, PowerShell failed: '.trim($powerShell->getErrorOutput())); + } + + return $powerShell->getOutput(); + } + + private function killWithSigkill(int $pid): void + { + posix_kill($pid, 9); + } + + /** + * No /T here, deliberately: killing the tree would take the bridge and the + * browser down by hand and prove nothing. Only the PHP process dies. + */ + private function killWithTaskkill(int $pid): void + { + (new Process(['taskkill', '/F', '/PID', (string) $pid]))->run(); + } +} From ef8585759a85d2e3145684b29804bdfab8c0ba51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Andr=C3=A9?= Date: Tue, 21 Jul 2026 16:50:39 +0200 Subject: [PATCH 2/2] Ignore the npm lockfile alongside the pnpm one --- bin/.gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/bin/.gitignore b/bin/.gitignore index c1d7933..544165a 100644 --- a/bin/.gitignore +++ b/bin/.gitignore @@ -2,3 +2,4 @@ debug-dispatch.log playwright-server.log screenshot_* pnpm-lock.yaml +package-lock.json