diff --git a/.github/workflows/phar.yml b/.github/workflows/phar.yml index c91433e90f..5c5669e2f2 100644 --- a/.github/workflows/phar.yml +++ b/.github/workflows/phar.yml @@ -1175,10 +1175,106 @@ jobs: php -r 'exit(function_exists("pcntl_exec") && function_exists("pcntl_fork") ? 0 : 1);' - name: "Run PHAR" + # No turbo binary sits next to this phar (the turbo-* jobs cover + # those), so ForkParallelChecker spawns the two workers phpstan.neon + # asks for. At -vvv each reports what it runs with: the OPcache + # setup its command line carries (see ProcessHelper) took effect. run: | php tmp/phpstan.phar --version cd e2e/phar-run - php ../../tmp/phpstan.phar analyse -vvv + php ../../tmp/phpstan.phar analyse -vvv 2>&1 | tee output.txt + OPCACHE="$(php -r 'echo extension_loaded("Zend OPcache") ? "on" : "off";')" + grep -F "Spawned worker 1/2 checked in: turbo off, OPcache $OPCACHE, trusted types off" output.txt + grep -F "Spawned worker 2/2 checked in: turbo off, OPcache $OPCACHE, trusted types off" output.txt + + phar-run-windows: + name: "Run PHAR on Windows, PHP ${{ matrix.php-version }}" + needs: + - compiler-tests + - turbo-compile-windows + runs-on: "windows-latest" + timeout-minutes: 20 + + defaults: + run: + shell: bash + + strategy: + fail-fast: false + matrix: + include: + # below TurboExtensionSelector::MINIMUM_PHP_VERSION_ID there is no + # binary to find: the workers get the OPcache entries alone + - php-version: "8.2" + turbo: "off" + - php-version: "8.5" + turbo: "on" + + steps: + - name: "Checkout" + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + persist-credentials: false + + - name: "Install PHP" + uses: "shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240" # v2.37.2 + with: + coverage: "none" + php-version: "${{ matrix.php-version }}" + extensions: "opcache" + + - name: "Download PHAR" + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: phar-file + path: tmp + + - name: "Download extension artifact" + if: matrix.turbo == 'on' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: "phpstan_turbo-windows-x86_64-php${{ matrix.php-version }}" + path: "tmp/turbo-artifact" + + - name: "Place the extension next to the PHAR" + # the distribution layout TurboExtensionSelector looks for, see the + # turbo-artifact job + if: matrix.turbo == 'on' + env: + PHP_VERSION: ${{ matrix.php-version }} + run: | + mkdir -p tmp/turbo-ext/windows-x86_64 + cp tmp/turbo-artifact/php_phpstan_turbo.dll "tmp/turbo-ext/windows-x86_64/phpstan_turbo-$PHP_VERSION.dll" + + - name: "Diagnose" + # Without pcntl the main process cannot restart itself, so the + # workers are spawned and their command line is where the extension + # and OPcache come in - each worker with its own OPcache instance + # (see ProcessHelper). + env: + TURBO: ${{ matrix.turbo }} + run: | + cd e2e/phar-run + php ../../tmp/phpstan.phar diagnose --no-ansi 2>&1 | tee diagnose.txt + grep -F "Mechanism: spawn (react/child-process)" diagnose.txt + grep -F "opcache.enable_cli=1" diagnose.txt + grep -E "opcache\.cache_id=phpstan-[0-9]+-1" diagnose.txt + grep -F "phpstan.restarted=1" diagnose.txt + if [ "$TURBO" = on ]; then + grep -F "Turbo extension: enabled in worker processes" diagnose.txt + else + grep -F "Turbo extension: not loaded" diagnose.txt + fi + + - name: "Run PHAR" + env: + TURBO: ${{ matrix.turbo }} + run: | + php tmp/phpstan.phar --version + cd e2e/phar-run + php ../../tmp/phpstan.phar analyse -vvv --no-ansi 2>&1 | tee output.txt + grep -F "Spawned worker 1/2 checked in: turbo $TURBO, OPcache on, trusted types $TURBO" output.txt + grep -F "Spawned worker 2/2 checked in: turbo $TURBO, OPcache on, trusted types $TURBO" output.txt integration-tests: if: github.event_name == 'pull_request' diff --git a/e2e/phar-run/phpstan.neon b/e2e/phar-run/phpstan.neon index c308dcf542..28b6e40399 100644 --- a/e2e/phar-run/phpstan.neon +++ b/e2e/phar-run/phpstan.neon @@ -2,3 +2,9 @@ parameters: level: 8 paths: - src + parallel: + # always two workers: the phar.yml phar-run jobs assert on what + # each one reports at -vvv (see ParallelAnalyser) + jobSize: 1 + minimumNumberOfJobsPerProcess: 1 + maximumNumberOfProcesses: 2 diff --git a/e2e/phar-run/src/Bar.php b/e2e/phar-run/src/Bar.php new file mode 100644 index 0000000000..d509b8b475 --- /dev/null +++ b/e2e/phar-run/src/Bar.php @@ -0,0 +1,13 @@ +getNumberOfProcesses() > 0) { $loop = new StreamSelectLoop(); $result = null; - $promise = $this->parallelAnalyser->analyse($loop, $schedule, $allAnalysedFiles, $mainScript, $postFileCallback, $projectConfigFile, $tmpFile, $insteadOfFile, $input, null); + $promise = $this->parallelAnalyser->analyse($loop, $schedule, $allAnalysedFiles, $mainScript, $postFileCallback, $projectConfigFile, $tmpFile, $insteadOfFile, $input, null, $errorOutput); $promise->then(static function (AnalyserResult $tmp) use (&$result): void { $result = $tmp; }); diff --git a/src/Command/FixerWorkerRunner.php b/src/Command/FixerWorkerRunner.php index dbc0494cdc..3b4ace9a3f 100644 --- a/src/Command/FixerWorkerRunner.php +++ b/src/Command/FixerWorkerRunner.php @@ -370,6 +370,7 @@ private function runAnalyser(LoopInterface $loop, array $files, array $allAnalys null, $input, $onFileAnalysisHandler, + null, ); } diff --git a/src/Parallel/ForkParallelChecker.php b/src/Parallel/ForkParallelChecker.php index a18822bed9..87f95feede 100644 --- a/src/Parallel/ForkParallelChecker.php +++ b/src/Parallel/ForkParallelChecker.php @@ -6,10 +6,15 @@ use PHPStan\Command\Output; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Diagnose\DiagnoseExtension; +use PHPStan\Process\ProcessHelper; use PHPStan\Turbo\TurboExtensionEnabler; +use PHPStan\Turbo\TurboProcessRestarter; use function function_exists; +use function getmypid; use function opcache_get_status; use function sprintf; +use function str_starts_with; +use const PHP_OS_FAMILY; /** * Decides whether parallel analysis should fork workers via pcntl_fork() @@ -62,6 +67,18 @@ public function print(Output $output): void $output->writeLineFormatted('Mechanism: spawn (react/child-process)'); $output->writeLineFormatted(sprintf('Reason fork not used: %s', $reason)); + + // what a spawned worker's command line adds on top of the php.ini + // (see ProcessHelper); the extension path is on the turbo lines + $parentPid = getmypid(); + $output->writeLineFormatted('Worker -d entries:'); + foreach (ProcessHelper::resolveWorkerIniEntries(TurboProcessRestarter::getOpcacheArgs(), PHP_OS_FAMILY, $parentPid === false ? 0 : $parentPid, 1) as $iniEntry) { + $output->writeLineFormatted(sprintf( + ' %s%s', + $iniEntry, + str_starts_with($iniEntry, 'opcache.cache_id=') ? ' (numbered per worker)' : '', + )); + } $output->writeLineFormatted(''); } diff --git a/src/Parallel/ParallelAnalyser.php b/src/Parallel/ParallelAnalyser.php index 63f2fd5a2d..f5d07d9b6f 100644 --- a/src/Parallel/ParallelAnalyser.php +++ b/src/Parallel/ParallelAnalyser.php @@ -11,6 +11,7 @@ use PHPStan\Analyser\InternalError; use PHPStan\Cache\ArenaCache; use PHPStan\Command\CommandHelper; +use PHPStan\Command\Output; use PHPStan\Dependency\RootExportedNode; use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\AutowiredService; @@ -67,6 +68,7 @@ public function __construct( * @param string[] $allAnalysedFiles * @param Closure(int, list=): void|null $postFileCallback * @param (callable(list, list, string[]): void)|null $onFileAnalysisHandler + * @param Output|null $errorOutput where spawned workers report what they run with at -vvv; null when nobody is listening * @return PromiseInterface */ public function analyse( @@ -80,6 +82,7 @@ public function analyse( ?string $insteadOfFile, InputInterface $input, ?callable $onFileAnalysisHandler, + ?Output $errorOutput, ): PromiseInterface { $jobs = array_reverse($schedule->getJobs()); @@ -127,6 +130,8 @@ public function analyse( /** @var Deferred $deferred */ $deferred = new Deferred(); + $useFork = $this->forkParallelChecker->isSupported(); + $server = new TcpServer('127.0.0.1:0', $loop); $this->processPool = new ProcessPool($server, static function () use ($deferred, &$jobs, &$internalErrors, &$internalErrorsCount, &$reachedInternalErrorsCountLimit, &$errors, &$filteredPhpErrors, &$allPhpErrors, &$locallyIgnoredErrors, &$linesToIgnore, &$unmatchedLineIgnores, &$collectedData, &$dependencies, &$usedTraitDependencies, &$packageDependencies, &$exportedNodes, &$peakMemoryUsages, &$allProcessedFiles, $arenaName): void { if ($arenaName !== null) { @@ -166,13 +171,13 @@ public function analyse( workerCount: count($peakMemoryUsages), )); }); - $server->on('connection', function (ConnectionInterface $connection) use (&$jobs, $arenaName, $expectedWorkerCount, &$helloCount): void { + $server->on('connection', function (ConnectionInterface $connection) use (&$jobs, $arenaName, $expectedWorkerCount, &$helloCount, $errorOutput, $useFork): void { // phpcs:disable SlevomatCodingStandard.Namespaces.ReferenceUsedNamesOnly $jsonInvalidUtf8Ignore = defined('JSON_INVALID_UTF8_IGNORE') ? JSON_INVALID_UTF8_IGNORE : 0; // phpcs:enable $decoder = new Decoder($connection, true, options: $jsonInvalidUtf8Ignore, maxlength: $this->decoderBufferSize); $encoder = new Encoder($connection, $jsonInvalidUtf8Ignore); - $decoder->on('data', function (array $data) use (&$jobs, $decoder, $encoder, $arenaName, $expectedWorkerCount, &$helloCount): void { + $decoder->on('data', function (array $data) use (&$jobs, $decoder, $encoder, $arenaName, $expectedWorkerCount, &$helloCount, $errorOutput, $useFork): void { if ($data['action'] !== 'hello') { return; } @@ -185,6 +190,19 @@ public function analyse( if ($arenaName !== null && $helloCount === $expectedWorkerCount) { ArenaCache::unlinkName(); } + if ($errorOutput !== null && $errorOutput->isVeryVerbose() && !$useFork) { + // the spawn command line asks for these (see ProcessHelper); + // this is whether they took effect. A forked worker inherits + // the main process and has nothing of its own to report. + $errorOutput->writeLineFormatted(sprintf( + 'Spawned worker %d/%d checked in: turbo %s, OPcache %s, trusted types %s', + $helloCount, + $expectedWorkerCount, + ($data['turbo'] ?? false) === true ? 'on' : 'off', + ($data['opcache'] ?? false) === true ? 'on' : 'off', + ($data['trustedTypes'] ?? false) === true ? 'on' : 'off', + )); + } $identifier = $data['identifier']; $process = $this->processPool->getProcess($identifier); @@ -217,8 +235,6 @@ public function analyse( $this->processPool->quitAll(); }; - $useFork = $this->forkParallelChecker->isSupported(); - if ($useFork && $numberOfProcesses > 1) { // Build the directory symbol indexes here, in the parent, so the // children inherit them copy-on-write instead of each scanning the diff --git a/src/Parallel/WorkerRunner.php b/src/Parallel/WorkerRunner.php index ae14e2d4c5..fa3b5caf2d 100644 --- a/src/Parallel/WorkerRunner.php +++ b/src/Parallel/WorkerRunner.php @@ -16,6 +16,7 @@ use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Rules\Registry as RuleRegistry; +use PHPStan\Turbo\TurboExtensionEnabler; use React\EventLoop\StreamSelectLoop; use React\Socket\ConnectionInterface; use React\Socket\TcpConnector; @@ -30,7 +31,9 @@ use function array_unshift; use function array_values; use function defined; +use function function_exists; use function memory_get_peak_usage; +use function opcache_get_status; use function sprintf; /** @@ -60,6 +63,25 @@ public function __construct( { } + /** + * Whether this process compiles into an opcode cache. Off in a forked + * worker by design (see ForkedProcess), which is why ParallelAnalyser + * reports spawned workers only. + */ + private function isOpcacheEnabled(): bool + { + if (!function_exists('opcache_get_status')) { + return false; + } + + $status = opcache_get_status(false); + if ($status === false) { + return false; + } + + return ($status['opcache_enabled'] ?? false) === true; + } + /** * @param string[] $analysedFiles the full analysed-files list (raw, before tmp-file substitution) * @return int exit code (0 on success, 1 if a worker-communication error occurred) @@ -100,7 +122,16 @@ public function run( // phpcs:enable $out = new Encoder($connection, $jsonInvalidUtf8Ignore); $in = new Decoder($connection, true, options: $jsonInvalidUtf8Ignore, maxlength: $this->decoderBufferSize); - $out->write(['action' => 'hello', 'identifier' => $identifier]); + $out->write([ + 'action' => 'hello', + 'identifier' => $identifier, + // what this worker actually runs with, for the -vvv report of + // a spawning ParallelAnalyser: its command line asks for these + // (see ProcessHelper), this is whether they took effect + 'turbo' => TurboExtensionEnabler::isActive(), + 'opcache' => $this->isOpcacheEnabled(), + 'trustedTypes' => TurboExtensionEnabler::isTrustingOwnTypes(), + ]); $this->runWorker($out, $in, $output, $analysedFiles, $tmpFile, $insteadOfFile, $errorCount); }); diff --git a/src/Process/ProcessHelper.php b/src/Process/ProcessHelper.php index 7f33d33abf..2af7feadfd 100644 --- a/src/Process/ProcessHelper.php +++ b/src/Process/ProcessHelper.php @@ -4,9 +4,11 @@ use PHPStan\Command\AnalyseCommand; use PHPStan\Turbo\TurboExtensionSelector; +use PHPStan\Turbo\TurboProcessRestarter; use Symfony\Component\Console\Input\InputInterface; use function array_merge; use function escapeshellarg; +use function getmypid; use function implode; use function ini_get; use function is_bool; @@ -14,10 +16,25 @@ use function sprintf; use function sys_get_temp_dir; use const PHP_BINARY; - +use const PHP_OS_FAMILY; + +/** + * Builds the command line of a spawned worker process (see SpawnedProcess + * and SpawnedProcessPromise). + * + * Besides the worker command and its options it spells out the PHP + * configuration the worker runs with. The php.ini is inherited through + * `-c`, but command-line `-d` entries are not, so whatever the spawning + * process got that way - the turbo extension and the OPcache setup of the + * TurboProcessRestarter restart - is repeated here; see + * resolveWorkerIniEntries() for the set and the reasoning. + */ final class ProcessHelper { + /** How many workers this process spawned so far - numbers the Windows OPcache instances, see resolveWorkerIniEntries() */ + private static int $spawnedWorkerCount = 0; + /** * @param string[] $additionalItems */ @@ -49,6 +66,18 @@ public static function getWorkerCommand( $processCommandArray[] = '-d'; // quote value so PHP will parse it as a string when the path contains a bitwise operator like ~ $processCommandArray[] = 'extension=' . escapeshellarg("'" . $turboExtension . "'"); + // the same marker the process restart sets: the worker's + // TurboExtensionSelector then knows the extension came through -d, + // not the php.ini - nothing that process spawns would inherit it + $processCommandArray[] = '-d'; + $processCommandArray[] = TurboProcessRestarter::EXTENSION_PATH_INI . '=' . escapeshellarg("'" . $turboExtension . "'"); + } + + $parentPid = getmypid(); + self::$spawnedWorkerCount++; + foreach (self::resolveWorkerIniEntries(TurboProcessRestarter::getOpcacheArgs(), PHP_OS_FAMILY, $parentPid === false ? 0 : $parentPid, self::$spawnedWorkerCount) as $iniEntry) { + $processCommandArray[] = '-d'; + $processCommandArray[] = $iniEntry; } foreach ([$mainScript, $commandName] as $arg) { @@ -96,4 +125,49 @@ public static function getWorkerCommand( return implode(' ', $processCommandArray); } + /** + * The ini entries a spawned worker gets as `-d name=value`, besides + * memory_limit, sys_temp_dir and the extension. + * + * The OPcache entries are the ones the TurboProcessRestarter restart + * gives the main process (see its resolveOpcacheArgs() for what each + * does). A spawned worker compiles the whole application again and gains + * the same from them - optimized opcodes, interned strings, the + * inheritance cache - and, with the turbo extension active in a phar, + * the optimizer pass dropping PHPStan's own run-time type checks, which + * exists only inside OPcache. Without them, a worker on a pcntl host + * re-executed itself through TurboProcessRestarter to get OPcache: one + * exec more per worker, and one that rebuilt the command line from + * scratch, dropping the sys_temp_dir and extension entries of the spawn. + * A Windows worker ran without OPcache altogether. + * + * On Windows the cache is not private to the process: OPcache there + * opens a named file mapping shared by every process of the same user, + * PHP build and SAPI, so identically configured workers would all attach + * to the first one's segment. That is the concurrently populated shared + * cache forked workers had to be taken off (see ForkedProcess), and + * attaching fails outright - a fatal startup error - when the segment's + * base address is taken in the new process. An opcache.cache_id (a + * Windows-only directive, folded into the mapping name) unique to the + * worker gives each its own segment, never shared, never reattached: the + * model spawned workers have elsewhere, where the segment is an anonymous + * mapping private to the process. + * + * The restart marker closes the loop: the worker's configuration is + * decided here, so the worker must not run TurboProcessRestarter itself. + * + * @param list $opcacheArgs TurboProcessRestarter::getOpcacheArgs() of the spawning process + * @return list + */ + public static function resolveWorkerIniEntries(array $opcacheArgs, string $osFamily, int $parentPid, int $workerNumber): array + { + $entries = $opcacheArgs; + if ($entries !== [] && $osFamily === 'Windows') { + $entries[] = sprintf('opcache.cache_id=phpstan-%d-%d', $parentPid, $workerNumber); + } + $entries[] = TurboProcessRestarter::RESTARTED_INI . '=1'; + + return $entries; + } + } diff --git a/src/Reflection/BetterReflection/SourceLocator/AutoloadSourceLocator.php b/src/Reflection/BetterReflection/SourceLocator/AutoloadSourceLocator.php index 3c43f17c7c..0a5a61c379 100644 --- a/src/Reflection/BetterReflection/SourceLocator/AutoloadSourceLocator.php +++ b/src/Reflection/BetterReflection/SourceLocator/AutoloadSourceLocator.php @@ -3,6 +3,7 @@ namespace PHPStan\Reflection\BetterReflection\SourceLocator; use Override; +use ParseError; use PhpParser\Node\Arg; use PhpParser\Node\Expr\FuncCall; use PhpParser\Node\Name; @@ -343,7 +344,13 @@ static function () use ($className): ?array { } foreach ($functions as $preExistingAutoloader) { - $preExistingAutoloader($className); + 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 + } /** * This static variable is populated by the side-effect of the stream wrapper @@ -367,6 +374,9 @@ static function () use ($className): ?array { return $result; } + // 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); } diff --git a/src/Reflection/BetterReflection/SourceLocator/FileReadTrapStreamWrapper.php b/src/Reflection/BetterReflection/SourceLocator/FileReadTrapStreamWrapper.php index 047a6808e2..c6a470e3ff 100644 --- a/src/Reflection/BetterReflection/SourceLocator/FileReadTrapStreamWrapper.php +++ b/src/Reflection/BetterReflection/SourceLocator/FileReadTrapStreamWrapper.php @@ -3,13 +3,17 @@ namespace PHPStan\Reflection\BetterReflection\SourceLocator; use PHPStan\ShouldNotHappenException; +use function function_exists; use function is_dir; use function is_file; +use function opcache_get_status; use function stat; use function stream_resolve_include_path; use function stream_wrapper_register; use function stream_wrapper_restore; use function stream_wrapper_unregister; +use function strpos; +use const PHP_VERSION_ID; use const SEEK_CUR; use const SEEK_END; use const SEEK_SET; @@ -40,10 +44,22 @@ final class FileReadTrapStreamWrapper /** @var string[] */ public static array $autoloadLocatedFiles = []; + /** + * Served instead of the empty script where OPcache would keep that empty + * script, see servesParseError(). A parse error compiles to nothing, so + * OPcache stores nothing, and it reaches AutoloadSourceLocator as the + * ParseError it catches. + */ + private const PARSE_ERROR_SCRIPT = "path = $path; $this->readFromFile = false; $this->seekPosition = 0; @@ -119,12 +136,55 @@ public function stream_read($count): string { $this->readFromFile = true; + if (self::servesParseError($this->path)) { + return self::PARSE_ERROR_SCRIPT; + } + // Dummy return value that is also valid PHP for require(). We'll read // and process the file elsewhere, so it's OK to provide dummy data for // this read. return ''; } + /** + * Whether the empty script served for this path would stay in OPcache and + * shadow the real file for the rest of the process. + * + * OPcache caches what an include compiles under the path the include was + * given - the empty script this wrapper serves too. AutoloadSourceLocator + * undoes that with opcache_invalidate() on the trapped files, except that + * on PHP < 8.1 the call fails for the path of any stream wrapper other + * than file://: zend_accel_invalidate() resolves the path first, and + * php_resolve_path() refuses such URLs (PHP >= 8.1 falls back to the name + * as given). The poisoned entry then serves the empty script to every + * later include of the same path, so a class in that file is never + * declared - "Class not found" on its first cold use. PHPStan's own + * classes that preload.php leaves to the autoloader are exactly such + * files when running from the phar, autoloaded as + * phar://.../vendor/composer/../../src/.... + */ + private static function servesParseError(string $path): bool + { + if (self::$opcacheEnabled === null) { + self::$opcacheEnabled = false; + if (function_exists('opcache_get_status')) { + $status = opcache_get_status(false); + self::$opcacheEnabled = $status !== false && ($status['opcache_enabled'] ?? false) === true; + } + } + + return self::resolveServesParseError(PHP_VERSION_ID, self::$opcacheEnabled, $path); + } + + public static function resolveServesParseError(int $phpVersionId, bool $opcacheEnabled, string $path): bool + { + if (!$opcacheEnabled || $phpVersionId >= 80100) { + return false; + } + + return strpos($path, '://') !== false && strpos($path, 'file://') !== 0; + } + /** * Since we allowed the open to succeed, we should allow the close to occur * as well. diff --git a/src/Turbo/TurboExtensionSelector.php b/src/Turbo/TurboExtensionSelector.php index c27517ec84..259e6cd97d 100644 --- a/src/Turbo/TurboExtensionSelector.php +++ b/src/Turbo/TurboExtensionSelector.php @@ -53,8 +53,9 @@ public static function findExtensionForWorkers(): ?string if (TurboExtensionEnabler::isLoaded()) { $restartPath = TurboProcessRestarter::getRestartExtensionPath(); if ($restartPath !== null) { - // loaded through the restart's own -d flag (see - // TurboProcessRestarter) — spawned workers do not inherit + // loaded through a -d flag — the restart's own, or the one + // ProcessHelper gave this process when spawning it as a worker + // (see TurboProcessRestarter): spawned workers do not inherit // command-line -d flags, so they need it passed explicitly return $restartPath; } diff --git a/src/Turbo/TurboProcessRestarter.php b/src/Turbo/TurboProcessRestarter.php index c74a027af5..b1580b3874 100644 --- a/src/Turbo/TurboProcessRestarter.php +++ b/src/Turbo/TurboProcessRestarter.php @@ -44,14 +44,19 @@ * stop (a binary that failed to load, or an OPcache that could not start, * would otherwise restart forever), and EXTENSION_PATH_INI tells * TurboExtensionSelector that spawned workers still need the -d flag - * (command-line -d flags, unlike php.ini, are not inherited). + * (command-line -d flags, unlike php.ini, are not inherited). ProcessHelper + * sets both on the workers it spawns, along with the OPcache entries: a + * worker's configuration is decided by the process spawning it, so it never + * restarts itself — before that, every spawned worker on a pcntl host + * re-executed itself once to activate OPcache, rebuilding its command line + * without the sys_temp_dir and extension entries of the spawn. */ final class TurboProcessRestarter { public const EXTENSION_PATH_INI = 'phpstan.turboExtensionPath'; - /** Set by the restart so the restarted process never restarts again */ + /** Set by the restart, and by ProcessHelper on spawned workers, so the process never restarts itself */ public const RESTARTED_INI = 'phpstan.restarted'; /** Shared memory reserved (not touched) for the opcode cache, in MB — see resolveOpcacheArgs() for the sizing */ @@ -75,8 +80,9 @@ final class TurboProcessRestarter ]; /** - * The extension path this process was restarted with, null when the - * process was not restarted. + * The extension path this process was given through -d — by the restart, + * or by ProcessHelper when spawned as a worker. Null when the extension + * came from the php.ini or is not loaded at all. */ public static function getRestartExtensionPath(): ?string { @@ -210,8 +216,9 @@ private static function normalizeIniValue(string $value): string } /** - * `-d` entries activating OPcache for the restarted process — see - * resolveOpcacheArgs() for the reasoning behind each of them. + * `-d` entries activating OPcache for the restarted process, and for the + * workers ProcessHelper spawns — see resolveOpcacheArgs() for the + * reasoning behind each of them. * * Nothing is added when OPcache is not loaded at all — real on PHP <= 8.4, * gone on 8.5+ (always built in and loaded). Loading it from here is not @@ -220,7 +227,7 @@ private static function normalizeIniValue(string $value): string * * @return list */ - private static function getOpcacheArgs(): array + public static function getOpcacheArgs(): array { if (!extension_loaded('Zend OPcache')) { return []; diff --git a/tests/PHPStan/Process/ProcessHelperTest.php b/tests/PHPStan/Process/ProcessHelperTest.php new file mode 100644 index 0000000000..20f1fcf07b --- /dev/null +++ b/tests/PHPStan/Process/ProcessHelperTest.php @@ -0,0 +1,82 @@ +, string, list}> + */ + public static function dataResolveWorkerIniEntries(): iterable + { + $opcacheArgs = ['opcache.enable=1', 'opcache.enable_cli=1']; + yield 'linux' => [$opcacheArgs, 'Linux', ['opcache.enable=1', 'opcache.enable_cli=1', 'phpstan.restarted=1']]; + yield 'darwin' => [$opcacheArgs, 'Darwin', ['opcache.enable=1', 'opcache.enable_cli=1', 'phpstan.restarted=1']]; + yield 'windows' => [$opcacheArgs, 'Windows', ['opcache.enable=1', 'opcache.enable_cli=1', 'opcache.cache_id=phpstan-4242-7', 'phpstan.restarted=1']]; + yield 'linux without OPcache' => [[], 'Linux', ['phpstan.restarted=1']]; + yield 'windows without OPcache' => [[], 'Windows', ['phpstan.restarted=1']]; + } + + /** + * @param list $opcacheArgs + * @param list $expected + */ + #[DataProvider('dataResolveWorkerIniEntries')] + public function testResolveWorkerIniEntries(array $opcacheArgs, string $osFamily, array $expected): void + { + $this->assertSame($expected, ProcessHelper::resolveWorkerIniEntries($opcacheArgs, $osFamily, 4242, 7)); + } + + public function testWorkerCommandCarriesTheIniEntries(): void + { + $command = ProcessHelper::getWorkerCommand('bin/phpstan', 'worker', null, ['--port', '1234'], $this->createInput()); + $commandAgain = ProcessHelper::getWorkerCommand('bin/phpstan', 'worker', null, ['--port', '1234'], $this->createInput()); + + $opcacheArgs = TurboProcessRestarter::getOpcacheArgs(); + foreach ($opcacheArgs as $opcacheArg) { + $this->assertStringContainsString(sprintf(' -d %s ', $opcacheArg), $command); + } + + // the restart marker is the last -d, right before the script + $this->assertStringContainsString( + sprintf(' -d %s=1 %s %s ', TurboProcessRestarter::RESTARTED_INI, escapeshellarg('bin/phpstan'), escapeshellarg('worker')), + $command, + ); + + if (PHP_OS_FAMILY !== 'Windows' || $opcacheArgs === []) { + $this->assertStringNotContainsString('opcache.cache_id=', $command); + return; + } + + // every Windows worker gets its own OPcache instance + $this->assertSame(1, preg_match('~ -d opcache\.cache_id=(phpstan-\d+-\d+) ~', $command, $matches)); + $this->assertSame(1, preg_match('~ -d opcache\.cache_id=(phpstan-\d+-\d+) ~', $commandAgain, $matchesAgain)); + $this->assertNotSame($matches[1], $matchesAgain[1]); + } + + private function createInput(): ArrayInput + { + return new ArrayInput(['paths' => ['src']], new InputDefinition([ + new InputArgument('paths', InputArgument::IS_ARRAY), + new InputOption('level', 'l', InputOption::VALUE_REQUIRED), + new InputOption('autoload-file', 'a', InputOption::VALUE_REQUIRED), + new InputOption('memory-limit', null, InputOption::VALUE_REQUIRED), + new InputOption('xdebug', null, InputOption::VALUE_NONE), + new InputOption('verbose', 'v', InputOption::VALUE_NONE), + ])); + } + +} diff --git a/tests/PHPStan/Reflection/BetterReflection/SourceLocator/FileReadTrapStreamWrapperTest.php b/tests/PHPStan/Reflection/BetterReflection/SourceLocator/FileReadTrapStreamWrapperTest.php new file mode 100644 index 0000000000..cd1a78087b --- /dev/null +++ b/tests/PHPStan/Reflection/BetterReflection/SourceLocator/FileReadTrapStreamWrapperTest.php @@ -0,0 +1,32 @@ + + */ + public static function dataResolveServesParseError(): iterable + { + $pharPath = 'phar:///project/vendor/phpstan/phpstan/phpstan.phar/vendor/composer/../../src/TrinaryLogic.php'; + yield 'phar path, OPcache, PHP 7.4' => [70433, true, $pharPath, true]; + yield 'phar path, OPcache, PHP 8.0' => [80030, true, $pharPath, true]; + yield 'phar path, OPcache, PHP 8.1 invalidates by name' => [80100, true, $pharPath, false]; + yield 'phar path, no OPcache' => [70433, false, $pharPath, false]; + yield 'plain path, OPcache, PHP 7.4' => [70433, true, '/project/src/Foo.php', false]; + yield 'file:// path, OPcache, PHP 7.4' => [70433, true, 'file:///project/src/Foo.php', false]; + yield 'other wrapper, OPcache, PHP 7.4' => [70433, true, 'vfs://project/src/Foo.php', true]; + } + + #[DataProvider('dataResolveServesParseError')] + public function testResolveServesParseError(int $phpVersionId, bool $opcacheEnabled, string $path, bool $expected): void + { + $this->assertSame($expected, FileReadTrapStreamWrapper::resolveServesParseError($phpVersionId, $opcacheEnabled, $path)); + } + +}