diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index b0f15a8a69d..2d48c37e3c0 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 '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: | 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);