Skip to content

Commit 1f51101

Browse files
theodorejbclaude
andcommitted
Refuse the trap's open for a file the process has already run
The empty script FileReadTrapStreamWrapper::stream_read() serves only shadows the real file while the compiler actually asks the wrapper for the contents. With OPcache holding the script it does not ask: stream_open() still runs, so the path is recorded, but stream_read() never does, and the real op_array 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. Refuse the open when the target has already run in this process. That stops the include before anything can be compiled or fetched from a cache, and the path is recorded beforehand, which is all AutoloadSourceLocator wants from the trap. Serving the empty script is unchanged for every file that has not run, so the cold path keeps working as it did. Deciding this on get_included_files() rather than on the OPcache API keeps the answer independent of how, or whether, the script is cached. An earlier revision of this fix dropped the shared memory entry with opcache_invalidate() so that the include would read through the wrapper again; that held on Windows but not on Linux, where the same three assumptions it rested on - that opcache_is_script_cached() reports the entry, that opcache_invalidate() drops it, and that OPcache consults its cache only after stream_open() has run - do not all hold with this wrapper registered over file://. Refusing the open depends on none of them, and also covers opcache.file_cache_only, which no API can invalidate. Refusing is not a new failure mode: an autoloader reaching this branch is about to include a file that already ran, which fatally redeclares today. An autoloader using include(), as Composer's does, now gets a warning that AutoloadSourceLocator::silenceErrors() swallows. The test drives both branches in a subprocess under the worker's own OPcache flags: a file the bootstrap already loaded, which must be trapped without running again, and one nothing has loaded, which must still be trapped and left undeclared. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 395468a commit 1f51101

5 files changed

Lines changed: 205 additions & 1 deletion

File tree

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

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,15 @@
33
namespace PHPStan\Reflection\BetterReflection\SourceLocator;
44

55
use PHPStan\ShouldNotHappenException;
6+
use function array_fill_keys;
7+
use function array_key_exists;
8+
use function count;
69
use function function_exists;
10+
use function get_included_files;
711
use function is_dir;
812
use function is_file;
913
use function opcache_get_status;
14+
use function realpath;
1015
use function stat;
1116
use function stream_resolve_include_path;
1217
use function stream_wrapper_register;
@@ -54,6 +59,11 @@ final class FileReadTrapStreamWrapper
5459

5560
private static ?bool $opcacheEnabled = null;
5661

62+
private static int $includedFilesCount = -1;
63+
64+
/** @var array<string, true> */
65+
private static array $includedFilesMap = [];
66+
5767
private bool $readFromFile = false;
5868

5969
private int $seekPosition = 0;
@@ -117,6 +127,12 @@ public function stream_open($path, $mode, $options, &$openedPath): bool
117127

118128
if ($exists) {
119129
self::$autoloadLocatedFiles[] = $path;
130+
131+
if (self::alreadyIncluded($path)) {
132+
// the path is recorded, which is all the locator wants; refusing
133+
// the open is the only way to keep the file from running again
134+
return false;
135+
}
120136
}
121137
$this->path = $path;
122138
$this->readFromFile = false;
@@ -146,6 +162,47 @@ public function stream_read($count): string
146162
return '';
147163
}
148164

