Skip to content

fix: keep the Playwright connection when a forked child process exits - #297

Open
Vasiliy-Bondarenko wants to merge 1 commit into
pestphp:5.xfrom
Vasiliy-Bondarenko:fix/forked-child-closes-connection
Open

Vasiliy-Bondarenko wants to merge 1 commit into
pestphp:5.xfrom
Vasiliy-Bondarenko:fix/forked-child-closes-connection

Conversation

@Vasiliy-Bondarenko

Copy link
Copy Markdown
Contributor

What

A process forked from the test process (pcntl_fork) that ends with a plain exit() kills the test process's Playwright connection. The next browser call fails:

Client unexpectedly closed; Code 1006 (ABNORMAL_CLOSE); Reason: "Writing to the client failed"

So does every later browser test in that process. With 5.0.x the test process could also spin at 100% CPU in Client::fetch() (fixed separately in 5.2.0). Code that forks shows this in real suites: worker pools, a job killed by pcntl_alarm() + exit(), race-condition tests that fork children.

Why

The child inherits the WebSocket socket. On exit() PHP destroys the child's copy of the connection. amphp's UpgradedSocket::__destruct() calls close() → ResourceSocket::close() → stream_socket_shutdown(). Unlike fclose(), shutdown() acts on the socket itself and not on the child's descriptor, so the Playwright server receives FIN and drops the connection that the parent still uses.

It depends on the order PHP destroys objects at shutdown. In a fresh process the socket's read and write streams go first and null their resource, so the later close() does nothing. PHP reuses freed object handles (last freed, first reused). In a process that has already created and freed many objects, which describes any long test run, the order flips. That is why it shows up in full suites and not when a single test runs.

Fix

Client creates the connection through a small ConnectionFactory that hands it the underlying socket. The client remembers which process opened the connection and registers a shutdown function once. In any other process, that function fclose()s the process's own copy of the socket before PHP runs the destructors, so the shared socket is never shut down.

Test

tests/Browser/Visit/ForkedChildTest.php reconnects after recycling object handles, which makes the destruction order deterministic. It then forks a child that calls exit(0) and visits a page again. The test fails on main with the error above and passes with the fix. composer test passes: lint, profanity, 100% type coverage, phpstan and all 432 tests.

Standalone reproduction: a clean Laravel 13 + Pest 5.3 + plugin 5.2.0 project with one failing test and one SIGKILL control. It can be shared if useful.

A process forked from the test process (pcntl_fork) inherits the Playwright
WebSocket socket. When the child exits, PHP destroys its copy of the
connection: amphp's UpgradedSocket::__destruct() closes the socket with
stream_socket_shutdown(), which acts on the socket shared by both processes,
so the Playwright server drops the connection the test process still uses.

The client now remembers the process that opened the connection and, in any
other process, closes only that process's copy of the socket (fclose) in a
shutdown function, before PHP runs the destructors.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant