Make fork-mode parallel workers work when PHPStan runs from a .phar#5669
Open
ondrejmirtes wants to merge 2 commits into
Open
Make fork-mode parallel workers work when PHPStan runs from a .phar#5669ondrejmirtes wants to merge 2 commits into
ondrejmirtes wants to merge 2 commits into
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 spuriousparse errors against phar-internal files:
Why it happens
PHP's built-in
phar://stream wrapper opens the.pharonce atruntime and caches a single fd internally. After
pcntl_fork()thatfd'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 seesa 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 anyfork:
Phar::extractTo()the running phar into a fresh per-run tmpdirectory.
stream_wrapper_unregister('phar')and re-registerPharRedirectStreamWrapperin its place.The redirect wrapper translates every
phar://…/fooaccess to$extractDir/fooand delegates to ordinary disk I/O. After fork eachchild opens fresh fds against ordinary files — no shared cursor, no
race. Autoload,
file_get_contents, stat, directory iteration allkeep working transparently (the user code never knows it isn't talking
to
phar://anymore).ParallelAnalyserandFixerApplicationcall the (idempotent)prepare()once they have decided to take the fork path. When notrunning from a phar (
Phar::running(false) === '') it's a no-op. Ashutdown hook with a
getmypid()guard cleans the tmp dir on parentexit only.
Cost
/tmp).by the saved per-worker re-boot).
need; nothing is eagerly pre-loaded.
🤖 Generated with Claude Code