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..4e69fa12 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,100 @@ +name: CI + +on: + push: + pull_request: + +jobs: + PHPUnit: + name: PHPUnit (PHP ${{ matrix.php }}) + runs-on: ubuntu-22.04 + 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: xdebug + ini-file: development + ini-values: disable_functions='' # do not disable PCNTL functions on PHP < 8.1 + extensions: sockets, pcntl, event, 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-22.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: xdebug + 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.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: xdebug + 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 a29daf2b..00000000 --- a/.travis.yml +++ /dev/null @@ -1,13 +0,0 @@ -language: php - -php: - - 5.4 - - 5.5 - - 5.6 - - 7.0 - - hhvm - -install: ./travis-init.sh - -script: - - ./vendor/bin/phpunit --coverage-text diff --git a/CHANGELOG.md b/CHANGELOG.md index 30787694..e634b12e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,402 @@ # 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. + (#167 by @Ocramius) + +* Improve test suite by simplifying test bootstrap by using dev autoloader. + (#169 by @lcobucci) + +* Minor internal changes to improved backward compatibility with PHP 5.3. + (#166 by @Donatello-za) + +## 0.5.2 (2018-04-24) + +* Feature: Improve memory consumption and runtime performance for `StreamSelectLoop` timers. + (#164 by @clue) + +* Improve test suite by removing I/O dependency at `StreamSelectLoopTest` to fix Mac OS X tests. + (#161 by @nawarian) + +## 0.5.1 (2018-04-09) + +* Feature: New `ExtEvLoop` (PECL ext-ev) (#148 by @kaduev13) + +## 0.5.0 (2018-04-05) + +A major feature release with a significant documentation overhaul and long overdue API cleanup! + +This update involves a number of BC breaks due to dropped support for deprecated +functionality. We've tried hard to avoid BC breaks where possible and minimize +impact otherwise. We expect that most consumers of this package will actually +not be affected by any BC breaks, see below for more details. + +We realize that the changes listed below may seem overwhelming, but we've tried +to be very clear about any possible BC breaks. Don't worry: In fact, all ReactPHP +components are already compatible and support both this new release as well as +providing backwards compatibility with the last release. + +* Feature / BC break: Add support for signal handling via new + `LoopInterface::addSignal()` and `LoopInterface::removeSignal()` methods. + (#104 by @WyriHaximus and #111 and #150 by @clue) + + ```php + $loop->addSignal(SIGINT, function () { + echo 'CTRL-C'; + }); + ``` + +* Feature: Significant documentation updates for `LoopInterface` and `Factory`. + (#100, #119, #126, #127, #159 and #160 by @clue, #113 by @WyriHaximus and #81 and #91 by @jsor) + +* Feature: Add examples to ease getting started + (#99, #100 and #125 by @clue, #59 by @WyriHaximus and #143 by @jsor) + +* Feature: Documentation for advanced timer concepts, such as monotonic time source vs wall-clock time + and high precision timers with millisecond accuracy or below. + (#130 and #157 by @clue) + +* Feature: Documentation for advanced stream concepts, such as edge-triggered event listeners + and stream buffers and allow throwing Exception if stream resource is not supported. + (#129 and #158 by @clue) + +* Feature: Throw `BadMethodCallException` on manual loop creation when required extension isn't installed. + (#153 by @WyriHaximus) + +* Feature / BC break: First class support for legacy PHP 5.3 through PHP 7.2 and HHVM + and remove all `callable` type hints for consistency reasons. + (#141 and #151 by @clue) + +* BC break: Documentation for timer API and clean up unneeded timer API. + (#102 by @clue) + + Remove `TimerInterface::cancel()`, use `LoopInterface::cancelTimer()` instead: + + ```php + // old (method invoked on timer instance) + $timer->cancel(); + + // already supported before: invoke method on loop instance + $loop->cancelTimer($timer); + ``` + + Remove unneeded `TimerInterface::setData()` and `TimerInterface::getData()`, + use closure binding to add arbitrary data to timer instead: + + ```php + // old (limited setData() and getData() only allows single variable) + $name = 'Tester'; + $timer = $loop->addTimer(1.0, function ($timer) { + echo 'Hello ' . $timer->getData() . PHP_EOL; + }); + $timer->setData($name); + + // already supported before: closure binding allows any number of variables + $name = 'Tester'; + $loop->addTimer(1.0, function () use ($name) { + echo 'Hello ' . $name . PHP_EOL; + }); + ``` + + Remove unneeded `TimerInterface::getLoop()`, use closure binding instead: + + ```php + // old (getLoop() called on timer instance) + $loop->addTimer(0.1, function ($timer) { + $timer->getLoop()->stop(); + }); + + // already supported before: use closure binding as usual + $loop->addTimer(0.1, function () use ($loop) { + $loop->stop(); + }); + ``` + +* BC break: Remove unneeded `LoopInterface::isTimerActive()` and + `TimerInterface::isActive()` to reduce API surface. + (#133 by @clue) + + ```php + // old (method on timer instance or on loop instance) + $timer->isActive(); + $loop->isTimerActive($timer); + ``` + +* BC break: Move `TimerInterface` one level up to `React\EventLoop\TimerInterface`. + (#138 by @WyriHaximus) + + ```php + // old (notice obsolete "Timer" namespace) + assert($timer instanceof React\EventLoop\Timer\TimerInterface); + + // new + assert($timer instanceof React\EventLoop\TimerInterface); + ``` + +* BC break: Remove unneeded `LoopInterface::nextTick()` (and internal `NextTickQueue`), + use `LoopInterface::futureTick()` instead. + (#30 by @clue) + + ```php + // old (removed) + $loop->nextTick(function () { + echo 'tick'; + }); + + // already supported before + $loop->futureTick(function () { + echo 'tick'; + }); + ``` + +* BC break: Remove unneeded `$loop` argument for `LoopInterface::futureTick()` + (and fix internal cyclic dependency). + (#103 by @clue) + + ```php + // old ($loop gets passed by default) + $loop->futureTick(function ($loop) { + $loop->stop(); + }); + + // already supported before: use closure binding as usual + $loop->futureTick(function () use ($loop) { + $loop->stop(); + }); + ``` + +* BC break: Remove unneeded `LoopInterface::tick()`. + (#72 by @jsor) + + ```php + // old (removed) + $loop->tick(); + + // suggested work around for testing purposes only + $loop->futureTick(function () use ($loop) { + $loop->stop(); + }); + ``` + +* BC break: Documentation for advanced stream API and clean up unneeded stream API. + (#110 by @clue) + + Remove unneeded `$loop` argument for `LoopInterface::addReadStream()` + and `LoopInterface::addWriteStream()`, use closure binding instead: + + ```php + // old ($loop gets passed by default) + $loop->addReadStream($stream, function ($stream, $loop) { + $loop->removeReadStream($stream); + }); + + // already supported before: use closure binding as usual + $loop->addReadStream($stream, function ($stream) use ($loop) { + $loop->removeReadStream($stream); + }); + ``` + +* BC break: Remove unneeded `LoopInterface::removeStream()` method, + use `LoopInterface::removeReadStream()` and `LoopInterface::removeWriteStream()` instead. + (#118 by @clue) + + ```php + // old + $loop->removeStream($stream); + + // already supported before + $loop->removeReadStream($stream); + $loop->removeWriteStream($stream); + ``` + +* BC break: Rename `LibEventLoop` to `ExtLibeventLoop` and `LibEvLoop` to `ExtLibevLoop` + for consistent naming for event loop implementations. + (#128 by @clue) + +* BC break: Remove optional `EventBaseConfig` argument from `ExtEventLoop` + and make its `FEATURE_FDS` enabled by default. + (#156 by @WyriHaximus) + +* BC break: Mark all classes as final to discourage inheritance. + (#131 by @clue) + +* Fix: Fix `ExtEventLoop` to keep track of stream resources (refcount) + (#123 by @clue) + +* Fix: Ensure large timer interval does not overflow on 32bit systems + (#132 by @clue) + +* Fix: Fix separately removing readable and writable side of stream when closing + (#139 by @clue) + +* Fix: Properly clean up event watchers for `ext-event` and `ext-libev` + (#149 by @clue) + +* Fix: Minor code cleanup and remove unneeded references + (#145 by @seregazhuk) + +* Fix: Discourage outdated `ext-libevent` on PHP 7 + (#62 by @cboden) + +* Improve test suite by adding forward compatibility with PHPUnit 6 and PHPUnit 5, + lock Travis distro so new defaults will not break the build, + improve test suite to be less fragile and increase test timeouts, + test against PHP 7.2 and reduce fwrite() call length to one chunk. + (#106 and #144 by @clue, #120 and #124 by @carusogabriel, #147 by nawarian and #92 by @kelunik) + +* A number of changes were originally planned for this release but have been backported + to the last `v0.4.3` already: #74, #76, #79, #81 (refs #65, #66, #67), #88 and #93 + +## 0.4.3 (2017-04-27) + +* Bug fix: Bugfix in the usage sample code #57 (@dandelionred) +* Improvement: Remove branch-alias definition #53 (@WyriHaximus) +* Improvement: StreamSelectLoop: Use fresh time so Timers added during stream events are accurate #51 (@andrewminerd) +* Improvement: Avoid deprecation warnings in test suite due to deprecation of getMock() in PHPUnit #68 (@martinschroeder) +* Improvement: Add PHPUnit 4.8 to require-dev #69 (@shaunbramley) +* Improvement: Increase test timeouts for HHVM and unify timeout handling #70 (@clue) +* Improvement: Travis improvements (backported from #74) #75 (@clue) +* Improvement: Test suite now uses socket pairs instead of memory streams #66 (@martinschroeder) +* Improvement: StreamSelectLoop: Test suite uses signal constant names in data provider #67 (@martinschroeder) +* Improvement: ExtEventLoop: No longer suppress all errors #65 (@mamciek) +* Improvement: Readme cleanup #89 (@jsor) +* Improvement: Restructure and improve README #90 (@jsor) +* Bug fix: StreamSelectLoop: Fix erroneous zero-time sleep (backport to 0.4) #94 (@jsor) + ## 0.4.2 (2016-03-07) * Bug fix: No longer error when signals sent to StreamSelectLoop @@ -12,10 +409,6 @@ * Bug fix: null timeout in StreamSelectLoop causing 100% CPU usage (@clue) * Bug fix: v0.3.4 changes merged for v0.4.1 -## 0.3.4 (2014-03-30) - -* Changed StreamSelectLoop to use non-blocking behavior on tick() (@astephens25) - ## 0.4.0 (2014-02-02) * Feature: Added `EventLoopInterface::nextTick()`, implemented in all event loops (@jmalloc) @@ -26,6 +419,18 @@ * BC break: New method: `EventLoopInterface::futureTick()` * Dependency: Autoloading and filesystem structure now PSR-4 instead of PSR-0 +## 0.3.5 (2016-12-28) + +This is a compatibility release that eases upgrading to the v0.4 release branch. +You should consider upgrading to the v0.4 release branch. + +* Feature: Cap min timer interval at 1µs, thus improving compatibility with v0.4 + (#47 by @clue) + +## 0.3.4 (2014-03-30) + +* Bug fix: Changed StreamSelectLoop to use non-blocking behavior on tick() (@astephens25) + ## 0.3.3 (2013-07-08) * Bug fix: No error on removing non-existent streams (@clue) 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 d6f7da51..8394784f 100644 --- a/README.md +++ b/README.md @@ -1,68 +1,864 @@ -# EventLoop Component +# EventLoop -[![Build Status](https://secure.travis-ci.org/reactphp/event-loop.png?branch=master)](http://travis-ci.org/reactphp/event-loop) [![Code Climate](https://codeclimate.com/github/reactphp/event-loop/badges/gpa.svg)](https://codeclimate.com/github/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) -Event loop abstraction layer that libraries can use for evented I/O. +[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` call that is controlled by the user. +single [`run()`](#run) call that is controlled by the user. + +**Table of contents** + +* [Quickstart example](#quickstart-example) +* [Usage](#usage) + * [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) +* [More](#more) + +## Quickstart example + +Here is an async HTTP server built with just the event loop. + +```php +addPeriodicTimer(0.1, function () { + echo 'Tick' . PHP_EOL; +}); + +$loop->addTimer(1.0, function () use ($loop, $timer) { + $loop->cancelTimer($timer); + echo 'Done' . PHP_EOL; +}); + +$loop->run(); +``` + +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: + +```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; +}); +``` + +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 +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'); +``` + +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. + +#### 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. -In addition to the interface there are some implementations provided: +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: -* `StreamSelectLoop`: This is the only implementation which works out of the - box with PHP. It does a simple `select` system call. It's not the most - performant of loops, but still does the job quite well. +```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. -* `LibEventLoop`: This uses the `libevent` pecl extension. `libevent` itself - supports a number of system-specific backends (epoll, kqueue). +```php +use React\EventLoop\Loop; +use React\EventLoop\LoopInterface; + +$loop = Loop::get(); + +assert($loop instanceof LoopInterface); +assert($loop === Loop::get()); +``` -* `LibEvLoop`: This uses the `libev` pecl extension - ([github](https://github.com/m4rw3r/php-libev)). It supports the same - backends as libevent. +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: -* `ExtEventLoop`: This uses the `event` pecl extension. It supports the same - backends as libevent. +```php +use React\EventLoop\Loop; +use React\EventLoop\LoopInterface; + +class Greeter +{ + private $loop; -All of the loops support these features: + 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 + +In addition to the [`LoopInterface`](#loopinterface), there are a number of +event loop implementations provided. + +All of the event loops support these features: * File descriptor polling * One-off timers * Periodic timers -* Deferred execution of callbacks +* Deferred execution on future loop tick -## Usage +For most consumers of this package, the underlying event loop implementation is +an implementation detail. +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. +Note that you may have to install the required PHP extensions for the respective +event loop implementation first or they will throw a `BadMethodCallException` on creation. + +#### StreamSelectLoop + +A `stream_select()` based event loop. + +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 any PHP version. +This means that no installation is required and this library works on all +platforms and supported PHP versions. +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. +This system call is limited to the maximum file descriptor number of +`FD_SETSIZE` (platform dependent, commonly 1024) and scales with `O(m)` +(`m` being the maximum file descriptor number passed). +This means that you may run into issues when handling thousands of streams +concurrently and you may want to look into using one of the alternative +event loop implementations listed below in this case. +If your use case is among the many common use cases that involve handling only +dozens or a few hundred streams at once, then this event loop implementation +performs really well. + +If you want to use signal handling (see also [`addSignal()`](#addsignal) below), +this event loop implementation requires `ext-pcntl`. +This extension is only available for Unix-like platforms and does not support +Windows. +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 +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 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), +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 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. +`libev` itself supports a number of system-specific backends (epoll, kqueue). + + +This loop is known to work with PHP 7.1 through PHP 8+. + +#### ExtUvLoop + +An `ext-uv` based event loop. + +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 loop is known to work with PHP 7.1 through PHP 8+. + +### LoopInterface + +#### run() + +The `run(): void` method can be used to +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 usually recommended to attach everything to the +same loop instance and then run the loop once at the bottom end of the +application. -Here is an async HTTP server built with just the event loop. ```php - $loop = React\EventLoop\Factory::create(); - - $server = stream_socket_server('tcp://127.0.0.1:8080'); - stream_set_blocking($server, 0); - $loop->addReadStream($server, function ($server) use ($loop) { - $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) { - $written = fwrite($conn, $data); - if ($written === strlen($data)) { - fclose($conn); - $loop->removeStream($conn); - } else { - $data = substr($data, $written); - } - }); +$loop->run(); +``` + +This method will keep the loop running until there are no more tasks +to perform. In other words: This method will block until the last +timer, stream and/or signal has been removed. + +Likewise, it is imperative to ensure the application actually invokes +this method once. Adding listeners to the loop and missing to actually +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 explicitly been +[`stop()`ped](#stop) or after it automatically stopped because it +previously did no longer have anything to do. + +#### stop() + +The `stop(): void` method can be used to +instruct a running event loop to stop. + +This method is considered advanced usage and should be used with care. +As a rule of thumb, it is usually recommended to let the loop stop +only automatically when it no longer has anything to do. + +This method can be used to explicitly instruct the event loop to stop: + +```php +$loop->addTimer(3.0, function () use ($loop) { + $loop->stop(); +}); +``` + +Calling this method on a loop instance that is not currently running or +on a loop instance that has already been stopped has no effect. + +#### addTimer() + +The `addTimer(float $interval, callable $callback): TimerInterface` method can be used to +enqueue a callback to be invoked once after the given interval. + +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. + +```php +$loop->addTimer(0.8, function () { + echo 'world!' . PHP_EOL; +}); + +$loop->addTimer(0.3, function () { + echo 'hello '; +}); +``` + +See also [example #1](examples). + +If you want to access any variables within your callback function, you +can bind arbitrary data to a callback closure like this: + +```php +function hello($name, LoopInterface $loop) +{ + $loop->addTimer(1.0, function () use ($name) { + echo "hello $name\n"; + }); +} + +hello('Tester', $loop); +``` + +This interface does not enforce any particular timer resolution, so +special care may have to be taken if you rely on very high precision with +millisecond accuracy or below. Event loop implementations SHOULD work on +a best effort basis and SHOULD provide at least millisecond accuracy +unless otherwise noted. Many existing event loop implementations are +known to provide microsecond accuracy, but it's generally not recommended +to rely on this high precision. + +Similarly, the execution order of timers scheduled to execute at the +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 +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). +This means that if you schedule a timer to trigger in 30s and then adjust +your system time forward by 20s, the timer SHOULD still trigger in 30s. +See also [event loop implementations](#loop-implementations) for more details. + +#### addPeriodicTimer() + +The `addPeriodicTimer(float $interval, callable $callback): TimerInterface` method can be used to +enqueue a callback to be invoked repeatedly after the given interval. + +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. +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 () { + echo 'tick!' . PHP_EOL; +}); + +$loop->addTimer(1.0, function () use ($loop, $timer) { + $loop->cancelTimer($timer); + echo 'Done' . PHP_EOL; +}); +``` + +See also [example #2](examples). + +If you want to limit the number of executions, you can bind +arbitrary data to a callback closure like this: + +```php +function hello($name, LoopInterface $loop) +{ + $n = 3; + $loop->addPeriodicTimer(1.0, function ($timer) use ($name, $loop, &$n) { + if ($n > 0) { + --$n; + echo "hello $name\n"; + } else { + $loop->cancelTimer($timer); + } }); +} + +hello('Tester', $loop); +``` + +This interface does not enforce any particular timer resolution, so +special care may have to be taken if you rely on very high precision with +millisecond accuracy or below. Event loop implementations SHOULD work on +a best effort basis and SHOULD provide at least millisecond accuracy +unless otherwise noted. Many existing event loop implementations are +known to provide microsecond accuracy, but it's generally not recommended +to rely on this high precision. + +Similarly, the execution order of timers scheduled to execute at the +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 +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). +This means that if you schedule a timer to trigger in 30s and then adjust +your system time forward by 20s, the timer SHOULD still trigger in 30s. +See also [event loop implementations](#loop-implementations) for more details. + +Additionally, periodic timers may be subject to timer drift due to +re-scheduling after each invocation. As such, it's generally not +recommended to rely on this for high precision intervals with millisecond +accuracy or below. + +#### cancelTimer() + +The `cancelTimer(TimerInterface $timer): void` method can be used to +cancel a pending timer. + +See also [`addPeriodicTimer()`](#addperiodictimer) and [example #2](examples). + +Calling this method on a timer instance that has not been added to this +loop instance or on a timer that has already been cancelled has no effect. + +#### futureTick() + +The `futureTick(callable $listener): void` method can be used to +schedule a callback to be invoked on a future tick of the event loop. + +This works very much similar to timers with an interval of zero seconds, +but does not require the overhead of scheduling a timer queue. + +The tick callback function MUST be able to accept zero parameters. + +The tick callback function MUST NOT throw an `Exception`. +The return value of the tick callback function will be ignored and has +no effect, so for performance reasons you're recommended to not return +any excessive data structures. - $loop->addPeriodicTimer(5, function () { - $memory = memory_get_usage() / 1024; - $formatted = number_format($memory, 3).'K'; - echo "Current memory usage: {$formatted}\n"; +If you want to access any variables within your callback function, you +can bind arbitrary data to a callback closure like this: + +```php +function hello($name, LoopInterface $loop) +{ + $loop->futureTick(function () use ($name) { + echo "hello $name\n"; }); +} + +hello('Tester', $loop); +``` + +Unlike timers, tick callbacks are guaranteed to be executed in the order +they are enqueued. +Also, once a callback is enqueued, there's no way to cancel this operation. + +This is often used to break down bigger tasks into smaller steps (a form +of cooperative multitasking). + +```php +$loop->futureTick(function () { + echo 'b'; +}); +$loop->futureTick(function () { + echo 'c'; +}); +echo 'a'; +``` + +See also [example #3](examples). + +#### addSignal() + +The `addSignal(int $signal, callable $listener): void` method can be used to +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 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 +no effect, so for performance reasons you're recommended to not return +any excessive data structures. - $loop->run(); +```php +$loop->addSignal(SIGINT, function (int $signal) { + echo 'Caught user interrupt signal' . PHP_EOL; +}); +``` + +See also [example #4](examples). + +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 than once will be ignored.** + +#### removeSignal() + +The `removeSignal(int $signal, callable $listener): void` method can be used to +remove a previously added signal listener. + +```php +$loop->removeSignal(SIGINT, $listener); +``` + +Any attempts to remove listeners that aren't registered will be ignored. + +#### addReadStream() + +> Advanced! Note that this low-level API is considered advanced usage. + Most use cases should probably use the higher-level + [readable Stream API](https://github.com/reactphp/stream#readablestreaminterface) + instead. + +The `addReadStream(resource $stream, callable $callback): void` method can be used to +register a listener to be notified when a stream is ready to read. + +The first parameter MUST be a valid stream resource that supports +checking whether it is ready to read by this loop implementation. +A single stream resource MUST NOT be added more than once. +Instead, either call [`removeReadStream()`](#removereadstream) first or +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 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 +no effect, so for performance reasons you're recommended to not return +any excessive data structures. + +If you want to access any variables within your callback function, you +can bind arbitrary data to a callback closure like this: + +```php +$loop->addReadStream($stream, function ($stream) use ($name) { + echo $name . ' said: ' . fread($stream); +}); +``` + +See also [example #11](examples). + +You can invoke [`removeReadStream()`](#removereadstream) to remove the +read event listener for this stream. + +The execution order of listeners when multiple streams become ready at +the same time is not guaranteed. + +Some event loop implementations are known to only trigger the listener 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. + +#### addWriteStream() + +> Advanced! Note that this low-level API is considered advanced usage. + Most use cases should probably use the higher-level + [writable Stream API](https://github.com/reactphp/stream#writablestreaminterface) + instead. + +The `addWriteStream(resource $stream, callable $callback): void` method can be used to +register a listener to be notified when a stream is ready to write. + +The first parameter MUST be a valid stream resource that supports +checking whether it is ready to write by this loop implementation. +A single stream resource MUST NOT be added more than once. +Instead, either call [`removeWriteStream()`](#removewritestream) first or +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 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 +no effect, so for performance reasons you're recommended to not return +any excessive data structures. + +If you want to access any variables within your callback function, you +can bind arbitrary data to a callback closure like this: + +```php +$loop->addWriteStream($stream, function ($stream) use ($name) { + fwrite($stream, 'Hello ' . $name); +}); ``` -**Note:** The factory is just for convenience. It tries to pick the best -available implementation. Libraries `SHOULD` allow the user to inject an -instance of the loop. They `MAY` use the factory when the user did not supply -a loop. + +See also [example #12](examples). + +You can invoke [`removeWriteStream()`](#removewritestream) to remove the +write event listener for this stream. + +The execution order of listeners when multiple streams become ready at +the same time is not guaranteed. + +#### removeReadStream() + +The `removeReadStream(resource $stream): void` method can be used to +remove the read event listener for the given stream. + +Removing a stream from the loop that has already been removed or trying +to remove a stream that was never added or is invalid has no effect. + +#### removeWriteStream() + +The `removeWriteStream(resource $stream): void` method can be used to +remove the write event listener for the given stream. + +Removing a stream from the loop that has already been removed or trying +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/). +[New to Composer?](https://getcomposer.org/doc/00-intro.md) + +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:^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 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. + +## Tests + +To run the test suite, you first need to clone this repo and then install all +dependencies [through Composer](https://getcomposer.org/): + +```bash +composer install +``` + +To run the test suite, go to the project root and run: + +```bash +vendor/bin/phpunit +``` + +## License + +MIT, see [LICENSE file](LICENSE). + +## More + +* See our [Stream component](https://github.com/reactphp/stream) for more + information on how streams are used in real-world applications. +* See our [users wiki](https://github.com/reactphp/react/wiki/Users) and the + [dependents on Packagist](https://packagist.org/packages/react/event-loop/dependents) + for a list of packages that use the EventLoop in real-world applications. diff --git a/composer.json b/composer.json index 5001a9c8..6d31e81d 100644 --- a/composer.json +++ b/composer.json @@ -1,22 +1,47 @@ { "name": "react/event-loop", - "description": "Event loop abstraction layer that libraries can use for evented I/O.", + "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.4.0" + "php": ">=7.1" }, "require-dev": { - "phpunit/phpunit": "~4.8" + "phpunit/phpunit": "^9.6 || ^7.5" }, "suggest": { - "ext-libevent": ">=0.1.0", - "ext-event": "~1.0", - "ext-libev": "*" + "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/" } } } diff --git a/examples/01-timers.php b/examples/01-timers.php new file mode 100644 index 00000000..5f263b3e --- /dev/null +++ b/examples/01-timers.php @@ -0,0 +1,13 @@ + closed + if ($r === 0) { + Loop::removeWriteStream($stdout); + fclose($stdout); + stream_set_blocking($stdout, true); + fwrite(STDERR, 'Stopped because STDOUT closed' . PHP_EOL); + + return; + } + + // implement a very simple ring buffer, unless everything has been written at once: + // everything written in this iteration will be appended for next iteration + if (isset($data[$r])) { + $data = substr($data, $r) . substr($data, 0, $r); + } +}); diff --git a/examples/13-http-client-blocking.php b/examples/13-http-client-blocking.php new file mode 100644 index 00000000..ae119b5a --- /dev/null +++ b/examples/13-http-client-blocking.php @@ -0,0 +1,31 @@ + 0) { + --$ticks; + //$loop->addTimer(0, $tick); + Loop::futureTick($tick); + } else { + echo 'done'; + } +}; + +$tick(); diff --git a/examples/94-benchmark-timers-delay.php b/examples/94-benchmark-timers-delay.php new file mode 100644 index 00000000..df237264 --- /dev/null +++ b/examples/94-benchmark-timers-delay.php @@ -0,0 +1,18 @@ + 0) { + --$ticks; + //$loop->futureTick($tick); + Loop::addTimer(0, $tick); + } else { + echo 'done'; + } +}; + +$tick(); diff --git a/examples/95-benchmark-memory.php b/examples/95-benchmark-memory.php new file mode 100644 index 00000000..efd8c82d --- /dev/null +++ b/examples/95-benchmark-memory.php @@ -0,0 +1,67 @@ + - + + 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 new file mode 100644 index 00000000..363ad0c4 --- /dev/null +++ b/src/ExtEvLoop.php @@ -0,0 +1,251 @@ +loop = new EvLoop(); + $this->futureTickQueue = new FutureTickQueue(); + $this->timers = new SplObjectStorage(); + $this->signals = new SignalsHandler(); + } + + public function addReadStream($stream, $listener) + { + $key = (int)$stream; + + if (isset($this->readStreams[$key])) { + return; + } + + $callback = $this->getStreamListenerClosure($stream, $listener); + $event = $this->loop->io($stream, Ev::READ, $callback); + $this->readStreams[$key] = $event; + } + + /** + * @param resource $stream + * @param callable $listener + * + * @return \Closure + */ + private function getStreamListenerClosure($stream, $listener) + { + return function () use ($stream, $listener) { + \call_user_func($listener, $stream); + }; + } + + public function addWriteStream($stream, $listener) + { + $key = (int)$stream; + + if (isset($this->writeStreams[$key])) { + return; + } + + $callback = $this->getStreamListenerClosure($stream, $listener); + $event = $this->loop->io($stream, Ev::WRITE, $callback); + $this->writeStreams[$key] = $event; + } + + public function removeReadStream($stream) + { + $key = (int)$stream; + + if (!isset($this->readStreams[$key])) { + return; + } + + $this->readStreams[$key]->stop(); + unset($this->readStreams[$key]); + } + + public function removeWriteStream($stream) + { + $key = (int)$stream; + + if (!isset($this->writeStreams[$key])) { + return; + } + + $this->writeStreams[$key]->stop(); + unset($this->writeStreams[$key]); + } + + 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 = $this->loop->timer($timer->getInterval(), 0.0, $callback); + $this->timers->attach($timer, $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 = $this->loop->timer($timer->getInterval(), $timer->getInterval(), $callback); + $this->timers->attach($timer, $event); + + return $timer; + } + + public function cancelTimer(TimerInterface $timer) + { + if (!isset($this->timers[$timer])) { + return; + } + + $event = $this->timers[$timer]; + $event->stop(); + $this->timers->detach($timer); + } + + public function futureTick($listener) + { + $this->futureTickQueue->add($listener); + } + + 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(); + + $flags = Ev::RUN_ONCE; + if ($wasJustStopped || $hasPendingCallbacks) { + $flags |= Ev::RUN_NOWAIT; + } elseif ($nothingLeftToDo) { + break; + } + + $this->loop->run($flags); + } + } + + public function stop() + { + $this->running = false; + } + + public function __destruct() + { + /** @var TimerInterface $timer */ + foreach ($this->timers as $timer) { + $this->cancelTimer($timer); + } + + foreach ($this->readStreams as $key => $stream) { + $this->removeReadStream($key); + } + + foreach ($this->writeStreams as $key => $stream) { + $this->removeWriteStream($key); + } + } + + public function addSignal($signal, $listener) + { + $this->signals->add($signal, $listener); + + if (!isset($this->signalEvents[$signal])) { + $this->signalEvents[$signal] = $this->loop->signal($signal, function() use ($signal) { + $this->signals->call($signal); + }); + } + } + + public function removeSignal($signal, $listener) + { + $this->signals->remove($signal, $listener); + + if (isset($this->signalEvents[$signal])) { + $this->signalEvents[$signal]->stop(); + unset($this->signalEvents[$signal]); + } + } +} diff --git a/src/ExtEventLoop.php b/src/ExtEventLoop.php index 7160c908..d6f24b2a 100644 --- a/src/ExtEventLoop.php +++ b/src/ExtEventLoop.php @@ -2,213 +2,206 @@ namespace React\EventLoop; +use BadMethodCallException; use Event; use EventBase; -use EventConfig as EventBaseConfig; use React\EventLoop\Tick\FutureTickQueue; -use React\EventLoop\Tick\NextTickQueue; use React\EventLoop\Timer\Timer; -use React\EventLoop\Timer\TimerInterface; use SplObjectStorage; /** - * An ext-event based event-loop. + * An `ext-event` based event loop. + * + * 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 7.1 through PHP 8+. + * + * @link https://pecl.php.net/package/event */ -class ExtEventLoop implements LoopInterface +final class ExtEventLoop implements LoopInterface { private $eventBase; - private $nextTickQueue; private $futureTickQueue; private $timerCallback; private $timerEvents; private $streamCallback; - private $streamEvents = []; - private $streamFlags = []; + private $readEvents = []; + private $writeEvents = []; private $readListeners = []; private $writeListeners = []; + private $readRefs = []; + private $writeRefs = []; private $running; + private $signals; + private $signalEvents = []; - public function __construct(EventBaseConfig $config = null) + public function __construct() { + if (!\class_exists('EventBase', false)) { + throw new BadMethodCallException('Cannot create ExtEventLoop, ext-event extension missing'); + } + + // 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->nextTickQueue = new NextTickQueue($this); - $this->futureTickQueue = new FutureTickQueue($this); + $this->futureTickQueue = new FutureTickQueue(); $this->timerEvents = new SplObjectStorage(); + $this->signals = new SignalsHandler(); $this->createTimerCallback(); $this->createStreamCallback(); } - /** - * {@inheritdoc} - */ - public function addReadStream($stream, callable $listener) + public function __destruct() { - $key = (int) $stream; - - if (!isset($this->readListeners[$key])) { - $this->readListeners[$key] = $listener; - $this->subscribeStreamEvent($stream, Event::READ); + // 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 = []; } - /** - * {@inheritdoc} - */ - public function addWriteStream($stream, callable $listener) + public function addReadStream($stream, $listener) { $key = (int) $stream; - - if (!isset($this->writeListeners[$key])) { - $this->writeListeners[$key] = $listener; - $this->subscribeStreamEvent($stream, Event::WRITE); + if (isset($this->readListeners[$key])) { + return; } + + $event = new Event($this->eventBase, $stream, Event::PERSIST | Event::READ, $this->streamCallback); + $event->add(); + $this->readEvents[$key] = $event; + $this->readListeners[$key] = $listener; + + // ext-event does not increase refcount on stream resources for PHP 7+ + // manually keep track of stream resource to prevent premature garbage collection + $this->readRefs[$key] = $stream; } - /** - * {@inheritdoc} - */ - public function removeReadStream($stream) + public function addWriteStream($stream, $listener) { $key = (int) $stream; - - if (isset($this->readListeners[$key])) { - unset($this->readListeners[$key]); - $this->unsubscribeStreamEvent($stream, Event::READ); + if (isset($this->writeListeners[$key])) { + return; } + + $event = new Event($this->eventBase, $stream, Event::PERSIST | Event::WRITE, $this->streamCallback); + $event->add(); + $this->writeEvents[$key] = $event; + $this->writeListeners[$key] = $listener; + + // ext-event does not increase refcount on stream resources for PHP 7+ + // manually keep track of stream resource to prevent premature garbage collection + $this->writeRefs[$key] = $stream; } - /** - * {@inheritdoc} - */ - public function removeWriteStream($stream) + public function removeReadStream($stream) { $key = (int) $stream; - if (isset($this->writeListeners[$key])) { - unset($this->writeListeners[$key]); - $this->unsubscribeStreamEvent($stream, Event::WRITE); + if (isset($this->readEvents[$key])) { + $this->readEvents[$key]->free(); + unset( + $this->readEvents[$key], + $this->readListeners[$key], + $this->readRefs[$key] + ); } } - /** - * {@inheritdoc} - */ - public function removeStream($stream) + public function removeWriteStream($stream) { $key = (int) $stream; - if (isset($this->streamEvents[$key])) { - $this->streamEvents[$key]->free(); - + if (isset($this->writeEvents[$key])) { + $this->writeEvents[$key]->free(); unset( - $this->streamFlags[$key], - $this->streamEvents[$key], - $this->readListeners[$key], - $this->writeListeners[$key] + $this->writeEvents[$key], + $this->writeListeners[$key], + $this->writeRefs[$key] ); } } - /** - * {@inheritdoc} - */ - public function addTimer($interval, callable $callback) + public function addTimer($interval, $callback) { - $timer = new Timer($this, $interval, $callback, false); + $timer = new Timer($interval, $callback, false); $this->scheduleTimer($timer); return $timer; } - /** - * {@inheritdoc} - */ - public function addPeriodicTimer($interval, callable $callback) + public function addPeriodicTimer($interval, $callback) { - $timer = new Timer($this, $interval, $callback, true); + $timer = new Timer($interval, $callback, true); $this->scheduleTimer($timer); return $timer; } - /** - * {@inheritdoc} - */ public function cancelTimer(TimerInterface $timer) { - if ($this->isTimerActive($timer)) { + if ($this->timerEvents->contains($timer)) { $this->timerEvents[$timer]->free(); $this->timerEvents->detach($timer); } } - /** - * {@inheritdoc} - */ - public function isTimerActive(TimerInterface $timer) + public function futureTick($listener) { - return $this->timerEvents->contains($timer); + $this->futureTickQueue->add($listener); } - /** - * {@inheritdoc} - */ - public function nextTick(callable $listener) + public function addSignal($signal, $listener) { - $this->nextTickQueue->add($listener); - } + $this->signals->add($signal, $listener); - /** - * {@inheritdoc} - */ - public function futureTick(callable $listener) - { - $this->futureTickQueue->add($listener); + if (!isset($this->signalEvents[$signal])) { + $this->signalEvents[$signal] = Event::signal($this->eventBase, $signal, [$this->signals, 'call']); + $this->signalEvents[$signal]->add(); + } } - /** - * {@inheritdoc} - */ - public function tick() + public function removeSignal($signal, $listener) { - $this->nextTickQueue->tick(); + $this->signals->remove($signal, $listener); - $this->futureTickQueue->tick(); - - // @-suppression: https://github.com/reactphp/react/pull/234#discussion-diff-7759616R226 - @$this->eventBase->loop(EventBase::LOOP_ONCE | EventBase::LOOP_NONBLOCK); + if (isset($this->signalEvents[$signal]) && $this->signals->count($signal) === 0) { + $this->signalEvents[$signal]->free(); + unset($this->signalEvents[$signal]); + } } - /** - * {@inheritdoc} - */ public function run() { $this->running = true; while ($this->running) { - $this->nextTickQueue->tick(); - $this->futureTickQueue->tick(); $flags = EventBase::LOOP_ONCE; - if (!$this->running || !$this->nextTickQueue->isEmpty() || !$this->futureTickQueue->isEmpty()) { + if (!$this->running || !$this->futureTickQueue->isEmpty()) { $flags |= EventBase::LOOP_NONBLOCK; - } elseif (!$this->streamEvents && !$this->timerEvents->count()) { + } elseif (!$this->readEvents && !$this->writeEvents && !$this->timerEvents->count() && $this->signals->isEmpty()) { break; } - // @-suppression: https://github.com/reactphp/react/pull/234#discussion-diff-7759616R226 - @$this->eventBase->loop($flags); + $this->eventBase->loop($flags); } } - /** - * {@inheritdoc} - */ public function stop() { $this->running = false; @@ -233,58 +226,6 @@ private function scheduleTimer(TimerInterface $timer) $event->add($timer->getInterval()); } - /** - * Create a new ext-event Event object, or update the existing one. - * - * @param resource $stream - * @param integer $flag Event::READ or Event::WRITE - */ - private function subscribeStreamEvent($stream, $flag) - { - $key = (int) $stream; - - if (isset($this->streamEvents[$key])) { - $event = $this->streamEvents[$key]; - $flags = ($this->streamFlags[$key] |= $flag); - - $event->del(); - $event->set($this->eventBase, $stream, Event::PERSIST | $flags, $this->streamCallback); - } else { - $event = new Event($this->eventBase, $stream, Event::PERSIST | $flag, $this->streamCallback); - - $this->streamEvents[$key] = $event; - $this->streamFlags[$key] = $flag; - } - - $event->add(); - } - - /** - * Update the ext-event Event object for this stream to stop listening to - * the given event type, or remove it entirely if it's no longer needed. - * - * @param resource $stream - * @param integer $flag Event::READ or Event::WRITE - */ - private function unsubscribeStreamEvent($stream, $flag) - { - $key = (int) $stream; - - $flags = $this->streamFlags[$key] &= ~$flag; - - if (0 === $flags) { - $this->removeStream($stream); - - return; - } - - $event = $this->streamEvents[$key]; - - $event->del(); - $event->set($this->eventBase, $stream, Event::PERSIST | $flags, $this->streamCallback); - $event->add(); - } - /** * Create a callback used as the target of timer events. * @@ -295,9 +236,9 @@ private function unsubscribeStreamEvent($stream, $flag) private function createTimerCallback() { $this->timerCallback = function ($_, $__, $timer) { - call_user_func($timer->getCallback(), $timer); + \call_user_func($timer->getCallback(), $timer); - if (!$timer->isPeriodic() && $this->isTimerActive($timer)) { + if (!$timer->isPeriodic() && $this->timerEvents->contains($timer)) { $this->cancelTimer($timer); } }; @@ -316,11 +257,11 @@ private function createStreamCallback() $key = (int) $stream; if (Event::READ === (Event::READ & $flags) && isset($this->readListeners[$key])) { - call_user_func($this->readListeners[$key], $stream, $this); + \call_user_func($this->readListeners[$key], $stream); } if (Event::WRITE === (Event::WRITE & $flags) && isset($this->writeListeners[$key])) { - call_user_func($this->writeListeners[$key], $stream, $this); + \call_user_func($this->writeListeners[$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 9a481e35..00000000 --- a/src/Factory.php +++ /dev/null @@ -1,21 +0,0 @@ -loop = new EventLoop(); - $this->nextTickQueue = new NextTickQueue($this); - $this->futureTickQueue = new FutureTickQueue($this); - $this->timerEvents = new SplObjectStorage(); - } - - /** - * {@inheritdoc} - */ - public function addReadStream($stream, callable $listener) - { - $callback = function () use ($stream, $listener) { - call_user_func($listener, $stream, $this); - }; - - $event = new IOEvent($callback, $stream, IOEvent::READ); - $this->loop->add($event); - - $this->readEvents[(int) $stream] = $event; - } - - /** - * {@inheritdoc} - */ - public function addWriteStream($stream, callable $listener) - { - $callback = function () use ($stream, $listener) { - call_user_func($listener, $stream, $this); - }; - - $event = new IOEvent($callback, $stream, IOEvent::WRITE); - $this->loop->add($event); - - $this->writeEvents[(int) $stream] = $event; - } - - /** - * {@inheritdoc} - */ - public function removeReadStream($stream) - { - $key = (int) $stream; - - if (isset($this->readEvents[$key])) { - $this->readEvents[$key]->stop(); - unset($this->readEvents[$key]); - } - } - - /** - * {@inheritdoc} - */ - public function removeWriteStream($stream) - { - $key = (int) $stream; - - if (isset($this->writeEvents[$key])) { - $this->writeEvents[$key]->stop(); - unset($this->writeEvents[$key]); - } - } - - /** - * {@inheritdoc} - */ - public function removeStream($stream) - { - $this->removeReadStream($stream); - $this->removeWriteStream($stream); - } - - /** - * {@inheritdoc} - */ - public function addTimer($interval, callable $callback) - { - $timer = new Timer($this, $interval, $callback, false); - - $callback = function () use ($timer) { - call_user_func($timer->getCallback(), $timer); - - if ($this->isTimerActive($timer)) { - $this->cancelTimer($timer); - } - }; - - $event = new TimerEvent($callback, $timer->getInterval()); - $this->timerEvents->attach($timer, $event); - $this->loop->add($event); - - return $timer; - } - - /** - * {@inheritdoc} - */ - public function addPeriodicTimer($interval, callable $callback) - { - $timer = new Timer($this, $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; - } - - /** - * {@inheritdoc} - */ - public function cancelTimer(TimerInterface $timer) - { - if (isset($this->timerEvents[$timer])) { - $this->loop->remove($this->timerEvents[$timer]); - $this->timerEvents->detach($timer); - } - } - - /** - * {@inheritdoc} - */ - public function isTimerActive(TimerInterface $timer) - { - return $this->timerEvents->contains($timer); - } - - /** - * {@inheritdoc} - */ - public function nextTick(callable $listener) - { - $this->nextTickQueue->add($listener); - } - - /** - * {@inheritdoc} - */ - public function futureTick(callable $listener) - { - $this->futureTickQueue->add($listener); - } - - /** - * {@inheritdoc} - */ - public function tick() - { - $this->nextTickQueue->tick(); - - $this->futureTickQueue->tick(); - - $this->loop->run(EventLoop::RUN_ONCE | EventLoop::RUN_NOWAIT); - } - - /** - * {@inheritdoc} - */ - public function run() - { - $this->running = true; - - while ($this->running) { - $this->nextTickQueue->tick(); - - $this->futureTickQueue->tick(); - - $flags = EventLoop::RUN_ONCE; - if (!$this->running || !$this->nextTickQueue->isEmpty() || !$this->futureTickQueue->isEmpty()) { - $flags |= EventLoop::RUN_NOWAIT; - } elseif (!$this->readEvents && !$this->writeEvents && !$this->timerEvents->count()) { - break; - } - - $this->loop->run($flags); - } - } - - /** - * {@inheritdoc} - */ - public function stop() - { - $this->running = false; - } -} diff --git a/src/LibEventLoop.php b/src/LibEventLoop.php deleted file mode 100644 index 99417a12..00000000 --- a/src/LibEventLoop.php +++ /dev/null @@ -1,343 +0,0 @@ -eventBase = event_base_new(); - $this->nextTickQueue = new NextTickQueue($this); - $this->futureTickQueue = new FutureTickQueue($this); - $this->timerEvents = new SplObjectStorage(); - - $this->createTimerCallback(); - $this->createStreamCallback(); - } - - /** - * {@inheritdoc} - */ - public function addReadStream($stream, callable $listener) - { - $key = (int) $stream; - - if (!isset($this->readListeners[$key])) { - $this->readListeners[$key] = $listener; - $this->subscribeStreamEvent($stream, EV_READ); - } - } - - /** - * {@inheritdoc} - */ - public function addWriteStream($stream, callable $listener) - { - $key = (int) $stream; - - if (!isset($this->writeListeners[$key])) { - $this->writeListeners[$key] = $listener; - $this->subscribeStreamEvent($stream, EV_WRITE); - } - } - - /** - * {@inheritdoc} - */ - public function removeReadStream($stream) - { - $key = (int) $stream; - - if (isset($this->readListeners[$key])) { - unset($this->readListeners[$key]); - $this->unsubscribeStreamEvent($stream, EV_READ); - } - } - - /** - * {@inheritdoc} - */ - public function removeWriteStream($stream) - { - $key = (int) $stream; - - if (isset($this->writeListeners[$key])) { - unset($this->writeListeners[$key]); - $this->unsubscribeStreamEvent($stream, EV_WRITE); - } - } - - /** - * {@inheritdoc} - */ - public function removeStream($stream) - { - $key = (int) $stream; - - if (isset($this->streamEvents[$key])) { - $event = $this->streamEvents[$key]; - - event_del($event); - event_free($event); - - unset( - $this->streamFlags[$key], - $this->streamEvents[$key], - $this->readListeners[$key], - $this->writeListeners[$key] - ); - } - } - - /** - * {@inheritdoc} - */ - public function addTimer($interval, callable $callback) - { - $timer = new Timer($this, $interval, $callback, false); - - $this->scheduleTimer($timer); - - return $timer; - } - - /** - * {@inheritdoc} - */ - public function addPeriodicTimer($interval, callable $callback) - { - $timer = new Timer($this, $interval, $callback, true); - - $this->scheduleTimer($timer); - - return $timer; - } - - /** - * {@inheritdoc} - */ - public function cancelTimer(TimerInterface $timer) - { - if ($this->isTimerActive($timer)) { - $event = $this->timerEvents[$timer]; - - event_del($event); - event_free($event); - - $this->timerEvents->detach($timer); - } - } - - /** - * {@inheritdoc} - */ - public function isTimerActive(TimerInterface $timer) - { - return $this->timerEvents->contains($timer); - } - - /** - * {@inheritdoc} - */ - public function nextTick(callable $listener) - { - $this->nextTickQueue->add($listener); - } - - /** - * {@inheritdoc} - */ - public function futureTick(callable $listener) - { - $this->futureTickQueue->add($listener); - } - - /** - * {@inheritdoc} - */ - public function tick() - { - $this->nextTickQueue->tick(); - - $this->futureTickQueue->tick(); - - event_base_loop($this->eventBase, EVLOOP_ONCE | EVLOOP_NONBLOCK); - } - - /** - * {@inheritdoc} - */ - public function run() - { - $this->running = true; - - while ($this->running) { - $this->nextTickQueue->tick(); - - $this->futureTickQueue->tick(); - - $flags = EVLOOP_ONCE; - if (!$this->running || !$this->nextTickQueue->isEmpty() || !$this->futureTickQueue->isEmpty()) { - $flags |= EVLOOP_NONBLOCK; - } elseif (!$this->streamEvents && !$this->timerEvents->count()) { - break; - } - - event_base_loop($this->eventBase, $flags); - } - } - - /** - * {@inheritdoc} - */ - 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 new ext-libevent event resource, or update the existing one. - * - * @param resource $stream - * @param integer $flag EV_READ or EV_WRITE - */ - private function subscribeStreamEvent($stream, $flag) - { - $key = (int) $stream; - - if (isset($this->streamEvents[$key])) { - $event = $this->streamEvents[$key]; - $flags = $this->streamFlags[$key] |= $flag; - - event_del($event); - event_set($event, $stream, EV_PERSIST | $flags, $this->streamCallback); - } else { - $event = event_new(); - - event_set($event, $stream, EV_PERSIST | $flag, $this->streamCallback); - event_base_set($event, $this->eventBase); - - $this->streamEvents[$key] = $event; - $this->streamFlags[$key] = $flag; - } - - event_add($event); - } - - /** - * Update the ext-libevent event resource for this stream to stop listening to - * the given event type, or remove it entirely if it's no longer needed. - * - * @param resource $stream - * @param integer $flag EV_READ or EV_WRITE - */ - private function unsubscribeStreamEvent($stream, $flag) - { - $key = (int) $stream; - - $flags = $this->streamFlags[$key] &= ~$flag; - - if (0 === $flags) { - $this->removeStream($stream); - - return; - } - - $event = $this->streamEvents[$key]; - - event_del($event); - event_set($event, $stream, EV_PERSIST | $flags, $this->streamCallback); - event_add($event); - } - - /** - * 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() - { - $this->timerCallback = function ($_, $__, $timer) { - call_user_func($timer->getCallback(), $timer); - - // Timer already cancelled ... - if (!$this->isTimerActive($timer)) { - return; - - // Reschedule periodic timers ... - } elseif ($timer->isPeriodic()) { - event_add( - $this->timerEvents[$timer], - $timer->getInterval() * self::MICROSECONDS_PER_SECOND - ); - - // Clean-up one shot timers ... - } else { - $this->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() - { - $this->streamCallback = function ($stream, $flags) { - $key = (int) $stream; - - if (EV_READ === (EV_READ & $flags) && isset($this->readListeners[$key])) { - call_user_func($this->readListeners[$key], $stream, $this); - } - - if (EV_WRITE === (EV_WRITE & $flags) && isset($this->writeListeners[$key])) { - call_user_func($this->writeListeners[$key], $stream, $this); - } - }; - } -} diff --git a/src/Loop.php b/src/Loop.php new file mode 100644 index 00000000..732c5d5e --- /dev/null +++ b/src/Loop.php @@ -0,0 +1,256 @@ +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 d046526c..9266f718 100644 --- a/src/LoopInterface.php +++ b/src/LoopInterface.php @@ -2,29 +2,122 @@ namespace React\EventLoop; -use React\EventLoop\Timer\TimerInterface; - interface LoopInterface { /** - * Register a listener to be notified when a stream is ready to read. + * [Advanced] Register a listener to be notified when a stream is ready to read. + * + * Note that this low-level API is considered advanced usage. + * Most use cases should probably use the higher-level + * [readable Stream API](https://github.com/reactphp/stream#readablestreaminterface) + * instead. + * + * The first parameter MUST be a valid stream resource that supports + * checking whether it is ready to read by this loop implementation. + * A single stream resource MUST NOT be added more than once. + * Instead, either call [`removeReadStream()`](#removereadstream) first or + * 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 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 + * no effect, so for performance reasons you're recommended to not return + * any excessive data structures. + * + * If you want to access any variables within your callback function, you + * can bind arbitrary data to a callback closure like this: + * + * ```php + * $loop->addReadStream($stream, function ($stream) use ($name) { + * echo $name . ' said: ' . fread($stream); + * }); + * ``` + * + * See also [example #11](examples). + * + * You can invoke [`removeReadStream()`](#removereadstream) to remove the + * read event listener for this stream. + * + * The execution order of listeners when multiple streams become ready at + * the same time is not guaranteed. * * @param resource $stream The PHP stream resource to check. * @param callable $listener Invoked when the stream is ready. + * @throws \Exception if the given resource type is not supported by this loop implementation + * @see self::removeReadStream() */ - public function addReadStream($stream, callable $listener); + public function addReadStream($stream, $listener); /** - * Register a listener to be notified when a stream is ready to write. + * [Advanced] Register a listener to be notified when a stream is ready to write. + * + * Note that this low-level API is considered advanced usage. + * Most use cases should probably use the higher-level + * [writable Stream API](https://github.com/reactphp/stream#writablestreaminterface) + * instead. + * + * The first parameter MUST be a valid stream resource that supports + * checking whether it is ready to write by this loop implementation. + * A single stream resource MUST NOT be added more than once. + * Instead, either call [`removeWriteStream()`](#removewritestream) first or + * 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 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 + * no effect, so for performance reasons you're recommended to not return + * any excessive data structures. + * + * If you want to access any variables within your callback function, you + * can bind arbitrary data to a callback closure like this: + * + * ```php + * $loop->addWriteStream($stream, function ($stream) use ($name) { + * fwrite($stream, 'Hello ' . $name); + * }); + * ``` + * + * See also [example #12](examples). + * + * You can invoke [`removeWriteStream()`](#removewritestream) to remove the + * write event listener for this stream. + * + * The execution order of listeners when multiple streams become ready at + * the same time is not guaranteed. + * + * Some event loop implementations are known to only trigger the listener 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. * * @param resource $stream The PHP stream resource to check. * @param callable $listener Invoked when the stream is ready. + * @throws \Exception if the given resource type is not supported by this loop implementation + * @see self::removeWriteStream() */ - public function addWriteStream($stream, callable $listener); + public function addWriteStream($stream, $listener); /** * Remove the read event listener for the given stream. * + * Removing a stream from the loop that has already been removed or trying + * to remove a stream that was never added or is invalid has no effect. + * * @param resource $stream The PHP stream resource. */ public function removeReadStream($stream); @@ -32,90 +125,348 @@ public function removeReadStream($stream); /** * Remove the write event listener for the given stream. * - * @param resource $stream The PHP stream resource. - */ - public function removeWriteStream($stream); - - /** - * Remove all listeners for the given stream. + * Removing a stream from the loop that has already been removed or trying + * to remove a stream that was never added or is invalid has no effect. * * @param resource $stream The PHP stream resource. */ - public function removeStream($stream); + public function removeWriteStream($stream); /** * Enqueue a callback to be invoked once after the given interval. * - * The execution order of timers scheduled to execute at the same time is - * not guaranteed. + * 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. + * + * ```php + * $loop->addTimer(0.8, function () { + * echo 'world!' . PHP_EOL; + * }); + * + * $loop->addTimer(0.3, function () { + * echo 'hello '; + * }); + * ``` + * + * See also [example #1](examples). + * + * If you want to access any variables within your callback function, you + * can bind arbitrary data to a callback closure like this: + * + * ```php + * function hello($name, LoopInterface $loop) + * { + * $loop->addTimer(1.0, function () use ($name) { + * echo "hello $name\n"; + * }); + * } + * + * hello('Tester', $loop); + * ``` + * + * This interface does not enforce any particular timer resolution, so + * special care may have to be taken if you rely on very high precision with + * millisecond accuracy or below. Event loop implementations SHOULD work on + * a best effort basis and SHOULD provide at least millisecond accuracy + * unless otherwise noted. Many existing event loop implementations are + * known to provide microsecond accuracy, but it's generally not recommended + * to rely on this high precision. + * + * Similarly, the execution order of timers scheduled to execute at the + * 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 + * 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). + * This means that if you schedule a timer to trigger in 30s and then adjust + * your system time forward by 20s, the timer SHOULD still trigger in 30s. + * See also [event loop implementations](#loop-implementations) for more details. * * @param int|float $interval The number of seconds to wait before execution. * @param callable $callback The callback to invoke. * * @return TimerInterface */ - public function addTimer($interval, callable $callback); + public function addTimer($interval, $callback); /** * Enqueue a callback to be invoked repeatedly after the given interval. * - * The execution order of timers scheduled to execute at the same time is - * not guaranteed. + * 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. + * 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 () { + * echo 'tick!' . PHP_EOL; + * }); + * + * $loop->addTimer(1.0, function () use ($loop, $timer) { + * $loop->cancelTimer($timer); + * echo 'Done' . PHP_EOL; + * }); + * ``` + * + * See also [example #2](examples). + * + * If you want to limit the number of executions, you can bind + * arbitrary data to a callback closure like this: + * + * ```php + * function hello($name, LoopInterface $loop) + * { + * $n = 3; + * $loop->addPeriodicTimer(1.0, function ($timer) use ($name, $loop, &$n) { + * if ($n > 0) { + * --$n; + * echo "hello $name\n"; + * } else { + * $loop->cancelTimer($timer); + * } + * }); + * } + * + * hello('Tester', $loop); + * ``` + * + * This interface does not enforce any particular timer resolution, so + * special care may have to be taken if you rely on very high precision with + * millisecond accuracy or below. Event loop implementations SHOULD work on + * a best effort basis and SHOULD provide at least millisecond accuracy + * unless otherwise noted. Many existing event loop implementations are + * known to provide microsecond accuracy, but it's generally not recommended + * to rely on this high precision. + * + * Similarly, the execution order of timers scheduled to execute at the + * 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 + * 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). + * This means that if you schedule a timer to trigger in 30s and then adjust + * your system time forward by 20s, the timer SHOULD still trigger in 30s. + * See also [event loop implementations](#loop-implementations) for more details. + * + * Additionally, periodic timers may be subject to timer drift due to + * re-scheduling after each invocation. As such, it's generally not + * recommended to rely on this for high precision intervals with millisecond + * accuracy or below. * * @param int|float $interval The number of seconds to wait before execution. * @param callable $callback The callback to invoke. * * @return TimerInterface */ - public function addPeriodicTimer($interval, callable $callback); + public function addPeriodicTimer($interval, $callback); /** * Cancel a pending timer. * + * See also [`addPeriodicTimer()`](#addperiodictimer) and [example #2](examples). + * + * Calling this method on a timer instance that has not been added to this + * loop instance or on a timer that has already been cancelled has no effect. + * * @param TimerInterface $timer The timer to cancel. + * + * @return void */ public function cancelTimer(TimerInterface $timer); /** - * Check if a given timer is active. + * Schedule a callback to be invoked on a future tick of the event loop. * - * @param TimerInterface $timer The timer to check. + * This works very much similar to timers with an interval of zero seconds, + * but does not require the overhead of scheduling a timer queue. * - * @return boolean True if the timer is still enqueued for execution. - */ - public function isTimerActive(TimerInterface $timer); - - /** - * Schedule a callback to be invoked on the next tick of the event loop. + * The tick callback function MUST be able to accept zero parameters. * - * Callbacks are guaranteed to be executed in the order they are enqueued, - * before any timer or stream events. + * The tick callback function MUST NOT throw an `Exception`. + * The return value of the tick callback function will be ignored and has + * no effect, so for performance reasons you're recommended to not return + * any excessive data structures. + * + * If you want to access any variables within your callback function, you + * can bind arbitrary data to a callback closure like this: + * + * ```php + * function hello($name, LoopInterface $loop) + * { + * $loop->futureTick(function () use ($name) { + * echo "hello $name\n"; + * }); + * } + * + * hello('Tester', $loop); + * ``` + * + * Unlike timers, tick callbacks are guaranteed to be executed in the order + * they are enqueued. + * Also, once a callback is enqueued, there's no way to cancel this operation. + * + * This is often used to break down bigger tasks into smaller steps (a form + * of cooperative multitasking). + * + * ```php + * $loop->futureTick(function () { + * echo 'b'; + * }); + * $loop->futureTick(function () { + * echo 'c'; + * }); + * echo 'a'; + * ``` + * + * See also [example #3](examples). * * @param callable $listener The callback to invoke. + * + * @return void */ - public function nextTick(callable $listener); + public function futureTick($listener); /** - * Schedule a callback to be invoked on a future tick of the event loop. + * Register a listener to be notified when a signal has been caught by this process. * - * Callbacks are guaranteed to be executed in the order they are enqueued. + * This is useful to catch user interrupt signals or shutdown signals from + * tools like `supervisor` or `systemd`. * - * @param callable $listener The callback to invoke. + * 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 + * no effect, so for performance reasons you're recommended to not return + * any excessive data structures. + * + * ```php + * $loop->addSignal(SIGINT, function (int $signal) { + * echo 'Caught user interrupt signal' . PHP_EOL; + * }); + * ``` + * + * See also [example #4](examples). + * + * 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 than once will be ignored.** + * + * @param int $signal + * @param callable $listener + * + * @throws \BadMethodCallException when signals aren't supported on this + * platform, for example when required extensions are missing. + * + * @return void */ - public function futureTick(callable $listener); + public function addSignal($signal, $listener); /** - * Perform a single iteration of the event loop. + * Removes a previously added signal listener. + * + * ```php + * $loop->removeSignal(SIGINT, $listener); + * ``` + * + * Any attempts to remove listeners that aren't registered will be ignored. + * + * @param int $signal + * @param callable $listener + * + * @return void */ - public function tick(); + public function removeSignal($signal, $listener); /** * 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 usually recommended to attach everything to the + * same loop instance and then run the loop once at the bottom end of the + * application. + * + * ```php + * $loop->run(); + * ``` + * + * This method will keep the loop running until there are no more tasks + * to perform. In other words: This method will block until the last + * timer, stream and/or signal has been removed. + * + * Likewise, it is imperative to ensure the application actually invokes + * this method once. Adding listeners to the loop and missing to actually + * 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 explicitly been + * [`stop()`ped](#stop) or after it automatically stopped because it + * previously did no longer have anything to do. + * + * @return void */ public function run(); /** * Instruct a running event loop to stop. + * + * This method is considered advanced usage and should be used with care. + * As a rule of thumb, it is usually recommended to let the loop stop + * only automatically when it no longer has anything to do. + * + * This method can be used to explicitly instruct the event loop to stop: + * + * ```php + * $loop->addTimer(3.0, function () use ($loop) { + * $loop->stop(); + * }); + * ``` + * + * Calling this method on a loop instance that is not currently running or + * on a loop instance that has already been stopped has no effect. + * + * @return void */ public function stop(); } diff --git a/src/SignalsHandler.php b/src/SignalsHandler.php new file mode 100644 index 00000000..e9b245ea --- /dev/null +++ b/src/SignalsHandler.php @@ -0,0 +1,63 @@ +signals[$signal])) { + $this->signals[$signal] = []; + } + + if (\in_array($listener, $this->signals[$signal])) { + return; + } + + $this->signals[$signal][] = $listener; + } + + public function remove($signal, $listener) + { + if (!isset($this->signals[$signal])) { + return; + } + + $index = \array_search($listener, $this->signals[$signal], true); + unset($this->signals[$signal][$index]); + + if (isset($this->signals[$signal]) && \count($this->signals[$signal]) === 0) { + unset($this->signals[$signal]); + } + } + + public function call($signal) + { + if (!isset($this->signals[$signal])) { + return; + } + + foreach ($this->signals[$signal] as $listener) { + \call_user_func($listener, $signal); + } + } + + public function count($signal) + { + if (!isset($this->signals[$signal])) { + return 0; + } + + return \count($this->signals[$signal]); + } + + public function isEmpty() + { + return !$this->signals; + } +} diff --git a/src/StreamSelectLoop.php b/src/StreamSelectLoop.php index e51a27f8..41dd2cb3 100644 --- a/src/StreamSelectLoop.php +++ b/src/StreamSelectLoop.php @@ -3,19 +3,57 @@ namespace React\EventLoop; use React\EventLoop\Tick\FutureTickQueue; -use React\EventLoop\Tick\NextTickQueue; use React\EventLoop\Timer\Timer; -use React\EventLoop\Timer\TimerInterface; use React\EventLoop\Timer\Timers; /** - * A stream_select() based event-loop. + * A `stream_select()` based event loop. + * + * 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 any PHP version. + * This means that no installation is required and this library works on all + * platforms and supported PHP versions. + * 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. + * This system call is limited to the maximum file descriptor number of + * `FD_SETSIZE` (platform dependent, commonly 1024) and scales with `O(m)` + * (`m` being the maximum file descriptor number passed). + * This means that you may run into issues when handling thousands of streams + * concurrently and you may want to look into using one of the alternative + * event loop implementations listed below in this case. + * If your use case is among the many common use cases that involve handling only + * dozens or a few hundred streams at once, then this event loop implementation + * performs really well. + * + * If you want to use signal handling (see also [`addSignal()`](#addsignal) below), + * this event loop implementation requires `ext-pcntl`. + * This extension is only available for Unix-like platforms and does not support + * Windows. + * 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 + * 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 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 https://www.php.net/manual/en/function.stream-select.php */ -class StreamSelectLoop implements LoopInterface +final class StreamSelectLoop implements LoopInterface { + /** @internal */ const MICROSECONDS_PER_SECOND = 1000000; - private $nextTickQueue; private $futureTickQueue; private $timers; private $readStreams = []; @@ -23,18 +61,25 @@ class StreamSelectLoop implements LoopInterface private $writeStreams = []; private $writeListeners = []; private $running; + private $pcntl = false; + private $pcntlPoll = false; + private $signals; public function __construct() { - $this->nextTickQueue = new NextTickQueue($this); - $this->futureTickQueue = new FutureTickQueue($this); + $this->futureTickQueue = new FutureTickQueue(); $this->timers = new Timers(); + $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); + } } - /** - * {@inheritdoc} - */ - public function addReadStream($stream, callable $listener) + public function addReadStream($stream, $listener) { $key = (int) $stream; @@ -44,10 +89,7 @@ public function addReadStream($stream, callable $listener) } } - /** - * {@inheritdoc} - */ - public function addWriteStream($stream, callable $listener) + public function addWriteStream($stream, $listener) { $key = (int) $stream; @@ -57,9 +99,6 @@ public function addWriteStream($stream, callable $listener) } } - /** - * {@inheritdoc} - */ public function removeReadStream($stream) { $key = (int) $stream; @@ -70,9 +109,6 @@ public function removeReadStream($stream) ); } - /** - * {@inheritdoc} - */ public function removeWriteStream($stream) { $key = (int) $stream; @@ -83,101 +119,72 @@ public function removeWriteStream($stream) ); } - /** - * {@inheritdoc} - */ - public function removeStream($stream) + public function addTimer($interval, $callback) { - $this->removeReadStream($stream); - $this->removeWriteStream($stream); - } - - /** - * {@inheritdoc} - */ - public function addTimer($interval, callable $callback) - { - $timer = new Timer($this, $interval, $callback, false); + $timer = new Timer($interval, $callback, false); $this->timers->add($timer); return $timer; } - /** - * {@inheritdoc} - */ - public function addPeriodicTimer($interval, callable $callback) + public function addPeriodicTimer($interval, $callback) { - $timer = new Timer($this, $interval, $callback, true); + $timer = new Timer($interval, $callback, true); $this->timers->add($timer); return $timer; } - /** - * {@inheritdoc} - */ public function cancelTimer(TimerInterface $timer) { $this->timers->cancel($timer); } - /** - * {@inheritdoc} - */ - public function isTimerActive(TimerInterface $timer) + public function futureTick($listener) { - return $this->timers->contains($timer); + $this->futureTickQueue->add($listener); } - /** - * {@inheritdoc} - */ - public function nextTick(callable $listener) + public function addSignal($signal, $listener) { - $this->nextTickQueue->add($listener); - } + if ($this->pcntl === false) { + throw new \BadMethodCallException('Event loop feature "signals" isn\'t supported by the "StreamSelectLoop"'); + } - /** - * {@inheritdoc} - */ - public function futureTick(callable $listener) - { - $this->futureTickQueue->add($listener); + $first = $this->signals->count($signal) === 0; + $this->signals->add($signal, $listener); + + if ($first) { + \pcntl_signal($signal, [$this->signals, 'call']); + } } - /** - * {@inheritdoc} - */ - public function tick() + public function removeSignal($signal, $listener) { - $this->nextTickQueue->tick(); - - $this->futureTickQueue->tick(); + if (!$this->signals->count($signal)) { + return; + } - $this->timers->tick(); + $this->signals->remove($signal, $listener); - $this->waitForStreamActivity(0); + if ($this->signals->count($signal) === 0) { + \pcntl_signal($signal, \SIG_DFL); + } } - /** - * {@inheritdoc} - */ public function run() { $this->running = true; while ($this->running) { - $this->nextTickQueue->tick(); - $this->futureTickQueue->tick(); $this->timers->tick(); - // Next-tick or future-tick queues have pending callbacks ... - if (!$this->running || !$this->nextTickQueue->isEmpty() || !$this->futureTickQueue->isEmpty()) { + // Future-tick queue has pending callbacks ... + if (!$this->running || !$this->futureTickQueue->isEmpty()) { $timeout = 0; // There is a pending timer, only block until it is due ... @@ -186,11 +193,15 @@ public function run() if ($timeout < 0) { $timeout = 0; } else { + // Convert float seconds to int microseconds. + // Ensure we do not exceed maximum integer size, which may + // cause the loop to tick once every ~35min on 32bit systems. $timeout *= self::MICROSECONDS_PER_SECOND; + $timeout = $timeout > \PHP_INT_MAX ? \PHP_INT_MAX : (int)$timeout; } - // The only possible event is stream activity, so wait forever ... - } elseif ($this->readStreams || $this->writeStreams) { + // The only possible event is stream or signal activity, so wait forever ... + } elseif ($this->readStreams || $this->writeStreams || !$this->signals->isEmpty()) { $timeout = null; // There's nothing left to do ... @@ -202,9 +213,6 @@ public function run() } } - /** - * {@inheritdoc} - */ public function stop() { $this->running = false; @@ -212,6 +220,8 @@ public function stop() /** * Wait/check for stream activity, or until the next timer is due. + * + * @param integer|null $timeout Activity timeout in microseconds, or null to wait forever. */ private function waitForStreamActivity($timeout) { @@ -219,6 +229,9 @@ private function waitForStreamActivity($timeout) $write = $this->writeStreams; $available = $this->streamSelect($read, $write, $timeout); + if ($this->pcntlPoll) { + \pcntl_signal_dispatch(); + } if (false === $available) { // if a system call has been interrupted, // we cannot rely on it's outcome @@ -229,7 +242,7 @@ private function waitForStreamActivity($timeout) $key = (int) $stream; if (isset($this->readListeners[$key])) { - call_user_func($this->readListeners[$key], $stream, $this); + \call_user_func($this->readListeners[$key], $stream); } } @@ -237,7 +250,7 @@ private function waitForStreamActivity($timeout) $key = (int) $stream; if (isset($this->writeListeners[$key])) { - call_user_func($this->writeListeners[$key], $stream, $this); + \call_user_func($this->writeListeners[$key], $stream); } } } @@ -246,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. */ - protected function streamSelect(array &$read, array &$write, $timeout) + 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; + } + } + } - // suppress warnings that occur, when stream_select is interrupted by a signal - return @stream_select($read, $write, $except, $timeout === null ? null : 0, $timeout); + /** @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; + } + + 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/Tick/FutureTickQueue.php b/src/Tick/FutureTickQueue.php index eeffd363..efabcbc5 100644 --- a/src/Tick/FutureTickQueue.php +++ b/src/Tick/FutureTickQueue.php @@ -2,20 +2,22 @@ namespace React\EventLoop\Tick; -use React\EventLoop\LoopInterface; use SplQueue; -class FutureTickQueue +/** + * A tick queue implementation that can hold multiple callback functions + * + * This class should only be used internally, see LoopInterface instead. + * + * @see LoopInterface + * @internal + */ +final class FutureTickQueue { - private $eventLoop; private $queue; - /** - * @param LoopInterface $eventLoop The event loop passed as the first parameter to callbacks. - */ - public function __construct(LoopInterface $eventLoop) + public function __construct() { - $this->eventLoop = $eventLoop; $this->queue = new SplQueue(); } @@ -26,7 +28,7 @@ public function __construct(LoopInterface $eventLoop) * * @param callable $listener The callback to invoke. */ - public function add(callable $listener) + public function add($listener) { $this->queue->enqueue($listener); } @@ -40,9 +42,8 @@ public function tick() $count = $this->queue->count(); while ($count--) { - call_user_func( - $this->queue->dequeue(), - $this->eventLoop + \call_user_func( + $this->queue->dequeue() ); } } diff --git a/src/Tick/NextTickQueue.php b/src/Tick/NextTickQueue.php deleted file mode 100644 index 5b8e1de8..00000000 --- a/src/Tick/NextTickQueue.php +++ /dev/null @@ -1,57 +0,0 @@ -eventLoop = $eventLoop; - $this->queue = new SplQueue(); - } - - /** - * Add a callback to be invoked on the next tick of the event loop. - * - * Callbacks are guaranteed to be executed in the order they are enqueued, - * before any timer or stream events. - * - * @param callable $listener The callback to invoke. - */ - public function add(callable $listener) - { - $this->queue->enqueue($listener); - } - - /** - * Flush the callback queue. - */ - public function tick() - { - while (!$this->queue->isEmpty()) { - call_user_func( - $this->queue->dequeue(), - $this->eventLoop - ); - } - } - - /** - * Check if the next tick queue is empty. - * - * @return boolean - */ - public function isEmpty() - { - return $this->queue->isEmpty(); - } -} diff --git a/src/Timer/Timer.php b/src/Timer/Timer.php index f670ab3c..da3602a3 100644 --- a/src/Timer/Timer.php +++ b/src/Timer/Timer.php @@ -2,101 +2,54 @@ namespace React\EventLoop\Timer; -use React\EventLoop\LoopInterface; - -class Timer implements TimerInterface +use React\EventLoop\TimerInterface; + +/** + * The actual connection implementation for TimerInterface + * + * This class should only be used internally, see TimerInterface instead. + * + * @see TimerInterface + * @internal + */ +final class Timer implements TimerInterface { const MIN_INTERVAL = 0.000001; - protected $loop; - protected $interval; - protected $callback; - protected $periodic; - protected $data; + private $interval; + private $callback; + private $periodic; /** * Constructor initializes the fields of the Timer * - * @param LoopInterface $loop The loop with which this timer is associated * @param float $interval The interval after which this timer will execute, in seconds * @param callable $callback The callback that will be executed when this timer elapses * @param bool $periodic Whether the time is periodic - * @param mixed $data Arbitrary data associated with timer */ - public function __construct(LoopInterface $loop, $interval, callable $callback, $periodic = false, $data = null) + public function __construct($interval, $callback, $periodic = false) { if ($interval < self::MIN_INTERVAL) { $interval = self::MIN_INTERVAL; } - $this->loop = $loop; $this->interval = (float) $interval; $this->callback = $callback; $this->periodic = (bool) $periodic; - $this->data = null; - } - - /** - * {@inheritdoc} - */ - public function getLoop() - { - return $this->loop; } - /** - * {@inheritdoc} - */ public function getInterval() { return $this->interval; } - /** - * {@inheritdoc} - */ public function getCallback() { return $this->callback; } - /** - * {@inheritdoc} - */ - public function setData($data) - { - $this->data = $data; - } - - /** - * {@inheritdoc} - */ - public function getData() - { - return $this->data; - } - - /** - * {@inheritdoc} - */ public function isPeriodic() { return $this->periodic; } - - /** - * {@inheritdoc} - */ - public function isActive() - { - return $this->loop->isTimerActive($this); - } - - /** - * {@inheritdoc} - */ - public function cancel() - { - $this->loop->cancelTimer($this); - } } diff --git a/src/Timer/TimerInterface.php b/src/Timer/TimerInterface.php deleted file mode 100644 index d066f369..00000000 --- a/src/Timer/TimerInterface.php +++ /dev/null @@ -1,62 +0,0 @@ -timers = new SplObjectStorage(); - $this->scheduler = new SplPriorityQueue(); + // 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() @@ -29,71 +38,75 @@ public function getTime() public function add(TimerInterface $timer) { - $interval = $timer->getInterval(); - $scheduledAt = $interval + microtime(true); - - $this->timers->attach($timer, $scheduledAt); - $this->scheduler->insert($timer, -$scheduledAt); + $id = \PHP_VERSION_ID < 70200 ? \spl_object_hash($timer) : \spl_object_id($timer); + $this->timers[$id] = $timer; + $this->schedule[$id] = $timer->getInterval() + $this->updateTime(); + $this->sorted = false; } public function contains(TimerInterface $timer) { - return $this->timers->contains($timer); + $id = \PHP_VERSION_ID < 70200 ? \spl_object_hash($timer) : \spl_object_id($timer); + return isset($this->timers[$id]); } public function cancel(TimerInterface $timer) { - $this->timers->detach($timer); + $id = \PHP_VERSION_ID < 70200 ? \spl_object_hash($timer) : \spl_object_id($timer); + unset($this->timers[$id], $this->schedule[$id]); } public function getFirst() { - while ($this->scheduler->count()) { - $timer = $this->scheduler->top(); - - if ($this->timers->contains($timer)) { - return $this->timers[$timer]; - } - - $this->scheduler->extract(); + // ensure timers are sorted to simply accessing next (first) one + if (!$this->sorted) { + $this->sorted = true; + \asort($this->schedule); } - return null; + return \reset($this->schedule); } public function isEmpty() { - return count($this->timers) === 0; + return \count($this->timers) === 0; } public function tick() { - $time = $this->updateTime(); - $timers = $this->timers; - $scheduler = $this->scheduler; + // hot path: skip timers if nothing is scheduled + if (!$this->schedule) { + return; + } - while (!$scheduler->isEmpty()) { - $timer = $scheduler->top(); + // ensure timers are sorted so we can execute in order + if (!$this->sorted) { + $this->sorted = true; + \asort($this->schedule); + } - if (!isset($timers[$timer])) { - $scheduler->extract(); - $timers->detach($timer); + $time = $this->updateTime(); - continue; + foreach ($this->schedule as $id => $scheduled) { + // schedule is ordered, so loop until first timer that is not scheduled for execution now + if ($scheduled >= $time) { + break; } - if ($timers[$timer] >= $time) { - break; + // skip any timers that are removed while we process the current schedule + if (!isset($this->schedule[$id]) || $this->schedule[$id] !== $scheduled) { + continue; } - $scheduler->extract(); - call_user_func($timer->getCallback(), $timer); + $timer = $this->timers[$id]; + \call_user_func($timer->getCallback(), $timer); - if ($timer->isPeriodic() && isset($timers[$timer])) { - $timers[$timer] = $scheduledAt = $timer->getInterval() + $time; - $scheduler->insert($timer, -$scheduledAt); + // re-schedule if this is a periodic timer and it has not been cancelled explicitly already + if ($timer->isPeriodic() && isset($this->timers[$id])) { + $this->schedule[$id] = $timer->getInterval() + $time; + $this->sorted = false; } else { - $timers->detach($timer); + unset($this->timers[$id], $this->schedule[$id]); } } } diff --git a/src/TimerInterface.php b/src/TimerInterface.php new file mode 100644 index 00000000..cdcf7732 --- /dev/null +++ b/src/TimerInterface.php @@ -0,0 +1,27 @@ +tickTimeout = defined('HHVM_VERSION') ? 0.02 : 0.005; + // It's a timeout, don't set it too low. Travis and other CI systems are slow. + $this->tickTimeout = 0.02; $this->loop = $this->createLoop(); } abstract public function createLoop(); - public function createStream() + public function createSocketPair() + { + $domain = (DIRECTORY_SEPARATOR === '\\') ? STREAM_PF_INET : STREAM_PF_UNIX; + $sockets = stream_socket_pair($domain, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP); + + foreach ($sockets as $socket) { + if (function_exists('stream_set_read_buffer')) { + stream_set_read_buffer($socket, 0); + } + } + + 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() { - return fopen('php://temp', 'r+'); + $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 writeToStream($stream, $content) + public function testAddWriteStreamTriggersWhenSocketConnectionRefused() { - fwrite($stream, $content); - rewind($stream); + // 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() { - $input = $this->createStream(); + 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)); + + fwrite($output, "foo\n"); + $this->tickLoop($this->loop); + + fwrite($output, "bar\n"); + $this->tickLoop($this->loop); + } + + 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)); + $this->loop->addReadStream($input, $this->expectCallableNever()); + + fwrite($output, "foo\n"); + $this->tickLoop($this->loop); + + fwrite($output, "bar\n"); + $this->tickLoop($this->loop); + } + + public function testAddReadStreamReceivesDataFromStreamReference() + { + $this->received = ''; + $this->subAddReadStreamReceivesDataFromStreamReference(); + $this->assertEquals('', $this->received); - $this->writeToStream($input, "foo\n"); - $this->loop->tick(); + $this->assertRunFasterThan($this->tickTimeout * 2); + $this->assertEquals('[hello]X', $this->received); + } - $this->writeToStream($input, "bar\n"); - $this->loop->tick(); + /** + * Helper for above test. This happens in another helper method to verify + * the loop keeps track of assigned stream resources (refcount). + */ + private function subAddReadStreamReceivesDataFromStreamReference() + { + list ($input, $output) = $this->createSocketPair(); + + fwrite($input, 'hello'); + fclose($input); + + $this->loop->addReadStream($output, function ($output) { + $chunk = fread($output, 1024); + if ($chunk === '') { + $this->received .= 'X'; + $this->loop->removeReadStream($output); + fclose($output); + } else { + $this->received .= '[' . $chunk . ']'; + } + }); } public function testAddWriteStream() { - $input = $this->createStream(); + 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)); - $this->loop->tick(); - $this->loop->tick(); + $this->tickLoop($this->loop); + $this->tickLoop($this->loop); + } + + 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)); + $this->loop->addWriteStream($input, $this->expectCallableNever()); + $this->tickLoop($this->loop); + $this->tickLoop($this->loop); } public function testRemoveReadStreamInstantly() { - $input = $this->createStream(); + 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()); $this->loop->removeReadStream($input); - $this->writeToStream($input, "bar\n"); - $this->loop->tick(); + fwrite($output, "bar\n"); + $this->tickLoop($this->loop); } public function testRemoveReadStreamAfterReading() { - $input = $this->createStream(); + 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()); - $this->writeToStream($input, "foo\n"); - $this->loop->tick(); + fwrite($output, "foo\n"); + $this->tickLoop($this->loop); $this->loop->removeReadStream($input); - $this->writeToStream($input, "bar\n"); - $this->loop->tick(); + fwrite($output, "bar\n"); + $this->tickLoop($this->loop); } public function testRemoveWriteStreamInstantly() { - $input = $this->createStream(); + 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()); $this->loop->removeWriteStream($input); - $this->loop->tick(); + $this->tickLoop($this->loop); } public function testRemoveWriteStreamAfterWriting() { - $input = $this->createStream(); + 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()); - $this->loop->tick(); + $this->tickLoop($this->loop); $this->loop->removeWriteStream($input); - $this->loop->tick(); + $this->tickLoop($this->loop); } - public function testRemoveStreamInstantly() + public function testRemoveStreamForReadOnly() { - $input = $this->createStream(); + 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()); - $this->loop->addWriteStream($input, $this->expectCallableNever()); - $this->loop->removeStream($input); + $this->loop->addWriteStream($output, $this->expectCallableOnce()); + $this->loop->removeReadStream($input); - $this->writeToStream($input, "bar\n"); - $this->loop->tick(); + fwrite($output, "foo\n"); + $this->tickLoop($this->loop); } - public function testRemoveStreamForReadOnly() + public function testRemoveStreamForWriteOnly() { - $input = $this->createStream(); + if ($this->loop instanceof ExtUvLoop && DIRECTORY_SEPARATOR === '\\') { + $this->markTestIncomplete('Ticking ExtUvLoop not supported on Windows'); + } - $this->loop->addReadStream($input, $this->expectCallableNever()); - $this->loop->addWriteStream($input, $this->expectCallableOnce()); - $this->loop->removeReadStream($input); + list ($input, $output) = $this->createSocketPair(); + + fwrite($output, "foo\n"); - $this->writeToStream($input, "foo\n"); - $this->loop->tick(); + $this->loop->addReadStream($input, $this->expectCallableOnce()); + $this->loop->addWriteStream($output, $this->expectCallableNever()); + $this->loop->removeWriteStream($output); + + $this->tickLoop($this->loop); } - public function testRemoveStreamForWriteOnly() + public function testRemoveReadAndWriteStreamFromLoopOnceResourceClosesEndsLoop() { - $input = $this->createStream(); + list($stream, $other) = $this->createSocketPair(); + stream_set_blocking($stream, false); + stream_set_blocking($other, false); - $this->writeToStream($input, "foo\n"); + // dummy writable handler + $this->loop->addWriteStream($stream, function () { }); - $this->loop->addReadStream($input, $this->expectCallableOnce()); - $this->loop->addWriteStream($input, $this->expectCallableNever()); - $this->loop->removeWriteStream($input); + // remove stream when the stream is readable (closes) + $this->loop->addReadStream($stream, function ($stream) { + $this->loop->removeReadStream($stream); + $this->loop->removeWriteStream($stream); + fclose($stream); + }); - $this->loop->tick(); + // close other side + fclose($other); + + $this->assertRunFasterThan($this->tickTimeout); } - public function testRemoveStream() + public function testRemoveReadAndWriteStreamFromLoopOnceResourceClosesOnEndOfFileEndsLoop() { - $input = $this->createStream(); + list($stream, $other) = $this->createSocketPair(); + stream_set_blocking($stream, false); + stream_set_blocking($other, false); - $this->loop->addReadStream($input, $this->expectCallableOnce()); - $this->loop->addWriteStream($input, $this->expectCallableOnce()); + // dummy writable handler + $this->loop->addWriteStream($stream, function () { }); + + // remove stream when the stream is readable (closes) + $this->loop->addReadStream($stream, function ($stream) { + $data = fread($stream, 1024); + if ($data !== '') { + return; + } - $this->writeToStream($input, "bar\n"); - $this->loop->tick(); + $this->loop->removeReadStream($stream); + $this->loop->removeWriteStream($stream); + fclose($stream); + }); + + // send data and close stream + fwrite($other, str_repeat('.', static::PHP_DEFAULT_CHUNK_SIZE)); + $this->loop->addTimer(0.01, function () use ($other) { + fclose($other); + }); + + $this->assertRunFasterThan(0.1); + } - $this->loop->removeStream($input); + public function testRemoveReadAndWriteStreamFromLoopWithClosingResourceEndsLoop() + { + // get only one part of the pair to ensure the other side will close immediately + list($stream) = $this->createSocketPair(); + stream_set_blocking($stream, false); + + // dummy writable handler + $this->loop->addWriteStream($stream, function () { }); + + // remove stream when the stream is readable (closes) + $this->loop->addReadStream($stream, function ($stream) { + $this->loop->removeReadStream($stream); + $this->loop->removeWriteStream($stream); + fclose($stream); + }); - $this->writeToStream($input, "bar\n"); - $this->loop->tick(); + $this->assertRunFasterThan($this->tickTimeout); } public function testRemoveInvalid() { - $stream = $this->createStream(); + list ($stream) = $this->createSocketPair(); // remove a valid stream from the event loop that was never added in the first place $this->loop->removeReadStream($stream); $this->loop->removeWriteStream($stream); - $this->loop->removeStream($stream); + + $this->assertTrue(true); } /** @test */ @@ -171,14 +422,13 @@ public function emptyRunShouldSimplyReturn() /** @test */ public function runShouldReturnWhenNoMoreFds() { - $input = $this->createStream(); + list ($input, $output) = $this->createSocketPair(); - $loop = $this->loop; - $this->loop->addReadStream($input, function ($stream) use ($loop) { - $loop->removeStream($stream); + $this->loop->addReadStream($input, function ($stream) { + $this->loop->removeReadStream($stream); }); - $this->writeToStream($input, "foo\n"); + fwrite($output, "foo\n"); $this->assertRunFasterThan($this->tickTimeout * 2); } @@ -186,14 +436,13 @@ public function runShouldReturnWhenNoMoreFds() /** @test */ public function stopShouldStopRunningLoop() { - $input = $this->createStream(); + 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(); }); - $this->writeToStream($input, "foo\n"); + fwrite($output, "foo\n"); $this->assertRunFasterThan($this->tickTimeout * 2); } @@ -207,7 +456,7 @@ function () { } ); - $this->loop->nextTick( + $this->loop->futureTick( function () { $this->loop->stop(); } @@ -219,145 +468,46 @@ function () { public function testIgnoreRemovedCallback() { // two independent streams, both should be readable right away - $stream1 = $this->createStream(); - $stream2 = $this->createStream(); - - $loop = $this->loop; - $loop->addReadStream($stream1, function ($stream) use ($loop, $stream2) { - // stream1 is readable, remove stream2 as well => this will invalidate its callback - $loop->removeReadStream($stream); - $loop->removeReadStream($stream2); - }); - - // this callback would have to be called as well, but the first stream already removed us - $loop->addReadStream($stream2, $this->expectCallableNever()); - - $this->writeToStream($stream1, "foo\n"); - $this->writeToStream($stream2, "foo\n"); + list ($input1, $output1) = $this->createSocketPair(); + list ($input2, $output2) = $this->createSocketPair(); - $loop->run(); - } - - public function testNextTick() - { $called = false; + $this->loop->addReadStream($input1, function ($stream) use (&$called, $input2) { + // stream1 is readable, remove stream2 as well => this will invalidate its callback + $this->loop->removeReadStream($stream); + $this->loop->removeReadStream($input2); - $callback = function ($loop) use (&$called) { - $this->assertSame($this->loop, $loop); $called = true; - }; - - $this->loop->nextTick($callback); - - $this->assertFalse($called); - - $this->loop->tick(); - - $this->assertTrue($called); - } - - public function testNextTickFiresBeforeIO() - { - $stream = $this->createStream(); - - $this->loop->addWriteStream( - $stream, - function () { - echo 'stream' . PHP_EOL; - } - ); - - $this->loop->nextTick( - function () { - echo 'next-tick' . PHP_EOL; - } - ); - - $this->expectOutputString('next-tick' . PHP_EOL . 'stream' . PHP_EOL); - - $this->loop->tick(); - } - - public function testRecursiveNextTick() - { - $stream = $this->createStream(); - - $this->loop->addWriteStream( - $stream, - function () { - echo 'stream' . PHP_EOL; - } - ); - - $this->loop->nextTick( - function () { - $this->loop->nextTick( - function () { - echo 'next-tick' . PHP_EOL; - } - ); - } - ); - - $this->expectOutputString('next-tick' . PHP_EOL . 'stream' . PHP_EOL); - - $this->loop->tick(); - } - - public function testRunWaitsForNextTickEvents() - { - $stream = $this->createStream(); + }); - $this->loop->addWriteStream( - $stream, - function () use ($stream) { - $this->loop->removeStream($stream); - $this->loop->nextTick( - function () { - echo 'next-tick' . PHP_EOL; - } - ); + // this callback would have to be called as well, but the first stream already removed us + $this->loop->addReadStream($input2, function () use (&$called) { + if ($called) { + $this->fail('Callback 2 must not be called after callback 1 was called'); } - ); + }); - $this->expectOutputString('next-tick' . PHP_EOL); + fwrite($output1, "foo\n"); + fwrite($output2, "foo\n"); $this->loop->run(); - } - - public function testNextTickEventGeneratedByFutureTick() - { - $stream = $this->createStream(); - - $this->loop->futureTick( - function () { - $this->loop->nextTick( - function () { - echo 'next-tick' . PHP_EOL; - } - ); - } - ); - $this->expectOutputString('next-tick' . PHP_EOL); - - $this->loop->run(); + $this->assertTrue($called); } - public function testNextTickEventGeneratedByTimer() + public function testFutureTickEventGeneratedByFutureTick() { - $this->loop->addTimer( - 0.001, + $this->loop->futureTick( function () { - $this->loop->nextTick( + $this->loop->futureTick( function () { - echo 'next-tick' . PHP_EOL; + echo 'future-tick' . PHP_EOL; } ); } ); - $this->expectOutputString('next-tick' . PHP_EOL); + $this->expectOutputString('future-tick' . PHP_EOL); $this->loop->run(); } @@ -366,8 +516,7 @@ public function testFutureTick() { $called = false; - $callback = function ($loop) use (&$called) { - $this->assertSame($this->loop, $loop); + $callback = function () use (&$called) { $called = true; }; @@ -375,14 +524,18 @@ public function testFutureTick() $this->assertFalse($called); - $this->loop->tick(); + $this->tickLoop($this->loop); $this->assertTrue($called); } public function testFutureTickFiresBeforeIO() { - $stream = $this->createStream(); + if ($this->loop instanceof ExtUvLoop && DIRECTORY_SEPARATOR === '\\') { + $this->markTestIncomplete('Ticking ExtUvLoop not supported on Windows'); + } + + list ($stream) = $this->createSocketPair(); $this->loop->addWriteStream( $stream, @@ -399,12 +552,15 @@ function () { $this->expectOutputString('future-tick' . PHP_EOL . 'stream' . PHP_EOL); - $this->loop->tick(); + $this->tickLoop($this->loop); } + /** + * @depends testFutureTickFiresBeforeIO + */ public function testRecursiveFutureTick() { - $stream = $this->createStream(); + list ($stream) = $this->createSocketPair(); $this->loop->addWriteStream( $stream, @@ -432,12 +588,12 @@ function () { public function testRunWaitsForFutureTickEvents() { - $stream = $this->createStream(); + list ($stream) = $this->createSocketPair(); $this->loop->addWriteStream( $stream, function () use ($stream) { - $this->loop->removeStream($stream); + $this->loop->removeWriteStream($stream); $this->loop->futureTick( function () { echo 'future-tick' . PHP_EOL; @@ -451,11 +607,10 @@ function () { $this->loop->run(); } - public function testFutureTickEventGeneratedByNextTick() + public function testFutureTickEventGeneratedByTimer() { - $stream = $this->createStream(); - - $this->loop->nextTick( + $this->loop->addTimer( + 0.001, function () { $this->loop->futureTick( function () { @@ -470,22 +625,140 @@ function () { $this->loop->run(); } - public function testFutureTickEventGeneratedByTimer() + public function testRemoveSignalNotRegisteredIsNoOp() { - $this->loop->addTimer( - 0.001, - function () { - $this->loop->futureTick( - function () { - echo 'future-tick' . PHP_EOL; - } - ); - } - ); + $this->loop->removeSignal(2, function () { }); + $this->assertTrue(true); + } - $this->expectOutputString('future-tick' . PHP_EOL); + /** + * @requires extension pcntl + * @requires function posix_kill() + * @requires function posix_getpid() + */ + public function testSignal() + { + 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; + $calledShouldNot = true; + + $timer = $this->loop->addPeriodicTimer(1, function () {}); + + $this->loop->addSignal(SIGUSR2, $func2 = function () use (&$calledShouldNot) { + $calledShouldNot = false; + }); + + $this->loop->addSignal(SIGUSR1, $func1 = function () use (&$func1, &$func2, &$called, $timer) { + $called = true; + $this->loop->removeSignal(SIGUSR1, $func1); + $this->loop->removeSignal(SIGUSR2, $func2); + $this->loop->cancelTimer($timer); + }); + + $this->loop->futureTick(function () { + posix_kill(posix_getpid(), SIGUSR1); + }); + + $this->loop->run(); + + $this->assertTrue($called); + $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++; + }; + $this->loop->addTimer(1, function () {}); + + $this->loop->addSignal(SIGUSR1, $func); + $this->loop->addSignal(SIGUSR1, $func); + + $this->loop->addTimer(0.4, function () { + posix_kill(posix_getpid(), SIGUSR1); + }); + $this->loop->addTimer(0.9, function () use (&$func) { + $this->loop->removeSignal(SIGUSR1, $func); + }); + + $this->loop->run(); + + $this->assertSame(1, $funcCallCount); + } + + /** + * @requires extension pcntl + */ + public function testSignalsKeepTheLoopRunning() + { + 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) { + $this->loop->removeSignal(SIGUSR1, $function); + $this->loop->stop(); + }); + + $this->assertRunSlowerThan(1.4); + } + + /** + * @requires extension pcntl + */ + public function testSignalsKeepTheLoopRunningAndRemovingItStopsTheLoop() + { + 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) { + $this->loop->removeSignal(SIGUSR1, $function); + }); + + $this->assertRunFasterThan(1.6); + } + + public function testTimerIntervalCanBeFarInFuture() + { + // Maximum interval for ExtUvLoop implementation + $interval = ((int) (PHP_INT_MAX / 1000)) - 1; + // start a timer very far in the future + $timer = $this->loop->addTimer($interval, function () { }); + + $this->loop->futureTick(function () use ($timer) { + $this->loop->cancelTimer($timer); + }); + + $this->assertRunFasterThan($this->tickTimeout); + } + + private function assertRunSlowerThan($minInterval) + { + $start = microtime(true); $this->loop->run(); + + $end = microtime(true); + $interval = $end - $start; + + $this->assertLessThan($interval, $minInterval); } private function assertRunFasterThan($maxInterval) 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 @@ -markTestSkipped('ExtEvLoop tests skipped because ext-ev extension is not installed.'); + } + + return new ExtEvLoop(); + } +} diff --git a/tests/ExtEventLoopTest.php b/tests/ExtEventLoopTest.php index 71f798c1..ce40ba58 100644 --- a/tests/ExtEventLoopTest.php +++ b/tests/ExtEventLoopTest.php @@ -6,6 +6,9 @@ class ExtEventLoopTest extends AbstractLoopTest { + /** @var ?string */ + private $fifoPath; + public function createLoop($readStreamCompatible = false) { if ('Linux' === PHP_OS && !extension_loaded('posix')) { @@ -16,13 +19,17 @@ public function createLoop($readStreamCompatible = false) $this->markTestSkipped('ext-event tests skipped because ext-event is not installed.'); } - $cfg = null; - if ($readStreamCompatible) { - $cfg = new \EventConfig(); - $cfg->requireFeatures(\EventConfig::FEATURE_FDS); - } + return new ExtEventLoop(); + } - return new ExtEventLoop($cfg); + /** + * @after + */ + public function tearDownFile() + { + if ($this->fifoPath !== null && file_exists($this->fifoPath)) { + unlink($this->fifoPath); + } } public function createStream() @@ -31,6 +38,7 @@ public function createStream() // 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); @@ -62,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)); - - $this->writeToStream($input, "foo\n"); - $this->loop->tick(); - - $this->writeToStream($input, "bar\n"); - $this->loop->tick(); - } } 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/LibEvLoopTest.php b/tests/LibEvLoopTest.php deleted file mode 100644 index 5ea98e30..00000000 --- a/tests/LibEvLoopTest.php +++ /dev/null @@ -1,22 +0,0 @@ -markTestSkipped('libev tests skipped because ext-libev is not installed.'); - } - - return new LibEvLoop(); - } - - public function testLibEvConstructor() - { - $loop = new LibEvLoop(); - } -} diff --git a/tests/LibEventLoopTest.php b/tests/LibEventLoopTest.php deleted file mode 100644 index 920b33cc..00000000 --- a/tests/LibEventLoopTest.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 LibEventLoop(); - } - - 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/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 new file mode 100644 index 00000000..a8cc4221 --- /dev/null +++ b/tests/SignalsHandlerTest.php @@ -0,0 +1,58 @@ +assertSame(0, $callCount); + + $signals->add(SIGUSR1, $func); + $this->assertSame(0, $callCount); + + $signals->add(SIGUSR1, $func); + $this->assertSame(0, $callCount); + + $signals->add(SIGUSR1, $func); + $this->assertSame(0, $callCount); + + $signals->call(SIGUSR1); + $this->assertSame(1, $callCount); + + $signals->add(SIGUSR2, $func); + $this->assertSame(1, $callCount); + + $signals->add(SIGUSR2, $func); + $this->assertSame(1, $callCount); + + $signals->call(SIGUSR2); + $this->assertSame(2, $callCount); + + $signals->remove(SIGUSR2, $func); + $this->assertSame(2, $callCount); + + $signals->remove(SIGUSR2, $func); + $this->assertSame(2, $callCount); + + $signals->call(SIGUSR2); + $this->assertSame(2, $callCount); + + $signals->remove(SIGUSR1, $func); + $this->assertSame(2, $callCount); + + $signals->call(SIGUSR1); + $this->assertSame(2, $callCount); + } +} diff --git a/tests/StreamSelectLoopTest.php b/tests/StreamSelectLoopTest.php index 61b059c1..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,86 +40,149 @@ public function testStreamSelectTimeoutEmulation() $this->assertGreaterThan(0.04, $interval); } - public function signalProvider() + public function testStreamSelectReportsWarningForStreamWithFilter() { - return [ - ['SIGUSR1', SIGUSR1], - ['SIGHUP', SIGHUP], - ['SIGTERM', SIGTERM], - ]; + $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); } - private $_signalHandled = false; + public function signalProvider() + { + 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($sigName, $signal) + public function testSignalInterruptNoStream($signal) { - if (!extension_loaded('pcntl')) { - $this->markTestSkipped('"pcntl" extension is required to run this test.'); - } - - // dispatch signal handler once before signal is sent and once after - $this->loop->addTimer(0.01, function() { pcntl_signal_dispatch(); }); - $this->loop->addTimer(0.03, function() { pcntl_signal_dispatch(); }); - if (defined('HHVM_VERSION')) { - // hhvm startup is slow so we need to add another handler much later - $this->loop->addTimer(0.5, function() { pcntl_signal_dispatch(); }); - } + // dispatch signal handler every 10ms for 0.1s + $check = $this->loop->addPeriodicTimer(0.01, function() { + pcntl_signal_dispatch(); + }); + $this->loop->addTimer(0.1, function () use ($check) { + $this->loop->cancelTimer($check); + }); - $this->setUpSignalHandler($signal); + $handled = false; + $this->assertTrue(pcntl_signal(constant($signal), function () use (&$handled) { + $handled = true; + })); // spawn external process to send signal to current process id $this->forkSendSignal($signal); + $this->loop->run(); - $this->assertTrue($this->_signalHandled); + $this->assertTrue($handled); } /** * 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($sigName, $signal) + 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(); }); + $this->loop->addPeriodicTimer(0.01, function() { + pcntl_signal_dispatch(); + }); // add stream to the loop - list($writeStream, $readStream) = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP); - $this->loop->addReadStream($readStream, function($stream, $loop) { + list($writeStream, $readStream) = $this->createSocketPair(); + $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.05, function() use ($writeStream) { + $this->loop->addTimer(0.1, function() use ($writeStream) { fwrite($writeStream, "end loop\n"); }); - $this->setUpSignalHandler($signal); + $handled = false; + $this->assertTrue(pcntl_signal(constant($signal), function () use (&$handled) { + $handled = true; + })); // spawn external process to send signal to current process id $this->forkSendSignal($signal); $this->loop->run(); - $this->assertTrue($this->_signalHandled); - } - - /** - * add signal handler for signal - */ - protected function setUpSignalHandler($signal) - { - $this->_signalHandled = false; - $this->assertTrue(pcntl_signal($signal, function() { $this->_signalHandled = true; })); + $this->assertTrue($handled); } /** @@ -125,7 +191,7 @@ protected function setUpSignalHandler($signal) protected function resetSignalHandlers() { foreach($this->signalProvider() as $signal) { - pcntl_signal($signal[1], SIG_DFL); + pcntl_signal(constant($signal[0]), SIG_DFL); } } @@ -141,7 +207,7 @@ protected function forkSendSignal($signal) } else if ($childPid === 0) { // this is executed in the child process usleep(20000); - posix_kill($currentPid, $signal); + posix_kill($currentPid, constant($signal)); die(); } } diff --git a/tests/TestCase.php b/tests/TestCase.php index 5114f4e0..55f04cf3 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,7 +2,10 @@ namespace React\Tests\EventLoop; -class TestCase extends \PHPUnit_Framework_TestCase +use PHPUnit\Framework\TestCase as BaseTestCase; +use React\EventLoop\LoopInterface; + +class TestCase extends BaseTestCase { protected function expectCallableExactly($amount) { @@ -36,6 +39,22 @@ 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) + { + $loop->futureTick(function () use ($loop) { + $loop->stop(); + }); + + $loop->run(); } } diff --git a/tests/Timer/AbstractTimerTest.php b/tests/Timer/AbstractTimerTest.php index 57689658..81099ff9 100644 --- a/tests/Timer/AbstractTimerTest.php +++ b/tests/Timer/AbstractTimerTest.php @@ -2,96 +2,161 @@ namespace React\Tests\EventLoop\Timer; +use React\EventLoop\LoopInterface; +use React\EventLoop\TimerInterface; use React\Tests\EventLoop\TestCase; abstract class AbstractTimerTest extends TestCase { + /** + * @return LoopInterface + */ abstract public function createLoop(); - public function testAddTimer() + public function testAddTimerReturnsNonPeriodicTimerInstance() { - // usleep is intentionally high - $loop = $this->createLoop(); - $loop->addTimer(0.001, $this->expectCallableOnce()); - usleep(1000); - $loop->tick(); + $timer = $loop->addTimer(0.001, $this->expectCallableNever()); + + $this->assertInstanceOf(TimerInterface::class, $timer); + $this->assertFalse($timer->isPeriodic()); } - public function testAddPeriodicTimer() + public function testAddTimerWillBeInvokedOnceAndBlocksLoopWhenRunning() { $loop = $this->createLoop(); - $loop->addPeriodicTimer(0.001, $this->expectCallableExactly(3)); - usleep(1000); - $loop->tick(); - usleep(1000); - $loop->tick(); - usleep(1000); - $loop->tick(); + $loop->addTimer(0.005, $this->expectCallableOnce()); + + $start = microtime(true); + $loop->run(); + $end = microtime(true); + + // 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); } - public function testAddPeriodicTimerWithCancel() + public function testAddPeriodicTimerReturnsPeriodicTimerInstance() { $loop = $this->createLoop(); - $timer = $loop->addPeriodicTimer(0.001, $this->expectCallableExactly(2)); + $periodic = $loop->addPeriodicTimer(0.1, $this->expectCallableNever()); + + $this->assertInstanceOf(TimerInterface::class, $periodic); + $this->assertTrue($periodic->isPeriodic()); + } - usleep(1000); - $loop->tick(); - usleep(1000); - $loop->tick(); + public function testAddPeriodicTimerWillBeInvokedUntilItIsCancelled() + { + $loop = $this->createLoop(); - $timer->cancel(); + $periodic = $loop->addPeriodicTimer(0.1, $this->expectCallableExactly(3)); + + // make no strict assumptions about actual time interval. + // leave some room to ensure this ticks exactly 3 times. + $loop->addTimer(0.350, function () use ($loop, $periodic) { + $loop->cancelTimer($periodic); + }); - usleep(1000); - $loop->tick(); + $loop->run(); } - public function testAddPeriodicTimerCancelsItself() + public function testAddPeriodicTimerWillBeInvokedWithMaximumAccuracyUntilItIsCancelled() { + $loop = $this->createLoop(); + $i = 0; + $periodic = $loop->addPeriodicTimer(0.001, function () use (&$i) { + ++$i; + }); + + $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 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() + { $loop = $this->createLoop(); - $loop->addPeriodicTimer(0.001, function ($timer) use (&$i) { + $i = 0; + $loop->addPeriodicTimer(0.001, function ($timer) use (&$i, $loop) { $i++; - if ($i == 2) { - $timer->cancel(); + if ($i === 5) { + $loop->cancelTimer($timer); } }); - usleep(1000); - $loop->tick(); - usleep(1000); - $loop->tick(); - usleep(1000); - $loop->tick(); + $start = microtime(true); + $loop->run(); + $end = microtime(true); + + $this->assertEquals(5, $i); - $this->assertSame(2, $i); + // 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 testIsTimerActive() + public function testMinimumIntervalOneMicrosecond() { $loop = $this->createLoop(); - $timer = $loop->addPeriodicTimer(0.001, function () {}); + $timer = $loop->addTimer(0, function () {}); + + $this->assertEquals(0.000001, $timer->getInterval()); + } - $this->assertTrue($loop->isTimerActive($timer)); + public function testAddPeriodicTimerWithZeroIntervalWillExecuteCallbackFunctionAtLeastTwice() + { + $loop = $this->createLoop(); - $timer->cancel(); + $timeout = $loop->addTimer(2, $this->expectCallableNever()); //Timeout the test after two seconds if the periodic timer hasn't fired twice - $this->assertFalse($loop->isTimerActive($timer)); + $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 testMinimumIntervalOneMicrosecond() + public function testTimerIntervalBelowZeroRunsImmediately() { $loop = $this->createLoop(); + $start = 0; + $loop->addTimer( + -1, + function () use (&$start) { + $start = \microtime(true); + } + ); - $timer = $loop->addTimer(0, function () {}); + $loop->run(); + $end = \microtime(true); - $this->assertEquals(0.000001, $timer->getInterval()); + // 1ms should be enough even on slow machines (± 1ms due to timer inaccuracies) + $this->assertLessThan(0.002, $end - $start); } } diff --git a/tests/Timer/ExtEvTimerTest.php b/tests/Timer/ExtEvTimerTest.php new file mode 100644 index 00000000..bfa91861 --- /dev/null +++ b/tests/Timer/ExtEvTimerTest.php @@ -0,0 +1,17 @@ +markTestSkipped('ExtEvLoop tests skipped because ext-ev extension is not installed.'); + } + + return new ExtEvLoop(); + } +} 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/Timer/LibEvTimerTest.php b/tests/Timer/LibEvTimerTest.php deleted file mode 100644 index 73abe8ed..00000000 --- a/tests/Timer/LibEvTimerTest.php +++ /dev/null @@ -1,17 +0,0 @@ -markTestSkipped('libev tests skipped because ext-libev is not installed.'); - } - - return new LibEvLoop(); - } -} diff --git a/tests/Timer/LibEventTimerTest.php b/tests/Timer/LibEventTimerTest.php deleted file mode 100644 index 3db20350..00000000 --- a/tests/Timer/LibEventTimerTest.php +++ /dev/null @@ -1,17 +0,0 @@ -markTestSkipped('libevent tests skipped because ext-libevent is not installed.'); - } - - return new LibEventLoop(); - } -} diff --git a/tests/Timer/TimersTest.php b/tests/Timer/TimersTest.php index 0ca87e16..e7681c96 100644 --- a/tests/Timer/TimersTest.php +++ b/tests/Timer/TimersTest.php @@ -10,20 +10,31 @@ class TimersTest extends TestCase { public function testBlockedTimer() { - $loop = $this - ->getMockBuilder('React\EventLoop\LoopInterface') - ->getMock(); - $timers = new Timers(); $timers->tick(); - + // simulate a bunch of processing on stream events, // part of which schedules a future timer... sleep(1); - $timers->add(new Timer($loop, 0.5, function () { + $timers->add(new Timer(0.5, function () { $this->fail("Timer shouldn't be called"); })); $timers->tick(); + + $this->assertTrue(true); + } + + public function testContains() + { + $timers = new Timers(); + + $timer1 = new Timer(0.1, function () {}); + $timer2 = new Timer(0.1, function () {}); + + $timers->add($timer1); + + self::assertTrue($timers->contains($timer1)); + self::assertFalse($timers->contains($timer2)); } } 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 @@ +addPsr4('React\\Tests\\EventLoop\\', __DIR__); diff --git a/travis-init.sh b/travis-init.sh deleted file mode 100755 index 07b1d2a8..00000000 --- a/travis-init.sh +++ /dev/null @@ -1,40 +0,0 @@ -#!/bin/bash -set -e -set -o pipefail - -if [[ "$TRAVIS_PHP_VERSION" != "hhvm" && - "$TRAVIS_PHP_VERSION" != "hhvm-nightly" ]]; then - - # install "libevent" (used by 'event' and 'libevent' PHP extensions) - sudo apt-get install -y libevent-dev - - # install 'event' PHP extension - echo "yes" | pecl install event - - # install 'libevent' PHP extension (does not support php 7) - if [[ "$TRAVIS_PHP_VERSION" != "7.0" ]]; then - curl http://pecl.php.net/get/libevent-0.1.0.tgz | tar -xz - pushd libevent-0.1.0 - phpize - ./configure - make - make install - popd - echo "extension=libevent.so" >> "$(php -r 'echo php_ini_loaded_file();')" - fi - - # install 'libev' PHP extension (does not support php 7) - if [[ "$TRAVIS_PHP_VERSION" != "7.0" ]]; 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 - -composer install --dev --prefer-source