diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d261c5b..54f6615 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,12 +11,22 @@ jobs: include: - operating-system: 'ubuntu-latest' php-version: '8.1' + extensions: uv, eio - operating-system: 'ubuntu-latest' php-version: '8.2' + extensions: uv, eio - operating-system: 'ubuntu-latest' php-version: '8.3' + extensions: uv + style-fix: none + static-analysis: none + + - operating-system: 'ubuntu-latest' + php-version: '8.4' + extensions: uv + style-fix: none static-analysis: none - operating-system: 'windows-latest' @@ -26,7 +36,9 @@ jobs: - operating-system: 'macos-latest' php-version: '8.3' + extensions: uv job-description: 'on macOS' + style-fix: none static-analysis: none @@ -53,7 +65,7 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php-version }} - extensions: eio-beta, uv-amphp/ext-uv@master + extensions: ${{ matrix.extensions }} - name: Get Composer cache directory id: composer-cache @@ -91,7 +103,7 @@ jobs: env: PHP_CS_FIXER_IGNORE_ENV: 1 run: vendor/bin/php-cs-fixer --diff --dry-run -v fix - if: runner.os != 'Windows' + if: runner.os != 'Windows' && matrix.style-fix != 'none' - name: Install composer-require-checker run: php -r 'file_put_contents("composer-require-checker.phar", file_get_contents("https://github.com/maglnet/ComposerRequireChecker/releases/download/3.7.0/composer-require-checker.phar"));' diff --git a/README.md b/README.md index 3d22bbe..b7f96c7 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,10 @@ This package can be installed as a [Composer](https://getcomposer.org/) dependen composer require amphp/file ``` +## Requirements + +- PHP 8.1+ + `amphp/file` works out of the box without any PHP extensions. It uses multiple processes by default, but also comes with a blocking driver that uses PHP's blocking functions in the current process. @@ -163,7 +167,7 @@ array(13) { ## Security -If you discover any security related issues, please email [`me@kelunik.com`](mailto:me@kelunik.com) instead of using the issue tracker. +If you discover any security related issues, please use the private security issue reporter instead of using the public issue tracker. ## License diff --git a/composer-require-check.json b/composer-require-check.json index 93a5db8..3067f87 100644 --- a/composer-require-check.json +++ b/composer-require-check.json @@ -62,6 +62,7 @@ "EIO_S_IWUSR", "EIO_S_IXUSR", "UV", + "UVLoop", "uv_fs_chmod", "uv_fs_chown", "uv_fs_fstat", diff --git a/composer.json b/composer.json index 80dea86..6f46377 100644 --- a/composer.json +++ b/composer.json @@ -43,7 +43,7 @@ "require-dev": { "amphp/phpunit-util": "^3", "phpunit/phpunit": "^9", - "psalm/phar": "^5.4", + "psalm/phar": "5.22.2", "amphp/php-cs-fixer-config": "^2" }, "suggest": { @@ -59,11 +59,13 @@ "autoload-dev": { "psr-4": { "Amp\\File\\Test\\": "test", + "Amp\\Cache\\Test\\": "vendor/amphp/cache/test", "Amp\\Sync\\": "vendor/amphp/sync/test" } }, "config": { "preferred-install": { + "amphp/cache": "source", "amphp/sync": "source" } }, diff --git a/psalm.xml b/psalm.xml index e774aea..cf504b6 100644 --- a/psalm.xml +++ b/psalm.xml @@ -48,5 +48,12 @@ + + + + + + + diff --git a/src/Driver/ParallelFilesystemDriver.php b/src/Driver/ParallelFilesystemDriver.php index 4c978bf..f18a0a8 100644 --- a/src/Driver/ParallelFilesystemDriver.php +++ b/src/Driver/ParallelFilesystemDriver.php @@ -22,7 +22,7 @@ final class ParallelFilesystemDriver implements FilesystemDriver /** @var int Maximum number of workers to use for open files. */ private int $workerLimit; - /** @var \SplObjectStorage Worker storage. */ + /** @var \SplObjectStorage Worker storage. */ private \SplObjectStorage $workerStorage; /** @var Future Pending worker request */ @@ -31,11 +31,11 @@ final class ParallelFilesystemDriver implements FilesystemDriver /** * @param int $workerLimit Maximum number of workers to use from the pool for open files. */ - public function __construct(WorkerPool $pool = null, int $workerLimit = self::DEFAULT_WORKER_LIMIT) + public function __construct(?WorkerPool $pool = null, int $workerLimit = self::DEFAULT_WORKER_LIMIT) { $this->pool = $pool ?? workerPool(); $this->workerLimit = $workerLimit; - $this->workerStorage = new \SplObjectStorage; + $this->workerStorage = new \SplObjectStorage(); $this->pendingWorker = Future::complete(); } @@ -45,8 +45,11 @@ final class ParallelFilesystemDriver implements FilesystemDriver $workerStorage = $this->workerStorage; $worker = new Internal\FileWorker($worker, static function (Worker $worker) use ($workerStorage): void { - \assert($workerStorage->contains($worker)); - if (($workerStorage[$worker] -=1) === 0 || !$worker->isRunning()) { + if (!$workerStorage->contains($worker)) { + return; + } + + if (($workerStorage[$worker] -= 1) === 0 || !$worker->isRunning()) { $workerStorage->detach($worker); } }); diff --git a/src/Driver/UvFile.php b/src/Driver/UvFile.php index 23765bc..5b3f770 100644 --- a/src/Driver/UvFile.php +++ b/src/Driver/UvFile.php @@ -1,5 +1,4 @@ eventLoopHandle = $driver->getHandle(); $this->onClose = new DeferredFuture; - - $this->priorVersion = \version_compare(\phpversion('uv'), '0.3.0', '<'); } public function read(?Cancellation $cancellation = null, int $length = self::DEFAULT_READ_LENGTH): ?string @@ -86,16 +79,6 @@ final class UvFile extends Internal\QueuedWritesFile $deferred->complete($length ? $buffer : null); }; - if ($this->priorVersion) { - $onRead = static function ($fh, $result, $buffer) use ($onRead): void { - if ($result < 0) { - $buffer = $result; // php-uv v0.3.0 changed the callback to put an int in $buffer on error. - } - - $onRead($result, $buffer); - }; - } - \uv_fs_read($this->eventLoopHandle, $this->fh, $this->position, $length, $onRead); $id = $cancellation?->subscribe(function (\Throwable $exception) use ($deferred): void { diff --git a/src/Driver/UvFilesystemDriver.php b/src/Driver/UvFilesystemDriver.php index 695bdc3..f396c70 100644 --- a/src/Driver/UvFilesystemDriver.php +++ b/src/Driver/UvFilesystemDriver.php @@ -1,5 +1,4 @@ =') && $driver->getHandle() instanceof \UVLoop; } - /** @var \UVLoop|resource Loop resource of type uv_loop or instance of \UVLoop. */ - private $eventLoopHandle; + private readonly \UVLoop $eventLoopHandle; private readonly Internal\UvPoll $poll; - /** @var bool True if ext-uv version is < 0.3.0. */ - private readonly bool $priorVersion; - - public function __construct(private readonly UvLoopDriver $driver) + public function __construct(private readonly EventLoopDriver $driver) { + if (!self::isSupported($driver)) { + throw new \Error('Event loop did not return a compatible handle'); + } + /** @psalm-suppress PropertyTypeCoercion */ $this->eventLoopHandle = $driver->getHandle(); $this->poll = new Internal\UvPoll($driver); - $this->priorVersion = \version_compare(\phpversion('uv'), '0.3.0', '<'); } public function openFile(string $path, string $mode): UvFile @@ -83,16 +85,6 @@ final class UvFilesystemDriver implements FilesystemDriver $deferred->complete($stat); }; - if ($this->priorVersion) { - $callback = static function ($fh, $stat) use ($callback): void { - if (empty($fh)) { - $stat = 0; - } - - $callback($stat); - }; - } - \uv_fs_stat($this->eventLoopHandle, $path, $callback); try { @@ -107,17 +99,9 @@ final class UvFilesystemDriver implements FilesystemDriver $deferred = new DeferredFuture; $this->poll->listen(); - if ($this->priorVersion) { - $callback = static function ($fh, $stat) use ($deferred): void { - $deferred->complete(empty($fh) ? null : $stat); - }; - } else { - $callback = static function ($stat) use ($deferred): void { - $deferred->complete(\is_int($stat) ? null : $stat); - }; - } - - \uv_fs_lstat($this->eventLoopHandle, $path, $callback); + \uv_fs_lstat($this->eventLoopHandle, $path, static function ($stat) use ($deferred): void { + $deferred->complete(\is_int($stat) ? null : $stat); + }); try { return $deferred->getFuture()->await(); @@ -160,27 +144,14 @@ final class UvFilesystemDriver implements FilesystemDriver $deferred = new DeferredFuture; $this->poll->listen(); - if ($this->priorVersion) { - $callback = static function ($fh, $target) use ($deferred): void { - if (!(bool) $fh) { - $deferred->error(new FilesystemException("Could not read symbolic link")); - return; - } + \uv_fs_readlink($this->eventLoopHandle, $target, static function ($target) use ($deferred): void { + if (\is_int($target)) { + $deferred->error(new FilesystemException("Could not read symbolic link")); + return; + } - $deferred->complete($target); - }; - } else { - $callback = static function ($target) use ($deferred): void { - if (\is_int($target)) { - $deferred->error(new FilesystemException("Could not read symbolic link")); - return; - } - - $deferred->complete($target); - }; - } - - \uv_fs_readlink($this->eventLoopHandle, $target, $callback); + $deferred->complete($target); + }); try { return $deferred->getFuture()->await(); @@ -297,28 +268,16 @@ final class UvFilesystemDriver implements FilesystemDriver $deferred = new DeferredFuture; $this->poll->listen(); - if ($this->priorVersion) { - \uv_fs_readdir($this->eventLoopHandle, $path, 0, static function ($fh, $data) use ($deferred, $path): void { - if (empty($fh) && $data !== 0) { - $deferred->error(new FilesystemException("Failed reading contents from {$path}")); - } elseif ($data === 0) { - $deferred->complete([]); - } else { - $deferred->complete($data); - } - }); - } else { - /** @noinspection PhpUndefinedFunctionInspection */ - \uv_fs_scandir($this->eventLoopHandle, $path, static function ($data) use ($deferred, $path): void { - if (\is_int($data) && $data !== 0) { - $deferred->error(new FilesystemException("Failed reading contents from {$path}")); - } elseif ($data === 0) { - $deferred->complete([]); - } else { - $deferred->complete($data); - } - }); - } + /** @noinspection PhpUndefinedFunctionInspection */ + \uv_fs_scandir($this->eventLoopHandle, $path, static function ($data) use ($deferred, $path): void { + if (\is_int($data) && $data !== 0) { + $deferred->error(new FilesystemException("Failed reading contents from {$path}")); + } elseif ($data === 0) { + $deferred->complete([]); + } else { + $deferred->complete($data); + } + }); try { return $deferred->getFuture()->await(); @@ -528,42 +487,24 @@ final class UvFilesystemDriver implements FilesystemDriver { $deferred = new DeferredFuture; - if ($this->priorVersion) { - $callback = static function ($fileHandle, $readBytes, $buffer) use ($deferred): void { - $deferred->complete($readBytes < 0 ? null : $buffer); - }; - } else { - $callback = static function ($readBytes, $buffer) use ($deferred): void { - $deferred->complete($readBytes < 0 ? null : $buffer); - }; - } + $callback = static function ($readBytes, $buffer) use ($deferred): void { + $deferred->complete($readBytes < 0 ? null : $buffer); + }; \uv_fs_read($this->eventLoopHandle, $fileHandle, 0, $length, $callback); return $deferred->getFuture()->await(); } - private function doWrite(string $path, string $contents): void - { - } - private function createGenericCallback(DeferredFuture $deferred, string $error): \Closure { - $callback = static function (int $result) use ($deferred, $error): void { + return static function (int $result) use ($deferred, $error): void { if ($result !== 0) { $deferred->error(new FilesystemException($error)); return; } - $deferred->complete(null); + $deferred->complete(); }; - - if ($this->priorVersion) { - $callback = static function (bool $result) use ($callback): void { - $callback($result ? 0 : -1); - }; - } - - return $callback; } } diff --git a/src/FileCache.php b/src/FileCache.php new file mode 100644 index 0000000..f0971f5 --- /dev/null +++ b/src/FileCache.php @@ -0,0 +1,181 @@ +filesystem = $filesystem; + $this->directory = $directory = \rtrim($directory, "/\\"); + + $gcWatcher = static function () use ($directory, $mutex, $filesystem): void { + try { + $files = $filesystem->listFiles($directory); + + foreach ($files as $file) { + if (\strlen($file) !== 70 || !\str_ends_with($file, '.cache')) { + continue; + } + + try { + $lock = $mutex->acquire($file); + } catch (\Throwable) { + continue; + } + + try { + $handle = $filesystem->openFile($directory . '/' . $file, 'r'); + $ttl = $handle->read(length: 4); + + if ($ttl === null || \strlen($ttl) !== 4) { + $handle->close(); + continue; + } + + $ttl = \unpack('Nttl', $ttl)['ttl']; + if ($ttl < \time()) { + $filesystem->deleteFile($directory . '/' . $file); + } + } catch (\Throwable) { + // ignore + } finally { + $lock->release(); + } + } + } catch (\Throwable) { + // ignore + } + }; + + // trigger once, so short running scripts also GC and don't grow forever + EventLoop::defer($gcWatcher); + + $this->gcWatcher = EventLoop::repeat(300, $gcWatcher); + + EventLoop::unreference($this->gcWatcher); + } + + public function __destruct() + { + if ($this->gcWatcher !== null) { + EventLoop::cancel($this->gcWatcher); + } + } + + public function get(string $key): ?string + { + $filename = $this->getFilename($key); + + $lock = $this->lock($filename); + + try { + $cacheContent = $this->filesystem->read($this->directory . '/' . $filename); + + if (\strlen($cacheContent) < 4) { + return null; + } + + $ttl = \unpack('Nttl', \substr($cacheContent, 0, 4))['ttl']; + if ($ttl < \time()) { + $this->filesystem->deleteFile($this->directory . '/' . $filename); + + return null; + } + + $value = \substr($cacheContent, 4); + + \assert(\is_string($value)); + + return $value; + } catch (\Throwable) { + return null; + } finally { + $lock->release(); + } + } + + public function set(string $key, string $value, ?int $ttl = null): void + { + if ($ttl < 0) { + throw new \Error("Invalid cache TTL ({$ttl}); integer >= 0 or null required"); + } + + $filename = $this->getFilename($key); + + $lock = $this->lock($filename); + + if ($ttl === null) { + $ttl = \PHP_INT_MAX; + } else { + $ttl = \time() + $ttl; + } + + $encodedTtl = \pack('N', $ttl); + + try { + $this->filesystem->write($this->directory . '/' . $filename, $encodedTtl . $value); + } finally { + $lock->release(); + } + } + + public function delete(string $key): ?bool + { + $filename = $this->getFilename($key); + + $lock = $this->lock($filename); + + try { + $this->filesystem->deleteFile($this->directory . '/' . $filename); + } catch (FilesystemException) { + return false; + } finally { + $lock->release(); + } + + return true; + } + + private static function getFilename(string $key): string + { + return \hash('sha256', $key) . '.cache'; + } + + private function lock(string $key): Lock + { + try { + return $this->mutex->acquire($key); + } catch (\Throwable $exception) { + throw new CacheException( + \sprintf('Exception thrown when obtaining the lock for key "%s"', $key), + 0, + $exception + ); + } + } +} diff --git a/src/FileMutex.php b/src/FileMutex.php index 444dd68..f4fc820 100644 --- a/src/FileMutex.php +++ b/src/FileMutex.php @@ -2,33 +2,45 @@ namespace Amp\File; +use Amp\Cancellation; use Amp\Sync\Lock; use Amp\Sync\Mutex; +use Amp\Sync\SyncException; use function Amp\delay; final class FileMutex implements Mutex { private const LATENCY_TIMEOUT = 0.01; + private const DELAY_LIMIT = 1; + + private readonly Filesystem $filesystem; + + private readonly string $directory; /** * @param string $fileName Name of temporary file to use as a mutex. */ - public function __construct(private readonly string $fileName) + public function __construct(private readonly string $fileName, ?Filesystem $filesystem = null) { + $this->filesystem = $filesystem ?? filesystem(); + $this->directory = \dirname($this->fileName); } - public function acquire(): Lock + public function acquire(?Cancellation $cancellation = null): Lock { + if (!$this->filesystem->isDirectory($this->directory)) { + throw new SyncException(\sprintf('Directory of "%s" does not exist or is not a directory', $this->fileName)); + } $f = \fopen($this->fileName, 'c'); - while (true) { - if (\flock($f, LOCK_EX|LOCK_NB)) { - // Return a lock object that can be used to release the lock on the mutex. - $lock = new Lock(fn () => \flock($f, LOCK_UN)); + // Try to create the lock file. If the file already exists, someone else + // has the lock, so set an asynchronous timer and try again. + for ($attempt = 0; true; ++$attempt) { + if (\flock($f, LOCK_EX|LOCK_NB)) { + $lock = new Lock(fn () => \flock($f, LOCK_UN)); return $lock; } - - delay(self::LATENCY_TIMEOUT); + delay(\min(self::DELAY_LIMIT, self::LATENCY_TIMEOUT * (2 ** $attempt)), cancellation: $cancellation); } } } diff --git a/src/FilesystemException.php b/src/FilesystemException.php index d2b2cdb..5e58ea4 100644 --- a/src/FilesystemException.php +++ b/src/FilesystemException.php @@ -4,7 +4,7 @@ namespace Amp\File; class FilesystemException extends \Exception { - public function __construct(string $message, \Throwable $previous = null) + public function __construct(string $message, ?\Throwable $previous = null) { parent::__construct($message, 0, $previous); } diff --git a/src/Internal/QueuedWritesFile.php b/src/Internal/QueuedWritesFile.php index ad91e2b..30b4d97 100644 --- a/src/Internal/QueuedWritesFile.php +++ b/src/Internal/QueuedWritesFile.php @@ -38,8 +38,8 @@ abstract class QueuedWritesFile implements File, \IteratorAggregate } $this->queue = new \SplQueue(); - $this->writable = $this->mode[0] !== 'r'; - $this->position = $this->mode[0] === 'a' ? $this->size : 0; + $this->writable = !\str_contains($this->mode, 'r') || \str_contains($this->mode, '+'); + $this->position = \str_contains($this->mode, 'a') ? $this->size : 0; } public function __destruct() diff --git a/src/Internal/UvPoll.php b/src/Internal/UvPoll.php index 5bccadf..fec197d 100644 --- a/src/Internal/UvPoll.php +++ b/src/Internal/UvPoll.php @@ -2,7 +2,7 @@ namespace Amp\File\Internal; -use Revolt\EventLoop\Driver\UvDriver as UvLoopDriver; +use Revolt\EventLoop\Driver as EventLoopDriver; /** @internal */ final class UvPoll @@ -11,7 +11,7 @@ final class UvPoll private int $requests = 0; - public function __construct(private readonly UvLoopDriver $driver) + public function __construct(private readonly EventLoopDriver $driver) { // Create dummy watcher to keep loop running while polling. diff --git a/src/KeyedFileMutex.php b/src/KeyedFileMutex.php new file mode 100644 index 0000000..a6a340c --- /dev/null +++ b/src/KeyedFileMutex.php @@ -0,0 +1,76 @@ +filesystem = $filesystem ?? filesystem(); + $this->directory = \rtrim($directory, "/\\"); + } + + public function acquire(string $key, ?Cancellation $cancellation = null): Lock + { + if (!$this->filesystem->isDirectory($this->directory)) { + throw new SyncException(\sprintf('Directory "%s" does not exist or is not a directory', $this->directory)); + } + + $filename = $this->getFilename($key); + + // Try to create the lock file. If the file already exists, someone else + // has the lock, so set an asynchronous timer and try again. + for ($attempt = 0; true; ++$attempt) { + try { + $file = $this->filesystem->openFile($filename, 'x'); + + // Return a lock object that can be used to release the lock on the mutex. + $lock = new Lock(fn () => $this->release($filename)); + + $file->close(); + + return $lock; + } catch (FilesystemException) { + delay(\min(self::DELAY_LIMIT, self::LATENCY_TIMEOUT * (2 ** $attempt)), cancellation: $cancellation); + } + } + } + + /** + * Releases the lock on the mutex. + * + * @throws SyncException + */ + private function release(string $filename): void + { + try { + $this->filesystem->deleteFile($filename); + } catch (\Throwable $exception) { + throw new SyncException( + 'Failed to unlock the mutex file: ' . $filename, + previous: $exception, + ); + } + } + + private function getFilename(string $key): string + { + return $this->directory . '/' . \hash('sha256', $key) . '.lock'; + } +} diff --git a/src/Whence.php b/src/Whence.php index e21b8cb..21ef1d0 100644 --- a/src/Whence.php +++ b/src/Whence.php @@ -8,10 +8,12 @@ enum Whence * Set position equal to offset bytes. */ case Start; + /** * Set position to current location plus offset. */ case Current; + /** * Set position to end-of-file plus offset. */ diff --git a/src/functions.php b/src/functions.php index c81dd7e..80f8b3d 100644 --- a/src/functions.php +++ b/src/functions.php @@ -55,7 +55,6 @@ function createDefaultDriver(): FilesystemDriver $driver = EventLoop::getDriver(); if (UvFilesystemDriver::isSupported($driver)) { - /** @var EventLoop\Driver\UvDriver $driver */ return new UvFilesystemDriver($driver); } diff --git a/test/FileCacheTest.php b/test/FileCacheTest.php new file mode 100644 index 0000000..ed3daa6 --- /dev/null +++ b/test/FileCacheTest.php @@ -0,0 +1,27 @@ +