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