Skip to content

Commit 2eb5402

Browse files
theodorejbclaude
andcommitted
Evict the OPcache entry for a trapped path before the autoloader includes it
OPcache satisfies an include of an already-cached path from shared memory: FileReadTrapStreamWrapper::stream_open() still runs, so the path is recorded, but stream_read() never does, so the empty script the trap serves never reaches the compiler and the real file executes a second time. A file declaring a function then fatals with "Cannot redeclare", which is the function-per-file layout of php-standard-library and azjezz/psl: a files-autoload bootstrap loads every one of them at startup, and the same paths stay reachable through a PSR-4 prefix for the autoloader to include again. Dropping the entry in stream_open() forces the include to read through the wrapper. AutoloadSourceLocator invalidates the trapped paths once the trap is done regardless, so this costs no recompilation that wasn't going to happen anyway. The OPcache-enabled probe moves out of servesParseError() into opcacheEnabled() so both callers share the memoized check. opcache.file_cache_only is not covered, and is documented as such: OPcache reports itself disabled there, and no API evicts a file cache entry. That configuration is left alone by TurboProcessRestarter, so PHPStan never turns it on itself. Closes phpstan/phpstan#15184 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 395468a commit 2eb5402

4 files changed

Lines changed: 150 additions & 1 deletion

File tree

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

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
use function is_dir;
88
use function is_file;
99
use function opcache_get_status;
10+
use function opcache_invalidate;
11+
use function opcache_is_script_cached;
1012
use function stat;
1113
use function stream_resolve_include_path;
1214
use function stream_wrapper_register;
@@ -117,6 +119,7 @@ public function stream_open($path, $mode, $options, &$openedPath): bool
117119

118120
if ($exists) {
119121
self::$autoloadLocatedFiles[] = $path;
122+
self::evictCachedScript($path);
120123
}
121124
$this->path = $path;
122125
$this->readFromFile = false;
@@ -146,6 +149,44 @@ public function stream_read($count): string
146149
return '';
147150
}
148151

152+
/**
153+
* Drops the OPcache entry for a path the trap is about to serve, so that
154+
* the include reads the file through this wrapper instead of taking it from
155+
* shared memory.
156+
*
157+
* OPcache satisfies an include of an already-cached path from shared
158+
* memory: stream_open() still runs - so the path is recorded - but
159+
* stream_read() never does, so the empty script it serves never reaches
160+
* the compiler and the real file executes a second time. A file declaring
161+
* a function then fatals with "Cannot redeclare function...". This affects
162+
* function-per-file packages like php-standard-library and azjezz/psl: a
163+
* files-autoload bootstrap loads every one of them at startup, and the same
164+
* paths stay reachable through a PSR-4 prefix for the autoloader to include
165+
* again.
166+
*
167+
* AutoloadSourceLocator invalidates the trapped paths once the trap is done
168+
* regardless, so this costs no recompilation that wasn't going to happen anyway.
169+
*
170+
* Does not reach opcache.file_cache_only, where a cached script is served
171+
* from disk instead: opcache_get_status() reports OPcache as disabled there,
172+
* opcache_is_script_cached() always says no, and opcache_invalidate() cannot
173+
* drop a file cache entry - no API evicts one. TurboProcessRestarter leaves
174+
* that configuration alone (resolveOpcacheArgs() returns no entries for it),
175+
* so PHPStan never turns it on itself.
176+
*/
177+
private static function evictCachedScript(string $path): void
178+
{
179+
if (!self::opcacheEnabled()) {
180+
return;
181+
}
182+
183+
if (!opcache_is_script_cached($path)) {
184+
return;
185+
}
186+
187+
opcache_invalidate($path, true);
188+
}
189+
149190
/**
150191
* Whether the empty script served for this path would stay in OPcache and
151192
* shadow the real file for the rest of the process.
@@ -164,6 +205,11 @@ public function stream_read($count): string
164205
* phar://.../vendor/composer/../../src/....
165206
*/
166207
private static function servesParseError(string $path): bool
208+
{
209+
return self::resolveServesParseError(PHP_VERSION_ID, self::opcacheEnabled(), $path);
210+
}
211+
212+
private static function opcacheEnabled(): bool
167213
{
168214
if (self::$opcacheEnabled === null) {
169215
self::$opcacheEnabled = false;
@@ -173,7 +219,7 @@ private static function servesParseError(string $path): bool
173219
}
174220
}
175221

176-
return self::resolveServesParseError(PHP_VERSION_ID, self::$opcacheEnabled, $path);
222+
return self::$opcacheEnabled;
177223
}
178224

179225
public static function resolveServesParseError(int $phpVersionId, bool $opcacheEnabled, string $path): bool

‎tests/PHPStan/Reflection/BetterReflection/SourceLocator/FileReadTrapStreamWrapperTest.php‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,14 @@
33
namespace PHPStan\Reflection\BetterReflection\SourceLocator;
44

