diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a5fc960..b402b17 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,11 +22,16 @@ jobs: extensions: - "" - "uv-amphp/ext-uv@master" + - "uv-amphp/ext-uv@master, eio" + - "eio" steps: - uses: actions/checkout@v2 - name: Install libuv if: matrix.os == 'ubuntu-latest' run: sudo apt-get install libuv1-dev + - name: Install ext-eio + if: matrix.os == 'ubuntu-latest' && (matrix.extensions == 'eio' || matrix.extensions == 'uv-amphp/ext-uv@master, eio') + run: sudo pecl install eio || sudo pecl install eio-beta - uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} diff --git a/README.md b/README.md index 4093eeb..4c8876f 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ * [Factory](#factory) * [create()](#create) * [Filesystem implementations](#filesystem-implementations) + * [Eio](#eio) * [Uv](#uv) * [AdapterInterface](#adapterinterface) * [detect()](#detect) @@ -110,6 +111,15 @@ manually instantiate one of the following classes. Note that you may have to install the required PHP extensions for the respective event loop implementation first or they will throw a `BadMethodCallException` on creation. +#### Eio + +An `ext-eio` based filesystem. + +This filesystem uses the [`eio` PECL extension](https://pecl.php.net/package/eio), that +provides an interface to `libeio` library. + +This filesystem is known to work with PHP 7+. + #### Uv An `ext-uv` based filesystem. diff --git a/composer.json b/composer.json index 675633a..4ee56df 100644 --- a/composer.json +++ b/composer.json @@ -34,6 +34,7 @@ } }, "suggest": { + "ext-eio": "* for great I/O performance", "ext-uv": "* for better I/O performance" }, "config": { diff --git a/src/Eio/Adapter.php b/src/Eio/Adapter.php new file mode 100644 index 0000000..8b24410 --- /dev/null +++ b/src/Eio/Adapter.php @@ -0,0 +1,67 @@ +loop = Loop::get(); + $this->poll = new Poll($this->loop); + } + + public function detect(string $path): PromiseInterface + { + return $this->internalStat($path)->then(function (?Stat $stat) use ($path) { + if ($stat === null) { + return new NotExist($this->poll, $this, $this->loop, dirname($path) . DIRECTORY_SEPARATOR, basename($path)); + } + + switch (ModeTypeDetector::detect($stat->mode())) { + case Node\FileInterface::class: + return $this->file($stat->path()); + break; + case Node\DirectoryInterface::class: + return $this->directory($stat->path()); + break; + default: + return new Node\Unknown($stat->path(), $stat->path()); + break; + } + }); + } + + public function directory(string $path): Node\DirectoryInterface + { + return new Directory($this->poll, $this, dirname($path) . DIRECTORY_SEPARATOR, basename($path)); + } + + public function file(string $path): Node\FileInterface + { + return new File($this->poll, dirname($path) . DIRECTORY_SEPARATOR, basename($path)); + } + + protected function activate(): void + { + $this->poll->activate(); + } + + protected function deactivate(): void + { + $this->poll->deactivate(); + } +} diff --git a/src/Eio/Directory.php b/src/Eio/Directory.php new file mode 100644 index 0000000..a5ee925 --- /dev/null +++ b/src/Eio/Directory.php @@ -0,0 +1,101 @@ +poll = $poll; + $this->filesystem = $filesystem; + $this->path = $path; + $this->name = $name; + } + + public function stat(): PromiseInterface + { + return $this->internalStat($this->path . $this->name); + } + + public function ls(): PromiseInterface + { + $this->activate(); + return new Promise(function (callable $resolve): void { + \eio_readdir($this->path . $this->name . DIRECTORY_SEPARATOR, \EIO_READDIR_STAT_ORDER | \EIO_READDIR_DIRS_FIRST, \EIO_PRI_DEFAULT, function ($_, $contents) use ($resolve): void { + $this->deactivate(); + $list = []; + foreach ($contents['dents'] as $node) { + $fullPath = $this->path . $this->name . DIRECTORY_SEPARATOR . $node['name']; + switch ($node['type'] ?? null) { + case EIO_DT_DIR: + $list[] = $this->filesystem->directory($fullPath); + break; + case EIO_DT_REG : + $list[] = $this->filesystem->file($fullPath); + break; + default: + $list[] = $this->filesystem->detect($this->path . $this->name . DIRECTORY_SEPARATOR . $node['name']); + break; + } + } + + $resolve(all($list)); + }); + }); + } + + public function unlink(): PromiseInterface + { + $this->activate(); + return new Promise(function (callable $resolve): void { + \eio_readdir($this->path . $this->name . DIRECTORY_SEPARATOR, \EIO_READDIR_STAT_ORDER | \EIO_READDIR_DIRS_FIRST, \EIO_PRI_DEFAULT, function ($_, $contents) use ($resolve): void { + $this->deactivate(); + if (count($contents['dents']) > 0) { + $resolve(false); + + return; + } + + $this->activate(); + \eio_rmdir($this->path . $this->name, function () use ($resolve): void { + $this->deactivate(); + $resolve(true); + }); + }); + }); + } + + public function path(): string + { + return $this->path; + } + + public function name(): string + { + return $this->name; + } + + protected function activate(): void + { + $this->poll->activate(); + } + + protected function deactivate(): void + { + $this->poll->deactivate(); + } +} diff --git a/src/Eio/EventStream.php b/src/Eio/EventStream.php new file mode 100644 index 0000000..1777061 --- /dev/null +++ b/src/Eio/EventStream.php @@ -0,0 +1,25 @@ +poll = $poll; + $this->path = $path; + $this->name = $name; + } + + public function stat(): PromiseInterface + { + return $this->internalStat($this->path . $this->name); + } + + public function getContents(int $offset = 0 , ?int $maxlen = null): PromiseInterface + { + $this->activate(); + return $this->openFile( + $this->path . DIRECTORY_SEPARATOR . $this->name, + \EIO_O_RDONLY, + 0, + )->then( + function ($fileDescriptor) use ($offset, $maxlen): PromiseInterface { + if ($maxlen === null) { + $sizePromise = $this->statFileDescriptor($fileDescriptor)->then(static function ($stat): int { + return (int)$stat['size']; + }); + } else { + $sizePromise = resolve($maxlen); + } + return $sizePromise->then(function ($length) use ($fileDescriptor, $offset): PromiseInterface { + return new Promise (function (callable $resolve) use ($fileDescriptor, $offset, $length): void { + \eio_read($fileDescriptor, $length, $offset, \EIO_PRI_DEFAULT, function ($fileDescriptor, string $buffer) use ($resolve): void { + $resolve($this->closeOpenFile($fileDescriptor)->then(function () use ($buffer): string { + return $buffer; + })); + }, $fileDescriptor); + }); + }); + } + ); + } + + public function putContents(string $contents, int $flags = 0) + { + $this->activate(); + return $this->openFile( + $this->path . DIRECTORY_SEPARATOR . $this->name, + (($flags & \FILE_APPEND) == \FILE_APPEND) ? \EIO_O_RDWR | \EIO_O_APPEND : \EIO_O_RDWR | \EIO_O_CREAT, + 0644 + )->then( + function ($fileDescriptor) use ($contents, $flags): PromiseInterface { + return new Promise (function (callable $resolve) use ($contents, $fileDescriptor): void { + \eio_write($fileDescriptor, $contents, strlen($contents), 0, \EIO_PRI_DEFAULT, function ($fileDescriptor, int $bytesWritten) use ($resolve): void { + $resolve($this->closeOpenFile($fileDescriptor)->then(function () use ($bytesWritten): int { + return $bytesWritten; + })); + }, $fileDescriptor); + }); + } + ); + } + + private function statFileDescriptor($fileDescriptor): PromiseInterface + { + return new Promise(function (callable $resolve, callable $reject) use ($fileDescriptor) { + \eio_fstat($fileDescriptor, \EIO_PRI_DEFAULT, function ($_, $stat) use ($resolve): void { + $resolve($stat); + }, $fileDescriptor); + }); + } + + private function openFile(string $path, int $flags, int $mode): PromiseInterface + { + return new Promise(function (callable $resolve, callable $reject) use ($path, $flags, $mode): void { + \eio_open( + $path, + $flags, + $mode, + \EIO_PRI_DEFAULT, + function ($_, $fileDescriptor) use ($resolve): void { + $resolve($fileDescriptor); + } + ); + }); + } + + private function closeOpenFile($fileDescriptor): PromiseInterface + { + return new Promise(function (callable $resolve) use ($fileDescriptor) { + try { + \eio_close($fileDescriptor, \EIO_PRI_DEFAULT, function () use ($resolve): void { + $this->deactivate(); + $resolve(); + }); + } catch (\Throwable $error) { + $this->deactivate(); + throw $error; + } + }); + } + + public function unlink(): PromiseInterface + { + $this->activate(); + return new Promise(function (callable $resolve): void { + \eio_unlink($this->path . DIRECTORY_SEPARATOR . $this->name, \EIO_PRI_DEFAULT, function () use ($resolve): void { + $this->deactivate(); + $resolve(true); + }); + }); + } + + public function path(): string + { + return $this->path; + } + + public function name(): string + { + return $this->name; + } + + protected function activate(): void + { + $this->poll->activate(); + } + + protected function deactivate(): void + { + $this->poll->deactivate(); + } +} diff --git a/src/Eio/NotExist.php b/src/Eio/NotExist.php new file mode 100644 index 0000000..e5ad03b --- /dev/null +++ b/src/Eio/NotExist.php @@ -0,0 +1,117 @@ +poll = $poll; + $this->filesystem = $filesystem; + $this->loop = $loop; + $this->path = $path; + $this->name = $name; + } + + public function stat(): PromiseInterface + { + return $this->internalStat($this->path . $this->name); + } + + public function createDirectory(): PromiseInterface + { + return $this->filesystem->detect($this->path)->then(function (Node\NodeInterface $node): PromiseInterface { + if ($node instanceof Node\NotExistInterface) { + return $node->createDirectory(); + } + + return resolve($node); + })->then(function (Node\DirectoryInterface $directory): PromiseInterface { + return new Promise(function (callable $resolve): void { + $this->activate(); + \eio_mkdir($this->path . $this->name, 0777, \EIO_PRI_DEFAULT, function () use ($resolve): void { + $this->deactivate(); + $resolve($this->filesystem->directory($this->path . $this->name)); + }); + }); + }); + } + + public function createFile(): PromiseInterface + { + $file = new File($this->poll, $this->path, $this->name); + + return $this->filesystem->detect($this->path)->then(function (Node\NodeInterface $node): PromiseInterface { + if ($node instanceof Node\NotExistInterface) { + return $node->createDirectory(); + } + + return resolve($node); + })->then(function () use ($file): PromiseInterface { + $this->activate(); + return new Promise(function (callable $resolve, callable $reject): void { + \eio_open( + $this->path . DIRECTORY_SEPARATOR . $this->name, + \EIO_O_RDWR | \EIO_O_CREAT, + 0777, + \EIO_PRI_DEFAULT, + function ($_, $fileDescriptor) use ($resolve, $reject): void { + try { + \eio_close($fileDescriptor, \EIO_PRI_DEFAULT, function () use ($resolve) { + $this->deactivate(); + $resolve($this->filesystem->file($this->path . $this->name)); + }); + } catch (\Throwable $error) { + $this->deactivate(); + $reject($error); + } + } + ); + }); + })->then(function () use ($file): Node\FileInterface { + return $file; + }); + } + + public function unlink(): PromiseInterface + { + // Essentially a No-OP since it doesn't exist anyway + return resolve(true); + } + + public function path(): string + { + return $this->path; + } + + public function name(): string + { + return $this->name; + } + + protected function activate(): void + { + $this->poll->activate(); + } + + protected function deactivate(): void + { + $this->poll->deactivate(); + } +} diff --git a/src/Eio/Poll.php b/src/Eio/Poll.php new file mode 100644 index 0000000..ff97e8f --- /dev/null +++ b/src/Eio/Poll.php @@ -0,0 +1,44 @@ +fd = EventStream::get(); + $this->loop = $loop; + $this->handleEvent = function () { + $this->handleEvent(); + }; + } + + public function activate(): void + { + if ($this->workInProgress++ === 0) { + $this->loop->addReadStream($this->fd, $this->handleEvent); + } + } + + private function handleEvent() + { + while (eio_npending()) { + eio_poll(); + } + } + + public function deactivate(): void + { + if (--$this->workInProgress <= 0) { + $this->loop->removeReadStream($this->fd); + } + } +} diff --git a/src/Eio/StatTrait.php b/src/Eio/StatTrait.php new file mode 100644 index 0000000..d634400 --- /dev/null +++ b/src/Eio/StatTrait.php @@ -0,0 +1,32 @@ +activate(); + eio_lstat($path, EIO_PRI_DEFAULT, function ($_, $stat) use ($path, $resolve, $reject): void { + try { + $this->deactivate(); + if (is_array($stat)) { + $resolve(new Stat($path, $stat)); + } else { + $resolve(null); + } + } catch (\Throwable $error) { + $reject($error); + } + }); + }); + } + + abstract protected function activate(): void; + abstract protected function deactivate(): void; +} diff --git a/src/Factory.php b/src/Factory.php index 94880c5..9d5e294 100644 --- a/src/Factory.php +++ b/src/Factory.php @@ -4,15 +4,17 @@ use React\EventLoop\ExtUvLoop; use React\EventLoop\Loop; -use React\EventLoop\LoopInterface; use React\Filesystem\Uv; use React\Filesystem\Eio; -use React\Filesystem\ChildProcess; final class Factory { public static function create(): AdapterInterface { + if (\function_exists('eio_get_event_stream')) { + return new Eio\Adapter(); + } + if (\function_exists('uv_loop_new') && Loop::get() instanceof ExtUvLoop) { return new Uv\Adapter(); } diff --git a/tests/AbstractFilesystemTestCase.php b/tests/AbstractFilesystemTestCase.php index 4bee83a..cdbc396 100644 --- a/tests/AbstractFilesystemTestCase.php +++ b/tests/AbstractFilesystemTestCase.php @@ -2,14 +2,15 @@ namespace React\Tests\Filesystem; -use React\EventLoop; -use React\Filesystem\Fallback; -use React\Filesystem\Uv; use PHPUnit\Framework\TestCase; +use React\EventLoop; use React\EventLoop\ExtUvLoop; use React\EventLoop\LoopInterface; -use React\Filesystem\Factory; use React\Filesystem\AdapterInterface; +use React\Filesystem\Eio; +use React\Filesystem\Factory; +use React\Filesystem\Fallback; +use React\Filesystem\Uv; abstract class AbstractFilesystemTestCase extends TestCase { @@ -22,6 +23,10 @@ final public function provideFilesystems(): iterable yield 'fallback' => [new Fallback\Adapter()]; + if (\function_exists('eio_get_event_stream')) { + yield 'eio' => [new Eio\Adapter()]; + } + if (\function_exists('uv_loop_new') && $loop instanceof ExtUvLoop) { yield 'uv' => [new Uv\Adapter()]; } diff --git a/tests/FactoryTest.php b/tests/FactoryTest.php index 50d2076..97651f1 100644 --- a/tests/FactoryTest.php +++ b/tests/FactoryTest.php @@ -2,11 +2,7 @@ namespace React\Tests\Filesystem; -use React\EventLoop; -use React\EventLoop\LoopInterface; use React\Filesystem\Factory; -use React\Filesystem\AdapterInterface; -use React\Filesystem\Node\DirectoryInterface; use React\Filesystem\Node\FileInterface; use function React\Async\await;