From c13bc677b25ca43ea0bb4a6475e17688c96162a5 Mon Sep 17 00:00:00 2001 From: Sander Muller Date: Thu, 2 Jul 2026 21:21:24 +0200 Subject: [PATCH] Store the result cache with serialize() instead of a var_export'd PHP file The result cache was written as a var_export'd PHP file and hydrated with include. Including a multi-megabyte PHP source retains its compiled op_arrays and interned strings for the process lifetime, and building the var_export string concatenates the whole file in memory on save. unserialize() produces only the values, and the retained compiled-code cost disappears. The errorsCallback/collectedDataCallback/exportedNodesCallback closures existed to embed object graphs in the PHP file; restore() invoked all of them unconditionally right after the include, so plain arrays are equivalent. A cache file in the old PHP format fails to unserialize and is discarded like any other corrupted cache file (unlink and full analysis). The serialized payload is prefixed with '' so that an older PHPStan including the new-format file returns null immediately instead of echoing megabytes of inline text to stdout, and then discards it the same way. The format transition therefore needs no cache version bump in either direction. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ResultCache/ResultCacheManager.php | 138 ++++++------------ 1 file changed, 44 insertions(+), 94 deletions(-) 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, + ]), + ); } /**