165+
/**
166+
* Whether the file at this path has already run in this process, so that
167+
* including it again would redeclare everything it holds.
168+
*
169+
* The empty script stream_read() serves only shadows the real file while
170+
* the compiler actually asks this wrapper for the contents. With OPcache
171+
* holding the script it does not ask: stream_open() still runs - so the
172+
* path is recorded - but stream_read() never does, and the real op_array
173+
* executes a second time. A file declaring a function then fatals with
174+
* "Cannot redeclare function...". This affects function-per-file packages
175+
* like php-standard-library and azjezz/psl: a files-autoload bootstrap
176+
* loads every one of them at startup, and the same paths stay reachable
177+
* through a PSR-4 prefix for the autoloader to include again.
178+
*
179+
* Refusing the open is what closes that off, because it stops the include
180+
* before anything can be compiled or fetched from a cache. Deciding it on
181+
* get_included_files() rather than on the OPcache API keeps the answer
182+
* independent of how (or whether) the script is cached: opcache_invalidate()
183+
* reaches a shared memory entry but never a file cache one, and neither it
184+
* nor opcache_is_script_cached() behaves the same across platforms when
185+
* called with this wrapper registered over file://.
186+
*/
187+
private static function alreadyIncluded(string $path): bool
188+
{
189+
$includedFiles = get_included_files();
190+
if (count($includedFiles) !== self::$includedFilesCount) {
191+
self::$includedFilesCount = count($includedFiles);
192+
self::$includedFilesMap = array_fill_keys($includedFiles, true);
193+
}
194+
195+
if (array_key_exists($path, self::$includedFilesMap)) {
196+
return true;
197+
}
198+
199+
// get_included_files() reports the resolved path, the include may be
200+
// given one that only points at the same file
201+
$realPath = realpath($path);
202+
203+
return $realPath !== false && array_key_exists($realPath, self::$includedFilesMap);
204+
}
205+
149206
/**
150207
* Whether the empty script served for this path would stay in OPcache and
151208
* shadow the real file for the rest of the process.
@@ -164,6 +221,11 @@ public function stream_read($count): string
164221
* phar://.../vendor/composer/../../src/....
165222
*/
166223
private static function servesParseError(string $path): bool
224+
{
225+
return self::resolveServesParseError(PHP_VERSION_ID, self::opcacheEnabled(), $path);
226+
}
227+
228+
private static function opcacheEnabled(): bool
167229
{
168230
if (self::$opcacheEnabled === null) {
169231
self::$opcacheEnabled = false;
@@ -173,7 +235,7 @@ private static function servesParseError(string $path): bool
173235
}
174236
}
175237

176-
return self::resolveServesParseError(PHP_VERSION_ID, self::$opcacheEnabled, $path);
238+
return self::$opcacheEnabled;
177239
}
178240

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

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

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,16 @@
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 explode;
11+
use function extension_loaded;
12+
use function implode;
13+
use function sprintf;
14+
use function str_contains;
15+
use const PHP_BINARY;
716

