Description
After every pest run that includes browser tests, the playwright run-server node process
survives the pest process on Debian/Ubuntu-based environments (including Laravel Sail
runtimes). One orphan is leaked per run.
The most visible symptom: when pest output is piped (pest ... 2>&1 | cat, CI log
collectors, etc.), the terminal hangs forever after the final test summary — the orphaned
server inherits the stdout pipe, so the reader never gets EOF. Without a pipe the prompt
returns, but orphaned node processes silently accumulate until the machine/container is
restarted.
Environment
- pest-plugin-browser v4.3.1 (the relevant code is unchanged in
4.x HEAD)
- pest 4.x, PHP 8.3 (NTS), Laravel Sail runtime 8.3 (Ubuntu,
/bin/sh → dash)
- playwright 1.59.x, Chromium headless
- Not reproducible on macOS (see root cause — that's why it is easy to miss upstream)
Root cause
ServerManager::playwright() builds the server command as a string and
PlaywrightNpmServer::start() runs it via Process::fromShellCommandline(). A string
command goes through proc_open → /bin/sh -c '...', and Symfony Process only prepends
exec for array commandlines — not for fromShellCommandline().
On Debian/Ubuntu /bin/sh is dash, which does not exec-replace itself with a single
-c command. The resulting process tree is:
php (pest)
└─ sh -c ./node_modules/.bin/playwright run-server ... ← Symfony Process child (getPid())
└─ node ./node_modules/.bin/playwright run-server ... ← the actual server
PlaywrightNpmServer::stop() calls $process->stop(timeout: 0.1, signal: SIGTERM), which
signals only the direct child (sh). sh dies, the node server is reparented to PID 1
and keeps running forever. (The node process itself handles SIGTERM fine — killing it
directly works instantly.)
On macOS /bin/sh is bash, which does exec-replace a single -c command, so the signal
reaches node directly and everything shuts down cleanly — the bug is invisible there.
Standalone reproduction (no pest involved)
<?php
require __DIR__.'/vendor/autoload.php';
use Symfony\Component\Process\Process;
$process = Process::fromShellCommandline(
'./node_modules/.bin/playwright run-server --host 127.0.0.1 --port 59999 --mode launchServer',
getcwd(),
['APP_URL' => 'http://127.0.0.1:59999'],
);
$process->setTimeout(0);
$process->start();
$process->waitUntil(fn ($type, $output) => str_contains($output, 'Listening on'));
echo 'symfony getPid(): '.$process->getPid().PHP_EOL;
system("ps -ef --forest | grep -E 'playwright|php' | grep -v grep");
$process->stop(0.1, SIGTERM); // exactly what PlaywrightNpmServer::stop() does
usleep(700000);
system("pgrep -af 'playwright run-server' || echo 'no survivors'");
Observed output on Ubuntu (/bin/sh → dash):
symfony getPid(): 29995
php repro.php
\_ sh -c ./node_modules/.bin/playwright run-server --host 127.0.0.1 --port 59999 --mode launchServer
| \_ node ./node_modules/.bin/playwright run-server --host 127.0.0.1 --port 59999 --mode launchServer
29996 node ./node_modules/.bin/playwright run-server --host 127.0.0.1 --port 59999 --mode launchServer ← survivor
Suggested fix (verified)
Prepend exec to the shell command in ServerManager::playwright() (non-Windows):
'exec .'.DIRECTORY_SEPARATOR.'node_modules'.DIRECTORY_SEPARATOR.'.bin'.DIRECTORY_SEPARATOR.'playwright run-server --host %s --port %d --mode launchServer',
With exec, sh replaces itself with node, getPid() points at the server itself, and
stop() kills it reliably. Verified with the repro above — the tree collapses to
php → node and no survivors remain after stop(). Also verified end-to-end with a real
browser test run: the terminal returns immediately even with piped output, and
pgrep -af 'playwright run-server' is clean afterwards.
Alternatively, use the array form of Process (no shell at all). Two smaller related
observations spotted while debugging this:
PlaywrightNpmServer::stop() passes SIGTERM as the fallback signal of
Process::stop(), so it never escalates to SIGKILL; passing null (default SIGKILL)
would be more robust.
Plugin::terminate() swallows the Revolt Error («Must call resume() or throw() before
calling suspend() again») with an early return before stopping the playwright server —
if that path is ever taken, the server would leak even with the signal fix.
Description
After every pest run that includes browser tests, the
playwright run-servernode processsurvives the pest process on Debian/Ubuntu-based environments (including Laravel Sail
runtimes). One orphan is leaked per run.
The most visible symptom: when pest output is piped (
pest ... 2>&1 | cat, CI logcollectors, etc.), the terminal hangs forever after the final test summary — the orphaned
server inherits the stdout pipe, so the reader never gets EOF. Without a pipe the prompt
returns, but orphaned node processes silently accumulate until the machine/container is
restarted.
Environment
4.xHEAD)/bin/sh→ dash)Root cause
ServerManager::playwright()builds the server command as a string andPlaywrightNpmServer::start()runs it viaProcess::fromShellCommandline(). A stringcommand goes through
proc_open→/bin/sh -c '...', and Symfony Process only prependsexecfor array commandlines — not forfromShellCommandline().On Debian/Ubuntu
/bin/shis dash, which does not exec-replace itself with a single-ccommand. The resulting process tree is:PlaywrightNpmServer::stop()calls$process->stop(timeout: 0.1, signal: SIGTERM), whichsignals only the direct child (
sh).shdies, the node server is reparented to PID 1and keeps running forever. (The node process itself handles SIGTERM fine — killing it
directly works instantly.)
On macOS
/bin/shis bash, which does exec-replace a single-ccommand, so the signalreaches node directly and everything shuts down cleanly — the bug is invisible there.
Standalone reproduction (no pest involved)
Observed output on Ubuntu (
/bin/sh→ dash):Suggested fix (verified)
Prepend
execto the shell command inServerManager::playwright()(non-Windows):With
exec,shreplaces itself with node,getPid()points at the server itself, andstop()kills it reliably. Verified with the repro above — the tree collapses tophp → nodeand no survivors remain afterstop(). Also verified end-to-end with a realbrowser test run: the terminal returns immediately even with piped output, and
pgrep -af 'playwright run-server'is clean afterwards.Alternatively, use the array form of
Process(no shell at all). Two smaller relatedobservations spotted while debugging this:
PlaywrightNpmServer::stop()passesSIGTERMas the fallback signal ofProcess::stop(), so it never escalates to SIGKILL; passingnull(default SIGKILL)would be more robust.
Plugin::terminate()swallows the RevoltError(«Must call resume() or throw() beforecalling suspend() again») with an early
returnbefore stopping the playwright server —if that path is ever taken, the server would leak even with the signal fix.