55
use PHPUnit\Framework\Attributes\DataProvider;
6+
use PHPUnit\Framework\Attributes\Group;
67
use PHPUnit\Framework\TestCase;
8+
use function escapeshellarg;
9+
use function exec;
10+
use function extension_loaded;
11+
use function implode;
12+
use function sprintf;
13+
use const PHP_BINARY;
714

815
final class FileReadTrapStreamWrapperTest extends TestCase
916
{
@@ -29,4 +36,35 @@ public function testResolveServesParseError(int $phpVersionId, bool $opcacheEnab
2936
$this->assertSame($expected, FileReadTrapStreamWrapper::resolveServesParseError($phpVersionId, $opcacheEnabled, $path));
3037
}
3138

39+
/**
40+
* With OPcache enabled, an include of an already-cached path is served from
41+
* shared memory: stream_open() runs but stream_read() does not, so the file
42+
* the trap is supposed to shadow used to really execute a second time.
43+
*
44+
* Needs its own process: OPcache is only on in the processes PHPStan spawns
45+
* for itself, and the failure is a fatal error.
46+
*/
47+
#[Group('exec')]
48+
public function testTrapSurvivesOpcacheCacheHit(): void
49+
{
50+
if (!extension_loaded('Zend OPcache')) {
51+
self::markTestSkipped('OPcache is not available.');
52+
}
53+
54+
exec(sprintf(
55+
'%s -d opcache.enable=1 -d opcache.enable_cli=1 -d opcache.validate_timestamps=0 %s 2>&1',
56+
escapeshellarg(PHP_BINARY),
57+
escapeshellarg(__DIR__ . '/data/opcache-trap/driver.php'),
58+
), $outputLines, $exitCode);
59+
$output = implode("\n", $outputLines);
60+
61+
$this->assertSame(0, $exitCode, $output);
62+
63+
if ($output === "opcacheEnabled=0\ntrappedTarget=1") {
64+
self::markTestSkipped('OPcache could not be enabled for the CLI.');
65+
}
66+
67+
$this->assertSame("opcacheEnabled=1\ntrappedTarget=1", $output);
68+
}
69+
3270
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
<?php declare(strict_types = 1);
2+
3+
// Driver for FileReadTrapStreamWrapperTest::testTrapSurvivesOpcacheCacheHit().
4+
// Reproduces the php-standard-library / azjezz/psl layout: a files-autoload
5+
// bootstrap has already loaded the very file the PSR-4 prefix resolves to, so
6+
// the autoloader the trap runs includes it a second time. With OPcache on, the
7+
// include of the already-cached path is served from shared memory and never
8+
// reaches the trap's stream_read() - the real file executes and fatals with
9+
// "Cannot redeclare function OpcacheTrap\thing()".
10+
11+
use PHPStan\Reflection\BetterReflection\SourceLocator\FileReadTrapStreamWrapper;
12+
13+
require_once __DIR__ . '/../../../../../../../vendor/autoload.php';
14+
15+
$target = __DIR__ . '/function.php';
16+
17+
require_once $target;
18+
19+
spl_autoload_register(static function (string $name) use ($target): void {
20+
if ($name !== 'OpcacheTrap\thing') {
21+
return;
22+
}
23+
24+
include $target;
25+
});
26+
27+
$opcacheStatus = opcache_get_status(false);
28+
$opcacheEnabled = $opcacheStatus !== false && ($opcacheStatus['opcache_enabled'] ?? false) === true;
29+
30+
// AutoloadSourceLocator::silenceErrors() does the same around the trap.
31+
set_error_handler(static fn (): bool => true);
32+
33+
$locatedFiles = FileReadTrapStreamWrapper::withStreamWrapperOverride(static function (): array {
34+
$autoloaders = spl_autoload_functions();
35+
if ($autoloaders === false) {
36+
return [];
37+
}
38+
39+
foreach ($autoloaders as $autoloader) {
40+
$autoloader('OpcacheTrap\thing');
41+
42+
if (FileReadTrapStreamWrapper::$autoloadLocatedFiles !== []) {
43+
return FileReadTrapStreamWrapper::$autoloadLocatedFiles;
44+
}
45+
}
46+
47+
return [];
48+
});
49+
50+
restore_error_handler();
51+
52+
// Compared here rather than in the test so that the separator normalization
53+
// PHP applies to include paths on Windows does not leak into the assertion.
54+
$trappedTarget = array_map('realpath', $locatedFiles) === [realpath($target)];
55+
56+
echo 'opcacheEnabled=', $opcacheEnabled ? '1' : '0', "\n";
57+
echo 'trappedTarget=', $trappedTarget ? '1' : '0', "\n";
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 OpcacheTrap;
4+
5+
function thing(): string
6+
{
7+
return 'x';
8+
}

0 commit comments

Comments
 (0)