Skip to content

Commit f49b495

Browse files
SanderMullerclaude
authored andcommitted
Decode the framed sections on demand instead of on restore
restore() no longer hands back the errors, locally ignored errors, collected data and exported nodes as arrays: the cache file carries them as closures, and a run that never asks for a section never pays for it. The var_export format does this by writing `static function (): array { return [...]; }` into the file, which only defers the array literal - PHP still compiles it and OPCache still holds it. A framed file needs no such trick. Each of those sections is one array frame, so the reader remembers where it starts, walks past its entries without unserializing them, and hands out a callback that seeks back and decodes on demand. Walking rather than skipping wholesale keeps the eager validation of the whole file's framing, so a damaged cache is still discarded by restore() instead of throwing later inside a callback. fseek() past the end of a file succeeds, so the position is compared with the file size after every entry - a killed save leaves exactly that kind of truncation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent c8650cc commit f49b495

3 files changed

Lines changed: 124 additions & 7 deletions

File tree

.github/workflows/e2e-tests.yml

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,8 +127,21 @@ jobs:
127127
- script: |
128128
cd e2e/result-cache-truncated
129129
../../bin/phpstan -vvv
130+
# Cut inside the first section's payload: the cache is damaged, not merely stale, so
131+
# it has to be discarded rather than read as far as it goes.
130132
php truncate.php
131-
../../bin/phpstan -vvv
133+
OUTPUT=$(../../bin/phpstan -vvv 2>&1)
134+
echo "$OUTPUT"
135+
../bashunit -a contains 'an error occurred while loading the cache file' "$OUTPUT"
136+
../bashunit -a contains 'Result cache is saved.' "$OUTPUT"
137+
# And once more inside a section that is read lazily, which is a different code path.
138+
php truncate-lazy.php
139+
OUTPUT=$(../../bin/phpstan -vvv 2>&1)
140+
echo "$OUTPUT"
141+
../bashunit -a contains 'is truncated at entry' "$OUTPUT"
142+
OUTPUT=$(../../bin/phpstan -vvv 2>&1)
143+
echo "$OUTPUT"
144+
../bashunit -a contains 'Result cache restored.' "$OUTPUT"
132145
- script: |
133146
cd e2e/bug-14514
134147
composer install
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php declare(strict_types = 1);
2+
3+
// The same shape as truncate.php, but cutting far enough in to land inside one of the sections
4+
// restore() reads lazily. Those are walked rather than decoded when the cache is opened, and
5+
// fseek() past the end of a file succeeds, so only comparing the position with the file size
6+
// catches this - otherwise the section would be handed out as a callback pointing past the end.
7+
$file = __DIR__ . '/tmp/resultCache.php';
8+
$contents = file_get_contents($file);
9+
if ($contents === false) {
10+
throw new RuntimeException('No result cache at ' . $file);
11+
}
12+
13+
file_put_contents($file, substr($contents, 0, (int) (strlen($contents) * 0.9)));

src/Analyser/ResultCache/ResultCacheManager.php

Lines changed: 97 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@
4646
use function fgets;
4747
use function fopen;
4848
use function fread;
49+
use function fseek;
50+
use function fstat;
51+
use function ftell;
4952
use function fwrite;
5053
use function get_loaded_extensions;
5154
use function hash_file;
@@ -68,6 +71,7 @@
6871
use function unlink;
6972
use function unserialize;
7073
use const PHP_VERSION_ID;
74+
use const SEEK_CUR;
7175

7276
/**
7377
* @phpstan-import-type LinesToIgnore from FileAnalyserResult
@@ -98,6 +102,13 @@ final class ResultCacheManager
98102
*/
99103
private const SERIALIZED_FILE_PREFIX = '<?php return; ?>';
100104

105+
/**
106+
* Sections restore() hands back as callbacks instead of arrays, so a run that never asks for them
107+
* never pays for decoding them. Each is a whole array frame in the file, so the reader only has to
108+
* remember where it starts and walk past its entries.
109+
*/
110+
private const LAZY_SECTIONS = ['errors', 'locallyIgnoredErrors', 'collectedData', 'exportedNodes'];
111+
101112
/** @var array<string, string> */
102113
private array $fileHashes = [];
103114

@@ -471,12 +482,12 @@ public function restore(array $allAnalysedFiles, bool $debug, bool $onlyFiles, ?
471482
$filesToAnalyse = [];
472483
$invertedDependenciesToReturn = [];
473484
$invertedUsedTraitDependenciesToReturn = [];
474-
$errors = $data['errors'];
475-
$locallyIgnoredErrors = $data['locallyIgnoredErrors'];
485+
$errors = $data['errorsCallback']();
486+
$locallyIgnoredErrors = $data['locallyIgnoredErrorsCallback']();
476487
$linesToIgnore = $data['linesToIgnore'];
477488
$unmatchedLineIgnores = $data['unmatchedLineIgnores'];
478-
$collectedData = $data['collectedData'];
479-
$exportedNodes = $data['exportedNodes'];
489+
$collectedData = $data['collectedDataCallback']();
490+
$exportedNodes = $data['exportedNodesCallback']();
480491
$filteredErrors = [];
481492
$filteredLocallyIgnoredErrors = [];
482493
$filteredLinesToIgnore = [];
@@ -1346,12 +1357,17 @@ private function readCacheFile(string $cacheFilePath): ?array
13461357
return null;
13471358
}
13481359

1360+
$stat = fstat($handle);
1361+
$fileSize = $stat === false ? 0 : $stat['size'];
1362+
$closeHandle = true;
1363+
13491364
try {
13501365
if (rtrim((string) fgets($handle), "\n") !== self::SERIALIZED_FILE_PREFIX) {
13511366
return null;
13521367
}
13531368

13541369
$data = [];
1370+
$lazy = array_fill_keys(self::LAZY_SECTIONS, false);
13551371
while (($header = fgets($handle)) !== false) {
13561372
$header = rtrim($header, "\n");
13571373
if ($header === '') {
@@ -1370,15 +1386,90 @@ private function readCacheFile(string $cacheFilePath): ?array
13701386
continue;
13711387
}
13721388

1373-
$data[substr($name, 0, -1)] = $this->readEntryFrames($handle, (int) $size);
1389+
$name = substr($name, 0, -1);
1390+
$count = (int) $size;
1391+
if (!array_key_exists($name, $lazy)) {
1392+
$data[$name] = $this->readEntryFrames($handle, $count);
1393+
1394+
continue;
1395+
}
1396+
1397+
// The entries are walked rather than decoded: that validates the framing of the whole
1398+
// file up front, so a damaged cache is still discarded by restore() instead of failing
1399+
// later inside the callback, and leaves the payloads to be unserialized on demand.
1400+
$offset = ftell($handle);
1401+
if ($offset === false) {
1402+
throw new RuntimeException(sprintf('Cannot tell the position of section "%s".', $name));
1403+
}
1404+
1405+
$this->skipEntryFrames($handle, $count, $fileSize, $name);
1406+
$lazy[$name] = true;
1407+
$data[$name . 'Callback'] = fn (): array => $this->readEntryFramesAt($handle, $offset, $count, $name);
13741408
}
13751409

1410+
foreach ($lazy as $name => $seen) {
1411+
if ($seen) {
1412+
continue;
1413+
}
1414+
1415+
$data[$name . 'Callback'] = static fn (): array => [];
1416+
}
1417+
1418+
$closeHandle = false;
1419+
13761420
return $data;
13771421
} finally {
1378-
fclose($handle);
1422+
if ($closeHandle) {
1423+
fclose($handle);
1424+
}
13791425
}
13801426
}
13811427

1428+
/**
1429+
* Walks past an array frame's entries without unserializing them, checking as it goes that the
1430+
* file really holds them.
1431+
*
1432+
* @param resource $handle
1433+
*/
1434+
private function skipEntryFrames($handle, int $count, int $fileSize, string $name): void
1435+
{
1436+
for ($i = 0; $i < $count; $i++) {
1437+
$length = fgets($handle);
1438+
if ($length === false) {
1439+
throw new RuntimeException(sprintf('Section "%s" ended after %d of %d entries.', $name, $i, $count));
1440+
}
1441+
1442+
$length = (int) rtrim($length, "\n");
1443+
if ($length <= 0) {
1444+
throw new RuntimeException(sprintf('Frame length %d is not positive.', $length));
1445+
}
1446+
1447+
// fseek() past the end of a file succeeds, so the position is what catches a truncated
1448+
// section - a killed save leaves exactly that.
1449+
if (fseek($handle, $length, SEEK_CUR) !== 0) {
1450+
throw new RuntimeException(sprintf('Cannot skip entry %d of section "%s".', $i, $name));
1451+
}
1452+
1453+
$position = ftell($handle);
1454+
if ($position === false || $position > $fileSize) {
1455+
throw new RuntimeException(sprintf('Section "%s" is truncated at entry %d of %d.', $name, $i, $count));
1456+
}
1457+
}
1458+
}
1459+
1460+
/**
1461+
* @param resource $handle
1462+
* @return array<mixed>
1463+
*/
1464+
private function readEntryFramesAt($handle, int $offset, int $count, string $name): array
1465+
{
1466+
if (fseek($handle, $offset) !== 0) {
1467+
throw new RuntimeException(sprintf('Cannot seek to section "%s".', $name));
1468+
}
1469+
1470+
return $this->readEntryFrames($handle, $count);
1471+
}
1472+
13821473
/**
13831474
* @param resource $handle
13841475
* @return array<mixed>

0 commit comments

Comments
 (0)