diff --git a/.travis.yml b/.travis.yml index 7182febc..be4e6a06 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,16 +1,5 @@ language: php -php: -# - 5.3 # requires old distro, see below -# - 5.4 # requires old distro, see below -# - 5.5 # requires old distro, see below - - 5.6 - - 7.0 - - 7.1 - - 7.2 - - 7.3 -# - hhvm # requires legacy phpunit & ignore errors, see below - # lock distro so new future defaults will not break the build dist: xenial @@ -25,9 +14,17 @@ matrix: - php: 5.5 dist: trusty before_install: [] # skip libuv - - php: hhvm - install: composer require phpunit/phpunit:^5 --dev --no-interaction + - php: 5.6 + - php: 7.0 + - php: 7.1 + - php: 7.2 + - php: 7.3 + - php: 7.4 + - php: hhvm-3.18 + dist: trusty before_install: [] # skip libuv + install: + - composer require phpunit/phpunit:^5 --dev --no-interaction # requires legacy phpunit & skip ./travis-init.sh - name: "Windows" os: windows language: shell # no built-in php support @@ -51,8 +48,22 @@ matrix: - php -r "file_put_contents(php_ini_loaded_file(),'extension=event'.PHP_EOL,FILE_APPEND);" install: - composer install + - name: "Windows PHP 7.4 with ext-uv" + os: windows + language: shell # no built-in php support + before_install: + - curl -OL https://windows.php.net/downloads/pecl/releases/uv/0.2.4/php_uv-0.2.4-7.4-nts-vc15-x64.zip # latest version as of 2019-12-23 + - choco install php --version=7.4.0 # latest version supported by ext-uv as of 2019-12-23 + - choco install composer + - export PATH="$(powershell -Command '("Process", "Machine" | % { [Environment]::GetEnvironmentVariable("PATH", $_) -Split ";" -Replace "\\$", "" } | Select -Unique | % { cygpath $_ }) -Join ":"')" + - php -r "\$z=new ZipArchive();\$z->open(glob('php_uv*.zip')[0]);\$z->extractTo(dirname(php_ini_loaded_file()).'/ext','php_uv.dll');\$z->extractTo(dirname(php_ini_loaded_file()),'libuv.dll');" + - php -r "file_put_contents(php_ini_loaded_file(),'extension_dir=ext'.PHP_EOL,FILE_APPEND);" + - php -r "file_put_contents(php_ini_loaded_file(),'extension=sockets'.PHP_EOL,FILE_APPEND);" # ext-sockets needs to be loaded before ext-uv + - php -r "file_put_contents(php_ini_loaded_file(),'extension=uv'.PHP_EOL,FILE_APPEND);" + install: + - composer install allow_failures: - - php: hhvm + - php: hhvm-3.18 - os: windows sudo: false diff --git a/src/ExtUvLoop.php b/src/ExtUvLoop.php index 70471b59..002d6a2e 100644 --- a/src/ExtUvLoop.php +++ b/src/ExtUvLoop.php @@ -294,13 +294,15 @@ private function pollStream($stream) private function createStreamListener() { $callback = function ($event, $status, $events, $stream) { - if (!isset($this->streamEvents[(int) $stream])) { - return; - } - - if (($events | 4) === 4) { - // Disconnected - return; + // 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)) { diff --git a/src/StreamSelectLoop.php b/src/StreamSelectLoop.php index 3362d3e5..4426844a 100644 --- a/src/StreamSelectLoop.php +++ b/src/StreamSelectLoop.php @@ -269,10 +269,30 @@ private function waitForStreamActivity($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 = array(); + 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); + $ret = @\stream_select($read, $write, $except, $timeout === null ? null : 0, $timeout); + + if ($except) { + $write = \array_merge($write, $except); + } + return $ret; } if ($timeout > 0) { diff --git a/tests/AbstractLoopTest.php b/tests/AbstractLoopTest.php index 2e46b66e..9b55f959 100644 --- a/tests/AbstractLoopTest.php +++ b/tests/AbstractLoopTest.php @@ -2,6 +2,9 @@ namespace React\Tests\EventLoop; +use React\EventLoop\StreamSelectLoop; +use React\EventLoop\ExtUvLoop; + abstract class AbstractLoopTest extends TestCase { /** @@ -36,8 +39,111 @@ public function createSocketPair() return $sockets; } + public function testAddReadStreamTriggersWhenSocketReceivesData() + { + list ($input, $output) = $this->createSocketPair(); + + $loop = $this->loop; + $timeout = $loop->addTimer(0.1, function () use ($input, $loop) { + $loop->removeReadStream($input); + }); + + $called = 0; + $this->loop->addReadStream($input, function () use (&$called, $loop, $input, $timeout) { + ++$called; + $loop->removeReadStream($input); + $loop->cancelTimer($timeout); + }); + + fwrite($output, "foo\n"); + + $this->loop->run(); + + $this->assertEquals(1, $called); + } + + public function testAddReadStreamTriggersWhenSocketCloses() + { + list ($input, $output) = $this->createSocketPair(); + + $loop = $this->loop; + $timeout = $loop->addTimer(0.1, function () use ($input, $loop) { + $loop->removeReadStream($input); + }); + + $called = 0; + $this->loop->addReadStream($input, function () use (&$called, $loop, $input, $timeout) { + ++$called; + $loop->removeReadStream($input); + $loop->cancelTimer($timeout); + }); + + fclose($output); + + $this->loop->run(); + + $this->assertEquals(1, $called); + } + + public function testAddWriteStreamTriggersWhenSocketConnectionSucceeds() + { + $server = stream_socket_server('127.0.0.1:0'); + + $errno = $errstr = null; + $connecting = stream_socket_client(stream_socket_get_name($server, false), $errno, $errstr, 0, STREAM_CLIENT_CONNECT | STREAM_CLIENT_ASYNC_CONNECT); + + $loop = $this->loop; + $timeout = $loop->addTimer(0.1, function () use ($connecting, $loop) { + $loop->removeWriteStream($connecting); + }); + + $called = 0; + $this->loop->addWriteStream($connecting, function () use (&$called, $loop, $connecting, $timeout) { + ++$called; + $loop->removeWriteStream($connecting); + $loop->cancelTimer($timeout); + }); + + $this->loop->run(); + + $this->assertEquals(1, $called); + } + + public function testAddWriteStreamTriggersWhenSocketConnectionRefused() + { + // first verify the operating system actually refuses the connection and no firewall is in place + // use higher timeout because Windows retires multiple times and has a noticeable delay + // @link https://stackoverflow.com/questions/19440364/why-do-failed-attempts-of-socket-connect-take-1-sec-on-windows + $errno = $errstr = null; + if (@stream_socket_client('127.0.0.1:1', $errno, $errstr, 10.0) !== false || (defined('SOCKET_ECONNREFUSED') && $errno !== SOCKET_ECONNREFUSED)) { + $this->markTestSkipped('Expected host to refuse connection, but got error ' . $errno . ': ' . $errstr); + } + + $connecting = stream_socket_client('127.0.0.1:1', $errno, $errstr, 0, STREAM_CLIENT_CONNECT | STREAM_CLIENT_ASYNC_CONNECT); + + $loop = $this->loop; + $timeout = $loop->addTimer(10.0, function () use ($connecting, $loop) { + $loop->removeWriteStream($connecting); + }); + + $called = 0; + $this->loop->addWriteStream($connecting, function () use (&$called, $loop, $connecting, $timeout) { + ++$called; + $loop->removeWriteStream($connecting); + $loop->cancelTimer($timeout); + }); + + $this->loop->run(); + + $this->assertEquals(1, $called); + } + public function testAddReadStream() { + if ($this->loop instanceof ExtUvLoop && DIRECTORY_SEPARATOR === '\\') { + $this->markTestIncomplete('Ticking ExtUvLoop not supported on Windows'); + } + list ($input, $output) = $this->createSocketPair(); $this->loop->addReadStream($input, $this->expectCallableExactly(2)); @@ -51,6 +157,10 @@ public function testAddReadStream() public function testAddReadStreamIgnoresSecondCallable() { + if ($this->loop instanceof ExtUvLoop && DIRECTORY_SEPARATOR === '\\') { + $this->markTestIncomplete('Ticking ExtUvLoop not supported on Windows'); + } + list ($input, $output) = $this->createSocketPair(); $this->loop->addReadStream($input, $this->expectCallableExactly(2)); @@ -100,6 +210,10 @@ private function subAddReadStreamReceivesDataFromStreamReference() public function testAddWriteStream() { + if ($this->loop instanceof ExtUvLoop && DIRECTORY_SEPARATOR === '\\') { + $this->markTestIncomplete('Ticking ExtUvLoop not supported on Windows'); + } + list ($input) = $this->createSocketPair(); $this->loop->addWriteStream($input, $this->expectCallableExactly(2)); @@ -109,6 +223,10 @@ public function testAddWriteStream() public function testAddWriteStreamIgnoresSecondCallable() { + if ($this->loop instanceof ExtUvLoop && DIRECTORY_SEPARATOR === '\\') { + $this->markTestIncomplete('Ticking ExtUvLoop not supported on Windows'); + } + list ($input) = $this->createSocketPair(); $this->loop->addWriteStream($input, $this->expectCallableExactly(2)); @@ -119,6 +237,10 @@ public function testAddWriteStreamIgnoresSecondCallable() public function testRemoveReadStreamInstantly() { + if ($this->loop instanceof ExtUvLoop && DIRECTORY_SEPARATOR === '\\') { + $this->markTestIncomplete('Ticking ExtUvLoop not supported on Windows'); + } + list ($input, $output) = $this->createSocketPair(); $this->loop->addReadStream($input, $this->expectCallableNever()); @@ -130,6 +252,10 @@ public function testRemoveReadStreamInstantly() public function testRemoveReadStreamAfterReading() { + if ($this->loop instanceof ExtUvLoop && DIRECTORY_SEPARATOR === '\\') { + $this->markTestIncomplete('Ticking ExtUvLoop not supported on Windows'); + } + list ($input, $output) = $this->createSocketPair(); $this->loop->addReadStream($input, $this->expectCallableOnce()); @@ -145,6 +271,10 @@ public function testRemoveReadStreamAfterReading() public function testRemoveWriteStreamInstantly() { + if ($this->loop instanceof ExtUvLoop && DIRECTORY_SEPARATOR === '\\') { + $this->markTestIncomplete('Ticking ExtUvLoop not supported on Windows'); + } + list ($input) = $this->createSocketPair(); $this->loop->addWriteStream($input, $this->expectCallableNever()); @@ -154,6 +284,10 @@ public function testRemoveWriteStreamInstantly() public function testRemoveWriteStreamAfterWriting() { + if ($this->loop instanceof ExtUvLoop && DIRECTORY_SEPARATOR === '\\') { + $this->markTestIncomplete('Ticking ExtUvLoop not supported on Windows'); + } + list ($input) = $this->createSocketPair(); $this->loop->addWriteStream($input, $this->expectCallableOnce()); @@ -165,6 +299,10 @@ public function testRemoveWriteStreamAfterWriting() public function testRemoveStreamForReadOnly() { + if ($this->loop instanceof ExtUvLoop && DIRECTORY_SEPARATOR === '\\') { + $this->markTestIncomplete('Ticking ExtUvLoop not supported on Windows'); + } + list ($input, $output) = $this->createSocketPair(); $this->loop->addReadStream($input, $this->expectCallableNever()); @@ -177,6 +315,10 @@ public function testRemoveStreamForReadOnly() public function testRemoveStreamForWriteOnly() { + if ($this->loop instanceof ExtUvLoop && DIRECTORY_SEPARATOR === '\\') { + $this->markTestIncomplete('Ticking ExtUvLoop not supported on Windows'); + } + list ($input, $output) = $this->createSocketPair(); fwrite($output, "foo\n"); @@ -399,6 +541,10 @@ public function testFutureTick() public function testFutureTickFiresBeforeIO() { + if ($this->loop instanceof ExtUvLoop && DIRECTORY_SEPARATOR === '\\') { + $this->markTestIncomplete('Ticking ExtUvLoop not supported on Windows'); + } + list ($stream) = $this->createSocketPair(); $this->loop->addWriteStream( @@ -419,6 +565,9 @@ function () { $this->tickLoop($this->loop); } + /** + * @depends testFutureTickFiresBeforeIO + */ public function testRecursiveFutureTick() { list ($stream) = $this->createSocketPair(); diff --git a/travis-init.sh b/travis-init.sh index 63ea758c..94ec4f36 100755 --- a/travis-init.sh +++ b/travis-init.sh @@ -2,52 +2,37 @@ set -e set -o pipefail -if [[ "$TRAVIS_PHP_VERSION" != "hhvm" && - "$TRAVIS_PHP_VERSION" != "hhvm-nightly" ]]; then - - # install 'event' and 'ev' PHP extension - if [[ "$TRAVIS_PHP_VERSION" != "5.3" && - "$TRAVIS_PHP_VERSION" != "7.3" ]]; then - echo "yes" | pecl install event - echo "yes" | pecl install ev - fi - - # install 'libevent' PHP extension (does not support php 7) - if [[ "$TRAVIS_PHP_VERSION" != "7.0" && - "$TRAVIS_PHP_VERSION" != "7.1" && - "$TRAVIS_PHP_VERSION" != "7.2" && - "$TRAVIS_PHP_VERSION" != "7.3" ]]; 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 'event' and 'ev' PHP extension on PHP 5.4+ only +if [[ "$TRAVIS_PHP_VERSION" != "5.3" ]]; then + echo "yes" | pecl install event + echo "yes" | pecl install ev +fi - # install 'libev' PHP extension (does not support php 7) - if [[ "$TRAVIS_PHP_VERSION" != "7.0" && - "$TRAVIS_PHP_VERSION" != "7.1" && - "$TRAVIS_PHP_VERSION" != "7.2" && - "$TRAVIS_PHP_VERSION" != "7.3" ]]; 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 +# install 'libevent' PHP extension on legacy PHP 5 only +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 'libuv' PHP extension (does not support php 5) - if [[ "$TRAVIS_PHP_VERSION" = "7.0" || - "$TRAVIS_PHP_VERSION" = "7.1" || - "$TRAVIS_PHP_VERSION" = "7.2" || - "$TRAVIS_PHP_VERSION" = "7.3" ]]; then - echo "yes" | pecl install uv-beta - fi +# install 'libev' PHP extension on legacy PHP 5 only +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 +# install 'libuv' PHP extension on PHP 7+ only +if ! [[ "$TRAVIS_PHP_VERSION" < "7.0" ]]; then + echo "yes" | pecl install uv-beta fi