pestphp / pestphp/pest

[Bug]: Browser plugin — Client::execute() spins at 100% CPU forever when the Playwright connection closes

Open
#1,801 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
PHP
Stars
11.7k
Forks
538
Avg merge
4d 11h
Merged PRs (30d)
8

Description

What Happened

If the WebSocket connection to the Playwright server closes while Pest\Browser\Playwright\Client::execute() is waiting for a response, the test process never fails, never times out and never exits. It busy-loops at ~100% CPU indefinitely. No error, no stack trace, no output — CI runs until the job limit.

Filed here because issues are disabled on pestphp/pest-plugin-browser.

The cause is two lines in src/Playwright/Client.php. fetch() turns a closed connection into an empty string:

private function fetch(WebsocketConnection $client): string
{
    return (string) $client->receive()?->read();   // null -> ''
}

and execute() loops on it with no bound:

while (true) {
    $responseJson = $this->fetch($this->websocketConnection);
    $response = json_decode($responseJson, true);   // '' -> null

    if (isset($response['error']['error']['message'])) { /* null has no error key */ }

    yield $response;

    if (isset($response['id']) && $response['id'] === $requestId) {
        break;                                      // null has no id key either
    }
}

Once the connection is gone, receive() returns null immediately on every call. null becomes '', '' decodes to null, null has no error key so nothing throws and no id key so nothing breaks. Because the loop never blocks, no timeout anywhere in the stack can interrupt it.

Instrumenting fetch() during a live hang:

1785426709.031 EMPTY #1  message=NULL closed=yes closeInfo="Browser closed"
1785426709.032 EMPTY #2  message=NULL closed=yes closeInfo="Browser closed"
...
1785426709.032 EMPTY #15 message=NULL closed=yes closeInfo="Browser closed"

Fifteen iterations inside one millisecond, with the connection reported closed every time. ps shows state R at ~100% CPU for as long as you leave it.

How to Reproduce

Fresh Laravel 13 app with the React starter kit and pestphp/pest-plugin-browser. One browser test, run serially — --parallel is not needed:

// tests/Browser/TimeoutTest.php
it('fails within the configured timeout when the text never appears', function (): void {
    visit('/')->assertSee('THIS TEXT NEVER APPEARS ON THE PAGE');
});

Start the suite, then close the Playwright server while a request is in flight:

./vendor/bin/pest --filter='never appears' &
sleep 3
pkill -f "playwright run-server"

Expected: the test fails with a connection error.
Actual: Pest never exits. ps -o %cpu,stat reports ~100% and state R indefinitely.

In the sample repository, ./repro.sh hang 3 --filter='never appears' does all of this and prints a verdict.

Sample Repository

https://github.com/joshmanders/pest-plugin-browser-hang-repro

Pest Version

pest v5.0.2, pest-plugin-browser v5.0.0

PHP Version

8.5.8

Operation System

macOS, Linux

Notes

Not specific to a Playwright version. I first hit this on the playwright 1.61.1 → 1.62.0 bump and reported it there (microsoft/playwright#42046). It was closed as out of scope, correctly — the protocol change involved was intentional. Testing the client in isolation shows the spin is not tied to a Playwright version:

playwright Client.php connection closes mid-request
1.61.1 as shipped hang, ~100% CPU, forever
1.62.0 as shipped hang, ~100% CPU, forever
1.61.1 patched fails in ~2.5s with a clear message
1.62.0 patched fails in ~2.5s with a clear message

1.62.0 did not introduce this; it closed connections more often and exposed something latent. On any version, anything that drops the socket wedges the suite — a crashed browser, an OOM-killed server, a dropped connection on a loaded CI runner.

Scope of the reproduction. The steps above force the close with pkill, because that isolates the client defect deterministically. I have not reproduced an organic close in this minimal app — 60 browser tests under --parallel against a healthy server complete cleanly in ~11s. The original occurrence was a 235-test suite under --parallel, which hung on macOS and on ubuntu-latest in CI. What the forced close demonstrates is that whenever the connection does drop, for any reason, the client cannot survive it.

Related, but distinct. #1781 describes an action on a zero-match locator hanging in this same loop, but it blocks idle at 0% CPU waiting for a message that never arrives; this one spins at ~100% CPU on messages that already arrived and can never match. #1754 (the orphaned run-server) is a different mechanism, though worth noting it can look like this bug when output is piped, since the orphan holds the pipe open and the reader never gets EOF.

Suggested fix. Throw when receive() returns null — a closed connection is an error, not an empty message — and bound the request as a whole rather than per message. I have a PR ready and will link it here.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in src/Playwright/Client.php by tracing fetch() and execute() when WebsocketConnection::receive() returns null. Run the issue's tests/Browser/TimeoutTest.php reproduction or ./repro.sh hang 3 --filter='never appears' and verify that closing the Playwright server produces a connection error and the Pest process exits instead of spinning.

Written by the indexing model from the issue text.

Assessment

Tech stack
php
Domain
testing-qa
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.