Skip to content

[Browser plugin] playwright run-server is never killed on Debian/Ubuntu (/bin/sh = dash): orphaned node process after every run, terminal hangs when output is piped #1754

Description

@likemusic

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/shdash)
  • 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions