Skip to content

Ask Composer's autoloaders for the file instead of letting them include it - #6431

Open
theodorejb wants to merge 1 commit into
phpstan:2.3.xfrom
theodorejb:fix-gh-15184
Open

theodorejb wants to merge 1 commit into
phpstan:2.3.xfrom
theodorejb:fix-gh-15184

Conversation

@theodorejb

@theodorejb theodorejb commented Sep 13, 2026 •

Copy link
Copy Markdown
Contributor

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. When OPcache already holds the script, it does not ask, and the file runs a second time:

Child process error (exit code 255): Fatal error: Cannot redeclare function Psl\Type\optional()
(previously declared in C:\myProject\vendor\php-standard-library\type\src\Psl\Type\optional.php:18)
in C:\myProject\vendor\php-standard-library\type\src\Psl\Type\optional.php on line 18

That is fatal for any file that declares a function, which is how php-standard-library and azjezz/psl lay out their code: one function per file. Their files-autoload bootstrap has already loaded every path that their PSR-4 prefix also resolves to.

2.2.13 exposed this by starting spawned workers with OPcache on (ProcessHelper::resolveWorkerIniEntries()), and it still reproduces on 2.3.0. The existing code already handles the opposite direction: servesParseError() and the opcache_invalidate() loop stop the trap's empty script from being cached and shadowing the real file. A cache hit was the case that was missed.

In the reported stack trace, the probe comes from ScopeOps::containsExpressionToInvalidate(). When $this is invalidated, it passes every Name in stored expressions to ClassReflection::is(), including function-call names, and each one becomes a hasClass() lookup. Skipping non-class names there would be a worthwhile cleanup (with the matching change in ScopeOps.cpp), but it belongs in a separate PR. It also would not replace this fix, because any other path that looks up such a name as a class reaches the same trap.

Fix

ClassLoader::findFile() answers with the same path loadClass() 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. That is why the fix holds wherever PHP runs. FileReadTrapStreamWrapper is unchanged.

Ordering

Autoloaders still run in registration order. Every non-Composer autoloader still runs inside the trap, one at a time, because an autoloader ahead of Composer's may claim a name that Composer would resolve elsewhere. Running them one at a time also matters in practice: hoa/compiler registers an autoloader ahead of the analysed project's loader, so stopping at the first non-Composer autoloader would almost never reach Composer's.

Path resolution

findFile() joins the mapped prefix to the rest of the name, so its answer can contain ../ segments and mixed separators. PHP resolves an include path before the trap sees it, so the answer is resolved with realpath() here to match. locateIdentifier() depends on this when it compares the located path against ReflectionClass::getFileName() to tell two same-named classes in one file apart, and the existing AutoloadSourceLocatorTest catches the difference.

In the phar

php-scoper prefixes the new use Composer\Autoload\ClassLoader;, but Box leaves the class itself unprefixed. In the phar, the prefixed name would match no autoloader, and every probe would still go through the trap. compiler/build/scoper.inc.php already strips that prefix back off in a fixed list of files, and AutoloadSourceLocator.php is now on that list. The list moves from the patcher closure into compiler/build/scoper-namespaces.php, next to the scoper's other lists, so that a test can read it. ScoperComposerClassLoaderTest fails when a file in src/ or bin/phpstan refers to Composer\Autoload\ClassLoader without being listed, the same way ScoperClassNameStringsTest guards class-name strings.

Checksum PHAR is expected to fail. It runs when compiler/ changes and requires the phar to be byte-identical to the base's, and this PR deliberately changes how one file is scoped. Moving the list into scoper-namespaces.php does not change the phar by itself; I can split that move out if you'd like it verified separately.

Paths behind a stream wrapper

The phar's own autoloader is also an unprefixed ClassLoader, and it maps PHPStan\ to phar://.../src. realpath() cannot resolve a path behind a stream wrapper, so when a ClassLoader answers with such a path, the probe falls back to the trap, which has always recorded those paths. Only plain local paths take the new route. The OPcache hazard is about files on disk, so the fix still covers the case that matters. Without this fallback, PHPStan's own classes that are not loaded yet go missing when running from the phar (Call to method yes() on an unknown class PHPStan\TrinaryLogic.).

Alternatives considered

Keeping the include and making it safe would mean either dropping the OPcache entry with opcache_invalidate(), or refusing to open a file that is already in get_included_files(). Both depend on OPcache consulting the stream wrapper at all, which PHP does not promise, and both failed the Linux integration job while passing on Windows. Asking findFile() depends on nothing like that: if the autoloader is never called, the file cannot be included.

