From 9a50c32233b6f3c0da0c0cde05cacdc19f441f65 Mon Sep 17 00:00:00 2001 From: Caleb White Date: Mon, 21 Sep 2026 22:11:58 -0500 Subject: [PATCH] test(Caching) cover atomic cache file replacement in FileCacheStorage Every worker in a parallel run boots its own container, and each one require()s the configuration_hash cache file while another worker may be saving over it. copy() truncates the destination and streams into it, so a reader can catch a prefix of the new file and die on it: {"fatal_errors":["syntax error, unexpected string content ..."]} [ERROR] Could not process some files, due to: "Child process error". The interleaving that produces a torn read is not reproducible on demand, but the precondition for it is, with no threads and no timing: open a reader on the cache file, save over it, and read. copy() writes through the inode the reader is holding, so the reader's file changes underneath it; an atomic replace leaves that reader on the whole file it opened. Fails on copy(), passes on rename(). --- .../Storage/FileCacheStorageTest.php | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/Caching/ValueObject/Storage/FileCacheStorageTest.php b/tests/Caching/ValueObject/Storage/FileCacheStorageTest.php index bfb4281c216..61e9f1ae096 100644 --- a/tests/Caching/ValueObject/Storage/FileCacheStorageTest.php +++ b/tests/Caching/ValueObject/Storage/FileCacheStorageTest.php @@ -49,6 +49,32 @@ public function testClean(): void $this->assertDirectoryDoesNotExist(__DIR__ . '/Source/0e'); } + public function testSaveLeavesAConcurrentReaderOnACompleteFile(): void + { + $filePath = __DIR__ . '/Source/0e/76/0e76658526655756207688271159624026011393.php'; + + $this->fileCacheStorage->save('aaK1STfY', 'TEST', 'first'); + $contentsBeforeSave = (string) file_get_contents($filePath); + + // every parallel worker require()s this path while booting its container; open it the + // way such a worker would, then have another worker save over it mid-read + $readerHandle = fopen($filePath, 'r'); + $this->assertNotFalse($readerHandle); + + $this->fileCacheStorage->save('aaK1STfY', 'TEST', 'second'); + + $contentsSeenByReader = stream_get_contents($readerHandle); + fclose($readerHandle); + + // an atomic replace leaves the reader on the whole file it opened; writing through the + // published inode instead exposes the reader to however much of the new file has landed + $this->assertSame($contentsBeforeSave, $contentsSeenByReader); + + $this->assertSame('second', $this->fileCacheStorage->load('aaK1STfY', 'TEST')); + + $this->fileCacheStorage->clean('aaK1STfY'); + } + public function provideConfigFilePath(): string { return __DIR__ . '/config.php';