Skip to content

Commit e4f45f9

Browse files
authored
Ask Composer's autoloaders for the file instead of letting them include it (#6431)
1 parent bfca0a6 commit e4f45f9

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 'if (!extension_loaded("Zend OPcache")) { fwrite(STDERR, "OPcache is not loaded, so this step cannot reproduce the crash.\n"); exit(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)