From 78b51d29dd29ee8826b9bd5f114fc504eb0b2dde Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Fri, 4 Sep 2026 11:23:30 +0200 Subject: [PATCH] Read the cached collected data only when the rules on CollectedDataNode run restore() decoded the whole collectedData section into the main process before the analysis started. The section is the largest part of the cache on a project using collectors - a dead code detector stores every member usage in it, hundreds of megabytes on a large codebase - and only the rules that run on CollectedDataNode need it, after the analysis, in the main process. Decoding it up front kept it in memory through the whole run, and the forked workers inherited it. The array frames now carry the key in its own payload, so a reader can index the entries without decoding the values. readCacheFile() walks the collectedData section recording where each file's entry is, and restore() hands that index out as LazyCollectedData together with the fresh data of the run. save() copies the entries that stay valid byte for byte from the old file into the new one and records their new positions, so the finalizer reads them back from the file that exists after the rename. The section is decoded once, in AnalyserResultFinalizer, and lives as long as the CollectedDataNode. The file is opened for each read and closed again: an open handle would be inherited by the forked workers, and a rename over the file must not find it open on Windows. A read checks that the entry it finds belongs to the file it was indexed for, so a cache file replaced by another run fails loudly instead of handing out someone else's data. AnalysisResult takes the collected data as a closure too, so a formatter that never asks for it does not decode the section a second time. Measured on phpstan-src self-analysis with a dead code detector that keeps its usages in collected data (2467 files, 88 MB cache), main process: before after restore(), warm run 200 ms, +168 MB 175 ms, +74 MB partial run, 486 files 486 MB main 440 MB main 368 MB largest worker 276 MB largest worker A warm run that copies every entry writes a byte-identical cache file. The cache version is bumped because the entry framing changed. Amp-Thread-ID: https://ampcode.com/threads/T-01a19219-6260-7460-a52b-a418f709e4bf --- src/Analyser/Analyser.php | 3 +- src/Analyser/AnalyserResult.php | 11 +- src/Analyser/AnalyserResultFinalizer.php | 56 ++- .../ResultCache/LazyCollectedData.php | 107 +++++ src/Analyser/ResultCache/ResultCache.php | 10 +- .../ResultCache/ResultCacheManager.php | 402 ++++++++++++++---- .../ResultCache/ResultCacheProcessResult.php | 17 +- src/Command/AnalyseApplication.php | 22 +- src/Command/AnalyserRunner.php | 3 +- src/Command/AnalysisResult.php | 9 +- src/Command/FixerWorkerRunner.php | 3 +- src/Parallel/ParallelAnalyser.php | 3 +- .../Analyser/AnalyserResultFinalizerTest.php | 3 +- .../Analyser/CollectedDataRuleReuseTest.php | 3 +- .../ResultCache/LazyCollectedDataTest.php | 85 ++++ .../ResultCacheManagerCollectedDataTest.php | 325 ++++++++++++++ 16 files changed, 938 insertions(+), 124 deletions(-) create mode 100644 src/Analyser/ResultCache/LazyCollectedData.php create mode 100644 tests/PHPStan/Analyser/ResultCache/LazyCollectedDataTest.php create mode 100644 tests/PHPStan/Analyser/ResultCache/ResultCacheManagerCollectedDataTest.php diff --git a/src/Analyser/Analyser.php b/src/Analyser/Analyser.php index 2de73b41052..2f03f89768b 100644 --- a/src/Analyser/Analyser.php +++ b/src/Analyser/Analyser.php @@ -3,6 +3,7 @@ namespace PHPStan\Analyser; use Closure; +use PHPStan\Analyser\ResultCache\LazyCollectedData; use PHPStan\Collectors\CollectedData; use PHPStan\Collectors\Registry as CollectorRegistry; use PHPStan\DependencyInjection\AutowiredParameter; @@ -142,7 +143,7 @@ public function analyse( linesToIgnore: $linesToIgnore, unmatchedLineIgnores: $unmatchedLineIgnores, internalErrors: [], - collectedData: $collectedData, + collectedData: LazyCollectedData::fromArray($collectedData), dependencies: $internalErrorsCount === 0 ? $dependencies : null, usedTraitDependencies: $internalErrorsCount === 0 ? $usedTraitDependencies : null, valueDependencies: $internalErrorsCount === 0 ? $valueDependencies : null, diff --git a/src/Analyser/AnalyserResult.php b/src/Analyser/AnalyserResult.php index 4b814297f14..578f8d8ee03 100644 --- a/src/Analyser/AnalyserResult.php +++ b/src/Analyser/AnalyserResult.php @@ -2,6 +2,7 @@ namespace PHPStan\Analyser; +use PHPStan\Analyser\ResultCache\LazyCollectedData; use PHPStan\Collectors\CollectedData; use PHPStan\Dependency\RootExportedNode; use function usort; @@ -24,7 +25,6 @@ final class AnalyserResult * @param list $locallyIgnoredErrors * @param array $linesToIgnore * @param array $unmatchedLineIgnores - * @param CollectorData $collectedData * @param list $internalErrors * @param array>|null $dependencies * @param array>|null $usedTraitDependencies @@ -41,7 +41,7 @@ public function __construct( private array $linesToIgnore, private array $unmatchedLineIgnores, private array $internalErrors, - private array $collectedData, + private LazyCollectedData $collectedData, private ?array $dependencies, private ?array $usedTraitDependencies, private ?array $valueDependencies, @@ -145,9 +145,16 @@ public function getInternalErrors(): array } /** + * Decodes what came from the result cache; getLazyCollectedData() passes it on undecoded. + * * @return CollectorData */ public function getCollectedData(): array + { + return $this->collectedData->toArray(); + } + + public function getLazyCollectedData(): LazyCollectedData { return $this->collectedData; } diff --git a/src/Analyser/AnalyserResultFinalizer.php b/src/Analyser/AnalyserResultFinalizer.php index 265962f0c59..93826adf642 100644 --- a/src/Analyser/AnalyserResultFinalizer.php +++ b/src/Analyser/AnalyserResultFinalizer.php @@ -4,6 +4,7 @@ use Closure; use PHPStan\AnalysedCodeException; +use PHPStan\Analyser\ResultCache\LazyCollectedData; use PHPStan\BetterReflection\NodeCompiler\Exception\UnableToCompileNode; use PHPStan\BetterReflection\Reflection\Exception\CircularReference; use PHPStan\BetterReflection\Reflector\Exception\IdentifierNotFound; @@ -67,7 +68,7 @@ public function finalize( array $previousCollectedDataRuleResults = [], ): FinalizerResult { - if (count($analyserResult->getCollectedData()) === 0) { + if ($analyserResult->getLazyCollectedData()->isEmpty()) { return $this->addUnmatchedIgnoredErrors($this->mergeFilteredPhpErrors($analyserResult), [], []); } @@ -76,8 +77,17 @@ public function finalize( return $this->addUnmatchedIgnoredErrors($this->mergeFilteredPhpErrors($analyserResult), [], []); } + try { + $collectedData = $analyserResult->getCollectedData(); + } catch (Throwable $t) { + if ($debug) { + throw $t; + } + + return $this->addUnmatchedIgnoredErrors($this->mergeFilteredPhpErrors($this->withUnreadableCollectedData($analyserResult, $t)), [], []); + } + $nodeType = CollectedDataNode::class; - $collectedData = $analyserResult->getCollectedData(); $node = new CollectedDataNode($collectedData, $onlyFiles); // Hashed once per collector type and run, in the same order however the files were merged. @@ -223,7 +233,7 @@ public function finalize( linesToIgnore: $allLinesToIgnore, unmatchedLineIgnores: $allUnmatchedLineIgnores, internalErrors: $internalErrors, - collectedData: $analyserResult->getCollectedData(), + collectedData: $analyserResult->getLazyCollectedData(), dependencies: $analyserResult->getDependencies(), usedTraitDependencies: $analyserResult->getUsedTraitDependencies(), valueDependencies: $analyserResult->getValueDependencies(), @@ -319,6 +329,42 @@ private function addRuleErrors(array $errors, CollectedDataNode $node, Scope $sc } } + /** + * The result cache was discarded by the reader, so the run reports the failure and the collector + * rules do not run; the next run analyses everything and starts a new cache. + */ + private function withUnreadableCollectedData(AnalyserResult $analyserResult, Throwable $t): AnalyserResult + { + $internalErrors = $analyserResult->getInternalErrors(); + $internalErrors[] = new InternalError( + $t->getMessage(), + 'reading the collected data from the result cache', + InternalError::prepareTrace($t), + $t->getTraceAsString(), + shouldReportBug: false, + ); + + return new AnalyserResult( + unorderedErrors: $analyserResult->getUnorderedErrors(), + filteredPhpErrors: $analyserResult->getFilteredPhpErrors(), + allPhpErrors: $analyserResult->getAllPhpErrors(), + locallyIgnoredErrors: $analyserResult->getLocallyIgnoredErrors(), + linesToIgnore: $analyserResult->getLinesToIgnore(), + unmatchedLineIgnores: $analyserResult->getUnmatchedLineIgnores(), + internalErrors: $internalErrors, + collectedData: LazyCollectedData::fromArray([]), + dependencies: $analyserResult->getDependencies(), + usedTraitDependencies: $analyserResult->getUsedTraitDependencies(), + valueDependencies: $analyserResult->getValueDependencies(), + packageDependencies: $analyserResult->getPackageDependencies(), + exportedNodes: $analyserResult->getExportedNodes(), + reachedInternalErrorsCountLimit: $analyserResult->hasReachedInternalErrorsCountLimit(), + peakMemoryUsageBytes: $analyserResult->getPeakMemoryUsageBytes(), + processedFiles: $analyserResult->getProcessedFiles(), + workerCount: $analyserResult->getWorkerCount(), + ); + } + private function mergeFilteredPhpErrors(AnalyserResult $analyserResult): AnalyserResult { return new AnalyserResult( @@ -329,7 +375,7 @@ private function mergeFilteredPhpErrors(AnalyserResult $analyserResult): Analyse linesToIgnore: $analyserResult->getLinesToIgnore(), unmatchedLineIgnores: $analyserResult->getUnmatchedLineIgnores(), internalErrors: $analyserResult->getInternalErrors(), - collectedData: $analyserResult->getCollectedData(), + collectedData: $analyserResult->getLazyCollectedData(), dependencies: $analyserResult->getDependencies(), usedTraitDependencies: $analyserResult->getUsedTraitDependencies(), valueDependencies: $analyserResult->getValueDependencies(), @@ -400,7 +446,7 @@ private function addUnmatchedIgnoredErrors( linesToIgnore: $analyserResult->getLinesToIgnore(), unmatchedLineIgnores: $analyserResult->getUnmatchedLineIgnores(), internalErrors: $analyserResult->getInternalErrors(), - collectedData: $analyserResult->getCollectedData(), + collectedData: $analyserResult->getLazyCollectedData(), dependencies: $analyserResult->getDependencies(), usedTraitDependencies: $analyserResult->getUsedTraitDependencies(), valueDependencies: $analyserResult->getValueDependencies(), diff --git a/src/Analyser/ResultCache/LazyCollectedData.php b/src/Analyser/ResultCache/LazyCollectedData.php new file mode 100644 index 00000000000..b7c17635c71 --- /dev/null +++ b/src/Analyser/ResultCache/LazyCollectedData.php @@ -0,0 +1,107 @@ + $cachedIndex analysed file => [offset, length] of its entry in the cache file + * @param Closure(array): CollectorData $cachedReader + * @param CollectorData $fresh + */ + public function __construct( + private array $cachedIndex, + private Closure $cachedReader, + private array $fresh, + ) + { + } + + /** + * @param CollectorData $data + */ + public static function fromArray(array $data): self + { + return new self([], static fn (): array => [], $data); + } + + /** + * Editor mode analyses a temporary copy of a file and reports it under the file's own path. Only + * the fresh side can hold the temporary path: ResultCacheManager::mergeCollectedData() takes both + * paths out of the cached index, and a cached entry is verified against its file on reading, so it + * could not be renamed anyway. + */ + public function withRenamedFile(string $from, string $to): self + { + if (array_key_exists($from, $this->cachedIndex)) { + throw new ShouldNotHappenException(); + } + + $fresh = []; + foreach ($this->fresh as $file => $data) { + $fresh[$file === $from ? $to : $file] = $data; + } + + return new self($this->cachedIndex, $this->cachedReader, $fresh); + } + + /** + * @return array + */ + public function getCachedIndex(): array + { + return $this->cachedIndex; + } + + /** + * @return CollectorData + */ + public function getFresh(): array + { + return $this->fresh; + } + + public function isEmpty(): bool + { + return count($this->cachedIndex) === 0 && count($this->fresh) === 0; + } + + /** + * Cached entries first, then the fresh ones; a fresh entry replaces a cached one for the same + * file. Reads the cache file every time: nothing is kept, so the caller decides how long the + * decoded data lives. + * + * @return CollectorData + */ + public function toArray(): array + { + if (count($this->cachedIndex) === 0) { + return $this->fresh; + } + + $result = ($this->cachedReader)($this->cachedIndex); + foreach ($this->fresh as $file => $data) { + $result[$file] = $data; + } + + return $result; + } + +} diff --git a/src/Analyser/ResultCache/ResultCache.php b/src/Analyser/ResultCache/ResultCache.php index f95a010c555..67ed2712887 100644 --- a/src/Analyser/ResultCache/ResultCache.php +++ b/src/Analyser/ResultCache/ResultCache.php @@ -5,12 +5,10 @@ use PHPStan\Analyser\CollectedDataRuleResult; use PHPStan\Analyser\Error; use PHPStan\Analyser\FileAnalyserResult; -use PHPStan\Collectors\CollectedData; use PHPStan\Dependency\RootExportedNode; /** * @phpstan-import-type LinesToIgnore from FileAnalyserResult - * @phpstan-import-type CollectorData from CollectedData */ final class ResultCache { @@ -22,7 +20,6 @@ final class ResultCache * @param array> $locallyIgnoredErrors * @param array $linesToIgnore * @param array $unmatchedLineIgnores - * @param CollectorData $collectedData * @param array> $dependencies * @param array> $usedTraitDependencies * @param array, list}> $valueDependencies id => [extension class, key, current value, analysis dependents, declarations dependents] - see ValueDependencyCollector @@ -43,7 +40,7 @@ public function __construct( private array $locallyIgnoredErrors, private array $linesToIgnore, private array $unmatchedLineIgnores, - private array $collectedData, + private LazyCollectedData $collectedData, private array $dependencies, private array $usedTraitDependencies, private array $valueDependencies, @@ -125,10 +122,7 @@ public function getUnmatchedLineIgnores(): array return $this->unmatchedLineIgnores; } - /** - * @return CollectorData - */ - public function getCollectedData(): array + public function getCollectedData(): LazyCollectedData { return $this->collectedData; } diff --git a/src/Analyser/ResultCache/ResultCacheManager.php b/src/Analyser/ResultCache/ResultCacheManager.php index 375799e8605..f43162ea10c 100644 --- a/src/Analyser/ResultCache/ResultCacheManager.php +++ b/src/Analyser/ResultCache/ResultCacheManager.php @@ -2,6 +2,7 @@ namespace PHPStan\Analyser\ResultCache; +use Closure; use Nette\Neon\Neon; use PHPStan\Analyser\AnalyserResult; use PHPStan\Analyser\CollectedDataRuleResult; @@ -80,9 +81,11 @@ use function stat; use function str_ends_with; use function str_starts_with; +use function stream_copy_to_stream; use function strlen; use function substr; use function time; +use function uasort; use function uniqid; use function unlink; use function unserialize; @@ -108,7 +111,7 @@ final class ResultCacheManager */ private const EXTENSIONS_NOT_INVALIDATING_CACHE = ['xdebug', 'blackfire', 'phpstan_turbo']; - private const CACHE_VERSION = 'v24-filesWithCollectedDataRuleErrors'; + private const CACHE_VERSION = 'v25-lazyCollectedData'; /** * The recorded hash of a dependency that does not exist. A rule can depend on a path rather than on @@ -153,12 +156,12 @@ final class ResultCacheManager * remember where it starts and walk past its entries. * * Nothing closure-shaped is written: a frame holds the plain serialized payload - * (`a:1:{s:9:"file.php";a:1:{i:0;O:22:"PHPStan\Analyser\Error"...`), and readCacheFile() builds - * the callback in PHP around the open handle and that offset, which is only the shape restore() - * expects. The var_export format writes a real `static function (): array` into the file instead, - * and PHP still compiles the array literal inside it. + * (`s:8:"file.php";` followed by `a:1:{i:0;O:22:"PHPStan\Analyser\Error"...`), and + * readCacheFile() builds the callback in PHP around the open handle and that offset, which is only + * the shape restore() expects. The var_export format writes a real `static function (): array` + * into the file instead, and PHP still compiles the array literal inside it. */ - private const LAZY_SECTIONS = ['errors', 'locallyIgnoredErrors', 'collectedData', 'exportedNodes']; + private const LAZY_SECTIONS = ['errors', 'locallyIgnoredErrors', 'exportedNodes']; /** @var array */ private array $fileHashes = []; @@ -285,7 +288,7 @@ private function fullAnalysis( locallyIgnoredErrors: [], linesToIgnore: [], unmatchedLineIgnores: [], - collectedData: [], + collectedData: LazyCollectedData::fromArray([]), dependencies: [], usedTraitDependencies: [], valueDependencies: [], @@ -416,13 +419,12 @@ public function restore(array $allAnalysedFiles, bool $debug, bool $onlyFiles, ? $output, ); } + $data['collectedDataIndex'] = $transformer->absolutizeFileKeyed($data['collectedDataIndex']); $errorsCallback = $data['errorsCallback']; $data['errorsCallback'] = static fn (): array => $transformer->absolutizeErrors($errorsCallback()); $locallyIgnoredErrorsCallback = $data['locallyIgnoredErrorsCallback']; $data['locallyIgnoredErrorsCallback'] = static fn (): array => $transformer->absolutizeErrors($locallyIgnoredErrorsCallback()); - $collectedDataCallback = $data['collectedDataCallback']; - $data['collectedDataCallback'] = static fn (): array => $transformer->absolutizeCollectedData($collectedDataCallback()); $exportedNodesCallback = $data['exportedNodesCallback']; $data['exportedNodesCallback'] = static fn (): array => $transformer->absolutizeFileKeyed($exportedNodesCallback()); @@ -696,6 +698,7 @@ public function restore(array $allAnalysedFiles, bool $debug, bool $onlyFiles, ? $invertedUsedTraitDependenciesToReturn = []; $linesToIgnore = $data['linesToIgnore']; $unmatchedLineIgnores = $data['unmatchedLineIgnores']; + $collectedDataIndex = $data['collectedDataIndex']; try { // The cached objects are reconstructed here, and a cache written by a PHPStan whose classes @@ -706,7 +709,6 @@ public function restore(array $allAnalysedFiles, bool $debug, bool $onlyFiles, ? // that cannot be reconstructed is discarded like any other unusable one. $errors = $data['errorsCallback'](); $locallyIgnoredErrors = $data['locallyIgnoredErrorsCallback'](); - $collectedData = $data['collectedDataCallback'](); $exportedNodes = $data['exportedNodesCallback'](); $collectedDataRuleResults = $data['collectedDataRuleResults'] ?? []; if (!is_array($collectedDataRuleResults)) { @@ -745,7 +747,7 @@ public function restore(array $allAnalysedFiles, bool $debug, bool $onlyFiles, ? $filteredLocallyIgnoredErrors = []; $filteredLinesToIgnore = []; $filteredUnmatchedLineIgnores = []; - $filteredCollectedData = []; + $filteredCollectedDataIndex = []; $filteredExportedNodes = []; // the analysed and scanned files whose exported nodes stay in the old cache file until save() $keptCachedExportedNodes = []; @@ -826,8 +828,8 @@ public function restore(array $allAnalysedFiles, bool $debug, bool $onlyFiles, ? if (array_key_exists($analysedFile, $unmatchedLineIgnores)) { $filteredUnmatchedLineIgnores[$analysedFile] = $unmatchedLineIgnores[$analysedFile]; } - if (array_key_exists($analysedFile, $collectedData)) { - $filteredCollectedData[$analysedFile] = $collectedData[$analysedFile]; + if (array_key_exists($analysedFile, $collectedDataIndex)) { + $filteredCollectedDataIndex[$analysedFile] = $collectedDataIndex[$analysedFile]; } if (array_key_exists($analysedFile, $exportedNodes)) { $filteredExportedNodes[$analysedFile] = $exportedNodes[$analysedFile]; @@ -1113,7 +1115,7 @@ public function restore(array $allAnalysedFiles, bool $debug, bool $onlyFiles, ? $filteredLocallyIgnoredErrors[$fileToAnalyse], $filteredLinesToIgnore[$fileToAnalyse], $filteredUnmatchedLineIgnores[$fileToAnalyse], - $filteredCollectedData[$fileToAnalyse], + $filteredCollectedDataIndex[$fileToAnalyse], $filteredExportedNodes[$fileToAnalyse], ); } @@ -1128,7 +1130,7 @@ public function restore(array $allAnalysedFiles, bool $debug, bool $onlyFiles, ? locallyIgnoredErrors: $filteredLocallyIgnoredErrors, linesToIgnore: $filteredLinesToIgnore, unmatchedLineIgnores: $filteredUnmatchedLineIgnores, - collectedData: $filteredCollectedData, + collectedData: new LazyCollectedData($filteredCollectedDataIndex, $this->createCollectedDataReader(), []), dependencies: $invertedDependenciesToReturn, usedTraitDependencies: $invertedUsedTraitDependenciesToReturn, valueDependencies: $valueDependenciesToReturn, @@ -1277,7 +1279,7 @@ public function process(AnalyserResult $analyserResult, ResultCache $resultCache $freshLocallyIgnoredErrorsByFile[$error->getFilePath()][] = $error; } - $freshCollectedDataByFile = $analyserResult->getCollectedData(); + $freshCollectedData = $analyserResult->getLazyCollectedData(); $meta = $resultCache->getMeta(); $projectConfigArray = $meta['projectConfig']; @@ -1285,50 +1287,52 @@ public function process(AnalyserResult $analyserResult, ResultCache $resultCache $projectConfigArray = $this->getPathTransformer()->relativizeProjectConfig($projectConfigArray); $meta['projectConfig'] = Neon::encode($projectConfigArray); } - $doSave = function (array $errorsByFile, $locallyIgnoredErrorsByFile, $linesToIgnore, $unmatchedLineIgnores, $collectedDataByFile, ?array $dependencies, ?array $usedTraitDependencies, ?array $valueDependencies, ?array $packageDependencies, array $exportedNodes, CachedExportedNodes $cachedExportedNodes, array $projectExtensionFiles, FinalizerResult $finalizerResult) use ($internalErrors, $resultCache, $output, $onlyFiles, $meta): bool { + // Returns the collected data as it can be read back from the saved file, or null when nothing + // was saved. + $doSave = function (array $errorsByFile, $locallyIgnoredErrorsByFile, $linesToIgnore, $unmatchedLineIgnores, LazyCollectedData $collectedData, ?array $dependencies, ?array $usedTraitDependencies, ?array $valueDependencies, ?array $packageDependencies, array $exportedNodes, CachedExportedNodes $cachedExportedNodes, array $projectExtensionFiles, FinalizerResult $finalizerResult) use ($internalErrors, $resultCache, $output, $onlyFiles, $meta): ?LazyCollectedData { if ($onlyFiles) { if ($output->isVeryVerbose()) { $output->writeLineFormatted('Result cache was not saved because only files were passed as analysed paths.'); } - return false; + return null; } if ($dependencies === null) { if ($output->isVeryVerbose()) { $output->writeLineFormatted('Result cache was not saved because of error in dependencies.'); } - return false; + return null; } if ($usedTraitDependencies === null) { if ($output->isVeryVerbose()) { $output->writeLineFormatted('Result cache was not saved because of error in used trait dependencies.'); } - return false; + return null; } if ($packageDependencies === null) { if ($output->isVeryVerbose()) { $output->writeLineFormatted('Result cache was not saved because of error in package dependencies.'); } - return false; + return null; } if ($valueDependencies === null) { if ($output->isVeryVerbose()) { $output->writeLineFormatted('Result cache was not saved because of error in value dependencies.'); } - return false; + return null; } if (count($internalErrors) > 0) { if ($output->isVeryVerbose()) { $output->writeLineFormatted('Result cache was not saved because of internal errors.'); } - return false; + return null; } if (count($this->fileReplacements) > 0) { if ($output->isVeryVerbose()) { $output->writeLineFormatted('Result cache was not saved because of --tmp-file and --instead-of CLI options passed (editor mode).'); } - return false; + return null; } // A parse error describes the analysed code, so the result cache is still saved. The file's @@ -1352,7 +1356,7 @@ public function process(AnalyserResult $analyserResult, ResultCache $resultCache $output->writeLineFormatted(sprintf('Result cache was not saved because of non-ignorable exception: %s', $error->getMessage())); } - return false; + return null; } if (array_key_exists($file, $filesToReanalyse)) { @@ -1390,7 +1394,8 @@ public function process(AnalyserResult $analyserResult, ResultCache $resultCache && $locallyIgnoredErrorsByFile === $resultCache->getLocallyIgnoredErrors() && $linesToIgnore === $resultCache->getLinesToIgnore() && $unmatchedLineIgnores === $resultCache->getUnmatchedLineIgnores() - && $collectedDataByFile === $resultCache->getCollectedData() + && $collectedData->getFresh() === [] + && $collectedData->getCachedIndex() === $resultCache->getCollectedData()->getCachedIndex() && $collectedDataRuleResults === $resultCache->getCollectedDataRuleResults() && $filesWithCollectedDataRuleErrors === $resultCache->getFilesWithCollectedDataRuleErrors() && $packageDependencies === $resultCache->getPackageDependencies() @@ -1404,30 +1409,41 @@ public function process(AnalyserResult $analyserResult, ResultCache $resultCache $output->writeLineFormatted('Result cache was not rewritten because it is unchanged.'); } - return true; + return $collectedData; } foreach (array_keys($filesToReanalyse) as $file) { $currentFileHashes[$file] = self::REANALYSE_FILE_HASH_PREFIX . $currentFileHashes[$file]; } - $this->save($resultCache->getLastFullAnalysisTime(), $errorsByFile, $locallyIgnoredErrorsByFile, $linesToIgnore, $unmatchedLineIgnores, $collectedDataByFile, $dependencies, $usedTraitDependencies, $valueDependencies, $packageDependencies, $exportedNodes, $cachedExportedNodes, $projectExtensionFiles, $currentFileHashes, $meta, $stubFiles, $collectedDataRuleResults, $filesWithCollectedDataRuleErrors); + try { + $savedCollectedData = $this->save($resultCache->getLastFullAnalysisTime(), $errorsByFile, $locallyIgnoredErrorsByFile, $linesToIgnore, $unmatchedLineIgnores, $collectedData, $dependencies, $usedTraitDependencies, $valueDependencies, $packageDependencies, $exportedNodes, $cachedExportedNodes, $projectExtensionFiles, $currentFileHashes, $meta, $stubFiles, $collectedDataRuleResults, $filesWithCollectedDataRuleErrors); + } catch (RuntimeException $e) { + // Only the copy of the cached collected data throws this: the file it was restored from + // no longer holds the entries where the index says, so another run sharing the tmpDir + // replaced it. Its cache stays; the rules on CollectedDataNode discard it when they find + // the same, and the next run analyses everything. + if ($output->isVeryVerbose()) { + $output->writeLineFormatted(sprintf('Result cache was not saved because the previous cache file changed during the analysis: %s', $e->getMessage())); + } + return null; + } if ($output->isVeryVerbose()) { $output->writeLineFormatted('Result cache is saved.'); } - return true; + return $savedCollectedData; }; if ($resultCache->isFullAnalysis()) { - return new ResultCacheProcessResult($analyserResult, function (FinalizerResult $finalizerResult) use ($save, $output, $analyserResult, $projectConfigArray, $doSave, $freshErrorsByFile, $freshLocallyIgnoredErrorsByFile, $freshCollectedDataByFile): bool { + return new ResultCacheProcessResult($analyserResult, function (FinalizerResult $finalizerResult) use ($save, $output, $analyserResult, $projectConfigArray, $doSave, $freshErrorsByFile, $freshLocallyIgnoredErrorsByFile, $freshCollectedData): ?LazyCollectedData { if ($save === false) { if ($output->isVeryVerbose()) { $output->writeLineFormatted('Result cache was not saved because it was not requested.'); } - return false; + return null; } $projectExtensionFiles = []; @@ -1435,13 +1451,13 @@ public function process(AnalyserResult $analyserResult, ResultCache $resultCache $projectExtensionFiles = $this->getProjectExtensionFiles($projectConfigArray, $analyserResult->getDependencies()); } - return $doSave($freshErrorsByFile, $freshLocallyIgnoredErrorsByFile, $analyserResult->getLinesToIgnore(), $analyserResult->getUnmatchedLineIgnores(), $freshCollectedDataByFile, $analyserResult->getDependencies(), $analyserResult->getUsedTraitDependencies(), $analyserResult->getValueDependencies(), $analyserResult->getPackageDependencies(), $this->addNonAnalysedExportedNodes($analyserResult->getExportedNodes(), $analyserResult->getDependencies(), $analyserResult->getUsedTraitDependencies(), CachedExportedNodes::createEmpty()), CachedExportedNodes::createEmpty(), $projectExtensionFiles, $finalizerResult); + return $doSave($freshErrorsByFile, $freshLocallyIgnoredErrorsByFile, $analyserResult->getLinesToIgnore(), $analyserResult->getUnmatchedLineIgnores(), $freshCollectedData, $analyserResult->getDependencies(), $analyserResult->getUsedTraitDependencies(), $analyserResult->getValueDependencies(), $analyserResult->getPackageDependencies(), $this->addNonAnalysedExportedNodes($analyserResult->getExportedNodes(), $analyserResult->getDependencies(), $analyserResult->getUsedTraitDependencies(), CachedExportedNodes::createEmpty()), CachedExportedNodes::createEmpty(), $projectExtensionFiles, $finalizerResult); }); } $errorsByFile = $this->mergeErrors($resultCache, $freshErrorsByFile); $locallyIgnoredErrorsByFile = $this->mergeLocallyIgnoredErrors($resultCache, $freshLocallyIgnoredErrorsByFile); - $collectedDataByFile = $this->mergeCollectedData($resultCache, $freshCollectedDataByFile); + $collectedData = $this->mergeCollectedData($resultCache, $freshCollectedData->getFresh()); $dependencies = $this->mergeDependencies($resultCache->getDependencies(), $resultCache->getFilesToAnalyse(), $analyserResult->getDependencies()); $usedTraitDependencies = $this->mergeDependencies($resultCache->getUsedTraitDependencies(), $resultCache->getFilesToAnalyse(), $analyserResult->getUsedTraitDependencies()); $valueDependencies = $this->mergeValueDependencies($resultCache->getValueDependencies(), $resultCache->getFilesToAnalyse(), $analyserResult->getValueDependencies()); @@ -1452,9 +1468,9 @@ public function process(AnalyserResult $analyserResult, ResultCache $resultCache $linesToIgnore = $this->mergeLinesToIgnore($resultCache, $analyserResult->getLinesToIgnore()); $unmatchedLineIgnores = $this->mergeUnmatchedLineIgnores($resultCache, $analyserResult->getUnmatchedLineIgnores()); - $saveCallback = function (FinalizerResult $finalizerResult) use ($save, $resultCache, $projectConfigArray, $doSave, $errorsByFile, $locallyIgnoredErrorsByFile, $linesToIgnore, $unmatchedLineIgnores, $collectedDataByFile, $dependencies, $usedTraitDependencies, $valueDependencies, $packageDependencies, $exportedNodes, $cachedExportedNodes): bool { + $saveCallback = function (FinalizerResult $finalizerResult) use ($save, $resultCache, $projectConfigArray, $doSave, $errorsByFile, $locallyIgnoredErrorsByFile, $linesToIgnore, $unmatchedLineIgnores, $collectedData, $dependencies, $usedTraitDependencies, $valueDependencies, $packageDependencies, $exportedNodes, $cachedExportedNodes): ?LazyCollectedData { if ($save === false) { - return false; + return null; } $projectExtensionFiles = []; @@ -1477,7 +1493,7 @@ public function process(AnalyserResult $analyserResult, ResultCache $resultCache $projectExtensionFiles[$file] = [$hash, true, $className]; } } - return $doSave($errorsByFile, $locallyIgnoredErrorsByFile, $linesToIgnore, $unmatchedLineIgnores, $collectedDataByFile, $dependencies, $usedTraitDependencies, $valueDependencies, $packageDependencies, $exportedNodes, $cachedExportedNodes, $projectExtensionFiles, $finalizerResult); + return $doSave($errorsByFile, $locallyIgnoredErrorsByFile, $linesToIgnore, $unmatchedLineIgnores, $collectedData, $dependencies, $usedTraitDependencies, $valueDependencies, $packageDependencies, $exportedNodes, $cachedExportedNodes, $projectExtensionFiles, $finalizerResult); }; $flatErrors = []; @@ -1502,7 +1518,7 @@ public function process(AnalyserResult $analyserResult, ResultCache $resultCache linesToIgnore: $linesToIgnore, unmatchedLineIgnores: $unmatchedLineIgnores, internalErrors: $internalErrors, - collectedData: $collectedDataByFile, + collectedData: $collectedData, dependencies: $dependencies, usedTraitDependencies: $usedTraitDependencies, valueDependencies: $valueDependencies, @@ -1561,24 +1577,24 @@ private function mergeLocallyIgnoredErrors(ResultCache $resultCache, array $fres /** * @param CollectorData $freshCollectedDataByFile - * @return CollectorData */ - private function mergeCollectedData(ResultCache $resultCache, array $freshCollectedDataByFile): array + private function mergeCollectedData(ResultCache $resultCache, array $freshCollectedDataByFile): LazyCollectedData { - $collectedDataByFile = $resultCache->getCollectedData(); + $cachedIndex = $resultCache->getCollectedData()->getCachedIndex(); + $mergedFresh = []; foreach ($resultCache->getFilesToAnalyse() as $file) { if (array_key_exists($file, $this->fileReplacements)) { - unset($collectedDataByFile[$file]); + unset($cachedIndex[$file]); $file = $this->fileReplacements[$file]; } + unset($cachedIndex[$file]); if (!array_key_exists($file, $freshCollectedDataByFile)) { - unset($collectedDataByFile[$file]); continue; } - $collectedDataByFile[$file] = $freshCollectedDataByFile[$file]; + $mergedFresh[$file] = $freshCollectedDataByFile[$file]; } - return $collectedDataByFile; + return new LazyCollectedData($cachedIndex, $this->createCollectedDataReader(), $mergedFresh); } /** @@ -1926,7 +1942,6 @@ private function mergeUnmatchedLineIgnores(ResultCache $resultCache, array $fres * @param array> $locallyIgnoredErrors * @param array $linesToIgnore * @param array $unmatchedLineIgnores - * @param CollectorData $collectedData * @param array> $dependencies * @param array> $usedTraitDependencies * @param ValueDependencies $valueDependencies @@ -1938,6 +1953,7 @@ private function mergeUnmatchedLineIgnores(ResultCache $resultCache, array $fres * @param array $stubFiles * @param array $collectedDataRuleResults * @param list $filesWithCollectedDataRuleErrors + * @return LazyCollectedData The same collected data, with the cached entries indexed in the saved file */ private function save( int $lastFullAnalysisTime, @@ -1945,7 +1961,7 @@ private function save( array $locallyIgnoredErrors, array $linesToIgnore, array $unmatchedLineIgnores, - array $collectedData, + LazyCollectedData $collectedData, array $dependencies, array $usedTraitDependencies, array $valueDependencies, @@ -1958,7 +1974,7 @@ private function save( array $stubFiles, array $collectedDataRuleResults, array $filesWithCollectedDataRuleErrors, - ): void + ): LazyCollectedData { $invertedDependencies = []; $filesNoOneIsDependingOn = array_fill_keys(array_keys($dependencies), true); @@ -1998,13 +2014,8 @@ private function save( ksort($locallyIgnoredErrors); ksort($linesToIgnore); ksort($unmatchedLineIgnores); - ksort($collectedData); ksort($invertedDependencies); - foreach ($collectedData as & $collectedDataPerFile) { - ksort($collectedDataPerFile); - } - foreach ($invertedDependencies as $file => $fileData) { $dependentFiles = $fileData['dependentFiles']; sort($dependentFiles); @@ -2037,7 +2048,6 @@ private function save( $locallyIgnoredErrors = $transformer->relativizeErrors($locallyIgnoredErrors); $linesToIgnore = $transformer->relativizeCompoundKeyed($linesToIgnore); $unmatchedLineIgnores = $transformer->relativizeCompoundKeyed($unmatchedLineIgnores); - $collectedData = $transformer->relativizeCollectedData($collectedData); $dependencyGraph = $this->encodeDependencyGraph($invertedDependencies, $transformer); $encodedValueDependencies = $this->encodeValueDependencies($valueDependencies, $transformer); unset($invertedDependencies); @@ -2080,7 +2090,7 @@ private function save( $this->writeArrayFrame($handle, $file, 'locallyIgnoredErrors', $locallyIgnoredErrors); $this->writeArrayFrame($handle, $file, 'linesToIgnore', $linesToIgnore); $this->writeArrayFrame($handle, $file, 'unmatchedLineIgnores', $unmatchedLineIgnores); - $this->writeArrayFrame($handle, $file, 'collectedData', $collectedData); + $savedCollectedDataIndex = $this->writeCollectedDataFrame($handle, $file, $collectedData); $this->writeValueFrame($handle, $file, 'collectedDataRuleResults', $collectedDataRuleResults); $this->writeValueFrame($handle, $file, 'filesWithCollectedDataRuleErrors', $filesWithCollectedDataRuleErrors); // An older PHPStan reading this file absolutizes the dependencies before it gets to the @@ -2112,6 +2122,63 @@ private function save( @unlink($temporaryFile); } } + + return new LazyCollectedData($savedCollectedDataIndex, $this->createCollectedDataReader(), $collectedData->getFresh()); + } + + /** + * The collected data section: the cached entries are copied byte for byte from the file they + * were restored from, the fresh ones are serialized. Written in the order of the file paths, as + * every other section is, so the file does not depend on which files were re-analysed. + * + * @param resource $handle + * @return array Where every copied entry is in the new file + */ + private function writeCollectedDataFrame($handle, string $file, LazyCollectedData $collectedData): array + { + $transformer = $this->getPathTransformer(); + $cachedIndex = $collectedData->getCachedIndex(); + $fresh = $collectedData->getFresh(); + $files = array_keys($cachedIndex + $fresh); + sort($files); + $this->writeToHandle($handle, $file, 'collectedData* ' . count($files) . "\n"); + + $sourceHandle = null; + $savedIndex = []; + foreach ($files as $analysedFile) { + if (array_key_exists($analysedFile, $fresh)) { + $collectedDataPerFile = $fresh[$analysedFile]; + ksort($collectedDataPerFile); + foreach ($transformer->relativizeCollectedData([$analysedFile => $collectedDataPerFile]) as $relativeFile => $data) { + $this->writeEntryFrame($handle, $file, $relativeFile, $data); + } + + continue; + } + + $sourceHandle ??= $this->openCacheFile(); + $position = ftell($handle); + if ($position === false) { + throw new CouldNotWriteFileException($file, 'cannot tell the position in the file'); + } + + // Nothing holds the file open between restore() and here, so another run sharing the tmpDir + // may have renamed a different cache over it. The key at the offset says whether the bytes + // about to be copied are still the entry the index was built for. + [$offset, $length] = $cachedIndex[$analysedFile]; + $this->seekToEntry($sourceHandle, $offset, $analysedFile); + if (stream_copy_to_stream($sourceHandle, $handle, $length, $offset) !== $length) { + throw new RuntimeException(sprintf('The result cache file %s changed while it was being read: the entry of %s is not where it was.', $this->cacheFilePath, $analysedFile)); + } + + $savedIndex[$analysedFile] = [$position, $length]; + } + + if ($sourceHandle !== null) { + fclose($sourceHandle); + } + + return $savedIndex; } /** @@ -2318,10 +2385,7 @@ private function writeValueFrame($handle, string $file, string $name, mixed $val } /** - * An array, as `name* count\n` followed by one length-prefixed frame per entry. - * - * Each entry is serialized as a single-element array so its key travels with it, which keeps string - * and integer keys distinct without a second frame for the key. + * An array, as `name* count\n` followed by one entry frame per element. * * @param resource $handle * @param array $values @@ -2330,12 +2394,26 @@ private function writeArrayFrame($handle, string $file, string $name, array $val { $this->writeToHandle($handle, $file, $name . '* ' . count($values) . "\n"); foreach ($values as $key => $value) { - $blob = serialize([$key => $value]); - $this->writeToHandle($handle, $file, strlen($blob) . "\n"); - $this->writeToHandle($handle, $file, $blob); + $this->writeEntryFrame($handle, $file, $key, $value); } } + /** + * One element of an array frame: `keyLength valueLength\n`, the serialized key, the serialized + * value. The key has its own payload so a reader can index the entries without decoding the + * values, and serializing it keeps string and integer keys distinct. + * + * @param resource $handle + */ + private function writeEntryFrame($handle, string $file, int|string $key, mixed $value): void + { + $keyBlob = serialize($key); + $valueBlob = serialize($value); + $this->writeToHandle($handle, $file, strlen($keyBlob) . ' ' . strlen($valueBlob) . "\n"); + $this->writeToHandle($handle, $file, $keyBlob); + $this->writeToHandle($handle, $file, $valueBlob); + } + /** * Read a framed cache file back, one frame at a time. * @@ -2363,6 +2441,7 @@ private function readCacheFile(string $cacheFilePath): ?array $data = []; $lazy = array_fill_keys(self::LAZY_SECTIONS, false); + $data['collectedDataIndex'] = []; while (($header = fgets($handle)) !== false) { $header = rtrim($header, "\n"); if ($header === '') { @@ -2416,6 +2495,16 @@ private function readCacheFile(string $cacheFilePath): ?array $name = substr($name, 0, -1); $count = (int) $size; + // Never decoded as a whole: it is by far the largest section on a project using collectors, + // and only the rules on CollectedDataNode need it - after the analysis, in the main process. + // restore() hands the index out as LazyCollectedData, which reads the entries on demand, + // and save() copies the ones that stay valid from this file byte for byte. + if ($name === 'collectedData') { + $data['collectedDataIndex'] = $this->indexEntryFrames($handle, $count, $fileSize, $name); + + continue; + } + if (!array_key_exists($name, $lazy)) { $data[$name] = $this->readEntryFrames($handle, $count); @@ -2455,27 +2544,62 @@ private function readCacheFile(string $cacheFilePath): ?array } /** - * Walks past an array frame's entries without unserializing them, checking as it goes that the - * file really holds them. + * Walks past an array frame's entries decoding only their keys, checking as it goes that the + * file really holds them. Returns where each entry is: the offset of its header line and the + * length up to the end of its value, which is what stream_copy_to_stream() needs to carry the + * entry over to the next cache file unchanged. * * @param resource $handle + * @return array */ - private function skipEntryFrames($handle, int $count, int $fileSize, string $name): void + private function indexEntryFrames($handle, int $count, int $fileSize, string $name): array { + $offset = ftell($handle); + if ($offset === false) { + throw new RuntimeException(sprintf('Cannot tell the position of section "%s".', $name)); + } + + $index = []; for ($i = 0; $i < $count; $i++) { - $length = fgets($handle); - if ($length === false) { - throw new RuntimeException(sprintf('Section "%s" ended after %d of %d entries.', $name, $i, $count)); + [$key, $valueLength] = $this->readEntryKey($handle); + // The sections are keyed by file path. restore() absolutizes the keys outside the guard that + // turns a damaged file into a full analysis, so anything else has to be refused here. + if (!is_string($key)) { + throw new RuntimeException(sprintf('Entry %d of section "%s" is not keyed by a path.', $i, $name)); } - $length = (int) rtrim($length, "\n"); - if ($length <= 0) { - throw new RuntimeException(sprintf('Frame length %d is not positive.', $length)); + // fseek() past the end of a file succeeds, so the position is what catches a section the + // file does not actually hold. + if (fseek($handle, $valueLength, SEEK_CUR) !== 0) { + throw new RuntimeException(sprintf('Cannot skip entry %d of section "%s".', $i, $name)); } + $position = ftell($handle); + if ($position === false || $position > $fileSize) { + throw new RuntimeException(sprintf('Section "%s" is truncated at entry %d of %d.', $name, $i, $count)); + } + + $index[$key] = [$offset, $position - $offset]; + $offset = $position; + } + + return $index; + } + + /** + * Walks past an array frame's entries without decoding anything, checking as it goes that the + * file really holds them. + * + * @param resource $handle + */ + private function skipEntryFrames($handle, int $count, int $fileSize, string $name): void + { + for ($i = 0; $i < $count; $i++) { + [$keyLength, $valueLength] = $this->readEntryHeader($handle); + // fseek() past the end of a file succeeds, so the position is what catches a section the // file does not actually hold. - if (fseek($handle, $length, SEEK_CUR) !== 0) { + if (fseek($handle, $keyLength + $valueLength, SEEK_CUR) !== 0) { throw new RuntimeException(sprintf('Cannot skip entry %d of section "%s".', $i, $name)); } @@ -2486,6 +2610,127 @@ private function skipEntryFrames($handle, int $count, int $fileSize, string $nam } } + /** + * @param resource $handle + * @return array{int, int} The lengths of the key and value payloads + */ + private function readEntryHeader($handle): array + { + $header = fgets($handle); + if ($header === false) { + throw new RuntimeException('The cache file ended inside an array section.'); + } + + $lengths = explode(' ', rtrim($header, "\n")); + if (count($lengths) !== 2 || (int) $lengths[0] <= 0 || (int) $lengths[1] <= 0) { + throw new RuntimeException(sprintf('Malformed entry header "%s".', rtrim($header, "\n"))); + } + + return [(int) $lengths[0], (int) $lengths[1]]; + } + + /** + * The header and key of an entry frame, leaving the handle at its value. + * + * @param resource $handle + * @return array{int|string, int} The key and the length of the value payload + */ + private function readEntryKey($handle): array + { + [$keyLength, $valueLength] = $this->readEntryHeader($handle); + $key = $this->readFrame($handle, $keyLength); + if (!is_int($key) && !is_string($key)) { + throw new RuntimeException('An entry key is not an array key.'); + } + + return [$key, $valueLength]; + } + + /** + * @return Closure(array): CollectorData + */ + private function createCollectedDataReader(): Closure + { + return fn (array $index): array => $this->readCollectedData($index); + } + + /** + * Reads the collected data entries the index points at. The file is opened for the duration of + * the call only: an open handle would be inherited by the forked workers, and a rename over the + * file (the next save) must not find it open on Windows. + * + * @param array $index + * @return CollectorData + */ + private function readCollectedData(array $index): array + { + // Read front to back: the entries are stored in the order of their paths and the index + // usually is too, but a seek backwards on a file this size is what makes reading it slow. + uasort($index, static fn (array $a, array $b): int => $a[0] <=> $b[0]); + + $transformer = $this->getPathTransformer(); + $handle = $this->openCacheFile(); + $data = []; + try { + foreach ($index as $file => [$offset]) { + [$key, $valueLength] = $this->seekToEntry($handle, $offset, $file); + $value = $this->readFrame($handle, $valueLength); + if (!is_array($value)) { + throw new RuntimeException(sprintf('The collected data of %s is not an array.', $file)); + } + + $data[$key] = $value; + } + } catch (Throwable $e) { + // The walk in readCacheFile() validated the framing, not the values, so this is the first + // point where a damaged value shows, and save() has already carried it over into the new + // file. Left in place it would fail every run from now on; restore() discards a cache it + // cannot read back the same way. + fclose($handle); + @unlink($this->cacheFilePath); + + throw new RuntimeException(sprintf('The result cache file %s could not be read back and was discarded, so the next run analyses everything: %s', $this->cacheFilePath, $e->getMessage()), previous: $e); + } + + fclose($handle); + + return $transformer->absolutizeCollectedData($data); + } + + /** + * Moves to an entry of the collected data section and past its key, refusing an entry that is + * not the one the index was built for. + * + * @param resource $handle + * @return array{string, int} The key as stored and the length of the value payload + */ + private function seekToEntry($handle, int $offset, string $file): array + { + if (fseek($handle, $offset) !== 0) { + throw new RuntimeException(sprintf('Cannot seek to the collected data of %s.', $file)); + } + + [$key, $valueLength] = $this->readEntryKey($handle); + if (!is_string($key) || $this->getPathTransformer()->absolutizePath($key) !== $file) { + throw new RuntimeException(sprintf('The result cache file %s changed while it was being read: the entry of %s is not where it was.', $this->cacheFilePath, $file)); + } + + return [$key, $valueLength]; + } + + /** + * @return resource + */ + private function openCacheFile() + { + $handle = @fopen($this->cacheFilePath, 'r'); + if ($handle === false) { + throw new RuntimeException(sprintf('Cannot open the result cache file %s.', $this->cacheFilePath)); + } + + return $handle; + } + /** * @param resource $handle * @return array @@ -2507,19 +2752,8 @@ private function readEntryFrames($handle, int $count): array { $entries = []; for ($i = 0; $i < $count; $i++) { - $length = fgets($handle); - if ($length === false) { - throw new RuntimeException(sprintf('Cache file ended after %d of %d entries.', $i, $count)); - } - - $entry = $this->readFrame($handle, (int) rtrim($length, "\n")); - if (!is_array($entry)) { - throw new RuntimeException('An entry frame did not contain an array.'); - } - - foreach ($entry as $key => $value) { - $entries[$key] = $value; - } + [$key, $valueLength] = $this->readEntryKey($handle); + $entries[$key] = $this->readFrame($handle, $valueLength); } return $entries; diff --git a/src/Analyser/ResultCache/ResultCacheProcessResult.php b/src/Analyser/ResultCache/ResultCacheProcessResult.php index a79b65e0093..84c89695664 100644 --- a/src/Analyser/ResultCache/ResultCacheProcessResult.php +++ b/src/Analyser/ResultCache/ResultCacheProcessResult.php @@ -9,8 +9,10 @@ final class ResultCacheProcessResult { + private ?LazyCollectedData $savedCollectedData = null; + /** - * @param Closure(FinalizerResult): bool $saveCallback + * @param Closure(FinalizerResult): ?LazyCollectedData $saveCallback returns the collected data as it can be read back from the saved file, or null when nothing was saved */ public function __construct(private AnalyserResult $analyserResult, private Closure $saveCallback) { @@ -29,7 +31,18 @@ public function getAnalyserResult(): AnalyserResult */ public function save(FinalizerResult $finalizerResult): bool { - return ($this->saveCallback)($finalizerResult); + $this->savedCollectedData = ($this->saveCallback)($finalizerResult); + + return $this->savedCollectedData !== null; + } + + /** + * The collected data after save() wrote the cache file: the cached entries of the analyser result + * pointed into the file that the save replaced. Null when nothing was saved. + */ + public function getSavedCollectedData(): ?LazyCollectedData + { + return $this->savedCollectedData; } } diff --git a/src/Command/AnalyseApplication.php b/src/Command/AnalyseApplication.php index da47355bd36..7155cfcaf09 100644 --- a/src/Command/AnalyseApplication.php +++ b/src/Command/AnalyseApplication.php @@ -8,6 +8,7 @@ use PHPStan\Analyser\Error; use PHPStan\Analyser\FileAnalyserResult; use PHPStan\Analyser\Ignore\IgnoredErrorHelper; +use PHPStan\Analyser\ResultCache\LazyCollectedData; use PHPStan\Analyser\ResultCache\ResultCacheManagerFactory; use PHPStan\Analyser\ValueDependencyCollector; use PHPStan\Collectors\CollectedData; @@ -81,7 +82,7 @@ public function analyse( if (count($ignoredErrorHelperResult->getErrors()) > 0) { $notFileSpecificErrors = $ignoredErrorHelperResult->getErrors(); $internalErrors = []; - $collectedData = []; + $collectedData = LazyCollectedData::fromArray([]); $savedResultCache = false; $memoryUsageBytes = 0; $workerCount = 0; @@ -120,7 +121,7 @@ public function analyse( linesToIgnore: $intermediateAnalyserResult->getLinesToIgnore(), unmatchedLineIgnores: $intermediateAnalyserResult->getUnmatchedLineIgnores(), internalErrors: $intermediateAnalyserResult->getInternalErrors(), - collectedData: $intermediateAnalyserResult->getCollectedData(), + collectedData: $intermediateAnalyserResult->getLazyCollectedData(), dependencies: $intermediateAnalyserResult->getDependencies(), usedTraitDependencies: $intermediateAnalyserResult->getUsedTraitDependencies(), valueDependencies: $intermediateAnalyserResult->getValueDependencies(), @@ -168,7 +169,7 @@ public function analyse( $ignoredErrorHelperProcessedResult = $ignoredErrorHelperResult->process($errors, $onlyFiles, $files, $hasInternalErrors); $fileSpecificErrors = $ignoredErrorHelperProcessedResult->getNotIgnoredErrors(); $notFileSpecificErrors = $ignoredErrorHelperProcessedResult->getOtherIgnoreMessages(); - $collectedData = $analyserResult->getCollectedData(); + $collectedData = $resultCacheResult->getSavedCollectedData() ?? $analyserResult->getLazyCollectedData(); } return new AnalysisResult( @@ -176,7 +177,7 @@ public function analyse( $notFileSpecificErrors, $internalErrors, [], - $this->mapCollectedData($collectedData), + fn (): array => $this->mapCollectedData($collectedData->toArray()), $defaultLevelUsed, $projectConfigFile, $savedResultCache, @@ -240,7 +241,7 @@ private function runAnalyser( linesToIgnore: [], unmatchedLineIgnores: [], internalErrors: [], - collectedData: [], + collectedData: LazyCollectedData::fromArray([]), dependencies: [], usedTraitDependencies: [], valueDependencies: ['values' => [], 'dependents' => []], @@ -358,15 +359,6 @@ private function switchTmpFileInAnalyserResult( return $analyserResult; } - $newCollectedData = []; - foreach ($analyserResult->getCollectedData() as $file => $data) { - if ($file === $tmpFile) { - $file = $insteadOfFile; - } - - $newCollectedData[$file] = $data; - } - $dependencies = null; if ($analyserResult->getDependencies() !== null) { $dependencies = $this->switchTmpFileInDependencies($analyserResult->getDependencies(), $insteadOfFile, $tmpFile); @@ -401,7 +393,7 @@ private function switchTmpFileInAnalyserResult( linesToIgnore: $this->switchTmpFileInLinesToIgnore($analyserResult->getLinesToIgnore(), $insteadOfFile, $tmpFile), unmatchedLineIgnores: $this->switchTmpFileInLinesToIgnore($analyserResult->getUnmatchedLineIgnores(), $insteadOfFile, $tmpFile), internalErrors: $analyserResult->getInternalErrors(), - collectedData: $newCollectedData, + collectedData: $analyserResult->getLazyCollectedData()->withRenamedFile($tmpFile, $insteadOfFile), dependencies: $dependencies, usedTraitDependencies: $usedTraitDependencies, valueDependencies: $valueDependencies, diff --git a/src/Command/AnalyserRunner.php b/src/Command/AnalyserRunner.php index 496bdee4c82..f4e00aef77d 100644 --- a/src/Command/AnalyserRunner.php +++ b/src/Command/AnalyserRunner.php @@ -5,6 +5,7 @@ use Closure; use PHPStan\Analyser\Analyser; use PHPStan\Analyser\AnalyserResult; +use PHPStan\Analyser\ResultCache\LazyCollectedData; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Parallel\ParallelAnalyser; use PHPStan\Parallel\Scheduler; @@ -65,7 +66,7 @@ public function runAnalyser( linesToIgnore: [], unmatchedLineIgnores: [], internalErrors: [], - collectedData: [], + collectedData: LazyCollectedData::fromArray([]), dependencies: [], usedTraitDependencies: [], valueDependencies: ['values' => [], 'dependents' => []], diff --git a/src/Command/AnalysisResult.php b/src/Command/AnalysisResult.php index c0b4fe42951..bbc823d6340 100644 --- a/src/Command/AnalysisResult.php +++ b/src/Command/AnalysisResult.php @@ -2,6 +2,7 @@ namespace PHPStan\Command; +use Closure; use PHPStan\Analyser\Error; use PHPStan\Analyser\InternalError; use PHPStan\Collectors\CollectedData; @@ -22,7 +23,7 @@ final class AnalysisResult * @param list $notFileSpecificErrors * @param list $internalErrors * @param list $warnings - * @param list $collectedData + * @param list|(Closure(): list) $collectedData A closure defers decoding the result cache until asked * @param list $processedFiles */ public function __construct( @@ -30,7 +31,7 @@ public function __construct( private array $notFileSpecificErrors, private array $internalErrors, private array $warnings, - private array $collectedData, + private array|Closure $collectedData, private bool $defaultLevelUsed, private ?string $projectConfigFile, private bool $savedResultCache, @@ -109,6 +110,10 @@ public function hasWarnings(): bool */ public function getCollectedData(): array { + if ($this->collectedData instanceof Closure) { + $this->collectedData = ($this->collectedData)(); + } + return $this->collectedData; } diff --git a/src/Command/FixerWorkerRunner.php b/src/Command/FixerWorkerRunner.php index 3b5f8c270fd..182fb125d9d 100644 --- a/src/Command/FixerWorkerRunner.php +++ b/src/Command/FixerWorkerRunner.php @@ -9,6 +9,7 @@ use PHPStan\Analyser\Ignore\IgnoredErrorHelper; use PHPStan\Analyser\Ignore\IgnoredErrorHelperResult; use PHPStan\Analyser\InternalError; +use PHPStan\Analyser\ResultCache\LazyCollectedData; use PHPStan\Analyser\ResultCache\ResultCacheManager; use PHPStan\Analyser\ResultCache\ResultCacheManagerFactory; use PHPStan\DependencyInjection\AutowiredService; @@ -343,7 +344,7 @@ private function runAnalyser(LoopInterface $loop, array $files, array $allAnalys linesToIgnore: [], unmatchedLineIgnores: [], internalErrors: [], - collectedData: [], + collectedData: LazyCollectedData::fromArray([]), dependencies: [], usedTraitDependencies: [], valueDependencies: ['values' => [], 'dependents' => []], diff --git a/src/Parallel/ParallelAnalyser.php b/src/Parallel/ParallelAnalyser.php index c1e07733662..894a9bf5bfb 100644 --- a/src/Parallel/ParallelAnalyser.php +++ b/src/Parallel/ParallelAnalyser.php @@ -9,6 +9,7 @@ use PHPStan\Analyser\AnalyserResult; use PHPStan\Analyser\Error; use PHPStan\Analyser\InternalError; +use PHPStan\Analyser\ResultCache\LazyCollectedData; use PHPStan\Analyser\ValueDependencyCollector; use PHPStan\Cache\ArenaCache; use PHPStan\Command\CommandHelper; @@ -160,7 +161,7 @@ public function analyse( linesToIgnore: $linesToIgnore, unmatchedLineIgnores: $unmatchedLineIgnores, internalErrors: $internalErrors, - collectedData: $collectedData, + collectedData: LazyCollectedData::fromArray($collectedData), dependencies: $internalErrorsCount === 0 ? $dependencies : null, usedTraitDependencies: $internalErrorsCount === 0 ? $usedTraitDependencies : null, valueDependencies: $internalErrorsCount === 0 ? $valueDependencies : null, diff --git a/tests/PHPStan/Analyser/AnalyserResultFinalizerTest.php b/tests/PHPStan/Analyser/AnalyserResultFinalizerTest.php index b2a53bb6258..2d325e229b0 100644 --- a/tests/PHPStan/Analyser/AnalyserResultFinalizerTest.php +++ b/tests/PHPStan/Analyser/AnalyserResultFinalizerTest.php @@ -3,6 +3,7 @@ namespace PHPStan\Analyser; use PhpParser\Node; +use PHPStan\Analyser\ResultCache\LazyCollectedData; use PHPStan\DependencyInjection\DirectExtensionsCollection; use PHPStan\Node\CollectedDataNode; use PHPStan\Rules\DirectRegistry as DirectRuleRegistry; @@ -104,7 +105,7 @@ private function createAnalyserResult(): AnalyserResult linesToIgnore: [], unmatchedLineIgnores: [], internalErrors: [], - collectedData: [__FILE__ => [TraitDeclarationCollector::class => ['data']]], + collectedData: LazyCollectedData::fromArray([__FILE__ => [TraitDeclarationCollector::class => ['data']]]), dependencies: null, usedTraitDependencies: null, valueDependencies: null, diff --git a/tests/PHPStan/Analyser/CollectedDataRuleReuseTest.php b/tests/PHPStan/Analyser/CollectedDataRuleReuseTest.php index 2512a5a1687..7df8c83398d 100644 --- a/tests/PHPStan/Analyser/CollectedDataRuleReuseTest.php +++ b/tests/PHPStan/Analyser/CollectedDataRuleReuseTest.php @@ -11,6 +11,7 @@ use PHPStan\Analyser\CollectedDataReuse\ThrowingRule; use PHPStan\Analyser\CollectedDataReuse\TrackingRule; use PHPStan\Analyser\CollectedDataReuse\UnregisteredValueRule; +use PHPStan\Analyser\ResultCache\LazyCollectedData; use PHPStan\Analyser\ResultCache\ResultCacheValueExtension; use PHPStan\Collectors\CollectedData; use PHPStan\File\FileHelper; @@ -327,7 +328,7 @@ private function finalize(array $rules, array $collectedData, bool $onlyFiles, a linesToIgnore: [], unmatchedLineIgnores: [], internalErrors: [], - collectedData: $collectedData, + collectedData: LazyCollectedData::fromArray($collectedData), dependencies: [], usedTraitDependencies: [], valueDependencies: null, diff --git a/tests/PHPStan/Analyser/ResultCache/LazyCollectedDataTest.php b/tests/PHPStan/Analyser/ResultCache/LazyCollectedDataTest.php new file mode 100644 index 00000000000..d1c774862c5 --- /dev/null +++ b/tests/PHPStan/Analyser/ResultCache/LazyCollectedDataTest.php @@ -0,0 +1,85 @@ + [MethodWithoutImpurePointsCollector::class => ['x']]]; + $lazy = LazyCollectedData::fromArray($data); + + $this->assertFalse($lazy->isEmpty()); + $this->assertSame($data, $lazy->toArray()); + $this->assertSame([], $lazy->getCachedIndex()); + $this->assertTrue(LazyCollectedData::fromArray([])->isEmpty()); + } + + public function testCachedEntriesAreReadOnlyWhenAsked(): void + { + $reader = function (array $index): array { + $this->reads++; + $result = []; + foreach (array_keys($index) as $file) { + $result[$file] = [MethodWithoutImpurePointsCollector::class => ['cached ' . $file]]; + } + + return $result; + }; + + $lazy = new LazyCollectedData(['b.php' => [10, 20], 'a.php' => [30, 40]], $reader, []); + $this->assertFalse($lazy->isEmpty()); + $this->assertSame(0, $this->reads); + + $this->assertSame([ + 'b.php' => [MethodWithoutImpurePointsCollector::class => ['cached b.php']], + 'a.php' => [MethodWithoutImpurePointsCollector::class => ['cached a.php']], + ], $lazy->toArray()); + $this->assertSame(1, $this->reads); + + $lazy->toArray(); + $this->assertSame(2, $this->reads, 'nothing is kept between calls'); + } + + public function testFreshEntriesReplaceCachedOnes(): void + { + $reader = static fn (array $index): array => ['a.php' => [MethodWithoutImpurePointsCollector::class => ['cached']], 'b.php' => [MethodWithoutImpurePointsCollector::class => ['cached']]]; + $lazy = new LazyCollectedData(['a.php' => [0, 1], 'b.php' => [1, 1]], $reader, ['b.php' => [MethodWithoutImpurePointsCollector::class => ['fresh']], 'c.php' => [MethodWithoutImpurePointsCollector::class => ['fresh']]]); + + $this->assertSame([ + 'a.php' => [MethodWithoutImpurePointsCollector::class => ['cached']], + 'b.php' => [MethodWithoutImpurePointsCollector::class => ['fresh']], + 'c.php' => [MethodWithoutImpurePointsCollector::class => ['fresh']], + ], $lazy->toArray()); + } + + public function testWithRenamedFile(): void + { + $reader = static fn (array $index): array => ['cached.php' => [MethodWithoutImpurePointsCollector::class => ['cached']]]; + $lazy = (new LazyCollectedData(['cached.php' => [0, 1]], $reader, ['tmp.php' => [MethodWithoutImpurePointsCollector::class => ['fresh']]])) + ->withRenamedFile('tmp.php', 'renamed.php'); + + $this->assertSame(['cached.php' => [0, 1]], $lazy->getCachedIndex()); + $this->assertSame([ + 'cached.php' => [MethodWithoutImpurePointsCollector::class => ['cached']], + 'renamed.php' => [MethodWithoutImpurePointsCollector::class => ['fresh']], + ], $lazy->toArray()); + } + + public function testWithRenamedFileRefusesCachedFile(): void + { + $lazy = new LazyCollectedData(['cached.php' => [0, 1]], static fn (array $index): array => [], []); + + $this->expectException(ShouldNotHappenException::class); + $lazy->withRenamedFile('cached.php', 'renamed.php'); + } + +} diff --git a/tests/PHPStan/Analyser/ResultCache/ResultCacheManagerCollectedDataTest.php b/tests/PHPStan/Analyser/ResultCache/ResultCacheManagerCollectedDataTest.php new file mode 100644 index 00000000000..59d8692b6ea --- /dev/null +++ b/tests/PHPStan/Analyser/ResultCache/ResultCacheManagerCollectedDataTest.php @@ -0,0 +1,325 @@ + */ + private array $files = []; + + #[Override] + protected function setUp(): void + { + parent::setUp(); + $fileHelper = self::getContainer()->getByType(FileHelper::class); + $this->directory = $fileHelper->normalizePath(sys_get_temp_dir() . '/phpstan-collected-data-' . uniqid()); + // the cache file must not be in the analysed directory, or it counts as a scanned file + mkdir($this->directory . '/src', 0777, true); + foreach (['a', 'b', 'c'] as $name) { + $file = $fileHelper->normalizePath($this->directory . '/src/' . $name . '.php'); + file_put_contents($file, 'files[] = $file; + } + } + + #[Override] + protected function tearDown(): void + { + parent::tearDown(); + foreach ($this->files as $file) { + @unlink($file); + } + @unlink($this->cacheFilePath()); + @rmdir($this->directory . '/src'); + @rmdir($this->directory); + } + + public function testCachedEntriesAreIndexedAndReadBack(): void + { + $data = $this->collectedData(['a', 'b', 'c']); + $this->saveFullAnalysis($data); + + $resultCache = $this->createManager()->restore($this->files, false, false, null, $this->nullOutput()); + $this->assertFalse($resultCache->isFullAnalysis()); + $this->assertSame([], $resultCache->getFilesToAnalyse()); + + $lazy = $resultCache->getCollectedData(); + $this->assertSame($this->files, array_keys($lazy->getCachedIndex())); + $this->assertSame([], $lazy->getFresh()); + $this->assertSame($data, $lazy->toArray()); + + $contents = file_get_contents($this->cacheFilePath()); + $this->assertNotFalse($contents); + foreach ($lazy->getCachedIndex() as $file => [$offset, $length]) { + $entry = substr($contents, $offset, $length); + $this->assertStringContainsString('"src/' . basename($file) . '"', $entry); + $this->assertStringEndsWith('}', $entry); + } + } + + public function testWarmSaveCopiesEveryEntryByteForByte(): void + { + $this->saveFullAnalysis($this->collectedData(['a', 'b', 'c'])); + $before = md5_file($this->cacheFilePath()); + + $manager = $this->createManager(); + $resultCache = $manager->restore($this->files, false, false, null, $this->nullOutput()); + $processed = $manager->process($this->analyserResult([]), $resultCache, $this->nullOutput(), false, true); + $this->assertTrue($processed->save(new FinalizerResult($processed->getAnalyserResult(), [], []))); + $this->assertSame($before, md5_file($this->cacheFilePath())); + $this->assertSame($this->collectedData(['a', 'b', 'c']), $this->savedCollectedData($processed)); + } + + public function testPartialSaveEqualsFreshSaveOfMergedData(): void + { + $this->saveFullAnalysis($this->collectedData(['a', 'b', 'c'])); + + file_put_contents($this->files[1], "createManager(); + $resultCache = $manager->restore($this->files, false, false, null, $this->nullOutput()); + $this->assertSame([$this->files[1]], $resultCache->getFilesToAnalyse()); + + $freshB = [$this->files[1] => [MethodWithoutImpurePointsCollector::class => ['fresh b']]]; + $processed = $manager->process($this->analyserResult($freshB), $resultCache, $this->nullOutput(), false, true); + $this->assertTrue($processed->save(new FinalizerResult($processed->getAnalyserResult(), [], []))); + + // cached entries come first, the re-analysed file after them + $expected = $this->collectedData(['a', 'c', 'b']); + $expected[$this->files[1]] = $freshB[$this->files[1]]; + $this->assertSame($expected, $this->savedCollectedData($processed)); + + $partial = $this->cacheContentsWithoutTime(); + $this->saveFullAnalysis($expected); + $this->assertSame($partial, $this->cacheContentsWithoutTime()); + } + + public function testDamagedValueDiscardsTheCacheWhenRead(): void + { + $this->saveFullAnalysis($this->collectedData(['a', 'b', 'c'])); + $this->breakFirstCollectedDataValue(); + + $resultCache = $this->createManager()->restore($this->files, false, false, null, $this->nullOutput()); + $this->assertFalse($resultCache->isFullAnalysis(), 'the framing is intact, only a value is damaged'); + + try { + $resultCache->getCollectedData()->toArray(); + $this->fail('The damaged value should not decode.'); + } catch (RuntimeException $e) { + $this->assertStringContainsString('could not be read back and was discarded', $e->getMessage()); + } + + $this->assertFalse(is_file($this->cacheFilePath())); + } + + public function testTruncatedSectionIsDiscardedOnRestore(): void + { + $this->saveFullAnalysis($this->collectedData(['a', 'b', 'c'])); + $contents = file_get_contents($this->cacheFilePath()); + $this->assertNotFalse($contents); + $position = strpos($contents, "collectedData* 3\n"); + $this->assertNotFalse($position); + file_put_contents($this->cacheFilePath(), substr($contents, 0, $position + strlen("collectedData* 3\n") + 20)); + + $resultCache = $this->createManager()->restore($this->files, false, false, null, $this->nullOutput()); + $this->assertTrue($resultCache->isFullAnalysis()); + $this->assertFalse(is_file($this->cacheFilePath())); + } + + public function testPreviousEntryFramingIsDiscarded(): void + { + $this->saveFullAnalysis($this->collectedData(['a'])); + $contents = file_get_contents($this->cacheFilePath()); + $this->assertNotFalse($contents); + + // the 2.2.13 framing: one length per entry, the key inside the payload + $position = strpos($contents, "collectedData* 1\n"); + $this->assertNotFalse($position); + $entryStart = $position + strlen("collectedData* 1\n"); + $headerEnd = strpos($contents, "\n", $entryStart); + $this->assertNotFalse($headerEnd); + file_put_contents($this->cacheFilePath(), substr($contents, 0, $entryStart) . '42' . substr($contents, $headerEnd)); + + $resultCache = $this->createManager()->restore($this->files, false, false, null, $this->nullOutput()); + $this->assertTrue($resultCache->isFullAnalysis()); + } + + /** + * @param list $names + * @return array, list>> + */ + private function collectedData(array $names): array + { + $data = []; + foreach ($names as $name) { + $data[$this->files[ord($name) - ord('a')]] = [MethodWithoutImpurePointsCollector::class => ['usage in ' . $name]]; + } + + return $data; + } + + /** + * @param array, list>> $collectedData + */ + private function saveFullAnalysis(array $collectedData): void + { + @unlink($this->cacheFilePath()); + $manager = $this->createManager(); + $resultCache = $this->createManager()->restore($this->files, false, false, null, $this->nullOutput()); + $this->assertTrue($resultCache->isFullAnalysis()); + + $processed = $manager->process($this->analyserResult($collectedData), $resultCache, $this->nullOutput(), false, true); + $this->assertTrue($processed->save(new FinalizerResult($processed->getAnalyserResult(), [], []))); + $this->assertCount(count($collectedData), $this->savedCollectedData($processed)); + } + + /** + * @param array, list>> $collectedData + */ + private function analyserResult(array $collectedData): AnalyserResult + { + $dependencies = []; + foreach ($this->files as $file) { + $dependencies[$file] = []; + } + + return new AnalyserResult( + unorderedErrors: [], + filteredPhpErrors: [], + allPhpErrors: [], + locallyIgnoredErrors: [], + linesToIgnore: [], + unmatchedLineIgnores: [], + internalErrors: [], + collectedData: LazyCollectedData::fromArray($collectedData), + dependencies: $dependencies, + usedTraitDependencies: [], + valueDependencies: ['values' => [], 'dependents' => []], + packageDependencies: [], + exportedNodes: [], + reachedInternalErrorsCountLimit: false, + peakMemoryUsageBytes: 0, + processedFiles: $this->files, + ); + } + + /** + * A full analysis records its own time, which a partial save keeps from the previous one. + */ + private function cacheContentsWithoutTime(): string + { + $contents = file_get_contents($this->cacheFilePath()); + $this->assertNotFalse($contents); + + return Strings::replace($contents, '~^lastFullAnalysisTime \d+\ni:\d+;~m', ''); + } + + private function breakFirstCollectedDataValue(): void + { + $contents = file_get_contents($this->cacheFilePath()); + $this->assertNotFalse($contents); + $position = strpos($contents, 's:10:"usage in a"'); + $this->assertNotFalse($position); + // the declared length no longer matches the body, the frame keeps its size + file_put_contents($this->cacheFilePath(), substr($contents, 0, $position) . 's:11:' . substr($contents, $position + 5)); + } + + private function createManager(): ResultCacheManager + { + $container = self::getContainer(); + + return new ResultCacheManager( + resultCacheMetaExtensions: $container->getExtensionsCollection(ResultCacheMetaExtension::class), // @phpstan-ignore classConstant.deprecatedInterface + exportedNodeFetcher: $container->getByType(ExportedNodeFetcher::class), + scanFileFinder: $container->getService('fileFinderScan'), + stubFilesProvider: $container->getByType(StubFilesProvider::class), + fileHelper: $container->getByType(FileHelper::class), + packageDependencyResolver: $container->getByType(PackageDependencyResolver::class), + extensionVersionProvider: $container->getByType(ExtensionVersionProvider::class), + cacheFilePath: $this->cacheFilePath(), + analysedPaths: [$this->directory . '/src'], + analysedPathsFromConfig: [], + composerAutoloaderProjectPaths: [], + usedLevel: '8', + cliAutoloadFile: null, + bootstrapFiles: [], + scanFiles: [], + scanDirectories: [], + configStubFiles: [], + fileReplacements: [], + checkDependenciesOfProjectExtensionFiles: false, + parametersNotInvalidatingCache: [], + skipResultCacheIfOlderThanDays: 7, + anchorDirectory: $this->directory, + phpVersion: $container->getByType(PhpVersion::class), + composerPhpVersionFactory: $container->getByType(ComposerPhpVersionFactory::class), + fileStatSignatures: $container->getByType(FileStatSignatures::class), + valueDependencyCollector: $container->getByType(ValueDependencyCollector::class), + ); + } + + /** + * @return array + */ + private function savedCollectedData(ResultCacheProcessResult $processed): array + { + $collectedData = $processed->getSavedCollectedData(); + $this->assertNotNull($collectedData); + + return $collectedData->toArray(); + } + + private function cacheFilePath(): string + { + return $this->directory . '/resultCache.php'; + } + + private function nullOutput(): Output + { + $symfonyOutput = new NullOutput(); + + return new SymfonyOutput($symfonyOutput, new SymfonyStyle(new SymfonyConsoleStyle(new StringInput(''), $symfonyOutput))); + } + +}