Skip to content

Commit 158c21c

Browse files
SanderMullerclaude
andcommitted
Invalidate the OPcache entries of files a bootstrap autoloader included without declaring the class
AutoloadFunctionsSourceLocator runs a bootstrap autoloader for real. When the file it includes does not declare the requested class, AutoloadSourceLocator later runs the autoloaders again for the same name inside FileReadTrapStreamWrapper. With OPcache active, the second include runs the script compiled from the real file, not the trap's empty script, and the process dies with "Cannot redeclare class". OPcache is active in the --debug main process and in spawned workers, because the process restart switches it on. Forked workers switch it off, so a default run on Linux or macOS does not crash. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 579a7f1 commit 158c21c

6 files changed

Lines changed: 99 additions & 0 deletions

File tree

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,13 @@ jobs:
254254
cd e2e/bug-15102c
255255
composer install
256256
../../bin/phpstan analyze
257+
- script: |
258+
cd e2e/bootstrap-autoloader-wrong-file
259+
# The crash needs OPcache: forked workers switch it off, the --debug main process and spawned
260+
# workers (pcntl_fork disabled, as on Windows) keep it on.
261+
php -r 'exit(extension_loaded("Zend OPcache") ? 0 : 1);'
262+
../../bin/phpstan analyze --debug
263+
php -d disable_functions=pcntl_fork ../../bin/phpstan analyze
257264
- script: |
258265
cd e2e/bug-14724
259266
composer install
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?php declare(strict_types = 1);
2+
3+
// A hand-written autoloader, not Composer's: it includes the file for the requested
4+
// name and does not check what the file declared.
5+
spl_autoload_register(static function (string $class): void {
6+
$prefix = 'BootstrapAutoloaderWrongFile\\';
7+
if (!str_starts_with($class, $prefix)) {
8+
return;
9+
}
10+
11+
$file = __DIR__ . '/src/' . substr($class, strlen($prefix)) . '.php';
12+
if (!is_file($file)) {
13+
return;
14+
}
15+
16+
require $file;
17+
});
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
parameters:
2+
level: 8
3+
bootstrapFiles:
4+
- bootstrap.php
5+
paths:
6+
- test.php
7+
ignoreErrors:
8+
# The autoloader includes src/Widget.php for this name, and the file declares Widgets.
9+
# The analysis must report the unknown class, not die re-including that file.
10+
-
11+
message: '#^Parameter \$widget of method BootstrapAutoloaderWrongFile\\Consumer\:\:doFoo\(\) has invalid type BootstrapAutoloaderWrongFile\\Widget\.$#'
12+
identifier: class.notFound
13+
count: 1
14+
path: test.php
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace BootstrapAutoloaderWrongFile;
4+
5+
// The declared name does not match the file name, so the autoloader includes this
6+
// file for BootstrapAutoloaderWrongFile\Widget and Widget is never declared.
7+
class Widgets
8+
{
9+
10+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace BootstrapAutoloaderWrongFile;
4+
5+
class Consumer
6+
{
7+
8+
public function doFoo(Widget $widget): void
9+
{
10+
}
11+
12+
}

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

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,14 @@
88
use PHPStan\BetterReflection\Reflection\Reflection;
99
use PHPStan\BetterReflection\Reflector\Reflector;
1010
use PHPStan\BetterReflection\SourceLocator\Type\SourceLocator;
11+
use function array_slice;
1112
use function class_exists;
13+
use function count;
14+
use function function_exists;
15+
use function get_included_files;
16+
use function ini_get;
1217
use function interface_exists;
18+
use function opcache_invalidate;
1319
use function PHPStan\autoloadFunctions;
1420
use function trait_exists;
1521

@@ -37,7 +43,12 @@ public function locateIdentifier(Reflector $reflector, Identifier $identifier):
3743

3844
$autoloadFunctions = autoloadFunctions();
3945
foreach ($autoloadFunctions as $autoloadFunction) {
46+
$includedFilesCount = self::isOpcacheActive() ? count(get_included_files()) : null;
4047
$autoloadFunction($className);
48+
if ($includedFilesCount !== null) {
49+
self::invalidateFilesNotDeclaringClass($className, $includedFilesCount);
50+
}
51+
4152
$reflection = $this->autoloadSourceLocator->locateIdentifier($reflector, $identifier);
4253
if ($reflection !== null) {
4354
return $reflection;
@@ -52,6 +63,34 @@ public function locateIdentifier(Reflector $reflector, Identifier $identifier):
5263
return null;
5364
}
5465

66+
/**
67+
* The autoloader included files for the class for real, but did not declare it. AutoloadSourceLocator
68+
* later runs the autoloaders again for the same name inside FileReadTrapStreamWrapper, and the
69+
* autoloader includes the same files again. With OPcache active, that include runs the script
70+
* OPcache compiled from the real file, not the empty script the trap serves, and dies with
71+
* "Cannot redeclare class". Invalidating the entries makes that include go through the trap.
72+
*/
73+
private static function invalidateFilesNotDeclaringClass(string $className, int $includedFilesCount): void
74+
{
75+
if (class_exists($className, false) || interface_exists($className, false) || trait_exists($className, false)) {
76+
return;
77+
}
78+
79+
foreach (array_slice(get_included_files(), $includedFilesCount) as $file) {
80+
opcache_invalidate($file, true);
81+
}
82+
}
83+
84+
/**
85+
* Forked workers switch OPcache off (see ForkedProcess), so they skip the get_included_files() calls.
86+
*/
87+
private static function isOpcacheActive(): bool
88+
{
89+
return function_exists('opcache_invalidate')
90+
&& (bool) ini_get('opcache.enable')
91+
&& (bool) ini_get('opcache.enable_cli');
92+
}
93+
5594
#[Override]
5695
public function locateIdentifiersByType(Reflector $reflector, IdentifierType $identifierType): array
5796
{

0 commit comments

Comments
 (0)