Ask Composer's autoloaders for the file instead of letting them include it - #6431
theodorejb wants to merge 1 commit into
Conversation
2eb5402 to
3f90b11
Compare
3f90b11 to
1f51101
Compare
1f51101 to
5ea1f21
Compare
5ea1f21 to
80d5c78
Compare
|
This pull request has been marked as ready for review. |
|
The fix works from source, but in the phar the new fast path never runs, so the fatal from phpstan/phpstan#15184 is still there. php-scoper prefixes the new import. The phar that CI built for this PR (artifact The project's autoloader is an unprefixed Repro on macOS, PHP 8.5.8: a project with
On the PR head from source:
All five red checks are also red on #6564, another 2.2.x PR. I did not run the Windows setup from the issue, and I did not measure performance. |
80d5c78 to
fb40c09
Compare
|
@SanderMuller Thanks for finding the phar issue. I rebased and had Claude update the commit to add In regards to performance, two A/B runs each of PHPStan's own |
…de it AutoloadSourceLocator finds which file declares a class by running the registered autoloaders behind FileReadTrapStreamWrapper, which records the path an include reached for and serves an empty script in its place. That only shadows the real file while the compiler asks the wrapper for the contents. With OPcache already holding the script it does not ask, and the file runs a second time - fatal for one declaring a function, which is the function-per-file layout of php-standard-library and azjezz/psl: their files-autoload bootstrap has already loaded every path their PSR-4 prefix also resolves to. ClassLoader::findFile() answers with the same path loadClass() would include, without running anything, so ask it rather than arranging for the include to be harmless. Nothing is compiled and no cache is consulted, which is what makes this hold wherever PHP runs. Autoloaders still run in registration order: every non-Composer one runs inside the trap as before, one at a time, since an autoloader ahead of Composer's may claim a name Composer would resolve elsewhere. Running them one at a time also stops hoa/compiler's autoloader, registered ahead of the analysed project's loader, from forcing the whole probe down the include path. findFile() concatenates the mapped prefix with the rest of the name, so its answer can carry ../ segments and mixed separators, where PHP resolves an include path before the trap ever sees it. It is resolved here to match, which locateIdentifier() relies on when it compares the located path against ReflectionClass::getFileName() to tell two same-named classes in one file apart. In the phar, php-scoper prefixes the Composer\Autoload\ClassLoader import, but Box leaves the class itself unprefixed, so the prefixed name matches no autoloader at all. scoper.inc.php already strips that prefix back off in a fixed list of files; AutoloadSourceLocator.php joins it, and the list moves into scoper-namespaces.php so that ScoperComposerClassLoaderTest can fail when a file in src/ or bin/phpstan refers to the class without being listed. The phar's own autoloader is therefore a ClassLoader too, and it maps PHPStan\ to phar://.../src. realpath() cannot resolve a path behind a stream wrapper, so a ClassLoader that answers with one is left to the trap, which has always recorded such paths - otherwise PHPStan's own classes that are not loaded yet, PHPStan\TrinaryLogic among them, go missing when analysing code that uses them. The OPcache hazard is about files on disk. The OPcache test drives the real locator in a subprocess under the worker's own OPcache flags: a name whose PSR-4 prefix resolves to a file the process already ran, which must not run it again, and one whose file nothing has loaded, which must still resolve without executing. It runs from source, so the scoping above is left to ScoperComposerClassLoaderTest. AutoloadSourceLocatorTest covers a class inside a phar behind a ClassLoader, using a tar archive that PharData can write with phar.readonly on. Closes phpstan/phpstan#15184 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
fb40c09 to
bed3f0c
Compare
SanderMuller
left a comment
There was a problem hiding this comment.
Thanks, that fixes it. With the phar CI built from bed3f0c, the php-standard-library/type repro now reports class.notFound instead of the fatal, with and without OPcache. The new build test fails without the entry, and the phar test fails without the :// guard.
On a real project of about 4,500 files the reported errors are identical. CPU did not go up: median 140.2s before and 131.4s after, over 3 cold runs each. The full suite and make phpstan pass locally. The red Checksum PHAR job is expected here: the only file that differs between the base and PR checksum phars is AutoloadSourceLocator.php.
AutoloadSourceLocatorfinds which file declares a class by running the registered autoloaders behindFileReadTrapStreamWrapper, which records the path anincludereached for and serves an empty script in its place. That only shadows the real file while the compiler asks the wrapper for the contents. With OPcache already holding the script it does not ask, and the file runs a second time:That is fatal for a file declaring a function, which is the function-per-file layout of
php-standard-libraryandazjezz/psl: theirfiles-autoload bootstrap has already loaded every path their PSR-4 prefix also resolves to. The probe itself is legitimate — analysed code doinguse Psl\Type;and callingType\optional(...)makes PHPStan check whether that name is also a class.2.2.13 exposed this by force-enabling OPcache in spawned workers (
TurboProcessRestarter::resolveOpcacheArgs()). It already handles the opposite direction —servesParseError()and theopcache_invalidate()loop stop the trap's empty script from being cached and shadowing the real file — but the cache-hit bypass was missed.Fix
ClassLoader::findFile()answers with the same pathloadClass()would include, without running anything. Asking it, rather than arranging for the include to be harmless, means nothing is compiled and no cache is consulted — which is what makes this hold wherever PHP runs.FileReadTrapStreamWrapperis untouched.Ordering
Autoloaders still run in registration order. Every non-Composer autoloader runs inside the trap as before, one at a time, since an autoloader ahead of Composer's may claim a name Composer would resolve elsewhere.
Running them one at a time matters in practice:
hoa/compilerregisters an autoloader that sits ahead of the analysed project's loader, and an earlier revision of this patch — which gave up on the fast path at the first non-Composer autoloader — would therefore almost never have taken it.Path resolution
findFile()concatenates the mapped prefix with the rest of the name, so its answer can carry../segments and mixed separators, where PHP resolves an include path before the trap ever sees it. It is resolved here to match, whichlocateIdentifier()relies on when it compares the located path againstReflectionClass::getFileName()to tell two same-named classes in one file apart.AutoloadSourceLocatorTestcovers exactly that case and catches the difference.In the phar
php-scoper prefixes the new
use Composer\Autoload\ClassLoader;, but Box leaves the class itself unprefixed, so the prefixed name matches no autoloader at all and every probe still went through the trap. The phar built for an earlier push of this PR still had the fatal for that reason — thanks to the review that caught it.compiler/build/scoper.inc.phpalready strips that prefix back off in a fixed list of files;AutoloadSourceLocator.phpis now on it. The list moves from the patcher closure intocompiler/build/scoper-namespaces.php, next to the scoper's other lists, so that a test can read it:ScoperComposerClassLoaderTestfails when a file insrc/orbin/phpstanrefers toComposer\Autoload\ClassLoaderwithout being listed, the wayScoperClassNameStringsTestalready guards class-name strings.Checksum PHAR is expected to fail here. It runs when
compiler/changes, and it requires the phar to be byte-identical to the base's. This PR changessrc/and deliberately changes the scoping of one file. The list move on its own is output-neutral; I can split it out if you'd like that verified separately.Paths behind a stream wrapper
Fixing the scoping exposed a second problem. The phar's own autoloader is an unprefixed
ClassLoadertoo, and it mapsPHPStan\tophar://.../src.realpath()cannot resolve a path behind a stream wrapper, so the fast path discarded the answer, and PHPStan's own classes that were not loaded yet went missing. The extension jobs on that push failed withCall to method yes() on an unknown class PHPStan\TrinaryLogic.A
ClassLoaderthat answers with a stream-wrapper path is now left to the trap, which has always recorded such paths; only plain local paths take the new route. The OPcache hazard is about files on disk, so the fix still applies where it matters.AutoloadSourceLocatorTest::testClassInsidePharBehindComposerClassLoader()covers this with a tar archive standing in for the phar (PharDatawrites one even withphar.readonlyon). It fails on the previous push and passes against the base.On the earlier revisions
The first two versions of this PR kept the include and tried to make it safe — dropping the shared memory entry with
opcache_invalidate(), then refusing the open for a file already inget_included_files(). Both passed on Windows and failed the Linux integration job, which is why the branch was force-pushed twice.Both depend on OPcache consulting the wrapper at all, which is not something the engine promises. Asking
findFile()depends on nothing of the sort: if the autoloader is never called, the file cannot be included.Test
FileReadTrapStreamWrapperTest::testTrapSurvivesOpcacheCacheHit(), in theexecgroup. The failure is a fatal error and OPcache is only on in spawned processes, so it drives the realAutoloadSourceLocatorin a subprocess under the worker's own OPcache flags, against aClassLoaderwhose PSR-4 prefix resolves to an already-loaded file. Without the fix it reproduces the reported stack,ClassLoader->loadClass()insidewithStreamWrapperOverride(). It covers:Checked with OPcache on, with OPcache off, and against the pre-fix code. The test runs from source, so it cannot see the scoping problem above;
ScoperComposerClassLoaderTestcovers that, and fails without the new list entry.Also checked by hand on Windows, PHP 8.5.9, against a project on
php-standard-library/type6.2.1 at level 5, analysingreturn $o instanceof \Psl\Type\optional;with the worker's OPcache flags:Cannot redeclare function Psl\Type\optional()class.notFoundon theinstanceofline, no fatalApplying the patchers from
scoper.inc.phptoAutoloadSourceLocator.php, prefixed as in the CI-built phar, leaves the import prefixed with the base config and unprefixed with this one. I could not build a full phar on Windows, so the phar built for this push is what confirms the end-to-end result.What this does not cover
An autoloader that is not a
Composer\Autoload\ClassLoaderstill goes through the trap, and is still exposed to the same OPcache behaviour. That includes wrapped loaders — Symfony'sDebugClassLoaderis not aClassLoaderinstance even though it delegates to one.That exposure is pre-existing rather than introduced here, and closing it would mean solving the original problem: keeping an
includefrom running a file that is already in the opcode cache, without the engine promising to consult the stream wrapper. Happy to look at it separately if you would like it covered.Closes phpstan/phpstan#15184
🤖 Generated with Claude Code