Skip to content

End forked workers with _exit() instead of PHP's full teardown - #6320

Merged
ondrejmirtes merged 2 commits into
2.2.xfrom
forked-worker-exit
Aug 31, 2026
Merged

ondrejmirtes merged 2 commits into
2.2.xfrom
forked-worker-exit

Conversation

@ondrejmirtes

Copy link
Copy Markdown
Member

A pcntl_fork()ed worker inherits every loaded extension of the main process but none of its threads. exit() runs each extension's module shutdown in the child, and one that waits for its threads to check out there — ext-grpc's grpc_shutdown() without grpc.enable_fork_support, as sampled by a user in phpstan/phpstan#15131 — waits forever: the worker has delivered its result, the parent keeps polling waitpid(), and the run hangs at 100% with no "Result cache is saved". PHPSTAN_TURBO=0 means spawn mode, which is why it "works"; and the trigger is the machine's php.ini rather than the project, which is why no reproducer repository could show it.

A forked child that does not exec() has to end with _exit(), which PHP itself cannot do. The turbo extension now provides Runtime::exitImmediately() (_exit() with the engine's exit status), and ForkedChildTerminator registers it as the forked child's last shutdown function — after ForkedChildCrashReporter's — in both ForkedProcess and the fixer's ForkedProcessPromise. That covers every way out (exit(), a fatal error, an uncaught exception), skips the destructors and every extension's module shutdown, and still lets the crash report reach the parent. Fork mode from a phar already requires the extension; a source checkout forking without it keeps the plain exit().

Regression coverage:

  • turbo-ext/tests/exit-immediately.php models the wedged teardown in userland (a control pass proves the child hangs with plain exit()), run in every phar.yml matrix next to the smoke tests.
  • A new e2e job builds a minimal fork-unsafe extension with ext-grpc's shape (RINIT starts a thread, MSHUTDOWN waits for it) and runs fork mode with it loaded — after first proving that the extension alone wedges a bare pcntl_fork() + exit().

Verified locally with that model extension loaded: the shipped 2.2.11 phar hangs at 100% (the forked worker sampled in php_module_shutdown → zm_shutdown_* → pthread_cond_wait), a phar built from this branch completes in the same setup (symfony php, restart with the dist .so, fork (pcntl_fork)), a worker dying of the memory limit still reports "PHPStan process crashed because it reached configured PHP memory limit", the full test suite passes with the extension loaded, and raw analysis output is identical with turbo on and off.

Closes phpstan/phpstan#15138
Closes phpstan/phpstan#15142

🤖 Generated with Claude Code

https://claude.ai/code/session_01C7847gqpkLK9hdBztahv8c

Comment thread .github/workflows/e2e-tests.yml Fixed
- name: "Install bashunit"
uses: "TypedDevs/bashunit@94933c088b0719e0c4cf5e3147dad67f713b970d" # 0.44.0
with:
directory: "e2e"
- name: "Build the turbo extension"
run: make -C turbo-ext -j"$(nproc)"

- name: "Build the fork-unsafe extension"
- name: "Build the fork-unsafe extension"
# a model of ext-grpc without grpc.enable_fork_support: its module
# shutdown waits for a thread that a forked child never has. phpize
# builds in place, so a copy keeps the build tree out of the checkout.
Comment on lines +1082 to +1088
with:
coverage: "none"
php-version: "8.5"

- uses: "ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda" # v4.0.0

- name: "Install bashunit"
ondrejmirtes and others added 2 commits August 31, 2026 18:04
A pcntl_fork()ed worker inherits every loaded extension of the main
process but none of its threads. exit() runs each extension's module
shutdown in the child, and one that waits for its threads to check out
there - ext-grpc's grpc_shutdown() without grpc.enable_fork_support -
waits forever: the worker has delivered its result, the parent keeps
polling waitpid(), and the run hangs at 100%.

A forked child that does not exec() has to end with _exit(), which PHP
itself cannot do. The turbo extension now provides
Runtime::exitImmediately(), and ForkedChildTerminator registers it as the
child's last shutdown function, after the crash reporter's, so every way
out - exit(), a fatal error, an uncaught exception - skips the destructors
and module shutdowns while the crash report still reaches the parent.
Fork mode from a phar already requires the extension; a source checkout
forking without it keeps the plain exit().

turbo-ext/tests/exit-immediately.php models the wedged teardown in
userland, and the new e2e job builds a minimal fork-unsafe extension
(ext-grpc's shape) and runs fork mode with it loaded.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01C7847gqpkLK9hdBztahv8c
@ondrejmirtes
ondrejmirtes merged commit aa90b57 into 2.2.x Aug 31, 2026
389 of 391 checks passed
@ondrejmirtes
ondrejmirtes deleted the forked-worker-exit branch August 31, 2026 16:07
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.

PHPStan 2.2.11 hangs with turbo extension Stuck on 100%

2 participants