parent
687b7ac6cb
commit
5c3da4e406
8 changed files with 149 additions and 102 deletions
@ -0,0 +1,67 @@ |
|||||||
|
<?php |
||||||
|
/** |
||||||
|
* This file is part of TypePHP(AOT). |
||||||
|
* |
||||||
|
* @link https://www.swoole.com/aot/ |
||||||
|
* @contact service@swoole.com |
||||||
|
*/ |
||||||
|
|
||||||
|
namespace TypePhp\Tests\Build; |
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase; |
||||||
|
use TypePhp\Build\AtomicFile; |
||||||
|
|
||||||
|
/** |
||||||
|
* @internal |
||||||
|
* @coversNothing |
||||||
|
*/ |
||||||
|
final class AtomicFileTest extends TestCase |
||||||
|
{ |
||||||
|
private string $directory; |
||||||
|
|
||||||
|
protected function setUp(): void |
||||||
|
{ |
||||||
|
$this->directory = sys_get_temp_dir() . '/typephp-atomic-file-' . bin2hex(random_bytes(6)); |
||||||
|
} |
||||||
|
|
||||||
|
protected function tearDown(): void |
||||||
|
{ |
||||||
|
if (!is_dir($this->directory)) { |
||||||
|
return; |
||||||
|
} |
||||||
|
$iterator = new \RecursiveIteratorIterator( |
||||||
|
new \RecursiveDirectoryIterator($this->directory, \FilesystemIterator::SKIP_DOTS), |
||||||
|
\RecursiveIteratorIterator::CHILD_FIRST, |
||||||
|
); |
||||||
|
foreach ($iterator as $entry) { |
||||||
|
$entry->isDir() ? rmdir($entry->getPathname()) : unlink($entry->getPathname()); |
||||||
|
} |
||||||
|
rmdir($this->directory); |
||||||
|
} |
||||||
|
|
||||||
|
public function testWriteCreatesDirectoriesAndReplacesExistingContents(): void |
||||||
|
{ |
||||||
|
$path = $this->directory . '/nested/state.json'; |
||||||
|
|
||||||
|
AtomicFile::write($path, 'first', '.state-'); |
||||||
|
AtomicFile::write($path, 'second', '.state-'); |
||||||
|
|
||||||
|
self::assertSame('second', file_get_contents($path)); |
||||||
|
self::assertSame([$path], glob($this->directory . '/nested/*')); |
||||||
|
} |
||||||
|
|
||||||
|
public function testMissingReplacementDoesNotRemoveExistingTarget(): void |
||||||
|
{ |
||||||
|
$target = $this->directory . '/state.json'; |
||||||
|
AtomicFile::write($target, 'stable'); |
||||||
|
|
||||||
|
try { |
||||||
|
AtomicFile::replace($this->directory . '/missing.tmp', $target); |
||||||
|
self::fail('Missing replacement must be rejected'); |
||||||
|
} catch (\RuntimeException $error) { |
||||||
|
self::assertStringContainsString('does not exist', $error->getMessage()); |
||||||
|
} |
||||||
|
|
||||||
|
self::assertSame('stable', file_get_contents($target)); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,64 @@ |
|||||||
|
<?php |
||||||
|
/** |
||||||
|
* This file is part of TypePHP(AOT). |
||||||
|
* |
||||||
|
* @link https://www.swoole.com/aot/ |
||||||
|
* @contact service@swoole.com |
||||||
|
*/ |
||||||
|
|
||||||
|
namespace TypePhp\Build; |
||||||
|
|
||||||
|
final class AtomicFile |
||||||
|
{ |
||||||
|
public static function write(string $path, string $contents, string $temporaryPrefix = '.tmp-'): void |
||||||
|
{ |
||||||
|
$directory = dirname($path); |
||||||
|
self::ensureDirectory($directory); |
||||||
|
$temporary = tempnam($directory, $temporaryPrefix); |
||||||
|
if ($temporary === false) { |
||||||
|
throw new \RuntimeException("Cannot create temporary file for: {$path}"); |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
if (file_put_contents($temporary, $contents, LOCK_EX) === false) { |
||||||
|
throw new \RuntimeException("Cannot write temporary file for: {$path}"); |
||||||
|
} |
||||||
|
self::replace($temporary, $path); |
||||||
|
} finally { |
||||||
|
if (is_file($temporary)) { |
||||||
|
@unlink($temporary); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** Replace a target with a temporary file created in the same directory. */ |
||||||
|
public static function replace(string $temporary, string $target): void |
||||||
|
{ |
||||||
|
if (!is_file($temporary)) { |
||||||
|
throw new \RuntimeException("Replacement file does not exist: {$temporary}"); |
||||||
|
} |
||||||
|
if (@rename($temporary, $target)) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
// Windows cannot rename over an existing file. A concurrent reader may |
||||||
|
// safely treat this short gap as a cache miss and rebuild the snapshot. |
||||||
|
if (PHP_OS_FAMILY === 'Windows' && is_file($target)) { |
||||||
|
if (!@unlink($target)) { |
||||||
|
throw new \RuntimeException("Cannot remove file before replacement: {$target}"); |
||||||
|
} |
||||||
|
if (@rename($temporary, $target)) { |
||||||
|
return; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
throw new \RuntimeException("Cannot replace file: {$target}"); |
||||||
|
} |
||||||
|
|
||||||
|
private static function ensureDirectory(string $directory): void |
||||||
|
{ |
||||||
|
if (!is_dir($directory) && !mkdir($directory, 0777, true) && !is_dir($directory)) { |
||||||
|
throw new \RuntimeException("Cannot create directory: {$directory}"); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue