[Bug]: Browser plugin — Client::execute() spins at 100% CPU forever when the Playwright connection closes
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
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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