817
final class FileReadTrapStreamWrapperTest extends TestCase
918
{
@@ -29,4 +38,50 @@ public function testResolveServesParseError(int $phpVersionId, bool $opcacheEnab
2938
$this->assertSame($expected, FileReadTrapStreamWrapper::resolveServesParseError($phpVersionId, $opcacheEnabled, $path));
3039
}
3140

41+
/**
42+
* A file the process has already run must not run again when an autoloader
43+
* includes it inside the trap - with OPcache holding the script, the empty
44+
* script stream_read() serves never reaches the compiler. A file nothing has
45+
* loaded must still be trapped and left undeclared.
46+
*
47+
* Needs its own process: OPcache is only on in the processes PHPStan spawns
48+
* for itself, and the failure is a fatal error.
49+
*/
50+
#[Group('exec')]
51+
public function testTrapSurvivesOpcacheCacheHit(): void
52+
{
53+
if (!extension_loaded('Zend OPcache')) {
54+
self::markTestSkipped('OPcache is not available.');
55+
}
56+
57+
exec(sprintf(
58+
'%s -d opcache.enable=1 -d opcache.enable_cli=1 -d opcache.validate_timestamps=0 %s 2>&1',
59+
escapeshellarg(PHP_BINARY),
60+
escapeshellarg(__DIR__ . '/data/opcache-trap/driver.php'),
61+
), $outputLines, $exitCode);
62+
$output = implode("\n", $outputLines);
63+
64+
$this->assertSame(0, $exitCode, $output);
65+
66+
$values = [];
67+
foreach ($outputLines as $outputLine) {
68+
if (!str_contains($outputLine, '=')) {
69+
continue;
70+
}
71+
[$name, $value] = explode('=', $outputLine, 2);
72+
$values[$name] = $value;
73+
}
74+
75+
// hold whether or not OPcache could be turned on
76+
$this->assertSame('1', $values['trappedLoaded'] ?? null, $output);
77+
$this->assertSame('1', $values['trappedCold'] ?? null, $output);
78+
$this->assertSame('1', $values['coldStayedUndeclared'] ?? null, $output);
79+
80+
if (($values['opcacheEnabled'] ?? '0') === '1') {
81+
return;
82+
}
83+
84+
self::markTestSkipped('OPcache could not be enabled for the CLI, so the cache hit was not exercised.');
85+
}
86+
3287
}
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 coldThing(): string
6+
{
7+
return 'y';
8+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
<?php declare(strict_types = 1);
2+
3+
// Driver for FileReadTrapStreamWrapperTest::testTrapSurvivesOpcacheCacheHit().
4+
//
5+
// Probes two names through the trap the way AutoloadSourceLocator does:
6+
// - OpcacheTrap\thing lives in a file the bootstrap has already loaded, which
7+
// is the php-standard-library / azjezz/psl shape: a files-autoload bootstrap
8+
// loads every function file at startup, and the same paths stay reachable
9+
// through a PSR-4 prefix. With OPcache on, the include of the already-cached
10+
// path used to be served from shared memory without ever reaching the trap's
11+
// stream_read(), so the real file ran again and fatalled with
12+
// "Cannot redeclare function OpcacheTrap\thing()".
13+
// - OpcacheTrap\coldThing lives in a file nothing has loaded, and must still be
14+
// trapped and recorded the way it always was.
15+
16+
use PHPStan\Reflection\BetterReflection\SourceLocator\FileReadTrapStreamWrapper;
17+
18+
require_once __DIR__ . '/../../../../../../../vendor/autoload.php';
19+
20+
$loaded = __DIR__ . '/function.php';
21+
$cold = __DIR__ . '/cold-function.php';
22+
23+
require_once $loaded;
24+
25+
spl_autoload_register(static function (string $name) use ($loaded, $cold): void {
26+
if ($name === 'OpcacheTrap\thing') {
27+
include $loaded;
28+
} elseif ($name === 'OpcacheTrap\coldThing') {
29+
include $cold;
30+
}
31+
});
32+
33+
$opcacheStatus = opcache_get_status(false);
34+
$opcacheEnabled = $opcacheStatus !== false && ($opcacheStatus['opcache_enabled'] ?? false) === true;
35+
36+
// AutoloadSourceLocator::silenceErrors() does the same around the trap.
37+
set_error_handler(static fn (): bool => true);
38+
39+
$trap = static function (string $name): array {
40+
return FileReadTrapStreamWrapper::withStreamWrapperOverride(static function () use ($name): array {
41+
$autoloaders = spl_autoload_functions();
42+
if ($autoloaders === false) {
43+
return [];
44+
}
45+
46+
foreach ($autoloaders as $autoloader) {
47+
$autoloader($name);
48+
49+
if (FileReadTrapStreamWrapper::$autoloadLocatedFiles !== []) {
50+
return FileReadTrapStreamWrapper::$autoloadLocatedFiles;
51+
}
52+
}
53+
54+
return [];
55+
});
56+
};
57+
58+
$trappedLoaded = array_map('realpath', $trap('OpcacheTrap\thing')) === [realpath($loaded)];
59+
$trappedCold = array_map('realpath', $trap('OpcacheTrap\coldThing')) === [realpath($cold)];
60+
61+
// The cold file must have been shadowed by the trap, not really executed.
62+
$coldStayedUndeclared = !function_exists('OpcacheTrap\coldThing');
63+
64+
restore_error_handler();
65+
66+
// Compared here rather than in the test so that the separator normalization PHP
67+
// applies to include paths on Windows does not leak into the assertion.
68+
echo 'opcacheEnabled=', $opcacheEnabled ? '1' : '0', "\n";
69+
echo 'trappedLoaded=', $trappedLoaded ? '1' : '0', "\n";
70+
echo 'trappedCold=', $trappedCold ? '1' : '0', "\n";
71+
echo 'coldStayedUndeclared=', $coldStayedUndeclared ? '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)