Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace PHPStan\Reflection\BetterReflection\SourceLocator;

use Composer\Autoload\ClassLoader;
use Override;
use ParseError;
use PhpParser\Node\Arg;
Expand Down Expand Up @@ -31,9 +32,11 @@
use function defined;
use function function_exists;
use function interface_exists;
use function is_array;
use function is_file;
use function is_string;
use function opcache_invalidate;
use function realpath;
use function restore_error_handler;
use function set_error_handler;
use function spl_autoload_functions;
Expand Down Expand Up @@ -333,58 +336,99 @@ private function locateClassByName(string $className): ?array
return null;
}

$autoloadFunctions = spl_autoload_functions();
if ($autoloadFunctions === false) {
return null;
}

$this->silenceErrors();

try {
$result = FileReadTrapStreamWrapper::withStreamWrapperOverride(
static function () use ($className): ?array {
$functions = spl_autoload_functions();
if ($functions === false) {
return null;
}
return self::locateThroughAutoloaders($autoloadFunctions, $className);
} finally {
restore_error_handler();
}
}

foreach ($functions as $preExistingAutoloader) {
try {
$preExistingAutoloader($className);
} catch (ParseError) {
// the trap served a parse error instead of the empty
// script, see FileReadTrapStreamWrapper::stream_read();
// the file was recorded before the include compiled it
}
/**
* Runs the registered autoloaders, in order, to find which file declares a
* class - asking Composer's where it would look instead of letting it get
* there by including the file.
*
* ClassLoader::findFile() answers with the same path loadClass() would
* include, without running anything. Nothing is compiled, so the file cannot
* execute a second time - which it otherwise does whenever OPcache already
* holds the script: the include is then served from the cache without the
* trap being asked for the contents at all, and a file declaring a function
* fatals with "Cannot redeclare". That is the function-per-file layout of
* php-standard-library and azjezz/psl, whose files-autoload bootstrap has
* already loaded every path their PSR-4 prefix also resolves to.
*
* Every other autoloader still runs inside the trap, one at a time so that
* registration order is preserved either way: an autoloader ahead of
* Composer's may well claim a name Composer would resolve elsewhere.
*
* @param list<callable(string): void> $autoloadFunctions
* @return array{string[], string, int|null}|null
*/
private static function locateThroughAutoloaders(array $autoloadFunctions, string $className): ?array
{
foreach ($autoloadFunctions as $autoloadFunction) {
if (is_array($autoloadFunction) && $autoloadFunction[0] instanceof ClassLoader) {
$file = $autoloadFunction[0]->findFile($className);
if ($file === false) {
continue;
}

/**
* This static variable is populated by the side-effect of the stream wrapper
* trying to read the file path when `include()` is used by an autoloader.
*
* This will not be `null` when the autoloader tried to read a file.
*/
if (FileReadTrapStreamWrapper::$autoloadLocatedFiles !== []) {
return [FileReadTrapStreamWrapper::$autoloadLocatedFiles, $className, null];
}
// findFile() concatenates the mapped prefix with the rest of the
// name, so its answer can carry ../ segments and mixed
// separators. PHP resolves an include path before the trap ever
// sees it, and locateIdentifier() matches what lands here against
// ReflectionClass::getFileName(), which is resolved too.
$resolvedFile = realpath($file);

// a class map can outlive the file it points at, and loadClass()
// would move on to the next autoloader just the same
if ($resolvedFile === false || !is_file($resolvedFile)) {
continue;
}

return [[$resolvedFile], $className, null];
}

$locatedFiles = FileReadTrapStreamWrapper::withStreamWrapperOverride(
static function () use ($autoloadFunction, $className): array {
try {
$autoloadFunction($className);
} catch (ParseError) {
// the trap served a parse error instead of the empty
// script, see FileReadTrapStreamWrapper::stream_read();
// the file was recorded before the include compiled it
}

return null;
// populated by the side effect of the stream wrapper being
// asked for the file an include() reached for
return FileReadTrapStreamWrapper::$autoloadLocatedFiles;
},
);
if ($result === null) {
return null;
}

if (!function_exists('opcache_invalidate')) {
return $result;
if ($locatedFiles === []) {
continue;
}

// the trap's empty script got compiled - and cached, with OPcache
// active. Where this call cannot reach the entry, the trap served a
// parse error instead, see FileReadTrapStreamWrapper::stream_read()
foreach ($result[0] as $file) {
opcache_invalidate($file, true);
if (function_exists('opcache_invalidate')) {
foreach ($locatedFiles as $locatedFile) {
opcache_invalidate($locatedFile, true);
}
}

return $result;
} finally {
restore_error_handler();
return [$locatedFiles, $className, null];
}

return null;
}

private function silenceErrors(): void
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,16 @@
namespace PHPStan\Reflection\BetterReflection\SourceLocator;

use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\TestCase;
use function escapeshellarg;
use function exec;
use function explode;
use function extension_loaded;
use function implode;
use function sprintf;
use function str_contains;
use const PHP_BINARY;

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

/**
* Probing a name whose PSR-4 prefix resolves to a file the process already
* ran must not run it again: with OPcache holding the script, an include is
* served from the cache without the trap being asked for the contents. A name
* whose file nothing has loaded must still resolve, without executing it.
*
* Needs its own process: OPcache is only on in the processes PHPStan spawns
* for itself, and the failure is a fatal error.
*/
#[Group('exec')]
public function testTrapSurvivesOpcacheCacheHit(): void
{
if (!extension_loaded('Zend OPcache')) {
self::markTestSkipped('OPcache is not available.');
}

exec(sprintf(
'%s -d opcache.enable=1 -d opcache.enable_cli=1 -d opcache.validate_timestamps=0 %s 2>&1',
escapeshellarg(PHP_BINARY),
escapeshellarg(__DIR__ . '/data/opcache-trap/driver.php'),
), $outputLines, $exitCode);
$output = implode("\n", $outputLines);

$this->assertSame(0, $exitCode, $output);

$values = [];
foreach ($outputLines as $outputLine) {
if (!str_contains($outputLine, '=')) {
continue;
}
[$name, $value] = explode('=', $outputLine, 2);
$values[$name] = $value;
}

// hold whether or not OPcache could be turned on
$this->assertSame('1', $values['survivedLoadedProbe'] ?? null, $output);
$this->assertSame('1', $values['resolvedCold'] ?? null, $output);
$this->assertSame('1', $values['coldFileNotExecuted'] ?? null, $output);

if (($values['opcacheEnabled'] ?? '0') === '1') {
return;
}

self::markTestSkipped('OPcache could not be enabled for the CLI, so the cache hit was not exercised.');
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php declare(strict_types = 1);

namespace OpcacheTrap;

class ColdClass
{

}

function coldThing(): string
{
return 'y';
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?php declare(strict_types = 1);

// Driver for FileReadTrapStreamWrapperTest::testTrapSurvivesOpcacheCacheHit().
//
// Runs the real AutoloadSourceLocator against a Composer autoloader whose PSR-4
// prefix resolves to a file the process has already loaded - the
// php-standard-library / azjezz/psl shape, where a files-autoload bootstrap
// loads every function file at startup and the same paths stay reachable
// through the prefix. Letting the autoloader include such a path runs the file
// a second time whenever OPcache already holds the script, because the include
// is served from the cache without the trap being asked for the contents, and
// the process dies with "Cannot redeclare function OpcacheTrap\thing()".
//
// Each step prints as it goes, so a failure shows how far it got.

use PHPStan\BetterReflection\Identifier\Identifier;
use PHPStan\BetterReflection\Identifier\IdentifierType;
use PHPStan\BetterReflection\Reflector\DefaultReflector;
use PHPStan\Reflection\BetterReflection\SourceLocator\AutoloadSourceLocator;
use PHPStan\Reflection\BetterReflection\SourceLocator\FileNodesFetcher;
use PHPStan\Testing\PHPStanTestCase;

$loader = require __DIR__ . '/../../../../../../../vendor/autoload.php';
require_once __DIR__ . '/../../../../../../phpstan-bootstrap.php';

$opcacheStatus = opcache_get_status(false);
$opcacheEnabled = $opcacheStatus !== false && ($opcacheStatus['opcache_enabled'] ?? false) === true;
echo 'opcacheEnabled=', $opcacheEnabled ? '1' : '0', "\n";

// what the files-autoload bootstrap of such a package does at startup
require_once __DIR__ . '/thing.php';

// a real project registers its prefixes on the Composer loader that is
// already in place, rather than adding another autoloader behind it
$loader->addPsr4('OpcacheTrap\\', [__DIR__]);

$locator = new AutoloadSourceLocator(
PHPStanTestCase::getContainer()->getByType(FileNodesFetcher::class),
true,
);
$reflector = new DefaultReflector($locator);

// OpcacheTrap\thing is a function, not a class, but PHPStan probes the name as
// a class the same way it does for Psl\Type\optional - and the PSR-4 prefix
// sends the autoloader at the already-loaded function.php
$locator->locateIdentifier($reflector, new Identifier('OpcacheTrap\thing', new IdentifierType(IdentifierType::IDENTIFIER_CLASS)));
echo "survivedLoadedProbe=1\n";

// a name whose file nothing has loaded must still resolve
$cold = $reflector->reflectClass('OpcacheTrap\ColdClass');
echo 'resolvedCold=', $cold->getName() === 'OpcacheTrap\ColdClass' ? '1' : '0', "\n";
echo 'coldFileNotExecuted=', !function_exists('OpcacheTrap\coldThing') ? '1' : '0', "\n";
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php declare(strict_types = 1);

namespace OpcacheTrap;

// one function per file, named so that the PSR-4 prefix resolves OpcacheTrap\thing
// to this very path - the php-standard-library / azjezz/psl layout
function thing(): string
{
return 'x';
}
Loading