Tests

  • FileReadTrapStreamWrapperTest::testTrapSurvivesOpcacheCacheHit() runs the real AutoloadSourceLocator in a subprocess with the same OPcache flags as a worker, because the failure is a fatal error and OPcache is only on in spawned processes. It checks that:

    • probing a name whose PSR-4 prefix resolves to a file the process already ran does not run that file again;
    • a name whose file nothing has loaded still resolves, without executing the file.

    Without the fix, it reproduces the reported stack: ClassLoader->loadClass() inside withStreamWrapperOverride(). It is in the exec group, which phpunit.xml skips by default, so run it with:

    vendor/bin/phpunit tests/PHPStan/Reflection/BetterReflection/SourceLocator/FileReadTrapStreamWrapperTest.php --group exec
    
  • AutoloadSourceLocatorTest::testClassInsidePharBehindComposerClassLoader() covers the stream-wrapper fallback. A tar archive stands in for the phar, because PharData can write one even with phar.readonly on.

  • ScoperComposerClassLoaderTest covers the scoping, which the other tests cannot see because they run from source. It fails without the new list entry.

Checked by hand on Windows with PHP 8.5, against a project on php-standard-library/type 6.2.1 at level 5 that analyses return $o instanceof \Psl\Type\optional; with the worker's OPcache flags:

  • the 2.3.0 phar: Cannot redeclare function Psl\Type\optional()
  • this PR from source: class.notFound on the instanceof line, no fatal

I could not build a full phar on Windows, so the phar that CI builds for this PR is what confirms the end-to-end result.

Not covered

An autoloader that is not a Composer\Autoload\ClassLoader still goes through the trap and is still exposed to the same OPcache behaviour. That includes wrapped loaders: Symfony's DebugClassLoader delegates to a ClassLoader but is not one. This exposure already existed before this PR. Closing it means solving the original problem, keeping an include from running a file that is already in the opcode cache, without relying on the engine to consult the stream wrapper. Happy to look at that separately.

Closes phpstan/phpstan#15184

🤖 Generated with Claude Code

@theodorejb theodorejb changed the title Evict the OPcache entry for a trapped path before the autoloader includes it Refuse the trap's open for a file the process has already run Sep 13, 2026
@theodorejb
theodorejb marked this pull request as draft September 13, 2026 03:57
@theodorejb theodorejb changed the title Refuse the trap's open for a file the process has already run Ask Composer's autoloaders for the file instead of letting them include it Sep 13, 2026
@theodorejb
theodorejb marked this pull request as ready for review September 13, 2026 04:39
@phpstan-bot

Copy link
Copy Markdown
Collaborator

This pull request has been marked as ready for review.

@SanderMuller

Copy link
Copy Markdown
Contributor

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 phar-file of run 34738406883) has:

use _PHPStan_6c01f7a60\Composer\Autoload\ClassLoader;

The project's autoloader is an unprefixed Composer\Autoload\ClassLoader, so the instanceof check is always false and every autoloader still goes through the trap. compiler/build/scoper.inc.php already has a patcher that removes this prefix, but only in bin/phpstan, TestCaseSourceLocatorFactory.php, PHPStanTestCase.php and BetterReflection's ComposerSourceLocator.php. I think AutoloadSourceLocator.php needs to go on that list.

Repro on macOS, PHP 8.5.8: a project with php-standard-library/type 6.x, level 5, analysing one file that does return $o instanceof \Psl\Type\optional;. With --debug:

  • 2.2.15 phar: Fatal error: Cannot redeclare function Psl\Type\optional()
  • this PR's CI phar: the same fatal
  • this PR's CI phar with only that import unprefixed: class.notFound on the instanceof line, no fatal

On the PR head from source:

  • testTrapSurvivesOpcacheCacheHit passes. With the base AutoloadSourceLocator.php it fails with Cannot redeclare function OpcacheTrap\thing(), so the test covers the bug. It cannot catch the scoping problem, because it does not run from the phar.
  • The SourceLocator tests pass (73 tests), and phpcs is clean on the touched files. make phpstan reports one error, at tests/PHPStan/Type/ObjectTypeTest.php:803, which this PR does not touch.
  • I suspected that realpath() changes the located path for a package installed through a symlink, as a Composer path repository does. It does not: the base and the PR both return the resolved target.

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.

@theodorejb

Copy link
Copy Markdown
Contributor Author

@SanderMuller Thanks for finding the phar issue. I rebased and had Claude update the commit to add AutoloadSourceLocator.php to the list of files the ClassLoader patcher un-prefixes. The list now lives in compiler/build/scoper-namespaces.php to avoid future regressions, and a new ScoperComposerClassLoaderTest fails when a file in src/ or bin/phpstan refers to Composer\Autoload\ClassLoader without being on it (the same pattern ScoperClassNameStringsTest uses). It fails without the new entry.

In regards to performance, two A/B runs each of PHPStan's own src came out within noise (70–72s with the change, 72–76s without), and the changed path only runs about 50 times per worker.

@SanderMuller SanderMuller left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@theodorejb

theodorejb commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor Author

@ondrejmirtes Should I rebase this against the 2.3.x branch? I don't know if there's another approach to fixing this you would prefer, but I really hope something can be done as I'm still stuck on PHPStan 2.2.12 in order to use the PSL Type library and plugin.

@theodorejb
theodorejb changed the base branch from 2.2.x to 2.3.x October 8, 2026 13:29
…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>
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.

2.2.13: "Cannot redeclare function" fatal error with php-standard-library/type package

3 participants