diff --git a/src/Analyser/ResultCache/ResultCacheManager.php b/src/Analyser/ResultCache/ResultCacheManager.php index a732a37c39..779f95dd6d 100644 --- a/src/Analyser/ResultCache/ResultCacheManager.php +++ b/src/Analyser/ResultCache/ResultCacheManager.php @@ -17,9 +17,10 @@ use PHPStan\DependencyInjection\GenerateFactory; use PHPStan\DependencyInjection\ProjectConfigHelper; use PHPStan\File\CouldNotReadFileException; -use PHPStan\File\CouldNotWriteFileException; use PHPStan\File\FileFinder; use PHPStan\File\FileHelper; +use PHPStan\File\FileReader; +use PHPStan\File\FileWriter; use PHPStan\Internal\ArrayHelper; use PHPStan\Internal\ComposerHelper; use PHPStan\PhpDoc\StubFilesProvider; @@ -36,11 +37,7 @@ use function array_unique; use function array_values; use function count; -use function error_get_last; use function explode; -use function fclose; -use function fopen; -use function fwrite; use function get_loaded_extensions; use function getenv; use function hash_file; @@ -50,13 +47,15 @@ use function is_file; use function ksort; use function microtime; +use function serialize; use function sort; use function sprintf; use function str_starts_with; +use function strlen; use function substr; use function time; use function unlink; -use function var_export; +use function unserialize; use const PHP_VERSION_ID; /** @@ -69,6 +68,15 @@ final class ResultCacheManager private const CACHE_VERSION = 'v13-packageDependencies'; + /** + * The cache file is serialize() output, but an older PHPStan reading it would + * include it as PHP and echo the whole multi-megabyte content to stdout as + * inline text before discarding it. This prefix makes such an include return + * null immediately (the text after ?> is never reached), so a downgrade + * degrades to a silent full analysis instead. + */ + private const SERIALIZED_FILE_PREFIX = ''; + /** @var array */ private array $fileHashes = []; @@ -206,7 +214,16 @@ public function restore(array $allAnalysedFiles, bool $debug, bool $onlyFiles, ? } try { - $data = require $cacheFilePath; + // The cache used to be a var_export'd PHP file loaded via include. Including a + // multi-megabyte PHP source retains its compiled op_arrays and interned strings + // for the process lifetime; unserialize() produces only the values. A cache file + // in the old PHP format fails to unserialize and is discarded below like any + // other corrupted file, so no cache version bump is needed for the transition. + $contents = FileReader::read($cacheFilePath); + if (str_starts_with($contents, self::SERIALIZED_FILE_PREFIX)) { + $contents = substr($contents, strlen(self::SERIALIZED_FILE_PREFIX)); + } + $data = @unserialize($contents); } catch (Throwable $e) { if ($output->isVeryVerbose()) { $output->writeLineFormatted(sprintf('Result cache not used because an error occurred while loading the cache file: %s', $e->getMessage())); @@ -402,12 +419,12 @@ public function restore(array $allAnalysedFiles, bool $debug, bool $onlyFiles, ? $filesToAnalyse = []; $invertedDependenciesToReturn = []; $invertedUsedTraitDependenciesToReturn = []; - $errors = $data['errorsCallback'](); - $locallyIgnoredErrors = $data['locallyIgnoredErrorsCallback'](); + $errors = $data['errors']; + $locallyIgnoredErrors = $data['locallyIgnoredErrors']; $linesToIgnore = $data['linesToIgnore']; $unmatchedLineIgnores = $data['unmatchedLineIgnores']; - $collectedData = $data['collectedDataCallback'](); - $exportedNodes = $data['exportedNodesCallback'](); + $collectedData = $data['collectedData']; + $exportedNodes = $data['exportedNodes']; $filteredErrors = []; $filteredLocallyIgnoredErrors = []; $filteredLinesToIgnore = []; @@ -1158,89 +1175,22 @@ private function save( $file = $this->cacheFilePath; - // streamed to the file section by section - building the whole - // var_export()ed contents in memory at once would take up roughly - // twice the size of the resulting file in the main process - $handle = @fopen($file, 'w'); - if ($handle === false) { - $error = error_get_last(); - throw new CouldNotWriteFileException($file, $error !== null ? $error['message'] : 'unknown cause'); - } - - try { - $this->writeToHandle($handle, $file, " " . var_export($lastFullAnalysisTime, true) . ", - 'meta' => " . var_export($meta, true) . ", - 'projectExtensionFiles' => " . var_export($projectExtensionFiles, true) . ", - 'errorsCallback' => static function (): array { return "); - $this->streamArrayVarExportToHandle($handle, $file, $errors); - $this->writeToHandle($handle, $file, "; }, - 'locallyIgnoredErrorsCallback' => static function (): array { return "); - $this->streamArrayVarExportToHandle($handle, $file, $locallyIgnoredErrors); - $this->writeToHandle($handle, $file, "; }, - 'linesToIgnore' => "); - $this->streamArrayVarExportToHandle($handle, $file, $linesToIgnore); - $this->writeToHandle($handle, $file, ", - 'unmatchedLineIgnores' => "); - $this->streamArrayVarExportToHandle($handle, $file, $unmatchedLineIgnores); - $this->writeToHandle($handle, $file, ", - 'collectedDataCallback' => static function (): array { return "); - $this->streamArrayVarExportToHandle($handle, $file, $collectedData); - $this->writeToHandle($handle, $file, "; }, - 'dependencies' => "); - $this->streamArrayVarExportToHandle($handle, $file, $invertedDependencies); - $this->writeToHandle($handle, $file, ", - 'packageDependencies' => "); - $this->streamArrayVarExportToHandle($handle, $file, $packageDependencies); - $this->writeToHandle($handle, $file, ", - 'exportedNodesCallback' => static function (): array { return "); - $this->streamArrayVarExportToHandle($handle, $file, $exportedNodes); - $this->writeToHandle($handle, $file, '; }, -]; -'); - } finally { - fclose($handle); - } - } - - /** - * @param resource $handle - */ - private function writeToHandle($handle, string $file, string $contents): void - { - if (@fwrite($handle, $contents) === false) { - $error = error_get_last(); - throw new CouldNotWriteFileException($file, $error !== null ? $error['message'] : 'unknown cause'); - } - } - - /** - * Streams the var_export() representation of an array to the file entry - * by entry, producing output byte-identical to var_export($values, true). - * - * var_export() builds the whole export in memory even when told to print it, - * so exporting a big section in one call would take up as much memory - * as the resulting file section itself. - * - * Each entry is exported wrapped in a single-entry array whose "array (\n" - * prefix and "\n)" suffix are stripped, yielding the same bytes (including - * indentation) the entry would get inside the full export. Indenting the lines - * of a standalone value export would corrupt multi-line string contents instead. - * - * @param resource $handle - * @param array $values - */ - private function streamArrayVarExportToHandle($handle, string $file, array $values): void - { - $this->writeToHandle($handle, $file, 'array ('); - foreach ($values as $key => $value) { - $entry = var_export([$key => $value], true); - $this->writeToHandle($handle, $file, "\n" . substr($entry, 8, -2)); - } - - $this->writeToHandle($handle, $file, "\n)"); + FileWriter::write( + $file, + self::SERIALIZED_FILE_PREFIX . serialize([ + 'lastFullAnalysisTime' => $lastFullAnalysisTime, + 'meta' => $meta, + 'projectExtensionFiles' => $projectExtensionFiles, + 'errors' => $errors, + 'locallyIgnoredErrors' => $locallyIgnoredErrors, + 'linesToIgnore' => $linesToIgnore, + 'unmatchedLineIgnores' => $unmatchedLineIgnores, + 'collectedData' => $collectedData, + 'dependencies' => $invertedDependencies, + 'packageDependencies' => $packageDependencies, + 'exportedNodes' => $exportedNodes, + ]), + ); } /**