Skip to content

Commit 18409d7

Browse files
SanderMullerclaude
andauthored
Invalidate the OPcache entries of included files before an autoloader runs in the autoload trap (#6742)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 4ba46aa commit 18409d7

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)