From b1dd831d8612457ba45dff234ea884c9f9ec9ccc Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Mon, 25 May 2026 18:16:00 +0200 Subject: [PATCH] [Php86] Polyfill the Io\Poll API Tracks https://wiki.php.net/rfc/poll_api for PHP 8.6. Requires PHP >= 8.1 (enums); only the Poll backend is available since the polyfill is backed by stream_select(). --- README.md | 1 + src/Php86/README.md | 2 + src/Php86/Resources/stubs/Io/IoException.php | 18 + src/Php86/Resources/stubs/Io/Poll/Backend.php | 42 ++ .../Io/Poll/BackendUnavailableException.php | 18 + src/Php86/Resources/stubs/Io/Poll/Context.php | 217 ++++++++ src/Php86/Resources/stubs/Io/Poll/Event.php | 25 + .../FailedContextInitializationException.php | 18 + .../Io/Poll/FailedHandleAddException.php | 18 + .../Io/Poll/FailedPollOperationException.php | 30 ++ .../stubs/Io/Poll/FailedPollWaitException.php | 18 + .../FailedWatcherModificationException.php | 18 + src/Php86/Resources/stubs/Io/Poll/Handle.php | 21 + .../Io/Poll/HandleAlreadyWatchedException.php | 18 + .../Io/Poll/InactiveWatcherException.php | 18 + .../stubs/Io/Poll/InvalidHandleException.php | 18 + .../Resources/stubs/Io/Poll/PollException.php | 18 + src/Php86/Resources/stubs/Io/Poll/Watcher.php | 107 ++++ .../Resources/stubs/StreamPollHandle.php | 51 ++ tests/Php86/Php86Test.php | 6 +- tests/Php86/PollTest.php | 469 ++++++++++++++++++ 21 files changed, 1148 insertions(+), 3 deletions(-) create mode 100644 src/Php86/Resources/stubs/Io/IoException.php create mode 100644 src/Php86/Resources/stubs/Io/Poll/Backend.php create mode 100644 src/Php86/Resources/stubs/Io/Poll/BackendUnavailableException.php create mode 100644 src/Php86/Resources/stubs/Io/Poll/Context.php create mode 100644 src/Php86/Resources/stubs/Io/Poll/Event.php create mode 100644 src/Php86/Resources/stubs/Io/Poll/FailedContextInitializationException.php create mode 100644 src/Php86/Resources/stubs/Io/Poll/FailedHandleAddException.php create mode 100644 src/Php86/Resources/stubs/Io/Poll/FailedPollOperationException.php create mode 100644 src/Php86/Resources/stubs/Io/Poll/FailedPollWaitException.php create mode 100644 src/Php86/Resources/stubs/Io/Poll/FailedWatcherModificationException.php create mode 100644 src/Php86/Resources/stubs/Io/Poll/Handle.php create mode 100644 src/Php86/Resources/stubs/Io/Poll/HandleAlreadyWatchedException.php create mode 100644 src/Php86/Resources/stubs/Io/Poll/InactiveWatcherException.php create mode 100644 src/Php86/Resources/stubs/Io/Poll/InvalidHandleException.php create mode 100644 src/Php86/Resources/stubs/Io/Poll/PollException.php create mode 100644 src/Php86/Resources/stubs/Io/Poll/Watcher.php create mode 100644 src/Php86/Resources/stubs/StreamPollHandle.php create mode 100644 tests/Php86/PollTest.php diff --git a/README.md b/README.md index 3761ad59..cd056701 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,7 @@ Polyfills are provided for: - the `ARRAY_FILTER_USE_VALUE` constant introduced in PHP 8.6; - the `SortDirection` enum introduced in PHP 8.6; - the `grapheme_strrev` function introduced in PHP 8.6; +- the `Io\Poll` API and `StreamPollHandle` introduced in PHP 8.6 (requires PHP >= 8.1; only the `Poll` backend is available); It is strongly recommended to upgrade your PHP version and/or install the missing extensions whenever possible. This polyfill should be used only when there is no diff --git a/src/Php86/README.md b/src/Php86/README.md index 98822360..f29b72b5 100644 --- a/src/Php86/README.md +++ b/src/Php86/README.md @@ -6,6 +6,8 @@ This component provides features added to PHP 8.6 core: - [`clamp`](https://wiki.php.net/rfc/clamp_v2) - `ARRAY_FILTER_USE_VALUE` constant - [`SortDirection`](https://wiki.php.net/rfc/sort_direction_enum) +- `grapheme_strrev` +- [`Io\Poll` API and `StreamPollHandle`](https://wiki.php.net/rfc/poll_api) (requires PHP >= 8.1; backed by `stream_select()`, so only the `Poll` backend is available) More information can be found in the [main Polyfill README](https://github.com/symfony/polyfill/blob/main/README.md). diff --git a/src/Php86/Resources/stubs/Io/IoException.php b/src/Php86/Resources/stubs/Io/IoException.php new file mode 100644 index 00000000..8f178ab2 --- /dev/null +++ b/src/Php86/Resources/stubs/Io/IoException.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Io; + +if (\PHP_VERSION_ID < 80600 && \PHP_VERSION_ID >= 80100) { + class IoException extends \Exception + { + } +} diff --git a/src/Php86/Resources/stubs/Io/Poll/Backend.php b/src/Php86/Resources/stubs/Io/Poll/Backend.php new file mode 100644 index 00000000..61b99bec --- /dev/null +++ b/src/Php86/Resources/stubs/Io/Poll/Backend.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Io\Poll; + +if (\PHP_VERSION_ID < 80600 && \PHP_VERSION_ID >= 80100) { + enum Backend + { + case Auto; + case Poll; + case Epoll; + case Kqueue; + case EventPorts; + case WSAPoll; + + public function isAvailable(): bool + { + return match ($this) { + self::Auto, self::Poll => true, + default => false, + }; + } + + public function supportsEdgeTriggering(): bool + { + return false; + } + + public static function getAvailableBackends(): array + { + return [self::Auto, self::Poll]; + } + } +} diff --git a/src/Php86/Resources/stubs/Io/Poll/BackendUnavailableException.php b/src/Php86/Resources/stubs/Io/Poll/BackendUnavailableException.php new file mode 100644 index 00000000..b8084916 --- /dev/null +++ b/src/Php86/Resources/stubs/Io/Poll/BackendUnavailableException.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Io\Poll; + +if (\PHP_VERSION_ID < 80600 && \PHP_VERSION_ID >= 80100) { + class BackendUnavailableException extends PollException + { + } +} diff --git a/src/Php86/Resources/stubs/Io/Poll/Context.php b/src/Php86/Resources/stubs/Io/Poll/Context.php new file mode 100644 index 00000000..65d3395d --- /dev/null +++ b/src/Php86/Resources/stubs/Io/Poll/Context.php @@ -0,0 +1,217 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Io\Poll; + +if (\PHP_VERSION_ID < 80600 && \PHP_VERSION_ID >= 80100) { + final class Context + { + private Backend $backend; + + /** + * @var \SplObjectStorage + */ + private \SplObjectStorage $watchers; + + public function __construct(Backend $backend = Backend::Auto) + { + if (!$backend->isAvailable()) { + throw new BackendUnavailableException(\sprintf('Backend "%s" is not available.', $backend->name)); + } + + $this->backend = Backend::Auto === $backend ? Backend::Poll : $backend; + $this->watchers = new \SplObjectStorage(); + } + + public function getBackend(): Backend + { + return $this->backend; + } + + public function add(Handle $handle, array $events, mixed $data = null): Watcher + { + if (!$handle instanceof \StreamPollHandle) { + throw new InvalidHandleException(\sprintf('Handle of type "%s" is not supported by the polyfill; only %s is.', \get_class($handle), \StreamPollHandle::class)); + } + + if (!$handle->isValid()) { + throw new InvalidHandleException('Handle is no longer valid.'); + } + + foreach ($events as $i => $event) { + if (!$event instanceof Event) { + throw new \TypeError(\sprintf('%s(): Argument #2 ($events)[%d] must be of type %s, %s given', __METHOD__, $i, Event::class, get_debug_type($event))); + } + } + + $stream = $handle->getStream(); + foreach ($this->watchers as $existing) { + if ($existing->getHandle() === $handle + || ($existing->getHandle() instanceof \StreamPollHandle && $existing->getHandle()->getStream() === $stream) + ) { + throw new HandleAlreadyWatchedException('Handle already added'); + } + } + + static $create; + $create ??= \Closure::bind( + static fn (Context $ctx, Handle $h, array $e, mixed $d): Watcher => new Watcher($ctx, $h, $e, $d), + null, + Watcher::class, + ); + $watcher = $create($this, $handle, $events, $data); + $this->watchers[$watcher] = null; + + return $watcher; + } + + public function wait(?int $timeoutSeconds = null, int $timeoutMicroseconds = 0, ?int $maxEvents = null): array + { + static $setTriggered, $clearContext; + $setTriggered ??= \Closure::bind( + static function (Watcher $w, array $events): void { $w->triggeredEvents = $events; }, + null, + Watcher::class, + ); + $clearContext ??= \Closure::bind( + static function (Watcher $w): void { $w->context = null; }, + null, + Watcher::class, + ); + + foreach ($this->watchers as $watcher) { + $setTriggered($watcher, []); + } + + $read = $write = $except = []; + $byId = []; + + foreach ($this->watchers as $watcher) { + $handle = $watcher->getHandle(); + if (!$handle instanceof \StreamPollHandle || !$handle->isValid()) { + continue; + } + + $stream = $handle->getStream(); + $id = get_resource_id($stream); + $byId[$id] = $watcher; + + $read[$id] = $stream; + $except[$id] = $stream; + + foreach ($watcher->getWatchedEvents() as $event) { + if (Event::Write === $event) { + $write[$id] = $stream; + break; + } + } + } + + if (!$byId) { + if (null !== $timeoutSeconds) { + $micros = $timeoutSeconds * 1_000_000 + $timeoutMicroseconds; + if ($micros > 0) { + usleep($micros); + } + } + + return []; + } + + $readCopy = $read; + $writeCopy = $write; + $exceptCopy = $except; + + $result = @stream_select($readCopy, $writeCopy, $exceptCopy, $timeoutSeconds, $timeoutMicroseconds); + + if (false === $result) { + $error = error_get_last(); + if ($error && false !== stripos($error['message'], 'interrupt')) { + return []; + } + throw new FailedPollWaitException($error['message'] ?? 'stream_select() failed'); + } + + if (0 === $result) { + return []; + } + + $triggered = []; + foreach ($byId as $id => $watcher) { + $isReadable = isset($readCopy[$id]); + $isWritable = isset($writeCopy[$id]); + $hasOob = isset($exceptCopy[$id]); + + if (!$isReadable && !$isWritable && !$hasOob) { + continue; + } + + $watched = $watcher->getWatchedEvents(); + $stream = $watcher->getHandle()->getStream(); + $events = []; + + if ($isReadable) { + $peek = @stream_socket_recvfrom($stream, 1, \STREAM_PEEK); + if ('' === $peek) { + $events[] = Event::HangUp; + if (\in_array(Event::ReadHangUp, $watched, true)) { + $events[] = Event::ReadHangUp; + } + } elseif (\in_array(Event::Read, $watched, true)) { + $events[] = Event::Read; + } + } + + if ($isWritable && \in_array(Event::Write, $watched, true)) { + $events[] = Event::Write; + } + + if ($hasOob) { + $events[] = Event::Error; + } + + if ($events) { + $setTriggered($watcher, $events); + $triggered[] = $watcher; + } + } + + if (null !== $maxEvents && \count($triggered) > $maxEvents) { + for ($i = $maxEvents, $n = \count($triggered); $i < $n; ++$i) { + $setTriggered($triggered[$i], []); + } + $triggered = \array_slice($triggered, 0, $maxEvents); + } + + foreach ($triggered as $watcher) { + foreach ($watcher->getWatchedEvents() as $event) { + if (Event::OneShot === $event) { + unset($this->watchers[$watcher]); + $clearContext($watcher); + break; + } + } + } + + return $triggered; + } + + public function __serialize(): array + { + throw new \Exception("Serialization of 'Io\\Poll\\Context' is not allowed"); + } + + public function __unserialize(array $data): void + { + throw new \Exception("Unserialization of 'Io\\Poll\\Context' is not allowed"); + } + } +} diff --git a/src/Php86/Resources/stubs/Io/Poll/Event.php b/src/Php86/Resources/stubs/Io/Poll/Event.php new file mode 100644 index 00000000..88f82fe0 --- /dev/null +++ b/src/Php86/Resources/stubs/Io/Poll/Event.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Io\Poll; + +if (\PHP_VERSION_ID < 80600 && \PHP_VERSION_ID >= 80100) { + enum Event + { + case Read; + case Write; + case Error; + case HangUp; + case ReadHangUp; + case OneShot; + case EdgeTriggered; + } +} diff --git a/src/Php86/Resources/stubs/Io/Poll/FailedContextInitializationException.php b/src/Php86/Resources/stubs/Io/Poll/FailedContextInitializationException.php new file mode 100644 index 00000000..beb8d375 --- /dev/null +++ b/src/Php86/Resources/stubs/Io/Poll/FailedContextInitializationException.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Io\Poll; + +if (\PHP_VERSION_ID < 80600 && \PHP_VERSION_ID >= 80100) { + class FailedContextInitializationException extends FailedPollOperationException + { + } +} diff --git a/src/Php86/Resources/stubs/Io/Poll/FailedHandleAddException.php b/src/Php86/Resources/stubs/Io/Poll/FailedHandleAddException.php new file mode 100644 index 00000000..f476952a --- /dev/null +++ b/src/Php86/Resources/stubs/Io/Poll/FailedHandleAddException.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Io\Poll; + +if (\PHP_VERSION_ID < 80600 && \PHP_VERSION_ID >= 80100) { + class FailedHandleAddException extends FailedPollOperationException + { + } +} diff --git a/src/Php86/Resources/stubs/Io/Poll/FailedPollOperationException.php b/src/Php86/Resources/stubs/Io/Poll/FailedPollOperationException.php new file mode 100644 index 00000000..647b69fb --- /dev/null +++ b/src/Php86/Resources/stubs/Io/Poll/FailedPollOperationException.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Io\Poll; + +if (\PHP_VERSION_ID < 80600 && \PHP_VERSION_ID >= 80100) { + abstract class FailedPollOperationException extends PollException + { + public const ERROR_NONE = 0; + public const ERROR_SYSTEM = 1; + public const ERROR_NOMEM = 2; + public const ERROR_INVALID = 3; + public const ERROR_EXISTS = 4; + public const ERROR_NOTFOUND = 5; + public const ERROR_TIMEOUT = 6; + public const ERROR_INTERRUPTED = 7; + public const ERROR_PERMISSION = 8; + public const ERROR_TOOBIG = 9; + public const ERROR_AGAIN = 10; + public const ERROR_NOSUPPORT = 11; + } +} diff --git a/src/Php86/Resources/stubs/Io/Poll/FailedPollWaitException.php b/src/Php86/Resources/stubs/Io/Poll/FailedPollWaitException.php new file mode 100644 index 00000000..65568392 --- /dev/null +++ b/src/Php86/Resources/stubs/Io/Poll/FailedPollWaitException.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Io\Poll; + +if (\PHP_VERSION_ID < 80600 && \PHP_VERSION_ID >= 80100) { + class FailedPollWaitException extends FailedPollOperationException + { + } +} diff --git a/src/Php86/Resources/stubs/Io/Poll/FailedWatcherModificationException.php b/src/Php86/Resources/stubs/Io/Poll/FailedWatcherModificationException.php new file mode 100644 index 00000000..94666de1 --- /dev/null +++ b/src/Php86/Resources/stubs/Io/Poll/FailedWatcherModificationException.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Io\Poll; + +if (\PHP_VERSION_ID < 80600 && \PHP_VERSION_ID >= 80100) { + class FailedWatcherModificationException extends FailedPollOperationException + { + } +} diff --git a/src/Php86/Resources/stubs/Io/Poll/Handle.php b/src/Php86/Resources/stubs/Io/Poll/Handle.php new file mode 100644 index 00000000..845523a3 --- /dev/null +++ b/src/Php86/Resources/stubs/Io/Poll/Handle.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Io\Poll; + +if (\PHP_VERSION_ID < 80600 && \PHP_VERSION_ID >= 80100) { + /** + * @internal only classes provided by PHP core or extensions may implement this interface + */ + interface Handle + { + } +} diff --git a/src/Php86/Resources/stubs/Io/Poll/HandleAlreadyWatchedException.php b/src/Php86/Resources/stubs/Io/Poll/HandleAlreadyWatchedException.php new file mode 100644 index 00000000..d761c28e --- /dev/null +++ b/src/Php86/Resources/stubs/Io/Poll/HandleAlreadyWatchedException.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Io\Poll; + +if (\PHP_VERSION_ID < 80600 && \PHP_VERSION_ID >= 80100) { + class HandleAlreadyWatchedException extends PollException + { + } +} diff --git a/src/Php86/Resources/stubs/Io/Poll/InactiveWatcherException.php b/src/Php86/Resources/stubs/Io/Poll/InactiveWatcherException.php new file mode 100644 index 00000000..12a6d9ae --- /dev/null +++ b/src/Php86/Resources/stubs/Io/Poll/InactiveWatcherException.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Io\Poll; + +if (\PHP_VERSION_ID < 80600 && \PHP_VERSION_ID >= 80100) { + class InactiveWatcherException extends PollException + { + } +} diff --git a/src/Php86/Resources/stubs/Io/Poll/InvalidHandleException.php b/src/Php86/Resources/stubs/Io/Poll/InvalidHandleException.php new file mode 100644 index 00000000..1fce8047 --- /dev/null +++ b/src/Php86/Resources/stubs/Io/Poll/InvalidHandleException.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Io\Poll; + +if (\PHP_VERSION_ID < 80600 && \PHP_VERSION_ID >= 80100) { + class InvalidHandleException extends PollException + { + } +} diff --git a/src/Php86/Resources/stubs/Io/Poll/PollException.php b/src/Php86/Resources/stubs/Io/Poll/PollException.php new file mode 100644 index 00000000..959a0519 --- /dev/null +++ b/src/Php86/Resources/stubs/Io/Poll/PollException.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Io\Poll; + +if (\PHP_VERSION_ID < 80600 && \PHP_VERSION_ID >= 80100) { + class PollException extends \Io\IoException + { + } +} diff --git a/src/Php86/Resources/stubs/Io/Poll/Watcher.php b/src/Php86/Resources/stubs/Io/Poll/Watcher.php new file mode 100644 index 00000000..0b8b75a1 --- /dev/null +++ b/src/Php86/Resources/stubs/Io/Poll/Watcher.php @@ -0,0 +1,107 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Io\Poll; + +if (\PHP_VERSION_ID < 80600 && \PHP_VERSION_ID >= 80100) { + final class Watcher + { + private array $triggeredEvents = []; + + private function __construct( + private ?Context $context, + private readonly Handle $handle, + private array $events, + private mixed $data, + ) { + } + + public function getHandle(): Handle + { + return $this->handle; + } + + public function getWatchedEvents(): array + { + return $this->events; + } + + public function getTriggeredEvents(): array + { + return $this->triggeredEvents; + } + + public function getData(): mixed + { + return $this->data; + } + + public function hasTriggered(Event $event): bool + { + return \in_array($event, $this->triggeredEvents, true); + } + + public function isActive(): bool + { + return null !== $this->context; + } + + public function modify(array $events, mixed $data = null): void + { + $this->ensureActive(); + $this->events = $events; + $this->data = $data; + } + + public function modifyEvents(array $events): void + { + $this->ensureActive(); + $this->events = $events; + } + + public function modifyData(mixed $data): void + { + $this->ensureActive(); + $this->data = $data; + } + + public function remove(): void + { + $this->ensureActive(); + static $detach; + $detach ??= \Closure::bind( + static function (Context $ctx, Watcher $w): void { unset($ctx->watchers[$w]); }, + null, + Context::class, + ); + $context = $this->context; + $this->context = null; + $detach($context, $this); + } + + public function __serialize(): array + { + throw new \Exception("Serialization of 'Io\\Poll\\Watcher' is not allowed"); + } + + public function __unserialize(array $data): void + { + throw new \Exception("Unserialization of 'Io\\Poll\\Watcher' is not allowed"); + } + + private function ensureActive(): void + { + if (null === $this->context) { + throw new InactiveWatcherException('Watcher is no longer registered.'); + } + } + } +} diff --git a/src/Php86/Resources/stubs/StreamPollHandle.php b/src/Php86/Resources/stubs/StreamPollHandle.php new file mode 100644 index 00000000..f339242c --- /dev/null +++ b/src/Php86/Resources/stubs/StreamPollHandle.php @@ -0,0 +1,51 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +if (\PHP_VERSION_ID < 80600 && \PHP_VERSION_ID >= 80100) { + final class StreamPollHandle implements Io\Poll\Handle + { + private $stream; + + public function __construct($stream) + { + if (is_resource($stream)) { + $type = get_resource_type($stream); + if ('stream' !== $type && 'persistent stream' !== $type) { + throw new TypeError(sprintf('%s(): Argument #1 ($stream) must be of type stream resource, resource (%s) given', __METHOD__, $type)); + } + } elseif ('resource (closed)' !== gettype($stream)) { + throw new TypeError(sprintf('%s(): Argument #1 ($stream) must be of type stream resource, %s given', __METHOD__, get_debug_type($stream))); + } + + $this->stream = $stream; + } + + public function getStream() + { + return $this->stream; + } + + public function isValid(): bool + { + return is_resource($this->stream); + } + + public function __serialize(): array + { + throw new Exception("Serialization of 'StreamPollHandle' is not allowed"); + } + + public function __unserialize(array $data): void + { + throw new Exception("Unserialization of 'StreamPollHandle' is not allowed"); + } + } +} diff --git a/tests/Php86/Php86Test.php b/tests/Php86/Php86Test.php index 23f46fae..83949a2c 100644 --- a/tests/Php86/Php86Test.php +++ b/tests/Php86/Php86Test.php @@ -18,7 +18,7 @@ class Php86Test extends TestCase /** * @dataProvider provideValidClampInput */ - public function testClampSuccess(array $arguments, $result): void + public function testClampSuccess(array $arguments, $result) { [$value, $min, $max] = $arguments; @@ -33,7 +33,7 @@ public function testClampSuccess(array $arguments, $result): void $this->assertSame($result, $actual); } - public function testClampNanReturn(): void + public function testClampNanReturn() { $this->assertNan(clamp(\NAN, 4, 6)); } @@ -139,7 +139,7 @@ public static function provideValidClampInput(): array /** * @dataProvider provideInvalidClampInput */ - public function testClampFailure(array $arguments, string $error): void + public function testClampFailure(array $arguments, string $error) { $this->expectException(\ValueError::class); $this->expectExceptionMessage($error); diff --git a/tests/Php86/PollTest.php b/tests/Php86/PollTest.php new file mode 100644 index 00000000..6577b37f --- /dev/null +++ b/tests/Php86/PollTest.php @@ -0,0 +1,469 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Polyfill\Tests\Php86; + +use Io\IoException; +use Io\Poll\Backend; +use Io\Poll\BackendUnavailableException; +use Io\Poll\Context; +use Io\Poll\Event; +use Io\Poll\FailedPollOperationException; +use Io\Poll\Handle; +use Io\Poll\HandleAlreadyWatchedException; +use Io\Poll\InactiveWatcherException; +use Io\Poll\InvalidHandleException; +use Io\Poll\PollException; +use Io\Poll\Watcher; +use PHPUnit\Framework\TestCase; + +/** + * @requires PHP >= 8.1 + */ +class PollTest extends TestCase +{ + public function testBackendEnumCases() + { + $this->assertTrue(enum_exists(Backend::class)); + + $names = array_column(Backend::cases(), 'name'); + $this->assertContains('Auto', $names); + $this->assertContains('Poll', $names); + $this->assertContains('Epoll', $names); + $this->assertContains('Kqueue', $names); + $this->assertContains('EventPorts', $names); + $this->assertContains('WSAPoll', $names); + } + + public function testBackendAutoAndPollAreAvailable() + { + $this->assertTrue((Backend::Auto)->isAvailable()); + $this->assertTrue((Backend::Poll)->isAvailable()); + } + + public function testBackendUnavailableInPolyfill() + { + $this->assertFalse((Backend::Epoll)->isAvailable()); + $this->assertFalse((Backend::Kqueue)->isAvailable()); + $this->assertFalse((Backend::EventPorts)->isAvailable()); + $this->assertFalse((Backend::WSAPoll)->isAvailable()); + } + + public function testBackendGetAvailableBackends() + { + $available = Backend::getAvailableBackends(); + $this->assertContains((Backend::Auto), $available); + $this->assertContains((Backend::Poll), $available); + $this->assertNotContains((Backend::Epoll), $available); + } + + public function testBackendSupportsEdgeTriggering() + { + $this->assertFalse((Backend::Auto)->supportsEdgeTriggering()); + $this->assertFalse((Backend::Poll)->supportsEdgeTriggering()); + } + + public function testEventEnumCases() + { + $this->assertTrue(enum_exists(Event::class)); + + $names = array_column(Event::cases(), 'name'); + $this->assertContains('Read', $names); + $this->assertContains('Write', $names); + $this->assertContains('Error', $names); + $this->assertContains('HangUp', $names); + $this->assertContains('ReadHangUp', $names); + $this->assertContains('OneShot', $names); + $this->assertContains('EdgeTriggered', $names); + } + + public function testHandleIsInterface() + { + $this->assertTrue(interface_exists(Handle::class)); + } + + public function testStreamPollHandleImplementsHandle() + { + $stream = fopen('php://temp', 'r+'); + $handle = new \StreamPollHandle($stream); + $this->assertInstanceOf(Handle::class, $handle); + $this->assertSame($stream, $handle->getStream()); + $this->assertTrue($handle->isValid()); + fclose($stream); + $this->assertFalse($handle->isValid()); + } + + public function testStreamPollHandleRejectsNonStream() + { + $this->expectException(\TypeError::class); + new \StreamPollHandle('not a stream'); + } + + public function testExceptionHierarchy() + { + $this->assertTrue(class_exists(IoException::class)); + $this->assertTrue(class_exists(PollException::class)); + $this->assertTrue(class_exists(FailedPollOperationException::class)); + $this->assertTrue(class_exists(BackendUnavailableException::class)); + $this->assertTrue(class_exists(InactiveWatcherException::class)); + $this->assertTrue(class_exists(HandleAlreadyWatchedException::class)); + $this->assertTrue(class_exists(InvalidHandleException::class)); + + $this->assertTrue(is_subclass_of(PollException::class, IoException::class)); + $this->assertTrue(is_subclass_of(FailedPollOperationException::class, PollException::class)); + $this->assertTrue(is_subclass_of(BackendUnavailableException::class, PollException::class)); + $this->assertTrue(is_subclass_of(InactiveWatcherException::class, PollException::class)); + $this->assertTrue(is_subclass_of(HandleAlreadyWatchedException::class, PollException::class)); + $this->assertTrue(is_subclass_of(InvalidHandleException::class, PollException::class)); + + $this->assertTrue(is_subclass_of(IoException::class, \Exception::class)); + } + + public function testErrorConstantsOnFailedPollOperationException() + { + $constants = [ + 'ERROR_NONE', 'ERROR_SYSTEM', 'ERROR_NOMEM', 'ERROR_INVALID', + 'ERROR_EXISTS', 'ERROR_NOTFOUND', 'ERROR_TIMEOUT', 'ERROR_INTERRUPTED', + 'ERROR_PERMISSION', 'ERROR_TOOBIG', 'ERROR_AGAIN', 'ERROR_NOSUPPORT', + ]; + + foreach ($constants as $const) { + $this->assertTrue( + \defined(FailedPollOperationException::class.'::'.$const), + "FailedPollOperationException::$const is defined" + ); + } + } + + public function testContextDefaultBackendIsPoll() + { + $context = new Context(); + $this->assertSame(Backend::Poll, $context->getBackend()); + } + + public function testContextExplicitPollBackend() + { + $context = new Context(Backend::Poll); + $this->assertSame(Backend::Poll, $context->getBackend()); + } + + public function testContextUnavailableBackendThrows() + { + $this->expectException(BackendUnavailableException::class); + new Context(Backend::Epoll); + } + + public function testContextAddReturnsWatcher() + { + $stream = fopen('php://temp', 'r+'); + $handle = new \StreamPollHandle($stream); + $context = new Context(); + + $watcher = $context->add($handle, [Event::Read], 'user data'); + + $this->assertInstanceOf(Watcher::class, $watcher); + $this->assertSame($handle, $watcher->getHandle()); + $this->assertSame([Event::Read], $watcher->getWatchedEvents()); + $this->assertSame('user data', $watcher->getData()); + $this->assertTrue($watcher->isActive()); + $this->assertSame([], $watcher->getTriggeredEvents()); + + fclose($stream); + } + + public function testContextAddDuplicateHandleThrows() + { + $stream = fopen('php://temp', 'r+'); + $handle = new \StreamPollHandle($stream); + $context = new Context(); + $context->add($handle, [Event::Read]); + + $this->expectException(HandleAlreadyWatchedException::class); + try { + $context->add($handle, [Event::Write]); + } finally { + fclose($stream); + } + } + + public function testContextAddInvalidHandleThrows() + { + $stream = fopen('php://temp', 'r+'); + fclose($stream); + $handle = new \StreamPollHandle($stream); + $context = new Context(); + + $this->expectException(InvalidHandleException::class); + $context->add($handle, [Event::Read]); + } + + public function testWaitImmediateTimeoutReturnsEmpty() + { + [$r, $w] = stream_socket_pair(\STREAM_PF_UNIX, \STREAM_SOCK_STREAM, \STREAM_IPPROTO_IP); + $handle = new \StreamPollHandle($r); + $context = new Context(); + $context->add($handle, [Event::Read]); + + $result = $context->wait(0, 0); + + $this->assertSame([], $result); + fclose($r); + fclose($w); + } + + public function testWaitDetectsReadable() + { + [$r, $w] = stream_socket_pair(\STREAM_PF_UNIX, \STREAM_SOCK_STREAM, \STREAM_IPPROTO_IP); + fwrite($w, 'hello'); + + $context = new Context(); + $handle = new \StreamPollHandle($r); + $watcher = $context->add($handle, [Event::Read]); + + $result = $context->wait(0, 0); + + $this->assertCount(1, $result); + $this->assertSame($watcher, $result[0]); + $this->assertTrue($watcher->hasTriggered(Event::Read)); + $this->assertContains(Event::Read, $watcher->getTriggeredEvents()); + + fclose($r); + fclose($w); + } + + public function testWaitDetectsWritable() + { + [$r, $w] = stream_socket_pair(\STREAM_PF_UNIX, \STREAM_SOCK_STREAM, \STREAM_IPPROTO_IP); + + $context = new Context(); + $handle = new \StreamPollHandle($w); + $watcher = $context->add($handle, [Event::Write]); + + $result = $context->wait(0, 0); + + $this->assertCount(1, $result); + $this->assertSame($watcher, $result[0]); + $this->assertTrue($watcher->hasTriggered(Event::Write)); + + fclose($r); + fclose($w); + } + + public function testWaitBlocksUntilTimeoutWithNoEvents() + { + [$r, $w] = stream_socket_pair(\STREAM_PF_UNIX, \STREAM_SOCK_STREAM, \STREAM_IPPROTO_IP); + $handle = new \StreamPollHandle($r); + $context = new Context(); + $context->add($handle, [Event::Read]); + + $start = hrtime(true); + $result = $context->wait(0, 50000); + $elapsed = (hrtime(true) - $start) / 1000000; + + $this->assertSame([], $result); + $this->assertGreaterThanOrEqual(40, $elapsed); + + fclose($r); + fclose($w); + } + + public function testWaitDetectsHangUpAutomatically() + { + [$r, $w] = stream_socket_pair(\STREAM_PF_UNIX, \STREAM_SOCK_STREAM, \STREAM_IPPROTO_IP); + + $context = new Context(); + $handle = new \StreamPollHandle($r); + $watcher = $context->add($handle, [Event::Read]); + + fclose($w); + + $result = $context->wait(0, 0); + + $this->assertCount(1, $result); + $triggered = $watcher->getTriggeredEvents(); + $this->assertNotEmpty($triggered); + + fclose($r); + } + + public function testWaitMaxEventsLimitsReturn() + { + [$r1, $w1] = stream_socket_pair(\STREAM_PF_UNIX, \STREAM_SOCK_STREAM, \STREAM_IPPROTO_IP); + [$r2, $w2] = stream_socket_pair(\STREAM_PF_UNIX, \STREAM_SOCK_STREAM, \STREAM_IPPROTO_IP); + fwrite($w1, 'a'); + fwrite($w2, 'b'); + + $context = new Context(); + $context->add(new \StreamPollHandle($r1), [Event::Read]); + $context->add(new \StreamPollHandle($r2), [Event::Read]); + + $result = $context->wait(0, 0, 1); + $this->assertCount(1, $result); + + fclose($r1); + fclose($w1); + fclose($r2); + fclose($w2); + } + + public function testWatcherRemove() + { + $stream = fopen('php://temp', 'r+'); + $handle = new \StreamPollHandle($stream); + $context = new Context(); + $watcher = $context->add($handle, [Event::Read]); + + $this->assertTrue($watcher->isActive()); + $watcher->remove(); + $this->assertFalse($watcher->isActive()); + + $watcher2 = $context->add($handle, [Event::Read]); + $this->assertTrue($watcher2->isActive()); + + fclose($stream); + } + + public function testWatcherRemoveTwiceThrows() + { + $stream = fopen('php://temp', 'r+'); + $handle = new \StreamPollHandle($stream); + $context = new Context(); + $watcher = $context->add($handle, [Event::Read]); + $watcher->remove(); + + $this->expectException(InactiveWatcherException::class); + try { + $watcher->remove(); + } finally { + fclose($stream); + } + } + + public function testWatcherModifyEvents() + { + $stream = fopen('php://temp', 'r+'); + $handle = new \StreamPollHandle($stream); + $context = new Context(); + $watcher = $context->add($handle, [Event::Read]); + + $watcher->modifyEvents([Event::Write]); + $this->assertSame([Event::Write], $watcher->getWatchedEvents()); + + fclose($stream); + } + + public function testWatcherModifyData() + { + $stream = fopen('php://temp', 'r+'); + $handle = new \StreamPollHandle($stream); + $context = new Context(); + $watcher = $context->add($handle, [Event::Read], 'old'); + + $watcher->modifyData('new'); + $this->assertSame('new', $watcher->getData()); + $this->assertSame([Event::Read], $watcher->getWatchedEvents()); + + fclose($stream); + } + + public function testWatcherModifyBoth() + { + $stream = fopen('php://temp', 'r+'); + $handle = new \StreamPollHandle($stream); + $context = new Context(); + $watcher = $context->add($handle, [Event::Read], 'old'); + + $watcher->modify([Event::Write], 'new'); + $this->assertSame([Event::Write], $watcher->getWatchedEvents()); + $this->assertSame('new', $watcher->getData()); + + fclose($stream); + } + + public function testWatcherModifyAfterRemoveThrows() + { + $stream = fopen('php://temp', 'r+'); + $handle = new \StreamPollHandle($stream); + $context = new Context(); + $watcher = $context->add($handle, [Event::Read]); + $watcher->remove(); + + $this->expectException(InactiveWatcherException::class); + try { + $watcher->modifyEvents([Event::Write]); + } finally { + fclose($stream); + } + } + + public function testWaitOneShotRemovesWatcher() + { + [$r, $w] = stream_socket_pair(\STREAM_PF_UNIX, \STREAM_SOCK_STREAM, \STREAM_IPPROTO_IP); + fwrite($w, 'x'); + + $context = new Context(); + $watcher = $context->add(new \StreamPollHandle($r), [Event::Read, Event::OneShot]); + + $result = $context->wait(0, 0); + $this->assertCount(1, $result); + $this->assertFalse($watcher->isActive()); + + fclose($r); + fclose($w); + } + + public function testWatcherConstructorIsPrivate() + { + $ref = new \ReflectionMethod(Watcher::class, '__construct'); + $this->assertTrue($ref->isPrivate()); + } + + /** + * @dataProvider provideNotSerializable + */ + public function testNotSerializable(string $class, object $factory) + { + $instance = $factory instanceof \Closure ? $factory() : $factory; + $this->expectException(\Exception::class); + $this->expectExceptionMessage("Serialization of '$class' is not allowed"); + serialize($instance); + } + + public static function provideNotSerializable(): array + { + return [ + 'Context' => ['Io\\Poll\\Context', new Context()], + 'StreamPollHandle' => ['StreamPollHandle', new \StreamPollHandle(fopen('php://temp', 'r+'))], + 'Watcher' => ['Io\\Poll\\Watcher', static function () { + $ctx = new Context(); + + return $ctx->add(new \StreamPollHandle(fopen('php://temp', 'r+')), [Event::Read]); + }], + ]; + } + + public function testTimeoutMicroOverflowIntoSeconds() + { + [$r, $w] = stream_socket_pair(\STREAM_PF_UNIX, \STREAM_SOCK_STREAM, \STREAM_IPPROTO_IP); + $handle = new \StreamPollHandle($r); + $context = new Context(); + $context->add($handle, [Event::Read]); + + $start = hrtime(true); + $context->wait(0, 1100000); + $elapsed = (hrtime(true) - $start) / 1000000; + + $this->assertGreaterThanOrEqual(1000, $elapsed); + + fclose($r); + fclose($w); + } +}