Skip to content

fix: bound Playwright requests so a lost or unanswered response cannot hang the suite - #241

Open
joshmanders wants to merge 2 commits into
pestphp:5.xfrom
joshmanders:fix/unbounded-playwright-request-loop
Open

fix: bound Playwright requests so a lost or unanswered response cannot hang the suite#241
joshmanders wants to merge 2 commits into
pestphp:5.xfrom
joshmanders:fix/unbounded-playwright-request-loop

Conversation

@joshmanders

Copy link
Copy Markdown

A browser suite currently has no failure mode for a request that is never answered — only a wedged process. No error, no timeout, no exit; CI runs until the job limit. This fixes three causes of that, all in Playwright/Client.php.

1. Send the timeout as protocol metadata. Playwright 1.62.0 (microsoft/playwright#41712) moved the timeout off each method's params onto the protocol-level metadata, where an absent value means no timeout at all. The client still sent it only in params, so every request to a 1.62.0 server was implicitly unbounded. That's why an action on a zero-match locator hangs forever there but fails cleanly on 1.61.1. Sending it in both places is backwards compatible and needs no version check.

Fixes pestphp/pest#1781.

2. Treat a closed connection as an error. fetch() coerced the null that receive() returns on a closed connection into '', which decodes to null — which has neither an error key to throw on nor an id key to break on. The loop then spins at ~100% CPU forever. This one isn't version specific; it reproduces identically on 1.61.1 and 1.62.0, and anything that drops the socket triggers it — a crashed browser, an OOM-killed server, a flaky runner.

Fixes pestphp/pest#1801.

3. Bound the request as a whole. Insurance against future protocol drift. Both bounds are needed and each covers the other's blind spot: the cancellation stops a request the server never answers, where receive() blocks forever; the deadline check stops one that keeps receiving unrelated messages, where receive() returns without ever suspending and the cancellation never gets scheduled. A per-message timeout bounds neither — messages keep arriving, they're just never the one being awaited.

Verification

Stock Laravel 13 app, both Playwright versions. Reproduction repo: https://github.com/joshmanders/pest-plugin-browser-hang-repro

playwright client zero-match action connection closed mid-request
1.61.1 as shipped Timeout 5000ms exceeded 10.8s hang, 100% CPU, forever
1.62.0 as shipped hang, 0% CPU, forever hang, 100% CPU, forever
1.61.1 this PR Timeout 5000ms exceeded 11.2s error in 2.5s
1.62.0 this PR Timeout 5000ms exceeded 10.6s error in 2.5s

The zero-match rows take ~10s rather than the configured 5000ms because the action goes through the assertion retry loop first — that's pestphp/pest#1755, untouched here.

This repo's suite: 359 passed on 1.62.0 under --parallel. Four unit tests added; I checked they fail without the fix rather than passing vacuously.

Related, and deliberately not addressed

pestphp/pest#1759 reports a separate defect in this same loop — the waitUntil early-break stranding a command's response and desynchronizing later ones. I've left the break condition exactly as it was so the two changes don't collide; that issue's suggested fix applies cleanly on top of this one.

Notes for review

  • requestGraceSeconds is 30s on top of the request timeout, deliberately generous so a slow-but-healthy operation on a loaded CI box doesn't start failing. It's a property rather than a constant only so the tests can shrink it — happy to make it a constant and test it another way.
  • params.timeout is kept alongside the new metadata.timeout so servers older than 1.62.0 keep working.

Two things found while investigating, not fixed here

Happy to open issues or PRs for either.

  • waitForLoadState(), waitForFunction() and waitForURL() are silent no-ops. execute() is a generator function, so its body doesn't run until iterated — these three call it and discard the result. Confirmed by logging every message written to the socket during visit('/')->assertNoJavascriptErrors(), which routes through waitForLoadState('load'): zero waitFor* messages. Present on 4.x too. This bears on withinFrame hangs on pages with external iframes (Stripe, etc) pest#1650 and #1625, which attribute the withinFrame hang to waitForLoadState('networkidle') never resolving — that call currently does nothing at all, so the hang must originate in the waitFor(['state' => 'attached']) that follows it.
  • The persisted server address is trusted without validation. ServerManager::playwright() hands parallel workers AlreadyStartedPlaywrightServer::fromPersisted(), which reads .temp/playwright-server.json without checking anything is still listening, and markAsStopped() only runs on a graceful stop — so a killed run leaves the file pointing at a dead port. Distinct from [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 pest#1754, which covers the orphaned process itself.

@joshmanders
joshmanders marked this pull request as ready for review July 30, 2026 20:08
@bambamboole

Copy link
Copy Markdown

yeah i somehow also encounter this.

@GDanielRG

Copy link
Copy Markdown

same

@yeapea

yeapea commented Aug 24, 2026

Copy link
Copy Markdown

Most of this still applies, but the second commit doesn't any more.

9f43da2 landed on 5.x independently as e84a76d on 2026-08-10, eleven days after you pushed. Same change, plus a bit more — it pulls a per-request $timeout out of $params['timeout'] and sends it in both params and metadata.

It's also the only thing making this PR conflict. git merge-tree origin/5.x pr/241 reports a single content conflict, and the hunk is nothing but the two versions of that same fix. Cherry-pick 0456514 on its own and src/Playwright/Client.php goes in clean; the only leftover is tests/Unit/Playwright/ClientTest.php, which collides because 9f43da2 creates that file and 0456514 adds to it.

One thing worth carrying across when you rebase:

$allowance = ($this->timeout / 1000) + $this->requestGraceSeconds;

e84a76d introduced a local $timeout, and this line wants it. As it stands the grace budget comes off the global default while the request itself might be running on a longer explicit timeout. Nothing breaks today — Page.php:521 is the only caller passing its own timeout (30000 ms) and it still fits — but it stops fitting for anything above global + 30 s.

Last thing, since this PR and #247 both say the two changes don't collide. True for src/, not for the tests: both add tests/Unit/Playwright/ClientTest.php as a new file, so whoever merges second hits an add/add on it. The eight test names don't overlap, so it's just a matter of keeping both.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

4 participants