Skip to content

Commit 80d339d

Browse files
SanderMullerclaude
andcommitted
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 '<?php return; ?>' 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) <noreply@anthropic.com>
1 parent 709654e commit 80d339d

1 file changed

Lines changed: 44 additions & 94 deletions

File tree

src/Analyser/ResultCache/ResultCacheManager.php

Lines changed: 44 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,10 @@
1717
use PHPStan\DependencyInjection\GenerateFactory;
1818
use PHPStan\DependencyInjection\ProjectConfigHelper;
1919
use PHPStan\File\CouldNotReadFileException;
20-
use PHPStan\File\CouldNotWriteFileException;
2120
use PHPStan\File\FileFinder;
2221
use PHPStan\File\FileHelper;
22+
use PHPStan\File\FileReader;
23+
use PHPStan\File\FileWriter;
2324
use PHPStan\Internal\ArrayHelper;
2425
use PHPStan\Internal\ComposerHelper;
2526
use PHPStan\PhpDoc\StubFilesProvider;
@@ -36,11 +37,7 @@
3637
use function array_unique;
3738
use function array_values;
3839
use function count;
39-
use function error_get_last;
4040
use function explode;
41-
use function fclose;
42-
use function fopen;
43-
use function fwrite;
4441
use function get_loaded_extensions;
4542
use function getenv;
4643
use function hash_file;
@@ -50,13 +47,15 @@
5047
use function is_file;
5148
use function ksort;
5249
use function microtime;
50+
use function serialize;
5351
use function sort;
5452
use function sprintf;
5553
use function str_starts_with;
54+
use function strlen;
5655
use function substr;
5756
use function time;
5857
use function unlink;
59-
use function var_export;
58+
use function unserialize;
6059
use const PHP_VERSION_ID;
6160

6261
/**
@@ -69,6 +68,15 @@ final class ResultCacheManager
6968

7069
private const CACHE_VERSION = 'v13-packageDependencies';
7170

71+
/**
72+
* The cache file is serialize() output, but an older PHPStan reading it would
73+
* include it as PHP and echo the whole multi-megabyte content to stdout as
74+
* inline text before discarding it. This prefix makes such an include return
75+
* null immediately (the text after ?> is never reached), so a downgrade
76+
* degrades to a silent full analysis instead.
77+
*/
78+
private const SERIALIZED_FILE_PREFIX = '<?php return; ?>';
79+
7280
/** @var array<string, string> */
7381
private array $fileHashes = [];
7482

@@ -206,7 +214,16 @@ public function restore(array $allAnalysedFiles, bool $debug, bool $onlyFiles, ?
206214
}
207215

