Skip to content
Open
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
138 changes: 44 additions & 94 deletions src/Analyser/ResultCache/ResultCacheManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;

/**
Expand All @@ -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 = '<?php return; ?>';

/** @var array<string, string> */
private array $fileHashes = [];

Expand Down Expand Up @@ -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()));
Expand Down Expand Up @@ -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 = [];
Expand Down Expand Up @@ -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, "<?php declare(strict_types = 1);

return [
'lastFullAnalysisTime' => " . 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<mixed> $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,
]),
);
}

/**
Expand Down
Loading