Skip to content

Make fork-mode parallel workers work when PHPStan runs from a .phar#5669

Open
ondrejmirtes wants to merge 2 commits into
2.1.xfrom
pcntl-fork-phar-extract
Open

Make fork-mode parallel workers work when PHPStan runs from a .phar#5669
ondrejmirtes wants to merge 2 commits into
2.1.xfrom
pcntl-fork-phar-extract

Conversation

@ondrejmirtes
Copy link
Copy Markdown
Member

Follow-up to #5663. Without this, the experimental pcntl_fork()
parallel-worker path can't be used when PHPStan is installed via
composer (i.e. running as phpstan.phar) — it surfaces as spurious
parse errors against phar-internal files:

Parse error: Unterminated comment starting line 103 in
phar:///…/phpstan.phar/vendor/react/socket/src/ConnectionInterface.php
Parse error: Unclosed '{' on line 170 in
phar:///…/phpstan.phar/vendor/react/socket/src/SocketServer.php

Why it happens

PHP's built-in phar:// stream wrapper opens the .phar once at
runtime and caches a single fd internally. After pcntl_fork() that
fd's open file description (and its seek cursor) is shared between
parent and every forked child — concurrent lazy class loads in
different workers move the cursor under each other, so a worker
fread()s the bytes belonging to a different file and the parser sees
a half-truncated source that almost parses. The corruption position
is non-deterministic but always lands at a "looks plausible" spot in
some phar-internal file.

This is OS-level fd semantics, not something PHP userland can fix by
fiddling with the existing wrapper.

How this fixes it

PharForkPreparation::prepare() runs in the parent before any
fork:

  1. Phar::extractTo() the running phar into a fresh per-run tmp
    directory.
  2. stream_wrapper_unregister('phar') and re-register
    PharRedirectStreamWrapper in its place.

The redirect wrapper translates every phar://…/foo access to
$extractDir/foo and delegates to ordinary disk I/O. After fork each
child opens fresh fds against ordinary files — no shared cursor, no
race. Autoload, file_get_contents, stat, directory iteration all
keep working transparently (the user code never knows it isn't talking
to phar:// anymore).

ParallelAnalyser and FixerApplication call the (idempotent)
prepare() once they have decided to take the fork path. When not
running from a phar (Phar::running(false) === '') it's a no-op. A
shutdown hook with a getmypid() guard cleans the tmp dir on parent
exit only.

Cost

  • Parent: one extraction (~100ms for PHPStan's phar, ~30–80 MB in
    /tmp).
  • Workers: per-read translation in the wrapper (microseconds, dwarfed
    by the saved per-worker re-boot).
  • Memory: laziness preserved — children only read files they actually
    need; nothing is eagerly pre-loaded.

🤖 Generated with Claude Code

ondrejmirtes and others added 2 commits May 15, 2026 10:40
PHP's built-in phar:// stream wrapper caches a single fd for the running
.phar. After pcntl_fork() that fd's open file description is shared
between parent and all forked children, so concurrent lazy class loads
in different workers race on the shared seek cursor and read garbage
offsets — surfacing as spurious parse errors at "almost valid"
positions in phpstan.phar-internal files.

This adds PharForkPreparation: before forking, when running inside a
phar, it extracts the phar to a fresh tmp directory and registers
PharRedirectStreamWrapper as the phar:// handler. Every phar://… read
in the children is then transparently rerouted to the on-disk copy —
each open is an ordinary fopen() with its own fd, so there is no shared
cursor and no race. Autoload, file_get_contents, stat, directory
iteration all keep working through the wrapper.

ParallelAnalyser and FixerApplication call the (idempotent) prepare()
once they have decided to take the fork path. The extracted directory
is cleaned up on parent shutdown.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…apper

Phars can be addressed three equivalent ways: by absolute path
(phar:///abs/path/to/phpstan.phar/x), by their explicit alias
(phar://phpstan.phar/x — what Phar::setAlias() / the build-time alias
declares), or by basename (also typically the implicit alias). The
redirect wrapper only knew about the absolute-path form, so anything
constructing URLs via the alias — Nette's config loader does exactly
this for "phar://phpstan.phar/conf/bleedingEdge.neon" — couldn't be
translated and ended up "file not found".

Wrapper now accepts any of the three prefixes (deduplicated; the
in-flight cascade where the boundary-after-prefix-check rejects a
false positive like "/foo.phar.bak" matching "/foo.phar" is kept).

PharForkPreparation reads Phar::getAlias() at extraction time and
passes it to ::configure().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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