diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..fc0be872 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +/.gitattributes export-ignore +/.github/ export-ignore +/.gitignore export-ignore +/examples/ export-ignore +/phpunit.xml.dist export-ignore +/phpunit.xml.legacy export-ignore +/tests/ export-ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..871d3f4e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,102 @@ +name: CI + +on: + push: + pull_request: + +jobs: + PHPUnit: + name: PHPUnit (PHP ${{ matrix.php }}) + runs-on: ubuntu-24.04 + strategy: + matrix: + php: + - 8.4 + - 8.3 + - 8.2 + - 8.1 + - 8.0 + - 7.4 + - 7.3 + - 7.2 + - 7.1 + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: ${{ matrix.php < 8.0 && 'xdebug' || 'pcov' }} + ini-file: development + ini-values: disable_functions='' # do not disable PCNTL functions on PHP < 8.1 + extensions: sockets, pcntl, event, ${{ matrix.php < 8.0 && 'ev-1.1.5' || 'ev' }} + env: + fail-fast: true # fail step if any extension can not be installed + - run: composer install + - run: vendor/bin/phpunit --coverage-text + if: ${{ matrix.php >= 7.3 }} + - run: vendor/bin/phpunit --coverage-text -c phpunit.xml.legacy + if: ${{ matrix.php < 7.3 }} + + PHPUnit-Unstable: + name: PHPUnit (Unstable PHP ${{ matrix.php }}) + runs-on: ubuntu-24.04 + continue-on-error: true + strategy: + matrix: + php: + - 8.3 + - 8.2 + - 8.1 + - 8.0 + - 7.4 + - 7.3 + - 7.2 + - 7.1 + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: ${{ matrix.php < 8.0 && 'xdebug' || 'pcov' }} + ini-file: development + extensions: sockets, pcntl + - name: Install ext-uv + run: | + sudo apt-get update -q && sudo apt-get install libuv1-dev + echo "yes" | sudo pecl install ${{ matrix.php >= 8.0 && 'uv-0.3.0' || 'uv-0.2.4' }} + php -m | grep -q uv || echo "extension=uv.so" >> "$(php -r 'echo php_ini_loaded_file();')" + - run: composer install + - run: vendor/bin/phpunit --coverage-text + if: ${{ matrix.php >= 7.3 }} + - run: vendor/bin/phpunit --coverage-text -c phpunit.xml.legacy + if: ${{ matrix.php < 7.3 }} + + PHPUnit-Windows: + name: PHPUnit (PHP ${{ matrix.php }} on Windows) + runs-on: windows-2022 + continue-on-error: true + strategy: + matrix: + php: + - 8.4 + - 8.3 + - 8.2 + - 8.1 + - 8.0 + - 7.4 + - 7.3 + - 7.2 + - 7.1 + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: ${{ matrix.php < 8.0 && 'xdebug' || 'pcov' }} + ini-file: development + extensions: sockets,event # future: add uv-beta (installs, but can not load) + - run: composer install + - run: vendor/bin/phpunit --coverage-text + if: ${{ matrix.php >= 7.3 }} + - run: vendor/bin/phpunit --coverage-text -c phpunit.xml.legacy + if: ${{ matrix.php < 7.3 }} diff --git a/.gitignore b/.gitignore index 81b92580..5cf9a2cf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ -composer.lock -phpunit.xml -vendor +/composer.lock +/phpunit.xml +/vendor/ diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 7af713a4..00000000 --- a/.travis.yml +++ /dev/null @@ -1,39 +0,0 @@ -language: php - -php: -# - 5.3 # requires old distro, see below - - 5.4 - - 5.5 - - 5.6 - - 7.0 - - 7.1 - - 7.2 - - hhvm # ignore errors, see below - -# lock distro so new future defaults will not break the build -dist: trusty - -matrix: - include: - - php: 5.3 - dist: precise - allow_failures: - - php: hhvm - -sudo: false - -addons: - apt: - packages: - - libevent-dev # Used by 'event' and 'libevent' PHP extensions - -cache: - directories: - - $HOME/.composer/cache/files - -install: - - ./travis-init.sh - - composer install - -script: - - ./vendor/bin/phpunit --coverage-text diff --git a/CHANGELOG.md b/CHANGELOG.md index d98bd69f..e634b12e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,138 @@ # Changelog +## 1.5.0 (2023-11-13) + +* Feature: Improve performance by using `spl_object_id()` on PHP 7.2+. + (#267 by @samsonasik) + +* Feature: Full PHP 8.3 compatibility. + (#269 by @clue) + +* Update tests for `ext-uv` on PHP 8+ and legacy PHP. + (#270 by @clue and #268 by @SimonFrings) + +## 1.4.0 (2023-05-05) + +* Feature: Improve performance of `Loop` by avoiding unneeded method calls. + (#266 by @clue) + +* Feature: Support checking `EINTR` constant from `ext-pcntl` without `ext-sockets`. + (#265 by @clue) + +* Minor documentation improvements. + (#254 by @nhedger) + +* Improve test suite, run tests on PHP 8.2 and report failed assertions. + (#258 by @WyriHaximus, #264 by @clue and #251, #261 and #262 by @SimonFrings) + +## 1.3.0 (2022-03-17) + +* Feature: Improve default `StreamSelectLoop` to report any warnings for invalid streams. + (#245 by @clue) + +* Feature: Improve performance of `StreamSelectLoop` when no timers are scheduled. + (#246 by @clue) + +* Fix: Fix periodic timer with zero interval for `ExtEvLoop` and legacy `ExtLibevLoop`. + (#243 by @lucasnetau) + +* Minor documentation improvements, update PHP version references. + (#240, #248 and #250 by @SimonFrings, #241 by @dbu and #249 by @clue) + +* Improve test suite and test against PHP 8.1. + (#238 by @WyriHaximus and #242 by @clue) + +## 1.2.0 (2021-07-11) + +A major new feature release, see [**release announcement**](https://clue.engineering/2021/announcing-reactphp-default-loop). + +* Feature: Introduce new concept of default loop with the new `Loop` class. + (#226 by @WyriHaximus, #229, #231 and #232 by @clue) + + The `Loop` class exists as a convenient global accessor for the event loop. + It provides all methods that exist on the `LoopInterface` as static methods and + will automatically execute the loop at the end of the program: + + ```php + $timer = Loop::addPeriodicTimer(0.1, function () { + echo 'Tick' . PHP_EOL; + }); + + Loop::addTimer(1.0, function () use ($timer) { + Loop::cancelTimer($timer); + echo 'Done' . PHP_EOL; + }); + ``` + + The explicit loop instructions are still valid and may still be useful in some applications, + especially for a transition period towards the more concise style. + The `Loop::get()` method can be used to get the currently active event loop instance. + + ```php + // deprecated + $loop = React\EventLoop\Factory::create(); + + // new + $loop = React\EventLoop\Loop::get(); + ``` + +* Minor documentation improvements and mark legacy extensions as deprecated. + (#234 by @SimonFrings, #214 by @WyriHaximus and #233 and #235 by @nhedger) + +* Improve test suite, use GitHub actions for continuous integration (CI), + update PHPUnit config and run tests on PHP 8. + (#212 and #215 by @SimonFrings and #230 by @clue) + +## 1.1.1 (2020-01-01) + +* Fix: Fix reporting connection refused errors with `ExtUvLoop` on Linux and `StreamSelectLoop` on Windows. + (#207 and #208 by @clue) + +* Fix: Fix unsupported EventConfig and `SEGFAULT` on shutdown with `ExtEventLoop` on Windows. + (#205 by @clue) + +* Fix: Prevent interval overflow for timers very far in the future with `ExtUvLoop`. + (#196 by @PabloKowalczyk) + +* Fix: Check PCNTL functions for signal support instead of PCNTL extension with `StreamSelectLoop`. + (#195 by @clue) + +* Add `.gitattributes` to exclude dev files from exports. + (#201 by @reedy) + +* Improve test suite to fix testing `ExtUvLoop` on Travis, + fix Travis CI builds, do not install `libuv` on legacy PHP setups, + fix failing test cases due to inaccurate timers, + run tests on Windows via Travis CI and + run tests on PHP 7.4 and simplify test matrix and test setup. + (#197 by @WyriHaximus and #202, #203, #204 and #209 by @clue) + +## 1.1.0 (2019-02-07) + +* New UV based event loop (ext-uv). + (#112 by @WyriHaximus) + +* Use high resolution timer on PHP 7.3+. + (#182 by @clue) + +* Improve PCNTL signals by using async signal dispatching if available. + (#179 by @CharlotteDunois) + +* Improve test suite and test suite set up. + (#174 by @WyriHaximus, #181 by @clue) + +* Fix PCNTL signals edge case. + (#183 by @clue) + +## 1.0.0 (2018-07-11) + +* First stable LTS release, now following [SemVer](https://semver.org/). + We'd like to emphasize that this component is production ready and battle-tested. + We plan to support all long-term support (LTS) releases for at least 24 months, + so you have a rock-solid foundation to build on top of. + +> Contains no other changes, so it's actually fully compatible with the v0.5.3 release. + ## 0.5.3 (2018-07-09) * Improve performance by importing global functions. diff --git a/LICENSE b/LICENSE index a808108c..d6f8901f 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,6 @@ -Copyright (c) 2012 Igor Wiedler, Chris Boden +The MIT License (MIT) + +Copyright (c) 2012 Christian Lück, Cees-Jan Kiewiet, Jan Sorgalla, Chris Boden, Igor Wiedler Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 62650b7c..8394784f 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,49 @@ -# EventLoop Component +# EventLoop -[![Build Status](https://travis-ci.org/reactphp/event-loop.svg?branch=master)](https://travis-ci.org/reactphp/event-loop) +[![CI status](https://github.com/reactphp/event-loop/actions/workflows/ci.yml/badge.svg)](https://github.com/reactphp/event-loop/actions) +[![installs on Packagist](https://img.shields.io/packagist/dt/react/event-loop?color=blue&label=installs%20on%20Packagist)](https://packagist.org/packages/react/event-loop) [ReactPHP](https://reactphp.org/)'s core reactor event loop that libraries can use for evented I/O. +> **Development version:** This branch contains the code for the upcoming v3 +> release. For the code of the current stable v1 release, check out the +> [`1.x` branch](https://github.com/reactphp/event-loop/tree/1.x). +> +> The upcoming v3 release will be the way forward for this package. However, +> we will still actively support v1 for those not yet on the latest version. +> See also [installation instructions](#install) for more details. + In order for async based libraries to be interoperable, they need to use the same event loop. This component provides a common `LoopInterface` that any library can target. This allows them to be used in the same loop, with one single [`run()`](#run) call that is controlled by the user. -**Table of Contents** +**Table of contents** * [Quickstart example](#quickstart-example) * [Usage](#usage) - * [Factory](#factory) - * [create()](#create) - * [Loop implementations](#loop-implementations) - * [StreamSelectLoop](#streamselectloop) - * [ExtEventLoop](#exteventloop) - * [ExtLibeventLoop](#extlibeventloop) - * [ExtLibevLoop](#extlibevloop) - * [ExtEvLoop](#extevloop) - * [LoopInterface](#loopinterface) - * [run()](#run) - * [stop()](#stop) - * [addTimer()](#addtimer) - * [addPeriodicTimer()](#addperiodictimer) - * [cancelTimer()](#canceltimer) - * [futureTick()](#futuretick) - * [addSignal()](#addsignal) - * [removeSignal()](#removesignal) - * [addReadStream()](#addreadstream) - * [addWriteStream()](#addwritestream) - * [removeReadStream()](#removereadstream) - * [removeWriteStream()](#removewritestream) + * [Loop](#loop) + * [Loop methods](#loop-methods) + * [Loop autorun](#loop-autorun) + * [get()](#get) + * [Loop implementations](#loop-implementations) + * [StreamSelectLoop](#streamselectloop) + * [ExtEventLoop](#exteventloop) + * [ExtEvLoop](#extevloop) + * [ExtUvLoop](#extuvloop) + * [LoopInterface](#loopinterface) + * [run()](#run) + * [stop()](#stop) + * [addTimer()](#addtimer) + * [addPeriodicTimer()](#addperiodictimer) + * [cancelTimer()](#canceltimer) + * [futureTick()](#futuretick) + * [addSignal()](#addsignal) + * [removeSignal()](#removesignal) + * [addReadStream()](#addreadstream) + * [addWriteStream()](#addwritestream) + * [removeReadStream()](#removereadstream) + * [removeWriteStream()](#removewritestream) * [Install](#install) * [Tests](#tests) * [License](#license) @@ -44,89 +54,253 @@ single [`run()`](#run) call that is controlled by the user. Here is an async HTTP server built with just the event loop. ```php -$loop = React\EventLoop\Factory::create(); +addReadStream($server, function ($server) use ($loop) { +Loop::addReadStream($server, function ($server) { $conn = stream_socket_accept($server); $data = "HTTP/1.1 200 OK\r\nContent-Length: 3\r\n\r\nHi\n"; - $loop->addWriteStream($conn, function ($conn) use (&$data, $loop) { + Loop::addWriteStream($conn, function ($conn) use (&$data) { $written = fwrite($conn, $data); if ($written === strlen($data)) { fclose($conn); - $loop->removeWriteStream($conn); + Loop::removeWriteStream($conn); } else { $data = substr($data, $written); } }); }); -$loop->addPeriodicTimer(5, function () { +Loop::addPeriodicTimer(5, function () { $memory = memory_get_usage() / 1024; $formatted = number_format($memory, 3).'K'; echo "Current memory usage: {$formatted}\n"; }); - -$loop->run(); ``` See also the [examples](examples). ## Usage -Typical applications use a single event loop which is created at the beginning -and run at the end of the program. +Typical applications would use the [`Loop` class](#loop) to use the default +event loop like this: + +```php +use React\EventLoop\Loop; + +$timer = Loop::addPeriodicTimer(0.1, function () { + echo 'Tick' . PHP_EOL; +}); + +Loop::addTimer(1.0, function () use ($timer) { + Loop::cancelTimer($timer); + echo 'Done' . PHP_EOL; +}); +``` + +As an alternative, you can also explicitly create an event loop instance at the +beginning, reuse it throughout your program and finally run it at the end of the +program like this: ```php -// [1] -$loop = React\EventLoop\Factory::create(); +$loop = React\EventLoop\Loop::get(); -// [2] -$loop->addPeriodicTimer(1, function () { - echo "Tick\n"; +$timer = $loop->addPeriodicTimer(0.1, function () { + echo 'Tick' . PHP_EOL; }); -$stream = new React\Stream\ReadableResourceStream( - fopen('file.txt', 'r'), - $loop -); +$loop->addTimer(1.0, function () use ($loop, $timer) { + $loop->cancelTimer($timer); + echo 'Done' . PHP_EOL; +}); -// [3] $loop->run(); ``` -1. The loop instance is created at the beginning of the program. A convenience - factory [`React\EventLoop\Factory::create()`](#create) is provided by this library which - picks the best available [loop implementation](#loop-implementations). -2. The loop instance is used directly or passed to library and application code. - In this example, a periodic timer is registered with the event loop which - simply outputs `Tick` every second and a - [readable stream](https://github.com/reactphp/stream#readableresourcestream) - is created by using ReactPHP's - [stream component](https://github.com/reactphp/stream) for demonstration - purposes. -3. The loop is run with a single [`$loop->run()`](#run) call at the end of the program. +While the former is more concise, the latter is more explicit. +In both cases, the program would perform the exact same steps. + +1. The event loop instance is created at the beginning of the program. This is + implicitly done the first time you call the [`Loop` class](#loop) + (or by manually instantiating any of the [loop implementations](#loop-implementations)). +2. The event loop is used directly or passed as an instance to library and + application code. In this example, a periodic timer is registered with the + event loop which simply outputs `Tick` every fraction of a second until another + timer stops the periodic timer after a second. +3. The event loop is run at the end of the program. This is automatically done + when using the [`Loop` class](#loop) or explicitly with a single [`run()`](#run) + call at the end of the program. + +As of `v1.2.0`, we highly recommend using the [`Loop` class](#loop). +The explicit loop instructions are still valid and may still be useful in some +applications, especially for a transition period towards the more concise style. + +### Loop + +The `Loop` class exists as a convenient global accessor for the event loop. + +#### Loop methods + +The `Loop` class provides all methods that exist on the [`LoopInterface`](#loopinterface) +as static methods: + +* [run()](#run) +* [stop()](#stop) +* [addTimer()](#addtimer) +* [addPeriodicTimer()](#addperiodictimer) +* [cancelTimer()](#canceltimer) +* [futureTick()](#futuretick) +* [addSignal()](#addsignal) +* [removeSignal()](#removesignal) +* [addReadStream()](#addreadstream) +* [addWriteStream()](#addwritestream) +* [removeReadStream()](#removereadstream) +* [removeWriteStream()](#removewritestream) + +If you're working with the event loop in your application code, it's often +easiest to directly interface with the static methods defined on the `Loop` class +like this: -### Factory +```php +use React\EventLoop\Loop; -The `Factory` class exists as a convenient way to pick the best available -[event loop implementation](#loop-implementations). +$timer = Loop::addPeriodicTimer(0.1, function () { + echo 'Tick' . PHP_EOL; +}); -#### create() +Loop::addTimer(1.0, function () use ($timer) { + Loop::cancelTimer($timer); + echo 'Done' . PHP_EOL; +}); +``` -The `create(): LoopInterface` method can be used to create a new event loop -instance: +On the other hand, if you're familiar with object-oriented programming (OOP) and +dependency injection (DI), you may want to inject an event loop instance and +invoke instance methods on the `LoopInterface` like this: ```php -$loop = React\EventLoop\Factory::create(); +use React\EventLoop\Loop; +use React\EventLoop\LoopInterface; + +class Greeter +{ + private $loop; + + public function __construct(LoopInterface $loop) + { + $this->loop = $loop; + } + + public function greet(string $name) + { + $this->loop->addTimer(1.0, function () use ($name) { + echo 'Hello ' . $name . '!' . PHP_EOL; + }); + } +} + +$greeter = new Greeter(Loop::get()); +$greeter->greet('Alice'); +$greeter->greet('Bob'); ``` -This method always returns an instance implementing [`LoopInterface`](#loopinterface), -the actual [event loop implementation](#loop-implementations) is an implementation detail. +Each static method call will be forwarded as-is to the underlying event loop +instance by using the [`Loop::get()`](#get) call internally. +See [`LoopInterface`](#loopinterface) for more details about available methods. -This method should usually only be called once at the beginning of the program. +#### Loop autorun + +When using the `Loop` class, it will automatically execute the loop at the end of +the program. This means the following example will schedule a timer and will +automatically execute the program until the timer event fires: + +```php +use React\EventLoop\Loop; + +Loop::addTimer(1.0, function () { + echo 'Hello' . PHP_EOL; +}); +``` + +As of `v1.2.0`, we highly recommend using the `Loop` class this way and omitting any +explicit [`run()`](#run) calls. For BC reasons, the explicit [`run()`](#run) +method is still valid and may still be useful in some applications, especially +for a transition period towards the more concise style. + +If you don't want the `Loop` to run automatically, you can either explicitly +[`run()`](#run) or [`stop()`](#stop) it. This can be useful if you're using +a global exception handler like this: + +```php +use React\EventLoop\Loop; + +Loop::addTimer(10.0, function () { + echo 'Never happens'; +}); + +set_exception_handler(function (Throwable $e) { + echo 'Error: ' . $e->getMessage() . PHP_EOL; + Loop::stop(); +}); + +throw new RuntimeException('Demo'); +``` + +#### get() + +The `get(): LoopInterface` method can be used to +get the currently active event loop instance. + +This method will always return the same event loop instance throughout the +lifetime of your application. + +```php +use React\EventLoop\Loop; +use React\EventLoop\LoopInterface; + +$loop = Loop::get(); + +assert($loop instanceof LoopInterface); +assert($loop === Loop::get()); +``` + +This is particularly useful if you're using object-oriented programming (OOP) +and dependency injection (DI). In this case, you may want to inject an event +loop instance and invoke instance methods on the `LoopInterface` like this: + +```php +use React\EventLoop\Loop; +use React\EventLoop\LoopInterface; + +class Greeter +{ + private $loop; + + public function __construct(LoopInterface $loop) + { + $this->loop = $loop; + } + + public function greet(string $name) + { + $this->loop->addTimer(1.0, function () use ($name) { + echo 'Hello ' . $name . '!' . PHP_EOL; + }); + } +} + +$greeter = new Greeter(Loop::get()); +$greeter->greet('Alice'); +$greeter->greet('Bob'); +``` + +See [`LoopInterface`](#loopinterface) for more details about available methods. ### Loop implementations @@ -142,7 +316,7 @@ All of the event loops support these features: For most consumers of this package, the underlying event loop implementation is an implementation detail. -You should use the [`Factory`](#factory) to automatically create a new instance. +You should use the [`Loop` class](#loop) to automatically create a new instance. Advanced! If you explicitly need a certain event loop implementation, you can manually instantiate one of the following classes. @@ -153,13 +327,13 @@ event loop implementation first or they will throw a `BadMethodCallException` on A `stream_select()` based event loop. -This uses the [`stream_select()`](http://php.net/manual/en/function.stream-select.php) -function and is the only implementation which works out of the box with PHP. +This uses the [`stream_select()`](https://www.php.net/manual/en/function.stream-select.php) +function and is the only implementation that works out of the box with PHP. -This event loop works out of the box on PHP 5.3 through PHP 7+ and HHVM. +This event loop works out of the box on any PHP version. This means that no installation is required and this library works on all platforms and supported PHP versions. -Accordingly, the [`Factory`](#factory) will use this event loop by default if +Accordingly, the [`Loop` class](#loop) will use this event loop by default if you do not install any of the event loop extensions listed below. Under the hood, it does a simple `select` system call. @@ -181,67 +355,46 @@ It is commonly installed as part of many PHP distributions. If this extension is missing (or you're running on Windows), signal handling is not supported and throws a `BadMethodCallException` instead. -This event loop is known to rely on wall-clock time to schedule future -timers, because a monotonic time source is not available in PHP by default. +This event loop is known to rely on wall-clock time to schedule future timers +when using any version before PHP 7.3, because a monotonic time source is +only available as of PHP 7.3 (`hrtime()`). While this does not affect many common use cases, this is an important distinction for programs that rely on a high time precision or on systems that are subject to discontinuous time adjustments (time jumps). -This means that if you schedule a timer to trigger in 30s and then adjust -your system time forward by 20s, the timer may trigger in 10s. +This means that if you schedule a timer to trigger in 30s on PHP < 7.3 and +then adjust your system time forward by 20s, the timer may trigger in 10s. See also [`addTimer()`](#addtimer) for more details. #### ExtEventLoop An `ext-event` based event loop. -This uses the [`event` PECL extension](https://pecl.php.net/package/event). -It supports the same backends as libevent. +This uses the [`event` PECL extension](https://pecl.php.net/package/event), +that provides an interface to `libevent` library. +`libevent` itself supports a number of system-specific backends (epoll, kqueue). -This loop is known to work with PHP 5.4 through PHP 7+. +This loop is known to work with PHP 7.1 through PHP 8+. #### ExtEvLoop An `ext-ev` based event loop. -This loop uses the [`ev` PECL extension](https://pecl.php.net/package/ev), that -provides an interface to `libev` library. +This loop uses the [`ev` PECL extension](https://pecl.php.net/package/ev), +that provides an interface to `libev` library. +`libev` itself supports a number of system-specific backends (epoll, kqueue). -This loop is known to work with PHP 5.4 through PHP 7+. +This loop is known to work with PHP 7.1 through PHP 8+. -#### ExtLibeventLoop +#### ExtUvLoop -An `ext-libevent` based event loop. +An `ext-uv` based event loop. -This uses the [`libevent` PECL extension](https://pecl.php.net/package/libevent). -`libevent` itself supports a number of system-specific backends (epoll, kqueue). +This loop uses the [`uv` PECL extension](https://pecl.php.net/package/uv), +that provides an interface to `libuv` library. +`libuv` itself supports a number of system-specific backends (epoll, kqueue). -This event loop does only work with PHP 5. -An [unofficial update](https://github.com/php/pecl-event-libevent/pull/2) for -PHP 7 does exist, but it is known to cause regular crashes due to `SEGFAULT`s. -To reiterate: Using this event loop on PHP 7 is not recommended. -Accordingly, the [`Factory`](#factory) will not try to use this event loop on -PHP 7. - -This event loop is known to trigger a readable listener only if -the stream *becomes* readable (edge-triggered) and may not trigger if the -stream has already been readable from the beginning. -This also implies that a stream may not be recognized as readable when data -is still left in PHP's internal stream buffers. -As such, it's recommended to use `stream_set_read_buffer($stream, 0);` -to disable PHP's internal read buffer in this case. -See also [`addReadStream()`](#addreadstream) for more details. - -#### ExtLibevLoop - -An `ext-libev` based event loop. - -This uses an [unofficial `libev` extension](https://github.com/m4rw3r/php-libev). -It supports the same backends as libevent. - -This loop does only work with PHP 5. -An update for PHP 7 is [unlikely](https://github.com/m4rw3r/php-libev/issues/8) -to happen any time soon. +This loop is known to work with PHP 7.1 through PHP 8+. ### LoopInterface @@ -252,7 +405,7 @@ run the event loop until there are no more tasks to perform. For many applications, this method is the only directly visible invocation on the event loop. -As a rule of thumb, it is usally recommended to attach everything to the +As a rule of thumb, it is usually recommended to attach everything to the same loop instance and then run the loop once at the bottom end of the application. @@ -270,7 +423,7 @@ run it will result in the application exiting without actually waiting for any of the attached listeners. This method MUST NOT be called while the loop is already running. -This method MAY be called more than once after it has explicity been +This method MAY be called more than once after it has explicitly been [`stop()`ped](#stop) or after it automatically stopped because it previously did no longer have anything to do. @@ -299,18 +452,21 @@ on a loop instance that has already been stopped has no effect. The `addTimer(float $interval, callable $callback): TimerInterface` method can be used to enqueue a callback to be invoked once after the given interval. -The timer callback function MUST be able to accept a single parameter, -the timer instance as also returned by this method or you MAY use a -function which has no parameters at all. +The second parameter MUST be a timer callback function that accepts +the timer instance as its only parameter. +If you don't use the timer instance inside your timer callback function +you MAY use a function which has no parameters at all. The timer callback function MUST NOT throw an `Exception`. The return value of the timer callback function will be ignored and has no effect, so for performance reasons you're recommended to not return any excessive data structures. +This method returns a timer instance. The same timer instance will also be +passed into the timer callback function as described above. +You can invoke [`cancelTimer`](#canceltimer) to cancel a pending timer. Unlike [`addPeriodicTimer()`](#addperiodictimer), this method will ensure the callback will be invoked only once after the given interval. -You can invoke [`cancelTimer`](#canceltimer) to cancel a pending timer. ```php $loop->addTimer(0.8, function () { @@ -351,8 +507,8 @@ same time (within its possible accuracy) is not guaranteed. This interface suggests that event loop implementations SHOULD use a monotonic time source if available. Given that a monotonic time source is -not available on PHP by default, event loop implementations MAY fall back -to using wall-clock time. +only available as of PHP 7.3 by default, event loop implementations MAY +fall back to using wall-clock time. While this does not affect many common use cases, this is an important distinction for programs that rely on a high time precision or on systems that are subject to discontinuous time adjustments (time jumps). @@ -365,18 +521,21 @@ See also [event loop implementations](#loop-implementations) for more details. The `addPeriodicTimer(float $interval, callable $callback): TimerInterface` method can be used to enqueue a callback to be invoked repeatedly after the given interval. -The timer callback function MUST be able to accept a single parameter, -the timer instance as also returned by this method or you MAY use a -function which has no parameters at all. +The second parameter MUST be a timer callback function that accepts +the timer instance as its only parameter. +If you don't use the timer instance inside your timer callback function +you MAY use a function which has no parameters at all. The timer callback function MUST NOT throw an `Exception`. The return value of the timer callback function will be ignored and has no effect, so for performance reasons you're recommended to not return any excessive data structures. -Unlike [`addTimer()`](#addtimer), this method will ensure the the -callback will be invoked infinitely after the given interval or until you -invoke [`cancelTimer`](#canceltimer). +This method returns a timer instance. The same timer instance will also be +passed into the timer callback function as described above. +Unlike [`addTimer()`](#addtimer), this method will ensure the callback +will be invoked infinitely after the given interval or until you invoke +[`cancelTimer`](#canceltimer). ```php $timer = $loop->addPeriodicTimer(0.1, function () { @@ -424,8 +583,8 @@ same time (within its possible accuracy) is not guaranteed. This interface suggests that event loop implementations SHOULD use a monotonic time source if available. Given that a monotonic time source is -not available on PHP by default, event loop implementations MAY fall back -to using wall-clock time. +only available as of PHP 7.3 by default, event loop implementations MAY +fall back to using wall-clock time. While this does not affect many common use cases, this is an important distinction for programs that rely on a high time precision or on systems that are subject to discontinuous time adjustments (time jumps). @@ -504,9 +663,10 @@ register a listener to be notified when a signal has been caught by this process This is useful to catch user interrupt signals or shutdown signals from tools like `supervisor` or `systemd`. -The listener callback function MUST be able to accept a single parameter, -the signal added by this method or you MAY use a function which -has no parameters at all. +The second parameter MUST be a listener callback function that accepts +the signal as its only parameter. +If you don't use the signal inside your listener callback function +you MAY use a function which has no parameters at all. The listener callback function MUST NOT throw an `Exception`. The return value of the listener callback function will be ignored and has @@ -521,14 +681,14 @@ $loop->addSignal(SIGINT, function (int $signal) { See also [example #4](examples). -Signaling is only available on Unix-like platform, Windows isn't +Signaling is only available on Unix-like platforms, Windows isn't supported due to operating system limitations. This method may throw a `BadMethodCallException` if signals aren't supported on this platform, for example when required extensions are missing. **Note: A listener can only be added once to the same signal, any -attempts to add it more then once will be ignored.** +attempts to add it more than once will be ignored.** #### removeSignal() @@ -559,9 +719,10 @@ react to this event with a single listener and then dispatch from this listener. This method MAY throw an `Exception` if the given resource type is not supported by this loop implementation. -The listener callback function MUST be able to accept a single parameter, -the stream resource added by this method or you MAY use a function which -has no parameters at all. +The second parameter MUST be a listener callback function that accepts +the stream resource as its only parameter. +If you don't use the stream resource inside your listener callback function +you MAY use a function which has no parameters at all. The listener callback function MUST NOT throw an `Exception`. The return value of the listener callback function will be ignored and has @@ -611,9 +772,10 @@ react to this event with a single listener and then dispatch from this listener. This method MAY throw an `Exception` if the given resource type is not supported by this loop implementation. -The listener callback function MUST be able to accept a single parameter, -the stream resource added by this method or you MAY use a function which -has no parameters at all. +The second parameter MUST be a listener callback function that accepts +the stream resource as its only parameter. +If you don't use the stream resource inside your listener callback function +you MAY use a function which has no parameters at all. The listener callback function MUST NOT throw an `Exception`. The return value of the listener callback function will be ignored and has @@ -655,21 +817,21 @@ to remove a stream that was never added or is invalid has no effect. ## Install -The recommended way to install this library is [through Composer](https://getcomposer.org). +The recommended way to install this library is [through Composer](https://getcomposer.org/). [New to Composer?](https://getcomposer.org/doc/00-intro.md) -This will install the latest supported version: +Once released, this project will follow [SemVer](https://semver.org/). +At the moment, this will install the latest development version: ```bash -$ composer require react/event-loop:^0.5.3 +composer require react/event-loop:^3@dev ``` See also the [CHANGELOG](CHANGELOG.md) for details about version upgrades. This project aims to run on any platform and thus does not require any PHP -extensions and supports running on legacy PHP 5.3 through current PHP 7+ and -HHVM. -It's *highly recommended to use PHP 7+* for this project. +extensions and supports running on PHP 7.1 through current PHP 8+. +It's *highly recommended to use the latest supported PHP version* for this project. Installing any of the event loop extensions is suggested, but entirely optional. See also [event loop implementations](#loop-implementations) for more details. @@ -677,16 +839,16 @@ See also [event loop implementations](#loop-implementations) for more details. ## Tests To run the test suite, you first need to clone this repo and then install all -dependencies [through Composer](https://getcomposer.org): +dependencies [through Composer](https://getcomposer.org/): ```bash -$ composer install +composer install ``` To run the test suite, go to the project root and run: ```bash -$ php vendor/bin/phpunit +vendor/bin/phpunit ``` ## License diff --git a/composer.json b/composer.json index c8ff91ec..6d31e81d 100644 --- a/composer.json +++ b/composer.json @@ -3,24 +3,45 @@ "description": "ReactPHP's core reactor event loop that libraries can use for evented I/O.", "keywords": ["event-loop", "asynchronous"], "license": "MIT", + "authors": [ + { + "name": "Christian Lück", + "homepage": "https://clue.engineering/", + "email": "christian@clue.engineering" + }, + { + "name": "Cees-Jan Kiewiet", + "homepage": "https://wyrihaximus.net/", + "email": "reactphp@ceesjankiewiet.nl" + }, + { + "name": "Jan Sorgalla", + "homepage": "https://sorgalla.com/", + "email": "jsorgalla@gmail.com" + }, + { + "name": "Chris Boden", + "homepage": "https://cboden.dev/", + "email": "cboden@gmail.com" + } + ], "require": { - "php": ">=5.3.0" + "php": ">=7.1" }, "require-dev": { - "phpunit/phpunit": "~4.8.35 || ^5.7 || ^6.4" + "phpunit/phpunit": "^9.6 || ^7.5" }, "suggest": { - "ext-event": "~1.0 for ExtEventLoop", "ext-pcntl": "For signal handling support when using the StreamSelectLoop" }, "autoload": { "psr-4": { - "React\\EventLoop\\": "src" + "React\\EventLoop\\": "src/" } }, "autoload-dev": { "psr-4": { - "React\\Tests\\EventLoop\\": "tests" + "React\\Tests\\EventLoop\\": "tests/" } } } diff --git a/examples/01-timers.php b/examples/01-timers.php index e6107e46..5f263b3e 100644 --- a/examples/01-timers.php +++ b/examples/01-timers.php @@ -1,15 +1,13 @@ addTimer(0.8, function () { +Loop::addTimer(0.8, function () { echo 'world!' . PHP_EOL; }); -$loop->addTimer(0.3, function () { +Loop::addTimer(0.3, function () { echo 'hello '; }); - -$loop->run(); diff --git a/examples/02-periodic.php b/examples/02-periodic.php index 5e138a62..68533bd3 100644 --- a/examples/02-periodic.php +++ b/examples/02-periodic.php @@ -1,16 +1,14 @@ addPeriodicTimer(0.1, function () { - echo 'tick!' . PHP_EOL; +$timer = Loop::addPeriodicTimer(0.1, function () { + echo 'Tick' . PHP_EOL; }); -$loop->addTimer(1.0, function () use ($loop, $timer) { - $loop->cancelTimer($timer); +Loop::addTimer(1.0, function () use ($timer) { + Loop::cancelTimer($timer); echo 'Done' . PHP_EOL; }); - -$loop->run(); diff --git a/examples/03-ticks.php b/examples/03-ticks.php index 3f36c6d4..e32e67af 100644 --- a/examples/03-ticks.php +++ b/examples/03-ticks.php @@ -1,15 +1,13 @@ futureTick(function () { +Loop::futureTick(function () { echo 'b'; }); -$loop->futureTick(function () { +Loop::futureTick(function () { echo 'c'; }); echo 'a'; - -$loop->run(); diff --git a/examples/04-signals.php b/examples/04-signals.php index 90b68989..e841311b 100644 --- a/examples/04-signals.php +++ b/examples/04-signals.php @@ -1,5 +1,7 @@ addSignal(SIGINT, $func = function ($signal) use ($loop, &$func) { +Loop::addSignal(SIGINT, $func = function ($signal) use (&$func) { echo 'Signal: ', (string)$signal, PHP_EOL; - $loop->removeSignal(SIGINT, $func); + Loop::removeSignal(SIGINT, $func); }); echo 'Listening for SIGINT. Use "kill -SIGINT ' . getmypid() . '" or CTRL+C' . PHP_EOL; - -$loop->run(); diff --git a/examples/11-consume-stdin.php b/examples/11-consume-stdin.php index 2a772455..dfcb220d 100644 --- a/examples/11-consume-stdin.php +++ b/examples/11-consume-stdin.php @@ -1,6 +1,6 @@ addReadStream(STDIN, function ($stream) use ($loop) { +Loop::addReadStream(STDIN, function ($stream) { $chunk = fread($stream, 64 * 1024); // reading nothing means we reached EOF if ($chunk === '') { - $loop->removeReadStream($stream); + Loop::removeReadStream($stream); stream_set_blocking($stream, true); fclose($stream); return; @@ -26,5 +24,3 @@ echo strlen($chunk) . ' bytes' . PHP_EOL; }); - -$loop->run(); diff --git a/examples/12-generate-yes.php b/examples/12-generate-yes.php index ebc2beb4..91442016 100644 --- a/examples/12-generate-yes.php +++ b/examples/12-generate-yes.php @@ -1,15 +1,15 @@ addWriteStream(STDOUT, function ($stdout) use ($loop, &$data) { +Loop::addWriteStream(STDOUT, function ($stdout) use (&$data) { // try to write data $r = fwrite($stdout, $data); // nothing could be written despite being writable => closed if ($r === 0) { - $loop->removeWriteStream($stdout); + Loop::removeWriteStream($stdout); fclose($stdout); stream_set_blocking($stdout, true); fwrite(STDERR, 'Stopped because STDOUT closed' . PHP_EOL); @@ -37,5 +37,3 @@ $data = substr($data, $r) . substr($data, 0, $r); } }); - -$loop->run(); diff --git a/examples/13-http-client-blocking.php b/examples/13-http-client-blocking.php index a2dde55c..ae119b5a 100644 --- a/examples/13-http-client-blocking.php +++ b/examples/13-http-client-blocking.php @@ -1,35 +1,31 @@ addReadStream($stream, function ($stream) use ($loop) { +Loop::addReadStream($stream, function ($stream) { $chunk = fread($stream, 64 * 1024); // reading nothing means we reached EOF if ($chunk === '') { echo '[END]' . PHP_EOL; - $loop->removeReadStream($stream); + Loop::removeReadStream($stream); fclose($stream); return; } echo $chunk; }); - -$loop->run(); diff --git a/examples/14-http-client-async.php b/examples/14-http-client-async.php index c82c9887..93419c02 100644 --- a/examples/14-http-client-async.php +++ b/examples/14-http-client-async.php @@ -1,14 +1,13 @@ addPeriodicTimer(0.01, function () { +$timer = Loop::addPeriodicTimer(0.01, function () { echo '.'; }); // wait for connection success/error -$loop->addWriteStream($stream, function ($stream) use ($loop, $timer) { - $loop->removeWriteStream($stream); - $loop->cancelTimer($timer); +Loop::addWriteStream($stream, function ($stream) use ($timer) { + Loop::removeWriteStream($stream); + Loop::cancelTimer($timer); // check for socket error (connection rejected) if (stream_socket_get_name($stream, true) === false) { @@ -42,16 +41,16 @@ } // send HTTP request - fwrite($stream, "GET / HTTP/1.1\r\nHost: www.google.com\r\nConnection: close\r\n\r\n"); + fwrite($stream, "GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n"); // wait for HTTP response - $loop->addReadStream($stream, function ($stream) use ($loop) { + Loop::addReadStream($stream, function ($stream) { $chunk = fread($stream, 64 * 1024); // reading nothing means we reached EOF if ($chunk === '') { echo '[END]' . PHP_EOL; - $loop->removeReadStream($stream); + Loop::removeReadStream($stream); fclose($stream); return; } @@ -59,5 +58,3 @@ echo $chunk; }); }); - -$loop->run(); diff --git a/examples/21-http-server.php b/examples/21-http-server.php index 89520cec..61529240 100644 --- a/examples/21-http-server.php +++ b/examples/21-http-server.php @@ -1,8 +1,8 @@ addReadStream($server, function ($server) use ($loop) { +Loop::addReadStream($server, function ($server) { $conn = stream_socket_accept($server); $data = "HTTP/1.1 200 OK\r\nContent-Length: 3\r\n\r\nHi\n"; - $loop->addWriteStream($conn, function ($conn) use (&$data, $loop) { + Loop::addWriteStream($conn, function ($conn) use (&$data) { $written = fwrite($conn, $data); if ($written === strlen($data)) { fclose($conn); - $loop->removeWriteStream($conn); + Loop::removeWriteStream($conn); } else { $data = substr($data, $written); } }); }); -$loop->addPeriodicTimer(5, function () { +Loop::addPeriodicTimer(5, function () { $memory = memory_get_usage() / 1024; $formatted = number_format($memory, 3).'K'; echo "Current memory usage: {$formatted}\n"; }); - -$loop->run(); diff --git a/examples/91-benchmark-ticks.php b/examples/91-benchmark-ticks.php index 3f4690b3..7a38f424 100644 --- a/examples/91-benchmark-ticks.php +++ b/examples/91-benchmark-ticks.php @@ -1,15 +1,11 @@ futureTick(function () { }); + Loop::futureTick(function () { }); } - -$loop->run(); diff --git a/examples/92-benchmark-timers.php b/examples/92-benchmark-timers.php index e2e02e49..ef838e60 100644 --- a/examples/92-benchmark-timers.php +++ b/examples/92-benchmark-timers.php @@ -1,15 +1,11 @@ addTimer(0, function () { }); + Loop::addTimer(0, function () { }); } - -$loop->run(); diff --git a/examples/93-benchmark-ticks-delay.php b/examples/93-benchmark-ticks-delay.php index 95ee78c6..28a99070 100644 --- a/examples/93-benchmark-ticks-delay.php +++ b/examples/93-benchmark-ticks-delay.php @@ -1,22 +1,18 @@ 0) { --$ticks; //$loop->addTimer(0, $tick); - $loop->futureTick($tick); + Loop::futureTick($tick); } else { echo 'done'; } }; $tick(); - -$loop->run(); diff --git a/examples/94-benchmark-timers-delay.php b/examples/94-benchmark-timers-delay.php index 2d6cfa25..df237264 100644 --- a/examples/94-benchmark-timers-delay.php +++ b/examples/94-benchmark-timers-delay.php @@ -1,22 +1,18 @@ 0) { --$ticks; //$loop->futureTick($tick); - $loop->addTimer(0, $tick); + Loop::addTimer(0, $tick); } else { echo 'done'; } }; $tick(); - -$loop->run(); diff --git a/examples/95-benchmark-memory.php b/examples/95-benchmark-memory.php index 084c4042..efd8c82d 100644 --- a/examples/95-benchmark-memory.php +++ b/examples/95-benchmark-memory.php @@ -7,40 +7,40 @@ * php 95-benchmark-memory.php -t 30 -l StreamSelect -r 10 */ -use React\EventLoop\Factory; +use React\EventLoop\Loop; use React\EventLoop\LoopInterface; use React\EventLoop\TimerInterface; require __DIR__ . '/../vendor/autoload.php'; $args = getopt('t:l:r:'); -$t = isset($args['t']) ? (int)$args['t'] : 0; -$loop = isset($args['l']) && class_exists('React\EventLoop\\' . $args['l'] . 'Loop') ? 'React\EventLoop\\' . $args['l'] . 'Loop' : Factory::create(); +$t = (int) ($args['t'] ?? 0); +$loop = isset($args['l']) && class_exists('React\EventLoop\\' . $args['l'] . 'Loop') ? 'React\EventLoop\\' . $args['l'] . 'Loop' : Loop::get(); if (!($loop instanceof LoopInterface)) { - $loop = new $loop(); + Loop::set(new $loop()); } -$r = isset($args['r']) ? (int)$args['r'] : 2; +$r = (int) ($args['r'] ?? 2); $runs = 0; if (5 < $t) { - $loop->addTimer($t, function () use ($loop) { - $loop->stop(); + Loop::addTimer($t, function () { + Loop::stop(); }); } -$loop->addPeriodicTimer(0.001, function () use (&$runs, $loop) { +Loop::addPeriodicTimer(0.001, function () use (&$runs) { $runs++; - $loop->addPeriodicTimer(1, function (TimerInterface $timer) use ($loop) { - $loop->cancelTimer($timer); + Loop::addPeriodicTimer(1, function (TimerInterface $timer) { + Loop::cancelTimer($timer); }); }); -$loop->addPeriodicTimer($r, function () use (&$runs) { +Loop::addPeriodicTimer($r, function () use (&$runs) { $kmem = round(memory_get_usage() / 1024); $kmemReal = round(memory_get_usage(true) / 1024); echo "Runs:\t\t\t$runs\n"; @@ -50,18 +50,18 @@ }); echo "PHP Version:\t\t", phpversion(), "\n"; -echo "Loop\t\t\t", get_class($loop), "\n"; +echo "Loop\t\t\t", get_class(Loop::get()), "\n"; echo "Time\t\t\t", date('r'), "\n"; echo str_repeat('-', 50), "\n"; $beginTime = time(); -$loop->run(); +Loop::run(); $endTime = time(); $timeTaken = $endTime - $beginTime; echo "PHP Version:\t\t", phpversion(), "\n"; -echo "Loop\t\t\t", get_class($loop), "\n"; +echo "Loop\t\t\t", get_class(Loop::get()), "\n"; echo "Time\t\t\t", date('r'), "\n"; echo "Time taken\t\t", $timeTaken, " seconds\n"; echo "Runs per second\t\t", round($runs / $timeTaken), "\n"; diff --git a/phpunit.xml.dist b/phpunit.xml.dist index cba6d4dd..ffc13cd3 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,25 +1,28 @@ - + + convertDeprecationsToExceptions="true"> - + ./tests/ - - - + + ./src/ - - + + + + + + + + + + diff --git a/phpunit.xml.legacy b/phpunit.xml.legacy new file mode 100644 index 00000000..7c148001 --- /dev/null +++ b/phpunit.xml.legacy @@ -0,0 +1,26 @@ + + + + + + + ./tests/ + + + + + ./src/ + + + + + + + + + + + diff --git a/src/ExtEvLoop.php b/src/ExtEvLoop.php index fedd5884..363ad0c4 100644 --- a/src/ExtEvLoop.php +++ b/src/ExtEvLoop.php @@ -14,8 +14,9 @@ * * This loop uses the [`ev` PECL extension](https://pecl.php.net/package/ev), * that provides an interface to `libev` library. + * `libev` itself supports a number of system-specific backends (epoll, kqueue). * - * This loop is known to work with PHP 5.4 through PHP 7+. + * This loop is known to work with PHP 7.1 through PHP 8+. * * @see http://php.net/manual/en/book.ev.php * @see https://bitbucket.org/osmanov/pecl-ev/overview @@ -40,12 +41,12 @@ class ExtEvLoop implements LoopInterface /** * @var EvIo[] */ - private $readStreams = array(); + private $readStreams = []; /** * @var EvIo[] */ - private $writeStreams = array(); + private $writeStreams = []; /** * @var bool @@ -60,7 +61,7 @@ class ExtEvLoop implements LoopInterface /** * @var \EvSignal[] */ - private $signalEvents = array(); + private $signalEvents = []; public function __construct() { @@ -137,13 +138,11 @@ public function addTimer($interval, $callback) { $timer = new Timer($interval, $callback, false); - $that = $this; - $timers = $this->timers; - $callback = function () use ($timer, $timers, $that) { + $callback = function () use ($timer) { \call_user_func($timer->getCallback(), $timer); - if ($timers->contains($timer)) { - $that->cancelTimer($timer); + if ($this->timers->contains($timer)) { + $this->cancelTimer($timer); } }; @@ -161,7 +160,7 @@ public function addPeriodicTimer($interval, $callback) \call_user_func($timer->getCallback(), $timer); }; - $event = $this->loop->timer($interval, $interval, $callback); + $event = $this->loop->timer($timer->getInterval(), $timer->getInterval(), $callback); $this->timers->attach($timer, $event); return $timer; diff --git a/src/ExtEventLoop.php b/src/ExtEventLoop.php index fd403d4a..d6f24b2a 100644 --- a/src/ExtEventLoop.php +++ b/src/ExtEventLoop.php @@ -5,7 +5,6 @@ use BadMethodCallException; use Event; use EventBase; -use EventConfig as EventBaseConfig; use React\EventLoop\Tick\FutureTickQueue; use React\EventLoop\Timer\Timer; use SplObjectStorage; @@ -13,10 +12,11 @@ /** * An `ext-event` based event loop. * - * This uses the [`event` PECL extension](https://pecl.php.net/package/event). - * It supports the same backends as libevent. + * This uses the [`event` PECL extension](https://pecl.php.net/package/event), + * that provides an interface to `libevent` library. + * `libevent` itself supports a number of system-specific backends (epoll, kqueue). * - * This loop is known to work with PHP 5.4 through PHP 7+. + * This loop is known to work with PHP 7.1 through PHP 8+. * * @link https://pecl.php.net/package/event */ @@ -27,15 +27,15 @@ final class ExtEventLoop implements LoopInterface private $timerCallback; private $timerEvents; private $streamCallback; - private $readEvents = array(); - private $writeEvents = array(); - private $readListeners = array(); - private $writeListeners = array(); - private $readRefs = array(); - private $writeRefs = array(); + private $readEvents = []; + private $writeEvents = []; + private $readListeners = []; + private $writeListeners = []; + private $readRefs = []; + private $writeRefs = []; private $running; private $signals; - private $signalEvents = array(); + private $signalEvents = []; public function __construct() { @@ -43,8 +43,13 @@ public function __construct() throw new BadMethodCallException('Cannot create ExtEventLoop, ext-event extension missing'); } - $config = new EventBaseConfig(); - $config->requireFeatures(EventBaseConfig::FEATURE_FDS); + // support arbitrary file descriptors and not just sockets + // Windows only has limited file descriptor support, so do not require this (will fail otherwise) + // @link http://www.wangafu.net/~nickm/libevent-book/Ref2_eventbase.html#_setting_up_a_complicated_event_base + $config = new \EventConfig(); + if (\DIRECTORY_SEPARATOR !== '\\') { + $config->requireFeatures(\EventConfig::FEATURE_FDS); + } $this->eventBase = new EventBase($config); $this->futureTickQueue = new FutureTickQueue(); @@ -55,6 +60,17 @@ public function __construct() $this->createStreamCallback(); } + public function __destruct() + { + // explicitly clear all references to Event objects to prevent SEGFAULTs on Windows + foreach ($this->timerEvents as $timer) { + $this->timerEvents->detach($timer); + } + + $this->readEvents = []; + $this->writeEvents = []; + } + public function addReadStream($stream, $listener) { $key = (int) $stream; @@ -69,9 +85,7 @@ public function addReadStream($stream, $listener) // ext-event does not increase refcount on stream resources for PHP 7+ // manually keep track of stream resource to prevent premature garbage collection - if (\PHP_VERSION_ID >= 70000) { - $this->readRefs[$key] = $stream; - } + $this->readRefs[$key] = $stream; } public function addWriteStream($stream, $listener) @@ -88,9 +102,7 @@ public function addWriteStream($stream, $listener) // ext-event does not increase refcount on stream resources for PHP 7+ // manually keep track of stream resource to prevent premature garbage collection - if (\PHP_VERSION_ID >= 70000) { - $this->writeRefs[$key] = $stream; - } + $this->writeRefs[$key] = $stream; } public function removeReadStream($stream) @@ -157,7 +169,7 @@ public function addSignal($signal, $listener) $this->signals->add($signal, $listener); if (!isset($this->signalEvents[$signal])) { - $this->signalEvents[$signal] = Event::signal($this->eventBase, $signal, array($this->signals, 'call')); + $this->signalEvents[$signal] = Event::signal($this->eventBase, $signal, [$this->signals, 'call']); $this->signalEvents[$signal]->add(); } } @@ -223,11 +235,10 @@ private function scheduleTimer(TimerInterface $timer) */ private function createTimerCallback() { - $timers = $this->timerEvents; - $this->timerCallback = function ($_, $__, $timer) use ($timers) { + $this->timerCallback = function ($_, $__, $timer) { \call_user_func($timer->getCallback(), $timer); - if (!$timer->isPeriodic() && $timers->contains($timer)) { + if (!$timer->isPeriodic() && $this->timerEvents->contains($timer)) { $this->cancelTimer($timer); } }; @@ -242,17 +253,15 @@ private function createTimerCallback() */ private function createStreamCallback() { - $read =& $this->readListeners; - $write =& $this->writeListeners; - $this->streamCallback = function ($stream, $flags) use (&$read, &$write) { + $this->streamCallback = function ($stream, $flags) { $key = (int) $stream; - if (Event::READ === (Event::READ & $flags) && isset($read[$key])) { - \call_user_func($read[$key], $stream); + if (Event::READ === (Event::READ & $flags) && isset($this->readListeners[$key])) { + \call_user_func($this->readListeners[$key], $stream); } - if (Event::WRITE === (Event::WRITE & $flags) && isset($write[$key])) { - \call_user_func($write[$key], $stream); + if (Event::WRITE === (Event::WRITE & $flags) && isset($this->writeListeners[$key])) { + \call_user_func($this->writeListeners[$key], $stream); } }; } diff --git a/src/ExtLibevLoop.php b/src/ExtLibevLoop.php deleted file mode 100644 index 193c6c0d..00000000 --- a/src/ExtLibevLoop.php +++ /dev/null @@ -1,199 +0,0 @@ -loop = new EventLoop(); - $this->futureTickQueue = new FutureTickQueue(); - $this->timerEvents = new SplObjectStorage(); - $this->signals = new SignalsHandler(); - } - - public function addReadStream($stream, $listener) - { - if (isset($this->readEvents[(int) $stream])) { - return; - } - - $callback = function () use ($stream, $listener) { - \call_user_func($listener, $stream); - }; - - $event = new IOEvent($callback, $stream, IOEvent::READ); - $this->loop->add($event); - - $this->readEvents[(int) $stream] = $event; - } - - public function addWriteStream($stream, $listener) - { - if (isset($this->writeEvents[(int) $stream])) { - return; - } - - $callback = function () use ($stream, $listener) { - \call_user_func($listener, $stream); - }; - - $event = new IOEvent($callback, $stream, IOEvent::WRITE); - $this->loop->add($event); - - $this->writeEvents[(int) $stream] = $event; - } - - public function removeReadStream($stream) - { - $key = (int) $stream; - - if (isset($this->readEvents[$key])) { - $this->readEvents[$key]->stop(); - $this->loop->remove($this->readEvents[$key]); - unset($this->readEvents[$key]); - } - } - - public function removeWriteStream($stream) - { - $key = (int) $stream; - - if (isset($this->writeEvents[$key])) { - $this->writeEvents[$key]->stop(); - $this->loop->remove($this->writeEvents[$key]); - unset($this->writeEvents[$key]); - } - } - - public function addTimer($interval, $callback) - { - $timer = new Timer( $interval, $callback, false); - - $that = $this; - $timers = $this->timerEvents; - $callback = function () use ($timer, $timers, $that) { - \call_user_func($timer->getCallback(), $timer); - - if ($timers->contains($timer)) { - $that->cancelTimer($timer); - } - }; - - $event = new TimerEvent($callback, $timer->getInterval()); - $this->timerEvents->attach($timer, $event); - $this->loop->add($event); - - return $timer; - } - - public function addPeriodicTimer($interval, $callback) - { - $timer = new Timer($interval, $callback, true); - - $callback = function () use ($timer) { - \call_user_func($timer->getCallback(), $timer); - }; - - $event = new TimerEvent($callback, $interval, $interval); - $this->timerEvents->attach($timer, $event); - $this->loop->add($event); - - return $timer; - } - - public function cancelTimer(TimerInterface $timer) - { - if (isset($this->timerEvents[$timer])) { - $this->loop->remove($this->timerEvents[$timer]); - $this->timerEvents->detach($timer); - } - } - - public function futureTick($listener) - { - $this->futureTickQueue->add($listener); - } - - public function addSignal($signal, $listener) - { - $this->signals->add($signal, $listener); - - if (!isset($this->signalEvents[$signal])) { - $signals = $this->signals; - $this->signalEvents[$signal] = new SignalEvent(function () use ($signals, $signal) { - $signals->call($signal); - }, $signal); - $this->loop->add($this->signalEvents[$signal]); - } - } - - public function removeSignal($signal, $listener) - { - $this->signals->remove($signal, $listener); - - if (isset($this->signalEvents[$signal]) && $this->signals->count($signal) === 0) { - $this->signalEvents[$signal]->stop(); - $this->loop->remove($this->signalEvents[$signal]); - unset($this->signalEvents[$signal]); - } - } - - public function run() - { - $this->running = true; - - while ($this->running) { - $this->futureTickQueue->tick(); - - $flags = EventLoop::RUN_ONCE; - if (!$this->running || !$this->futureTickQueue->isEmpty()) { - $flags |= EventLoop::RUN_NOWAIT; - } elseif (!$this->readEvents && !$this->writeEvents && !$this->timerEvents->count() && $this->signals->isEmpty()) { - break; - } - - $this->loop->run($flags); - } - } - - public function stop() - { - $this->running = false; - } -} diff --git a/src/ExtLibeventLoop.php b/src/ExtLibeventLoop.php deleted file mode 100644 index 55c2fca0..00000000 --- a/src/ExtLibeventLoop.php +++ /dev/null @@ -1,283 +0,0 @@ -eventBase = \event_base_new(); - $this->futureTickQueue = new FutureTickQueue(); - $this->timerEvents = new SplObjectStorage(); - $this->signals = new SignalsHandler(); - - $this->createTimerCallback(); - $this->createStreamCallback(); - } - - public function addReadStream($stream, $listener) - { - $key = (int) $stream; - if (isset($this->readListeners[$key])) { - return; - } - - $event = \event_new(); - \event_set($event, $stream, \EV_PERSIST | \EV_READ, $this->streamCallback); - \event_base_set($event, $this->eventBase); - \event_add($event); - - $this->readEvents[$key] = $event; - $this->readListeners[$key] = $listener; - } - - public function addWriteStream($stream, $listener) - { - $key = (int) $stream; - if (isset($this->writeListeners[$key])) { - return; - } - - $event = \event_new(); - \event_set($event, $stream, \EV_PERSIST | \EV_WRITE, $this->streamCallback); - \event_base_set($event, $this->eventBase); - \event_add($event); - - $this->writeEvents[$key] = $event; - $this->writeListeners[$key] = $listener; - } - - public function removeReadStream($stream) - { - $key = (int) $stream; - - if (isset($this->readListeners[$key])) { - $event = $this->readEvents[$key]; - \event_del($event); - \event_free($event); - - unset( - $this->readEvents[$key], - $this->readListeners[$key] - ); - } - } - - public function removeWriteStream($stream) - { - $key = (int) $stream; - - if (isset($this->writeListeners[$key])) { - $event = $this->writeEvents[$key]; - \event_del($event); - \event_free($event); - - unset( - $this->writeEvents[$key], - $this->writeListeners[$key] - ); - } - } - - public function addTimer($interval, $callback) - { - $timer = new Timer($interval, $callback, false); - - $this->scheduleTimer($timer); - - return $timer; - } - - public function addPeriodicTimer($interval, $callback) - { - $timer = new Timer($interval, $callback, true); - - $this->scheduleTimer($timer); - - return $timer; - } - - public function cancelTimer(TimerInterface $timer) - { - if ($this->timerEvents->contains($timer)) { - $event = $this->timerEvents[$timer]; - \event_del($event); - \event_free($event); - - $this->timerEvents->detach($timer); - } - } - - public function futureTick($listener) - { - $this->futureTickQueue->add($listener); - } - - public function addSignal($signal, $listener) - { - $this->signals->add($signal, $listener); - - if (!isset($this->signalEvents[$signal])) { - $this->signalEvents[$signal] = \event_new(); - \event_set($this->signalEvents[$signal], $signal, \EV_PERSIST | \EV_SIGNAL, array($this->signals, 'call')); - \event_base_set($this->signalEvents[$signal], $this->eventBase); - \event_add($this->signalEvents[$signal]); - } - } - - public function removeSignal($signal, $listener) - { - $this->signals->remove($signal, $listener); - - if (isset($this->signalEvents[$signal]) && $this->signals->count($signal) === 0) { - \event_del($this->signalEvents[$signal]); - \event_free($this->signalEvents[$signal]); - unset($this->signalEvents[$signal]); - } - } - - public function run() - { - $this->running = true; - - while ($this->running) { - $this->futureTickQueue->tick(); - - $flags = \EVLOOP_ONCE; - if (!$this->running || !$this->futureTickQueue->isEmpty()) { - $flags |= \EVLOOP_NONBLOCK; - } elseif (!$this->readEvents && !$this->writeEvents && !$this->timerEvents->count() && $this->signals->isEmpty()) { - break; - } - - \event_base_loop($this->eventBase, $flags); - } - } - - public function stop() - { - $this->running = false; - } - - /** - * Schedule a timer for execution. - * - * @param TimerInterface $timer - */ - private function scheduleTimer(TimerInterface $timer) - { - $this->timerEvents[$timer] = $event = \event_timer_new(); - - \event_timer_set($event, $this->timerCallback, $timer); - \event_base_set($event, $this->eventBase); - \event_add($event, $timer->getInterval() * self::MICROSECONDS_PER_SECOND); - } - - /** - * Create a callback used as the target of timer events. - * - * A reference is kept to the callback for the lifetime of the loop - * to prevent "Cannot destroy active lambda function" fatal error from - * the event extension. - */ - private function createTimerCallback() - { - $that = $this; - $timers = $this->timerEvents; - $this->timerCallback = function ($_, $__, $timer) use ($timers, $that) { - \call_user_func($timer->getCallback(), $timer); - - // Timer already cancelled ... - if (!$timers->contains($timer)) { - return; - } - - // Reschedule periodic timers ... - if ($timer->isPeriodic()) { - \event_add( - $timers[$timer], - $timer->getInterval() * ExtLibeventLoop::MICROSECONDS_PER_SECOND - ); - - // Clean-up one shot timers ... - } else { - $that->cancelTimer($timer); - } - }; - } - - /** - * Create a callback used as the target of stream events. - * - * A reference is kept to the callback for the lifetime of the loop - * to prevent "Cannot destroy active lambda function" fatal error from - * the event extension. - */ - private function createStreamCallback() - { - $read =& $this->readListeners; - $write =& $this->writeListeners; - $this->streamCallback = function ($stream, $flags) use (&$read, &$write) { - $key = (int) $stream; - - if (\EV_READ === (\EV_READ & $flags) && isset($read[$key])) { - \call_user_func($read[$key], $stream); - } - - if (\EV_WRITE === (\EV_WRITE & $flags) && isset($write[$key])) { - \call_user_func($write[$key], $stream); - } - }; - } -} diff --git a/src/ExtUvLoop.php b/src/ExtUvLoop.php new file mode 100644 index 00000000..e9e79524 --- /dev/null +++ b/src/ExtUvLoop.php @@ -0,0 +1,339 @@ +uv = \uv_loop_new(); + $this->futureTickQueue = new FutureTickQueue(); + $this->timers = new SplObjectStorage(); + $this->streamListener = $this->createStreamListener(); + $this->signals = new SignalsHandler(); + } + + /** + * Returns the underlying ext-uv event loop. (Internal ReactPHP use only.) + * + * @internal + * + * @return resource + */ + public function getUvLoop() + { + return $this->uv; + } + + /** + * {@inheritdoc} + */ + public function addReadStream($stream, $listener) + { + if (isset($this->readStreams[(int) $stream])) { + return; + } + + $this->readStreams[(int) $stream] = $listener; + $this->addStream($stream); + } + + /** + * {@inheritdoc} + */ + public function addWriteStream($stream, $listener) + { + if (isset($this->writeStreams[(int) $stream])) { + return; + } + + $this->writeStreams[(int) $stream] = $listener; + $this->addStream($stream); + } + + /** + * {@inheritdoc} + */ + public function removeReadStream($stream) + { + if (!isset($this->streamEvents[(int) $stream])) { + return; + } + + unset($this->readStreams[(int) $stream]); + $this->removeStream($stream); + } + + /** + * {@inheritdoc} + */ + public function removeWriteStream($stream) + { + if (!isset($this->streamEvents[(int) $stream])) { + return; + } + + unset($this->writeStreams[(int) $stream]); + $this->removeStream($stream); + } + + /** + * {@inheritdoc} + */ + public function addTimer($interval, $callback) + { + $timer = new Timer($interval, $callback, false); + + $callback = function () use ($timer) { + \call_user_func($timer->getCallback(), $timer); + + if ($this->timers->contains($timer)) { + $this->cancelTimer($timer); + } + }; + + $event = \uv_timer_init($this->uv); + $this->timers->attach($timer, $event); + \uv_timer_start( + $event, + $this->convertFloatSecondsToMilliseconds($interval), + 0, + $callback + ); + + return $timer; + } + + /** + * {@inheritdoc} + */ + public function addPeriodicTimer($interval, $callback) + { + $timer = new Timer($interval, $callback, true); + + $callback = function () use ($timer) { + \call_user_func($timer->getCallback(), $timer); + }; + + $interval = $this->convertFloatSecondsToMilliseconds($interval); + $event = \uv_timer_init($this->uv); + $this->timers->attach($timer, $event); + \uv_timer_start( + $event, + $interval, + (int) $interval === 0 ? 1 : $interval, + $callback + ); + + return $timer; + } + + /** + * {@inheritdoc} + */ + public function cancelTimer(TimerInterface $timer) + { + if (isset($this->timers[$timer])) { + @\uv_timer_stop($this->timers[$timer]); + $this->timers->detach($timer); + } + } + + /** + * {@inheritdoc} + */ + public function futureTick($listener) + { + $this->futureTickQueue->add($listener); + } + + public function addSignal($signal, $listener) + { + $this->signals->add($signal, $listener); + + if (!isset($this->signalEvents[$signal])) { + $this->signalEvents[$signal] = \uv_signal_init($this->uv); + \uv_signal_start($this->signalEvents[$signal], function () use ($signal) { + $this->signals->call($signal); + }, $signal); + } + } + + public function removeSignal($signal, $listener) + { + $this->signals->remove($signal, $listener); + + if (isset($this->signalEvents[$signal]) && $this->signals->count($signal) === 0) { + \uv_signal_stop($this->signalEvents[$signal]); + unset($this->signalEvents[$signal]); + } + } + + /** + * {@inheritdoc} + */ + public function run() + { + $this->running = true; + + while ($this->running) { + $this->futureTickQueue->tick(); + + $hasPendingCallbacks = !$this->futureTickQueue->isEmpty(); + $wasJustStopped = !$this->running; + $nothingLeftToDo = !$this->readStreams + && !$this->writeStreams + && !$this->timers->count() + && $this->signals->isEmpty(); + + // Use UV::RUN_ONCE when there are only I/O events active in the loop and block until one of those triggers, + // otherwise use UV::RUN_NOWAIT. + // @link http://docs.libuv.org/en/v1.x/loop.html#c.uv_run + $flags = \UV::RUN_ONCE; + if ($wasJustStopped || $hasPendingCallbacks) { + $flags = \UV::RUN_NOWAIT; + } elseif ($nothingLeftToDo) { + break; + } + + \uv_run($this->uv, $flags); + } + } + + /** + * {@inheritdoc} + */ + public function stop() + { + $this->running = false; + } + + private function addStream($stream) + { + if (!isset($this->streamEvents[(int) $stream])) { + $this->streamEvents[(int)$stream] = \uv_poll_init_socket($this->uv, $stream); + } + + if ($this->streamEvents[(int) $stream] !== false) { + $this->pollStream($stream); + } + } + + private function removeStream($stream) + { + if (!isset($this->streamEvents[(int) $stream])) { + return; + } + + if (!isset($this->readStreams[(int) $stream]) + && !isset($this->writeStreams[(int) $stream])) { + \uv_poll_stop($this->streamEvents[(int) $stream]); + \uv_close($this->streamEvents[(int) $stream]); + unset($this->streamEvents[(int) $stream]); + return; + } + + $this->pollStream($stream); + } + + private function pollStream($stream) + { + if (!isset($this->streamEvents[(int) $stream])) { + return; + } + + $flags = 0; + if (isset($this->readStreams[(int) $stream])) { + $flags |= \UV::READABLE; + } + + if (isset($this->writeStreams[(int) $stream])) { + $flags |= \UV::WRITABLE; + } + + \uv_poll_start($this->streamEvents[(int) $stream], $flags, $this->streamListener); + } + + /** + * Create a stream listener + * + * @return callable Returns a callback + */ + private function createStreamListener() + { + $callback = function ($event, $status, $events, $stream) { + // libuv automatically stops polling on error, re-enable polling to match other loop implementations + if ($status !== 0) { + $this->pollStream($stream); + + // libuv may report no events on error, but this should still invoke stream listeners to report closed connections + // re-enable both readable and writable, correct listeners will be checked below anyway + if ($events === 0) { + $events = \UV::READABLE | \UV::WRITABLE; + } + } + + if (isset($this->readStreams[(int) $stream]) && ($events & \UV::READABLE)) { + \call_user_func($this->readStreams[(int) $stream], $stream); + } + + if (isset($this->writeStreams[(int) $stream]) && ($events & \UV::WRITABLE)) { + \call_user_func($this->writeStreams[(int) $stream], $stream); + } + }; + + return $callback; + } + + /** + * @param float $interval + * @return int + */ + private function convertFloatSecondsToMilliseconds($interval) + { + if ($interval < 0) { + return 0; + } + + $maxValue = (int) (\PHP_INT_MAX / 1000); + $intInterval = (int) $interval; + + if (($intInterval <= 0 && $interval > 1) || $intInterval >= $maxValue) { + throw new \InvalidArgumentException( + "Interval overflow, value must be lower than '{$maxValue}', but '{$interval}' passed." + ); + } + + return (int) \floor($interval * 1000); + } +} diff --git a/src/Factory.php b/src/Factory.php deleted file mode 100644 index 763c077b..00000000 --- a/src/Factory.php +++ /dev/null @@ -1,41 +0,0 @@ -futureTick(function () use (&$hasRun) { + $hasRun = true; + }); + + register_shutdown_function(function () use ($loop, &$hasRun) { + // Don't run if we're coming from a fatal error (uncaught exception). + $error = error_get_last(); + if (($error['type'] ?? 0) & (E_ERROR | E_CORE_ERROR | E_COMPILE_ERROR | E_USER_ERROR | E_RECOVERABLE_ERROR)) { + return; + } + + if (!$hasRun && !self::$stopped) { + $loop->run(); + } + }); + // @codeCoverageIgnoreEnd + + return self::$instance; + } + + /** + * Internal undocumented method, behavior might change or throw in the + * future. Use with caution and at your own risk. + * + * @internal + * @return void + */ + public static function set(LoopInterface $loop) + { + self::$instance = $loop; + } + + /** + * [Advanced] Register a listener to be notified when a stream is ready to read. + * + * @param resource $stream + * @param callable $listener + * @return void + * @throws \Exception + * @see LoopInterface::addReadStream() + */ + public static function addReadStream($stream, $listener) + { + (self::$instance ?? self::get())->addReadStream($stream, $listener); + } + + /** + * [Advanced] Register a listener to be notified when a stream is ready to write. + * + * @param resource $stream + * @param callable $listener + * @return void + * @throws \Exception + * @see LoopInterface::addWriteStream() + */ + public static function addWriteStream($stream, $listener) + { + (self::$instance ?? self::get())->addWriteStream($stream, $listener); + } + + /** + * Remove the read event listener for the given stream. + * + * @param resource $stream + * @return void + * @see LoopInterface::removeReadStream() + */ + public static function removeReadStream($stream) + { + if (self::$instance !== null) { + self::$instance->removeReadStream($stream); + } + } + + /** + * Remove the write event listener for the given stream. + * + * @param resource $stream + * @return void + * @see LoopInterface::removeWriteStream() + */ + public static function removeWriteStream($stream) + { + if (self::$instance !== null) { + self::$instance->removeWriteStream($stream); + } + } + + /** + * Enqueue a callback to be invoked once after the given interval. + * + * @param float $interval + * @param callable $callback + * @return TimerInterface + * @see LoopInterface::addTimer() + */ + public static function addTimer($interval, $callback) + { + return (self::$instance ?? self::get())->addTimer($interval, $callback); + } + + /** + * Enqueue a callback to be invoked repeatedly after the given interval. + * + * @param float $interval + * @param callable $callback + * @return TimerInterface + * @see LoopInterface::addPeriodicTimer() + */ + public static function addPeriodicTimer($interval, $callback) + { + return (self::$instance ?? self::get())->addPeriodicTimer($interval, $callback); + } + + /** + * Cancel a pending timer. + * + * @param TimerInterface $timer + * @return void + * @see LoopInterface::cancelTimer() + */ + public static function cancelTimer(TimerInterface $timer) + { + if (self::$instance !== null) { + self::$instance->cancelTimer($timer); + } + } + + /** + * Schedule a callback to be invoked on a future tick of the event loop. + * + * @param callable $listener + * @return void + * @see LoopInterface::futureTick() + */ + public static function futureTick($listener) + { + (self::$instance ?? self::get())->futureTick($listener); + } + + /** + * Register a listener to be notified when a signal has been caught by this process. + * + * @param int $signal + * @param callable $listener + * @return void + * @see LoopInterface::addSignal() + */ + public static function addSignal($signal, $listener) + { + (self::$instance ?? self::get())->addSignal($signal, $listener); + } + + /** + * Removes a previously added signal listener. + * + * @param int $signal + * @param callable $listener + * @return void + * @see LoopInterface::removeSignal() + */ + public static function removeSignal($signal, $listener) + { + if (self::$instance !== null) { + self::$instance->removeSignal($signal, $listener); + } + } + + /** + * Run the event loop until there are no more tasks to perform. + * + * @return void + * @see LoopInterface::run() + */ + public static function run() + { + (self::$instance ?? self::get())->run(); + } + + /** + * Instruct a running event loop to stop. + * + * @return void + * @see LoopInterface::stop() + */ + public static function stop() + { + self::$stopped = true; + if (self::$instance !== null) { + self::$instance->stop(); + } + } + + /** + * @return LoopInterface + */ + private static function create() + { + // @codeCoverageIgnoreStart + if (\function_exists('uv_loop_new')) { + return new ExtUvLoop(); + } + + if (\class_exists('EvLoop', false)) { + return new ExtEvLoop(); + } + + if (\class_exists('EventBase', false)) { + return new ExtEventLoop(); + } + + return new StreamSelectLoop(); + // @codeCoverageIgnoreEnd + } +} diff --git a/src/LoopInterface.php b/src/LoopInterface.php index 1cc8640f..9266f718 100644 --- a/src/LoopInterface.php +++ b/src/LoopInterface.php @@ -20,9 +20,10 @@ interface LoopInterface * listener. This method MAY throw an `Exception` if the given resource type * is not supported by this loop implementation. * - * The listener callback function MUST be able to accept a single parameter, - * the stream resource added by this method or you MAY use a function which - * has no parameters at all. + * The second parameter MUST be a listener callback function that accepts + * the stream resource as its only parameter. + * If you don't use the stream resource inside your listener callback function + * you MAY use a function which has no parameters at all. * * The listener callback function MUST NOT throw an `Exception`. * The return value of the listener callback function will be ignored and has @@ -69,9 +70,10 @@ public function addReadStream($stream, $listener); * listener. This method MAY throw an `Exception` if the given resource type * is not supported by this loop implementation. * - * The listener callback function MUST be able to accept a single parameter, - * the stream resource added by this method or you MAY use a function which - * has no parameters at all. + * The second parameter MUST be a listener callback function that accepts + * the stream resource as its only parameter. + * If you don't use the stream resource inside your listener callback function + * you MAY use a function which has no parameters at all. * * The listener callback function MUST NOT throw an `Exception`. * The return value of the listener callback function will be ignored and has @@ -133,18 +135,21 @@ public function removeWriteStream($stream); /** * Enqueue a callback to be invoked once after the given interval. * - * The timer callback function MUST be able to accept a single parameter, - * the timer instance as also returned by this method or you MAY use a - * function which has no parameters at all. + * The second parameter MUST be a timer callback function that accepts + * the timer instance as its only parameter. + * If you don't use the timer instance inside your timer callback function + * you MAY use a function which has no parameters at all. * * The timer callback function MUST NOT throw an `Exception`. * The return value of the timer callback function will be ignored and has * no effect, so for performance reasons you're recommended to not return * any excessive data structures. * + * This method returns a timer instance. The same timer instance will also be + * passed into the timer callback function as described above. + * You can invoke [`cancelTimer`](#canceltimer) to cancel a pending timer. * Unlike [`addPeriodicTimer()`](#addperiodictimer), this method will ensure * the callback will be invoked only once after the given interval. - * You can invoke [`cancelTimer`](#canceltimer) to cancel a pending timer. * * ```php * $loop->addTimer(0.8, function () { @@ -185,8 +190,8 @@ public function removeWriteStream($stream); * * This interface suggests that event loop implementations SHOULD use a * monotonic time source if available. Given that a monotonic time source is - * not available on PHP by default, event loop implementations MAY fall back - * to using wall-clock time. + * only available as of PHP 7.3 by default, event loop implementations MAY + * fall back to using wall-clock time. * While this does not affect many common use cases, this is an important * distinction for programs that rely on a high time precision or on systems * that are subject to discontinuous time adjustments (time jumps). @@ -204,18 +209,21 @@ public function addTimer($interval, $callback); /** * Enqueue a callback to be invoked repeatedly after the given interval. * - * The timer callback function MUST be able to accept a single parameter, - * the timer instance as also returned by this method or you MAY use a - * function which has no parameters at all. + * The second parameter MUST be a timer callback function that accepts + * the timer instance as its only parameter. + * If you don't use the timer instance inside your timer callback function + * you MAY use a function which has no parameters at all. * * The timer callback function MUST NOT throw an `Exception`. * The return value of the timer callback function will be ignored and has * no effect, so for performance reasons you're recommended to not return * any excessive data structures. * - * Unlike [`addTimer()`](#addtimer), this method will ensure the the - * callback will be invoked infinitely after the given interval or until you - * invoke [`cancelTimer`](#canceltimer). + * This method returns a timer instance. The same timer instance will also be + * passed into the timer callback function as described above. + * Unlike [`addTimer()`](#addtimer), this method will ensure the callback + * will be invoked infinitely after the given interval or until you invoke + * [`cancelTimer`](#canceltimer). * * ```php * $timer = $loop->addPeriodicTimer(0.1, function () { @@ -263,8 +271,8 @@ public function addTimer($interval, $callback); * * This interface suggests that event loop implementations SHOULD use a * monotonic time source if available. Given that a monotonic time source is - * not available on PHP by default, event loop implementations MAY fall back - * to using wall-clock time. + * only available as of PHP 7.3 by default, event loop implementations MAY + * fall back to using wall-clock time. * While this does not affect many common use cases, this is an important * distinction for programs that rely on a high time precision or on systems * that are subject to discontinuous time adjustments (time jumps). @@ -356,9 +364,10 @@ public function futureTick($listener); * This is useful to catch user interrupt signals or shutdown signals from * tools like `supervisor` or `systemd`. * - * The listener callback function MUST be able to accept a single parameter, - * the signal added by this method or you MAY use a function which - * has no parameters at all. + * The second parameter MUST be a listener callback function that accepts + * the signal as its only parameter. + * If you don't use the signal inside your listener callback function + * you MAY use a function which has no parameters at all. * * The listener callback function MUST NOT throw an `Exception`. * The return value of the listener callback function will be ignored and has @@ -373,14 +382,14 @@ public function futureTick($listener); * * See also [example #4](examples). * - * Signaling is only available on Unix-like platform, Windows isn't + * Signaling is only available on Unix-like platforms, Windows isn't * supported due to operating system limitations. * This method may throw a `BadMethodCallException` if signals aren't * supported on this platform, for example when required extensions are * missing. * * **Note: A listener can only be added once to the same signal, any - * attempts to add it more then once will be ignored.** + * attempts to add it more than once will be ignored.** * * @param int $signal * @param callable $listener @@ -413,7 +422,7 @@ public function removeSignal($signal, $listener); * * For many applications, this method is the only directly visible * invocation on the event loop. - * As a rule of thumb, it is usally recommended to attach everything to the + * As a rule of thumb, it is usually recommended to attach everything to the * same loop instance and then run the loop once at the bottom end of the * application. * @@ -431,7 +440,7 @@ public function removeSignal($signal, $listener); * for any of the attached listeners. * * This method MUST NOT be called while the loop is already running. - * This method MAY be called more than once after it has explicity been + * This method MAY be called more than once after it has explicitly been * [`stop()`ped](#stop) or after it automatically stopped because it * previously did no longer have anything to do. * diff --git a/src/SignalsHandler.php b/src/SignalsHandler.php index 10d125df..e9b245ea 100644 --- a/src/SignalsHandler.php +++ b/src/SignalsHandler.php @@ -7,12 +7,12 @@ */ final class SignalsHandler { - private $signals = array(); + private $signals = []; public function add($signal, $listener) { if (!isset($this->signals[$signal])) { - $this->signals[$signal] = array(); + $this->signals[$signal] = []; } if (\in_array($listener, $this->signals[$signal])) { diff --git a/src/StreamSelectLoop.php b/src/StreamSelectLoop.php index 625b6fb6..41dd2cb3 100644 --- a/src/StreamSelectLoop.php +++ b/src/StreamSelectLoop.php @@ -2,7 +2,6 @@ namespace React\EventLoop; -use React\EventLoop\Signal\Pcntl; use React\EventLoop\Tick\FutureTickQueue; use React\EventLoop\Timer\Timer; use React\EventLoop\Timer\Timers; @@ -10,13 +9,13 @@ /** * A `stream_select()` based event loop. * - * This uses the [`stream_select()`](http://php.net/manual/en/function.stream-select.php) - * function and is the only implementation which works out of the box with PHP. + * This uses the [`stream_select()`](https://www.php.net/manual/en/function.stream-select.php) + * function and is the only implementation that works out of the box with PHP. * - * This event loop works out of the box on PHP 5.4 through PHP 7+ and HHVM. + * This event loop works out of the box on any PHP version. * This means that no installation is required and this library works on all * platforms and supported PHP versions. - * Accordingly, the [`Factory`](#factory) will use this event loop by default if + * Accordingly, the [`Loop` class](#loop) will use this event loop by default if * you do not install any of the event loop extensions listed below. * * Under the hood, it does a simple `select` system call. @@ -38,16 +37,17 @@ * If this extension is missing (or you're running on Windows), signal handling is * not supported and throws a `BadMethodCallException` instead. * - * This event loop is known to rely on wall-clock time to schedule future - * timers, because a monotonic time source is not available in PHP by default. + * This event loop is known to rely on wall-clock time to schedule future timers + * when using any version before PHP 7.3, because a monotonic time source is + * only available as of PHP 7.3 (`hrtime()`). * While this does not affect many common use cases, this is an important * distinction for programs that rely on a high time precision or on systems * that are subject to discontinuous time adjustments (time jumps). - * This means that if you schedule a timer to trigger in 30s and then adjust - * your system time forward by 20s, the timer may trigger in 10s. + * This means that if you schedule a timer to trigger in 30s on PHP < 7.3 and + * then adjust your system time forward by 20s, the timer may trigger in 10s. * See also [`addTimer()`](#addtimer) for more details. * - * @link http://php.net/manual/en/function.stream-select.php + * @link https://www.php.net/manual/en/function.stream-select.php */ final class StreamSelectLoop implements LoopInterface { @@ -56,20 +56,27 @@ final class StreamSelectLoop implements LoopInterface private $futureTickQueue; private $timers; - private $readStreams = array(); - private $readListeners = array(); - private $writeStreams = array(); - private $writeListeners = array(); + private $readStreams = []; + private $readListeners = []; + private $writeStreams = []; + private $writeListeners = []; private $running; private $pcntl = false; + private $pcntlPoll = false; private $signals; public function __construct() { $this->futureTickQueue = new FutureTickQueue(); $this->timers = new Timers(); - $this->pcntl = \extension_loaded('pcntl'); + $this->pcntl = \function_exists('pcntl_signal') && \function_exists('pcntl_signal_dispatch'); + $this->pcntlPoll = $this->pcntl && !\function_exists('pcntl_async_signals'); $this->signals = new SignalsHandler(); + + // prefer async signals if available (PHP 7.1+) or fall back to dispatching on each tick + if ($this->pcntl && !$this->pcntlPoll) { + \pcntl_async_signals(true); + } } public function addReadStream($stream, $listener) @@ -150,7 +157,7 @@ public function addSignal($signal, $listener) $this->signals->add($signal, $listener); if ($first) { - \pcntl_signal($signal, array($this->signals, 'call')); + \pcntl_signal($signal, [$this->signals, 'call']); } } @@ -222,7 +229,7 @@ private function waitForStreamActivity($timeout) $write = $this->writeStreams; $available = $this->streamSelect($read, $write, $timeout); - if ($this->pcntl) { + if ($this->pcntlPoll) { \pcntl_signal_dispatch(); } if (false === $available) { @@ -252,23 +259,67 @@ private function waitForStreamActivity($timeout) * Emulate a stream_select() implementation that does not break when passed * empty stream arrays. * - * @param array &$read An array of read streams to select upon. - * @param array &$write An array of write streams to select upon. - * @param integer|null $timeout Activity timeout in microseconds, or null to wait forever. + * @param array $read An array of read streams to select upon. + * @param array $write An array of write streams to select upon. + * @param int|null $timeout Activity timeout in microseconds, or null to wait forever. * - * @return integer|false The total number of streams that are ready for read/write. - * Can return false if stream_select() is interrupted by a signal. + * @return int|false The total number of streams that are ready for read/write. + * Can return false if stream_select() is interrupted by a signal. */ private function streamSelect(array &$read, array &$write, $timeout) { if ($read || $write) { + // We do not usually use or expose the `exceptfds` parameter passed to the underlying `select`. + // However, Windows does not report failed connection attempts in `writefds` passed to `select` like most other platforms. + // Instead, it uses `writefds` only for successful connection attempts and `exceptfds` for failed connection attempts. + // We work around this by adding all sockets that look like a pending connection attempt to `exceptfds` automatically on Windows and merge it back later. + // This ensures the public API matches other loop implementations across all platforms (see also test suite or rather test matrix). + // Lacking better APIs, every write-only socket that has not yet read any data is assumed to be in a pending connection attempt state. + // @link https://docs.microsoft.com/de-de/windows/win32/api/winsock2/nf-winsock2-select $except = null; + if (\DIRECTORY_SEPARATOR === '\\') { + $except = []; + foreach ($write as $key => $socket) { + if (!isset($read[$key]) && @\ftell($socket) === 0) { + $except[$key] = $socket; + } + } + } + + /** @var ?callable $previous */ + $previous = \set_error_handler(function ($errno, $errstr) use (&$previous) { + // suppress warnings that occur when `stream_select()` is interrupted by a signal + // PHP defines `EINTR` through `ext-sockets` or `ext-pcntl`, otherwise use common default (Linux & Mac) + $eintr = \defined('SOCKET_EINTR') ? \SOCKET_EINTR : (\defined('PCNTL_EINTR') ? \PCNTL_EINTR : 4); + if ($errno === \E_WARNING && \strpos($errstr, '[' . $eintr .']: ') !== false) { + return; + } + + // forward any other error to registered error handler or print warning + return ($previous !== null) ? \call_user_func_array($previous, \func_get_args()) : false; + }); + + try { + $ret = \stream_select($read, $write, $except, $timeout === null ? null : 0, $timeout); + \restore_error_handler(); + } catch (\Throwable $e) { + \restore_error_handler(); + throw $e; + } - // suppress warnings that occur, when stream_select is interrupted by a signal - return @\stream_select($read, $write, $except, $timeout === null ? null : 0, $timeout); + if ($except) { + $write = \array_merge($write, $except); + } + return $ret; } - $timeout && \usleep($timeout); + if ($timeout > 0) { + \usleep($timeout); + } elseif ($timeout === null) { + // wait forever (we only reach this if we're only awaiting signals) + // this may be interrupted and return earlier when a signal is received + \sleep(PHP_INT_MAX); + } return 0; } diff --git a/src/Timer/Timers.php b/src/Timer/Timers.php index 1d4ac9b7..c9ae5ed8 100644 --- a/src/Timer/Timers.php +++ b/src/Timer/Timers.php @@ -15,13 +15,20 @@ final class Timers { private $time; - private $timers = array(); - private $schedule = array(); + private $timers = []; + private $schedule = []; private $sorted = true; + private $useHighResolution; + + public function __construct() + { + // prefer high-resolution timer, available as of PHP 7.3+ + $this->useHighResolution = \function_exists('hrtime'); + } public function updateTime() { - return $this->time = \microtime(true); + return $this->time = $this->useHighResolution ? \hrtime(true) * 1e-9 : \microtime(true); } public function getTime() @@ -31,20 +38,21 @@ public function getTime() public function add(TimerInterface $timer) { - $id = \spl_object_hash($timer); + $id = \PHP_VERSION_ID < 70200 ? \spl_object_hash($timer) : \spl_object_id($timer); $this->timers[$id] = $timer; - $this->schedule[$id] = $timer->getInterval() + \microtime(true); + $this->schedule[$id] = $timer->getInterval() + $this->updateTime(); $this->sorted = false; } public function contains(TimerInterface $timer) { - return isset($this->timers[\spl_object_hash($timer)]); + $id = \PHP_VERSION_ID < 70200 ? \spl_object_hash($timer) : \spl_object_id($timer); + return isset($this->timers[$id]); } public function cancel(TimerInterface $timer) { - $id = \spl_object_hash($timer); + $id = \PHP_VERSION_ID < 70200 ? \spl_object_hash($timer) : \spl_object_id($timer); unset($this->timers[$id], $this->schedule[$id]); } @@ -66,6 +74,11 @@ public function isEmpty() public function tick() { + // hot path: skip timers if nothing is scheduled + if (!$this->schedule) { + return; + } + // ensure timers are sorted so we can execute in order if (!$this->sorted) { $this->sorted = true; diff --git a/tests/AbstractLoopTest.php b/tests/AbstractLoopTest.php index 3d844382..e89a8371 100644 --- a/tests/AbstractLoopTest.php +++ b/tests/AbstractLoopTest.php @@ -2,6 +2,9 @@ namespace React\Tests\EventLoop; +use React\EventLoop\StreamSelectLoop; +use React\EventLoop\ExtUvLoop; + abstract class AbstractLoopTest extends TestCase { /** @@ -9,11 +12,18 @@ abstract class AbstractLoopTest extends TestCase */ protected $loop; + /** @var float */ private $tickTimeout; + /** @var ?string */ + private $received; + const PHP_DEFAULT_CHUNK_SIZE = 8192; - public function setUp() + /** + * @before + */ + public function setUpLoop() { // It's a timeout, don't set it too low. Travis and other CI systems are slow. $this->tickTimeout = 0.02; @@ -36,8 +46,107 @@ public function createSocketPair() return $sockets; } + public function testAddReadStreamTriggersWhenSocketReceivesData() + { + list ($input, $output) = $this->createSocketPair(); + + $timeout = $this->loop->addTimer(0.1, function () use ($input) { + $this->loop->removeReadStream($input); + }); + + $called = 0; + $this->loop->addReadStream($input, function () use (&$called, $input, $timeout) { + ++$called; + $this->loop->removeReadStream($input); + $this->loop->cancelTimer($timeout); + }); + + fwrite($output, "foo\n"); + + $this->loop->run(); + + $this->assertEquals(1, $called); + } + + public function testAddReadStreamTriggersWhenSocketCloses() + { + list ($input, $output) = $this->createSocketPair(); + + $timeout = $this->loop->addTimer(0.1, function () use ($input) { + $this->loop->removeReadStream($input); + }); + + $called = 0; + $this->loop->addReadStream($input, function () use (&$called, $input, $timeout) { + ++$called; + $this->loop->removeReadStream($input); + $this->loop->cancelTimer($timeout); + }); + + fclose($output); + + $this->loop->run(); + + $this->assertEquals(1, $called); + } + + public function testAddWriteStreamTriggersWhenSocketConnectionSucceeds() + { + $server = stream_socket_server('127.0.0.1:0'); + + $errno = $errstr = null; + $connecting = stream_socket_client(stream_socket_get_name($server, false), $errno, $errstr, 0, STREAM_CLIENT_CONNECT | STREAM_CLIENT_ASYNC_CONNECT); + + $timeout = $this->loop->addTimer(0.1, function () use ($connecting) { + $this->loop->removeWriteStream($connecting); + }); + + $called = 0; + $this->loop->addWriteStream($connecting, function () use (&$called, $connecting, $timeout) { + ++$called; + $this->loop->removeWriteStream($connecting); + $this->loop->cancelTimer($timeout); + }); + + $this->loop->run(); + + $this->assertEquals(1, $called); + } + + public function testAddWriteStreamTriggersWhenSocketConnectionRefused() + { + // first verify the operating system actually refuses the connection and no firewall is in place + // use higher timeout because Windows retires multiple times and has a noticeable delay + // @link https://stackoverflow.com/questions/19440364/why-do-failed-attempts-of-socket-connect-take-1-sec-on-windows + $errno = $errstr = null; + if (@stream_socket_client('127.0.0.1:1', $errno, $errstr, 10.0) !== false || (defined('SOCKET_ECONNREFUSED') && $errno !== SOCKET_ECONNREFUSED)) { + $this->markTestSkipped('Expected host to refuse connection, but got error ' . $errno . ': ' . $errstr); + } + + $connecting = stream_socket_client('127.0.0.1:1', $errno, $errstr, 0, STREAM_CLIENT_CONNECT | STREAM_CLIENT_ASYNC_CONNECT); + + $timeout = $this->loop->addTimer(10.0, function () use ($connecting) { + $this->loop->removeWriteStream($connecting); + }); + + $called = 0; + $this->loop->addWriteStream($connecting, function () use (&$called, $connecting, $timeout) { + ++$called; + $this->loop->removeWriteStream($connecting); + $this->loop->cancelTimer($timeout); + }); + + $this->loop->run(); + + $this->assertEquals(1, $called); + } + public function testAddReadStream() { + if ($this->loop instanceof ExtUvLoop && DIRECTORY_SEPARATOR === '\\') { + $this->markTestIncomplete('Ticking ExtUvLoop not supported on Windows'); + } + list ($input, $output) = $this->createSocketPair(); $this->loop->addReadStream($input, $this->expectCallableExactly(2)); @@ -51,6 +160,10 @@ public function testAddReadStream() public function testAddReadStreamIgnoresSecondCallable() { + if ($this->loop instanceof ExtUvLoop && DIRECTORY_SEPARATOR === '\\') { + $this->markTestIncomplete('Ticking ExtUvLoop not supported on Windows'); + } + list ($input, $output) = $this->createSocketPair(); $this->loop->addReadStream($input, $this->expectCallableExactly(2)); @@ -84,22 +197,24 @@ private function subAddReadStreamReceivesDataFromStreamReference() fwrite($input, 'hello'); fclose($input); - $loop = $this->loop; - $received =& $this->received; - $loop->addReadStream($output, function ($output) use ($loop, &$received) { + $this->loop->addReadStream($output, function ($output) { $chunk = fread($output, 1024); if ($chunk === '') { - $received .= 'X'; - $loop->removeReadStream($output); + $this->received .= 'X'; + $this->loop->removeReadStream($output); fclose($output); } else { - $received .= '[' . $chunk . ']'; + $this->received .= '[' . $chunk . ']'; } }); } public function testAddWriteStream() { + if ($this->loop instanceof ExtUvLoop && DIRECTORY_SEPARATOR === '\\') { + $this->markTestIncomplete('Ticking ExtUvLoop not supported on Windows'); + } + list ($input) = $this->createSocketPair(); $this->loop->addWriteStream($input, $this->expectCallableExactly(2)); @@ -109,6 +224,10 @@ public function testAddWriteStream() public function testAddWriteStreamIgnoresSecondCallable() { + if ($this->loop instanceof ExtUvLoop && DIRECTORY_SEPARATOR === '\\') { + $this->markTestIncomplete('Ticking ExtUvLoop not supported on Windows'); + } + list ($input) = $this->createSocketPair(); $this->loop->addWriteStream($input, $this->expectCallableExactly(2)); @@ -119,6 +238,10 @@ public function testAddWriteStreamIgnoresSecondCallable() public function testRemoveReadStreamInstantly() { + if ($this->loop instanceof ExtUvLoop && DIRECTORY_SEPARATOR === '\\') { + $this->markTestIncomplete('Ticking ExtUvLoop not supported on Windows'); + } + list ($input, $output) = $this->createSocketPair(); $this->loop->addReadStream($input, $this->expectCallableNever()); @@ -130,6 +253,10 @@ public function testRemoveReadStreamInstantly() public function testRemoveReadStreamAfterReading() { + if ($this->loop instanceof ExtUvLoop && DIRECTORY_SEPARATOR === '\\') { + $this->markTestIncomplete('Ticking ExtUvLoop not supported on Windows'); + } + list ($input, $output) = $this->createSocketPair(); $this->loop->addReadStream($input, $this->expectCallableOnce()); @@ -145,6 +272,10 @@ public function testRemoveReadStreamAfterReading() public function testRemoveWriteStreamInstantly() { + if ($this->loop instanceof ExtUvLoop && DIRECTORY_SEPARATOR === '\\') { + $this->markTestIncomplete('Ticking ExtUvLoop not supported on Windows'); + } + list ($input) = $this->createSocketPair(); $this->loop->addWriteStream($input, $this->expectCallableNever()); @@ -154,6 +285,10 @@ public function testRemoveWriteStreamInstantly() public function testRemoveWriteStreamAfterWriting() { + if ($this->loop instanceof ExtUvLoop && DIRECTORY_SEPARATOR === '\\') { + $this->markTestIncomplete('Ticking ExtUvLoop not supported on Windows'); + } + list ($input) = $this->createSocketPair(); $this->loop->addWriteStream($input, $this->expectCallableOnce()); @@ -165,6 +300,10 @@ public function testRemoveWriteStreamAfterWriting() public function testRemoveStreamForReadOnly() { + if ($this->loop instanceof ExtUvLoop && DIRECTORY_SEPARATOR === '\\') { + $this->markTestIncomplete('Ticking ExtUvLoop not supported on Windows'); + } + list ($input, $output) = $this->createSocketPair(); $this->loop->addReadStream($input, $this->expectCallableNever()); @@ -177,6 +316,10 @@ public function testRemoveStreamForReadOnly() public function testRemoveStreamForWriteOnly() { + if ($this->loop instanceof ExtUvLoop && DIRECTORY_SEPARATOR === '\\') { + $this->markTestIncomplete('Ticking ExtUvLoop not supported on Windows'); + } + list ($input, $output) = $this->createSocketPair(); fwrite($output, "foo\n"); @@ -198,10 +341,9 @@ public function testRemoveReadAndWriteStreamFromLoopOnceResourceClosesEndsLoop() $this->loop->addWriteStream($stream, function () { }); // remove stream when the stream is readable (closes) - $loop = $this->loop; - $loop->addReadStream($stream, function ($stream) use ($loop) { - $loop->removeReadStream($stream); - $loop->removeWriteStream($stream); + $this->loop->addReadStream($stream, function ($stream) { + $this->loop->removeReadStream($stream); + $this->loop->removeWriteStream($stream); fclose($stream); }); @@ -221,15 +363,14 @@ public function testRemoveReadAndWriteStreamFromLoopOnceResourceClosesOnEndOfFil $this->loop->addWriteStream($stream, function () { }); // remove stream when the stream is readable (closes) - $loop = $this->loop; - $loop->addReadStream($stream, function ($stream) use ($loop) { + $this->loop->addReadStream($stream, function ($stream) { $data = fread($stream, 1024); if ($data !== '') { return; } - $loop->removeReadStream($stream); - $loop->removeWriteStream($stream); + $this->loop->removeReadStream($stream); + $this->loop->removeWriteStream($stream); fclose($stream); }); @@ -252,10 +393,9 @@ public function testRemoveReadAndWriteStreamFromLoopWithClosingResourceEndsLoop( $this->loop->addWriteStream($stream, function () { }); // remove stream when the stream is readable (closes) - $loop = $this->loop; - $loop->addReadStream($stream, function ($stream) use ($loop) { - $loop->removeReadStream($stream); - $loop->removeWriteStream($stream); + $this->loop->addReadStream($stream, function ($stream) { + $this->loop->removeReadStream($stream); + $this->loop->removeWriteStream($stream); fclose($stream); }); @@ -284,9 +424,8 @@ public function runShouldReturnWhenNoMoreFds() { list ($input, $output) = $this->createSocketPair(); - $loop = $this->loop; - $this->loop->addReadStream($input, function ($stream) use ($loop) { - $loop->removeReadStream($stream); + $this->loop->addReadStream($input, function ($stream) { + $this->loop->removeReadStream($stream); }); fwrite($output, "foo\n"); @@ -299,9 +438,8 @@ public function stopShouldStopRunningLoop() { list ($input, $output) = $this->createSocketPair(); - $loop = $this->loop; - $this->loop->addReadStream($input, function ($stream) use ($loop) { - $loop->stop(); + $this->loop->addReadStream($input, function ($stream) { + $this->loop->stop(); }); fwrite($output, "foo\n"); @@ -311,18 +449,16 @@ public function stopShouldStopRunningLoop() public function testStopShouldPreventRunFromBlocking() { - $that = $this; $this->loop->addTimer( 1, - function () use ($that) { - $that->fail('Timer was executed.'); + function () { + $this->fail('Timer was executed.'); } ); - $loop = $this->loop; $this->loop->futureTick( - function () use ($loop) { - $loop->stop(); + function () { + $this->loop->stop(); } ); @@ -336,38 +472,34 @@ public function testIgnoreRemovedCallback() list ($input2, $output2) = $this->createSocketPair(); $called = false; - - $loop = $this->loop; - $loop->addReadStream($input1, function ($stream) use (& $called, $loop, $input2) { + $this->loop->addReadStream($input1, function ($stream) use (&$called, $input2) { // stream1 is readable, remove stream2 as well => this will invalidate its callback - $loop->removeReadStream($stream); - $loop->removeReadStream($input2); + $this->loop->removeReadStream($stream); + $this->loop->removeReadStream($input2); $called = true; }); // this callback would have to be called as well, but the first stream already removed us - $that = $this; - $loop->addReadStream($input2, function () use (& $called, $that) { + $this->loop->addReadStream($input2, function () use (&$called) { if ($called) { - $that->fail('Callback 2 must not be called after callback 1 was called'); + $this->fail('Callback 2 must not be called after callback 1 was called'); } }); fwrite($output1, "foo\n"); fwrite($output2, "foo\n"); - $loop->run(); + $this->loop->run(); $this->assertTrue($called); } public function testFutureTickEventGeneratedByFutureTick() { - $loop = $this->loop; $this->loop->futureTick( - function () use ($loop) { - $loop->futureTick( + function () { + $this->loop->futureTick( function () { echo 'future-tick' . PHP_EOL; } @@ -399,6 +531,10 @@ public function testFutureTick() public function testFutureTickFiresBeforeIO() { + if ($this->loop instanceof ExtUvLoop && DIRECTORY_SEPARATOR === '\\') { + $this->markTestIncomplete('Ticking ExtUvLoop not supported on Windows'); + } + list ($stream) = $this->createSocketPair(); $this->loop->addWriteStream( @@ -419,23 +555,25 @@ function () { $this->tickLoop($this->loop); } + /** + * @depends testFutureTickFiresBeforeIO + */ public function testRecursiveFutureTick() { list ($stream) = $this->createSocketPair(); - $loop = $this->loop; $this->loop->addWriteStream( $stream, - function () use ($stream, $loop) { + function () use ($stream) { echo 'stream' . PHP_EOL; - $loop->removeWriteStream($stream); + $this->loop->removeWriteStream($stream); } ); $this->loop->futureTick( - function () use ($loop) { + function () { echo 'future-tick-1' . PHP_EOL; - $loop->futureTick( + $this->loop->futureTick( function () { echo 'future-tick-2' . PHP_EOL; } @@ -452,12 +590,11 @@ public function testRunWaitsForFutureTickEvents() { list ($stream) = $this->createSocketPair(); - $loop = $this->loop; $this->loop->addWriteStream( $stream, - function () use ($stream, $loop) { - $loop->removeWriteStream($stream); - $loop->futureTick( + function () use ($stream) { + $this->loop->removeWriteStream($stream); + $this->loop->futureTick( function () { echo 'future-tick' . PHP_EOL; } @@ -472,11 +609,10 @@ function () { public function testFutureTickEventGeneratedByTimer() { - $loop = $this->loop; $this->loop->addTimer( 0.001, - function () use ($loop) { - $loop->futureTick( + function () { + $this->loop->futureTick( function () { echo 'future-tick' . PHP_EOL; } @@ -491,14 +627,19 @@ function () { public function testRemoveSignalNotRegisteredIsNoOp() { - $this->loop->removeSignal(SIGINT, function () { }); + $this->loop->removeSignal(2, function () { }); $this->assertTrue(true); } + /** + * @requires extension pcntl + * @requires function posix_kill() + * @requires function posix_getpid() + */ public function testSignal() { - if (!function_exists('posix_kill') || !function_exists('posix_getpid')) { - $this->markTestSkipped('Signal test skipped because functions "posix_kill" and "posix_getpid" are missing.'); + if ($this->loop instanceof StreamSelectLoop && !(\function_exists('pcntl_signal') && \function_exists('pcntl_signal_dispatch'))) { + $this->markTestSkipped('Signal handling with StreamSelectLoop requires pcntl_signal() and pcntl_signal_dispatch(), see also disable_functions'); } $called = false; @@ -510,12 +651,11 @@ public function testSignal() $calledShouldNot = false; }); - $loop = $this->loop; - $this->loop->addSignal(SIGUSR1, $func1 = function () use (&$func1, &$func2, &$called, $timer, $loop) { + $this->loop->addSignal(SIGUSR1, $func1 = function () use (&$func1, &$func2, &$called, $timer) { $called = true; - $loop->removeSignal(SIGUSR1, $func1); - $loop->removeSignal(SIGUSR2, $func2); - $loop->cancelTimer($timer); + $this->loop->removeSignal(SIGUSR1, $func1); + $this->loop->removeSignal(SIGUSR2, $func2); + $this->loop->cancelTimer($timer); }); $this->loop->futureTick(function () { @@ -528,8 +668,15 @@ public function testSignal() $this->assertTrue($calledShouldNot); } + /** + * @requires extension pcntl + */ public function testSignalMultipleUsagesForTheSameListener() { + if ($this->loop instanceof StreamSelectLoop && !(\function_exists('pcntl_signal') && \function_exists('pcntl_signal_dispatch'))) { + $this->markTestSkipped('Signal handling with StreamSelectLoop requires pcntl_signal() and pcntl_signal_dispatch(), see also disable_functions'); + } + $funcCallCount = 0; $func = function () use (&$funcCallCount) { $funcCallCount++; @@ -542,9 +689,8 @@ public function testSignalMultipleUsagesForTheSameListener() $this->loop->addTimer(0.4, function () { posix_kill(posix_getpid(), SIGUSR1); }); - $loop = $this->loop; - $this->loop->addTimer(0.9, function () use (&$func, $loop) { - $loop->removeSignal(SIGUSR1, $func); + $this->loop->addTimer(0.9, function () use (&$func) { + $this->loop->removeSignal(SIGUSR1, $func); }); $this->loop->run(); @@ -552,26 +698,38 @@ public function testSignalMultipleUsagesForTheSameListener() $this->assertSame(1, $funcCallCount); } + /** + * @requires extension pcntl + */ public function testSignalsKeepTheLoopRunning() { - $loop = $this->loop; + if ($this->loop instanceof StreamSelectLoop && !(\function_exists('pcntl_signal') && \function_exists('pcntl_signal_dispatch'))) { + $this->markTestSkipped('Signal handling with StreamSelectLoop requires pcntl_signal() and pcntl_signal_dispatch(), see also disable_functions'); + } + $function = function () {}; $this->loop->addSignal(SIGUSR1, $function); - $this->loop->addTimer(1.5, function () use ($function, $loop) { - $loop->removeSignal(SIGUSR1, $function); - $loop->stop(); + $this->loop->addTimer(1.5, function () use ($function) { + $this->loop->removeSignal(SIGUSR1, $function); + $this->loop->stop(); }); - $this->assertRunSlowerThan(1.5); + $this->assertRunSlowerThan(1.4); } + /** + * @requires extension pcntl + */ public function testSignalsKeepTheLoopRunningAndRemovingItStopsTheLoop() { - $loop = $this->loop; + if ($this->loop instanceof StreamSelectLoop && !(\function_exists('pcntl_signal') && \function_exists('pcntl_signal_dispatch'))) { + $this->markTestSkipped('Signal handling with StreamSelectLoop requires pcntl_signal() and pcntl_signal_dispatch(), see also disable_functions'); + } + $function = function () {}; $this->loop->addSignal(SIGUSR1, $function); - $this->loop->addTimer(1.5, function () use ($function, $loop) { - $loop->removeSignal(SIGUSR1, $function); + $this->loop->addTimer(1.5, function () use ($function) { + $this->loop->removeSignal(SIGUSR1, $function); }); $this->assertRunFasterThan(1.6); @@ -579,12 +737,13 @@ public function testSignalsKeepTheLoopRunningAndRemovingItStopsTheLoop() public function testTimerIntervalCanBeFarInFuture() { - $loop = $this->loop; + // Maximum interval for ExtUvLoop implementation + $interval = ((int) (PHP_INT_MAX / 1000)) - 1; // start a timer very far in the future - $timer = $this->loop->addTimer(PHP_INT_MAX, function () { }); + $timer = $this->loop->addTimer($interval, function () { }); - $this->loop->futureTick(function () use ($timer, $loop) { - $loop->cancelTimer($timer); + $this->loop->futureTick(function () use ($timer) { + $this->loop->cancelTimer($timer); }); $this->assertRunFasterThan($this->tickTimeout); diff --git a/tests/BinTest.php b/tests/BinTest.php new file mode 100644 index 00000000..ebbad5e5 --- /dev/null +++ b/tests/BinTest.php @@ -0,0 +1,71 @@ +assertEquals('abc', $output); + } + + public function testExecuteExampleWithExplicitLoopRunRunsLoopAndExecutesTicks() + { + $output = exec(escapeshellarg(PHP_BINARY) . ' 02-ticks-loop-instance.php'); + + $this->assertEquals('abc', $output); + } + + public function testExecuteExampleWithExplicitLoopRunAndStopRunsLoopAndExecutesTicksUntilStopped() + { + $output = exec(escapeshellarg(PHP_BINARY) . ' 03-ticks-loop-stop.php'); + + $this->assertEquals('abc', $output); + } + + public function testExecuteExampleWithUncaughtExceptionShouldNotRunLoop() + { + $time = microtime(true); + exec(escapeshellarg(PHP_BINARY) . ' 11-uncaught.php 2>/dev/null'); + $time = microtime(true) - $time; + + $this->assertLessThan(1.0, $time); + } + + public function testExecuteExampleWithUndefinedVariableShouldNotRunLoop() + { + $time = microtime(true); + exec(escapeshellarg(PHP_BINARY) . ' 12-undefined.php 2>/dev/null'); + $time = microtime(true) - $time; + + $this->assertLessThan(1.0, $time); + } + + public function testExecuteExampleWithExplicitStopShouldNotRunLoop() + { + $time = microtime(true); + exec(escapeshellarg(PHP_BINARY) . ' 21-stop.php 2>/dev/null'); + $time = microtime(true) - $time; + + $this->assertLessThan(1.0, $time); + } + + public function testExecuteExampleWithExplicitStopInExceptionHandlerShouldNotRunLoop() + { + $time = microtime(true); + exec(escapeshellarg(PHP_BINARY) . ' 22-uncaught-stop.php 2>/dev/null'); + $time = microtime(true) - $time; + + $this->assertLessThan(1.0, $time); + } +} diff --git a/tests/CallableStub.php b/tests/CallableStub.php deleted file mode 100644 index 913d403a..00000000 --- a/tests/CallableStub.php +++ /dev/null @@ -1,10 +0,0 @@ -fifoPath !== null && file_exists($this->fifoPath)) { + unlink($this->fifoPath); + } + } + public function createStream() { // Use a FIFO on linux to get around lack of support for disk-based file // descriptors when using the EPOLL back-end. if ('Linux' === PHP_OS) { $this->fifoPath = tempnam(sys_get_temp_dir(), 'react-'); + assert(is_string($this->fifoPath)); unlink($this->fifoPath); @@ -56,29 +70,4 @@ public function writeToStream($stream, $content) fwrite($stream, $content); } - - /** - * @group epoll-readable-error - */ - public function testCanUseReadableStreamWithFeatureFds() - { - if (PHP_VERSION_ID > 70000) { - $this->markTestSkipped('Memory stream not supported'); - } - - $this->loop = $this->createLoop(true); - - $input = fopen('php://temp/maxmemory:0', 'r+'); - - fwrite($input, 'x'); - ftruncate($input, 0); - - $this->loop->addReadStream($input, $this->expectCallableExactly(2)); - - fwrite($input, "foo\n"); - $this->tickLoop($this->loop); - - fwrite($input, "bar\n"); - $this->tickLoop($this->loop); - } } diff --git a/tests/ExtLibevLoopTest.php b/tests/ExtLibevLoopTest.php deleted file mode 100644 index 19a5e876..00000000 --- a/tests/ExtLibevLoopTest.php +++ /dev/null @@ -1,22 +0,0 @@ -markTestSkipped('libev tests skipped because ext-libev is not installed.'); - } - - return new ExtLibevLoop(); - } - - public function testLibEvConstructor() - { - $loop = new ExtLibevLoop(); - } -} diff --git a/tests/ExtLibeventLoopTest.php b/tests/ExtLibeventLoopTest.php deleted file mode 100644 index 84970658..00000000 --- a/tests/ExtLibeventLoopTest.php +++ /dev/null @@ -1,58 +0,0 @@ -markTestSkipped('libevent tests skipped on linux due to linux epoll issues.'); - } - - if (!function_exists('event_base_new')) { - $this->markTestSkipped('libevent tests skipped because ext-libevent is not installed.'); - } - - return new ExtLibeventLoop(); - } - - public function tearDown() - { - if (file_exists($this->fifoPath)) { - unlink($this->fifoPath); - } - } - - public function createStream() - { - if ('Linux' !== PHP_OS) { - return parent::createStream(); - } - - $this->fifoPath = tempnam(sys_get_temp_dir(), 'react-'); - - unlink($this->fifoPath); - - // Use a FIFO on linux to get around lack of support for disk-based file - // descriptors when using the EPOLL back-end. - posix_mkfifo($this->fifoPath, 0600); - - $stream = fopen($this->fifoPath, 'r+'); - - return $stream; - } - - public function writeToStream($stream, $content) - { - if ('Linux' !== PHP_OS) { - return parent::writeToStream($stream, $content); - } - - fwrite($stream, $content); - } -} diff --git a/tests/ExtUvLoopTest.php b/tests/ExtUvLoopTest.php new file mode 100644 index 00000000..45b251ef --- /dev/null +++ b/tests/ExtUvLoopTest.php @@ -0,0 +1,93 @@ +markTestSkipped('uv tests skipped because ext-uv is not installed.'); + } + + return new ExtUvLoop(); + } + + /** @dataProvider intervalProvider */ + public function testTimerInterval($interval, $expectedExceptionMessage) + { + $this->expectException('InvalidArgumentException'); + $this->expectExceptionMessage($expectedExceptionMessage); + + $this->loop + ->addTimer( + $interval, + function () { + return 0; + } + ); + } + + public function intervalProvider() + { + $oversizeInterval = PHP_INT_MAX / 1000; + $maxValue = (int) (PHP_INT_MAX / 1000); + $oneMaxValue = $maxValue + 1; + $tenMaxValue = $maxValue + 10; + $tenMillionsMaxValue = $maxValue + 10000000; + $intMax = PHP_INT_MAX; + $oneIntMax = PHP_INT_MAX + 1; + $tenIntMax = PHP_INT_MAX + 10; + $oneHundredIntMax = PHP_INT_MAX + 100; + $oneThousandIntMax = PHP_INT_MAX + 1000; + $tenMillionsIntMax = PHP_INT_MAX + 10000000; + $tenThousandsTimesIntMax = PHP_INT_MAX * 1000; + + yield [ + $oversizeInterval, + "Interval overflow, value must be lower than '{$maxValue}', but '{$oversizeInterval}' passed." + ]; + yield [ + $oneMaxValue, + "Interval overflow, value must be lower than '{$maxValue}', but '{$oneMaxValue}' passed.", + ]; + yield [ + $tenMaxValue, + "Interval overflow, value must be lower than '{$maxValue}', but '{$tenMaxValue}' passed.", + ]; + yield [ + $tenMillionsMaxValue, + "Interval overflow, value must be lower than '{$maxValue}', but '{$tenMillionsMaxValue}' passed.", + ]; + yield [ + $intMax, + "Interval overflow, value must be lower than '{$maxValue}', but '{$intMax}' passed.", + ]; + yield [ + $oneIntMax, + "Interval overflow, value must be lower than '{$maxValue}', but '{$oneIntMax}' passed.", + ]; + yield [ + $tenIntMax, + "Interval overflow, value must be lower than '{$maxValue}', but '{$tenIntMax}' passed.", + ]; + yield [ + $oneHundredIntMax, + "Interval overflow, value must be lower than '{$maxValue}', but '{$oneHundredIntMax}' passed.", + ]; + yield [ + $oneThousandIntMax, + "Interval overflow, value must be lower than '{$maxValue}', but '{$oneThousandIntMax}' passed.", + ]; + yield [ + $tenMillionsIntMax, + "Interval overflow, value must be lower than '{$maxValue}', but '{$tenMillionsIntMax}' passed.", + ]; + yield [ + $tenThousandsTimesIntMax, + "Interval overflow, value must be lower than '{$maxValue}', but '{$tenThousandsTimesIntMax}' passed.", + ]; + } +} diff --git a/tests/LoopTest.php b/tests/LoopTest.php new file mode 100644 index 00000000..08e2107e --- /dev/null +++ b/tests/LoopTest.php @@ -0,0 +1,350 @@ + + */ + public function numberOfTests() + { + return [[], [], []]; + } + + public function testStaticAddReadStreamCallsAddReadStreamOnLoopInstance() + { + $stream = tmpfile(); + $listener = function () { }; + + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->once())->method('addReadStream')->with($stream, $listener); + + Loop::set($loop); + + Loop::addReadStream($stream, $listener); + } + + public function testStaticAddReadStreamWithNoDefaultLoopCallsAddReadStreamOnNewLoopInstance() + { + $ref = new \ReflectionProperty(Loop::class, 'instance'); + $ref->setAccessible(true); + $ref->setValue(null, null); + + $stream = stream_socket_server('127.0.0.1:0'); + $listener = function () { }; + Loop::addReadStream($stream, $listener); + + $this->assertInstanceOf(LoopInterface::class, $ref->getValue()); + } + + public function testStaticAddWriteStreamCallsAddWriteStreamOnLoopInstance() + { + $stream = tmpfile(); + $listener = function () { }; + + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->once())->method('addWriteStream')->with($stream, $listener); + + Loop::set($loop); + + Loop::addWriteStream($stream, $listener); + } + + public function testStaticAddWriteStreamWithNoDefaultLoopCallsAddWriteStreamOnNewLoopInstance() + { + $ref = new \ReflectionProperty(Loop::class, 'instance'); + $ref->setAccessible(true); + $ref->setValue(null, null); + + $stream = stream_socket_server('127.0.0.1:0'); + $listener = function () { }; + Loop::addWriteStream($stream, $listener); + + $this->assertInstanceOf(LoopInterface::class, $ref->getValue()); + } + + public function testStaticRemoveReadStreamCallsRemoveReadStreamOnLoopInstance() + { + $stream = tmpfile(); + + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->once())->method('removeReadStream')->with($stream); + + Loop::set($loop); + + Loop::removeReadStream($stream); + } + + public function testStaticRemoveReadStreamWithNoDefaultLoopIsNoOp() + { + $ref = new \ReflectionProperty(Loop::class, 'instance'); + $ref->setAccessible(true); + $ref->setValue(null, null); + + $stream = tmpfile(); + Loop::removeReadStream($stream); + + $this->assertNull($ref->getValue()); + } + + public function testStaticRemoveWriteStreamCallsRemoveWriteStreamOnLoopInstance() + { + $stream = tmpfile(); + + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->once())->method('removeWriteStream')->with($stream); + + Loop::set($loop); + + Loop::removeWriteStream($stream); + } + + public function testStaticRemoveWriteStreamWithNoDefaultLoopIsNoOp() + { + $ref = new \ReflectionProperty(Loop::class, 'instance'); + $ref->setAccessible(true); + $ref->setValue(null, null); + + $stream = tmpfile(); + Loop::removeWriteStream($stream); + + $this->assertNull($ref->getValue()); + } + + public function testStaticAddTimerCallsAddTimerOnLoopInstanceAndReturnsTimerInstance() + { + $interval = 1.0; + $callback = function () { }; + $timer = $this->createMock(TimerInterface::class); + + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->once())->method('addTimer')->with($interval, $callback)->willReturn($timer); + + Loop::set($loop); + + $ret = Loop::addTimer($interval, $callback); + + $this->assertSame($timer, $ret); + } + + public function testStaticAddTimerWithNoDefaultLoopCallsAddTimerOnNewLoopInstance() + { + $ref = new \ReflectionProperty(Loop::class, 'instance'); + $ref->setAccessible(true); + $ref->setValue(null, null); + + $interval = 1.0; + $callback = function () { }; + $ret = Loop::addTimer($interval, $callback); + + $this->assertInstanceOf(TimerInterface::class, $ret); + $this->assertInstanceOf(LoopInterface::class, $ref->getValue()); + } + + public function testStaticAddPeriodicTimerCallsAddPeriodicTimerOnLoopInstanceAndReturnsTimerInstance() + { + $interval = 1.0; + $callback = function () { }; + $timer = $this->createMock(TimerInterface::class); + + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->once())->method('addPeriodicTimer')->with($interval, $callback)->willReturn($timer); + + Loop::set($loop); + + $ret = Loop::addPeriodicTimer($interval, $callback); + + $this->assertSame($timer, $ret); + } + + public function testStaticAddPeriodicTimerWithNoDefaultLoopCallsAddPeriodicTimerOnNewLoopInstance() + { + $ref = new \ReflectionProperty(Loop::class, 'instance'); + $ref->setAccessible(true); + $ref->setValue(null, null); + + $interval = 1.0; + $callback = function () { }; + $ret = Loop::addPeriodicTimer($interval, $callback); + + $this->assertInstanceOf(TimerInterface::class, $ret); + $this->assertInstanceOf(LoopInterface::class, $ref->getValue()); + } + + + public function testStaticCancelTimerCallsCancelTimerOnLoopInstance() + { + $timer = $this->createMock(TimerInterface::class); + + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->once())->method('cancelTimer')->with($timer); + + Loop::set($loop); + + Loop::cancelTimer($timer); + } + + public function testStaticCancelTimerWithNoDefaultLoopIsNoOp() + { + $ref = new \ReflectionProperty(Loop::class, 'instance'); + $ref->setAccessible(true); + $ref->setValue(null, null); + + $timer = $this->createMock(TimerInterface::class); + Loop::cancelTimer($timer); + + $this->assertNull($ref->getValue()); + } + + public function testStaticFutureTickCallsFutureTickOnLoopInstance() + { + $listener = function () { }; + + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->once())->method('futureTick')->with($listener); + + Loop::set($loop); + + Loop::futureTick($listener); + } + + public function testStaticFutureTickWithNoDefaultLoopCallsFutureTickOnNewLoopInstance() + { + $ref = new \ReflectionProperty(Loop::class, 'instance'); + $ref->setAccessible(true); + $ref->setValue(null, null); + + $listener = function () { }; + Loop::futureTick($listener); + + $this->assertInstanceOf(LoopInterface::class, $ref->getValue()); + } + + public function testStaticAddSignalCallsAddSignalOnLoopInstance() + { + $signal = 1; + $listener = function () { }; + + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->once())->method('addSignal')->with($signal, $listener); + + Loop::set($loop); + + Loop::addSignal($signal, $listener); + } + + public function testStaticAddSignalWithNoDefaultLoopCallsAddSignalOnNewLoopInstance() + { + if (DIRECTORY_SEPARATOR === '\\') { + $this->markTestSkipped('Not supported on Windows'); + } + + $ref = new \ReflectionProperty(Loop::class, 'instance'); + $ref->setAccessible(true); + $ref->setValue(null, null); + + $signal = 1; + $listener = function () { }; + try { + Loop::addSignal($signal, $listener); + } catch (\BadMethodCallException $e) { + $this->markTestSkipped('Skipped: ' . $e->getMessage()); + } + + $this->assertInstanceOf(LoopInterface::class, $ref->getValue()); + } + + public function testStaticRemoveSignalCallsRemoveSignalOnLoopInstance() + { + $signal = 1; + $listener = function () { }; + + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->once())->method('removeSignal')->with($signal, $listener); + + Loop::set($loop); + + Loop::removeSignal($signal, $listener); + } + + public function testStaticRemoveSignalWithNoDefaultLoopIsNoOp() + { + $ref = new \ReflectionProperty(Loop::class, 'instance'); + $ref->setAccessible(true); + $ref->setValue(null, null); + + $signal = 1; + $listener = function () { }; + Loop::removeSignal($signal, $listener); + + $this->assertNull($ref->getValue()); + } + + public function testStaticRunCallsRunOnLoopInstance() + { + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->once())->method('run')->with(); + + Loop::set($loop); + + Loop::run(); + } + + public function testStaticRunWithNoDefaultLoopCallsRunsOnNewLoopInstance() + { + $ref = new \ReflectionProperty(Loop::class, 'instance'); + $ref->setAccessible(true); + $ref->setValue(null, null); + + Loop::run(); + + $this->assertInstanceOf(LoopInterface::class, $ref->getValue()); + } + + public function testStaticStopCallsStopOnLoopInstance() + { + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->once())->method('stop')->with(); + + Loop::set($loop); + + Loop::stop(); + } + + public function testStaticStopCallWithNoDefaultLoopIsNoOp() + { + $ref = new \ReflectionProperty(Loop::class, 'instance'); + $ref->setAccessible(true); + $ref->setValue(null, null); + + Loop::stop(); + + $this->assertNull($ref->getValue()); + } + + /** + * @after + * @before + */ + public function unsetLoopFromLoopAccessor() + { + $ref = new \ReflectionProperty(Loop::class, 'instance'); + $ref->setAccessible(true); + $ref->setValue(null, null); + } +} diff --git a/tests/SignalsHandlerTest.php b/tests/SignalsHandlerTest.php index f8b7df3d..a8cc4221 100644 --- a/tests/SignalsHandlerTest.php +++ b/tests/SignalsHandlerTest.php @@ -6,6 +6,9 @@ final class SignalsHandlerTest extends TestCase { + /** + * @requires extension pcntl + */ public function testEmittedEventsAndCallHandling() { $callCount = 0; diff --git a/tests/StreamSelectLoopTest.php b/tests/StreamSelectLoopTest.php index bd19e1cd..b2672d4e 100644 --- a/tests/StreamSelectLoopTest.php +++ b/tests/StreamSelectLoopTest.php @@ -7,7 +7,10 @@ class StreamSelectLoopTest extends AbstractLoopTest { - protected function tearDown() + /** + * @after + */ + protected function tearDownSignalHandlers() { parent::tearDown(); if (strncmp($this->getName(false), 'testSignal', 10) === 0 && extension_loaded('pcntl')) { @@ -37,32 +40,97 @@ public function testStreamSelectTimeoutEmulation() $this->assertGreaterThan(0.04, $interval); } + public function testStreamSelectReportsWarningForStreamWithFilter() + { + $stream = tmpfile(); + stream_filter_append($stream, 'string.rot13'); + + $this->loop->addReadStream($stream, $this->expectCallableNever()); + + $this->loop->futureTick(function () use ($stream) { + $this->loop->futureTick(function () use ($stream) { + $this->loop->removeReadStream($stream); + }); + }); + + $error = null; + $previous = set_error_handler(function ($_, $errstr) use (&$error) { + $error = $errstr; + }); + + try { + $this->loop->run(); + } catch (\ValueError $e) { + // ignore ValueError for PHP 8+ due to empty stream array + } + + restore_error_handler(); + + $this->assertNotNull($error); + + $now = set_error_handler(function () { }); + restore_error_handler(); + $this->assertEquals($previous, $now); + } + + public function testStreamSelectThrowsWhenCustomErrorHandlerThrowsForStreamWithFilter() + { + $stream = tmpfile(); + stream_filter_append($stream, 'string.rot13'); + + $this->loop->addReadStream($stream, $this->expectCallableNever()); + + $this->loop->futureTick(function () use ($stream) { + $this->loop->futureTick(function () use ($stream) { + $this->loop->removeReadStream($stream); + }); + }); + + $previous = set_error_handler(function ($_, $errstr) { + throw new \RuntimeException($errstr); + }); + + $e = null; + try { + $this->loop->run(); + restore_error_handler(); + $this->fail(); + } catch (\RuntimeException $e) { + restore_error_handler(); + } catch (\ValueError $e) { + restore_error_handler(); // PHP 8+ + $e = $e->getPrevious(); + } + + $this->assertInstanceOf(\RuntimeException::class, $e); + + $now = set_error_handler(function () { }); + restore_error_handler(); + $this->assertEquals($previous, $now); + } + public function signalProvider() { - return array( - array('SIGUSR1'), - array('SIGHUP'), - array('SIGTERM'), - ); + yield ['SIGUSR1']; + yield ['SIGHUP']; + yield ['SIGTERM']; } /** * Test signal interrupt when no stream is attached to the loop * @dataProvider signalProvider + * @requires extension pcntl + * @requires function pcntl_signal() + * @requires function pcntl_signal_dispatch() */ public function testSignalInterruptNoStream($signal) { - if (!extension_loaded('pcntl')) { - $this->markTestSkipped('"pcntl" extension is required to run this test.'); - } - // dispatch signal handler every 10ms for 0.1s $check = $this->loop->addPeriodicTimer(0.01, function() { pcntl_signal_dispatch(); }); - $loop = $this->loop; - $loop->addTimer(0.1, function () use ($check, $loop) { - $loop->cancelTimer($check); + $this->loop->addTimer(0.1, function () use ($check) { + $this->loop->cancelTimer($check); }); $handled = false; @@ -80,26 +148,24 @@ public function testSignalInterruptNoStream($signal) /** * Test signal interrupt when a stream is attached to the loop * @dataProvider signalProvider + * @requires extension pcntl + * @requires function pcntl_signal() + * @requires function pcntl_signal_dispatch() */ public function testSignalInterruptWithStream($signal) { - if (!extension_loaded('pcntl')) { - $this->markTestSkipped('"pcntl" extension is required to run this test.'); - } - // dispatch signal handler every 10ms $this->loop->addPeriodicTimer(0.01, function() { pcntl_signal_dispatch(); }); // add stream to the loop - $loop = $this->loop; list($writeStream, $readStream) = $this->createSocketPair(); - $loop->addReadStream($readStream, function ($stream) use ($loop) { + $this->loop->addReadStream($readStream, function ($stream) { /** @var $loop LoopInterface */ $read = fgets($stream); if ($read === "end loop\n") { - $loop->stop(); + $this->loop->stop(); } }); $this->loop->addTimer(0.1, function() use ($writeStream) { diff --git a/tests/TestCase.php b/tests/TestCase.php index dbdd54ce..55f04cf3 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -39,7 +39,14 @@ protected function expectCallableNever() protected function createCallableMock() { - return $this->getMockBuilder('React\Tests\EventLoop\CallableStub')->getMock(); + $builder = $this->getMockBuilder(\stdClass::class); + if (method_exists($builder, 'addMethods')) { + // PHPUnit 9+ + return $builder->addMethods(['__invoke'])->getMock(); + } else { + // legacy PHPUnit + return $builder->setMethods(['__invoke'])->getMock(); + } } protected function tickLoop(LoopInterface $loop) diff --git a/tests/Timer/AbstractTimerTest.php b/tests/Timer/AbstractTimerTest.php index 294e683f..81099ff9 100644 --- a/tests/Timer/AbstractTimerTest.php +++ b/tests/Timer/AbstractTimerTest.php @@ -3,6 +3,7 @@ namespace React\Tests\EventLoop\Timer; use React\EventLoop\LoopInterface; +use React\EventLoop\TimerInterface; use React\Tests\EventLoop\TestCase; abstract class AbstractTimerTest extends TestCase @@ -18,7 +19,7 @@ public function testAddTimerReturnsNonPeriodicTimerInstance() $timer = $loop->addTimer(0.001, $this->expectCallableNever()); - $this->assertInstanceOf('React\EventLoop\TimerInterface', $timer); + $this->assertInstanceOf(TimerInterface::class, $timer); $this->assertFalse($timer->isPeriodic()); } @@ -26,14 +27,15 @@ public function testAddTimerWillBeInvokedOnceAndBlocksLoopWhenRunning() { $loop = $this->createLoop(); - $loop->addTimer(0.001, $this->expectCallableOnce()); + $loop->addTimer(0.005, $this->expectCallableOnce()); $start = microtime(true); $loop->run(); $end = microtime(true); - // make no strict assumptions about actual time interval. - // must be at least 0.001s (1ms) and should not take longer than 0.1s + // 1 invocation should take 5ms (± a few milliseconds due to timer inaccuracies) + // make no strict assumptions about time interval, must at least take 1ms + // and should not take longer than 0.1s for slower loops. $this->assertGreaterThanOrEqual(0.001, $end - $start); $this->assertLessThan(0.1, $end - $start); } @@ -44,7 +46,7 @@ public function testAddPeriodicTimerReturnsPeriodicTimerInstance() $periodic = $loop->addPeriodicTimer(0.1, $this->expectCallableNever()); - $this->assertInstanceOf('React\EventLoop\TimerInterface', $periodic); + $this->assertInstanceOf(TimerInterface::class, $periodic); $this->assertTrue($periodic->isPeriodic()); } @@ -56,7 +58,7 @@ public function testAddPeriodicTimerWillBeInvokedUntilItIsCancelled() // make no strict assumptions about actual time interval. // leave some room to ensure this ticks exactly 3 times. - $loop->addTimer(0.399, function () use ($loop, $periodic) { + $loop->addTimer(0.350, function () use ($loop, $periodic) { $loop->cancelTimer($periodic); }); @@ -72,17 +74,17 @@ public function testAddPeriodicTimerWillBeInvokedWithMaximumAccuracyUntilItIsCan ++$i; }); - $loop->addTimer(0.02, function () use ($loop, $periodic) { + $loop->addTimer(0.1, function () use ($loop, $periodic) { $loop->cancelTimer($periodic); }); $loop->run(); // make no strict assumptions about number of invocations. - // we know it must be no more than 20 times and should at least be - // invoked twice for really slow loops - $this->assertLessThanOrEqual(20, $i); - $this->assertGreaterThan(2, $i); + // we know it must be no more than 100 times and should at least be + // invoked 4 times for really slow loops + $this->assertLessThanOrEqual(100, $i); + $this->assertGreaterThanOrEqual(4, $i); } public function testAddPeriodicTimerCancelsItself() @@ -104,11 +106,11 @@ public function testAddPeriodicTimerCancelsItself() $this->assertEquals(5, $i); - // make no strict assumptions about time interval. - // 5 invocations must take at least 0.005s (5ms) and should not take - // longer than 0.1s for slower loops. - $this->assertGreaterThanOrEqual(0.005, $end - $start); - $this->assertLessThan(0.1, $end - $start); + // 5 invocations should take 5ms (± 1ms due to timer inaccuracies) + // make no strict assumptions about time interval, must at least take 4ms + // and should not take longer than 0.2s for slower loops. + $this->assertGreaterThanOrEqual(0.004, $end - $start); + $this->assertLessThan(0.2, $end - $start); } public function testMinimumIntervalOneMicrosecond() @@ -119,4 +121,42 @@ public function testMinimumIntervalOneMicrosecond() $this->assertEquals(0.000001, $timer->getInterval()); } + + public function testAddPeriodicTimerWithZeroIntervalWillExecuteCallbackFunctionAtLeastTwice() + { + $loop = $this->createLoop(); + + $timeout = $loop->addTimer(2, $this->expectCallableNever()); //Timeout the test after two seconds if the periodic timer hasn't fired twice + + $i = 0; + $loop->addPeriodicTimer(0, function ($timer) use (&$i, $loop, $timeout) { + ++$i; + if ($i === 2) { + $loop->cancelTimer($timer); + $loop->cancelTimer($timeout); + } + }); + + $loop->run(); + + $this->assertEquals(2, $i); + } + + public function testTimerIntervalBelowZeroRunsImmediately() + { + $loop = $this->createLoop(); + $start = 0; + $loop->addTimer( + -1, + function () use (&$start) { + $start = \microtime(true); + } + ); + + $loop->run(); + $end = \microtime(true); + + // 1ms should be enough even on slow machines (± 1ms due to timer inaccuracies) + $this->assertLessThan(0.002, $end - $start); + } } diff --git a/tests/Timer/ExtLibevTimerTest.php b/tests/Timer/ExtLibevTimerTest.php deleted file mode 100644 index 65e82bee..00000000 --- a/tests/Timer/ExtLibevTimerTest.php +++ /dev/null @@ -1,17 +0,0 @@ -markTestSkipped('libev tests skipped because ext-libev is not installed.'); - } - - return new ExtLibevLoop(); - } -} diff --git a/tests/Timer/ExtLibeventTimerTest.php b/tests/Timer/ExtLibeventTimerTest.php deleted file mode 100644 index 9089b9a5..00000000 --- a/tests/Timer/ExtLibeventTimerTest.php +++ /dev/null @@ -1,17 +0,0 @@ -markTestSkipped('libevent tests skipped because ext-libevent is not installed.'); - } - - return new ExtLibeventLoop(); - } -} diff --git a/tests/Timer/ExtUvTimerTest.php b/tests/Timer/ExtUvTimerTest.php new file mode 100644 index 00000000..e0c70233 --- /dev/null +++ b/tests/Timer/ExtUvTimerTest.php @@ -0,0 +1,17 @@ +markTestSkipped('uv tests skipped because ext-uv is not installed.'); + } + + return new ExtUvLoop(); + } +} diff --git a/tests/bin/01-ticks-loop-class.php b/tests/bin/01-ticks-loop-class.php new file mode 100644 index 00000000..5d499f92 --- /dev/null +++ b/tests/bin/01-ticks-loop-class.php @@ -0,0 +1,14 @@ +futureTick(function () { + echo 'b'; +}); + +$loop->futureTick(function () { + echo 'c'; +}); + +echo 'a'; + +$loop->run(); diff --git a/tests/bin/03-ticks-loop-stop.php b/tests/bin/03-ticks-loop-stop.php new file mode 100644 index 00000000..87317563 --- /dev/null +++ b/tests/bin/03-ticks-loop-stop.php @@ -0,0 +1,24 @@ +futureTick(function () use ($loop) { + echo 'b'; + + $loop->stop(); + + $loop->futureTick(function () { + echo 'never'; + }); +}); + +echo 'a'; + +$loop->run(); + +echo 'c'; diff --git a/tests/bin/11-uncaught.php b/tests/bin/11-uncaught.php new file mode 100644 index 00000000..c5bf8c70 --- /dev/null +++ b/tests/bin/11-uncaught.php @@ -0,0 +1,12 @@ +addTimer(10.0, function () { + echo 'never'; +}); + +$undefined->foo('bar'); diff --git a/tests/bin/21-stop.php b/tests/bin/21-stop.php new file mode 100644 index 00000000..182ee016 --- /dev/null +++ b/tests/bin/21-stop.php @@ -0,0 +1,12 @@ +> "$(php -r 'echo php_ini_loaded_file();')" - fi - - # install 'libev' PHP extension (does not support php 7) - if [[ "$TRAVIS_PHP_VERSION" != "7.0" && - "$TRAVIS_PHP_VERSION" != "7.1" && - "$TRAVIS_PHP_VERSION" != "7.2" ]]; then - git clone --recursive https://github.com/m4rw3r/php-libev - pushd php-libev - phpize - ./configure --with-libev - make - make install - popd - echo "extension=libev.so" >> "$(php -r 'echo php_ini_loaded_file();')" - fi - -fi