From 44d3c5d64aee9edea0053aa7e64a4be3da139c7f Mon Sep 17 00:00:00 2001 From: Theodore Brown Date: Sat, 12 Sep 2026 21:39:34 -0500 Subject: [PATCH 1/2] Ask Composer's autoloaders for the file instead of letting them include it AutoloadSourceLocator finds the file that declares a class by running the autoloaders inside FileReadTrapStreamWrapper, which serves an empty script for each file they include. When OPcache already holds the script, the include runs the cached copy instead, so a file the process already ran executes again. A file declaring a function then dies with "Cannot redeclare". That is the function-per-file layout of php-standard-library, whose "files" autoload loads paths that its PSR-4 prefix also resolves to. Before running a Composer ClassLoader in the trap, ask its findFile() for the path, so nothing is included. Other autoloaders, and paths behind a stream wrapper such as the phar's own phar://, still go through the trap. In the phar, php-scoper prefixes the ClassLoader import but Box leaves the class unprefixed, so AutoloadSourceLocator.php joins the files where scoper.inc.php removes that prefix again. Closes https://github.com/phpstan/phpstan/issues/15184 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/e2e-tests.yml | 8 +++ compiler/build/scoper.inc.php | 1 + e2e/bug-15184/.gitignore | 2 + e2e/bug-15184/composer.json | 10 ++++ e2e/bug-15184/phpstan.dist.neon | 11 +++++ e2e/bug-15184/src/Type/optional.php | 9 ++++ e2e/bug-15184/test.php | 8 +++ .../SourceLocator/AutoloadSourceLocator.php | 49 ++++++++++++++++++- 8 files changed, 96 insertions(+), 2 deletions(-) create mode 100644 e2e/bug-15184/.gitignore create mode 100644 e2e/bug-15184/composer.json create mode 100644 e2e/bug-15184/phpstan.dist.neon create mode 100644 e2e/bug-15184/src/Type/optional.php create mode 100644 e2e/bug-15184/test.php diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index b0f15a8a69d..634ee71e7f0 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -254,6 +254,14 @@ jobs: cd e2e/bug-15102c composer install ../../bin/phpstan analyze + - script: | + cd e2e/bug-15184 + composer install + # The crash needs OPcache: forked workers switch it off, the --debug main process and + # spawned workers (pcntl_fork disabled, as on Windows) keep it on. + php -r 'exit(extension_loaded("Zend OPcache") ? 0 : 1);' + ../../bin/phpstan analyze --debug + php -d disable_functions=pcntl_fork ../../bin/phpstan analyze - script: | cd e2e/bug-14724 composer install diff --git a/compiler/build/scoper.inc.php b/compiler/build/scoper.inc.php index 6407e29f8c4..ac20519cee6 100644 --- a/compiler/build/scoper.inc.php +++ b/compiler/build/scoper.inc.php @@ -143,6 +143,7 @@ function (string $filePath, string $prefix, string $content): string { function (string $filePath, string $prefix, string $content): string { if (!in_array($filePath, [ 'bin/phpstan', + 'src/Reflection/BetterReflection/SourceLocator/AutoloadSourceLocator.php', 'src/Testing/TestCaseSourceLocatorFactory.php', 'src/Testing/PHPStanTestCase.php', 'vendor/ondrejmirtes/better-reflection/src/SourceLocator/Type/ComposerSourceLocator.php', diff --git a/e2e/bug-15184/.gitignore b/e2e/bug-15184/.gitignore new file mode 100644 index 00000000000..3a9875b460f --- /dev/null +++ b/e2e/bug-15184/.gitignore @@ -0,0 +1,2 @@ +/vendor/ +composer.lock diff --git a/e2e/bug-15184/composer.json b/e2e/bug-15184/composer.json new file mode 100644 index 00000000000..d0f3d580af2 --- /dev/null +++ b/e2e/bug-15184/composer.json @@ -0,0 +1,10 @@ +{ + "autoload": { + "psr-4": { + "Bug15184\\": "src/" + }, + "files": [ + "src/Type/optional.php" + ] + } +} diff --git a/e2e/bug-15184/phpstan.dist.neon b/e2e/bug-15184/phpstan.dist.neon new file mode 100644 index 00000000000..1290c812dc1 --- /dev/null +++ b/e2e/bug-15184/phpstan.dist.neon @@ -0,0 +1,11 @@ +parameters: + level: 8 + paths: + - test.php + ignoreErrors: + # The analysis must report the unknown class, not die including src/Type/optional.php again. + - + message: '#^Class Bug15184\\Type\\optional not found\.$#' + identifier: class.notFound + count: 1 + path: test.php diff --git a/e2e/bug-15184/src/Type/optional.php b/e2e/bug-15184/src/Type/optional.php new file mode 100644 index 00000000000..37c0ddad90e --- /dev/null +++ b/e2e/bug-15184/src/Type/optional.php @@ -0,0 +1,9 @@ +silenceErrors(); try { + $foundByComposer = false; $result = FileReadTrapStreamWrapper::withStreamWrapperOverride( - static function () use ($className): ?array { + static function () use ($className, &$foundByComposer): ?array { $functions = spl_autoload_functions(); if ($functions === false) { return null; } foreach ($functions as $preExistingAutoloader) { + $composerFile = self::findFileWithComposer($preExistingAutoloader, $className); + if ($composerFile !== null) { + $foundByComposer = true; + return [[$composerFile], $className, null]; + } + try { $preExistingAutoloader($className); } catch (ParseError) { @@ -370,7 +381,8 @@ static function () use ($className): ?array { return null; } - if (!function_exists('opcache_invalidate')) { + // nothing was included, so there is no trapped script to invalidate + if ($foundByComposer || !function_exists('opcache_invalidate')) { return $result; } @@ -387,6 +399,39 @@ static function () use ($className): ?array { } } + /** + * Asks Composer's ClassLoader which file loadClass() would include, without + * including it. With OPcache holding a file this process already ran, an + * include inside the trap runs the cached script instead of the trap's empty + * one, and a file declaring a function dies with "Cannot redeclare". + * + * A path behind a stream wrapper is left to the trap: realpath() cannot + * resolve it, and the phar's own ClassLoader maps PHPStan\ to phar://. + * + * @param callable(string): void $autoloader + */ + private static function findFileWithComposer(callable $autoloader, string $className): ?string + { + if (!is_array($autoloader) || !$autoloader[0] instanceof ClassLoader) { + return null; + } + + $file = $autoloader[0]->findFile($className); + if ($file === false || str_contains($file, '://')) { + return null; + } + + // findFile() can return ../ segments and mixed separators; resolve them + // as an include would, so that locateIdentifier() can compare the path + // with ReflectionClass::getFileName() + $resolvedFile = realpath($file); + if ($resolvedFile === false || !is_file($resolvedFile)) { + return null; + } + + return $resolvedFile; + } + private function silenceErrors(): void { set_error_handler(static fn (): bool => true); From 61f9e70d9203903c0e8a64ec2b42c9345576db15 Mon Sep 17 00:00:00 2001 From: Theodore Brown Date: Fri, 16 Oct 2026 08:23:22 -0500 Subject: [PATCH 2/2] Improve e2e test error message Co-authored-by: Markus Staab --- .github/workflows/e2e-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 634ee71e7f0..2d48c37e3c0 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -259,7 +259,7 @@ jobs: composer install # The crash needs OPcache: forked workers switch it off, the --debug main process and # spawned workers (pcntl_fork disabled, as on Windows) keep it on. - php -r 'exit(extension_loaded("Zend OPcache") ? 0 : 1);' + php -r 'if (!extension_loaded("Zend OPcache")) { fwrite(STDERR, "OPcache is not loaded, so this step cannot reproduce the crash.\n"); exit(1); }' ../../bin/phpstan analyze --debug php -d disable_functions=pcntl_fork ../../bin/phpstan analyze - script: |