Skip to content

Commit 44d3c5d

Browse files
theodorejbclaude
andcommitted
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 phpstan/phpstan#15184 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent bfca0a6 commit 44d3c5d

8 files changed

Lines changed: 96 additions & 2 deletions

File tree

‎.github/workflows/e2e-tests.yml‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,14 @@ jobs:
254254
cd e2e/bug-15102c
255255
composer install
256256
../../bin/phpstan analyze
257+
- script: |
258+
cd e2e/bug-15184
259+
composer install
260+
# The crash needs OPcache: forked workers switch it off, the --debug main process and
261+
# spawned workers (pcntl_fork disabled, as on Windows) keep it on.
262+
php -r 'exit(extension_loaded("Zend OPcache") ? 0 : 1);'
263+
../../bin/phpstan analyze --debug
264+
php -d disable_functions=pcntl_fork ../../bin/phpstan analyze
257265
- script: |
258266
cd e2e/bug-14724
259267
composer install

‎compiler/build/scoper.inc.php‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ function (string $filePath, string $prefix, string $content): string {
143143
function (string $filePath, string $prefix, string $content): string {
144144
if (!in_array($filePath, [
145145
'bin/phpstan',
146+
'src/Reflection/BetterReflection/SourceLocator/AutoloadSourceLocator.php',
146147
'src/Testing/TestCaseSourceLocatorFactory.php',
147148
'src/Testing/PHPStanTestCase.php',
148149
'vendor/ondrejmirtes/better-reflection/src/SourceLocator/Type/ComposerSourceLocator.php',

‎e2e/bug-15184/.gitignore‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
/vendor/
2+
composer.lock

‎e2e/bug-15184/composer.json‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"autoload": {
3+
"psr-4": {
4+
"Bug15184\\": "src/"
5+
},
6+
"files": [
7+
"src/Type/optional.php"
8+
]
9+
}
10+
}

‎e2e/bug-15184/phpstan.dist.neon‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
parameters:
2+
level: 8
3+
paths:
4+
- test.php
5+
ignoreErrors:
6+
# The analysis must report the unknown class, not die including src/Type/optional.php again.
7+
-
8+
message: '#^Class Bug15184\\Type\\optional not found\.$#'
9+
identifier: class.notFound
10+
count: 1
11+
path: test.php
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Bug15184\Type;
4+
5+
// One function per file, loaded through Composer's "files" autoload, in a directory
6+
// the PSR-4 prefix also maps - the layout of php-standard-library and azjezz/psl.
7+
function optional(): void
8+
{
9+
}

‎e2e/bug-15184/test.php‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Bug15184;
4+
5+
function check(object $o): bool
6+
{
7+
return $o instanceof \Bug15184\Type\optional;
8+
}

‎src/Reflection/BetterReflection/SourceLocator/AutoloadSourceLocator.php‎

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace PHPStan\Reflection\BetterReflection\SourceLocator;
44

5+
use Composer\Autoload\ClassLoader;
56
use Override;
67
use ParseError;
78
use PhpParser\Node\Arg;
@@ -31,12 +32,15 @@
3132
use function defined;
3233
use function function_exists;
3334
use function interface_exists;
35+
use function is_array;
3436
use function is_file;
3537
use function is_string;
3638
use function opcache_invalidate;
39+
use function realpath;
3740
use function restore_error_handler;
3841
use function set_error_handler;
3942
use function spl_autoload_functions;
43+
use function str_contains;
4044
use function strtolower;
4145
use function trait_exists;
4246
use const PHP_VERSION_ID;
@@ -336,14 +340,21 @@ private function locateClassByName(string $className): ?array
336340
$this->silenceErrors();
337341

338342
try {
343+
$foundByComposer = false;
339344
$result = FileReadTrapStreamWrapper::withStreamWrapperOverride(
340-
static function () use ($className): ?array {
345+
static function () use ($className, &$foundByComposer): ?array {
341346
$functions = spl_autoload_functions();
342347
if ($functions === false) {
343348
return null;
344349
}
345350

346351
foreach ($functions as $preExistingAutoloader) {
352+
$composerFile = self::findFileWithComposer($preExistingAutoloader, $className);
353+
if ($composerFile !== null) {
354+
$foundByComposer = true;
355+
return [[$composerFile], $className, null];
356+
}
357+
347358
try {
348359
$preExistingAutoloader($className);
349360
} catch (ParseError) {
@@ -370,7 +381,8 @@ static function () use ($className): ?array {
370381
return null;
371382
}
372383

373-
if (!function_exists('opcache_invalidate')) {
384+
// nothing was included, so there is no trapped script to invalidate
385+
if ($foundByComposer || !function_exists('opcache_invalidate')) {
374386
return $result;
375387
}
376388

@@ -387,6 +399,39 @@ static function () use ($className): ?array {
387399
}
388400
}
389401

402+
/**
403+
* Asks Composer's ClassLoader which file loadClass() would include, without
404+
* including it. With OPcache holding a file this process already ran, an
405+
* include inside the trap runs the cached script instead of the trap's empty
406+
* one, and a file declaring a function dies with "Cannot redeclare".
407+
*
408+
* A path behind a stream wrapper is left to the trap: realpath() cannot
409+
* resolve it, and the phar's own ClassLoader maps PHPStan\ to phar://.
410+
*
411+
* @param callable(string): void $autoloader
412+
*/
413+
private static function findFileWithComposer(callable $autoloader, string $className): ?string
414+
{
415+
if (!is_array($autoloader) || !$autoloader[0] instanceof ClassLoader) {
416+
return null;
417+
}
418+
419+
$file = $autoloader[0]->findFile($className);
420+
if ($file === false || str_contains($file, '://')) {
421+
return null;
422+
}
423+
424+
// findFile() can return ../ segments and mixed separators; resolve them
425+
// as an include would, so that locateIdentifier() can compare the path
426+
// with ReflectionClass::getFileName()
427+
$resolvedFile = realpath($file);
428+
if ($resolvedFile === false || !is_file($resolvedFile)) {
429+
return null;
430+
}
431+
432+
return $resolvedFile;
433+
}
434+
390435
private function silenceErrors(): void
391436
{
392437
set_error_handler(static fn (): bool => true);

0 commit comments

Comments
 (0)