208216
try {
209-
$data = require $cacheFilePath;
217+
// The cache used to be a var_export'd PHP file loaded via include. Including a
218+
// multi-megabyte PHP source retains its compiled op_arrays and interned strings
219+
// for the process lifetime; unserialize() produces only the values. A cache file
220+
// in the old PHP format fails to unserialize and is discarded below like any
221+
// other corrupted file, so no cache version bump is needed for the transition.
222+
$contents = FileReader::read($cacheFilePath);
223+
if (str_starts_with($contents, self::SERIALIZED_FILE_PREFIX)) {
224+
$contents = substr($contents, strlen(self::SERIALIZED_FILE_PREFIX));
225+
}
226+
$data = @unserialize($contents);
210227
} catch (Throwable $e) {
211228
if ($output->isVeryVerbose()) {
212229
$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, ?
402419
$filesToAnalyse = [];
403420
$invertedDependenciesToReturn = [];
404421
$invertedUsedTraitDependenciesToReturn = [];
405-
$errors = $data['errorsCallback']();
406-
$locallyIgnoredErrors = $data['locallyIgnoredErrorsCallback']();
422+
$errors = $data['errors'];
423+
$locallyIgnoredErrors = $data['locallyIgnoredErrors'];
407424
$linesToIgnore = $data['linesToIgnore'];
408425
$unmatchedLineIgnores = $data['unmatchedLineIgnores'];
409-
$collectedData = $data['collectedDataCallback']();
410-
$exportedNodes = $data['exportedNodesCallback']();
426+
$collectedData = $data['collectedData'];
427+
$exportedNodes = $data['exportedNodes'];
411428
$filteredErrors = [];
412429
$filteredLocallyIgnoredErrors = [];
413430
$filteredLinesToIgnore = [];
@@ -1158,89 +1175,22 @@ private function save(
11581175

11591176
$file = $this->cacheFilePath;
11601177

1161-
// streamed to the file section by section - building the whole
1162-
// var_export()ed contents in memory at once would take up roughly
1163-
// twice the size of the resulting file in the main process
1164-
$handle = @fopen($file, 'w');
1165-
if ($handle === false) {
1166-
$error = error_get_last();
1167-
throw new CouldNotWriteFileException($file, $error !== null ? $error['message'] : 'unknown cause');
1168-
}
1169-
1170-
try {
1171-
$this->writeToHandle($handle, $file, "<?php declare(strict_types = 1);
1172-
1173-
return [
1174-
'lastFullAnalysisTime' => " . var_export($lastFullAnalysisTime, true) . ",
1175-
'meta' => " . var_export($meta, true) . ",
1176-
'projectExtensionFiles' => " . var_export($projectExtensionFiles, true) . ",
1177-
'errorsCallback' => static function (): array { return ");
1178-
$this->streamArrayVarExportToHandle($handle, $file, $errors);
1179-
$this->writeToHandle($handle, $file, "; },
1180-
'locallyIgnoredErrorsCallback' => static function (): array { return ");
1181-
$this->streamArrayVarExportToHandle($handle, $file, $locallyIgnoredErrors);
1182-
$this->writeToHandle($handle, $file, "; },
1183-
'linesToIgnore' => ");
1184-
$this->streamArrayVarExportToHandle($handle, $file, $linesToIgnore);
1185-
$this->writeToHandle($handle, $file, ",
1186-
'unmatchedLineIgnores' => ");
1187-
$this->streamArrayVarExportToHandle($handle, $file, $unmatchedLineIgnores);
1188-
$this->writeToHandle($handle, $file, ",
1189-
'collectedDataCallback' => static function (): array { return ");
1190-
$this->streamArrayVarExportToHandle($handle, $file, $collectedData);
1191-
$this->writeToHandle($handle, $file, "; },
1192-
'dependencies' => ");
1193-
$this->streamArrayVarExportToHandle($handle, $file, $invertedDependencies);
1194-
$this->writeToHandle($handle, $file, ",
1195-
'packageDependencies' => ");
1196-
$this->streamArrayVarExportToHandle($handle, $file, $packageDependencies);
1197-
$this->writeToHandle($handle, $file, ",
1198-
'exportedNodesCallback' => static function (): array { return ");
1199-
$this->streamArrayVarExportToHandle($handle, $file, $exportedNodes);
1200-
$this->writeToHandle($handle, $file, '; },
1201-
];
1202-
');
1203-
} finally {
1204-
fclose($handle);
1205-
}
1206-
}
1207-
1208-
/**
1209-
* @param resource $handle
1210-
*/
1211-
private function writeToHandle($handle, string $file, string $contents): void
1212-
{
1213-
if (@fwrite($handle, $contents) === false) {
1214-
$error = error_get_last();
1215-
throw new CouldNotWriteFileException($file, $error !== null ? $error['message'] : 'unknown cause');
1216-
}
1217-
}
1218-
1219-
/**
1220-
* Streams the var_export() representation of an array to the file entry
1221-
* by entry, producing output byte-identical to var_export($values, true).
1222-
*
1223-
* var_export() builds the whole export in memory even when told to print it,
1224-
* so exporting a big section in one call would take up as much memory
1225-
* as the resulting file section itself.
1226-
*
1227-
* Each entry is exported wrapped in a single-entry array whose "array (\n"
1228-
* prefix and "\n)" suffix are stripped, yielding the same bytes (including
1229-
* indentation) the entry would get inside the full export. Indenting the lines
1230-
* of a standalone value export would corrupt multi-line string contents instead.
1231-
*
1232-
* @param resource $handle
1233-
* @param array<mixed> $values
1234-
*/
1235-
private function streamArrayVarExportToHandle($handle, string $file, array $values): void
1236-
{
1237-
$this->writeToHandle($handle, $file, 'array (');
1238-
foreach ($values as $key => $value) {
1239-
$entry = var_export([$key => $value], true);
1240-
$this->writeToHandle($handle, $file, "\n" . substr($entry, 8, -2));
1241-
}
1242-
1243-
$this->writeToHandle($handle, $file, "\n)");
1178+
FileWriter::write(
1179+
$file,
1180+
self::SERIALIZED_FILE_PREFIX . serialize([
1181+
'lastFullAnalysisTime' => $lastFullAnalysisTime,
1182+
'meta' => $meta,
1183+
'projectExtensionFiles' => $projectExtensionFiles,
1184+
'errors' => $errors,
1185+
'locallyIgnoredErrors' => $locallyIgnoredErrors,
1186+
'linesToIgnore' => $linesToIgnore,
1187+
'unmatchedLineIgnores' => $unmatchedLineIgnores,
1188+
'collectedData' => $collectedData,
1189+
'dependencies' => $invertedDependencies,
1190+
'packageDependencies' => $packageDependencies,
1191+
'exportedNodes' => $exportedNodes,
1192+
]),
1193+
);
12441194
}
12451195

12461196
/**

0 commit comments

Comments
 (0)