Skip to content

Commit d3e5eb4

Browse files
SanderMullerclaude
andcommitted
Invalidate the OPcache entries of included files before an autoloader runs in the autoload trap
A bootstrap autoloader can include a file that the process already included for real. With OPcache active, the trapped include then runs the cached real script, and the process dies with "Cannot redeclare". Composer's loaders no longer run in the trap (findFileWithComposer()), so only the other autoloaders need this. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent e4f45f9 commit d3e5eb4

6 files changed

Lines changed: 94 additions & 0 deletions

File tree

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,13 @@ jobs:
262262
php -r 'if (!extension_loaded("Zend OPcache")) { fwrite(STDERR, "OPcache is not loaded, so this step cannot reproduce the crash.\n"); exit(1); }'
263263
../../bin/phpstan analyze --debug
264264
php -d disable_functions=pcntl_fork ../../bin/phpstan analyze
265+
- script: |
266+
cd e2e/bootstrap-autoloader-wrong-file
267+
# The crash needs OPcache: forked workers switch it off, the --debug main process and
268+
# spawned workers (pcntl_fork disabled, as on Windows) keep it on.
269+
php -r 'if (!extension_loaded("Zend OPcache")) { fwrite(STDERR, "OPcache is not loaded, so this step cannot reproduce the crash.\n"); exit(1); }'
270+
../../bin/phpstan analyze --debug
271+
php -d disable_functions=pcntl_fork ../../bin/phpstan analyze
265272
- script: |
266273
cd e2e/bug-14724
267274
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/AutoloadSourceLocator.php‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@
3131
use function count;
3232
use function defined;
3333
use function function_exists;
34+
use function get_included_files;
35+
use function ini_get;
3436
use function interface_exists;
3537
use function is_array;
3638
use function is_file;
@@ -69,6 +71,9 @@ final class AutoloadSourceLocator implements SourceLocator
6971
/** @var array<string, int> */
7072
private array $startLineByClass = [];
7173

74+
/** How many get_included_files() entries invalidateIncludedFiles() has handled. */
75+
private static int $invalidatedIncludedFilesCount = 0;
76+
7277
public function __construct(private FileNodesFetcher $fileNodesFetcher, private bool $executeAutoloadersInFileReadTrap)
7378
{
7479
}
@@ -355,6 +360,8 @@ static function () use ($className, &$foundByComposer): ?array {
355360
return [[$composerFile], $className, null];
356361
}
357362

363+
self::invalidateIncludedFiles();
364+
358365
try {
359366
$preExistingAutoloader($className);
360367
} catch (ParseError) {
@@ -432,6 +439,33 @@ private static function findFileWithComposer(callable $autoloader, string $class
432439
return $resolvedFile;
433440
}
434441

442+
/**
443+
* An autoloader can include a file that this process already included for real, for example a file
444+
* a bootstrap autoloader included without declaring the class. With OPcache active, the trapped
445+
* include of that file runs the script OPcache compiled from the real file, not the empty script
446+
* FileReadTrapStreamWrapper serves, and dies with "Cannot redeclare". Invalidating the entries of
447+
* the included files makes that include go through the trap. The opcache_invalidate() loop after
448+
* the trap cannot do this, because the include has already run by then. That loop removes the
449+
* empty script the trap left in OPcache.
450+
*
451+
* Only an autoloader that runs inside the trap needs this. findFileWithComposer() includes nothing.
452+
* Forked workers switch OPcache off (see ForkedProcess), so they skip this.
453+
*/
454+
private static function invalidateIncludedFiles(): void
455+
{
456+
if (!function_exists('opcache_invalidate') || !(bool) ini_get('opcache.enable') || !(bool) ini_get('opcache.enable_cli')) {
457+
return;
458+
}
459+
460+
$includedFiles = get_included_files();
461+
$includedFilesCount = count($includedFiles);
462+
for ($i = self::$invalidatedIncludedFilesCount; $i < $includedFilesCount; $i++) {
463+
opcache_invalidate($includedFiles[$i], true);
464+
}
465+
466+
self::$invalidatedIncludedFilesCount = $includedFilesCount;
467+
}
468+
435469
private function silenceErrors(): void
436470
{
437471
set_error_handler(static fn (): bool => true);

0 commit comments

Comments
 (0)