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..71149ad0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,51 @@ +name: CI + +on: + push: + pull_request: + +jobs: + PHPUnit: + name: PHPUnit (PHP ${{ matrix.php }} on ${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: + - ubuntu-24.04 + - windows-2022 + php: + - 8.5 + - 8.4 + - 8.3 + - 8.2 + - 8.1 + - 8.0 + - 7.4 + - 7.3 + - 7.2 + - 7.1 + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: ${{ matrix.php < 8.0 && 'xdebug' || 'pcov' }} + ini-file: development + - 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-macOS: + name: PHPUnit (macOS) + runs-on: macos-14 + continue-on-error: true + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: 8.5 + coverage: pcov + - run: composer install + - run: vendor/bin/phpunit --coverage-text diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..c8153b57 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/composer.lock +/vendor/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..db178ca6 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,780 @@ +# Changelog + +## 1.15.0 (2023-12-15) + +* Feature: Full PHP 8.3 compatibility. + (#310 by @clue) + +* Fix: Fix cancelling during the 50ms resolution delay when DNS is still pending. + (#311 by @clue) + +## 1.14.0 (2023-08-25) + +* Feature: Improve Promise v3 support and use template types. + (#307 and #309 by @clue) + +* Improve test suite and update to collect all garbage cycles. + (#308 by @clue) + +## 1.13.0 (2023-06-07) + +* Feature: Include timeout logic to avoid dependency on reactphp/promise-timer. + (#305 by @clue) + +* Feature: Improve errno detection for failed connections without `ext-sockets`. + (#304 by @clue) + +* Improve test suite, clean up leftover `.sock` files and report failed assertions. + (#299, #300, #301 and #306 by @clue) + +## 1.12.0 (2022-08-25) + +* Feature: Forward compatibility with react/promise 3. + (#214 by @WyriHaximus and @clue) + +* Feature: Full support for PHP 8.2 release. + (#298 by @WyriHaximus) + +* Feature: Avoid unneeded syscall on socket close. + (#292 by @clue) + +* Feature / Fix: Improve error reporting when custom error handler is used. + (#290 by @clue) + +* Fix: Fix invalid references in exception stack trace. + (#284 by @clue) + +* Minor documentation improvements, update to use new reactphp/async package instead of clue/reactphp-block. + (#296 by @clue, #285 by @SimonFrings and #295 by @nhedger) + +* Improve test suite, update macOS and HHVM environment, fix optional tests for `ENETUNREACH`. + (#288, #289 and #297 by @clue) + +## 1.11.0 (2022-01-14) + +* Feature: Full support for PHP 8.1 release. + (#277 by @clue) + +* Feature: Avoid dependency on `ext-filter`. + (#279 by @clue) + +* Improve test suite to skip FD test when hitting memory limit + and skip legacy TLS 1.0 tests if disabled by system. + (#278 and #281 by @clue and #283 by @SimonFrings) + +## 1.10.0 (2021-11-29) + +* Feature: Support listening on existing file descriptors (FDs) with `SocketServer`. + (#269 by @clue) + + ```php + $socket = new React\Socket\SocketSever('php://fd/3'); + ``` + + This is particularly useful when using [systemd socket activation](https://www.freedesktop.org/software/systemd/man/systemd.socket.html) like this: + + ```bash + $ systemd-socket-activate -l 8000 php examples/03-http-server.php php://fd/3 + ``` + +* Feature: Improve error messages for failed connection attempts with `errno` and `errstr`. + (#265, #266, #267, #270 and #271 by @clue and #268 by @SimonFrings) + + All error messages now always include the appropriate `errno` and `errstr` to + give more details about the error reason when available. Along with these + error details exposed by the underlying system functions, it will also + include the appropriate error constant name (such as `ECONNREFUSED`) when + available. Accordingly, failed TCP/IP connections will now report the actual + underlying error condition instead of a generic "Connection refused" error. + Higher-level error messages will now consistently report the connection URI + scheme and hostname used in all error messages. + + For most common use cases this means that simply reporting the `Exception` + message should give the most relevant details for any connection issues: + + ```php + $connector = new React\Socket\Connector(); + $connector->connect($uri)->then(function (React\Socket\ConnectionInterface $conn) { + // … + }, function (Exception $e) { + echo 'Error:' . $e->getMessage() . PHP_EOL; + }); + ``` + +* Improve test suite, test against PHP 8.1 release. + (#274 by @SimonFrings) + +## 1.9.0 (2021-08-03) + +* Feature: Add new `SocketServer` and deprecate `Server` to avoid class name collisions. + (#263 by @clue) + + The new `SocketServer` class has been added with an improved constructor signature + as a replacement for the previous `Server` class in order to avoid any ambiguities. + The previous name has been deprecated and should not be used anymore. + In its most basic form, the deprecated `Server` can now be considered an alias for new `SocketServer`. + + ```php + // deprecated + $socket = new React\Socket\Server(0); + $socket = new React\Socket\Server('127.0.0.1:8000'); + $socket = new React\Socket\Server('127.0.0.1:8000', null, $context); + $socket = new React\Socket\Server('127.0.0.1:8000', $loop, $context); + + // new + $socket = new React\Socket\SocketServer('127.0.0.1:0'); + $socket = new React\Socket\SocketServer('127.0.0.1:8000'); + $socket = new React\Socket\SocketServer('127.0.0.1:8000', $context); + $socket = new React\Socket\SocketServer('127.0.0.1:8000', $context, $loop); + ``` + +* Feature: Update `Connector` signature to take optional `$context` as first argument. + (#264 by @clue) + + The new signature has been added to match the new `SocketServer` and + consistently move the now commonly unneeded loop argument to the last argument. + The previous signature has been deprecated and should not be used anymore. + In its most basic form, both signatures are compatible. + + ```php + // deprecated + $connector = new React\Socket\Connector(null, $context); + $connector = new React\Socket\Connector($loop, $context); + + // new + $connector = new React\Socket\Connector($context); + $connector = new React\Socket\Connector($context, $loop); + ``` + +## 1.8.0 (2021-07-11) + +A major new feature release, see [**release announcement**](https://clue.engineering/2021/announcing-reactphp-default-loop). + +* Feature: Simplify usage by supporting new [default loop](https://reactphp.org/event-loop/#loop). + (#260 by @clue) + + ```php + // old (still supported) + $socket = new React\Socket\Server('127.0.0.1:8080', $loop); + $connector = new React\Socket\Connector($loop); + + // new (using default loop) + $socket = new React\Socket\Server('127.0.0.1:8080'); + $connector = new React\Socket\Connector(); + ``` + +## 1.7.0 (2021-06-25) + +* Feature: Support falling back to multiple DNS servers from DNS config. + (#257 by @clue) + + If you're using the default `Connector`, it will now use all DNS servers + configured on your system. If you have multiple DNS servers configured and + connectivity to the primary DNS server is broken, it will now fall back to + your other DNS servers, thus providing improved connectivity and redundancy + for broken DNS configurations. + +* Feature: Use round robin for happy eyeballs DNS responses (load balancing). + (#247 by @clue) + + If you're using the default `Connector`, it will now randomize the order of + the IP addresses resolved via DNS when connecting. This allows the load to + be distributed more evenly across all returned IP addresses. This can be + used as a very basic DNS load balancing mechanism. + +* Internal improvement to avoid unhandled rejection for future Promise API. + (#258 by @clue) + +* Improve test suite, use GitHub actions for continuous integration (CI). + (#254 by @SimonFrings) + +## 1.6.0 (2020-08-28) + +* Feature: Support upcoming PHP 8 release. + (#246 by @clue) + +* Feature: Change default socket backlog size to 511. + (#242 by @clue) + +* Fix: Fix closing connection when cancelling during TLS handshake. + (#241 by @clue) + +* Fix: Fix blocking during possible `accept()` race condition + when multiple socket servers listen on same socket address. + (#244 by @clue) + +* Improve test suite, update PHPUnit config and add full core team to the license. + (#243 by @SimonFrings and #245 by @WyriHaximus) + +## 1.5.0 (2020-07-01) + +* Feature / Fix: Improve error handling and reporting for happy eyeballs and + immediately try next connection when one connection attempt fails. + (#230, #231, #232 and #233 by @clue) + + Error messages for failed connection attempts now include more details to + ease debugging. Additionally, the happy eyeballs algorithm has been improved + to avoid having to wait for some timers to expire which significantly + improves connection setup times (in particular when IPv6 isn't available). + +* Improve test suite, minor code cleanup and improve code coverage to 100%. + Update to PHPUnit 9 and skip legacy TLS 1.0 / TLS 1.1 tests if disabled by + system. Run tests on Windows and simplify Travis CI test matrix for Mac OS X + setup and skip all TLS tests on legacy HHVM. + (#229, #235, #236 and #238 by @clue and #239 by @SimonFrings) + +## 1.4.0 (2020-03-12) + +A major new feature release, see [**release announcement**](https://clue.engineering/2020/introducing-ipv6-for-reactphp). + +* Feature: Add IPv6 support to `Connector` (implement "Happy Eyeballs" algorithm to support IPv6 probing). + IPv6 support is turned on by default, use new `happy_eyeballs` option in `Connector` to toggle behavior. + (#196, #224 and #225 by @WyriHaximus and @clue) + +* Feature: Default to using DNS cache (with max 256 entries) for `Connector`. + (#226 by @clue) + +* Add `.gitattributes` to exclude dev files from exports and some minor code style fixes. + (#219 by @reedy and #218 by @mmoreram) + +* Improve test suite to fix failing test cases when using new DNS component, + significantly improve test performance by awaiting events instead of sleeping, + exclude TLS 1.3 test on PHP 7.3, run tests on PHP 7.4 and simplify test matrix. + (#208, #209, #210, #217 and #223 by @clue) + +## 1.3.0 (2019-07-10) + +* Feature: Forward compatibility with upcoming stable DNS component. + (#206 by @clue) + +## 1.2.1 (2019-06-03) + +* Avoid uneeded fragmented TLS work around for PHP 7.3.3+ and + work around failing test case detecting EOF on TLS 1.3 socket streams. + (#201 and #202 by @clue) + +* Improve TLS certificate/passphrase example. + (#190 by @jsor) + +## 1.2.0 (2019-01-07) + +* Feature / Fix: Improve TLS 1.3 support. + (#186 by @clue) + + TLS 1.3 is now an official standard as of August 2018! :tada: + The protocol has major improvements in the areas of security, performance, and privacy. + TLS 1.3 is supported by default as of [OpenSSL 1.1.1](https://www.openssl.org/blog/blog/2018/09/11/release111/). + For example, this version ships with Ubuntu 18.10 (and newer) by default, meaning that recent installations support TLS 1.3 out of the box :shipit: + +* Fix: Avoid possibility of missing remote address when TLS handshake fails. + (#188 by @clue) + +* Improve performance by prefixing all global functions calls with `\` to skip the look up and resolve process and go straight to the global function. + (#183 by @WyriHaximus) + +* Update documentation to use full class names with namespaces. + (#187 by @clue) + +* Improve test suite to avoid some possible race conditions, + test against PHP 7.3 on Travis and + use dedicated `assertInstanceOf()` assertions. + (#185 by @clue, #178 by @WyriHaximus and #181 by @carusogabriel) + +## 1.1.0 (2018-10-01) + +* Feature: Improve error reporting for failed connection attempts and improve + cancellation forwarding during DNS lookup, TCP/IP connection or TLS handshake. + (#168, #169, #170, #171, #176 and #177 by @clue) + + All error messages now always contain a reference to the remote URI to give + more details which connection actually failed and the reason for this error. + Accordingly, failures during DNS lookup will now mention both the remote URI + as well as the DNS error reason. TCP/IP connection issues and errors during + a secure TLS handshake will both mention the remote URI as well as the + underlying socket error. Similarly, lost/dropped connections during a TLS + handshake will now report a lost connection instead of an empty error reason. + + For most common use cases this means that simply reporting the `Exception` + message should give the most relevant details for any connection issues: + + ```php + $promise = $connector->connect('tls://example.com:443'); + $promise->then(function (ConnectionInterface $conn) use ($loop) { + // … + }, function (Exception $e) { + echo $e->getMessage(); + }); + ``` + +## 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.8.12 release. + +## 0.8.12 (2018-06-11) + +* Feature: Improve memory consumption for failed and cancelled connection attempts. + (#161 by @clue) + +* Improve test suite to fix Travis config to test against legacy PHP 5.3 again. + (#162 by @clue) + +## 0.8.11 (2018-04-24) + +* Feature: Improve memory consumption for cancelled connection attempts and + simplify skipping DNS lookup when connecting to IP addresses. + (#159 and #160 by @clue) + +## 0.8.10 (2018-02-28) + +* Feature: Update DNS dependency to support loading system default DNS + nameserver config on all supported platforms + (`/etc/resolv.conf` on Unix/Linux/Mac/Docker/WSL and WMIC on Windows) + (#152 by @clue) + + This means that connecting to hosts that are managed by a local DNS server, + such as a corporate DNS server or when using Docker containers, will now + work as expected across all platforms with no changes required: + + ```php + $connector = new Connector($loop); + $connector->connect('intranet.example:80')->then(function ($connection) { + // … + }); + ``` + +## 0.8.9 (2018-01-18) + +* Feature: Support explicitly choosing TLS version to negotiate with remote side + by respecting `crypto_method` context parameter for all classes. + (#149 by @clue) + + By default, all connector and server classes support TLSv1.0+ and exclude + support for legacy SSLv2/SSLv3. As of PHP 5.6+ you can also explicitly + choose the TLS version you want to negotiate with the remote side: + + ```php + // new: now supports 'crypto_method` context parameter for all classes + $connector = new Connector($loop, array( + 'tls' => array( + 'crypto_method' => STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT + ) + )); + ``` + +* Minor internal clean up to unify class imports + (#148 by @clue) + +## 0.8.8 (2018-01-06) + +* Improve test suite by adding test group to skip integration tests relying on + internet connection and fix minor documentation typo. + (#146 by @clue and #145 by @cn007b) + +## 0.8.7 (2017-12-24) + +* Fix: Fix closing socket resource before removing from loop + (#141 by @clue) + + This fixes the root cause of an uncaught `Exception` that only manifested + itself after the recent Stream v0.7.4 component update and only if you're + using `ext-event` (`ExtEventLoop`). + +* Improve test suite by testing against PHP 7.2 + (#140 by @carusogabriel) + +## 0.8.6 (2017-11-18) + +* Feature: Add Unix domain socket (UDS) support to `Server` with `unix://` URI scheme + and add advanced `UnixServer` class. + (#120 by @andig) + + ```php + // new: Server now supports "unix://" scheme + $server = new Server('unix:///tmp/server.sock', $loop); + + // new: advanced usage + $server = new UnixServer('/tmp/server.sock', $loop); + ``` + +* Restructure examples to ease getting started + (#136 by @clue) + +* Improve test suite by adding forward compatibility with PHPUnit 6 and + ignore Mac OS X test failures for now until Travis tests work again + (#133 by @gabriel-caruso and #134 by @clue) + +## 0.8.5 (2017-10-23) + +* Fix: Work around PHP bug with Unix domain socket (UDS) paths for Mac OS X + (#123 by @andig) + +* Fix: Fix `SecureServer` to return `null` URI if server socket is already closed + (#129 by @clue) + +* Improve test suite by adding forward compatibility with PHPUnit v5 and + forward compatibility with upcoming EventLoop releases in tests and + test Mac OS X on Travis + (#122 by @andig and #125, #127 and #130 by @clue) + +* Readme improvements + (#118 by @jsor) + +## 0.8.4 (2017-09-16) + +* Feature: Add `FixedUriConnector` decorator to use fixed, preconfigured URI instead + (#117 by @clue) + + This can be useful for consumers that do not support certain URIs, such as + when you want to explicitly connect to a Unix domain socket (UDS) path + instead of connecting to a default address assumed by an higher-level API: + + ```php + $connector = new FixedUriConnector( + 'unix:///var/run/docker.sock', + new UnixConnector($loop) + ); + + // destination will be ignored, actually connects to Unix domain socket + $promise = $connector->connect('localhost:80'); + ``` + +## 0.8.3 (2017-09-08) + +* Feature: Reduce memory consumption for failed connections + (#113 by @valga) + +* Fix: Work around write chunk size for TLS streams for PHP < 7.1.14 + (#114 by @clue) + +## 0.8.2 (2017-08-25) + +* Feature: Update DNS dependency to support hosts file on all platforms + (#112 by @clue) + + This means that connecting to hosts such as `localhost` will now work as + expected across all platforms with no changes required: + + ```php + $connector = new Connector($loop); + $connector->connect('localhost:8080')->then(function ($connection) { + // … + }); + ``` + +## 0.8.1 (2017-08-15) + +* Feature: Forward compatibility with upcoming EventLoop v1.0 and v0.5 and + target evenement 3.0 a long side 2.0 and 1.0 + (#104 by @clue and #111 by @WyriHaximus) + +* Improve test suite by locking Travis distro so new defaults will not break the build and + fix HHVM build for now again and ignore future HHVM build errors + (#109 and #110 by @clue) + +* Minor documentation fixes + (#103 by @christiaan and #108 by @hansott) + +## 0.8.0 (2017-05-09) + +* Feature: New `Server` class now acts as a facade for existing server classes + and renamed old `Server` to `TcpServer` for advanced usage. + (#96 and #97 by @clue) + + The `Server` class is now the main class in this package that implements the + `ServerInterface` and allows you to accept incoming streaming connections, + such as plaintext TCP/IP or secure TLS connection streams. + + > This is not a BC break and consumer code does not have to be updated. + +* Feature / BC break: All addresses are now URIs that include the URI scheme + (#98 by @clue) + + ```diff + - $parts = parse_url('tcp://' . $conn->getRemoteAddress()); + + $parts = parse_url($conn->getRemoteAddress()); + ``` + +* Fix: Fix `unix://` addresses for Unix domain socket (UDS) paths + (#100 by @clue) + +* Feature: Forward compatibility with Stream v1.0 and v0.7 + (#99 by @clue) + +## 0.7.2 (2017-04-24) + +* Fix: Work around latest PHP 7.0.18 and 7.1.4 no longer accepting full URIs + (#94 by @clue) + +## 0.7.1 (2017-04-10) + +* Fix: Ignore HHVM errors when closing connection that is already closing + (#91 by @clue) + +## 0.7.0 (2017-04-10) + +* Feature: Merge SocketClient component into this component + (#87 by @clue) + + This means that this package now provides async, streaming plaintext TCP/IP + and secure TLS socket server and client connections for ReactPHP. + + ``` + $connector = new React\Socket\Connector($loop); + $connector->connect('google.com:80')->then(function (ConnectionInterface $conn) { + $connection->write('…'); + }); + ``` + + Accordingly, the `ConnectionInterface` is now used to represent both incoming + server side connections as well as outgoing client side connections. + + If you've previously used the SocketClient component to establish outgoing + client connections, upgrading should take no longer than a few minutes. + All classes have been merged as-is from the latest `v0.7.0` release with no + other changes, so you can simply update your code to use the updated namespace + like this: + + ```php + // old from SocketClient component and namespace + $connector = new React\SocketClient\Connector($loop); + $connector->connect('google.com:80')->then(function (ConnectionInterface $conn) { + $connection->write('…'); + }); + + // new + $connector = new React\Socket\Connector($loop); + $connector->connect('google.com:80')->then(function (ConnectionInterface $conn) { + $connection->write('…'); + }); + ``` + +## 0.6.0 (2017-04-04) + +* Feature: Add `LimitingServer` to limit and keep track of open connections + (#86 by @clue) + + ```php + $server = new Server(0, $loop); + $server = new LimitingServer($server, 100); + + $server->on('connection', function (ConnectionInterface $connection) { + $connection->write('hello there!' . PHP_EOL); + … + }); + ``` + +* Feature / BC break: Add `pause()` and `resume()` methods to limit active + connections + (#84 by @clue) + + ```php + $server = new Server(0, $loop); + $server->pause(); + + $loop->addTimer(1.0, function() use ($server) { + $server->resume(); + }); + ``` + +## 0.5.1 (2017-03-09) + +* Feature: Forward compatibility with Stream v0.5 and upcoming v0.6 + (#79 by @clue) + +## 0.5.0 (2017-02-14) + +* Feature / BC break: Replace `listen()` call with URIs passed to constructor + and reject listening on hostnames with `InvalidArgumentException` + and replace `ConnectionException` with `RuntimeException` for consistency + (#61, #66 and #72 by @clue) + + ```php + // old + $server = new Server($loop); + $server->listen(8080); + + // new + $server = new Server(8080, $loop); + ``` + + Similarly, you can now pass a full listening URI to the constructor to change + the listening host: + + ```php + // old + $server = new Server($loop); + $server->listen(8080, '127.0.0.1'); + + // new + $server = new Server('127.0.0.1:8080', $loop); + ``` + + Trying to start listening on (DNS) host names will now throw an + `InvalidArgumentException`, use IP addresses instead: + + ```php + // old + $server = new Server($loop); + $server->listen(8080, 'localhost'); + + // new + $server = new Server('127.0.0.1:8080', $loop); + ``` + + If trying to listen fails (such as if port is already in use or port below + 1024 may require root access etc.), it will now throw a `RuntimeException`, + the `ConnectionException` class has been removed: + + ```php + // old: throws React\Socket\ConnectionException + $server = new Server($loop); + $server->listen(80); + + // new: throws RuntimeException + $server = new Server(80, $loop); + ``` + +* Feature / BC break: Rename `shutdown()` to `close()` for consistency throughout React + (#62 by @clue) + + ```php + // old + $server->shutdown(); + + // new + $server->close(); + ``` + +* Feature / BC break: Replace `getPort()` with `getAddress()` + (#67 by @clue) + + ```php + // old + echo $server->getPort(); // 8080 + + // new + echo $server->getAddress(); // 127.0.0.1:8080 + ``` + +* Feature / BC break: `getRemoteAddress()` returns full address instead of only IP + (#65 by @clue) + + ```php + // old + echo $connection->getRemoteAddress(); // 192.168.0.1 + + // new + echo $connection->getRemoteAddress(); // 192.168.0.1:51743 + ``` + +* Feature / BC break: Add `getLocalAddress()` method + (#68 by @clue) + + ```php + echo $connection->getLocalAddress(); // 127.0.0.1:8080 + ``` + +* BC break: The `Server` and `SecureServer` class are now marked `final` + and you can no longer `extend` them + (which was never documented or recommended anyway). + Public properties and event handlers are now internal only. + Please use composition instead of extension. + (#71, #70 and #69 by @clue) + +## 0.4.6 (2017-01-26) + +* Feature: Support socket context options passed to `Server` + (#64 by @clue) + +* Fix: Properly return `null` for unknown addresses + (#63 by @clue) + +* Improve documentation for `ServerInterface` and lock test suite requirements + (#60 by @clue, #57 by @shaunbramley) + +## 0.4.5 (2017-01-08) + +* Feature: Add `SecureServer` for secure TLS connections + (#55 by @clue) + +* Add functional integration tests + (#54 by @clue) + +## 0.4.4 (2016-12-19) + +* Feature / Fix: `ConnectionInterface` should extend `DuplexStreamInterface` + documentation + (#50 by @clue) + +* Feature / Fix: Improve test suite and switch to normal stream handler + (#51 by @clue) + +* Feature: Add examples + (#49 by @clue) + +## 0.4.3 (2016-03-01) + +* Bug fix: Suppress errors on stream_socket_accept to prevent PHP from crashing +* Support for PHP7 and HHVM +* Support PHP 5.3 again + +## 0.4.2 (2014-05-25) + +* Verify stream is a valid resource in Connection + +## 0.4.1 (2014-04-13) + +* Bug fix: Check read buffer for data before shutdown signal and end emit (@ArtyDev) +* Bug fix: v0.3.4 changes merged for v0.4.1 + +## 0.3.4 (2014-03-30) + +* Bug fix: Reset socket to non-blocking after shutting down (PHP bug) + +## 0.4.0 (2014-02-02) + +* BC break: Bump minimum PHP version to PHP 5.4, remove 5.3 specific hacks +* BC break: Update to React/Promise 2.0 +* BC break: Update to Evenement 2.0 +* Dependency: Autoloading and filesystem structure now PSR-4 instead of PSR-0 +* Bump React dependencies to v0.4 + +## 0.3.3 (2013-07-08) + +* Version bump + +## 0.3.2 (2013-05-10) + +* Version bump + +## 0.3.1 (2013-04-21) + +* Feature: Support binding to IPv6 addresses (@clue) + +## 0.3.0 (2013-04-14) + +* Bump React dependencies to v0.3 + +## 0.2.6 (2012-12-26) + +* Version bump + +## 0.2.3 (2012-11-14) + +* Version bump + +## 0.2.0 (2012-09-10) + +* Bump React dependencies to v0.2 + +## 0.1.1 (2012-07-12) + +* Version bump + +## 0.1.0 (2012-07-11) + +* First tagged release diff --git a/Connection.php b/Connection.php deleted file mode 100644 index 3342d1b0..00000000 --- a/Connection.php +++ /dev/null @@ -1,38 +0,0 @@ -bufferSize); - if ('' === $data || false === $data || feof($stream)) { - $this->end(); - } else { - $this->emit('data', array($data, $this)); - } - } - - public function handleClose() - { - if (is_resource($this->stream)) { - stream_socket_shutdown($this->stream, STREAM_SHUT_RDWR); - fclose($this->stream); - } - } - - public function getRemoteAddress() - { - return $this->parseAddress(stream_socket_get_name($this->stream, true)); - } - - private function parseAddress($address) - { - return trim(substr($address, 0, strrpos($address, ':')), '[]'); - } -} diff --git a/ConnectionException.php b/ConnectionException.php deleted file mode 100644 index 72b10280..00000000 --- a/ConnectionException.php +++ /dev/null @@ -1,7 +0,0 @@ - **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/socket/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. -The server can listen on a port and will emit a `connection` event whenever a -client connects. +The socket library provides re-usable interfaces for a socket-layer +server and client based on the [`EventLoop`](https://github.com/reactphp/event-loop) +and [`Stream`](https://github.com/reactphp/stream) components. +Its server component allows you to build networking servers that accept incoming +connections from networking clients (such as an HTTP server). +Its client component allows you to build networking clients that establish +outgoing connections to networking servers (such as an HTTP or database client). +This library provides async, streaming means for all of this, so you can +handle multiple concurrent connections without blocking. -## Connection +**Table of Contents** -The connection is a readable and writable stream. It can be used in a server -or in a client context. +* [Quickstart example](#quickstart-example) +* [Connection usage](#connection-usage) + * [ConnectionInterface](#connectioninterface) + * [getRemoteAddress()](#getremoteaddress) + * [getLocalAddress()](#getlocaladdress) +* [Server usage](#server-usage) + * [ServerInterface](#serverinterface) + * [connection event](#connection-event) + * [error event](#error-event) + * [getAddress()](#getaddress) + * [pause()](#pause) + * [resume()](#resume) + * [close()](#close) + * [SocketServer](#socketserver) + * [Advanced server usage](#advanced-server-usage) + * [TcpServer](#tcpserver) + * [SecureServer](#secureserver) + * [UnixServer](#unixserver) + * [LimitingServer](#limitingserver) + * [getConnections()](#getconnections) +* [Client usage](#client-usage) + * [ConnectorInterface](#connectorinterface) + * [connect()](#connect) + * [Connector](#connector) + * [Advanced client usage](#advanced-client-usage) + * [TcpConnector](#tcpconnector) + * [HappyEyeBallsConnector](#happyeyeballsconnector) + * [DnsConnector](#dnsconnector) + * [SecureConnector](#secureconnector) + * [TimeoutConnector](#timeoutconnector) + * [UnixConnector](#unixconnector) + * [FixUriConnector](#fixeduriconnector) +* [Install](#install) +* [Tests](#tests) +* [License](#license) -## Usage +## Quickstart example -Here is a server that closes the connection if you send it anything. +Here is a server that closes the connection if you send it anything: - $loop = React\EventLoop\Factory::create(); +```php +$socket = new React\Socket\SocketServer('127.0.0.1:8080'); - $socket = new React\Socket\Server($loop); - $socket->on('connection', function ($conn) { - $conn->write("Hello there!\n"); - $conn->write("Welcome to this amazing server!\n"); - $conn->write("Here's a tip: don't say anything.\n"); +$socket->on('connection', function (React\Socket\ConnectionInterface $connection) { + $connection->write("Hello " . $connection->getRemoteAddress() . "!\n"); + $connection->write("Welcome to this amazing server!\n"); + $connection->write("Here's a tip: don't say anything.\n"); - $conn->on('data', function ($data) use ($conn) { - $conn->close(); - }); + $connection->on('data', function ($data) use ($connection) { + $connection->close(); }); - $socket->listen(1337); +}); +``` - $loop->run(); +See also the [examples](examples). + +Here's a client that outputs the output of said server and then attempts to +send it a string: + +```php +$connector = new React\Socket\Connector(); + +$connector->connect('127.0.0.1:8080')->then(function (React\Socket\ConnectionInterface $connection) { + $connection->pipe(new React\Stream\WritableResourceStream(STDOUT)); + $connection->write("Hello World!\n"); +}, function (Exception $e) { + echo 'Error: ' . $e->getMessage() . PHP_EOL; +}); +``` + +## Connection usage + +### ConnectionInterface + +The `ConnectionInterface` is used to represent any incoming and outgoing +connection, such as a normal TCP/IP connection. + +An incoming or outgoing connection is a duplex stream (both readable and +writable) that implements React's +[`DuplexStreamInterface`](https://github.com/reactphp/stream#duplexstreaminterface). +It contains additional properties for the local and remote address (client IP) +where this connection has been established to/from. + +Most commonly, instances implementing this `ConnectionInterface` are emitted +by all classes implementing the [`ServerInterface`](#serverinterface) and +used by all classes implementing the [`ConnectorInterface`](#connectorinterface). + +Because the `ConnectionInterface` implements the underlying +[`DuplexStreamInterface`](https://github.com/reactphp/stream#duplexstreaminterface) +you can use any of its events and methods as usual: + +```php +$connection->on('data', function ($chunk) { + echo $chunk; +}); + +$connection->on('end', function () { + echo 'ended'; +}); + +$connection->on('error', function (Exception $e) { + echo 'error: ' . $e->getMessage(); +}); + +$connection->on('close', function () { + echo 'closed'; +}); + +$connection->write($data); +$connection->end($data = null); +$connection->close(); +// … +``` + +For more details, see the +[`DuplexStreamInterface`](https://github.com/reactphp/stream#duplexstreaminterface). + +#### getRemoteAddress() + +The `getRemoteAddress(): ?string` method returns the full remote address +(URI) where this connection has been established with. + +```php +$address = $connection->getRemoteAddress(); +echo 'Connection with ' . $address . PHP_EOL; +``` + +If the remote address can not be determined or is unknown at this time (such as +after the connection has been closed), it MAY return a `NULL` value instead. + +Otherwise, it will return the full address (URI) as a string value, such +as `tcp://127.0.0.1:8080`, `tcp://[::1]:80`, `tls://127.0.0.1:443`, +`unix://example.sock` or `unix:///path/to/example.sock`. +Note that individual URI components are application specific and depend +on the underlying transport protocol. + +If this is a TCP/IP based connection and you only want the remote IP, you may +use something like this: + +```php +$address = $connection->getRemoteAddress(); +$ip = trim(parse_url($address, PHP_URL_HOST), '[]'); +echo 'Connection with ' . $ip . PHP_EOL; +``` + +#### getLocalAddress() + +The `getLocalAddress(): ?string` method returns the full local address +(URI) where this connection has been established with. + +```php +$address = $connection->getLocalAddress(); +echo 'Connection with ' . $address . PHP_EOL; +``` + +If the local address can not be determined or is unknown at this time (such as +after the connection has been closed), it MAY return a `NULL` value instead. + +Otherwise, it will return the full address (URI) as a string value, such +as `tcp://127.0.0.1:8080`, `tcp://[::1]:80`, `tls://127.0.0.1:443`, +`unix://example.sock` or `unix:///path/to/example.sock`. +Note that individual URI components are application specific and depend +on the underlying transport protocol. + +This method complements the [`getRemoteAddress()`](#getremoteaddress) method, +so they should not be confused. + +If your `TcpServer` instance is listening on multiple interfaces (e.g. using +the address `0.0.0.0`), you can use this method to find out which interface +actually accepted this connection (such as a public or local interface). + +If your system has multiple interfaces (e.g. a WAN and a LAN interface), +you can use this method to find out which interface was actually +used for this connection. + +## Server usage + +### ServerInterface + +The `ServerInterface` is responsible for providing an interface for accepting +incoming streaming connections, such as a normal TCP/IP connection. + +Most higher-level components (such as a HTTP server) accept an instance +implementing this interface to accept incoming streaming connections. +This is usually done via dependency injection, so it's fairly simple to actually +swap this implementation against any other implementation of this interface. +This means that you SHOULD typehint against this interface instead of a concrete +implementation of this interface. + +Besides defining a few methods, this interface also implements the +[`EventEmitterInterface`](https://github.com/igorw/evenement) +which allows you to react to certain events. + +#### connection event + +The `connection` event will be emitted whenever a new connection has been +established, i.e. a new client connects to this server socket: + +```php +$socket->on('connection', function (React\Socket\ConnectionInterface $connection) { + echo 'new connection' . PHP_EOL; +}); +``` + +See also the [`ConnectionInterface`](#connectioninterface) for more details +about handling the incoming connection. + +#### error event + +The `error` event will be emitted whenever there's an error accepting a new +connection from a client. + +```php +$socket->on('error', function (Exception $e) { + echo 'error: ' . $e->getMessage() . PHP_EOL; +}); +``` + +Note that this is not a fatal error event, i.e. the server keeps listening for +new connections even after this event. + +#### getAddress() + +The `getAddress(): ?string` method can be used to +return the full address (URI) this server is currently listening on. + +```php +$address = $socket->getAddress(); +echo 'Server listening on ' . $address . PHP_EOL; +``` + +If the address can not be determined or is unknown at this time (such as +after the socket has been closed), it MAY return a `NULL` value instead. + +Otherwise, it will return the full address (URI) as a string value, such +as `tcp://127.0.0.1:8080`, `tcp://[::1]:80`, `tls://127.0.0.1:443` +`unix://example.sock` or `unix:///path/to/example.sock`. +Note that individual URI components are application specific and depend +on the underlying transport protocol. + +If this is a TCP/IP based server and you only want the local port, you may +use something like this: + +```php +$address = $socket->getAddress(); +$port = parse_url($address, PHP_URL_PORT); +echo 'Server listening on port ' . $port . PHP_EOL; +``` + +#### pause() + +The `pause(): void` method can be used to +pause accepting new incoming connections. + +Removes the socket resource from the EventLoop and thus stop accepting +new connections. Note that the listening socket stays active and is not +closed. + +This means that new incoming connections will stay pending in the +operating system backlog until its configurable backlog is filled. +Once the backlog is filled, the operating system may reject further +incoming connections until the backlog is drained again by resuming +to accept new connections. + +Once the server is paused, no futher `connection` events SHOULD +be emitted. + +```php +$socket->pause(); + +$socket->on('connection', assertShouldNeverCalled()); +``` + +This method is advisory-only, though generally not recommended, the +server MAY continue emitting `connection` events. + +Unless otherwise noted, a successfully opened server SHOULD NOT start +in paused state. + +You can continue processing events by calling `resume()` again. + +Note that both methods can be called any number of times, in particular +calling `pause()` more than once SHOULD NOT have any effect. +Similarly, calling this after `close()` is a NO-OP. + +#### resume() + +The `resume(): void` method can be used to +resume accepting new incoming connections. + +Re-attach the socket resource to the EventLoop after a previous `pause()`. + +```php +$socket->pause(); + +Loop::addTimer(1.0, function () use ($socket) { + $socket->resume(); +}); +``` + +Note that both methods can be called any number of times, in particular +calling `resume()` without a prior `pause()` SHOULD NOT have any effect. +Similarly, calling this after `close()` is a NO-OP. + +#### close() + +The `close(): void` method can be used to +shut down this listening socket. + +This will stop listening for new incoming connections on this socket. + +```php +echo 'Shutting down server socket' . PHP_EOL; +$socket->close(); +``` + +Calling this method more than once on the same instance is a NO-OP. + +### SocketServer + +The `SocketServer` class is the main class in this package that implements the +[`ServerInterface`](#serverinterface) and allows you to accept incoming +streaming connections, such as plaintext TCP/IP or secure TLS connection streams. + +In order to accept plaintext TCP/IP connections, you can simply pass a host +and port combination like this: + +```php +$socket = new React\Socket\SocketServer('127.0.0.1:8080'); +``` + +Listening on the localhost address `127.0.0.1` means it will not be reachable from +outside of this system. +In order to change the host the socket is listening on, you can provide an IP +address of an interface or use the special `0.0.0.0` address to listen on all +interfaces: + +```php +$socket = new React\Socket\SocketServer('0.0.0.0:8080'); +``` + +If you want to listen on an IPv6 address, you MUST enclose the host in square +brackets: + +```php +$socket = new React\Socket\SocketServer('[::1]:8080'); +``` + +In order to use a random port assignment, you can use the port `0`: + +```php +$socket = new React\Socket\SocketServer('127.0.0.1:0'); +$address = $socket->getAddress(); +``` + +To listen on a Unix domain socket (UDS) path, you MUST prefix the URI with the +`unix://` scheme: + +```php +$socket = new React\Socket\SocketServer('unix:///tmp/server.sock'); +``` + +In order to listen on an existing file descriptor (FD) number, you MUST prefix +the URI with `php://fd/` like this: + +```php +$socket = new React\Socket\SocketServer('php://fd/3'); +``` + +If the given URI is invalid, does not contain a port, any other scheme or if it +contains a hostname, it will throw an `InvalidArgumentException`: + +```php +// throws InvalidArgumentException due to missing port +$socket = new React\Socket\SocketServer('127.0.0.1'); +``` + +If the given URI appears to be valid, but listening on it fails (such as if port +is already in use or port below 1024 may require root access etc.), it will +throw a `RuntimeException`: + +```php +$first = new React\Socket\SocketServer('127.0.0.1:8080'); + +// throws RuntimeException because port is already in use +$second = new React\Socket\SocketServer('127.0.0.1:8080'); +``` + +> Note that these error conditions may vary depending on your system and/or + configuration. + See the exception message and code for more details about the actual error + condition. + +Optionally, you can specify [TCP socket context options](https://www.php.net/manual/en/context.socket.php) +for the underlying stream socket resource like this: + +```php +$socket = new React\Socket\SocketServer('[::1]:8080', [ + 'tcp' => [ + 'backlog' => 200, + 'so_reuseport' => true, + 'ipv6_v6only' => true + ] +]); +``` + +> Note that available [socket context options](https://www.php.net/manual/en/context.socket.php), + their defaults and effects of changing these may vary depending on your system + and/or PHP version. + Passing unknown context options has no effect. + The `backlog` context option defaults to `511` unless given explicitly. + +You can start a secure TLS (formerly known as SSL) server by simply prepending +the `tls://` URI scheme. +Internally, it will wait for plaintext TCP/IP connections and then performs a +TLS handshake for each connection. +It thus requires valid [TLS context options](https://www.php.net/manual/en/context.ssl.php), +which in its most basic form may look something like this if you're using a +PEM encoded certificate file: + +```php +$socket = new React\Socket\SocketServer('tls://127.0.0.1:8080', [ + 'tls' => [ + 'local_cert' => 'server.pem' + ] +]); +``` + +> Note that the certificate file will not be loaded on instantiation but when an + incoming connection initializes its TLS context. + This implies that any invalid certificate file paths or contents will only cause + an `error` event at a later time. + +If your private key is encrypted with a passphrase, you have to specify it +like this: + +```php +$socket = new React\Socket\SocketServer('tls://127.0.0.1:8000', [ + 'tls' => [ + 'local_cert' => 'server.pem', + 'passphrase' => 'secret' + ] +]); +``` + +By default, this server supports TLSv1.0+ and excludes support for legacy +SSLv2/SSLv3. You can also explicitly choose the TLS version you +want to negotiate with the remote side: + +```php +$socket = new React\Socket\SocketServer('tls://127.0.0.1:8000', [ + 'tls' => [ + 'local_cert' => 'server.pem', + 'crypto_method' => STREAM_CRYPTO_METHOD_TLSv1_2_SERVER + ] +]); +``` + +> Note that available [TLS context options](https://www.php.net/manual/en/context.ssl.php), + their defaults and effects of changing these may vary depending on your system + and/or PHP version. + The outer context array allows you to also use `tcp` (and possibly more) + context options at the same time. + Passing unknown context options has no effect. + If you do not use the `tls://` scheme, then passing `tls` context options + has no effect. + +Whenever a client connects, it will emit a `connection` event with a connection +instance implementing [`ConnectionInterface`](#connectioninterface): + +```php +$socket->on('connection', function (React\Socket\ConnectionInterface $connection) { + echo 'Plaintext connection from ' . $connection->getRemoteAddress() . PHP_EOL; -You can change the host the socket is listening on through a second parameter -provided to the listen method: + $connection->write('hello there!' . PHP_EOL); + … +}); +``` - $socket->listen(1337, '192.168.0.1'); +See also the [`ServerInterface`](#serverinterface) for more details. -Here's a client that outputs the output of said server and then attempts to -send it a string. +This class takes an optional `LoopInterface|null $loop` parameter that can be used to +pass the event loop instance to use for this object. You can use a `null` value +here in order to use the [default loop](https://github.com/reactphp/event-loop#loop). +This value SHOULD NOT be given unless you're sure you want to explicitly use a +given event loop instance. + +> Note that the `SocketServer` class is a concrete implementation for TCP/IP sockets. + If you want to typehint in your higher-level protocol implementation, you SHOULD + use the generic [`ServerInterface`](#serverinterface) instead. + +### Advanced server usage + +#### TcpServer + +The `TcpServer` class implements the [`ServerInterface`](#serverinterface) and +is responsible for accepting plaintext TCP/IP connections. + +```php +$server = new React\Socket\TcpServer(8080); +``` + +As above, the `$uri` parameter can consist of only a port, in which case the +server will default to listening on the localhost address `127.0.0.1`, +which means it will not be reachable from outside of this system. + +In order to use a random port assignment, you can use the port `0`: + +```php +$server = new React\Socket\TcpServer(0); +$address = $server->getAddress(); +``` + +In order to change the host the socket is listening on, you can provide an IP +address through the first parameter provided to the constructor, optionally +preceded by the `tcp://` scheme: + +```php +$server = new React\Socket\TcpServer('192.168.0.1:8080'); +``` + +If you want to listen on an IPv6 address, you MUST enclose the host in square +brackets: + +```php +$server = new React\Socket\TcpServer('[::1]:8080'); +``` + +If the given URI is invalid, does not contain a port, any other scheme or if it +contains a hostname, it will throw an `InvalidArgumentException`: + +```php +// throws InvalidArgumentException due to missing port +$server = new React\Socket\TcpServer('127.0.0.1'); +``` + +If the given URI appears to be valid, but listening on it fails (such as if port +is already in use or port below 1024 may require root access etc.), it will +throw a `RuntimeException`: + +```php +$first = new React\Socket\TcpServer(8080); + +// throws RuntimeException because port is already in use +$second = new React\Socket\TcpServer(8080); +``` + +> Note that these error conditions may vary depending on your system and/or +configuration. +See the exception message and code for more details about the actual error +condition. + +This class takes an optional `LoopInterface|null $loop` parameter that can be used to +pass the event loop instance to use for this object. You can use a `null` value +here in order to use the [default loop](https://github.com/reactphp/event-loop#loop). +This value SHOULD NOT be given unless you're sure you want to explicitly use a +given event loop instance. + +Optionally, you can specify [socket context options](https://www.php.net/manual/en/context.socket.php) +for the underlying stream socket resource like this: + +```php +$server = new React\Socket\TcpServer('[::1]:8080', null, [ + 'backlog' => 200, + 'so_reuseport' => true, + 'ipv6_v6only' => true +]); +``` + +> Note that available [socket context options](https://www.php.net/manual/en/context.socket.php), +their defaults and effects of changing these may vary depending on your system +and/or PHP version. +Passing unknown context options has no effect. +The `backlog` context option defaults to `511` unless given explicitly. + +Whenever a client connects, it will emit a `connection` event with a connection +instance implementing [`ConnectionInterface`](#connectioninterface): + +```php +$server->on('connection', function (React\Socket\ConnectionInterface $connection) { + echo 'Plaintext connection from ' . $connection->getRemoteAddress() . PHP_EOL; + + $connection->write('hello there!' . PHP_EOL); + … +}); +``` + +See also the [`ServerInterface`](#serverinterface) for more details. + +#### SecureServer + +The `SecureServer` class implements the [`ServerInterface`](#serverinterface) +and is responsible for providing a secure TLS (formerly known as SSL) server. + +It does so by wrapping a [`TcpServer`](#tcpserver) instance which waits for plaintext +TCP/IP connections and then performs a TLS handshake for each connection. +It thus requires valid [TLS context options](https://www.php.net/manual/en/context.ssl.php), +which in its most basic form may look something like this if you're using a +PEM encoded certificate file: + +```php +$server = new React\Socket\TcpServer(8000); +$server = new React\Socket\SecureServer($server, null, [ + 'local_cert' => 'server.pem' +]); +``` + +> Note that the certificate file will not be loaded on instantiation but when an +incoming connection initializes its TLS context. +This implies that any invalid certificate file paths or contents will only cause +an `error` event at a later time. + +If your private key is encrypted with a passphrase, you have to specify it +like this: + +```php +$server = new React\Socket\TcpServer(8000); +$server = new React\Socket\SecureServer($server, null, [ + 'local_cert' => 'server.pem', + 'passphrase' => 'secret' +]); +``` + +By default, this server supports TLSv1.0+ and excludes support for legacy +SSLv2/SSLv3. You can also explicitly choose the TLS version you +want to negotiate with the remote side: + +```php +$server = new React\Socket\TcpServer(8000); +$server = new React\Socket\SecureServer($server, null, [ + 'local_cert' => 'server.pem', + 'crypto_method' => STREAM_CRYPTO_METHOD_TLSv1_2_SERVER +]); +``` + +> Note that available [TLS context options](https://www.php.net/manual/en/context.ssl.php), +their defaults and effects of changing these may vary depending on your system +and/or PHP version. +Passing unknown context options has no effect. + +Whenever a client completes the TLS handshake, it will emit a `connection` event +with a connection instance implementing [`ConnectionInterface`](#connectioninterface): + +```php +$server->on('connection', function (React\Socket\ConnectionInterface $connection) { + echo 'Secure connection from' . $connection->getRemoteAddress() . PHP_EOL; + + $connection->write('hello there!' . PHP_EOL); + … +}); +``` + +Whenever a client fails to perform a successful TLS handshake, it will emit an +`error` event and then close the underlying TCP/IP connection: + +```php +$server->on('error', function (Exception $e) { + echo 'Error' . $e->getMessage() . PHP_EOL; +}); +``` + +See also the [`ServerInterface`](#serverinterface) for more details. + +Note that the `SecureServer` class is a concrete implementation for TLS sockets. +If you want to typehint in your higher-level protocol implementation, you SHOULD +use the generic [`ServerInterface`](#serverinterface) instead. + +This class takes an optional `LoopInterface|null $loop` parameter that can be used to +pass the event loop instance to use for this object. You can use a `null` value +here in order to use the [default loop](https://github.com/reactphp/event-loop#loop). +This value SHOULD NOT be given unless you're sure you want to explicitly use a +given event loop instance. + +> Advanced usage: Despite allowing any `ServerInterface` as first parameter, +you SHOULD pass a `TcpServer` instance as first parameter, unless you +know what you're doing. +Internally, the `SecureServer` has to set the required TLS context options on +the underlying stream resources. +These resources are not exposed through any of the interfaces defined in this +package, but only through the internal `Connection` class. +The `TcpServer` class is guaranteed to emit connections that implement +the `ConnectionInterface` and uses the internal `Connection` class in order to +expose these underlying resources. +If you use a custom `ServerInterface` and its `connection` event does not +meet this requirement, the `SecureServer` will emit an `error` event and +then close the underlying connection. + +#### UnixServer + +The `UnixServer` class implements the [`ServerInterface`](#serverinterface) and +is responsible for accepting connections on Unix domain sockets (UDS). + +```php +$server = new React\Socket\UnixServer('/tmp/server.sock'); +``` + +As above, the `$uri` parameter can consist of only a socket path or socket path +prefixed by the `unix://` scheme. + +If the given URI appears to be valid, but listening on it fails (such as if the +socket is already in use or the file not accessible etc.), it will throw a +`RuntimeException`: + +```php +$first = new React\Socket\UnixServer('/tmp/same.sock'); + +// throws RuntimeException because socket is already in use +$second = new React\Socket\UnixServer('/tmp/same.sock'); +``` + +> Note that these error conditions may vary depending on your system and/or + configuration. + In particular, Zend PHP does only report "Unknown error" when the UDS path + already exists and can not be bound. You may want to check `is_file()` on the + given UDS path to report a more user-friendly error message in this case. + See the exception message and code for more details about the actual error + condition. + +This class takes an optional `LoopInterface|null $loop` parameter that can be used to +pass the event loop instance to use for this object. You can use a `null` value +here in order to use the [default loop](https://github.com/reactphp/event-loop#loop). +This value SHOULD NOT be given unless you're sure you want to explicitly use a +given event loop instance. + +Whenever a client connects, it will emit a `connection` event with a connection +instance implementing [`ConnectionInterface`](#connectioninterface): + +```php +$server->on('connection', function (React\Socket\ConnectionInterface $connection) { + echo 'New connection' . PHP_EOL; + + $connection->write('hello there!' . PHP_EOL); + … +}); +``` + +See also the [`ServerInterface`](#serverinterface) for more details. + +#### LimitingServer + +The `LimitingServer` decorator wraps a given `ServerInterface` and is responsible +for limiting and keeping track of open connections to this server instance. + +Whenever the underlying server emits a `connection` event, it will check its +limits and then either + - keep track of this connection by adding it to the list of + open connections and then forward the `connection` event + - or reject (close) the connection when its limits are exceeded and will + forward an `error` event instead. + +Whenever a connection closes, it will remove this connection from the list of +open connections. + +```php +$server = new React\Socket\LimitingServer($server, 100); +$server->on('connection', function (React\Socket\ConnectionInterface $connection) { + $connection->write('hello there!' . PHP_EOL); + … +}); +``` + +See also the [second example](examples) for more details. + +You have to pass a maximum number of open connections to ensure +the server will automatically reject (close) connections once this limit +is exceeded. In this case, it will emit an `error` event to inform about +this and no `connection` event will be emitted. + +```php +$server = new React\Socket\LimitingServer($server, 100); +$server->on('connection', function (React\Socket\ConnectionInterface $connection) { + $connection->write('hello there!' . PHP_EOL); + … +}); +``` + +You MAY pass a `null` limit in order to put no limit on the number of +open connections and keep accepting new connection until you run out of +operating system resources (such as open file handles). This may be +useful if you do not want to take care of applying a limit but still want +to use the `getConnections()` method. + +You can optionally configure the server to pause accepting new +connections once the connection limit is reached. In this case, it will +pause the underlying server and no longer process any new connections at +all, thus also no longer closing any excessive connections. +The underlying operating system is responsible for keeping a backlog of +pending connections until its limit is reached, at which point it will +start rejecting further connections. +Once the server is below the connection limit, it will continue consuming +connections from the backlog and will process any outstanding data on +each connection. +This mode may be useful for some protocols that are designed to wait for +a response message (such as HTTP), but may be less useful for other +protocols that demand immediate responses (such as a "welcome" message in +an interactive chat). + +```php +$server = new React\Socket\LimitingServer($server, 100, true); +$server->on('connection', function (React\Socket\ConnectionInterface $connection) { + $connection->write('hello there!' . PHP_EOL); + … +}); +``` + +##### getConnections() + +The `getConnections(): ConnectionInterface[]` method can be used to +return an array with all currently active connections. + +```php +foreach ($server->getConnection() as $connection) { + $connection->write('Hi!'); +} +``` + +## Client usage + +### ConnectorInterface + +The `ConnectorInterface` is responsible for providing an interface for +establishing streaming connections, such as a normal TCP/IP connection. + +This is the main interface defined in this package and it is used throughout +React's vast ecosystem. + +Most higher-level components (such as HTTP, database or other networking +service clients) accept an instance implementing this interface to create their +TCP/IP connection to the underlying networking service. +This is usually done via dependency injection, so it's fairly simple to actually +swap this implementation against any other implementation of this interface. + +The interface only offers a single method: + +#### connect() + +The `connect(string $uri): PromiseInterface` method can be used to +create a streaming connection to the given remote address. + +It returns a [Promise](https://github.com/reactphp/promise) which either +fulfills with a stream implementing [`ConnectionInterface`](#connectioninterface) +on success or rejects with an `Exception` if the connection is not successful: + +```php +$connector->connect('google.com:443')->then( + function (React\Socket\ConnectionInterface $connection) { + // connection successfully established + }, + function (Exception $error) { + // failed to connect due to $error + } +); +``` + +See also [`ConnectionInterface`](#connectioninterface) for more details. + +The returned Promise MUST be implemented in such a way that it can be +cancelled when it is still pending. Cancelling a pending promise MUST +reject its value with an `Exception`. It SHOULD clean up any underlying +resources and references as applicable: + +```php +$promise = $connector->connect($uri); + +$promise->cancel(); +``` + +### Connector + +The `Connector` class is the main class in this package that implements the +[`ConnectorInterface`](#connectorinterface) and allows you to create streaming connections. + +You can use this connector to create any kind of streaming connections, such +as plaintext TCP/IP, secure TLS or local Unix connection streams. + +It binds to the main event loop and can be used like this: + +```php +$connector = new React\Socket\Connector(); + +$connector->connect($uri)->then(function (React\Socket\ConnectionInterface $connection) { + $connection->write('...'); + $connection->end(); +}, function (Exception $e) { + echo 'Error: ' . $e->getMessage() . PHP_EOL; +}); +``` + +In order to create a plaintext TCP/IP connection, you can simply pass a host +and port combination like this: + +```php +$connector->connect('www.google.com:80')->then(function (React\Socket\ConnectionInterface $connection) { + $connection->write('...'); + $connection->end(); +}); +``` + +> If you do no specify a URI scheme in the destination URI, it will assume + `tcp://` as a default and establish a plaintext TCP/IP connection. + Note that TCP/IP connections require a host and port part in the destination + URI like above, all other URI components are optional. + +In order to create a secure TLS connection, you can use the `tls://` URI scheme +like this: + +```php +$connector->connect('tls://www.google.com:443')->then(function (React\Socket\ConnectionInterface $connection) { + $connection->write('...'); + $connection->end(); +}); +``` + +In order to create a local Unix domain socket connection, you can use the +`unix://` URI scheme like this: + +```php +$connector->connect('unix:///tmp/demo.sock')->then(function (React\Socket\ConnectionInterface $connection) { + $connection->write('...'); + $connection->end(); +}); +``` + +> The [`getRemoteAddress()`](#getremoteaddress) method will return the target + Unix domain socket (UDS) path as given to the `connect()` method, including + the `unix://` scheme, for example `unix:///tmp/demo.sock`. + The [`getLocalAddress()`](#getlocaladdress) method will most likely return a + `null` value as this value is not applicable to UDS connections here. + +Under the hood, the `Connector` is implemented as a *higher-level facade* +for the lower-level connectors implemented in this package. This means it +also shares all of their features and implementation details. +If you want to typehint in your higher-level protocol implementation, you SHOULD +use the generic [`ConnectorInterface`](#connectorinterface) instead. + +As of `v1.4.0`, the `Connector` class defaults to using the +[happy eyeballs algorithm](https://en.wikipedia.org/wiki/Happy_Eyeballs) to +automatically connect over IPv4 or IPv6 when a hostname is given. +This automatically attempts to connect using both IPv4 and IPv6 at the same time +(preferring IPv6), thus avoiding the usual problems faced by users with imperfect +IPv6 connections or setups. +If you want to revert to the old behavior of only doing an IPv4 lookup and +only attempt a single IPv4 connection, you can set up the `Connector` like this: + +```php +$connector = new React\Socket\Connector([ + 'happy_eyeballs' => false +]); +``` + +Similarly, you can also affect the default DNS behavior as follows. +The `Connector` class will try to detect your system DNS settings (and uses +Google's public DNS server `8.8.8.8` as a fallback if unable to determine your +system settings) to resolve all public hostnames into underlying IP addresses by +default. +If you explicitly want to use a custom DNS server (such as a local DNS relay or +a company wide DNS server), you can set up the `Connector` like this: + +```php +$connector = new React\Socket\Connector([ + 'dns' => '127.0.1.1' +]); + +$connector->connect('localhost:80')->then(function (React\Socket\ConnectionInterface $connection) { + $connection->write('...'); + $connection->end(); +}); +``` + +If you do not want to use a DNS resolver at all and want to connect to IP +addresses only, you can also set up your `Connector` like this: + +```php +$connector = new React\Socket\Connector([ + 'dns' => false +]); + +$connector->connect('127.0.0.1:80')->then(function (React\Socket\ConnectionInterface $connection) { + $connection->write('...'); + $connection->end(); +}); +``` + +Advanced: If you need a custom DNS `React\Dns\Resolver\ResolverInterface` instance, you +can also set up your `Connector` like this: + +```php +$dnsResolverFactory = new React\Dns\Resolver\Factory(); +$resolver = $dnsResolverFactory->createCached('127.0.1.1'); + +$connector = new React\Socket\Connector([ + 'dns' => $resolver +]); + +$connector->connect('localhost:80')->then(function (React\Socket\ConnectionInterface $connection) { + $connection->write('...'); + $connection->end(); +}); +``` + +By default, the `tcp://` and `tls://` URI schemes will use timeout value that +respects your `default_socket_timeout` ini setting (which defaults to 60s). +If you want a custom timeout value, you can simply pass this like this: + +```php +$connector = new React\Socket\Connector([ + 'timeout' => 10.0 +]); +``` + +Similarly, if you do not want to apply a timeout at all and let the operating +system handle this, you can pass a boolean flag like this: + +```php +$connector = new React\Socket\Connector([ + 'timeout' => false +]); +``` + +By default, the `Connector` supports the `tcp://`, `tls://` and `unix://` +URI schemes. If you want to explicitly prohibit any of these, you can simply +pass boolean flags like this: + +```php +// only allow secure TLS connections +$connector = new React\Socket\Connector([ + 'tcp' => false, + 'tls' => true, + 'unix' => false, +)); + +$connector->connect('tls://google.com:443')->then(function (React\Socket\ConnectionInterface $connection) { + $connection->write('...'); + $connection->end(); +}); +``` + +The `tcp://` and `tls://` also accept additional context options passed to +the underlying connectors. +If you want to explicitly pass additional context options, you can simply +pass arrays of context options like this: + +```php +// allow insecure TLS connections +$connector = new React\Socket\Connector([ + 'tcp' => [ + 'bindto' => '192.168.0.1:0' + ], + 'tls' => [ + 'verify_peer' => false, + 'verify_peer_name' => false + ], +]); + +$connector->connect('tls://localhost:443')->then(function (React\Socket\ConnectionInterface $connection) { + $connection->write('...'); + $connection->end(); +}); +``` + +By default, this connector supports TLSv1.0+ and excludes support for legacy +SSLv2/SSLv3. You can also explicitly choose the TLS version you +want to negotiate with the remote side: + +```php +$connector = new React\Socket\Connector([ + 'tls' => [ + 'crypto_method' => STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT + ] +]); +``` + +> For more details about context options, please refer to the PHP documentation + about [socket context options](https://www.php.net/manual/en/context.socket.php) + and [SSL context options](https://www.php.net/manual/en/context.ssl.php). + +Advanced: By default, the `Connector` supports the `tcp://`, `tls://` and +`unix://` URI schemes. +For this, it sets up the required connector classes automatically. +If you want to explicitly pass custom connectors for any of these, you can simply +pass an instance implementing the `ConnectorInterface` like this: + +```php +$dnsResolverFactory = new React\Dns\Resolver\Factory(); +$resolver = $dnsResolverFactory->createCached('127.0.1.1'); +$tcp = new React\Socket\HappyEyeBallsConnector(null, new React\Socket\TcpConnector(), $resolver); + +$tls = new React\Socket\SecureConnector($tcp); + +$unix = new React\Socket\UnixConnector(); + +$connector = new React\Socket\Connector([ + 'tcp' => $tcp, + 'tls' => $tls, + 'unix' => $unix, + + 'dns' => false, + 'timeout' => false, +]); + +$connector->connect('google.com:80')->then(function (React\Socket\ConnectionInterface $connection) { + $connection->write('...'); + $connection->end(); +}); +``` + +> Internally, the `tcp://` connector will always be wrapped by the DNS resolver, + unless you disable DNS like in the above example. In this case, the `tcp://` + connector receives the actual hostname instead of only the resolved IP address + and is thus responsible for performing the lookup. + Internally, the automatically created `tls://` connector will always wrap the + underlying `tcp://` connector for establishing the underlying plaintext + TCP/IP connection before enabling secure TLS mode. If you want to use a custom + underlying `tcp://` connector for secure TLS connections only, you may + explicitly pass a `tls://` connector like above instead. + Internally, the `tcp://` and `tls://` connectors will always be wrapped by + `TimeoutConnector`, unless you disable timeouts like in the above example. + +This class takes an optional `LoopInterface|null $loop` parameter that can be used to +pass the event loop instance to use for this object. You can use a `null` value +here in order to use the [default loop](https://github.com/reactphp/event-loop#loop). +This value SHOULD NOT be given unless you're sure you want to explicitly use a +given event loop instance. + +### Advanced client usage + +#### TcpConnector + +The `TcpConnector` class implements the +[`ConnectorInterface`](#connectorinterface) and allows you to create plaintext +TCP/IP connections to any IP-port-combination: + +```php +$tcpConnector = new React\Socket\TcpConnector(); + +$tcpConnector->connect('127.0.0.1:80')->then(function (React\Socket\ConnectionInterface $connection) { + $connection->write('...'); + $connection->end(); +}); +``` + +See also the [examples](examples). + +Pending connection attempts can be cancelled by cancelling its pending promise like so: + +```php +$promise = $tcpConnector->connect('127.0.0.1:80'); + +$promise->cancel(); +``` + +Calling `cancel()` on a pending promise will close the underlying socket +resource, thus cancelling the pending TCP/IP connection, and reject the +resulting promise. + +This class takes an optional `LoopInterface|null $loop` parameter that can be used to +pass the event loop instance to use for this object. You can use a `null` value +here in order to use the [default loop](https://github.com/reactphp/event-loop#loop). +This value SHOULD NOT be given unless you're sure you want to explicitly use a +given event loop instance. + +You can optionally pass additional +[socket context options](https://www.php.net/manual/en/context.socket.php) +to the constructor like this: + +```php +$tcpConnector = new React\Socket\TcpConnector(null, [ + 'bindto' => '192.168.0.1:0' +]); +``` + +Note that this class only allows you to connect to IP-port-combinations. +If the given URI is invalid, does not contain a valid IP address and port +or contains any other scheme, it will reject with an +`InvalidArgumentException`: + +If the given URI appears to be valid, but connecting to it fails (such as if +the remote host rejects the connection etc.), it will reject with a +`RuntimeException`. + +If you want to connect to hostname-port-combinations, see also the following chapter. + +> Advanced usage: Internally, the `TcpConnector` allocates an empty *context* +resource for each stream resource. +If the destination URI contains a `hostname` query parameter, its value will +be used to set up the TLS peer name. +This is used by the `SecureConnector` and `DnsConnector` to verify the peer +name and can also be used if you want a custom TLS peer name. + +#### HappyEyeBallsConnector + +The `HappyEyeBallsConnector` class implements the +[`ConnectorInterface`](#connectorinterface) and allows you to create plaintext +TCP/IP connections to any hostname-port-combination. Internally it implements the +happy eyeballs algorithm from [`RFC6555`](https://tools.ietf.org/html/rfc6555) and +[`RFC8305`](https://tools.ietf.org/html/rfc8305) to support IPv6 and IPv4 hostnames. + +It does so by decorating a given `TcpConnector` instance so that it first +looks up the given domain name via DNS (if applicable) and then establishes the +underlying TCP/IP connection to the resolved target IP address. + +Make sure to set up your DNS resolver and underlying TCP connector like this: + +```php +$dnsResolverFactory = new React\Dns\Resolver\Factory(); +$dns = $dnsResolverFactory->createCached('8.8.8.8'); + +$dnsConnector = new React\Socket\HappyEyeBallsConnector(null, $tcpConnector, $dns); + +$dnsConnector->connect('www.google.com:80')->then(function (React\Socket\ConnectionInterface $connection) { + $connection->write('...'); + $connection->end(); +}); +``` + +See also the [examples](examples). + +Pending connection attempts can be cancelled by cancelling its pending promise like so: + +```php +$promise = $dnsConnector->connect('www.google.com:80'); + +$promise->cancel(); +``` + +Calling `cancel()` on a pending promise will cancel the underlying DNS lookups +and/or the underlying TCP/IP connection(s) and reject the resulting promise. + +This class takes an optional `LoopInterface|null $loop` parameter that can be used to +pass the event loop instance to use for this object. You can use a `null` value +here in order to use the [default loop](https://github.com/reactphp/event-loop#loop). +This value SHOULD NOT be given unless you're sure you want to explicitly use a +given event loop instance. + +> Advanced usage: Internally, the `HappyEyeBallsConnector` relies on a `Resolver` to +look up the IP addresses for the given hostname. +It will then replace the hostname in the destination URI with this IP's and +append a `hostname` query parameter and pass this updated URI to the underlying +connector. +The Happy Eye Balls algorithm describes looking the IPv6 and IPv4 address for +the given hostname so this connector sends out two DNS lookups for the A and +AAAA records. It then uses all IP addresses (both v6 and v4) and tries to +connect to all of them with a 50ms interval in between. Alterating between IPv6 +and IPv4 addresses. When a connection is established all the other DNS lookups +and connection attempts are cancelled. + +#### DnsConnector + +The `DnsConnector` class implements the +[`ConnectorInterface`](#connectorinterface) and allows you to create plaintext +TCP/IP connections to any hostname-port-combination. + +It does so by decorating a given `TcpConnector` instance so that it first +looks up the given domain name via DNS (if applicable) and then establishes the +underlying TCP/IP connection to the resolved target IP address. + +Make sure to set up your DNS resolver and underlying TCP connector like this: + +```php +$dnsResolverFactory = new React\Dns\Resolver\Factory(); +$dns = $dnsResolverFactory->createCached('8.8.8.8'); + +$dnsConnector = new React\Socket\DnsConnector($tcpConnector, $dns); + +$dnsConnector->connect('www.google.com:80')->then(function (React\Socket\ConnectionInterface $connection) { + $connection->write('...'); + $connection->end(); +}); +``` + +See also the [examples](examples). + +Pending connection attempts can be cancelled by cancelling its pending promise like so: + +```php +$promise = $dnsConnector->connect('www.google.com:80'); + +$promise->cancel(); +``` + +Calling `cancel()` on a pending promise will cancel the underlying DNS lookup +and/or the underlying TCP/IP connection and reject the resulting promise. + +> Advanced usage: Internally, the `DnsConnector` relies on a `React\Dns\Resolver\ResolverInterface` +to look up the IP address for the given hostname. +It will then replace the hostname in the destination URI with this IP and +append a `hostname` query parameter and pass this updated URI to the underlying +connector. +The underlying connector is thus responsible for creating a connection to the +target IP address, while this query parameter can be used to check the original +hostname and is used by the `TcpConnector` to set up the TLS peer name. +If a `hostname` is given explicitly, this query parameter will not be modified, +which can be useful if you want a custom TLS peer name. + +#### SecureConnector + +The `SecureConnector` class implements the +[`ConnectorInterface`](#connectorinterface) and allows you to create secure +TLS (formerly known as SSL) connections to any hostname-port-combination. + +It does so by decorating a given `DnsConnector` instance so that it first +creates a plaintext TCP/IP connection and then enables TLS encryption on this +stream. + +```php +$secureConnector = new React\Socket\SecureConnector($dnsConnector); + +$secureConnector->connect('www.google.com:443')->then(function (React\Socket\ConnectionInterface $connection) { + $connection->write("GET / HTTP/1.0\r\nHost: www.google.com\r\n\r\n"); + ... +}); +``` + +See also the [examples](examples). + +Pending connection attempts can be cancelled by cancelling its pending promise like so: + +```php +$promise = $secureConnector->connect('www.google.com:443'); + +$promise->cancel(); +``` + +Calling `cancel()` on a pending promise will cancel the underlying TCP/IP +connection and/or the SSL/TLS negotiation and reject the resulting promise. + +This class takes an optional `LoopInterface|null $loop` parameter that can be used to +pass the event loop instance to use for this object. You can use a `null` value +here in order to use the [default loop](https://github.com/reactphp/event-loop#loop). +This value SHOULD NOT be given unless you're sure you want to explicitly use a +given event loop instance. + +You can optionally pass additional +[SSL context options](https://www.php.net/manual/en/context.ssl.php) +to the constructor like this: + +```php +$secureConnector = new React\Socket\SecureConnector($dnsConnector, null, [ + 'verify_peer' => false, + 'verify_peer_name' => false +]); +``` + +By default, this connector supports TLSv1.0+ and excludes support for legacy +SSLv2/SSLv3. You can also explicitly choose the TLS version you +want to negotiate with the remote side: + +```php +$secureConnector = new React\Socket\SecureConnector($dnsConnector, null, [ + 'crypto_method' => STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT +]); +``` + +> Advanced usage: Internally, the `SecureConnector` relies on setting up the +required *context options* on the underlying stream resource. +It should therefor be used with a `TcpConnector` somewhere in the connector +stack so that it can allocate an empty *context* resource for each stream +resource and verify the peer name. +Failing to do so may result in a TLS peer name mismatch error or some hard to +trace race conditions, because all stream resources will use a single, shared +*default context* resource otherwise. + +#### TimeoutConnector + +The `TimeoutConnector` class implements the +[`ConnectorInterface`](#connectorinterface) and allows you to add timeout +handling to any existing connector instance. + +It does so by decorating any given [`ConnectorInterface`](#connectorinterface) +instance and starting a timer that will automatically reject and abort any +underlying connection attempt if it takes too long. + +```php +$timeoutConnector = new React\Socket\TimeoutConnector($connector, 3.0); + +$timeoutConnector->connect('google.com:80')->then(function (React\Socket\ConnectionInterface $connection) { + // connection succeeded within 3.0 seconds +}); +``` + +See also any of the [examples](examples). + +This class takes an optional `LoopInterface|null $loop` parameter that can be used to +pass the event loop instance to use for this object. You can use a `null` value +here in order to use the [default loop](https://github.com/reactphp/event-loop#loop). +This value SHOULD NOT be given unless you're sure you want to explicitly use a +given event loop instance. + +Pending connection attempts can be cancelled by cancelling its pending promise like so: + +```php +$promise = $timeoutConnector->connect('google.com:80'); + +$promise->cancel(); +``` + +Calling `cancel()` on a pending promise will cancel the underlying connection +attempt, abort the timer and reject the resulting promise. + +#### UnixConnector + +The `UnixConnector` class implements the +[`ConnectorInterface`](#connectorinterface) and allows you to connect to +Unix domain socket (UDS) paths like this: + +```php +$connector = new React\Socket\UnixConnector(); + +$connector->connect('/tmp/demo.sock')->then(function (React\Socket\ConnectionInterface $connection) { + $connection->write("HELLO\n"); +}); +``` + +Connecting to Unix domain sockets is an atomic operation, i.e. its promise will +settle (either resolve or reject) immediately. +As such, calling `cancel()` on the resulting promise has no effect. + +> The [`getRemoteAddress()`](#getremoteaddress) method will return the target + Unix domain socket (UDS) path as given to the `connect()` method, prepended + with the `unix://` scheme, for example `unix:///tmp/demo.sock`. + The [`getLocalAddress()`](#getlocaladdress) method will most likely return a + `null` value as this value is not applicable to UDS connections here. + +This class takes an optional `LoopInterface|null $loop` parameter that can be used to +pass the event loop instance to use for this object. You can use a `null` value +here in order to use the [default loop](https://github.com/reactphp/event-loop#loop). +This value SHOULD NOT be given unless you're sure you want to explicitly use a +given event loop instance. + +#### FixedUriConnector + +The `FixedUriConnector` class implements the +[`ConnectorInterface`](#connectorinterface) and decorates an existing Connector +to always use a fixed, preconfigured URI. + +This can be useful for consumers that do not support certain URIs, such as +when you want to explicitly connect to a Unix domain socket (UDS) path +instead of connecting to a default address assumed by an higher-level API: + +```php +$connector = new React\Socket\FixedUriConnector( + 'unix:///var/run/docker.sock', + new React\Socket\UnixConnector() +); + +// destination will be ignored, actually connects to Unix domain socket +$promise = $connector->connect('localhost:80'); +``` + +## 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/socket:^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. + +Legacy PHP < 7.3.3 (and PHP < 7.2.15) suffers from a bug where feof() might +block with 100% CPU usage on fragmented TLS records. +We try to work around this by always consuming the complete receive +buffer at once to avoid stale data in TLS buffers. This is known to +work around high CPU usage for well-behaving peers, but this may +cause very large data chunks for high throughput scenarios. The buggy +behavior can still be triggered due to network I/O buffers or +malicious peers on affected versions, upgrading is highly recommended. + +Legacy PHP < 7.1.4 suffers from a bug when writing big +chunks of data over TLS streams at once. +We try to work around this by limiting the write chunk size to 8192 +bytes for older PHP versions only. +This is only a work-around and has a noticable performance penalty on +affected versions. + +## 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 +``` + +The test suite also contains a number of functional integration tests that rely +on a stable internet connection. +If you do not want to run these, they can simply be skipped like this: - $loop = React\EventLoop\Factory::create(); +```bash +vendor/bin/phpunit --exclude-group internet +``` - $client = stream_socket_client('tcp://127.0.0.1:1337'); - $conn = new React\Socket\Connection($client, $loop); - $conn->pipe(new React\Stream\Stream(STDOUT, $loop)); - $conn->write("Hello World!\n"); +## License - $loop->run(); +MIT, see [LICENSE file](LICENSE). diff --git a/Server.php b/Server.php deleted file mode 100644 index 98a0a835..00000000 --- a/Server.php +++ /dev/null @@ -1,71 +0,0 @@ -loop = $loop; - } - - public function listen($port, $host = '127.0.0.1') - { - if (strpos($host, ':') !== false) { - // enclose IPv6 addresses in square brackets before appending port - $host = '[' . $host . ']'; - } - - $this->master = @stream_socket_server("tcp://$host:$port", $errno, $errstr); - if (false === $this->master) { - $message = "Could not bind to tcp://$host:$port: $errstr"; - throw new ConnectionException($message, $errno); - } - stream_set_blocking($this->master, 0); - - $this->loop->addReadStream($this->master, function ($master) { - $newSocket = stream_socket_accept($master); - if (false === $newSocket) { - $this->emit('error', array(new \RuntimeException('Error accepting new connection'))); - - return; - } - $this->handleConnection($newSocket); - }); - } - - public function handleConnection($socket) - { - stream_set_blocking($socket, 0); - - $client = $this->createConnection($socket); - - $this->emit('connection', array($client)); - } - - public function getPort() - { - $name = stream_socket_get_name($this->master, false); - - return (int) substr(strrchr($name, ':'), 1); - } - - public function shutdown() - { - $this->loop->removeStream($this->master); - fclose($this->master); - $this->removeAllListeners(); - } - - public function createConnection($socket) - { - return new Connection($socket, $this->loop); - } -} diff --git a/ServerInterface.php b/ServerInterface.php deleted file mode 100644 index 3665a165..00000000 --- a/ServerInterface.php +++ /dev/null @@ -1,13 +0,0 @@ -=5.4.0", - "evenement/evenement": "~2.0", - "react/event-loop": "0.4.*", - "react/stream": "0.4.*" + "php": ">=7.1", + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "react/dns": "^1.13", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.6 || ^1.2.1", + "react/stream": "^1.4" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^8.5 || ^7.5", + "react/async": "^4.3 || ^3", + "react/promise-stream": "^1.4", + "react/promise-timer": "^1.11" }, "autoload": { - "psr-4": { "React\\Socket\\": "" } + "psr-4": { + "React\\Socket\\": "src/" + } }, - "extra": { - "branch-alias": { - "dev-master": "0.4-dev" + "autoload-dev": { + "psr-4": { + "React\\Tests\\Socket\\": "tests/" } } } diff --git a/examples/01-echo-server.php b/examples/01-echo-server.php new file mode 100644 index 00000000..e85c9c2c --- /dev/null +++ b/examples/01-echo-server.php @@ -0,0 +1,45 @@ + [ + 'local_cert' => $argv[2] ?? __DIR__ . '/localhost.pem' + ] +]); + +$socket->on('connection', function (React\Socket\ConnectionInterface $connection) { + echo '[' . $connection->getRemoteAddress() . ' connected]' . PHP_EOL; + $connection->pipe($connection); + + $connection->on('close', function () use ($connection) { + echo '[' . $connection->getRemoteAddress() . ' disconnected]' . PHP_EOL; + }); +}); + +$socket->on('error', function (Exception $e) { + echo 'Error: ' . $e->getMessage() . PHP_EOL; +}); + +echo 'Listening on ' . $socket->getAddress() . PHP_EOL; diff --git a/examples/02-chat-server.php b/examples/02-chat-server.php new file mode 100644 index 00000000..cd0e826a --- /dev/null +++ b/examples/02-chat-server.php @@ -0,0 +1,63 @@ + [ + 'local_cert' => $argv[2] ?? __DIR__ . '/localhost.pem' + ] +]); + +$socket = new React\Socket\LimitingServer($socket, null); + +$socket->on('connection', function (React\Socket\ConnectionInterface $connection) use ($socket) { + echo '[' . $connection->getRemoteAddress() . ' connected]' . PHP_EOL; + + // whenever a new message comes in + $connection->on('data', function ($data) use ($connection, $socket) { + // remove any non-word characters (just for the demo) + $data = trim(preg_replace('/[^\w\d \.\,\-\!\?]/u', '', $data)); + + // ignore empty messages + if ($data === '') { + return; + } + + // prefix with client IP and broadcast to all connected clients + $data = trim(parse_url($connection->getRemoteAddress(), PHP_URL_HOST), '[]') . ': ' . $data . PHP_EOL; + foreach ($socket->getConnections() as $connection) { + $connection->write($data); + } + }); + + $connection->on('close', function () use ($connection) { + echo '[' . $connection->getRemoteAddress() . ' disconnected]' . PHP_EOL; + }); +}); + +$socket->on('error', function (Exception $e) { + echo 'Error: ' . $e->getMessage() . PHP_EOL; +}); + +echo 'Listening on ' . $socket->getAddress() . PHP_EOL; diff --git a/examples/03-http-server.php b/examples/03-http-server.php new file mode 100644 index 00000000..14846904 --- /dev/null +++ b/examples/03-http-server.php @@ -0,0 +1,64 @@ + [ + 'local_cert' => $argv[2] ?? __DIR__ . '/localhost.pem' + ] +]); + +$socket->on('connection', function (React\Socket\ConnectionInterface $connection) { + echo '[' . $connection->getRemoteAddress() . ' connected]' . PHP_EOL; + + $connection->once('data', function () use ($connection) { + $body = "

Hello world!

\r\n"; + $connection->end("HTTP/1.1 200 OK\r\nContent-Length: " . strlen($body) . "\r\nConnection: close\r\n\r\n" . $body); + }); + + $connection->on('close', function () use ($connection) { + echo '[' . $connection->getRemoteAddress() . ' disconnected]' . PHP_EOL; + }); +}); + +$socket->on('error', function (Exception $e) { + echo 'Error: ' . $e->getMessage() . PHP_EOL; +}); + +echo 'Listening on ' . strtr($socket->getAddress(), ['tcp:' => 'http:', 'tls:' => 'https:']) . PHP_EOL; diff --git a/examples/11-http-client.php b/examples/11-http-client.php new file mode 100644 index 00000000..60444eb2 --- /dev/null +++ b/examples/11-http-client.php @@ -0,0 +1,34 @@ +connect($host. ':80')->then(function (ConnectionInterface $connection) use ($host) { + $connection->on('data', function ($data) { + echo $data; + }); + $connection->on('close', function () { + echo '[CLOSED]' . PHP_EOL; + }); + + $connection->write("GET / HTTP/1.0\r\nHost: $host\r\n\r\n"); +}, function (Exception $e) { + echo 'Error: ' . $e->getMessage() . PHP_EOL; +}); diff --git a/examples/12-https-client.php b/examples/12-https-client.php new file mode 100644 index 00000000..865a34ff --- /dev/null +++ b/examples/12-https-client.php @@ -0,0 +1,34 @@ +connect('tls://' . $host . ':443')->then(function (ConnectionInterface $connection) use ($host) { + $connection->on('data', function ($data) { + echo $data; + }); + $connection->on('close', function () { + echo '[CLOSED]' . PHP_EOL; + }); + + $connection->write("GET / HTTP/1.0\r\nHost: $host\r\n\r\n"); +}, function (Exception $e) { + echo 'Error: ' . $e->getMessage() . PHP_EOL; +}); diff --git a/examples/21-netcat-client.php b/examples/21-netcat-client.php new file mode 100644 index 00000000..86014b21 --- /dev/null +++ b/examples/21-netcat-client.php @@ -0,0 +1,64 @@ +' . PHP_EOL); + exit(1); +} + +$connector = new Connector(); + +$stdin = new ReadableResourceStream(STDIN); +$stdin->pause(); +$stdout = new WritableResourceStream(STDOUT); +$stderr = new WritableResourceStream(STDERR); + +$stderr->write('Connecting' . PHP_EOL); + +$connector->connect($argv[1])->then(function (ConnectionInterface $connection) use ($stdin, $stdout, $stderr) { + // pipe everything from STDIN into connection + $stdin->resume(); + $stdin->pipe($connection); + + // pipe everything from connection to STDOUT + $connection->pipe($stdout); + + // report errors to STDERR + $connection->on('error', function (Exception $e) use ($stderr) { + $stderr->write('Stream error: ' . $e->getMessage() . PHP_EOL); + }); + + // report closing and stop reading from input + $connection->on('close', function () use ($stderr, $stdin) { + $stderr->write('[CLOSED]' . PHP_EOL); + $stdin->close(); + }); + + $stderr->write('Connected' . PHP_EOL); +}, function (Exception $e) use ($stderr) { + $stderr->write('Connection error: ' . $e->getMessage() . PHP_EOL); +}); diff --git a/examples/22-http-client.php b/examples/22-http-client.php new file mode 100644 index 00000000..541fe464 --- /dev/null +++ b/examples/22-http-client.php @@ -0,0 +1,58 @@ +' . PHP_EOL); + exit(1); +} + +$connector = new Connector(); + +if (!isset($parts['port'])) { + $parts['port'] = $parts['scheme'] === 'https' ? 443 : 80; +} + +$host = $parts['host']; +if (($parts['scheme'] === 'http' && $parts['port'] !== 80) || ($parts['scheme'] === 'https' && $parts['port'] !== 443)) { + $host .= ':' . $parts['port']; +} +$target = ($parts['scheme'] === 'https' ? 'tls' : 'tcp') . '://' . $parts['host'] . ':' . $parts['port']; +$resource = $parts['path'] ?? '/'; +if (isset($parts['query'])) { + $resource .= '?' . $parts['query']; +} + +$stdout = new WritableResourceStream(STDOUT); + +$connector->connect($target)->then(function (ConnectionInterface $connection) use ($resource, $host, $stdout) { + $connection->pipe($stdout); + + $connection->write("GET $resource HTTP/1.0\r\nHost: $host\r\n\r\n"); +}, function (Exception $e) { + echo 'Error: ' . $e->getMessage() . PHP_EOL; +}); diff --git a/examples/91-benchmark-server.php b/examples/91-benchmark-server.php new file mode 100644 index 00000000..a3ea960c --- /dev/null +++ b/examples/91-benchmark-server.php @@ -0,0 +1,61 @@ + [ + 'local_cert' => $argv[2] ?? __DIR__ . '/localhost.pem' + ] +]); + +$socket->on('connection', function (React\Socket\ConnectionInterface $connection) { + echo '[' . $connection->getRemoteAddress() . ' connected]' . PHP_EOL; + + // count the number of bytes received from this connection + $bytes = 0; + $connection->on('data', function ($chunk) use (&$bytes) { + $bytes += strlen($chunk); + }); + + // report average throughput once client disconnects + $t = microtime(true); + $connection->on('close', function () use ($connection, $t, &$bytes) { + $t = microtime(true) - $t; + echo '[' . $connection->getRemoteAddress() . ' disconnected after receiving ' . $bytes . ' bytes in ' . round($t, 3) . 's => ' . round($bytes / $t / 1024 / 1024, 1) . ' MiB/s]' . PHP_EOL; + }); +}); + +$socket->on('error', function (Exception $e) { + echo 'Error: ' . $e->getMessage() . PHP_EOL; +}); + +echo 'Listening on ' . $socket->getAddress() . PHP_EOL; diff --git a/examples/99-generate-self-signed.php b/examples/99-generate-self-signed.php new file mode 100644 index 00000000..bf6a9886 --- /dev/null +++ b/examples/99-generate-self-signed.php @@ -0,0 +1,31 @@ + secret.pem + +// certificate details (Distinguished Name) +// (OpenSSL applies defaults to missing fields) +$dn = [ + "commonName" => $argv[1] ?? "localhost", +// "countryName" => "AU", +// "stateOrProvinceName" => "Some-State", +// "localityName" => "London", +// "organizationName" => "Internet Widgits Pty Ltd", +// "organizationalUnitName" => "R&D", +// "emailAddress" => "admin@example.com" +]; + +// create certificate which is valid for ~10 years +$privkey = openssl_pkey_new(); +$cert = openssl_csr_new($dn, $privkey); +$cert = openssl_csr_sign($cert, null, $privkey, 3650); + +// export public and (optionally encrypted) private key in PEM format +openssl_x509_export($cert, $out); +echo $out; + +$passphrase = $argv[2] ?? null; +openssl_pkey_export($privkey, $out, $passphrase); +echo $out; diff --git a/examples/localhost.pem b/examples/localhost.pem new file mode 100644 index 00000000..be692792 --- /dev/null +++ b/examples/localhost.pem @@ -0,0 +1,49 @@ +-----BEGIN CERTIFICATE----- +MIIDfTCCAmWgAwIBAgIBADANBgkqhkiG9w0BAQUFADBZMRIwEAYDVQQDDAkxMjcu +MC4wLjExCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQK +DBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwHhcNMTYxMjMwMTQ1OTA2WhcNMjYx +MjI4MTQ1OTA2WjBZMRIwEAYDVQQDDAkxMjcuMC4wLjExCzAJBgNVBAYTAkFVMRMw +EQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0 +eSBMdGQwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC8SZWNS+Ktg0Py +W8dx5uXZ+ZUawd3wnzLMHW7EhoUpIrIdp3kDU9NezF68dOhPMJY/Kh+6btRCxWXN +2OVTqS5Xi826j3TSE07iF83JRLeveW0PcodjUBd+RzdwCWWo2pfMJz4v7x1wu1c9 +zNi6JxxpDAXTFSB4GiWsI4tFu2XmMRhfm6LRK4WPfsZIJKokdiG5fKSPDn7nrVj0 +UUXr2eBsEAzdwL14U9+mwbLdaAkz3qK3fqi8sEC09lEWm95gKMOhkQf5qvXODtT4 +wdVrrKDTyehLv0xaItnUDnXzrkMBU5QS9TQzzqSW6ZaBsSxtONEFUiXiN9dtyXsY +YCUE54G/AgMBAAGjUDBOMB0GA1UdDgQWBBQ2GRz3QsQzdXaTMnPVCKfpigA10DAf +BgNVHSMEGDAWgBQ2GRz3QsQzdXaTMnPVCKfpigA10DAMBgNVHRMEBTADAQH/MA0G +CSqGSIb3DQEBBQUAA4IBAQA77iZ4KrpPY18Ezjt0mngYAuAxunKddXYdLZ2khywN +0uI/VzYnkFVtrsC7y2jLHSxlmE2/viPPGZDUplENV2acN6JNW+tlt7/bsrQHDQw3 +7VCF27EWiDxHsaghhLkqC+kcop5YR5c0oDQTdEWEKSbow2zayUXDYbRRs76SClTe +824Yul+Ts8Mka+AX2PXDg47iZ84fJRN/nKavcJUTJ2iS1uYw0GNnFMge/uwsfMR3 +V47qN0X5emky8fcq99FlMCbcy0gHAeSWAjClgr2dd2i0LDatUbj7YmdmFcskOgII +IwGfvuWR2yPevYGAE0QgFeLHniN3RW8zmpnX/XtrJ4a7 +-----END CERTIFICATE----- +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC8SZWNS+Ktg0Py +W8dx5uXZ+ZUawd3wnzLMHW7EhoUpIrIdp3kDU9NezF68dOhPMJY/Kh+6btRCxWXN +2OVTqS5Xi826j3TSE07iF83JRLeveW0PcodjUBd+RzdwCWWo2pfMJz4v7x1wu1c9 +zNi6JxxpDAXTFSB4GiWsI4tFu2XmMRhfm6LRK4WPfsZIJKokdiG5fKSPDn7nrVj0 +UUXr2eBsEAzdwL14U9+mwbLdaAkz3qK3fqi8sEC09lEWm95gKMOhkQf5qvXODtT4 +wdVrrKDTyehLv0xaItnUDnXzrkMBU5QS9TQzzqSW6ZaBsSxtONEFUiXiN9dtyXsY +YCUE54G/AgMBAAECggEBAKiO/3FE1CMddkCLZVtUp8ShqJgRokx9WI5ecwFApAkV +ZHsjqDQQYRNmxhDUX/w0tOzLGyhde2xjJyZG29YviKsbHwu6zYwbeOzy/mkGOaK/ +g6DmmMmRs9Z6juifoQCu4GIFZ6il2adIL2vF7OeJh+eKudQj/7NFRSB7mXzNrQWK +tZY3eux5zXWmio7pgZrx1HFZQiiL9NVLwT9J7oBnaoO3fREiu5J2xBpljG9Cr0j1 +LLiVLhukWJYRlHDtGt1CzI9w8iKo44PCRzpKyxpbsOrQxeSyEWUYQRv9VHA59LC7 +tVAJTbnTX1BNHkGZkOkoOpoZLwBaM2XbbDtcOGCAZMECgYEA+mTURFQ85/pxawvk +9ndqZ+5He1u/bMLYIJDp0hdB/vgD+vw3gb2UyRwp0I6Wc6Si4FEEnbY7L0pzWsiR +43CpLs+cyLfnD9NycuIasxs5fKb/1s1nGTkRAp7x9x/ZTtEf8v4YTmmMXFHzdo7V +pv+czO89ppEDkxEtMf/b5SifhO8CgYEAwIDIUvXLduGhL+RPDwjc2SKdydXGV6om +OEdt/V8oS801Z7k8l3gHXFm7zL/MpHmh9cag+F9dHK42kw2RSjDGsBlXXiAO1Z0I +2A34OdPw/kow8fmIKWTMu3+28Kca+3RmUqeyaq0vazQ/bWMO9px+Ud3YfLo1Tn5I +li0MecAx8DECgYEAvsLceKYYtL83c09fg2oc1ctSCCgw4WJcGAtvJ9DyRZacKbXH +b/+H/+OF8879zmKqd+0hcCnqUzAMTCisBLPLIM+o6b45ufPkqKObpcJi/JWaKgLY +vf2c+Psw6o4IF6T5Cz4MNIjzF06UBknxecYZpoPJ20F1kLCwVvxPgfl99l8CgYAb +XfOcv67WTstgiJ+oroTfJamy+P5ClkDqvVTosW+EHz9ZaJ8xlXHOcj9do2LPey9I +Rp250azmF+pQS5x9JKQKgv/FtN8HBVUtigbhCb14GUoODICMCfWFLmnumoMefnTR +iV+3BLn6Dqp5vZxx+NuIffZ5/Or5JsDhALSGVomC8QKBgAi3Z/dNQrDHfkXMNn/L ++EAoLuAbFgLs76r9VGgNaRQ/q5gex2bZEGoBj4Sxvs95NUIcfD9wKT7FF8HdxARv +y3o6Bfc8Xp9So9SlFXrje+gkdEJ0rQR67d+XBuJZh86bXJHVrMwpoNL+ahLGdVSe +81oh1uCH1YPLM29hPyaohxL8 +-----END PRIVATE KEY----- diff --git a/examples/localhost_swordfish.pem b/examples/localhost_swordfish.pem new file mode 100644 index 00000000..7d1ee804 --- /dev/null +++ b/examples/localhost_swordfish.pem @@ -0,0 +1,51 @@ +-----BEGIN CERTIFICATE----- +MIIDfTCCAmWgAwIBAgIBADANBgkqhkiG9w0BAQUFADBZMRIwEAYDVQQDDAkxMjcu +MC4wLjExCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQK +DBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwHhcNMTYxMjMwMTQxMDQzWhcNMjYx +MjI4MTQxMDQzWjBZMRIwEAYDVQQDDAkxMjcuMC4wLjExCzAJBgNVBAYTAkFVMRMw +EQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0 +eSBMdGQwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDRXt83SrKIHr/i +3lc8O8pz6NHE1DNHJa4xg2xalXWzCEV6m1qLd9VdaLT9cJD1afNmEMBgY6RblNL/ +paJWVoR9MOUeIoYl2PrhUCxsf7h6MRtezQQe3e+n+/0XunF0JUQIZuJqbxfRk5WT +XmYnphqOZKEcistAYvFBjzl/D+Cl/nYsreADc+t9l5Vni89oTWEuqIrsM4WUZqqB +VMAakd2nZJLWIrMxq9hbW1XNukOQfcmZVFTC6CUnLq8qzGbtfZYBuMBACnL1k/E/ +yPaAgR46l14VAcndDUJBtMeL2qYuNwvXQhg3KuBmpTUpH+yzxU+4T3lmv0xXmPqu +ySH3xvW3AgMBAAGjUDBOMB0GA1UdDgQWBBRu68WTI4pVeTB7wuG9QGI3Ie441TAf +BgNVHSMEGDAWgBRu68WTI4pVeTB7wuG9QGI3Ie441TAMBgNVHRMEBTADAQH/MA0G +CSqGSIb3DQEBBQUAA4IBAQCc4pEjEHO47VRJkbHgC+c2gAVgxekkaA1czBA1uAvh +ILRda0NLlvyftbjaG0zZp2ABUCfRfksl/Pf/PzWLUMEuH/9kEW2rgP43z6YgiL6k +kBPlmAU607UjD726RPGkw8QPSXS/dWiNJ5CBpPWLpxC45pokqItYbY0ijQ5Piq09 +TchYlCX044oSRnPiP394PQ3HVdaGhJB2DnjDq3in5dVivFf8EdgzQSvp/wXy3WQs +uFSVonSnrZGY/4AgT3psGaQ6fqKb4SBoqtf5bFQvp1XNNRkuEJnS/0dygEya0c+c +aCe/1gXC2wDjx0/TekY5m1Nyw5SY6z7stOqL/ekwgejt +-----END CERTIFICATE----- +-----BEGIN ENCRYPTED PRIVATE KEY----- +MIIFDjBABgkqhkiG9w0BBQ0wMzAbBgkqhkiG9w0BBQwwDgQIG7idPRLgiHkCAggA +MBQGCCqGSIb3DQMHBAg+MLPdepHWSwSCBMgVW9LseCjfTAmF9U1qRnKsq3kIwEnW +6aERBqs/mnmEhrXgZYgcvRRK7kD12TdHt/Nz46Ymu0h+Lrvuwtl1fHQUARTk/gFh +onLhc9kjMUhLRIR007vJe3HvWOb/v+SBSDB38OpUxUwJmBVBuSaYLWVuPR6J5kUj +xOgBS049lN3E9cfrHvb3bF/epIQrU0OgfyyxEvIi5n30y+tlRn3y68PY6Qd46t4Y +UN5VZUwvJBgoRy9TGxSkiSRjhxC2PWpLYq/HMzDbcRcFF5dVAIioUd/VZ7fdgBfA +uMW4SFfpFLDUX0aaYe+ZdA5tM0Bc0cOtG8Z0sc9JYDNNcvjmSiGCi646h8F0D3O6 +JKAQMMxQGWiyQeJ979LVjtq4lJESXA8VEKz9rV03y5xunmFCLy6dGt+6GJwXgabn +OH7nvEv4GqAOqKc6E9je4JM+AF/oUazrfPse1KEEtsPKarazjCB/SKYtHyDJaavD +GGjtiU9zWwGMOgIDyNmXe3ga7/TWoGOAg5YlTr6Hbq2Y/5ycgjAgPFjuXtvnoT0+ +mF5TnNfMAqTgQsE2gjhonK1pdlOen0lN5FtoUXp3CXU0dOq0J70GiX+1YA7VDn30 +n5WNAgfOXX3l3E95jGN370pHXyli5RUNW0NZVHV+22jlNWCVtQHUh+DVswQZg+i5 ++DqaIHz2jUetMo7gWtqGn/wwSopOs87VM1rcALhZL4EsJ+Zy81I/hA32RNnGbuol +NAiZh+0KrtTcc/fPunpd8vRtOwGphM11dKucozUufuiPG2inR3aEqt5yNx54ec/f +J6nryWRYiHEA/rCU9MSBM9cqKFtEmy9/8oxV41/SPxhXjHwDlABWTtFuJ3pf2sOF +ILSYYFwB0ZGvdjE5yAJFBr9efno/L9fafmGk7a3vmVgK2AmUC9VNB5XHw1GjF8OP +aQAXe4md9Bh0jk/D/iyp7e7IWNssul/7XejhabidWgFj6EXc9YxE59+FlhDqyMhn +V6houc+QeUXuwsAKgRJJhJtpv/QSZ5BI3esxHHUt3ayGnvhFElpAc0t7C/EiXKIv +DAFYP2jksBqijM8YtEgPWYzEP5buYxZnf/LK7FDocLsNcdF38UaKBbeF90e7bR8j +SHspG9aJWICu8Yawnh8zuy/vQv+h9gWyGodd2p9lQzlbRXrutbwfmPf7xP6nzT9i +9GcugJxTaZgkCfhhHxFk/nRHS2NAzagKVib1xkUlZJg2hX0fIFUdYteL1GGTvOx5 +m3mTOino4T19z9SEdZYb2OHYh29e/T74bJiLCYdXwevSYHxfZc8pYAf0jp4UnMT2 +f7B0ctX1iXuQ2uZVuxh+U1Mcu+v0gDla1jWh7AhcePSi4xBNUCak0kQip6r5e6Oi +r4MIyMRk/Pc5pzEKo8G6nk26rNvX3aRvECoVfmK7IVdsqZ6IXlt9kOmWx3IeKzrO +J5DxpzW+9oIRZJgPTkc4/XRb0tFmFQYTiChiQ1AJUEiCX0GpkFf7cq61aLGYtWyn +vL2lmQhljzjrDo15hKErvk7eBZW7GW/6j/m/PfRdcBI4ceuP9zWQXnDOd9zmaE4b +q3bJ+IbbyVZA2WwyzN7umCKWghsiPMAolxEnYM9JRf8BcqeqQiwVZlfO5KFuN6Ze +le4= +-----END ENCRYPTED PRIVATE KEY----- diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 00000000..ac542e77 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,28 @@ + + + + + + + ./tests/ + + + + + ./src/ + + + + + + + + + + + diff --git a/phpunit.xml.legacy b/phpunit.xml.legacy new file mode 100644 index 00000000..00868603 --- /dev/null +++ b/phpunit.xml.legacy @@ -0,0 +1,26 @@ + + + + + + + ./tests/ + + + + + ./src/ + + + + + + + + + + + diff --git a/src/Connection.php b/src/Connection.php new file mode 100644 index 00000000..6bc8deb3 --- /dev/null +++ b/src/Connection.php @@ -0,0 +1,178 @@ += 70300 && \PHP_VERSION_ID < 70303); + + // Legacy PHP < 7.1.4 suffers from a bug when writing big + // chunks of data over TLS streams at once. + // We try to work around this by limiting the write chunk size to 8192 + // bytes for older PHP versions only. + // This is only a work-around and has a noticable performance penalty on + // affected versions. Please update your PHP version. + // This applies to all streams because TLS may be enabled later on. + // See https://github.com/reactphp/socket/issues/105 + $limitWriteChunks = \PHP_VERSION_ID < 70104; + + $this->input = new DuplexResourceStream( + $resource, + $loop, + $clearCompleteBuffer ? -1 : null, + new WritableResourceStream($resource, $loop, null, $limitWriteChunks ? 8192 : null) + ); + + $this->stream = $resource; + + Util::forwardEvents($this->input, $this, ['data', 'end', 'error', 'close', 'pipe', 'drain']); + + $this->input->on('close', [$this, 'close']); + } + + public function isReadable() + { + return $this->input->isReadable(); + } + + public function isWritable() + { + return $this->input->isWritable(); + } + + public function pause() + { + $this->input->pause(); + } + + public function resume() + { + $this->input->resume(); + } + + public function pipe(WritableStreamInterface $dest, array $options = []) + { + return $this->input->pipe($dest, $options); + } + + public function write($data) + { + return $this->input->write($data); + } + + public function end($data = null) + { + $this->input->end($data); + } + + public function close() + { + $this->input->close(); + $this->handleClose(); + $this->removeAllListeners(); + } + + public function handleClose() + { + if (!\is_resource($this->stream)) { + return; + } + + // Try to cleanly shut down socket and ignore any errors in case other + // side already closed. Underlying Stream implementation will take care + // of closing stream resource, so we otherwise keep this open here. + @\stream_socket_shutdown($this->stream, \STREAM_SHUT_RDWR); + } + + public function getRemoteAddress() + { + if (!\is_resource($this->stream)) { + return null; + } + + return $this->parseAddress(\stream_socket_get_name($this->stream, true)); + } + + public function getLocalAddress() + { + if (!\is_resource($this->stream)) { + return null; + } + + return $this->parseAddress(\stream_socket_get_name($this->stream, false)); + } + + private function parseAddress($address) + { + if ($address === false) { + return null; + } + + if ($this->unix) { + // Legacy PHP < 7.1.7 may use "\0" string instead of false: https://3v4l.org/5C1lo and https://bugs.php.net/bug.php?id=74556 + // Work around by returning null for "\0" string + if ($address[0] === "\x00" ) { + return null; // @codeCoverageIgnore + } + + return 'unix://' . $address; + } + + // Legacy PHP < 7.3 uses IPv6 address which includes multiple colons but no square brackets: https://bugs.php.net/bug.php?id=76136 + // Work around by adding square brackets around IPv6 address when not already present + $pos = \strrpos($address, ':'); + if ($pos !== false && \strpos($address, ':') < $pos && \substr($address, 0, 1) !== '[') { + $address = '[' . \substr($address, 0, $pos) . ']:' . \substr($address, $pos + 1); // @codeCoverageIgnore + } + + return ($this->encryptionEnabled ? 'tls' : 'tcp') . '://' . $address; + } +} diff --git a/src/ConnectionInterface.php b/src/ConnectionInterface.php new file mode 100644 index 00000000..64613b58 --- /dev/null +++ b/src/ConnectionInterface.php @@ -0,0 +1,119 @@ +on('data', function ($chunk) { + * echo $chunk; + * }); + * + * $connection->on('end', function () { + * echo 'ended'; + * }); + * + * $connection->on('error', function (Exception $e) { + * echo 'error: ' . $e->getMessage(); + * }); + * + * $connection->on('close', function () { + * echo 'closed'; + * }); + * + * $connection->write($data); + * $connection->end($data = null); + * $connection->close(); + * // … + * ``` + * + * For more details, see the + * [`DuplexStreamInterface`](https://github.com/reactphp/stream#duplexstreaminterface). + * + * @see DuplexStreamInterface + * @see ServerInterface + * @see ConnectorInterface + */ +interface ConnectionInterface extends DuplexStreamInterface +{ + /** + * Returns the full remote address (URI) where this connection has been established with + * + * ```php + * $address = $connection->getRemoteAddress(); + * echo 'Connection with ' . $address . PHP_EOL; + * ``` + * + * If the remote address can not be determined or is unknown at this time (such as + * after the connection has been closed), it MAY return a `NULL` value instead. + * + * Otherwise, it will return the full address (URI) as a string value, such + * as `tcp://127.0.0.1:8080`, `tcp://[::1]:80`, `tls://127.0.0.1:443`, + * `unix://example.sock` or `unix:///path/to/example.sock`. + * Note that individual URI components are application specific and depend + * on the underlying transport protocol. + * + * If this is a TCP/IP based connection and you only want the remote IP, you may + * use something like this: + * + * ```php + * $address = $connection->getRemoteAddress(); + * $ip = trim(parse_url($address, PHP_URL_HOST), '[]'); + * echo 'Connection with ' . $ip . PHP_EOL; + * ``` + * + * @return ?string remote address (URI) or null if unknown + */ + public function getRemoteAddress(); + + /** + * Returns the full local address (full URI with scheme, IP and port) where this connection has been established with + * + * ```php + * $address = $connection->getLocalAddress(); + * echo 'Connection with ' . $address . PHP_EOL; + * ``` + * + * If the local address can not be determined or is unknown at this time (such as + * after the connection has been closed), it MAY return a `NULL` value instead. + * + * Otherwise, it will return the full address (URI) as a string value, such + * as `tcp://127.0.0.1:8080`, `tcp://[::1]:80`, `tls://127.0.0.1:443`, + * `unix://example.sock` or `unix:///path/to/example.sock`. + * Note that individual URI components are application specific and depend + * on the underlying transport protocol. + * + * This method complements the [`getRemoteAddress()`](#getremoteaddress) method, + * so they should not be confused. + * + * If your `TcpServer` instance is listening on multiple interfaces (e.g. using + * the address `0.0.0.0`), you can use this method to find out which interface + * actually accepted this connection (such as a public or local interface). + * + * If your system has multiple interfaces (e.g. a WAN and a LAN interface), + * you can use this method to find out which interface was actually + * used for this connection. + * + * @return ?string local address (URI) or null if unknown + * @see self::getRemoteAddress() + */ + public function getLocalAddress(); +} diff --git a/src/Connector.php b/src/Connector.php new file mode 100644 index 00000000..8a5e994d --- /dev/null +++ b/src/Connector.php @@ -0,0 +1,222 @@ + true, + 'tls' => true, + 'unix' => true, + + 'dns' => true, + 'timeout' => true, + 'happy_eyeballs' => true, + ]; + + if ($context['timeout'] === true) { + $context['timeout'] = (float)\ini_get("default_socket_timeout"); + } + + if ($context['tcp'] instanceof ConnectorInterface) { + $tcp = $context['tcp']; + } else { + $tcp = new TcpConnector( + $loop, + \is_array($context['tcp']) ? $context['tcp'] : [] + ); + } + + if ($context['dns'] !== false) { + if ($context['dns'] instanceof ResolverInterface) { + $resolver = $context['dns']; + } else { + if ($context['dns'] !== true) { + $config = $context['dns']; + } else { + // try to load nameservers from system config or default to Google's public DNS + $config = DnsConfig::loadSystemConfigBlocking(); + if (!$config->nameservers) { + $config->nameservers[] = '8.8.8.8'; // @codeCoverageIgnore + } + } + + $factory = new DnsFactory(); + $resolver = $factory->createCached( + $config, + $loop + ); + } + + if ($context['happy_eyeballs'] === true) { + $tcp = new HappyEyeBallsConnector($loop, $tcp, $resolver); + } else { + $tcp = new DnsConnector($tcp, $resolver); + } + } + + if ($context['tcp'] !== false) { + $context['tcp'] = $tcp; + + if ($context['timeout'] !== false) { + $context['tcp'] = new TimeoutConnector( + $context['tcp'], + $context['timeout'], + $loop + ); + } + + $this->connectors['tcp'] = $context['tcp']; + } + + if ($context['tls'] !== false) { + if (!$context['tls'] instanceof ConnectorInterface) { + $context['tls'] = new SecureConnector( + $tcp, + $loop, + \is_array($context['tls']) ? $context['tls'] : [] + ); + } + + if ($context['timeout'] !== false) { + $context['tls'] = new TimeoutConnector( + $context['tls'], + $context['timeout'], + $loop + ); + } + + $this->connectors['tls'] = $context['tls']; + } + + if ($context['unix'] !== false) { + if (!$context['unix'] instanceof ConnectorInterface) { + $context['unix'] = new UnixConnector($loop); + } + $this->connectors['unix'] = $context['unix']; + } + } + + public function connect($uri) + { + $scheme = 'tcp'; + if (\strpos($uri, '://') !== false) { + $scheme = (string)\substr($uri, 0, \strpos($uri, '://')); + } + + if (!isset($this->connectors[$scheme])) { + return reject(new \RuntimeException( + 'No connector available for URI scheme "' . $scheme . '" (EINVAL)', + \defined('SOCKET_EINVAL') ? \SOCKET_EINVAL : (\defined('PCNTL_EINVAL') ? \PCNTL_EINVAL : 22) + )); + } + + return $this->connectors[$scheme]->connect($uri); + } + + + /** + * [internal] Builds on URI from the given URI parts and ip address with original hostname as query + * + * @param array $parts + * @param string $host + * @param string $ip + * @return string + * @internal + */ + public static function uri(array $parts, $host, $ip) + { + $uri = ''; + + // prepend original scheme if known + if (isset($parts['scheme'])) { + $uri .= $parts['scheme'] . '://'; + } + + if (\strpos($ip, ':') !== false) { + // enclose IPv6 addresses in square brackets before appending port + $uri .= '[' . $ip . ']'; + } else { + $uri .= $ip; + } + + // append original port if known + if (isset($parts['port'])) { + $uri .= ':' . $parts['port']; + } + + // append orignal path if known + if (isset($parts['path'])) { + $uri .= $parts['path']; + } + + // append original query if known + if (isset($parts['query'])) { + $uri .= '?' . $parts['query']; + } + + // append original hostname as query if resolved via DNS and if + // destination URI does not contain "hostname" query param already + $args = []; + \parse_str($parts['query'] ?? '', $args); + if ($host !== $ip && !isset($args['hostname'])) { + $uri .= (isset($parts['query']) ? '&' : '?') . 'hostname=' . \rawurlencode($host); + } + + // append original fragment if known + if (isset($parts['fragment'])) { + $uri .= '#' . $parts['fragment']; + } + + return $uri; + } +} diff --git a/src/ConnectorInterface.php b/src/ConnectorInterface.php new file mode 100644 index 00000000..1f07b753 --- /dev/null +++ b/src/ConnectorInterface.php @@ -0,0 +1,59 @@ +connect('google.com:443')->then( + * function (React\Socket\ConnectionInterface $connection) { + * // connection successfully established + * }, + * function (Exception $error) { + * // failed to connect due to $error + * } + * ); + * ``` + * + * The returned Promise MUST be implemented in such a way that it can be + * cancelled when it is still pending. Cancelling a pending promise MUST + * reject its value with an Exception. It SHOULD clean up any underlying + * resources and references as applicable. + * + * ```php + * $promise = $connector->connect($uri); + * + * $promise->cancel(); + * ``` + * + * @param string $uri + * @return \React\Promise\PromiseInterface + * Resolves with a `ConnectionInterface` on success or rejects with an `Exception` on error. + * @see ConnectionInterface + */ + public function connect($uri); +} diff --git a/src/DnsConnector.php b/src/DnsConnector.php new file mode 100644 index 00000000..4a8d1a03 --- /dev/null +++ b/src/DnsConnector.php @@ -0,0 +1,119 @@ +connector = $connector; + $this->resolver = $resolver; + } + + public function connect($uri) + { + $original = $uri; + if (\strpos($uri, '://') === false) { + $uri = 'tcp://' . $uri; + $parts = \parse_url($uri); + if (isset($parts['scheme'])) { + unset($parts['scheme']); + } + } else { + $parts = \parse_url($uri); + } + + if (!$parts || !isset($parts['host'])) { + return reject(new \InvalidArgumentException( + 'Given URI "' . $original . '" is invalid (EINVAL)', + \defined('SOCKET_EINVAL') ? \SOCKET_EINVAL : (\defined('PCNTL_EINVAL') ? \PCNTL_EINVAL : 22) + )); + } + + $host = \trim($parts['host'], '[]'); + + // skip DNS lookup / URI manipulation if this URI already contains an IP + if (@\inet_pton($host) !== false) { + return $this->connector->connect($original); + } + + $promise = $this->resolver->resolve($host); + $resolved = null; + + return new Promise( + function ($resolve, $reject) use (&$promise, &$resolved, $uri, $host, $parts) { + // resolve/reject with result of DNS lookup + $promise->then(function ($ip) use (&$promise, &$resolved, $uri, $host, $parts) { + $resolved = $ip; + + return $promise = $this->connector->connect( + Connector::uri($parts, $host, $ip) + )->then(null, function (\Exception $e) use ($uri) { + if ($e instanceof \RuntimeException) { + $message = \preg_replace('/^(Connection to [^ ]+)[&?]hostname=[^ &]+/', '$1', $e->getMessage()); + $e = new \RuntimeException( + 'Connection to ' . $uri . ' failed: ' . $message, + $e->getCode(), + $e + ); + + // avoid garbage references by replacing all closures in call stack. + // what a lovely piece of code! + $r = new \ReflectionProperty(\Exception::class, 'trace'); + if (\PHP_VERSION_ID < 80100) { + $r->setAccessible(true); + } + $trace = $r->getValue($e); + + // Exception trace arguments are not available on some PHP 7.4 installs + // @codeCoverageIgnoreStart + foreach ($trace as $ti => $one) { + if (isset($one['args'])) { + foreach ($one['args'] as $ai => $arg) { + if ($arg instanceof \Closure) { + $trace[$ti]['args'][$ai] = 'Object(' . \get_class($arg) . ')'; + } + } + } + } + // @codeCoverageIgnoreEnd + $r->setValue($e, $trace); + } + + throw $e; + }); + }, function ($e) use ($uri, $reject) { + $reject(new \RuntimeException('Connection to ' . $uri .' failed during DNS lookup: ' . $e->getMessage(), 0, $e)); + })->then($resolve, $reject); + }, + function ($_, $reject) use (&$promise, &$resolved, $uri) { + // cancellation should reject connection attempt + // reject DNS resolution with custom reason, otherwise rely on connection cancellation below + if ($resolved === null) { + $reject(new \RuntimeException( + 'Connection to ' . $uri . ' cancelled during DNS lookup (ECONNABORTED)', + \defined('SOCKET_ECONNABORTED') ? \SOCKET_ECONNABORTED : 103 + )); + } + + // (try to) cancel pending DNS lookup / connection attempt + if ($promise instanceof PromiseInterface && \method_exists($promise, 'cancel')) { + // overwrite callback arguments for PHP7+ only, so they do not show + // up in the Exception trace and do not cause a possible cyclic reference. + $_ = $reject = null; + + $promise->cancel(); + $promise = null; + } + } + ); + } +} diff --git a/src/FdServer.php b/src/FdServer.php new file mode 100644 index 00000000..b00681c2 --- /dev/null +++ b/src/FdServer.php @@ -0,0 +1,217 @@ +on('connection', function (ConnectionInterface $connection) { + * echo 'Plaintext connection from ' . $connection->getRemoteAddress() . PHP_EOL; + * $connection->write('hello there!' . PHP_EOL); + * … + * }); + * ``` + * + * See also the `ServerInterface` for more details. + * + * @see ServerInterface + * @see ConnectionInterface + * @internal + */ +final class FdServer extends EventEmitter implements ServerInterface +{ + private $master; + private $loop; + private $unix = false; + private $listening = false; + + /** + * Creates a socket server and starts listening on the given file descriptor + * + * This starts accepting new incoming connections on the given file descriptor. + * See also the `connection event` documented in the `ServerInterface` + * for more details. + * + * ```php + * $socket = new React\Socket\FdServer(3); + * ``` + * + * If the given FD is invalid or out of range, it will throw an `InvalidArgumentException`: + * + * ```php + * // throws InvalidArgumentException + * $socket = new React\Socket\FdServer(-1); + * ``` + * + * If the given FD appears to be valid, but listening on it fails (such as + * if the FD does not exist or does not refer to a socket server), it will + * throw a `RuntimeException`: + * + * ```php + * // throws RuntimeException because FD does not reference a socket server + * $socket = new React\Socket\FdServer(0, $loop); + * ``` + * + * Note that these error conditions may vary depending on your system and/or + * configuration. + * See the exception message and code for more details about the actual error + * condition. + * + * @param int|string $fd FD number such as `3` or as URL in the form of `php://fd/3` + * @param ?LoopInterface $loop + * @throws \InvalidArgumentException if the listening address is invalid + * @throws \RuntimeException if listening on this address fails (already in use etc.) + */ + public function __construct($fd, ?LoopInterface $loop = null) + { + if (\preg_match('#^php://fd/(\d+)$#', $fd, $m)) { + $fd = (int) $m[1]; + } + if (!\is_int($fd) || $fd < 0 || $fd >= \PHP_INT_MAX) { + throw new \InvalidArgumentException( + 'Invalid FD number given (EINVAL)', + \defined('SOCKET_EINVAL') ? \SOCKET_EINVAL : (\defined('PCNTL_EINVAL') ? \PCNTL_EINVAL : 22) + ); + } + + $this->loop = $loop ?? Loop::get(); + + $errno = 0; + $errstr = ''; + \set_error_handler(function ($_, $error) use (&$errno, &$errstr) { + // Match errstr from PHP's warning message. + // fopen(php://fd/3): Failed to open stream: Error duping file descriptor 3; possibly it doesn't exist: [9]: Bad file descriptor + \preg_match('/\[(\d+)\]: (.*)/', $error, $m); + $errno = (int) ($m[1] ?? 0); + $errstr = $m[2] ?? $error; + }); + + $this->master = \fopen('php://fd/' . $fd, 'r+'); + + \restore_error_handler(); + + if (false === $this->master) { + throw new \RuntimeException( + 'Failed to listen on FD ' . $fd . ': ' . $errstr . SocketServer::errconst($errno), + $errno + ); + } + + $meta = \stream_get_meta_data($this->master); + if (!isset($meta['stream_type']) || $meta['stream_type'] !== 'tcp_socket') { + \fclose($this->master); + + $errno = \defined('SOCKET_ENOTSOCK') ? \SOCKET_ENOTSOCK : 88; + $errstr = \function_exists('socket_strerror') ? \socket_strerror($errno) : 'Not a socket'; + + throw new \RuntimeException( + 'Failed to listen on FD ' . $fd . ': ' . $errstr . ' (ENOTSOCK)', + $errno + ); + } + + // Socket should not have a peer address if this is a listening socket. + // Looks like this work-around is the closest we can get because PHP doesn't expose SO_ACCEPTCONN even with ext-sockets. + if (\stream_socket_get_name($this->master, true) !== false) { + \fclose($this->master); + + $errno = \defined('SOCKET_EISCONN') ? \SOCKET_EISCONN : 106; + $errstr = \function_exists('socket_strerror') ? \socket_strerror($errno) : 'Socket is connected'; + + throw new \RuntimeException( + 'Failed to listen on FD ' . $fd . ': ' . $errstr . ' (EISCONN)', + $errno + ); + } + + // Assume this is a Unix domain socket (UDS) when its listening address doesn't parse as a valid URL with a port. + // Looks like this work-around is the closest we can get because PHP doesn't expose SO_DOMAIN even with ext-sockets. + $this->unix = \parse_url($this->getAddress(), \PHP_URL_PORT) === false; + + \stream_set_blocking($this->master, false); + + $this->resume(); + } + + public function getAddress() + { + if (!\is_resource($this->master)) { + return null; + } + + $address = \stream_socket_get_name($this->master, false); + + if ($this->unix === true) { + return 'unix://' . $address; + } + + // check if this is an IPv6 address which includes multiple colons but no square brackets + $pos = \strrpos($address, ':'); + if ($pos !== false && \strpos($address, ':') < $pos && \substr($address, 0, 1) !== '[') { + $address = '[' . \substr($address, 0, $pos) . ']:' . \substr($address, $pos + 1); // @codeCoverageIgnore + } + + return 'tcp://' . $address; + } + + public function pause() + { + if (!$this->listening) { + return; + } + + $this->loop->removeReadStream($this->master); + $this->listening = false; + } + + public function resume() + { + if ($this->listening || !\is_resource($this->master)) { + return; + } + + $this->loop->addReadStream($this->master, function ($master) { + try { + $newSocket = SocketServer::accept($master); + } catch (\RuntimeException $e) { + $this->emit('error', [$e]); + return; + } + $this->handleConnection($newSocket); + }); + $this->listening = true; + } + + public function close() + { + if (!\is_resource($this->master)) { + return; + } + + $this->pause(); + \fclose($this->master); + $this->removeAllListeners(); + } + + /** @internal */ + public function handleConnection($socket) + { + $connection = new Connection($socket, $this->loop); + $connection->unix = $this->unix; + + $this->emit('connection', [$connection]); + } +} diff --git a/src/FixedUriConnector.php b/src/FixedUriConnector.php new file mode 100644 index 00000000..f83241d6 --- /dev/null +++ b/src/FixedUriConnector.php @@ -0,0 +1,41 @@ +connect('localhost:80'); + * ``` + */ +class FixedUriConnector implements ConnectorInterface +{ + private $uri; + private $connector; + + /** + * @param string $uri + * @param ConnectorInterface $connector + */ + public function __construct($uri, ConnectorInterface $connector) + { + $this->uri = $uri; + $this->connector = $connector; + } + + public function connect($_) + { + return $this->connector->connect($this->uri); + } +} diff --git a/src/HappyEyeBallsConnectionBuilder.php b/src/HappyEyeBallsConnectionBuilder.php new file mode 100644 index 00000000..57a94aae --- /dev/null +++ b/src/HappyEyeBallsConnectionBuilder.php @@ -0,0 +1,332 @@ + false, + Message::TYPE_AAAA => false, + ]; + public $resolverPromises = []; + public $connectionPromises = []; + public $connectQueue = []; + public $nextAttemptTimer; + public $parts; + public $ipsCount = 0; + public $failureCount = 0; + public $resolve; + public $reject; + + public $lastErrorFamily; + public $lastError6; + public $lastError4; + + public function __construct(LoopInterface $loop, ConnectorInterface $connector, ResolverInterface $resolver, $uri, $host, $parts) + { + $this->loop = $loop; + $this->connector = $connector; + $this->resolver = $resolver; + $this->uri = $uri; + $this->host = $host; + $this->parts = $parts; + } + + public function connect() + { + return new Promise(function ($resolve, $reject) { + $lookupResolve = function ($type) use ($resolve, $reject) { + return function (array $ips) use ($type, $resolve, $reject) { + unset($this->resolverPromises[$type]); + $this->resolved[$type] = true; + + $this->mixIpsIntoConnectQueue($ips); + + // start next connection attempt if not already awaiting next + if ($this->nextAttemptTimer === null && $this->connectQueue) { + $this->check($resolve, $reject); + } + }; + }; + + $this->resolverPromises[Message::TYPE_AAAA] = $this->resolve(Message::TYPE_AAAA, $reject)->then($lookupResolve(Message::TYPE_AAAA)); + $this->resolverPromises[Message::TYPE_A] = $this->resolve(Message::TYPE_A, $reject)->then(function (array $ips) { + // happy path: IPv6 has resolved already (or could not resolve), continue with IPv4 addresses + if ($this->resolved[Message::TYPE_AAAA] === true || !$ips) { + return $ips; + } + + // Otherwise delay processing IPv4 lookup until short timer passes or IPv6 resolves in the meantime + $deferred = new Deferred(function () use (&$ips) { + // discard all IPv4 addresses if cancelled + $ips = []; + }); + $timer = $this->loop->addTimer($this::RESOLUTION_DELAY, function () use ($deferred, $ips) { + $deferred->resolve($ips); + }); + + $this->resolverPromises[Message::TYPE_AAAA]->then(function () use ($timer, $deferred, &$ips) { + $this->loop->cancelTimer($timer); + $deferred->resolve($ips); + }); + + return $deferred->promise(); + })->then($lookupResolve(Message::TYPE_A)); + }, function ($_, $reject) { + $reject(new \RuntimeException( + 'Connection to ' . $this->uri . ' cancelled' . (!$this->connectionPromises ? ' during DNS lookup' : '') . ' (ECONNABORTED)', + \defined('SOCKET_ECONNABORTED') ? \SOCKET_ECONNABORTED : 103 + )); + $_ = $reject = null; + + $this->cleanUp(); + }); + } + + /** + * @internal + * @param int $type DNS query type + * @param callable $reject + * @return \React\Promise\PromiseInterface Returns a promise that + * always resolves with a list of IP addresses on success or an empty + * list on error. + */ + public function resolve($type, $reject) + { + return $this->resolver->resolveAll($this->host, $type)->then(null, function (\Exception $e) use ($type, $reject) { + unset($this->resolverPromises[$type]); + $this->resolved[$type] = true; + + if ($type === Message::TYPE_A) { + $this->lastError4 = $e->getMessage(); + $this->lastErrorFamily = 4; + } else { + $this->lastError6 = $e->getMessage(); + $this->lastErrorFamily = 6; + } + + // cancel next attempt timer when there are no more IPs to connect to anymore + if ($this->nextAttemptTimer !== null && !$this->connectQueue) { + $this->loop->cancelTimer($this->nextAttemptTimer); + $this->nextAttemptTimer = null; + } + + if ($this->hasBeenResolved() && $this->ipsCount === 0) { + $reject(new \RuntimeException( + $this->error(), + 0, + $e + )); + } + + // Exception already handled above, so don't throw an unhandled rejection here + return []; + }); + } + + /** + * @internal + */ + public function check($resolve, $reject) + { + $ip = \array_shift($this->connectQueue); + + // start connection attempt and remember array position to later unset again + $this->connectionPromises[] = $this->attemptConnection($ip); + \end($this->connectionPromises); + $index = \key($this->connectionPromises); + + $this->connectionPromises[$index]->then(function ($connection) use ($index, $resolve) { + unset($this->connectionPromises[$index]); + + $this->cleanUp(); + + $resolve($connection); + }, function (\Exception $e) use ($index, $ip, $resolve, $reject) { + unset($this->connectionPromises[$index]); + + $this->failureCount++; + + $message = \preg_replace('/^(Connection to [^ ]+)[&?]hostname=[^ &]+/', '$1', $e->getMessage()); + if (\strpos($ip, ':') === false) { + $this->lastError4 = $message; + $this->lastErrorFamily = 4; + } else { + $this->lastError6 = $message; + $this->lastErrorFamily = 6; + } + + // start next connection attempt immediately on error + if ($this->connectQueue) { + if ($this->nextAttemptTimer !== null) { + $this->loop->cancelTimer($this->nextAttemptTimer); + $this->nextAttemptTimer = null; + } + + $this->check($resolve, $reject); + } + + if ($this->hasBeenResolved() === false) { + return; + } + + if ($this->ipsCount === $this->failureCount) { + $this->cleanUp(); + + $reject(new \RuntimeException( + $this->error(), + $e->getCode(), + $e + )); + } + }); + + // Allow next connection attempt in 100ms: https://tools.ietf.org/html/rfc8305#section-5 + // Only start timer when more IPs are queued or when DNS query is still pending (might add more IPs) + if ($this->nextAttemptTimer === null && (\count($this->connectQueue) > 0 || $this->resolved[Message::TYPE_A] === false || $this->resolved[Message::TYPE_AAAA] === false)) { + $this->nextAttemptTimer = $this->loop->addTimer(self::CONNECTION_ATTEMPT_DELAY, function () use ($resolve, $reject) { + $this->nextAttemptTimer = null; + + if ($this->connectQueue) { + $this->check($resolve, $reject); + } + }); + } + } + + /** + * @internal + */ + public function attemptConnection($ip) + { + $uri = Connector::uri($this->parts, $this->host, $ip); + + return $this->connector->connect($uri); + } + + /** + * @internal + */ + public function cleanUp() + { + // clear list of outstanding IPs to avoid creating new connections + $this->connectQueue = []; + + // cancel pending connection attempts + foreach ($this->connectionPromises as $connectionPromise) { + if ($connectionPromise instanceof PromiseInterface && \method_exists($connectionPromise, 'cancel')) { + $connectionPromise->cancel(); + } + } + + // cancel pending DNS resolution (cancel IPv4 first in case it is awaiting IPv6 resolution delay) + foreach (\array_reverse($this->resolverPromises) as $resolverPromise) { + if ($resolverPromise instanceof PromiseInterface && \method_exists($resolverPromise, 'cancel')) { + $resolverPromise->cancel(); + } + } + + if ($this->nextAttemptTimer instanceof TimerInterface) { + $this->loop->cancelTimer($this->nextAttemptTimer); + $this->nextAttemptTimer = null; + } + } + + /** + * @internal + */ + public function hasBeenResolved() + { + foreach ($this->resolved as $typeHasBeenResolved) { + if ($typeHasBeenResolved === false) { + return false; + } + } + + return true; + } + + /** + * Mixes an array of IP addresses into the connect queue in such a way they alternate when attempting to connect. + * The goal behind it is first attempt to connect to IPv6, then to IPv4, then to IPv6 again until one of those + * attempts succeeds. + * + * @link https://tools.ietf.org/html/rfc8305#section-4 + * + * @internal + */ + public function mixIpsIntoConnectQueue(array $ips) + { + \shuffle($ips); + $this->ipsCount += \count($ips); + $connectQueueStash = $this->connectQueue; + $this->connectQueue = []; + while (\count($connectQueueStash) > 0 || \count($ips) > 0) { + if (\count($ips) > 0) { + $this->connectQueue[] = \array_shift($ips); + } + if (\count($connectQueueStash) > 0) { + $this->connectQueue[] = \array_shift($connectQueueStash); + } + } + } + + /** + * @internal + * @return string + */ + public function error() + { + if ($this->lastError4 === $this->lastError6) { + $message = $this->lastError6; + } elseif ($this->lastErrorFamily === 6) { + $message = 'Last error for IPv6: ' . $this->lastError6 . '. Previous error for IPv4: ' . $this->lastError4; + } else { + $message = 'Last error for IPv4: ' . $this->lastError4 . '. Previous error for IPv6: ' . $this->lastError6; + } + + if ($this->hasBeenResolved() && $this->ipsCount === 0) { + if ($this->lastError6 === $this->lastError4) { + $message = ' during DNS lookup: ' . $this->lastError6; + } else { + $message = ' during DNS lookup. ' . $message; + } + } else { + $message = ': ' . $message; + } + + return 'Connection to ' . $this->uri . ' failed' . $message; + } +} diff --git a/src/HappyEyeBallsConnector.php b/src/HappyEyeBallsConnector.php new file mode 100644 index 00000000..89ec203a --- /dev/null +++ b/src/HappyEyeBallsConnector.php @@ -0,0 +1,60 @@ +loop = $loop ?? Loop::get(); + $this->connector = $connector; + $this->resolver = $resolver; + } + + public function connect($uri) + { + $original = $uri; + if (\strpos($uri, '://') === false) { + $uri = 'tcp://' . $uri; + $parts = \parse_url($uri); + if (isset($parts['scheme'])) { + unset($parts['scheme']); + } + } else { + $parts = \parse_url($uri); + } + + if (!$parts || !isset($parts['host'])) { + return reject(new \InvalidArgumentException( + 'Given URI "' . $original . '" is invalid (EINVAL)', + \defined('SOCKET_EINVAL') ? \SOCKET_EINVAL : (\defined('PCNTL_EINVAL') ? \PCNTL_EINVAL : 22) + )); + } + + $host = \trim($parts['host'], '[]'); + + // skip DNS lookup / URI manipulation if this URI already contains an IP + if (@\inet_pton($host) !== false) { + return $this->connector->connect($original); + } + + $builder = new HappyEyeBallsConnectionBuilder( + $this->loop, + $this->connector, + $this->resolver, + $uri, + $host, + $parts + ); + return $builder->connect(); + } +} diff --git a/src/LimitingServer.php b/src/LimitingServer.php new file mode 100644 index 00000000..4742e252 --- /dev/null +++ b/src/LimitingServer.php @@ -0,0 +1,200 @@ +on('connection', function (React\Socket\ConnectionInterface $connection) { + * $connection->write('hello there!' . PHP_EOL); + * … + * }); + * ``` + * + * See also the `ServerInterface` for more details. + * + * @see ServerInterface + * @see ConnectionInterface + */ +class LimitingServer extends EventEmitter implements ServerInterface +{ + private $connections = []; + private $server; + private $limit; + + private $pauseOnLimit = false; + private $autoPaused = false; + private $manuPaused = false; + + /** + * Instantiates a new LimitingServer. + * + * You have to pass a maximum number of open connections to ensure + * the server will automatically reject (close) connections once this limit + * is exceeded. In this case, it will emit an `error` event to inform about + * this and no `connection` event will be emitted. + * + * ```php + * $server = new React\Socket\LimitingServer($server, 100); + * $server->on('connection', function (React\Socket\ConnectionInterface $connection) { + * $connection->write('hello there!' . PHP_EOL); + * … + * }); + * ``` + * + * You MAY pass a `null` limit in order to put no limit on the number of + * open connections and keep accepting new connection until you run out of + * operating system resources (such as open file handles). This may be + * useful if you do not want to take care of applying a limit but still want + * to use the `getConnections()` method. + * + * You can optionally configure the server to pause accepting new + * connections once the connection limit is reached. In this case, it will + * pause the underlying server and no longer process any new connections at + * all, thus also no longer closing any excessive connections. + * The underlying operating system is responsible for keeping a backlog of + * pending connections until its limit is reached, at which point it will + * start rejecting further connections. + * Once the server is below the connection limit, it will continue consuming + * connections from the backlog and will process any outstanding data on + * each connection. + * This mode may be useful for some protocols that are designed to wait for + * a response message (such as HTTP), but may be less useful for other + * protocols that demand immediate responses (such as a "welcome" message in + * an interactive chat). + * + * ```php + * $server = new React\Socket\LimitingServer($server, 100, true); + * $server->on('connection', function (React\Socket\ConnectionInterface $connection) { + * $connection->write('hello there!' . PHP_EOL); + * … + * }); + * ``` + * + * @param ServerInterface $server + * @param int|null $connectionLimit + * @param bool $pauseOnLimit + */ + public function __construct(ServerInterface $server, $connectionLimit, $pauseOnLimit = false) + { + $this->server = $server; + $this->limit = $connectionLimit; + if ($connectionLimit !== null) { + $this->pauseOnLimit = $pauseOnLimit; + } + + $this->server->on('connection', [$this, 'handleConnection']); + $this->server->on('error', [$this, 'handleError']); + } + + /** + * Returns an array with all currently active connections + * + * ```php + * foreach ($server->getConnection() as $connection) { + * $connection->write('Hi!'); + * } + * ``` + * + * @return ConnectionInterface[] + */ + public function getConnections() + { + return $this->connections; + } + + public function getAddress() + { + return $this->server->getAddress(); + } + + public function pause() + { + if (!$this->manuPaused) { + $this->manuPaused = true; + + if (!$this->autoPaused) { + $this->server->pause(); + } + } + } + + public function resume() + { + if ($this->manuPaused) { + $this->manuPaused = false; + + if (!$this->autoPaused) { + $this->server->resume(); + } + } + } + + public function close() + { + $this->server->close(); + } + + /** @internal */ + public function handleConnection(ConnectionInterface $connection) + { + // close connection if limit exceeded + if ($this->limit !== null && \count($this->connections) >= $this->limit) { + $this->handleError(new \OverflowException('Connection closed because server reached connection limit')); + $connection->close(); + return; + } + + $this->connections[] = $connection; + $connection->on('close', function () use ($connection) { + $this->handleDisconnection($connection); + }); + + // pause accepting new connections if limit exceeded + if ($this->pauseOnLimit && !$this->autoPaused && \count($this->connections) >= $this->limit) { + $this->autoPaused = true; + + if (!$this->manuPaused) { + $this->server->pause(); + } + } + + $this->emit('connection', [$connection]); + } + + /** @internal */ + public function handleDisconnection(ConnectionInterface $connection) + { + unset($this->connections[\array_search($connection, $this->connections)]); + + // continue accepting new connection if below limit + if ($this->autoPaused && \count($this->connections) < $this->limit) { + $this->autoPaused = false; + + if (!$this->manuPaused) { + $this->server->resume(); + } + } + } + + /** @internal */ + public function handleError(\Exception $error) + { + $this->emit('error', [$error]); + } +} diff --git a/src/SecureConnector.php b/src/SecureConnector.php new file mode 100644 index 00000000..7626b0a6 --- /dev/null +++ b/src/SecureConnector.php @@ -0,0 +1,117 @@ +connector = $connector; + $this->streamEncryption = new StreamEncryption($loop ?? Loop::get(), false); + $this->context = $context; + } + + public function connect($uri) + { + if (\strpos($uri, '://') === false) { + $uri = 'tls://' . $uri; + } + + $parts = \parse_url($uri); + if (!$parts || !isset($parts['scheme']) || $parts['scheme'] !== 'tls') { + return reject(new \InvalidArgumentException( + 'Given URI "' . $uri . '" is invalid (EINVAL)', + \defined('SOCKET_EINVAL') ? \SOCKET_EINVAL : (\defined('PCNTL_EINVAL') ? \PCNTL_EINVAL : 22) + )); + } + + $connected = false; + /** @var \React\Promise\PromiseInterface $promise */ + $promise = $this->connector->connect( + \str_replace('tls://', '', $uri) + )->then(function (ConnectionInterface $connection) use ($uri, &$promise, &$connected) { + // (unencrypted) TCP/IP connection succeeded + $connected = true; + + if (!$connection instanceof Connection) { + $connection->close(); + throw new \UnexpectedValueException('Base connector does not use internal Connection class exposing stream resource'); + } + + // set required SSL/TLS context options + foreach ($this->context as $name => $value) { + \stream_context_set_option($connection->stream, 'ssl', $name, $value); + } + + // try to enable encryption + return $promise = $this->streamEncryption->enable($connection)->then(null, function ($error) use ($connection, $uri) { + // establishing encryption failed => close invalid connection and return error + $connection->close(); + + throw new \RuntimeException( + 'Connection to ' . $uri . ' failed during TLS handshake: ' . $error->getMessage(), + $error->getCode() + ); + }); + }, function (\Exception $e) use ($uri) { + if ($e instanceof \RuntimeException) { + $message = \preg_replace('/^Connection to [^ ]+/', '', $e->getMessage()); + $e = new \RuntimeException( + 'Connection to ' . $uri . $message, + $e->getCode(), + $e + ); + + // avoid garbage references by replacing all closures in call stack. + // what a lovely piece of code! + $r = new \ReflectionProperty(\Exception::class, 'trace'); + if (\PHP_VERSION_ID < 80100) { + $r->setAccessible(true); + } + $trace = $r->getValue($e); + + // Exception trace arguments are not available on some PHP 7.4 installs + // @codeCoverageIgnoreStart + foreach ($trace as $ti => $one) { + if (isset($one['args'])) { + foreach ($one['args'] as $ai => $arg) { + if ($arg instanceof \Closure) { + $trace[$ti]['args'][$ai] = 'Object(' . \get_class($arg) . ')'; + } + } + } + } + // @codeCoverageIgnoreEnd + $r->setValue($e, $trace); + } + + throw $e; + }); + + return new Promise( + function ($resolve, $reject) use ($promise) { + $promise->then($resolve, $reject); + }, + function ($_, $reject) use (&$promise, $uri, &$connected) { + if ($connected) { + $reject(new \RuntimeException( + 'Connection to ' . $uri . ' cancelled during TLS handshake (ECONNABORTED)', + \defined('SOCKET_ECONNABORTED') ? \SOCKET_ECONNABORTED : 103 + )); + } + + $promise->cancel(); + $promise = null; + } + ); + } +} diff --git a/src/SecureServer.php b/src/SecureServer.php new file mode 100644 index 00000000..7ef5d94d --- /dev/null +++ b/src/SecureServer.php @@ -0,0 +1,197 @@ +on('connection', function (React\Socket\ConnectionInterface $connection) { + * echo 'Secure connection from' . $connection->getRemoteAddress() . PHP_EOL; + * + * $connection->write('hello there!' . PHP_EOL); + * … + * }); + * ``` + * + * Whenever a client fails to perform a successful TLS handshake, it will emit an + * `error` event and then close the underlying TCP/IP connection: + * + * ```php + * $server->on('error', function (Exception $e) { + * echo 'Error' . $e->getMessage() . PHP_EOL; + * }); + * ``` + * + * See also the `ServerInterface` for more details. + * + * Note that the `SecureServer` class is a concrete implementation for TLS sockets. + * If you want to typehint in your higher-level protocol implementation, you SHOULD + * use the generic `ServerInterface` instead. + * + * @see ServerInterface + * @see ConnectionInterface + */ +final class SecureServer extends EventEmitter implements ServerInterface +{ + private $tcp; + private $encryption; + private $context; + + /** + * Creates a secure TLS server and starts waiting for incoming connections + * + * It does so by wrapping a `TcpServer` instance which waits for plaintext + * TCP/IP connections and then performs a TLS handshake for each connection. + * It thus requires valid [TLS context options], + * which in its most basic form may look something like this if you're using a + * PEM encoded certificate file: + * + * ```php + * $server = new React\Socket\TcpServer(8000); + * $server = new React\Socket\SecureServer($server, null, [ + * 'local_cert' => 'server.pem' + * ]); + * ``` + * + * Note that the certificate file will not be loaded on instantiation but when an + * incoming connection initializes its TLS context. + * This implies that any invalid certificate file paths or contents will only cause + * an `error` event at a later time. + * + * If your private key is encrypted with a passphrase, you have to specify it + * like this: + * + * ```php + * $server = new React\Socket\TcpServer(8000); + * $server = new React\Socket\SecureServer($server, null, [ + * 'local_cert' => 'server.pem', + * 'passphrase' => 'secret' + * ]); + * ``` + * + * Note that available [TLS context options], + * their defaults and effects of changing these may vary depending on your system + * and/or PHP version. + * Passing unknown context options has no effect. + * + * This class takes an optional `LoopInterface|null $loop` parameter that can be used to + * pass the event loop instance to use for this object. You can use a `null` value + * here in order to use the [default loop](https://github.com/reactphp/event-loop#loop). + * This value SHOULD NOT be given unless you're sure you want to explicitly use a + * given event loop instance. + * + * Advanced usage: Despite allowing any `ServerInterface` as first parameter, + * you SHOULD pass a `TcpServer` instance as first parameter, unless you + * know what you're doing. + * Internally, the `SecureServer` has to set the required TLS context options on + * the underlying stream resources. + * These resources are not exposed through any of the interfaces defined in this + * package, but only through the internal `Connection` class. + * The `TcpServer` class is guaranteed to emit connections that implement + * the `ConnectionInterface` and uses the internal `Connection` class in order to + * expose these underlying resources. + * If you use a custom `ServerInterface` and its `connection` event does not + * meet this requirement, the `SecureServer` will emit an `error` event and + * then close the underlying connection. + * + * @param ServerInterface|TcpServer $tcp + * @param ?LoopInterface $loop + * @param array $context + * @see TcpServer + * @link https://www.php.net/manual/en/context.ssl.php for TLS context options + */ + public function __construct(ServerInterface $tcp, ?LoopInterface $loop = null, array $context = []) + { + // default to empty passphrase to suppress blocking passphrase prompt + $context += [ + 'passphrase' => '' + ]; + + $this->tcp = $tcp; + $this->encryption = new StreamEncryption($loop ?? Loop::get()); + $this->context = $context; + + $this->tcp->on('connection', function ($connection) { + $this->handleConnection($connection); + }); + $this->tcp->on('error', function ($error) { + $this->emit('error', [$error]); + }); + } + + public function getAddress() + { + $address = $this->tcp->getAddress(); + if ($address === null) { + return null; + } + + return \str_replace('tcp://' , 'tls://', $address); + } + + public function pause() + { + $this->tcp->pause(); + } + + public function resume() + { + $this->tcp->resume(); + } + + public function close() + { + return $this->tcp->close(); + } + + /** @internal */ + public function handleConnection(ConnectionInterface $connection) + { + if (!$connection instanceof Connection) { + $this->emit('error', [new \UnexpectedValueException('Base server does not use internal Connection class exposing stream resource')]); + $connection->close(); + return; + } + + foreach ($this->context as $name => $value) { + \stream_context_set_option($connection->stream, 'ssl', $name, $value); + } + + // get remote address before starting TLS handshake in case connection closes during handshake + $remote = $connection->getRemoteAddress(); + + $this->encryption->enable($connection)->then( + function ($conn) { + $this->emit('connection', [$conn]); + }, + function ($error) use ($connection, $remote) { + $error = new \RuntimeException( + 'Connection from ' . $remote . ' failed during TLS handshake: ' . $error->getMessage(), + $error->getCode() + ); + + $this->emit('error', [$error]); + $connection->close(); + } + ); + } +} diff --git a/src/ServerInterface.php b/src/ServerInterface.php new file mode 100644 index 00000000..aa79fa17 --- /dev/null +++ b/src/ServerInterface.php @@ -0,0 +1,151 @@ +on('connection', function (React\Socket\ConnectionInterface $connection) { + * echo 'new connection' . PHP_EOL; + * }); + * ``` + * + * See also the `ConnectionInterface` for more details about handling the + * incoming connection. + * + * error event: + * The `error` event will be emitted whenever there's an error accepting a new + * connection from a client. + * + * ```php + * $socket->on('error', function (Exception $e) { + * echo 'error: ' . $e->getMessage() . PHP_EOL; + * }); + * ``` + * + * Note that this is not a fatal error event, i.e. the server keeps listening for + * new connections even after this event. + * + * @see ConnectionInterface + */ +interface ServerInterface extends EventEmitterInterface +{ + /** + * Returns the full address (URI) this server is currently listening on + * + * ```php + * $address = $socket->getAddress(); + * echo 'Server listening on ' . $address . PHP_EOL; + * ``` + * + * If the address can not be determined or is unknown at this time (such as + * after the socket has been closed), it MAY return a `NULL` value instead. + * + * Otherwise, it will return the full address (URI) as a string value, such + * as `tcp://127.0.0.1:8080`, `tcp://[::1]:80` or `tls://127.0.0.1:443`. + * Note that individual URI components are application specific and depend + * on the underlying transport protocol. + * + * If this is a TCP/IP based server and you only want the local port, you may + * use something like this: + * + * ```php + * $address = $socket->getAddress(); + * $port = parse_url($address, PHP_URL_PORT); + * echo 'Server listening on port ' . $port . PHP_EOL; + * ``` + * + * @return ?string the full listening address (URI) or NULL if it is unknown (not applicable to this server socket or already closed) + */ + public function getAddress(); + + /** + * Pauses accepting new incoming connections. + * + * Removes the socket resource from the EventLoop and thus stop accepting + * new connections. Note that the listening socket stays active and is not + * closed. + * + * This means that new incoming connections will stay pending in the + * operating system backlog until its configurable backlog is filled. + * Once the backlog is filled, the operating system may reject further + * incoming connections until the backlog is drained again by resuming + * to accept new connections. + * + * Once the server is paused, no futher `connection` events SHOULD + * be emitted. + * + * ```php + * $socket->pause(); + * + * $socket->on('connection', assertShouldNeverCalled()); + * ``` + * + * This method is advisory-only, though generally not recommended, the + * server MAY continue emitting `connection` events. + * + * Unless otherwise noted, a successfully opened server SHOULD NOT start + * in paused state. + * + * You can continue processing events by calling `resume()` again. + * + * Note that both methods can be called any number of times, in particular + * calling `pause()` more than once SHOULD NOT have any effect. + * Similarly, calling this after `close()` is a NO-OP. + * + * @see self::resume() + * @return void + */ + public function pause(); + + /** + * Resumes accepting new incoming connections. + * + * Re-attach the socket resource to the EventLoop after a previous `pause()`. + * + * ```php + * $socket->pause(); + * + * Loop::addTimer(1.0, function () use ($socket) { + * $socket->resume(); + * }); + * ``` + * + * Note that both methods can be called any number of times, in particular + * calling `resume()` without a prior `pause()` SHOULD NOT have any effect. + * Similarly, calling this after `close()` is a NO-OP. + * + * @see self::pause() + * @return void + */ + public function resume(); + + /** + * Shuts down this listening socket + * + * This will stop listening for new incoming connections on this socket. + * + * Calling this method more than once on the same instance is a NO-OP. + * + * @return void + */ + public function close(); +} diff --git a/src/SocketServer.php b/src/SocketServer.php new file mode 100644 index 00000000..2106ff36 --- /dev/null +++ b/src/SocketServer.php @@ -0,0 +1,210 @@ + [], + 'tls' => [], + 'unix' => [] + ]; + + $scheme = 'tcp'; + $pos = \strpos($uri, '://'); + if ($pos !== false) { + $scheme = \substr($uri, 0, $pos); + } + + if ($scheme === 'unix') { + $server = new UnixServer($uri, $loop, $context['unix']); + } elseif ($scheme === 'php') { + $server = new FdServer($uri, $loop); + } else { + if (preg_match('#^(?:\w+://)?\d+$#', $uri)) { + throw new \InvalidArgumentException( + 'Invalid URI given (EINVAL)', + \defined('SOCKET_EINVAL') ? \SOCKET_EINVAL : (\defined('PCNTL_EINVAL') ? \PCNTL_EINVAL : 22) + ); + } + + $server = new TcpServer(str_replace('tls://', '', $uri), $loop, $context['tcp']); + + if ($scheme === 'tls') { + $server = new SecureServer($server, $loop, $context['tls']); + } + } + + $this->server = $server; + + $server->on('connection', function (ConnectionInterface $conn) { + $this->emit('connection', [$conn]); + }); + $server->on('error', function (\Exception $error) { + $this->emit('error', [$error]); + }); + } + + public function getAddress() + { + return $this->server->getAddress(); + } + + public function pause() + { + $this->server->pause(); + } + + public function resume() + { + $this->server->resume(); + } + + public function close() + { + $this->server->close(); + } + + /** + * [internal] Internal helper method to accept new connection from given server socket + * + * @param resource $socket server socket to accept connection from + * @return resource new client socket if any + * @throws \RuntimeException if accepting fails + * @internal + */ + public static function accept($socket) + { + $errno = 0; + $errstr = ''; + \set_error_handler(function ($_, $error) use (&$errno, &$errstr) { + // Match errstr from PHP's warning message. + // stream_socket_accept(): accept failed: Connection timed out + $errstr = \preg_replace('#.*: #', '', $error); + $errno = self::errno($errstr); + }); + + $newSocket = \stream_socket_accept($socket, 0); + + \restore_error_handler(); + + if (false === $newSocket) { + throw new \RuntimeException( + 'Unable to accept new connection: ' . $errstr . self::errconst($errno), + $errno + ); + } + + return $newSocket; + } + + /** + * [Internal] Returns errno value for given errstr + * + * The errno and errstr values describes the type of error that has been + * encountered. This method tries to look up the given errstr and find a + * matching errno value which can be useful to provide more context to error + * messages. It goes through the list of known errno constants when either + * `ext-sockets`, `ext-posix` or `ext-pcntl` is available to find an errno + * matching the given errstr. + * + * @param string $errstr + * @return int errno value (e.g. value of `SOCKET_ECONNREFUSED`) or 0 if not found + * @internal + * @copyright Copyright (c) 2023 Christian Lück, taken from https://github.com/clue/errno with permission + * @codeCoverageIgnore + */ + public static function errno($errstr) + { + // PHP defines the required `strerror()` function through either `ext-sockets`, `ext-posix` or `ext-pcntl` + $strerror = \function_exists('socket_strerror') ? 'socket_strerror' : (\function_exists('posix_strerror') ? 'posix_strerror' : (\function_exists('pcntl_strerror') ? 'pcntl_strerror' : null)); + if ($strerror !== null) { + assert(\is_string($strerror) && \is_callable($strerror)); + + // PHP defines most useful errno constants like `ECONNREFUSED` through constants in `ext-sockets` like `SOCKET_ECONNREFUSED` + // PHP also defines a hand full of errno constants like `EMFILE` through constants in `ext-pcntl` like `PCNTL_EMFILE` + // go through list of all defined constants like `SOCKET_E*` and `PCNTL_E*` and see if they match the given `$errstr` + foreach (\get_defined_constants(false) as $name => $value) { + if (\is_int($value) && (\strpos($name, 'SOCKET_E') === 0 || \strpos($name, 'PCNTL_E') === 0) && $strerror($value) === $errstr) { + return $value; + } + } + + // if we reach this, no matching errno constant could be found (unlikely when `ext-sockets` is available) + // go through list of all possible errno values from 1 to `MAX_ERRNO` and see if they match the given `$errstr` + for ($errno = 1, $max = \defined('MAX_ERRNO') ? \MAX_ERRNO : 4095; $errno <= $max; ++$errno) { + if ($strerror($errno) === $errstr) { + return $errno; + } + } + } + + // if we reach this, no matching errno value could be found (unlikely when either `ext-sockets`, `ext-posix` or `ext-pcntl` is available) + return 0; + } + + /** + * [Internal] Returns errno constant name for given errno value + * + * The errno value describes the type of error that has been encountered. + * This method tries to look up the given errno value and find a matching + * errno constant name which can be useful to provide more context and more + * descriptive error messages. It goes through the list of known errno + * constants when either `ext-sockets` or `ext-pcntl` is available to find + * the matching errno constant name. + * + * Because this method is used to append more context to error messages, the + * constant name will be prefixed with a space and put between parenthesis + * when found. + * + * @param int $errno + * @return string e.g. ` (ECONNREFUSED)` or empty string if no matching const for the given errno could be found + * @internal + * @copyright Copyright (c) 2023 Christian Lück, taken from https://github.com/clue/errno with permission + * @codeCoverageIgnore + */ + public static function errconst($errno) + { + // PHP defines most useful errno constants like `ECONNREFUSED` through constants in `ext-sockets` like `SOCKET_ECONNREFUSED` + // PHP also defines a hand full of errno constants like `EMFILE` through constants in `ext-pcntl` like `PCNTL_EMFILE` + // go through list of all defined constants like `SOCKET_E*` and `PCNTL_E*` and see if they match the given `$errno` + foreach (\get_defined_constants(false) as $name => $value) { + if ($value === $errno && (\strpos($name, 'SOCKET_E') === 0 || \strpos($name, 'PCNTL_E') === 0)) { + return ' (' . \substr($name, \strpos($name, '_') + 1) . ')'; + } + } + + // if we reach this, no matching errno constant could be found (unlikely when `ext-sockets` is available) + return ''; + } +} diff --git a/src/StreamEncryption.php b/src/StreamEncryption.php new file mode 100644 index 00000000..b03b79b8 --- /dev/null +++ b/src/StreamEncryption.php @@ -0,0 +1,149 @@ +loop = $loop; + $this->server = $server; + + // support TLSv1.0+ by default and exclude legacy SSLv2/SSLv3. + // As of PHP 7.2+ the main crypto method constant includes all TLS versions. + // In prior PHP versions, the crypto method is a bitmask, so we explicitly include all TLS versions. + // @link https://3v4l.org/9PSST + if ($server) { + $this->method = \STREAM_CRYPTO_METHOD_TLS_SERVER; + + if (\PHP_VERSION_ID < 70200) { + $this->method |= \STREAM_CRYPTO_METHOD_TLSv1_0_SERVER | \STREAM_CRYPTO_METHOD_TLSv1_1_SERVER | \STREAM_CRYPTO_METHOD_TLSv1_2_SERVER; // @codeCoverageIgnore + } + } else { + $this->method = \STREAM_CRYPTO_METHOD_TLS_CLIENT; + + if (\PHP_VERSION_ID < 70200) { + $this->method |= \STREAM_CRYPTO_METHOD_TLSv1_0_CLIENT | \STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT | \STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT; // @codeCoverageIgnore + } + } + } + + /** + * @param Connection $stream + * @return \React\Promise\PromiseInterface + */ + public function enable(Connection $stream) + { + return $this->toggle($stream, true); + } + + /** + * @param Connection $stream + * @param bool $toggle + * @return \React\Promise\PromiseInterface + */ + public function toggle(Connection $stream, $toggle) + { + // pause actual stream instance to continue operation on raw stream socket + $stream->pause(); + + // TODO: add write() event to make sure we're not sending any excessive data + + // cancelling this leaves this stream in an inconsistent state… + $deferred = new Deferred(function () { + throw new \RuntimeException(); + }); + + // get actual stream socket from stream instance + $socket = $stream->stream; + + // get crypto method from context options or use global setting from constructor + $context = \stream_context_get_options($socket); + $method = $context['ssl']['crypto_method'] ?? $this->method; + + $toggleCrypto = function () use ($socket, $deferred, $toggle, $method) { + $this->toggleCrypto($socket, $deferred, $toggle, $method); + }; + + $this->loop->addReadStream($socket, $toggleCrypto); + + if (!$this->server) { + $toggleCrypto(); + } + + return $deferred->promise()->then(function () use ($stream, $socket, $toggle) { + $this->loop->removeReadStream($socket); + + $stream->encryptionEnabled = $toggle; + $stream->resume(); + + return $stream; + }, function($error) use ($stream, $socket) { + $this->loop->removeReadStream($socket); + $stream->resume(); + throw $error; + }); + } + + /** + * @internal + * @param resource $socket + * @param Deferred $deferred + * @param bool $toggle + * @param int $method + * @return void + */ + public function toggleCrypto($socket, Deferred $deferred, $toggle, $method) + { + $error = null; + \set_error_handler(function ($_, $errstr) use (&$error) { + $error = \str_replace(["\r", "\n"], ' ', $errstr); + + // remove useless function name from error message + if (($pos = \strpos($error, "): ")) !== false) { + $error = \substr($error, $pos + 3); + } + }); + + $result = \stream_socket_enable_crypto($socket, $toggle, $method); + + \restore_error_handler(); + + if (true === $result) { + $deferred->resolve(null); + } else if (false === $result) { + // overwrite callback arguments for PHP7+ only, so they do not show + // up in the Exception trace and do not cause a possible cyclic reference. + $d = $deferred; + $deferred = null; + + if (\feof($socket) || $error === null) { + // EOF or failed without error => connection closed during handshake + $d->reject(new \UnexpectedValueException( + 'Connection lost during TLS handshake (ECONNRESET)', + \defined('SOCKET_ECONNRESET') ? \SOCKET_ECONNRESET : 104 + )); + } else { + // handshake failed with error message + $d->reject(new \UnexpectedValueException( + $error + )); + } + } else { + // need more data, will retry + } + } +} diff --git a/src/TcpConnector.php b/src/TcpConnector.php new file mode 100644 index 00000000..0949184e --- /dev/null +++ b/src/TcpConnector.php @@ -0,0 +1,143 @@ +loop = $loop ?? Loop::get(); + $this->context = $context; + } + + public function connect($uri) + { + if (\strpos($uri, '://') === false) { + $uri = 'tcp://' . $uri; + } + + $parts = \parse_url($uri); + if (!$parts || !isset($parts['scheme'], $parts['host'], $parts['port']) || $parts['scheme'] !== 'tcp') { + return reject(new \InvalidArgumentException( + 'Given URI "' . $uri . '" is invalid (EINVAL)', + \defined('SOCKET_EINVAL') ? \SOCKET_EINVAL : (\defined('PCNTL_EINVAL') ? \PCNTL_EINVAL : 22) + )); + } + + $ip = \trim($parts['host'], '[]'); + if (@\inet_pton($ip) === false) { + return reject(new \InvalidArgumentException( + 'Given URI "' . $uri . '" does not contain a valid host IP (EINVAL)', + \defined('SOCKET_EINVAL') ? \SOCKET_EINVAL : (\defined('PCNTL_EINVAL') ? \PCNTL_EINVAL : 22) + )); + } + + // use context given in constructor + $context = [ + 'socket' => $this->context + ]; + + // parse arguments from query component of URI + $args = []; + if (isset($parts['query'])) { + \parse_str($parts['query'], $args); + } + + // If an original hostname has been given, use this for TLS setup. + // This can happen due to layers of nested connectors, such as a + // DnsConnector reporting its original hostname. + // These context options are here in case TLS is enabled later on this stream. + // If TLS is not enabled later, this doesn't hurt either. + if (isset($args['hostname'])) { + $context['ssl'] = [ + 'SNI_enabled' => true, + 'peer_name' => $args['hostname'] + ]; + } + + // PHP 7.1.4 does not accept any other URI components (such as a query with no path), so let's simplify our URI here + $remote = 'tcp://' . $parts['host'] . ':' . $parts['port']; + + $stream = @\stream_socket_client( + $remote, + $errno, + $errstr, + 0, + \STREAM_CLIENT_CONNECT | \STREAM_CLIENT_ASYNC_CONNECT, + \stream_context_create($context) + ); + + if (false === $stream) { + return reject(new \RuntimeException( + 'Connection to ' . $uri . ' failed: ' . $errstr . SocketServer::errconst($errno), + $errno + )); + } + + // wait for connection + return new Promise(function ($resolve, $reject) use ($stream, $uri) { + $this->loop->addWriteStream($stream, function ($stream) use ($resolve, $reject, $uri) { + $this->loop->removeWriteStream($stream); + + // The following hack looks like the only way to + // detect connection refused errors with PHP's stream sockets. + if (false === \stream_socket_get_name($stream, true)) { + // If we reach this point, we know the connection is dead, but we don't know the underlying error condition. + // @codeCoverageIgnoreStart + if (\function_exists('socket_import_stream')) { + // actual socket errno and errstr can be retrieved with ext-sockets + $socket = \socket_import_stream($stream); + $errno = \socket_get_option($socket, \SOL_SOCKET, \SO_ERROR); + $errstr = \socket_strerror($errno); + } elseif (\PHP_OS === 'Linux') { + // Linux reports socket errno and errstr again when trying to write to the dead socket. + // Suppress error reporting to get error message below and close dead socket before rejecting. + // This is only known to work on Linux, Mac and Windows are known to not support this. + $errno = 0; + $errstr = ''; + \set_error_handler(function ($_, $error) use (&$errno, &$errstr) { + // Match errstr from PHP's warning message. + // fwrite(): send of 1 bytes failed with errno=111 Connection refused + \preg_match('/errno=(\d+) (.+)/', $error, $m); + $errno = (int) ($m[1] ?? 0); + $errstr = $m[2] ?? $error; + }); + + \fwrite($stream, \PHP_EOL); + + \restore_error_handler(); + } else { + // Not on Linux and ext-sockets not available? Too bad. + $errno = \defined('SOCKET_ECONNREFUSED') ? \SOCKET_ECONNREFUSED : 111; + $errstr = 'Connection refused?'; + } + // @codeCoverageIgnoreEnd + + \fclose($stream); + $reject(new \RuntimeException( + 'Connection to ' . $uri . ' failed: ' . $errstr . SocketServer::errconst($errno), + $errno + )); + } else { + $resolve(new Connection($stream, $this->loop)); + } + }); + }, function () use ($stream, $uri) { + $this->loop->removeWriteStream($stream); + \fclose($stream); + + throw new \RuntimeException( + 'Connection to ' . $uri . ' cancelled during TCP/IP handshake (ECONNABORTED)', + \defined('SOCKET_ECONNABORTED') ? \SOCKET_ECONNABORTED : 103 + ); + }); + } +} diff --git a/src/TcpServer.php b/src/TcpServer.php new file mode 100644 index 00000000..a49ca9d1 --- /dev/null +++ b/src/TcpServer.php @@ -0,0 +1,255 @@ +on('connection', function (React\Socket\ConnectionInterface $connection) { + * echo 'Plaintext connection from ' . $connection->getRemoteAddress() . PHP_EOL; + * $connection->write('hello there!' . PHP_EOL); + * … + * }); + * ``` + * + * See also the `ServerInterface` for more details. + * + * @see ServerInterface + * @see ConnectionInterface + */ +final class TcpServer extends EventEmitter implements ServerInterface +{ + private $master; + private $loop; + private $listening = false; + + /** + * Creates a plaintext TCP/IP socket server and starts listening on the given address + * + * This starts accepting new incoming connections on the given address. + * See also the `connection event` documented in the `ServerInterface` + * for more details. + * + * ```php + * $server = new React\Socket\TcpServer(8080); + * ``` + * + * As above, the `$uri` parameter can consist of only a port, in which case the + * server will default to listening on the localhost address `127.0.0.1`, + * which means it will not be reachable from outside of this system. + * + * In order to use a random port assignment, you can use the port `0`: + * + * ```php + * $server = new React\Socket\TcpServer(0); + * $address = $server->getAddress(); + * ``` + * + * In order to change the host the socket is listening on, you can provide an IP + * address through the first parameter provided to the constructor, optionally + * preceded by the `tcp://` scheme: + * + * ```php + * $server = new React\Socket\TcpServer('192.168.0.1:8080'); + * ``` + * + * If you want to listen on an IPv6 address, you MUST enclose the host in square + * brackets: + * + * ```php + * $server = new React\Socket\TcpServer('[::1]:8080'); + * ``` + * + * If the given URI is invalid, does not contain a port, any other scheme or if it + * contains a hostname, it will throw an `InvalidArgumentException`: + * + * ```php + * // throws InvalidArgumentException due to missing port + * $server = new React\Socket\TcpServer('127.0.0.1'); + * ``` + * + * If the given URI appears to be valid, but listening on it fails (such as if port + * is already in use or port below 1024 may require root access etc.), it will + * throw a `RuntimeException`: + * + * ```php + * $first = new React\Socket\TcpServer(8080); + * + * // throws RuntimeException because port is already in use + * $second = new React\Socket\TcpServer(8080); + * ``` + * + * Note that these error conditions may vary depending on your system and/or + * configuration. + * See the exception message and code for more details about the actual error + * condition. + * + * This class takes an optional `LoopInterface|null $loop` parameter that can be used to + * pass the event loop instance to use for this object. You can use a `null` value + * here in order to use the [default loop](https://github.com/reactphp/event-loop#loop). + * This value SHOULD NOT be given unless you're sure you want to explicitly use a + * given event loop instance. + * + * Optionally, you can specify [socket context options](https://www.php.net/manual/en/context.socket.php) + * for the underlying stream socket resource like this: + * + * ```php + * $server = new React\Socket\TcpServer('[::1]:8080', null, [ + * 'backlog' => 200, + * 'so_reuseport' => true, + * 'ipv6_v6only' => true + * ]); + * ``` + * + * Note that available [socket context options](https://www.php.net/manual/en/context.socket.php), + * their defaults and effects of changing these may vary depending on your system + * and/or PHP version. + * Passing unknown context options has no effect. + * The `backlog` context option defaults to `511` unless given explicitly. + * + * @param string|int $uri + * @param ?LoopInterface $loop + * @param array $context + * @throws \InvalidArgumentException if the listening address is invalid + * @throws \RuntimeException if listening on this address fails (already in use etc.) + */ + public function __construct($uri, ?LoopInterface $loop = null, array $context = []) + { + $this->loop = $loop ?? Loop::get(); + + // a single port has been given => assume localhost + if ((string)(int)$uri === (string)$uri) { + $uri = '127.0.0.1:' . $uri; + } + + // assume default scheme if none has been given + if (\strpos($uri, '://') === false) { + $uri = 'tcp://' . $uri; + } + + // parse_url() does not accept null ports (random port assignment) => manually remove + if (\substr($uri, -2) === ':0') { + $parts = \parse_url(\substr($uri, 0, -2)); + if ($parts) { + $parts['port'] = 0; + } + } else { + $parts = \parse_url($uri); + } + + // ensure URI contains TCP scheme, host and port + if (!$parts || !isset($parts['scheme'], $parts['host'], $parts['port']) || $parts['scheme'] !== 'tcp') { + throw new \InvalidArgumentException( + 'Invalid URI "' . $uri . '" given (EINVAL)', + \defined('SOCKET_EINVAL') ? \SOCKET_EINVAL : (\defined('PCNTL_EINVAL') ? \PCNTL_EINVAL : 22) + ); + } + + if (@\inet_pton(\trim($parts['host'], '[]')) === false) { + throw new \InvalidArgumentException( + 'Given URI "' . $uri . '" does not contain a valid host IP (EINVAL)', + \defined('SOCKET_EINVAL') ? \SOCKET_EINVAL : (\defined('PCNTL_EINVAL') ? \PCNTL_EINVAL : 22) + ); + } + + $this->master = @\stream_socket_server( + $uri, + $errno, + $errstr, + \STREAM_SERVER_BIND | \STREAM_SERVER_LISTEN, + \stream_context_create(['socket' => $context + ['backlog' => 511]]) + ); + if (false === $this->master) { + if ($errno === 0) { + // PHP does not seem to report errno, so match errno from errstr + // @link https://3v4l.org/3qOBl + $errno = SocketServer::errno($errstr); + } + + throw new \RuntimeException( + 'Failed to listen on "' . $uri . '": ' . $errstr . SocketServer::errconst($errno), + $errno + ); + } + \stream_set_blocking($this->master, false); + + $this->resume(); + } + + public function getAddress() + { + if (!\is_resource($this->master)) { + return null; + } + + $address = \stream_socket_get_name($this->master, false); + + // check if this is an IPv6 address which includes multiple colons but no square brackets + $pos = \strrpos($address, ':'); + if ($pos !== false && \strpos($address, ':') < $pos && \substr($address, 0, 1) !== '[') { + $address = '[' . \substr($address, 0, $pos) . ']:' . \substr($address, $pos + 1); // @codeCoverageIgnore + } + + return 'tcp://' . $address; + } + + public function pause() + { + if (!$this->listening) { + return; + } + + $this->loop->removeReadStream($this->master); + $this->listening = false; + } + + public function resume() + { + if ($this->listening || !\is_resource($this->master)) { + return; + } + + $this->loop->addReadStream($this->master, function ($master) { + try { + $newSocket = SocketServer::accept($master); + } catch (\RuntimeException $e) { + $this->emit('error', [$e]); + return; + } + $this->handleConnection($newSocket); + }); + $this->listening = true; + } + + public function close() + { + if (!\is_resource($this->master)) { + return; + } + + $this->pause(); + \fclose($this->master); + $this->removeAllListeners(); + } + + /** @internal */ + public function handleConnection($socket) + { + $this->emit('connection', [ + new Connection($socket, $this->loop) + ]); + } +} diff --git a/src/TimeoutConnector.php b/src/TimeoutConnector.php new file mode 100644 index 00000000..5031a0b6 --- /dev/null +++ b/src/TimeoutConnector.php @@ -0,0 +1,68 @@ +connector = $connector; + $this->timeout = $timeout; + $this->loop = $loop ?? Loop::get(); + } + + public function connect($uri) + { + $promise = $this->connector->connect($uri); + + return new Promise(function ($resolve, $reject) use ($promise, $uri) { + $timer = null; + $promise = $promise->then(function ($v) use (&$timer, $resolve) { + if ($timer) { + $this->loop->cancelTimer($timer); + } + $timer = false; + $resolve($v); + }, function ($v) use (&$timer, $reject) { + if ($timer) { + $this->loop->cancelTimer($timer); + } + $timer = false; + $reject($v); + }); + + // promise already resolved => no need to start timer + if ($timer === false) { + return; + } + + // start timeout timer which will cancel the pending promise + $timer = $this->loop->addTimer($this->timeout, function () use (&$promise, $reject, $uri) { + $reject(new \RuntimeException( + 'Connection to ' . $uri . ' timed out after ' . $this->timeout . ' seconds (ETIMEDOUT)', + \defined('SOCKET_ETIMEDOUT') ? \SOCKET_ETIMEDOUT : 110 + )); + + // Cancel pending connection to clean up any underlying resources and references. + // Avoid garbage references in call stack by passing pending promise by reference. + assert(\method_exists($promise, 'cancel')); + $promise->cancel(); + $promise = null; + }); + }, function () use (&$promise) { + // Cancelling this promise will cancel the pending connection, thus triggering the rejection logic above. + // Avoid garbage references in call stack by passing pending promise by reference. + assert(\method_exists($promise, 'cancel')); + $promise->cancel(); + $promise = null; + }); + } +} diff --git a/src/UnixConnector.php b/src/UnixConnector.php new file mode 100644 index 00000000..ecc62620 --- /dev/null +++ b/src/UnixConnector.php @@ -0,0 +1,50 @@ +loop = $loop ?? Loop::get(); + } + + public function connect($path) + { + if (\strpos($path, '://') === false) { + $path = 'unix://' . $path; + } elseif (\substr($path, 0, 7) !== 'unix://') { + return reject(new \InvalidArgumentException( + 'Given URI "' . $path . '" is invalid (EINVAL)', + \defined('SOCKET_EINVAL') ? \SOCKET_EINVAL : (\defined('PCNTL_EINVAL') ? \PCNTL_EINVAL : 22) + )); + } + + $resource = @\stream_socket_client($path, $errno, $errstr, 1.0); + + if (!$resource) { + return reject(new \RuntimeException( + 'Unable to connect to unix domain socket "' . $path . '": ' . $errstr . SocketServer::errconst($errno), + $errno + )); + } + + $connection = new Connection($resource, $this->loop); + $connection->unix = true; + + return resolve($connection); + } +} diff --git a/src/UnixServer.php b/src/UnixServer.php new file mode 100644 index 00000000..8b4e416b --- /dev/null +++ b/src/UnixServer.php @@ -0,0 +1,154 @@ +loop = $loop ?? Loop::get(); + + if (\strpos($path, '://') === false) { + $path = 'unix://' . $path; + } elseif (\substr($path, 0, 7) !== 'unix://') { + throw new \InvalidArgumentException( + 'Given URI "' . $path . '" is invalid (EINVAL)', + \defined('SOCKET_EINVAL') ? \SOCKET_EINVAL : (\defined('PCNTL_EINVAL') ? \PCNTL_EINVAL : 22) + ); + } + + $errno = 0; + $errstr = ''; + \set_error_handler(function ($_, $error) use (&$errno, &$errstr) { + // PHP does not seem to report errno/errstr for Unix domain sockets (UDS) right now. + // This only applies to UDS server sockets, see also https://3v4l.org/NAhpr. + if (\preg_match('/\(([^\)]+)\)|\[(\d+)\]: (.*)/', $error, $match)) { + $errstr = $match[3] ?? $match[1]; + $errno = (int) ($match[2] ?? 0); + } + }); + + $this->master = \stream_socket_server( + $path, + $errno, + $errstr, + \STREAM_SERVER_BIND | \STREAM_SERVER_LISTEN, + \stream_context_create(['socket' => $context]) + ); + + \restore_error_handler(); + + if (false === $this->master) { + throw new \RuntimeException( + 'Failed to listen on Unix domain socket "' . $path . '": ' . $errstr . SocketServer::errconst($errno), + $errno + ); + } + \stream_set_blocking($this->master, 0); + + $this->resume(); + } + + public function getAddress() + { + if (!\is_resource($this->master)) { + return null; + } + + return 'unix://' . \stream_socket_get_name($this->master, false); + } + + public function pause() + { + if (!$this->listening) { + return; + } + + $this->loop->removeReadStream($this->master); + $this->listening = false; + } + + public function resume() + { + if ($this->listening || !is_resource($this->master)) { + return; + } + + $this->loop->addReadStream($this->master, function ($master) { + try { + $newSocket = SocketServer::accept($master); + } catch (\RuntimeException $e) { + $this->emit('error', [$e]); + return; + } + $this->handleConnection($newSocket); + }); + $this->listening = true; + } + + public function close() + { + if (!\is_resource($this->master)) { + return; + } + + $this->pause(); + \fclose($this->master); + $this->removeAllListeners(); + } + + /** @internal */ + public function handleConnection($socket) + { + $connection = new Connection($socket, $this->loop); + $connection->unix = true; + + $this->emit('connection', [ + $connection + ]); + } +} diff --git a/tests/ConnectionTest.php b/tests/ConnectionTest.php new file mode 100644 index 00000000..2ef1e5ce --- /dev/null +++ b/tests/ConnectionTest.php @@ -0,0 +1,40 @@ +createMock(LoopInterface::class); + + $connection = new Connection($resource, $loop); + $connection->close(); + + $this->assertFalse(is_resource($resource)); + } + + public function testCloseConnectionWillRemoveResourceFromLoopBeforeClosingResource() + { + $resource = fopen('php://memory', 'r+'); + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->once())->method('addWriteStream')->with($resource); + + $onRemove = null; + $loop->expects($this->once())->method('removeWriteStream')->with($this->callback(function ($param) use (&$onRemove) { + $onRemove = is_resource($param); + return true; + })); + + $connection = new Connection($resource, $loop); + $connection->write('test'); + $connection->close(); + + $this->assertTrue($onRemove); + $this->assertFalse(is_resource($resource)); + } +} diff --git a/tests/ConnectorTest.php b/tests/ConnectorTest.php new file mode 100644 index 00000000..afb14699 --- /dev/null +++ b/tests/ConnectorTest.php @@ -0,0 +1,217 @@ +setAccessible(true); + } + $connectors = $ref->getValue($connector); + + $ref = new \ReflectionProperty($connectors['tcp'], 'loop'); + if (\PHP_VERSION_ID < 80100) { + $ref->setAccessible(true); + } + $loop = $ref->getValue($connectors['tcp']); + + $this->assertInstanceOf(LoopInterface::class, $loop); + } + + public function testConstructWithLoopAssignsGivenLoop() + { + $loop = $this->createMock(LoopInterface::class); + + $connector = new Connector([], $loop); + + $ref = new \ReflectionProperty($connector, 'connectors'); + if (\PHP_VERSION_ID < 80100) { + $ref->setAccessible(true); + } + $connectors = $ref->getValue($connector); + + $ref = new \ReflectionProperty($connectors['tcp'], 'loop'); + if (\PHP_VERSION_ID < 80100) { + $ref->setAccessible(true); + } + $loop = $ref->getValue($connectors['tcp']); + + $this->assertInstanceOf(LoopInterface::class, $loop); + } + + public function testConstructWithContextAssignsGivenContext() + { + $tcp = $this->createMock(ConnectorInterface::class); + + $connector = new Connector([ + 'tcp' => $tcp, + 'dns' => false, + 'timeout' => false + ]); + + $ref = new \ReflectionProperty($connector, 'connectors'); + if (\PHP_VERSION_ID < 80100) { + $ref->setAccessible(true); + } + $connectors = $ref->getValue($connector); + + $this->assertSame($tcp, $connectors['tcp']); + } + + public function testConnectorUsesTcpAsDefaultScheme() + { + $loop = $this->createMock(LoopInterface::class); + + $promise = new Promise(function () { }); + $tcp = $this->createMock(ConnectorInterface::class); + $tcp->expects($this->once())->method('connect')->with('127.0.0.1:80')->willReturn($promise); + + $connector = new Connector([ + 'tcp' => $tcp + ], $loop); + + $connector->connect('127.0.0.1:80'); + } + + public function testConnectorPassedThroughHostnameIfDnsIsDisabled() + { + $loop = $this->createMock(LoopInterface::class); + + $promise = new Promise(function () { }); + $tcp = $this->createMock(ConnectorInterface::class); + $tcp->expects($this->once())->method('connect')->with('tcp://google.com:80')->willReturn($promise); + + $connector = new Connector([ + 'tcp' => $tcp, + 'dns' => false + ], $loop); + + $connector->connect('tcp://google.com:80'); + } + + public function testConnectorWithUnknownSchemeAlwaysFails() + { + $loop = $this->createMock(LoopInterface::class); + $connector = new Connector([], $loop); + + $promise = $connector->connect('unknown://google.com:80'); + + $promise->then(null, $this->expectCallableOnceWithException( + \RuntimeException::class, + 'No connector available for URI scheme "unknown" (EINVAL)', + defined('SOCKET_EINVAL') ? SOCKET_EINVAL : (defined('PCNTL_EINVAL') ? PCNTL_EINVAL : 22) + )); + } + + public function testConnectorWithDisabledTcpDefaultSchemeAlwaysFails() + { + $loop = $this->createMock(LoopInterface::class); + $connector = new Connector([ + 'tcp' => false + ], $loop); + + $promise = $connector->connect('google.com:80'); + + $promise->then(null, $this->expectCallableOnceWithException( + \RuntimeException::class, + 'No connector available for URI scheme "tcp" (EINVAL)', + defined('SOCKET_EINVAL') ? SOCKET_EINVAL : (defined('PCNTL_EINVAL') ? PCNTL_EINVAL : 22) + )); + } + + public function testConnectorWithDisabledTcpSchemeAlwaysFails() + { + $loop = $this->createMock(LoopInterface::class); + $connector = new Connector([ + 'tcp' => false + ], $loop); + + $promise = $connector->connect('tcp://google.com:80'); + + $promise->then(null, $this->expectCallableOnceWithException( + \RuntimeException::class, + 'No connector available for URI scheme "tcp" (EINVAL)', + defined('SOCKET_EINVAL') ? SOCKET_EINVAL : (defined('PCNTL_EINVAL') ? PCNTL_EINVAL : 22) + )); + } + + public function testConnectorWithDisabledTlsSchemeAlwaysFails() + { + $loop = $this->createMock(LoopInterface::class); + $connector = new Connector([ + 'tls' => false + ], $loop); + + $promise = $connector->connect('tls://google.com:443'); + + $promise->then(null, $this->expectCallableOnceWithException( + \RuntimeException::class, + 'No connector available for URI scheme "tls" (EINVAL)', + defined('SOCKET_EINVAL') ? SOCKET_EINVAL : (defined('PCNTL_EINVAL') ? PCNTL_EINVAL : 22) + )); + } + + public function testConnectorWithDisabledUnixSchemeAlwaysFails() + { + $loop = $this->createMock(LoopInterface::class); + $connector = new Connector([ + 'unix' => false + ], $loop); + + $promise = $connector->connect('unix://demo.sock'); + + $promise->then(null, $this->expectCallableOnceWithException( + \RuntimeException::class, + 'No connector available for URI scheme "unix" (EINVAL)', + defined('SOCKET_EINVAL') ? SOCKET_EINVAL : (defined('PCNTL_EINVAL') ? PCNTL_EINVAL : 22) + )); + } + + public function testConnectorUsesGivenResolverInstance() + { + $loop = $this->createMock(LoopInterface::class); + + $promise = new Promise(function () { }); + $resolver = $this->createMock(ResolverInterface::class); + $resolver->expects($this->once())->method('resolve')->with('google.com')->willReturn($promise); + + $connector = new Connector([ + 'dns' => $resolver, + 'happy_eyeballs' => false + ], $loop); + + $connector->connect('google.com:80'); + } + + public function testConnectorUsesResolvedHostnameIfDnsIsUsed() + { + $loop = $this->createMock(LoopInterface::class); + + $promise = new Promise(function ($resolve) { $resolve('127.0.0.1'); }); + $resolver = $this->createMock(ResolverInterface::class); + $resolver->expects($this->once())->method('resolve')->with('google.com')->willReturn($promise); + + $promise = new Promise(function () { }); + $tcp = $this->createMock(ConnectorInterface::class); + $tcp->expects($this->once())->method('connect')->with('tcp://127.0.0.1:80?hostname=google.com')->willReturn($promise); + + $connector = new Connector([ + 'tcp' => $tcp, + 'dns' => $resolver, + 'happy_eyeballs' => false + ], $loop); + + $connector->connect('tcp://google.com:80'); + } +} diff --git a/tests/DnsConnectorTest.php b/tests/DnsConnectorTest.php new file mode 100644 index 00000000..11d1f2f0 --- /dev/null +++ b/tests/DnsConnectorTest.php @@ -0,0 +1,424 @@ +tcp = $this->createMock(ConnectorInterface::class); + $this->resolver = $this->createMock(ResolverInterface::class); + + $this->connector = new DnsConnector($this->tcp, $this->resolver); + } + + public function testPassByResolverIfGivenIp() + { + $this->resolver->expects($this->never())->method('resolve'); + $this->tcp->expects($this->once())->method('connect')->with('127.0.0.1:80')->willReturn(reject(new \Exception('reject'))); + + $promise = $this->connector->connect('127.0.0.1:80'); + + $promise->then(null, $this->expectCallableOnce()); // avoid reporting unhandled rejection + } + + public function testPassThroughResolverIfGivenHost() + { + $this->resolver->expects($this->once())->method('resolve')->with('google.com')->willReturn(resolve('1.2.3.4')); + $this->tcp->expects($this->once())->method('connect')->with('1.2.3.4:80?hostname=google.com')->willReturn(reject(new \Exception('reject'))); + + $promise = $this->connector->connect('google.com:80'); + + $promise->then(null, $this->expectCallableOnce()); // avoid reporting unhandled rejection + } + + public function testPassThroughResolverIfGivenHostWhichResolvesToIpv6() + { + $this->resolver->expects($this->once())->method('resolve')->with('google.com')->willReturn(resolve('::1')); + $this->tcp->expects($this->once())->method('connect')->with('[::1]:80?hostname=google.com')->willReturn(reject(new \Exception('reject'))); + + $promise = $this->connector->connect('google.com:80'); + + $promise->then(null, $this->expectCallableOnce()); // avoid reporting unhandled rejection + } + + public function testPassByResolverIfGivenCompleteUri() + { + $this->resolver->expects($this->never())->method('resolve'); + $this->tcp->expects($this->once())->method('connect')->with('scheme://127.0.0.1:80/path?query#fragment')->willReturn(reject(new \Exception('reject'))); + + $promise = $this->connector->connect('scheme://127.0.0.1:80/path?query#fragment'); + + $promise->then(null, $this->expectCallableOnce()); // avoid reporting unhandled rejection + } + + public function testPassThroughResolverIfGivenCompleteUri() + { + $this->resolver->expects($this->once())->method('resolve')->with('google.com')->willReturn(resolve('1.2.3.4')); + $this->tcp->expects($this->once())->method('connect')->with('scheme://1.2.3.4:80/path?query&hostname=google.com#fragment')->willReturn(reject(new \Exception('reject'))); + + $promise = $this->connector->connect('scheme://google.com:80/path?query#fragment'); + + $promise->then(null, $this->expectCallableOnce()); // avoid reporting unhandled rejection + } + + public function testPassThroughResolverIfGivenExplicitHost() + { + $this->resolver->expects($this->once())->method('resolve')->with('google.com')->willReturn(resolve('1.2.3.4')); + $this->tcp->expects($this->once())->method('connect')->with('scheme://1.2.3.4:80/?hostname=google.de')->willReturn(reject(new \Exception('reject'))); + + $promise = $this->connector->connect('scheme://google.com:80/?hostname=google.de'); + + $promise->then(null, $this->expectCallableOnce()); // avoid reporting unhandled rejection + } + + public function testRejectsImmediatelyIfUriIsInvalid() + { + $this->resolver->expects($this->never())->method('resolve'); + $this->tcp->expects($this->never())->method('connect'); + + $promise = $this->connector->connect('////'); + + $promise->then(null, $this->expectCallableOnceWithException( + \InvalidArgumentException::class, + 'Given URI "////" is invalid (EINVAL)', + defined('SOCKET_EINVAL') ? SOCKET_EINVAL : (defined('PCNTL_EINVAL') ? PCNTL_EINVAL : 22) + )); + } + + public function testConnectRejectsIfGivenIpAndTcpConnectorRejectsWithRuntimeException() + { + $promise = reject(new \RuntimeException('Connection to tcp://1.2.3.4:80 failed: Connection failed', 42)); + $this->resolver->expects($this->never())->method('resolve'); + $this->tcp->expects($this->once())->method('connect')->with('1.2.3.4:80')->willReturn($promise); + + $promise = $this->connector->connect('1.2.3.4:80'); + + $exception = null; + $promise->then(null, function ($reason) use (&$exception) { + $exception = $reason; + }); + + assert($exception instanceof \RuntimeException); + $this->assertInstanceOf(\RuntimeException::class, $exception); + $this->assertEquals('Connection to tcp://1.2.3.4:80 failed: Connection failed', $exception->getMessage()); + $this->assertEquals(42, $exception->getCode()); + $this->assertNull($exception->getPrevious()); + $this->assertNotEquals('', $exception->getTraceAsString()); + } + + public function testConnectRejectsIfGivenIpAndTcpConnectorRejectsWithInvalidArgumentException() + { + $promise = reject(new \InvalidArgumentException('Invalid', 42)); + $this->resolver->expects($this->never())->method('resolve'); + $this->tcp->expects($this->once())->method('connect')->with('1.2.3.4:80')->willReturn($promise); + + $promise = $this->connector->connect('1.2.3.4:80'); + + $exception = null; + $promise->then(null, function ($reason) use (&$exception) { + $exception = $reason; + }); + + assert($exception instanceof \InvalidArgumentException); + $this->assertInstanceOf(\InvalidArgumentException::class, $exception); + $this->assertEquals('Invalid', $exception->getMessage()); + $this->assertEquals(42, $exception->getCode()); + $this->assertNull($exception->getPrevious()); + $this->assertNotEquals('', $exception->getTraceAsString()); + } + + public function testConnectRejectsWithOriginalHostnameInMessageAfterResolvingIfTcpConnectorRejectsWithRuntimeException() + { + $promise = reject(new \RuntimeException('Connection to tcp://1.2.3.4:80?hostname=example.com failed: Connection failed', 42)); + $this->resolver->expects($this->once())->method('resolve')->with('example.com')->willReturn(resolve('1.2.3.4')); + $this->tcp->expects($this->once())->method('connect')->with('1.2.3.4:80?hostname=example.com')->willReturn($promise); + + $promise = $this->connector->connect('example.com:80'); + + $exception = null; + $promise->then(null, function ($reason) use (&$exception) { + $exception = $reason; + }); + + assert($exception instanceof \RuntimeException); + $this->assertInstanceOf(\RuntimeException::class, $exception); + $this->assertEquals('Connection to tcp://example.com:80 failed: Connection to tcp://1.2.3.4:80 failed: Connection failed', $exception->getMessage()); + $this->assertEquals(42, $exception->getCode()); + $this->assertInstanceOf(\RuntimeException::class, $exception->getPrevious()); + $this->assertNotEquals('', $exception->getTraceAsString()); + } + + public function testConnectRejectsWithOriginalExceptionAfterResolvingIfTcpConnectorRejectsWithInvalidArgumentException() + { + $promise = reject(new \InvalidArgumentException('Invalid', 42)); + $this->resolver->expects($this->once())->method('resolve')->with('example.com')->willReturn(resolve('1.2.3.4')); + $this->tcp->expects($this->once())->method('connect')->with('1.2.3.4:80?hostname=example.com')->willReturn($promise); + + $promise = $this->connector->connect('example.com:80'); + + $exception = null; + $promise->then(null, function ($reason) use (&$exception) { + $exception = $reason; + }); + + assert($exception instanceof \InvalidArgumentException); + $this->assertInstanceOf(\InvalidArgumentException::class, $exception); + $this->assertEquals('Invalid', $exception->getMessage()); + $this->assertEquals(42, $exception->getCode()); + $this->assertNull($exception->getPrevious()); + $this->assertNotEquals('', $exception->getTraceAsString()); + } + + public function testSkipConnectionIfDnsFails() + { + $promise = reject(new \RuntimeException('DNS error')); + $this->resolver->expects($this->once())->method('resolve')->with('example.invalid')->willReturn($promise); + $this->tcp->expects($this->never())->method('connect'); + + $promise = $this->connector->connect('example.invalid:80'); + + $exception = null; + $promise->then(null, function ($reason) use (&$exception) { + $exception = $reason; + }); + + assert($exception instanceof \RuntimeException); + $this->assertInstanceOf(\RuntimeException::class, $exception); + $this->assertEquals('Connection to tcp://example.invalid:80 failed during DNS lookup: DNS error', $exception->getMessage()); + $this->assertEquals(0, $exception->getCode()); + $this->assertInstanceOf(\RuntimeException::class, $exception->getPrevious()); + $this->assertNotEquals('', $exception->getTraceAsString()); + } + + public function testRejectionExceptionUsesPreviousExceptionIfDnsFails() + { + $exception = new \RuntimeException(); + + $this->resolver->expects($this->once())->method('resolve')->with('example.invalid')->willReturn(reject($exception)); + + $promise = $this->connector->connect('example.invalid:80'); + + $promise->then(null, function ($e) { + throw $e->getPrevious(); + })->then(null, $this->expectCallableOnceWith($this->identicalTo($exception))); + } + + public function testCancelDuringDnsCancelsDnsAndDoesNotStartTcpConnection() + { + $pending = new Promise(function () { }, $this->expectCallableOnce()); + $this->resolver->expects($this->once())->method('resolve')->with('example.com')->willReturn($pending); + $this->tcp->expects($this->never())->method('connect'); + + $promise = $this->connector->connect('example.com:80'); + $promise->cancel(); + + $exception = null; + $promise->then(null, function ($reason) use (&$exception) { + $exception = $reason; + }); + + assert($exception instanceof \RuntimeException); + $this->assertInstanceOf(\RuntimeException::class, $exception); + $this->assertEquals('Connection to tcp://example.com:80 cancelled during DNS lookup (ECONNABORTED)', $exception->getMessage()); + $this->assertEquals(defined('SOCKET_ECONNABORTED') ? SOCKET_ECONNABORTED : 103, $exception->getCode()); + $this->assertNull($exception->getPrevious()); + $this->assertNotEquals('', $exception->getTraceAsString()); + } + + public function testCancelDuringTcpConnectionCancelsTcpConnectionIfGivenIp() + { + $pending = new Promise(function () { }, $this->expectCallableOnce()); + $this->resolver->expects($this->never())->method('resolve'); + $this->tcp->expects($this->once())->method('connect')->with('1.2.3.4:80')->willReturn($pending); + + $promise = $this->connector->connect('1.2.3.4:80'); + $promise->cancel(); + } + + public function testCancelDuringTcpConnectionCancelsTcpConnectionAfterDnsIsResolved() + { + $pending = new Promise(function () { }, $this->expectCallableOnce()); + $this->resolver->expects($this->once())->method('resolve')->with('example.com')->willReturn(resolve('1.2.3.4')); + $this->tcp->expects($this->once())->method('connect')->with('1.2.3.4:80?hostname=example.com')->willReturn($pending); + + $promise = $this->connector->connect('example.com:80'); + $promise->cancel(); + } + + public function testCancelDuringTcpConnectionCancelsTcpConnectionWithTcpRejectionAfterDnsIsResolved() + { + $first = new Deferred(); + $this->resolver->expects($this->once())->method('resolve')->with('example.com')->willReturn($first->promise()); + $pending = new Promise(function () { }, function () { + throw new \RuntimeException( + 'Connection cancelled', + defined('SOCKET_ECONNABORTED') ? SOCKET_ECONNABORTED : 103 + ); + }); + $this->tcp->expects($this->once())->method('connect')->with('1.2.3.4:80?hostname=example.com')->willReturn($pending); + + $promise = $this->connector->connect('example.com:80'); + $first->resolve('1.2.3.4'); + + $promise->cancel(); + + $exception = null; + $promise->then(null, function ($reason) use (&$exception) { + $exception = $reason; + }); + + assert($exception instanceof \RuntimeException); + $this->assertInstanceOf(\RuntimeException::class, $exception); + $this->assertEquals('Connection to tcp://example.com:80 failed: Connection cancelled', $exception->getMessage()); + $this->assertEquals(defined('SOCKET_ECONNABORTED') ? SOCKET_ECONNABORTED : 103, $exception->getCode()); + $this->assertInstanceOf(\RuntimeException::class, $exception->getPrevious()); + $this->assertNotEquals('', $exception->getTraceAsString()); + } + + public function testRejectionDuringDnsLookupShouldNotCreateAnyGarbageReferences() + { + if (class_exists('React\Promise\When')) { + $this->markTestSkipped('Not supported on legacy Promise v1 API'); + } + + while (gc_collect_cycles()) { + // collect all garbage cycles + } + + $dns = new Deferred(); + $this->resolver->expects($this->once())->method('resolve')->with('example.com')->willReturn($dns->promise()); + $this->tcp->expects($this->never())->method('connect'); + + $promise = $this->connector->connect('example.com:80'); + + $promise->then(null, $this->expectCallableOnce()); // avoid reporting unhandled rejection + + $dns->reject(new \RuntimeException('DNS failed')); + unset($promise, $dns); + + $this->assertEquals(0, gc_collect_cycles()); + } + + public function testRejectionAfterDnsLookupShouldNotCreateAnyGarbageReferences() + { + if (class_exists('React\Promise\When')) { + $this->markTestSkipped('Not supported on legacy Promise v1 API'); + } + + while (gc_collect_cycles()) { + // collect all garbage cycles + } + + $dns = new Deferred(); + $this->resolver->expects($this->once())->method('resolve')->with('example.com')->willReturn($dns->promise()); + + $tcp = new Deferred(); + $this->tcp->expects($this->once())->method('connect')->with('1.2.3.4:80?hostname=example.com')->willReturn($tcp->promise()); + + $promise = $this->connector->connect('example.com:80'); + + $promise->then(null, $this->expectCallableOnce()); // avoid reporting unhandled rejection + + $dns->resolve('1.2.3.4'); + $tcp->reject(new \RuntimeException('Connection failed')); + unset($promise, $dns, $tcp); + + $this->assertEquals(0, gc_collect_cycles()); + } + + public function testRejectionAfterDnsLookupShouldNotCreateAnyGarbageReferencesAgain() + { + if (class_exists('React\Promise\When')) { + $this->markTestSkipped('Not supported on legacy Promise v1 API'); + } + + while (gc_collect_cycles()) { + // collect all garbage cycles + } + + $dns = new Deferred(); + $this->resolver->expects($this->once())->method('resolve')->with('example.com')->willReturn($dns->promise()); + + $tcp = new Deferred(); + $dns->promise()->then(function () use ($tcp) { + $tcp->reject(new \RuntimeException('Connection failed')); + }); + $this->tcp->expects($this->once())->method('connect')->with('1.2.3.4:80?hostname=example.com')->willReturn($tcp->promise()); + + $promise = $this->connector->connect('example.com:80'); + + $promise->then(null, $this->expectCallableOnce()); // avoid reporting unhandled rejection + + $dns->resolve('1.2.3.4'); + + unset($promise, $dns, $tcp); + + $this->assertEquals(0, gc_collect_cycles()); + } + + public function testCancelDuringDnsLookupShouldNotCreateAnyGarbageReferences() + { + if (class_exists('React\Promise\When')) { + $this->markTestSkipped('Not supported on legacy Promise v1 API'); + } + + while (gc_collect_cycles()) { + // collect all garbage cycles + } + + $dns = new Deferred(function () { + throw new \RuntimeException(); + }); + $this->resolver->expects($this->once())->method('resolve')->with('example.com')->willReturn($dns->promise()); + $this->tcp->expects($this->never())->method('connect'); + + $promise = $this->connector->connect('example.com:80'); + + $promise->cancel(); + unset($promise, $dns); + + $this->assertEquals(0, gc_collect_cycles()); + } + + public function testCancelDuringTcpConnectionShouldNotCreateAnyGarbageReferences() + { + if (class_exists('React\Promise\When')) { + $this->markTestSkipped('Not supported on legacy Promise v1 API'); + } + + while (gc_collect_cycles()) { + // collect all garbage cycles + } + + $dns = new Deferred(); + $this->resolver->expects($this->once())->method('resolve')->with('example.com')->willReturn($dns->promise()); + $tcp = new Promise(function () { }, function () { + throw new \RuntimeException('Connection cancelled'); + }); + $this->tcp->expects($this->once())->method('connect')->with('1.2.3.4:80?hostname=example.com')->willReturn($tcp); + + $promise = $this->connector->connect('example.com:80'); + $dns->resolve('1.2.3.4'); + + $promise->cancel(); + unset($promise, $dns, $tcp); + + $this->assertEquals(0, gc_collect_cycles()); + } +} diff --git a/tests/FdServerTest.php b/tests/FdServerTest.php new file mode 100644 index 00000000..4ecd81e4 --- /dev/null +++ b/tests/FdServerTest.php @@ -0,0 +1,425 @@ +markTestSkipped('Not supported on your platform'); + } + + $fd = self::getNextFreeFd(); + $socket = stream_socket_server('127.0.0.1:0'); + assert($socket !== false); + + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->once())->method('addReadStream'); + + new FdServer($fd, $loop); + } + + public function testCtorThrowsForInvalidFd() + { + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->never())->method('addReadStream'); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid FD number given (EINVAL)'); + $this->expectExceptionCode(defined('SOCKET_EINVAL') ? SOCKET_EINVAL : (defined('PCNTL_EINVAL') ? PCNTL_EINVAL : 22)); + new FdServer(-1, $loop); + } + + public function testCtorThrowsForInvalidUrl() + { + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->never())->method('addReadStream'); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid FD number given (EINVAL)'); + $this->expectExceptionCode(defined('SOCKET_EINVAL') ? SOCKET_EINVAL : (defined('PCNTL_EINVAL') ? PCNTL_EINVAL : 22)); + new FdServer('tcp://127.0.0.1:8080', $loop); + } + + public function testCtorThrowsForUnknownFdWithoutCallingCustomErrorHandler() + { + if (!is_dir('/dev/fd')) { + $this->markTestSkipped('Not supported on your platform'); + } + + $fd = self::getNextFreeFd(); + + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->never())->method('addReadStream'); + + $error = null; + set_error_handler(function ($_, $errstr) use (&$error) { + $error = $errstr; + }); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Failed to listen on FD ' . $fd . ': ' . (function_exists('socket_strerror') ? socket_strerror(SOCKET_EBADF) . ' (EBADF)' : 'Bad file descriptor')); + $this->expectExceptionCode(defined('SOCKET_EBADF') ? SOCKET_EBADF : 9); + + try { + new FdServer($fd, $loop); + + restore_error_handler(); + } catch (\Exception $e) { + restore_error_handler(); + $this->assertNull($error); + + throw $e; + } + } + + public function testCtorThrowsIfFdIsAFileAndNotASocket() + { + if (!is_dir('/dev/fd')) { + $this->markTestSkipped('Not supported on your platform'); + } + + $fd = self::getNextFreeFd(); + $tmpfile = tmpfile(); + assert($tmpfile !== false); + + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->never())->method('addReadStream'); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Failed to listen on FD ' . $fd . ': ' . (function_exists('socket_strerror') ? socket_strerror(SOCKET_ENOTSOCK) : 'Not a socket') . ' (ENOTSOCK)'); + $this->expectExceptionCode(defined('SOCKET_ENOTSOCK') ? SOCKET_ENOTSOCK : 88); + new FdServer($fd, $loop); + } + + public function testCtorThrowsIfFdIsAConnectedSocketInsteadOfServerSocket() + { + if (!is_dir('/dev/fd')) { + $this->markTestSkipped('Not supported on your platform'); + } + + $socket = stream_socket_server('tcp://127.0.0.1:0'); + + $fd = self::getNextFreeFd(); + $client = stream_socket_client('tcp://' . stream_socket_get_name($socket, false)); + assert($client !== false); + + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->never())->method('addReadStream'); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Failed to listen on FD ' . $fd . ': ' . (function_exists('socket_strerror') ? socket_strerror(SOCKET_EISCONN) : 'Socket is connected') . ' (EISCONN)'); + $this->expectExceptionCode(defined('SOCKET_EISCONN') ? SOCKET_EISCONN : 106); + new FdServer($fd, $loop); + } + + public function testGetAddressReturnsSameAddressAsOriginalSocketForIpv4Socket() + { + if (!is_dir('/dev/fd')) { + $this->markTestSkipped('Not supported on your platform'); + } + + $fd = self::getNextFreeFd(); + $socket = stream_socket_server('127.0.0.1:0'); + + $loop = $this->createMock(LoopInterface::class); + + $server = new FdServer($fd, $loop); + + $this->assertEquals('tcp://' . stream_socket_get_name($socket, false), $server->getAddress()); + } + + public function testGetAddressReturnsSameAddressAsOriginalSocketForIpv4SocketGivenAsUrlToFd() + { + if (!is_dir('/dev/fd')) { + $this->markTestSkipped('Not supported on your platform'); + } + + $fd = self::getNextFreeFd(); + $socket = stream_socket_server('127.0.0.1:0'); + + $loop = $this->createMock(LoopInterface::class); + + $server = new FdServer('php://fd/' . $fd, $loop); + + $this->assertEquals('tcp://' . stream_socket_get_name($socket, false), $server->getAddress()); + } + + public function testGetAddressReturnsSameAddressAsOriginalSocketForIpv6Socket() + { + if (!is_dir('/dev/fd')) { + $this->markTestSkipped('Not supported on your platform'); + } + + $fd = self::getNextFreeFd(); + $socket = @stream_socket_server('[::1]:0'); + if ($socket === false) { + $this->markTestSkipped('Listening on IPv6 not supported'); + } + + $loop = $this->createMock(LoopInterface::class); + + $server = new FdServer($fd, $loop); + + $port = preg_replace('/.*:/', '', stream_socket_get_name($socket, false)); + $this->assertEquals('tcp://[::1]:' . $port, $server->getAddress()); + } + + public function testGetAddressReturnsSameAddressAsOriginalSocketForUnixDomainSocket() + { + if (!is_dir('/dev/fd')) { + $this->markTestSkipped('Not supported on your platform'); + } + + $fd = self::getNextFreeFd(); + $socket = @stream_socket_server($this->getRandomSocketUri()); + if ($socket === false) { + $this->markTestSkipped('Listening on Unix domain socket (UDS) not supported'); + } + + assert(is_resource($socket)); + unlink(str_replace('unix://', '', stream_socket_get_name($socket, false))); + + $loop = $this->createMock(LoopInterface::class); + + $server = new FdServer($fd, $loop); + + $this->assertEquals('unix://' . stream_socket_get_name($socket, false), $server->getAddress()); + } + + public function testGetAddressReturnsNullAfterClose() + { + if (!is_dir('/dev/fd')) { + $this->markTestSkipped('Not supported on your platform'); + } + + $fd = self::getNextFreeFd(); + $socket = stream_socket_server('127.0.0.1:0'); + assert($socket !== false); + + $loop = $this->createMock(LoopInterface::class); + + $server = new FdServer($fd, $loop); + $server->close(); + + $this->assertNull($server->getAddress()); + } + + public function testCloseRemovesResourceFromLoop() + { + if (!is_dir('/dev/fd')) { + $this->markTestSkipped('Not supported on your platform'); + } + + $fd = self::getNextFreeFd(); + $socket = stream_socket_server('127.0.0.1:0'); + assert($socket !== false); + + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->once())->method('removeReadStream'); + + $server = new FdServer($fd, $loop); + $server->close(); + } + + public function testCloseTwiceRemovesResourceFromLoopOnce() + { + if (!is_dir('/dev/fd')) { + $this->markTestSkipped('Not supported on your platform'); + } + + $fd = self::getNextFreeFd(); + $socket = stream_socket_server('127.0.0.1:0'); + assert($socket !== false); + + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->once())->method('removeReadStream'); + + $server = new FdServer($fd, $loop); + $server->close(); + $server->close(); + } + + public function testResumeWithoutPauseIsNoOp() + { + if (!is_dir('/dev/fd')) { + $this->markTestSkipped('Not supported on your platform'); + } + + $fd = self::getNextFreeFd(); + $socket = stream_socket_server('127.0.0.1:0'); + assert($socket !== false); + + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->once())->method('addReadStream'); + + $server = new FdServer($fd, $loop); + $server->resume(); + } + + public function testPauseRemovesResourceFromLoop() + { + if (!is_dir('/dev/fd')) { + $this->markTestSkipped('Not supported on your platform'); + } + + $fd = self::getNextFreeFd(); + $socket = stream_socket_server('127.0.0.1:0'); + assert($socket !== false); + + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->once())->method('removeReadStream'); + + $server = new FdServer($fd, $loop); + $server->pause(); + } + + public function testPauseAfterPauseIsNoOp() + { + if (!is_dir('/dev/fd')) { + $this->markTestSkipped('Not supported on your platform'); + } + + $fd = self::getNextFreeFd(); + $socket = stream_socket_server('127.0.0.1:0'); + assert($socket !== false); + + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->once())->method('removeReadStream'); + + $server = new FdServer($fd, $loop); + $server->pause(); + $server->pause(); + } + + public function testServerEmitsConnectionEventForNewConnection() + { + if (!is_dir('/dev/fd')) { + $this->markTestSkipped('Not supported on your platform'); + } + + $fd = self::getNextFreeFd(); + $socket = stream_socket_server('127.0.0.1:0'); + assert($socket !== false); + + $client = stream_socket_client('tcp://' . stream_socket_get_name($socket, false)); + + $server = new FdServer($fd); + $promise = new Promise(function ($resolve) use ($server) { + $server->on('connection', $resolve); + }); + + $connection = await(timeout($promise, 1.0)); + + /** + * @var ConnectionInterface $connection + */ + $this->assertInstanceOf(ConnectionInterface::class, $connection); + + fclose($client); + $connection->close(); + $server->close(); + } + + public function testEmitsErrorWhenAcceptListenerFailsWithoutCallingCustomErrorHandler() + { + if (!is_dir('/dev/fd')) { + $this->markTestSkipped('Not supported on your platform'); + } + + $listener = null; + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->once())->method('addReadStream')->with($this->anything(), $this->callback(function ($cb) use (&$listener) { + $listener = $cb; + return true; + })); + + $fd = self::getNextFreeFd(); + $socket = stream_socket_server('127.0.0.1:0'); + assert($socket !== false); + + $server = new FdServer($fd, $loop); + + $exception = null; + $server->on('error', function ($e) use (&$exception) { + $exception = $e; + }); + + $this->assertNotNull($listener); + $socket = stream_socket_server('tcp://127.0.0.1:0'); + + $error = null; + set_error_handler(function ($_, $errstr) use (&$error) { + $error = $errstr; + }); + + $time = microtime(true); + $listener($socket); + $time = microtime(true) - $time; + + restore_error_handler(); + $this->assertNull($error); + + $this->assertLessThan(1, $time); + + $this->assertInstanceOf(\RuntimeException::class, $exception); + assert($exception instanceof \RuntimeException); + $this->assertStringStartsWith('Unable to accept new connection: ', $exception->getMessage()); + + return $exception; + } + + /** + * @param \RuntimeException $e + * @requires extension sockets + * @depends testEmitsErrorWhenAcceptListenerFailsWithoutCallingCustomErrorHandler + */ + public function testEmitsTimeoutErrorWhenAcceptListenerFails(\RuntimeException $exception) + { + $this->assertEquals('Unable to accept new connection: ' . socket_strerror(SOCKET_ETIMEDOUT) . ' (ETIMEDOUT)', $exception->getMessage()); + $this->assertEquals(SOCKET_ETIMEDOUT, $exception->getCode()); + } + + /** + * @return int + * @throws \UnexpectedValueException + * @throws \BadMethodCallException + * @throws \UnderflowException + * @copyright Copyright (c) 2018 Christian Lück, taken from https://github.com/clue/fd with permission + */ + public static function getNextFreeFd() + { + // open tmpfile to occupy next free FD temporarily + $tmp = tmpfile(); + + $dir = @scandir('/dev/fd'); + if ($dir === false) { + throw new \BadMethodCallException('Not supported on your platform because /dev/fd is not readable'); + } + + $stat = fstat($tmp); + $ino = (int) $stat['ino']; + + foreach ($dir as $file) { + $stat = @stat('/dev/fd/' . $file); + if (isset($stat['ino']) && $stat['ino'] === $ino) { + return (int) $file; + } + } + + throw new \UnderflowException('Could not locate file descriptor for this resource'); + } + + private function getRandomSocketUri() + { + return "unix://" . sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid(rand(), true) . '.sock'; + } +} diff --git a/tests/FixedUriConnectorTest.php b/tests/FixedUriConnectorTest.php new file mode 100644 index 00000000..b649b61a --- /dev/null +++ b/tests/FixedUriConnectorTest.php @@ -0,0 +1,19 @@ +createMock(ConnectorInterface::class); + $base->expects($this->once())->method('connect')->with('test')->willReturn('ret'); + + $connector = new FixedUriConnector('test', $base); + + $this->assertEquals('ret', $connector->connect('ignored')); + } +} diff --git a/tests/FunctionalConnectorTest.php b/tests/FunctionalConnectorTest.php new file mode 100644 index 00000000..ad1aa992 --- /dev/null +++ b/tests/FunctionalConnectorTest.php @@ -0,0 +1,200 @@ +connect('localhost:9998'), self::TIMEOUT)); + + $server->close(); + + $this->assertInstanceOf(ConnectionInterface::class, $connection); + + $connection->close(); + } + + /** + * @group internet + */ + public function testConnectTwiceWithoutHappyEyeBallsOnlySendsSingleDnsQueryDueToLocalDnsCache() + { + $socket = stream_socket_server('udp://127.0.0.1:0', $errno, $errstr, STREAM_SERVER_BIND); + + $connector = new Connector([ + 'dns' => 'udp://' . stream_socket_get_name($socket, false), + 'happy_eyeballs' => false + ]); + + // minimal DNS proxy stub which forwards DNS messages to actual DNS server + $received = 0; + Loop::addReadStream($socket, function ($socket) use (&$received) { + $request = stream_socket_recvfrom($socket, 65536, 0, $peer); + + $client = stream_socket_client('udp://8.8.8.8:53'); + fwrite($client, $request); + $response = fread($client, 65536); + + stream_socket_sendto($socket, $response, 0, $peer); + ++$received; + fclose($client); + }); + + $connection = await($connector->connect('example.com:80')); + $connection->close(); + $this->assertEquals(1, $received); + + $connection = await($connector->connect('example.com:80')); + $connection->close(); + $this->assertEquals(1, $received); + + Loop::removeReadStream($socket); + } + + /** + * @test + * @group internet + */ + public function connectionToRemoteTCP4n6ServerShouldResultInOurIP() + { + $connector = new Connector(['happy_eyeballs' => true]); + + $ip = await(timeout($this->request('dual.tlund.se', $connector), self::TIMEOUT)); + + $this->assertNotFalse(inet_pton($ip)); + } + + /** + * @test + * @group internet + */ + public function connectionToRemoteTCP4ServerShouldResultInOurIP() + { + $connector = new Connector(['happy_eyeballs' => true]); + + try { + $ip = await(timeout($this->request('ipv4.tlund.se', $connector), self::TIMEOUT)); + } catch (\Exception $e) { + $this->checkIpv4(); + throw $e; + } + + $this->assertNotFalse(inet_pton($ip)); + $this->assertEquals(4, strlen(inet_pton($ip))); + } + + /** + * @test + * @group internet + */ + public function connectionToRemoteTCP6ServerShouldResultInOurIP() + { + $connector = new Connector(['happy_eyeballs' => true]); + + try { + $ip = await(timeout($this->request('ipv6.tlund.se', $connector), self::TIMEOUT)); + } catch (\Exception $e) { + $this->checkIpv6(); + throw $e; + } + + $this->assertNotFalse(inet_pton($ip)); + $this->assertEquals(16, strlen(inet_pton($ip))); + } + + public function testCancelPendingTlsConnectionDuringTlsHandshakeShouldCloseTcpConnectionToServer() + { + $server = new TcpServer(0); + $uri = str_replace('tcp://', 'tls://', $server->getAddress()); + + $connector = new Connector([]); + $promise = $connector->connect($uri); + + $deferred = new Deferred(); + $server->on('connection', function (ConnectionInterface $connection) use ($promise, $deferred) { + $connection->on('close', function () use ($deferred) { + $deferred->resolve(null); + }); + + Loop::futureTick(function () use ($promise) { + $promise->cancel(); + }); + }); + + await(timeout($deferred->promise(), self::TIMEOUT)); + $server->close(); + + try { + await(timeout($promise, self::TIMEOUT)); + $this->fail(); + } catch (\Exception $e) { + $this->assertInstanceOf(\RuntimeException::class, $e); + $this->assertEquals('Connection to ' . $uri . ' cancelled during TLS handshake (ECONNABORTED)', $e->getMessage()); + } + } + + /** + * @internal + */ + public function parseIpFromPage($body) + { + $ex = explode('title="Look up on bgp.he.net">', $body); + $ex = explode('<', $ex[1]); + + return $ex[0]; + } + + private function request($host, ConnectorInterface $connector) + { + return $connector->connect($host . ':80')->then(function (ConnectionInterface $connection) use ($host) { + $connection->write("GET / HTTP/1.1\r\nHost: " . $host . "\r\nConnection: close\r\n\r\n"); + + return buffer($connection); + })->then(function ($response) { + return $this->parseIpFromPage($response); + }); + } + + private function checkIpv4() + { + if ($this->ipv4 === null) { + $this->ipv4 = !!@file_get_contents('http://ipv4.tlund.se/'); + } + + if (!$this->ipv4) { + $this->markTestSkipped('IPv4 connection not supported on this system'); + } + } + + private function checkIpv6() + { + if ($this->ipv6 === null) { + $this->ipv6 = !!@file_get_contents('http://ipv6.tlund.se/'); + } + + if (!$this->ipv6) { + $this->markTestSkipped('IPv6 connection not supported on this system'); + } + } +} diff --git a/tests/FunctionalSecureServerTest.php b/tests/FunctionalSecureServerTest.php new file mode 100644 index 00000000..b30bbbb5 --- /dev/null +++ b/tests/FunctionalSecureServerTest.php @@ -0,0 +1,836 @@ + __DIR__ . '/../examples/localhost.pem' + ]); + + $connector = new SecureConnector(new TcpConnector(), null, [ + 'verify_peer' => false + ]); + $promise = $connector->connect($server->getAddress()); + + /* @var ConnectionInterface $client */ + $client = await(timeout($promise, self::TIMEOUT)); + + $this->assertInstanceOf(ConnectionInterface::class, $client); + $this->assertEquals($server->getAddress(), $client->getRemoteAddress()); + + $client->close(); + $server->close(); + } + + public function testClientUsesTls13ByDefaultWhenSupportedByOpenSSL() + { + if ((PHP_VERSION_ID >= 70300 && PHP_VERSION_ID < 70400) || !$this->supportsTls13()) { + // @link https://github.com/php/php-src/pull/3909 explicitly adds TLS 1.3 on PHP 7.4 + // @link https://github.com/php/php-src/pull/3317 implicitly limits to TLS 1.2 on PHP 7.3 + // all older PHP versions support TLS 1.3 (provided OpenSSL supports it) + $this->markTestSkipped('Test requires OpenSSL 1.1.1+ for TLS 1.3 but excludes PHP 7.3 because it implicitly limits to TLS 1.2'); + } + + $server = new TcpServer(0); + $server = new SecureServer($server, null, [ + 'local_cert' => __DIR__ . '/../examples/localhost.pem' + ]); + + $connector = new SecureConnector(new TcpConnector(), null, [ + 'verify_peer' => false + ]); + $promise = $connector->connect($server->getAddress()); + + /* @var ConnectionInterface $client */ + $client = await(timeout($promise, self::TIMEOUT)); + + $this->assertInstanceOf(Connection::class, $client); + $this->assertTrue(isset($client->stream)); + + $meta = stream_get_meta_data($client->stream); + $this->assertTrue(isset($meta['crypto']['protocol'])); + + if ($meta['crypto']['protocol'] === 'UNKNOWN') { + // TLSv1.3 protocol will only be added via https://github.com/php/php-src/pull/3700 + // prior to merging that PR, this info is still available in the cipher version by OpenSSL + $this->assertTrue(isset($meta['crypto']['cipher_version'])); + $this->assertEquals('TLSv1.3', $meta['crypto']['cipher_version']); + } else { + $this->assertEquals('TLSv1.3', $meta['crypto']['protocol']); + } + + $client->close(); + $server->close(); + } + + public function testClientUsesTls12WhenCryptoMethodIsExplicitlyConfiguredByClient() + { + $server = new TcpServer(0); + $server = new SecureServer($server, null, [ + 'local_cert' => __DIR__ . '/../examples/localhost.pem' + ]); + + $connector = new SecureConnector(new TcpConnector(), null, [ + 'verify_peer' => false, + 'crypto_method' => STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT + ]); + $promise = $connector->connect($server->getAddress()); + + /* @var ConnectionInterface $client */ + $client = await(timeout($promise, self::TIMEOUT)); + + $this->assertInstanceOf(Connection::class, $client); + $this->assertTrue(isset($client->stream)); + + $meta = stream_get_meta_data($client->stream); + $this->assertTrue(isset($meta['crypto']['protocol'])); + $this->assertEquals('TLSv1.2', $meta['crypto']['protocol']); + + $client->close(); + $server->close(); + } + + public function testClientUsesTls12WhenCryptoMethodIsExplicitlyConfiguredByServer() + { + $server = new TcpServer(0); + $server = new SecureServer($server, null, [ + 'local_cert' => __DIR__ . '/../examples/localhost.pem', + 'crypto_method' => STREAM_CRYPTO_METHOD_TLSv1_2_SERVER + ]); + + $connector = new SecureConnector(new TcpConnector(), null, [ + 'verify_peer' => false + ]); + $promise = $connector->connect($server->getAddress()); + + /* @var ConnectionInterface $client */ + $client = await(timeout($promise, self::TIMEOUT)); + + $this->assertInstanceOf(Connection::class, $client); + $this->assertTrue(isset($client->stream)); + + $meta = stream_get_meta_data($client->stream); + $this->assertTrue(isset($meta['crypto']['protocol'])); + $this->assertEquals('TLSv1.2', $meta['crypto']['protocol']); + + $client->close(); + $server->close(); + } + + public function testClientUsesTls10WhenCryptoMethodIsExplicitlyConfiguredByClient() + { + $server = new TcpServer(0); + $server = new SecureServer($server, null, [ + 'local_cert' => __DIR__ . '/../examples/localhost.pem' + ]); + + $connector = new SecureConnector(new TcpConnector(), null, [ + 'verify_peer' => false, + 'crypto_method' => STREAM_CRYPTO_METHOD_TLSv1_0_CLIENT + ]); + $promise = $connector->connect($server->getAddress()); + + /* @var ConnectionInterface $client */ + try { + $client = await(timeout($promise, self::TIMEOUT)); + } catch (\RuntimeException $e) { + // legacy TLS 1.0 would be considered insecure by today's standards, so skip test if connection fails + // OpenSSL error messages are version/platform specific + // […] no protocols available + // […] routines:state_machine:internal error + // SSL operation failed with code 1. OpenSSL Error messages: error:0A000438:SSL routines::tlsv1 alert internal error + // Connection lost during TLS handshake (ECONNRESET) + $server->close(); + $this->markTestSkipped('TLS 1.0 not available on this system (' . $e->getMessage() . ')'); + } + + $this->assertInstanceOf(Connection::class, $client); + $this->assertTrue(isset($client->stream)); + + $meta = stream_get_meta_data($client->stream); + $this->assertTrue(isset($meta['crypto']['protocol'])); + $this->assertEquals('TLSv1', $meta['crypto']['protocol']); + + $client->close(); + $server->close(); + } + + public function testServerEmitsConnectionForClientConnection() + { + $server = new TcpServer(0); + $server = new SecureServer($server, null, [ + 'local_cert' => __DIR__ . '/../examples/localhost.pem' + ]); + + $peer = new Promise(function ($resolve, $reject) use ($server) { + $server->on('connection', $resolve); + $server->on('error', $reject); + }); + + $connector = new SecureConnector(new TcpConnector(), null, [ + 'verify_peer' => false + ]); + $client = $connector->connect($server->getAddress()); + + // await both client and server side end of connection + /* @var ConnectionInterface[] $both */ + $both = await(timeout(all([$peer, $client]), self::TIMEOUT)); + + // both ends of the connection are represented by different instances of ConnectionInterface + $this->assertCount(2, $both); + $this->assertInstanceOf(ConnectionInterface::class, $both[0]); + $this->assertInstanceOf(ConnectionInterface::class, $both[1]); + $this->assertNotSame($both[0], $both[1]); + + // server side end has local server address and client end has remote server address + $this->assertEquals($server->getAddress(), $both[0]->getLocalAddress()); + $this->assertEquals($server->getAddress(), $both[1]->getRemoteAddress()); + + // clean up all connections and server again + $both[0]->close(); + $both[1]->close(); + $server->close(); + } + + public function testClientEmitsDataEventOnceForDataWrittenFromServer() + { + $server = new TcpServer(0); + $server = new SecureServer($server, null, [ + 'local_cert' => __DIR__ . '/../examples/localhost.pem' + ]); + + $server->on('connection', function (ConnectionInterface $conn) { + $conn->write('foo'); + }); + + $connector = new SecureConnector(new TcpConnector(), null, [ + 'verify_peer' => false + ]); + $connecting = $connector->connect($server->getAddress()); + + $promise = new Promise(function ($resolve, $reject) use ($connecting) { + $connecting->then(function (ConnectionInterface $connection) use ($resolve) { + $connection->on('data', $resolve); + }, $reject); + }); + + $data = await(timeout($promise, self::TIMEOUT)); + + $this->assertEquals('foo', $data); + + $server->close(); + + $connecting->then(function (ConnectionInterface $connection) { + $connection->close(); + }); + } + + public function testWritesDataInMultipleChunksToConnection() + { + $server = new TcpServer(0); + $server = new SecureServer($server, null, [ + 'local_cert' => __DIR__ . '/../examples/localhost.pem' + ]); + $server->on('connection', $this->expectCallableOnce()); + + $server->on('connection', function (ConnectionInterface $conn) { + $conn->write(str_repeat('*', 400000)); + }); + + $connector = new SecureConnector(new TcpConnector(), null, [ + 'verify_peer' => false + ]); + $connecting = $connector->connect($server->getAddress()); + + $promise = new Promise(function ($resolve, $reject) use ($connecting) { + $connecting->then(function (ConnectionInterface $connection) use ($resolve) { + $received = 0; + $connection->on('data', function ($chunk) use (&$received, $resolve, $connection) { + $received += strlen($chunk); + + if ($received >= 400000) { + $resolve($received); + } + }); + }, $reject); + }); + + $received = await(timeout($promise, self::TIMEOUT)); + + $this->assertEquals(400000, $received); + + $server->close(); + + $connecting->then(function (ConnectionInterface $connection) { + $connection->close(); + }); + } + + public function testWritesMoreDataInMultipleChunksToConnection() + { + $server = new TcpServer(0); + $server = new SecureServer($server, null, [ + 'local_cert' => __DIR__ . '/../examples/localhost.pem' + ]); + $server->on('connection', $this->expectCallableOnce()); + + $server->on('connection', function (ConnectionInterface $conn) { + $conn->write(str_repeat('*', 2000000)); + }); + + $connector = new SecureConnector(new TcpConnector(), null, [ + 'verify_peer' => false + ]); + $connecting = $connector->connect($server->getAddress()); + + $promise = new Promise(function ($resolve, $reject) use ($connecting) { + $connecting->then(function (ConnectionInterface $connection) use ($resolve) { + $received = 0; + $connection->on('data', function ($chunk) use (&$received, $resolve) { + $received += strlen($chunk); + + if ($received >= 2000000) { + $resolve($received); + } + }); + }, $reject); + }); + + $received = await(timeout($promise, self::TIMEOUT)); + + $this->assertEquals(2000000, $received); + + $server->close(); + + $connecting->then(function (ConnectionInterface $connection) { + $connection->close(); + }); + } + + public function testEmitsDataFromConnection() + { + $server = new TcpServer(0); + $server = new SecureServer($server, null, [ + 'local_cert' => __DIR__ . '/../examples/localhost.pem' + ]); + $server->on('connection', $this->expectCallableOnce()); + + $promise = new Promise(function ($resolve, $reject) use ($server) { + $server->on('connection', function (ConnectionInterface $connection) use ($resolve) { + $connection->on('data', $resolve); + }); + }); + + $connector = new SecureConnector(new TcpConnector(), null, [ + 'verify_peer' => false + ]); + $connecting = $connector->connect($server->getAddress()); + $connecting->then(function (ConnectionInterface $connection) { + $connection->write('foo'); + }); + + $data = await(timeout($promise, self::TIMEOUT)); + + $this->assertEquals('foo', $data); + + $server->close(); + + $connecting->then(function (ConnectionInterface $connection) { + $connection->close(); + }); + } + + public function testEmitsDataInMultipleChunksFromConnection() + { + $server = new TcpServer(0); + $server = new SecureServer($server, null, [ + 'local_cert' => __DIR__ . '/../examples/localhost.pem' + ]); + $server->on('connection', $this->expectCallableOnce()); + + $promise = new Promise(function ($resolve, $reject) use ($server) { + $server->on('connection', function (ConnectionInterface $connection) use ($resolve) { + $received = 0; + $connection->on('data', function ($chunk) use (&$received, $resolve) { + $received += strlen($chunk); + + if ($received >= 400000) { + $resolve($received); + } + }); + }); + }); + + $connector = new SecureConnector(new TcpConnector(), null, [ + 'verify_peer' => false + ]); + $connecting = $connector->connect($server->getAddress()); + $connecting->then(function (ConnectionInterface $connection) { + $connection->write(str_repeat('*', 400000)); + }); + + $received = await(timeout($promise, self::TIMEOUT)); + + $this->assertEquals(400000, $received); + + $server->close(); + + $connecting->then(function (ConnectionInterface $connection) { + $connection->close(); + }); + } + + public function testPipesDataBackInMultipleChunksFromConnection() + { + $server = new TcpServer(0); + $server = new SecureServer($server, null, [ + 'local_cert' => __DIR__ . '/../examples/localhost.pem' + ]); + $server->on('connection', $this->expectCallableOnce()); + + $server->on('connection', function (ConnectionInterface $conn) { + $conn->pipe($conn); + }); + + $connector = new SecureConnector(new TcpConnector(), null, [ + 'verify_peer' => false + ]); + $connecting = $connector->connect($server->getAddress()); + + $promise = new Promise(function ($resolve, $reject) use ($connecting) { + $connecting->then(function (ConnectionInterface $connection) use ($resolve) { + $received = 0; + $connection->on('data', function ($chunk) use (&$received, $resolve) { + $received += strlen($chunk); + + if ($received >= 400000) { + $resolve($received); + } + }); + $connection->write(str_repeat('*', 400000)); + }, $reject); + }); + + $received = await(timeout($promise, self::TIMEOUT)); + + $this->assertEquals(400000, $received); + + $server->close(); + + $connecting->then(function (ConnectionInterface $connection) { + $connection->close(); + }); + } + + /** + * @depends testClientUsesTls10WhenCryptoMethodIsExplicitlyConfiguredByClient + */ + public function testEmitsConnectionForNewTlsv11Connection() + { + $server = new TcpServer(0); + $server = new SecureServer($server, null, [ + 'local_cert' => __DIR__ . '/../examples/localhost.pem', + 'crypto_method' => STREAM_CRYPTO_METHOD_TLSv1_1_SERVER + ]); + $server->on('connection', $this->expectCallableOnce()); + + $connector = new SecureConnector(new TcpConnector(), null, [ + 'verify_peer' => false, + 'crypto_method' => STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT + ]); + $promise = $connector->connect($server->getAddress()); + + await(timeout($promise, self::TIMEOUT)); + + $server->close(); + $promise->then(function (ConnectionInterface $connection) { + $connection->close(); + }); + } + + /** + * @depends testClientUsesTls10WhenCryptoMethodIsExplicitlyConfiguredByClient + */ + public function testEmitsErrorForClientWithTlsVersionMismatch() + { + $server = new TcpServer(0); + $server = new SecureServer($server, null, [ + 'local_cert' => __DIR__ . '/../examples/localhost.pem', + 'crypto_method' => STREAM_CRYPTO_METHOD_TLSv1_1_SERVER|STREAM_CRYPTO_METHOD_TLSv1_2_SERVER + ]); + $server->on('connection', $this->expectCallableNever()); + $server->on('error', $this->expectCallableOnce()); + + $connector = new SecureConnector(new TcpConnector(), null, [ + 'verify_peer' => false, + 'crypto_method' => STREAM_CRYPTO_METHOD_TLSv1_0_CLIENT + ]); + $promise = $connector->connect($server->getAddress()); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('handshake'); + + try { + await(timeout($promise, self::TIMEOUT)); + } catch (\Exception $e) { + $server->close(); + + throw $e; + } + } + + public function testServerEmitsConnectionForNewConnectionWithEncryptedCertificate() + { + $server = new TcpServer(0); + $server = new SecureServer($server, null, [ + 'local_cert' => __DIR__ . '/../examples/localhost_swordfish.pem', + 'passphrase' => 'swordfish' + ]); + + $peer = new Promise(function ($resolve, $reject) use ($server) { + $server->on('connection', $resolve); + $server->on('error', $reject); + }); + + $connector = new SecureConnector(new TcpConnector(), null, [ + 'verify_peer' => false + ]); + $connector->connect($server->getAddress()); + + $connection = await(timeout($peer, self::TIMEOUT)); + + $this->assertInstanceOf(ConnectionInterface::class, $connection); + + $server->close(); + $connection->close(); + } + + public function testClientRejectsWithErrorForServerWithInvalidCertificate() + { + $server = new TcpServer(0); + $server = new SecureServer($server, null, [ + 'local_cert' => 'invalid.pem' + ]); + + $connector = new SecureConnector(new TcpConnector(), null, [ + 'verify_peer' => false + ]); + $promise = $connector->connect($server->getAddress()); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('handshake'); + + try { + await(timeout($promise, self::TIMEOUT)); + } catch (\Exception $e) { + $server->close(); + + throw $e; + } + } + + public function testServerEmitsErrorForClientWithInvalidCertificate() + { + $server = new TcpServer(0); + $server = new SecureServer($server, null, [ + 'local_cert' => 'invalid.pem' + ]); + + $peer = new Promise(function ($resolve, $reject) use ($server) { + $server->on('connection', function () use ($reject) { + $reject(new \RuntimeException('Did not expect connection to succeed')); + }); + $server->on('error', $reject); + }); + + $connector = new SecureConnector(new TcpConnector(), null, [ + 'verify_peer' => false + ]); + $promise = $connector->connect($server->getAddress()); + + try { + await($promise); + } catch (\RuntimeException $e) { + // ignore client-side exception + } + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('handshake'); + + try { + await(timeout($peer, self::TIMEOUT)); + } catch (\Exception $e) { + $server->close(); + + throw $e; + } + } + + public function testEmitsErrorForServerWithEncryptedCertificateMissingPassphrase() + { + if (DIRECTORY_SEPARATOR === '\\') { + $this->markTestSkipped('Not supported on Windows'); + } + + $server = new TcpServer(0); + $server = new SecureServer($server, null, [ + 'local_cert' => __DIR__ . '/../examples/localhost_swordfish.pem' + ]); + $server->on('connection', $this->expectCallableNever()); + $server->on('error', $this->expectCallableOnce()); + + $connector = new SecureConnector(new TcpConnector(), null, [ + 'verify_peer' => false + ]); + $promise = $connector->connect($server->getAddress()); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('handshake'); + + try { + await(timeout($promise, self::TIMEOUT)); + } catch (\Exception $e) { + $server->close(); + + throw $e; + } + } + + public function testEmitsErrorForServerWithEncryptedCertificateWithInvalidPassphrase() + { + if (DIRECTORY_SEPARATOR === '\\') { + $this->markTestSkipped('Not supported on Windows'); + } + + $server = new TcpServer(0); + $server = new SecureServer($server, null, [ + 'local_cert' => __DIR__ . '/../examples/localhost_swordfish.pem', + 'passphrase' => 'nope' + ]); + $server->on('connection', $this->expectCallableNever()); + $server->on('error', $this->expectCallableOnce()); + + $connector = new SecureConnector(new TcpConnector(), null, [ + 'verify_peer' => false + ]); + $promise = $connector->connect($server->getAddress()); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('handshake'); + + try { + await(timeout($promise, self::TIMEOUT)); + } catch (\Exception $e) { + $server->close(); + + throw $e; + } + } + + public function testEmitsErrorForConnectionWithPeerVerification() + { + $server = new TcpServer(0); + $server = new SecureServer($server, null, [ + 'local_cert' => __DIR__ . '/../examples/localhost.pem' + ]); + $server->on('connection', $this->expectCallableNever()); + $errorEvent = $this->createPromiseForServerError($server); + + $connector = new SecureConnector(new TcpConnector(), null, [ + 'verify_peer' => true + ]); + $promise = $connector->connect($server->getAddress()); + $promise->then(null, $this->expectCallableOnce()); + + await(timeout($errorEvent, self::TIMEOUT)); + + $server->close(); + } + + public function testEmitsErrorIfConnectionIsCancelled() + { + if (PHP_OS !== 'Linux') { + $this->markTestSkipped('Linux only (OS is ' . PHP_OS . ')'); + } + + $server = new TcpServer(0); + $server = new SecureServer($server, null, [ + 'local_cert' => __DIR__ . '/../examples/localhost.pem' + ]); + $server->on('connection', $this->expectCallableNever()); + $errorEvent = $this->createPromiseForServerError($server); + + $connector = new SecureConnector(new TcpConnector(), null, [ + 'verify_peer' => false + ]); + $promise = $connector->connect($server->getAddress()); + $promise->cancel(); + $promise->then(null, $this->expectCallableOnce()); + + await(timeout($errorEvent, self::TIMEOUT)); + + $server->close(); + } + + public function testEmitsErrorIfConnectionIsClosedBeforeHandshake() + { + $server = new TcpServer(0); + $server = new SecureServer($server, null, [ + 'local_cert' => __DIR__ . '/../examples/localhost.pem' + ]); + $server->on('connection', $this->expectCallableNever()); + $errorEvent = $this->createPromiseForServerError($server); + + $connector = new TcpConnector(); + $promise = $connector->connect(str_replace('tls://', '', $server->getAddress())); + + $promise->then(function (ConnectionInterface $stream) { + $stream->close(); + }); + + $error = await(timeout($errorEvent, self::TIMEOUT)); + + // Connection from tcp://127.0.0.1:39528 failed during TLS handshake: Connection lost during TLS handshake (ECONNRESET) + $this->assertInstanceOf(\RuntimeException::class, $error); + $this->assertStringStartsWith('Connection from tcp://', $error->getMessage()); + $this->assertStringEndsWith('failed during TLS handshake: Connection lost during TLS handshake (ECONNRESET)', $error->getMessage()); + $this->assertEquals(defined('SOCKET_ECONNRESET') ? SOCKET_ECONNRESET : 104, $error->getCode()); + $this->assertNull($error->getPrevious()); + + $server->close(); + } + + public function testEmitsErrorIfConnectionIsClosedWithIncompleteHandshake() + { + $server = new TcpServer(0); + $server = new SecureServer($server, null, [ + 'local_cert' => __DIR__ . '/../examples/localhost.pem' + ]); + $server->on('connection', $this->expectCallableNever()); + $errorEvent = $this->createPromiseForServerError($server); + + $connector = new TcpConnector(); + $promise = $connector->connect(str_replace('tls://', '', $server->getAddress())); + + $promise->then(function (ConnectionInterface $stream) { + $stream->end("\x1e"); + }); + + $error = await(timeout($errorEvent, self::TIMEOUT)); + + // Connection from tcp://127.0.0.1:39528 failed during TLS handshake: Connection lost during TLS handshake (ECONNRESET) + $this->assertInstanceOf(\RuntimeException::class, $error); + $this->assertStringStartsWith('Connection from tcp://', $error->getMessage()); + $this->assertStringEndsWith('failed during TLS handshake: Connection lost during TLS handshake (ECONNRESET)', $error->getMessage()); + $this->assertEquals(defined('SOCKET_ECONNRESET') ? SOCKET_ECONNRESET : 104, $error->getCode()); + $this->assertNull($error->getPrevious()); + + $server->close(); + } + + public function testEmitsNothingIfPlaintextConnectionIsIdle() + { + $server = new TcpServer(0); + $server = new SecureServer($server, null, [ + 'local_cert' => __DIR__ . '/../examples/localhost.pem' + ]); + $server->on('connection', $this->expectCallableNever()); + $server->on('error', $this->expectCallableNever()); + + $connector = new TcpConnector(); + $promise = $connector->connect(str_replace('tls://', '', $server->getAddress())); + + $connection = await(timeout($promise, self::TIMEOUT)); + $this->assertInstanceOf(ConnectionInterface::class, $connection); + + $server->close(); + $promise->then(function (ConnectionInterface $connection) { + $connection->close(); + }); + } + + public function testEmitsErrorIfConnectionIsHttpInsteadOfSecureHandshake() + { + $server = new TcpServer(0); + $server = new SecureServer($server, null, [ + 'local_cert' => __DIR__ . '/../examples/localhost.pem' + ]); + $server->on('connection', $this->expectCallableNever()); + $errorEvent = $this->createPromiseForServerError($server); + + $connector = new TcpConnector(); + $promise = $connector->connect(str_replace('tls://', '', $server->getAddress())); + + $promise->then(function (ConnectionInterface $stream) { + $stream->write("GET / HTTP/1.0\r\n\r\n"); + }); + + $error = await(timeout($errorEvent, self::TIMEOUT)); + + $this->assertInstanceOf(\RuntimeException::class, $error); + + // OpenSSL error messages are version/platform specific + // Unable to complete TLS handshake: SSL operation failed with code 1. OpenSSL Error messages: error:1408F10B:SSL routines:SSL3_GET_RECORD:http request + // Unable to complete TLS handshake: SSL operation failed with code 1. OpenSSL Error messages: error:1408F10B:SSL routines:ssl3_get_record:wrong version number + // Unable to complete TLS handshake: SSL operation failed with code 1. OpenSSL Error messages: error:1408F10B:SSL routines:func(143):reason(267) + // Unable to complete TLS handshake: Failed setting RSA key + + $server->close(); + } + + public function testEmitsErrorIfConnectionIsUnknownProtocolInsteadOfSecureHandshake() + { + $server = new TcpServer(0); + $server = new SecureServer($server, null, [ + 'local_cert' => __DIR__ . '/../examples/localhost.pem' + ]); + $server->on('connection', $this->expectCallableNever()); + $errorEvent = $this->createPromiseForServerError($server); + + $connector = new TcpConnector(); + $promise = $connector->connect(str_replace('tls://', '', $server->getAddress())); + + $promise->then(function (ConnectionInterface $stream) { + $stream->write("Hello world!\n"); + }); + + $error = await(timeout($errorEvent, self::TIMEOUT)); + + $this->assertInstanceOf(\RuntimeException::class, $error); + + // OpenSSL error messages are version/platform specific + // Unable to complete TLS handshake: SSL operation failed with code 1. OpenSSL Error messages: error:1408F10B:SSL routines:SSL3_GET_RECORD:unknown protocol + // Unable to complete TLS handshake: SSL operation failed with code 1. OpenSSL Error messages: error:1408F10B:SSL routines:ssl3_get_record:wrong version number + // Unable to complete TLS handshake: SSL operation failed with code 1. OpenSSL Error messages: error:1408F10B:SSL routines:func(143):reason(267) + // Unable to complete TLS handshake: Failed setting RSA key + + $server->close(); + } + + private function createPromiseForServerError(ServerInterface $server) + { + return new Promise(function ($resolve) use ($server) { + $server->on('error', function ($arg) use ($resolve) { + $resolve($arg); + }); + }); + } +} diff --git a/tests/FunctionalTcpServerTest.php b/tests/FunctionalTcpServerTest.php new file mode 100644 index 00000000..a3aa4be6 --- /dev/null +++ b/tests/FunctionalTcpServerTest.php @@ -0,0 +1,425 @@ +on('connection', $this->expectCallableOnce()); + + $peer = new Promise(function ($resolve, $reject) use ($server) { + $server->on('connection', function () use ($resolve) { + $resolve(null); + }); + }); + + $connector = new TcpConnector(); + $promise = $connector->connect($server->getAddress()); + + $promise->then($this->expectCallableOnce()); + + await(timeout($peer, self::TIMEOUT)); + await(sleep(0.0)); + + $server->close(); + + $promise->then(function (ConnectionInterface $connection) { + $connection->close(); + }); + } + + public function testEmitsNoConnectionForNewConnectionWhenPaused() + { + $server = new TcpServer(0); + $server->on('connection', $this->expectCallableNever()); + $server->pause(); + + $connector = new TcpConnector(); + $promise = $connector->connect($server->getAddress()); + + $promise->then($this->expectCallableOnce()); + + await(timeout($promise, self::TIMEOUT)); + await(sleep(0.0)); + } + + public function testConnectionForNewConnectionWhenResumedAfterPause() + { + $server = new TcpServer(0); + $server->on('connection', $this->expectCallableOnce()); + $server->pause(); + $server->resume(); + + $peer = new Promise(function ($resolve, $reject) use ($server) { + $server->on('connection', function () use ($resolve) { + $resolve(null); + }); + }); + + $connector = new TcpConnector(); + $promise = $connector->connect($server->getAddress()); + + $promise->then($this->expectCallableOnce()); + + await(timeout($peer, self::TIMEOUT)); + await(sleep(0.0)); + + $server->close(); + $promise->then(function (ConnectionInterface $connection) { + $connection->close(); + }); + } + + public function testEmitsConnectionWithRemoteIp() + { + $server = new TcpServer(0); + $peer = new Promise(function ($resolve, $reject) use ($server) { + $server->on('connection', function (ConnectionInterface $connection) use ($resolve) { + $resolve($connection->getRemoteAddress()); + }); + }); + + $connector = new TcpConnector(); + $promise = $connector->connect($server->getAddress()); + + $promise->then($this->expectCallableOnce()); + + $peer = await(timeout($peer, self::TIMEOUT)); + await(sleep(0.0)); + + $this->assertStringContainsString('127.0.0.1:', $peer); + + $server->close(); + $promise->then(function (ConnectionInterface $connection) { + $connection->close(); + }); + } + + public function testEmitsConnectionWithLocalIp() + { + $server = new TcpServer(0); + $peer = new Promise(function ($resolve, $reject) use ($server) { + $server->on('connection', function (ConnectionInterface $connection) use ($resolve) { + $resolve($connection->getLocalAddress()); + }); + }); + + $connector = new TcpConnector(); + $promise = $connector->connect($server->getAddress()); + + $promise->then($this->expectCallableOnce()); + + $promise->then($this->expectCallableOnce()); + + $local = await(timeout($peer, self::TIMEOUT)); + await(sleep(0.0)); + + $this->assertStringContainsString('127.0.0.1:', $local); + $this->assertEquals($server->getAddress(), $local); + + $server->close(); + $promise->then(function (ConnectionInterface $connection) { + $connection->close(); + }); + } + + public function testEmitsConnectionWithLocalIpDespiteListeningOnAll() + { + if (DIRECTORY_SEPARATOR === '\\') { + $this->markTestSkipped('Skipping on Windows due to default firewall rules'); + } + + $server = new TcpServer('0.0.0.0:0'); + $peer = new Promise(function ($resolve, $reject) use ($server) { + $server->on('connection', function (ConnectionInterface $connection) use ($resolve) { + $resolve($connection->getLocalAddress()); + }); + }); + + $connector = new TcpConnector(); + $promise = $connector->connect($server->getAddress()); + + $promise->then($this->expectCallableOnce()); + + $local = await(timeout($peer, self::TIMEOUT)); + await(sleep(0.0)); + + $this->assertStringContainsString('127.0.0.1:', $local); + + $server->close(); + $promise->then(function (ConnectionInterface $connection) { + $connection->close(); + }); + } + + public function testEmitsConnectionWithRemoteIpAfterConnectionIsClosedByPeer() + { + $server = new TcpServer(0); + $peer = new Promise(function ($resolve, $reject) use ($server) { + $server->on('connection', function (ConnectionInterface $connection) use ($resolve) { + $connection->on('close', function () use ($connection, $resolve) { + $resolve($connection->getRemoteAddress()); + }); + }); + }); + + $connector = new TcpConnector(); + $connector->connect($server->getAddress())->then(function (ConnectionInterface $connection) { + $connection->end(); + }); + + $peer = await(timeout($peer, self::TIMEOUT)); + + $this->assertStringContainsString('127.0.0.1:', $peer); + + $server->close(); + } + + public function testEmitsConnectionWithRemoteNullAddressAfterConnectionIsClosedByServer() + { + $server = new TcpServer(0); + $peer = new Promise(function ($resolve, $reject) use ($server) { + $server->on('connection', function (ConnectionInterface $connection) use ($resolve) { + $connection->close(); + $resolve($connection->getRemoteAddress()); + }); + }); + + $connector = new TcpConnector(); + $promise = $connector->connect($server->getAddress()); + + $promise->then($this->expectCallableOnce()); + + $peer = await(timeout($peer, self::TIMEOUT)); + await(sleep(0.0)); + + $this->assertNull($peer); + + $server->close(); + } + + public function testEmitsConnectionEvenIfClientConnectionIsCancelled() + { + if (PHP_OS !== 'Linux') { + $this->markTestSkipped('Linux only (OS is ' . PHP_OS . ')'); + } + + $server = new TcpServer(0); + $server->on('connection', $this->expectCallableOnce()); + + $peer = new Promise(function ($resolve, $reject) use ($server) { + $server->on('connection', function () use ($resolve) { + $resolve(null); + }); + }); + + $connector = new TcpConnector(); + $promise = $connector->connect($server->getAddress()); + $promise->cancel(); + + $promise->then(null, $this->expectCallableOnce()); + + await(timeout($peer, self::TIMEOUT)); + + $server->close(); + } + + public function testEmitsConnectionForNewIpv6Connection() + { + try { + $server = new TcpServer('[::1]:0'); + } catch (\RuntimeException $e) { + $this->markTestSkipped('Unable to start IPv6 server socket (not available on your platform?)'); + } + + $server->on('connection', $this->expectCallableOnce()); + + $peer = new Promise(function ($resolve, $reject) use ($server) { + $server->on('connection', function () use ($resolve) { + $resolve(null); + }); + }); + + $connector = new TcpConnector(); + $promise = $connector->connect($server->getAddress()); + + $promise->then($this->expectCallableOnce()); + + await(timeout($peer, self::TIMEOUT)); + await(sleep(0.0)); + + $server->close(); + $promise->then(function (ConnectionInterface $connection) { + $connection->close(); + }); + } + + public function testEmitsConnectionWithRemoteIpv6() + { + try { + $server = new TcpServer('[::1]:0'); + } catch (\RuntimeException $e) { + $this->markTestSkipped('Unable to start IPv6 server socket (not available on your platform?)'); + } + + $peer = new Promise(function ($resolve, $reject) use ($server) { + $server->on('connection', function (ConnectionInterface $connection) use ($resolve) { + $resolve($connection->getRemoteAddress()); + }); + }); + + $connector = new TcpConnector(); + $promise = $connector->connect($server->getAddress()); + + $promise->then($this->expectCallableOnce()); + + $peer = await(timeout($peer, self::TIMEOUT)); + await(sleep(0.0)); + + $this->assertStringContainsString('[::1]:', $peer); + + $server->close(); + $promise->then(function (ConnectionInterface $connection) { + $connection->close(); + }); + } + + public function testEmitsConnectionWithLocalIpv6() + { + try { + $server = new TcpServer('[::1]:0'); + } catch (\RuntimeException $e) { + $this->markTestSkipped('Unable to start IPv6 server socket (not available on your platform?)'); + } + + $peer = new Promise(function ($resolve, $reject) use ($server) { + $server->on('connection', function (ConnectionInterface $connection) use ($resolve) { + $resolve($connection->getLocalAddress()); + }); + }); + + $connector = new TcpConnector(); + $promise = $connector->connect($server->getAddress()); + + $promise->then($this->expectCallableOnce()); + + $local = await(timeout($peer, self::TIMEOUT)); + await(sleep(0.0)); + + $this->assertStringContainsString('[::1]:', $local); + $this->assertEquals($server->getAddress(), $local); + + $server->close(); + $promise->then(function (ConnectionInterface $connection) { + $connection->close(); + }); + } + + public function testServerPassesContextOptionsToSocket() + { + $server = new TcpServer(0, null, [ + 'backlog' => 4 + ]); + + $ref = new \ReflectionProperty($server, 'master'); + if (\PHP_VERSION_ID < 80100) { + $ref->setAccessible(true); + } + $socket = $ref->getValue($server); + + $context = stream_context_get_options($socket); + + $this->assertEquals(['socket' => ['backlog' => 4]], $context); + + $server->close(); + } + + public function testServerPassesDefaultBacklogSizeViaContextOptionsToSocket() + { + $server = new TcpServer(0); + + $ref = new \ReflectionProperty($server, 'master'); + if (\PHP_VERSION_ID < 80100) { + $ref->setAccessible(true); + } + $socket = $ref->getValue($server); + + $context = stream_context_get_options($socket); + + $this->assertEquals(['socket' => ['backlog' => 511]], $context); + + $server->close(); + } + + public function testEmitsConnectionWithInheritedContextOptions() + { + $server = new TcpServer(0, null, [ + 'backlog' => 4 + ]); + + $peer = new Promise(function ($resolve, $reject) use ($server) { + $server->on('connection', function (ConnectionInterface $connection) use ($resolve) { + $resolve(stream_context_get_options($connection->stream)); + }); + }); + + $connector = new TcpConnector(); + $promise = $connector->connect($server->getAddress()); + + $promise->then($this->expectCallableOnce()); + + $all = await(timeout($peer, self::TIMEOUT)); + await(sleep(0.0)); + + $this->assertEquals(['socket' => ['backlog' => 4]], $all); + + $server->close(); + $promise->then(function (ConnectionInterface $connection) { + $connection->close(); + }); + } + + public function testFailsToListenOnInvalidUri() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid URI "tcp://///" given (EINVAL)'); + $this->expectExceptionCode(defined('SOCKET_EINVAL') ? SOCKET_EINVAL : (defined('PCNTL_EINVAL') ? PCNTL_EINVAL : 22)); + new TcpServer('///'); + } + + public function testFailsToListenOnUriWithoutPort() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid URI "tcp://127.0.0.1" given (EINVAL)'); + $this->expectExceptionCode(defined('SOCKET_EINVAL') ? SOCKET_EINVAL : (defined('PCNTL_EINVAL') ? PCNTL_EINVAL : 22)); + new TcpServer('127.0.0.1'); + } + + public function testFailsToListenOnUriWithWrongScheme() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid URI "udp://127.0.0.1:0" given (EINVAL)'); + $this->expectExceptionCode(defined('SOCKET_EINVAL') ? SOCKET_EINVAL : (defined('PCNTL_EINVAL') ? PCNTL_EINVAL : 22)); + new TcpServer('udp://127.0.0.1:0'); + } + + public function testFailsToListenOnUriWIthHostname() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Given URI "tcp://localhost:8080" does not contain a valid host IP (EINVAL)'); + $this->expectExceptionCode(defined('SOCKET_EINVAL') ? SOCKET_EINVAL : (defined('PCNTL_EINVAL') ? PCNTL_EINVAL : 22)); + new TcpServer('localhost:8080'); + } +} diff --git a/tests/HappyEyeBallsConnectionBuilderTest.php b/tests/HappyEyeBallsConnectionBuilderTest.php new file mode 100644 index 00000000..95b9a9e8 --- /dev/null +++ b/tests/HappyEyeBallsConnectionBuilderTest.php @@ -0,0 +1,979 @@ +createMock(LoopInterface::class); + $loop->expects($this->never())->method('addTimer'); + + $connector = $this->createMock(ConnectorInterface::class); + $connector->expects($this->never())->method('connect'); + + $resolver = $this->createMock(ResolverInterface::class); + $resolver->expects($this->exactly(2))->method('resolveAll')->withConsecutive( + ['reactphp.org', Message::TYPE_AAAA], + ['reactphp.org', Message::TYPE_A] + )->willReturn(new Promise(function () { })); + + $uri = 'tcp://reactphp.org:80'; + $host = 'reactphp.org'; + $parts = parse_url($uri); + + $builder = new HappyEyeBallsConnectionBuilder($loop, $connector, $resolver, $uri, $host, $parts); + + $builder->connect(); + } + + public function testConnectWillRejectWhenBothDnsLookupsReject() + { + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->never())->method('addTimer'); + + $connector = $this->createMock(ConnectorInterface::class); + $connector->expects($this->never())->method('connect'); + + $resolver = $this->createMock(ResolverInterface::class); + $resolver->expects($this->exactly(2))->method('resolveAll')->withConsecutive( + ['reactphp.org', Message::TYPE_AAAA], + ['reactphp.org', Message::TYPE_A] + )->willReturn(new Promise(function () { + throw new \RuntimeException('DNS lookup error'); + })); + + $uri = 'tcp://reactphp.org:80'; + $host = 'reactphp.org'; + $parts = parse_url($uri); + + $builder = new HappyEyeBallsConnectionBuilder($loop, $connector, $resolver, $uri, $host, $parts); + + $promise = $builder->connect(); + + $exception = null; + $promise->then(null, function ($e) use (&$exception) { + $exception = $e; + }); + + $this->assertInstanceOf(\RuntimeException::class, $exception); + assert($exception instanceof \RuntimeException); + + $this->assertEquals('Connection to tcp://reactphp.org:80 failed during DNS lookup: DNS lookup error', $exception->getMessage()); + $this->assertEquals(0, $exception->getCode()); + $this->assertInstanceOf(\RuntimeException::class, $exception->getPrevious()); + } + + public function testConnectWillRejectWhenBothDnsLookupsRejectWithDifferentMessages() + { + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->never())->method('addTimer'); + + $connector = $this->createMock(ConnectorInterface::class); + $connector->expects($this->never())->method('connect'); + + $deferred = new Deferred(); + $resolver = $this->createMock(ResolverInterface::class); + $resolver->expects($this->exactly(2))->method('resolveAll')->withConsecutive( + ['reactphp.org', Message::TYPE_AAAA], + ['reactphp.org', Message::TYPE_A] + )->willReturnOnConsecutiveCalls( + $deferred->promise(), + reject(new \RuntimeException('DNS4 error')) + ); + + $uri = 'tcp://reactphp.org:80'; + $host = 'reactphp.org'; + $parts = parse_url($uri); + + $builder = new HappyEyeBallsConnectionBuilder($loop, $connector, $resolver, $uri, $host, $parts); + + $promise = $builder->connect(); + $deferred->reject(new \RuntimeException('DNS6 error')); + + $exception = null; + $promise->then(null, function ($e) use (&$exception) { + $exception = $e; + }); + + $this->assertInstanceOf(\RuntimeException::class, $exception); + assert($exception instanceof \RuntimeException); + + $this->assertEquals('Connection to tcp://reactphp.org:80 failed during DNS lookup. Last error for IPv6: DNS6 error. Previous error for IPv4: DNS4 error', $exception->getMessage()); + $this->assertEquals(0, $exception->getCode()); + $this->assertInstanceOf(\RuntimeException::class, $exception->getPrevious()); + } + + public function testConnectWillStartDelayTimerWhenIpv4ResolvesAndIpv6IsPending() + { + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->once())->method('addTimer')->with(0.05, $this->anything()); + $loop->expects($this->never())->method('cancelTimer'); + + $connector = $this->createMock(ConnectorInterface::class); + $connector->expects($this->never())->method('connect'); + + $resolver = $this->createMock(ResolverInterface::class); + $resolver->expects($this->exactly(2))->method('resolveAll')->withConsecutive( + ['reactphp.org', Message::TYPE_AAAA], + ['reactphp.org', Message::TYPE_A] + )->willReturnOnConsecutiveCalls( + new Promise(function () { }), + resolve(['127.0.0.1']) + ); + + $uri = 'tcp://reactphp.org:80'; + $host = 'reactphp.org'; + $parts = parse_url($uri); + + $builder = new HappyEyeBallsConnectionBuilder($loop, $connector, $resolver, $uri, $host, $parts); + + $builder->connect(); + } + + public function testConnectWillStartConnectingWithAttemptTimerButWithoutResolutionTimerWhenIpv6ResolvesAndIpv4IsPending() + { + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->once())->method('addTimer')->with(0.1, $this->anything()); + $loop->expects($this->never())->method('cancelTimer'); + + $connector = $this->createMock(ConnectorInterface::class); + $connector->expects($this->once())->method('connect')->with('tcp://[::1]:80?hostname=reactphp.org')->willReturn(new Promise(function () { })); + + $resolver = $this->createMock(ResolverInterface::class); + $resolver->expects($this->exactly(2))->method('resolveAll')->withConsecutive( + ['reactphp.org', Message::TYPE_AAAA], + ['reactphp.org', Message::TYPE_A] + )->willReturnOnConsecutiveCalls( + resolve(['::1']), + new Promise(function () { }) + ); + + $uri = 'tcp://reactphp.org:80'; + $host = 'reactphp.org'; + $parts = parse_url($uri); + + $builder = new HappyEyeBallsConnectionBuilder($loop, $connector, $resolver, $uri, $host, $parts); + + $builder->connect(); + } + + public function testConnectWillStartConnectingAndWillStartNextConnectionWithNewAttemptTimerWhenNextAttemptTimerFiresWithIpv4StillPending() + { + $timer = null; + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->exactly(2))->method('addTimer')->with(0.1, $this->callback(function ($cb) use (&$timer) { + $timer = $cb; + return true; + })); + $loop->expects($this->never())->method('cancelTimer'); + + $connector = $this->createMock(ConnectorInterface::class); + $connector->expects($this->exactly(2))->method('connect')->willReturn(new Promise(function () { })); + + $resolver = $this->createMock(ResolverInterface::class); + $resolver->expects($this->exactly(2))->method('resolveAll')->withConsecutive( + ['reactphp.org', Message::TYPE_AAAA], + ['reactphp.org', Message::TYPE_A] + )->willReturnOnConsecutiveCalls( + resolve(['::1', '::2']), + new Promise(function () { }) + ); + + $uri = 'tcp://reactphp.org:80'; + $host = 'reactphp.org'; + $parts = parse_url($uri); + + $builder = new HappyEyeBallsConnectionBuilder($loop, $connector, $resolver, $uri, $host, $parts); + + $builder->connect(); + + $this->assertNotNull($timer); + $timer(); + } + + public function testConnectWillStartConnectingAndWillDoNothingWhenNextAttemptTimerFiresWithNoOtherIps() + { + $timer = null; + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->once())->method('addTimer')->with(0.1, $this->callback(function ($cb) use (&$timer) { + $timer = $cb; + return true; + })); + $loop->expects($this->never())->method('cancelTimer'); + + $connector = $this->createMock(ConnectorInterface::class); + $connector->expects($this->once())->method('connect')->with('tcp://[::1]:80?hostname=reactphp.org')->willReturn(new Promise(function () { })); + + $resolver = $this->createMock(ResolverInterface::class); + $resolver->expects($this->exactly(2))->method('resolveAll')->withConsecutive( + ['reactphp.org', Message::TYPE_AAAA], + ['reactphp.org', Message::TYPE_A] + )->willReturnOnConsecutiveCalls( + resolve(['::1']), + new Promise(function () { }) + ); + + $uri = 'tcp://reactphp.org:80'; + $host = 'reactphp.org'; + $parts = parse_url($uri); + + $builder = new HappyEyeBallsConnectionBuilder($loop, $connector, $resolver, $uri, $host, $parts); + + $builder->connect(); + + $this->assertNotNull($timer); + $timer(); + } + + public function testConnectWillStartConnectingWithAttemptTimerButWithoutResolutionTimerWhenIpv6ResolvesAndWillCancelAttemptTimerWhenIpv4Rejects() + { + $timer = $this->createMock(TimerInterface::class); + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->once())->method('addTimer')->with(0.1, $this->anything())->willReturn($timer); + $loop->expects($this->once())->method('cancelTimer')->with($timer); + + $connector = $this->createMock(ConnectorInterface::class); + $connector->expects($this->once())->method('connect')->with('tcp://[::1]:80?hostname=reactphp.org')->willReturn(new Promise(function () { })); + + $deferred = new Deferred(); + $resolver = $this->createMock(ResolverInterface::class); + $resolver->expects($this->exactly(2))->method('resolveAll')->withConsecutive( + ['reactphp.org', Message::TYPE_AAAA], + ['reactphp.org', Message::TYPE_A] + )->willReturnOnConsecutiveCalls( + resolve(['::1']), + $deferred->promise() + ); + + $uri = 'tcp://reactphp.org:80'; + $host = 'reactphp.org'; + $parts = parse_url($uri); + + $builder = new HappyEyeBallsConnectionBuilder($loop, $connector, $resolver, $uri, $host, $parts); + + $builder->connect(); + $deferred->reject(new \RuntimeException()); + } + + public function testConnectWillStartConnectingWithAttemptTimerWhenIpv6AndIpv4ResolvesAndWillStartNextConnectionAttemptWithoutAttemptTimerImmediatelyWhenFirstConnectionAttemptFails() + { + $timer = $this->createMock(TimerInterface::class); + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->once())->method('addTimer')->with(0.1, $this->anything())->willReturn($timer); + $loop->expects($this->once())->method('cancelTimer')->with($timer); + + $deferred = new Deferred(); + $connector = $this->createMock(ConnectorInterface::class); + $connector->expects($this->exactly(2))->method('connect')->withConsecutive( + ['tcp://[::1]:80?hostname=reactphp.org'], + ['tcp://127.0.0.1:80?hostname=reactphp.org'] + )->willReturnOnConsecutiveCalls( + $deferred->promise(), + new Promise(function () { }) + ); + + $resolver = $this->createMock(ResolverInterface::class); + $resolver->expects($this->exactly(2))->method('resolveAll')->withConsecutive( + ['reactphp.org', Message::TYPE_AAAA], + ['reactphp.org', Message::TYPE_A] + )->willReturnOnConsecutiveCalls( + resolve(['::1']), + resolve(['127.0.0.1']) + ); + + $uri = 'tcp://reactphp.org:80'; + $host = 'reactphp.org'; + $parts = parse_url($uri); + + $builder = new HappyEyeBallsConnectionBuilder($loop, $connector, $resolver, $uri, $host, $parts); + + $builder->connect(); + + $deferred->reject(new \RuntimeException()); + } + + public function testConnectWillStartConnectingWithAlternatingIPv6AndIPv4WhenResolverReturnsMultipleIPAdresses() + { + $timer = $this->createMock(TimerInterface::class); + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->once())->method('addTimer')->with(0.1, $this->anything())->willReturn($timer); + $loop->expects($this->once())->method('cancelTimer')->with($timer); + + $deferred = new Deferred(); + $connector = $this->createMock(ConnectorInterface::class); + $connector->expects($this->exactly(4))->method('connect')->withConsecutive( + ['tcp://[::1]:80?hostname=reactphp.org'], + ['tcp://127.0.0.1:80?hostname=reactphp.org'], + ['tcp://[::1]:80?hostname=reactphp.org'], + ['tcp://127.0.0.1:80?hostname=reactphp.org'] + )->willReturnOnConsecutiveCalls( + $deferred->promise(), + $deferred->promise(), + $deferred->promise(), + new Promise(function () { }) + ); + + $resolver = $this->createMock(ResolverInterface::class); + $resolver->expects($this->exactly(2))->method('resolveAll')->withConsecutive( + ['reactphp.org', Message::TYPE_AAAA], + ['reactphp.org', Message::TYPE_A] + )->willReturnOnConsecutiveCalls( + resolve(['::1', '::1']), + resolve(['127.0.0.1', '127.0.0.1']) + ); + + $uri = 'tcp://reactphp.org:80'; + $host = 'reactphp.org'; + $parts = parse_url($uri); + + $builder = new HappyEyeBallsConnectionBuilder($loop, $connector, $resolver, $uri, $host, $parts); + + $builder->connect(); + + $deferred->reject(new \RuntimeException()); + } + + public function testConnectWillStartConnectingWithAttemptTimerWhenOnlyIpv6ResolvesAndWillStartNextConnectionAttemptWithoutAttemptTimerImmediatelyWhenFirstConnectionAttemptFails() + { + $timer = $this->createMock(TimerInterface::class); + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->once())->method('addTimer')->with(0.1, $this->anything())->willReturn($timer); + $loop->expects($this->once())->method('cancelTimer')->with($timer); + + $connector = $this->createMock(ConnectorInterface::class); + $connector->expects($this->exactly(2))->method('connect')->withConsecutive( + ['tcp://[::1]:80?hostname=reactphp.org'], + ['tcp://[::1]:80?hostname=reactphp.org'] + )->willReturnOnConsecutiveCalls( + reject(new \RuntimeException()), + new Promise(function () { }) + ); + + $resolver = $this->createMock(ResolverInterface::class); + $resolver->expects($this->exactly(2))->method('resolveAll')->withConsecutive( + ['reactphp.org', Message::TYPE_AAAA], + ['reactphp.org', Message::TYPE_A] + )->willReturnOnConsecutiveCalls( + resolve(['::1', '::1']), + reject(new \RuntimeException()) + ); + + $uri = 'tcp://reactphp.org:80'; + $host = 'reactphp.org'; + $parts = parse_url($uri); + + $builder = new HappyEyeBallsConnectionBuilder($loop, $connector, $resolver, $uri, $host, $parts); + + $builder->connect(); + } + + public function testConnectWillStartConnectingAndWillStartNextConnectionWithoutNewAttemptTimerWhenNextAttemptTimerFiresAfterIpv4Rejected() + { + $timer = null; + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->once())->method('addTimer')->with(0.1, $this->callback(function ($cb) use (&$timer) { + $timer = $cb; + return true; + })); + $loop->expects($this->never())->method('cancelTimer'); + + $connector = $this->createMock(ConnectorInterface::class); + $connector->expects($this->exactly(2))->method('connect')->willReturn(new Promise(function () { })); + + $deferred = new Deferred(); + $resolver = $this->createMock(ResolverInterface::class); + $resolver->expects($this->exactly(2))->method('resolveAll')->withConsecutive( + ['reactphp.org', Message::TYPE_AAAA], + ['reactphp.org', Message::TYPE_A] + )->willReturnOnConsecutiveCalls( + resolve(['::1', '::2']), + $deferred->promise() + ); + + $uri = 'tcp://reactphp.org:80'; + $host = 'reactphp.org'; + $parts = parse_url($uri); + + $builder = new HappyEyeBallsConnectionBuilder($loop, $connector, $resolver, $uri, $host, $parts); + + $builder->connect(); + $deferred->reject(new \RuntimeException()); + + $this->assertNotNull($timer); + $timer(); + } + + public function testConnectWillStartAndCancelResolutionTimerAndStartAttemptTimerWhenIpv4ResolvesAndIpv6ResolvesAfterwardsAndStartConnectingToIpv6() + { + $timerDelay = $this->createMock(TimerInterface::class); + $timerAttempt = $this->createMock(TimerInterface::class); + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->exactly(2))->method('addTimer')->withConsecutive( + [0.05, $this->anything()], + [0.1, $this->anything()] + )->willReturnOnConsecutiveCalls($timerDelay, $timerAttempt); + $loop->expects($this->once())->method('cancelTimer')->with($timerDelay); + + $connector = $this->createMock(ConnectorInterface::class); + $connector->expects($this->once())->method('connect')->with('tcp://[::1]:80?hostname=reactphp.org')->willReturn(new Promise(function () { })); + + $deferred = new Deferred(); + $resolver = $this->createMock(ResolverInterface::class); + $resolver->expects($this->exactly(2))->method('resolveAll')->withConsecutive( + ['reactphp.org', Message::TYPE_AAAA], + ['reactphp.org', Message::TYPE_A] + )->willReturnOnConsecutiveCalls( + $deferred->promise(), + resolve(['127.0.0.1']) + ); + + $uri = 'tcp://reactphp.org:80'; + $host = 'reactphp.org'; + $parts = parse_url($uri); + + $builder = new HappyEyeBallsConnectionBuilder($loop, $connector, $resolver, $uri, $host, $parts); + + $builder->connect(); + $deferred->resolve(['::1']); + } + + public function testConnectWillRejectWhenOnlyTcp6ConnectionRejectsAndCancelNextAttemptTimerImmediately() + { + $timer = $this->createMock(TimerInterface::class); + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->once())->method('addTimer')->with(0.1, $this->anything())->willReturn($timer); + $loop->expects($this->once())->method('cancelTimer')->with($timer); + + $deferred = new Deferred(); + $connector = $this->createMock(ConnectorInterface::class); + $connector->expects($this->once())->method('connect')->with('tcp://[::1]:80?hostname=reactphp.org')->willReturn($deferred->promise()); + + $resolver = $this->createMock(ResolverInterface::class); + $resolver->expects($this->exactly(2))->method('resolveAll')->withConsecutive( + ['reactphp.org', Message::TYPE_AAAA], + ['reactphp.org', Message::TYPE_A] + )->willReturnOnConsecutiveCalls( + resolve(['::1']), + reject(new \RuntimeException('DNS failed')) + ); + + $uri = 'tcp://reactphp.org:80'; + $host = 'reactphp.org'; + $parts = parse_url($uri); + + $builder = new HappyEyeBallsConnectionBuilder($loop, $connector, $resolver, $uri, $host, $parts); + + $promise = $builder->connect(); + $deferred->reject(new \RuntimeException( + 'Connection refused (ECONNREFUSED)', + defined('SOCKET_ECONNREFUSED') ? SOCKET_ECONNREFUSED : 111 + )); + + $exception = null; + $promise->then(null, function ($e) use (&$exception) { + $exception = $e; + }); + + $this->assertInstanceOf(\RuntimeException::class, $exception); + assert($exception instanceof \RuntimeException); + + $this->assertEquals('Connection to tcp://reactphp.org:80 failed: Last error for IPv6: Connection refused (ECONNREFUSED). Previous error for IPv4: DNS failed', $exception->getMessage()); + $this->assertEquals(defined('SOCKET_ECONNREFUSED') ? SOCKET_ECONNREFUSED : 111, $exception->getCode()); + $this->assertInstanceOf(\RuntimeException::class, $exception->getPrevious()); + } + + public function testConnectWillRejectWhenOnlyTcp4ConnectionRejectsAndWillNeverStartNextAttemptTimer() + { + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->never())->method('addTimer'); + + $deferred = new Deferred(); + $connector = $this->createMock(ConnectorInterface::class); + $connector->expects($this->once())->method('connect')->with('tcp://127.0.0.1:80?hostname=reactphp.org')->willReturn($deferred->promise()); + + $resolver = $this->createMock(ResolverInterface::class); + $resolver->expects($this->exactly(2))->method('resolveAll')->withConsecutive( + ['reactphp.org', Message::TYPE_AAAA], + ['reactphp.org', Message::TYPE_A] + )->willReturnOnConsecutiveCalls( + reject(new \RuntimeException('DNS failed')), + resolve(['127.0.0.1']) + ); + + $uri = 'tcp://reactphp.org:80'; + $host = 'reactphp.org'; + $parts = parse_url($uri); + + $builder = new HappyEyeBallsConnectionBuilder($loop, $connector, $resolver, $uri, $host, $parts); + + $promise = $builder->connect(); + $deferred->reject(new \RuntimeException( + 'Connection refused (ECONNREFUSED)', + defined('SOCKET_ECONNREFUSED') ? SOCKET_ECONNREFUSED : 111 + )); + + $exception = null; + $promise->then(null, function ($e) use (&$exception) { + $exception = $e; + }); + + $this->assertInstanceOf(\RuntimeException::class, $exception); + assert($exception instanceof \RuntimeException); + + $this->assertEquals('Connection to tcp://reactphp.org:80 failed: Last error for IPv4: Connection refused (ECONNREFUSED). Previous error for IPv6: DNS failed', $exception->getMessage()); + $this->assertEquals(defined('SOCKET_ECONNREFUSED') ? SOCKET_ECONNREFUSED : 111, $exception->getCode()); + $this->assertInstanceOf(\RuntimeException::class, $exception->getPrevious()); + } + + public function testConnectWillRejectWhenAllConnectionsRejectAndCancelNextAttemptTimerImmediately() + { + $timer = $this->createMock(TimerInterface::class); + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->once())->method('addTimer')->with(0.1, $this->anything())->willReturn($timer); + $loop->expects($this->once())->method('cancelTimer')->with($timer); + + $deferred = new Deferred(); + $connector = $this->createMock(ConnectorInterface::class); + $connector->expects($this->exactly(2))->method('connect')->willReturn($deferred->promise()); + + $resolver = $this->createMock(ResolverInterface::class); + $resolver->expects($this->exactly(2))->method('resolveAll')->withConsecutive( + ['reactphp.org', Message::TYPE_AAAA], + ['reactphp.org', Message::TYPE_A] + )->willReturnOnConsecutiveCalls( + resolve(['::1']), + resolve(['127.0.0.1']) + ); + + $uri = 'tcp://reactphp.org:80'; + $host = 'reactphp.org'; + $parts = parse_url($uri); + + $builder = new HappyEyeBallsConnectionBuilder($loop, $connector, $resolver, $uri, $host, $parts); + + $promise = $builder->connect(); + $deferred->reject(new \RuntimeException( + 'Connection refused (ECONNREFUSED)', + defined('SOCKET_ECONNREFUSED') ? SOCKET_ECONNREFUSED : 111 + )); + + $exception = null; + $promise->then(null, function ($e) use (&$exception) { + $exception = $e; + }); + + $this->assertInstanceOf(\RuntimeException::class, $exception); + assert($exception instanceof \RuntimeException); + + $this->assertEquals('Connection to tcp://reactphp.org:80 failed: Connection refused (ECONNREFUSED)', $exception->getMessage()); + $this->assertEquals(defined('SOCKET_ECONNREFUSED') ? SOCKET_ECONNREFUSED : 111, $exception->getCode()); + $this->assertInstanceOf(\RuntimeException::class, $exception->getPrevious()); + } + + public function testConnectWillRejectWithMessageWithoutHostnameWhenAllConnectionsRejectAndCancelNextAttemptTimerImmediately() + { + $timer = $this->createMock(TimerInterface::class); + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->once())->method('addTimer')->with(0.1, $this->anything())->willReturn($timer); + $loop->expects($this->once())->method('cancelTimer')->with($timer); + + $deferred = new Deferred(); + $connector = $this->createMock(ConnectorInterface::class); + $connector->expects($this->exactly(2))->method('connect')->willReturnOnConsecutiveCalls( + $deferred->promise(), + reject(new \RuntimeException( + 'Connection to tcp://127.0.0.1:80?hostname=localhost failed: Connection refused (ECONNREFUSED)', + defined('SOCKET_ECONNREFUSED') ? SOCKET_ECONNREFUSED : 111 + )) + ); + + $resolver = $this->createMock(ResolverInterface::class); + $resolver->expects($this->exactly(2))->method('resolveAll')->withConsecutive( + ['localhost', Message::TYPE_AAAA], + ['localhost', Message::TYPE_A] + )->willReturnOnConsecutiveCalls( + resolve(['::1']), + resolve(['127.0.0.1']) + ); + + $uri = 'tcp://localhost:80'; + $host = 'localhost'; + $parts = parse_url($uri); + + $builder = new HappyEyeBallsConnectionBuilder($loop, $connector, $resolver, $uri, $host, $parts); + + $promise = $builder->connect(); + $deferred->reject(new \RuntimeException( + 'Connection to tcp://[::1]:80?hostname=localhost failed: Connection refused (ECONNREFUSED)', + defined('SOCKET_ECONNREFUSED') ? SOCKET_ECONNREFUSED : 111 + )); + + $exception = null; + $promise->then(null, function ($e) use (&$exception) { + $exception = $e; + }); + + $this->assertInstanceOf(\RuntimeException::class, $exception); + assert($exception instanceof \RuntimeException); + + $this->assertEquals('Connection to tcp://localhost:80 failed: Last error for IPv4: Connection to tcp://127.0.0.1:80 failed: Connection refused (ECONNREFUSED). Previous error for IPv6: Connection to tcp://[::1]:80 failed: Connection refused (ECONNREFUSED)', $exception->getMessage()); + $this->assertEquals(defined('SOCKET_ECONNREFUSED') ? SOCKET_ECONNREFUSED : 111, $exception->getCode()); + $this->assertInstanceOf(\RuntimeException::class, $exception->getPrevious()); + } + + public function testCancelConnectWillRejectPromiseAndCancelBothDnsLookups() + { + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->never())->method('addTimer'); + + $connector = $this->createMock(ConnectorInterface::class); + $connector->expects($this->never())->method('connect'); + + $cancelled = 0; + $resolver = $this->createMock(ResolverInterface::class); + $resolver->expects($this->exactly(2))->method('resolveAll')->withConsecutive( + ['reactphp.org', Message::TYPE_AAAA], + ['reactphp.org', Message::TYPE_A] + )->willReturnOnConsecutiveCalls( + new Promise(function () { }, function () use (&$cancelled) { + ++$cancelled; + throw new \RuntimeException(); + }), + new Promise(function () { }, function () use (&$cancelled) { + ++$cancelled; + throw new \RuntimeException(); + }) + ); + + $uri = 'tcp://reactphp.org:80'; + $host = 'reactphp.org'; + $parts = parse_url($uri); + + $builder = new HappyEyeBallsConnectionBuilder($loop, $connector, $resolver, $uri, $host, $parts); + + $promise = $builder->connect(); + $promise->cancel(); + + $this->assertEquals(2, $cancelled); + + $exception = null; + $promise->then(null, function ($e) use (&$exception) { + $exception = $e; + }); + + $this->assertInstanceOf(\RuntimeException::class, $exception); + assert($exception instanceof \RuntimeException); + + $this->assertEquals('Connection to tcp://reactphp.org:80 cancelled during DNS lookup (ECONNABORTED)', $exception->getMessage()); + $this->assertEquals(defined('SOCKET_ECONNABORTED') ? SOCKET_ECONNABORTED : 103, $exception->getCode()); + } + + public function testCancelConnectWillRejectPromiseAndCancelPendingIpv6LookupAndCancelDelayTimer() + { + $timer = $this->createMock(TimerInterface::class); + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->once())->method('addTimer')->willReturn($timer); + $loop->expects($this->once())->method('cancelTimer')->with($timer); + + $connector = $this->createMock(ConnectorInterface::class); + $connector->expects($this->never())->method('connect'); + + $resolver = $this->createMock(ResolverInterface::class); + $resolver->expects($this->exactly(2))->method('resolveAll')->withConsecutive( + ['reactphp.org', Message::TYPE_AAAA], + ['reactphp.org', Message::TYPE_A] + )->willReturnOnConsecutiveCalls( + new Promise(function () { }, function () { + throw new \RuntimeException('DNS cancelled'); + }), + resolve(['127.0.0.1']) + ); + + $uri = 'tcp://reactphp.org:80'; + $host = 'reactphp.org'; + $parts = parse_url($uri); + + $builder = new HappyEyeBallsConnectionBuilder($loop, $connector, $resolver, $uri, $host, $parts); + + $promise = $builder->connect(); + $promise->cancel(); + + $exception = null; + $promise->then(null, function ($e) use (&$exception) { + $exception = $e; + }); + + $this->assertInstanceOf(\RuntimeException::class, $exception); + assert($exception instanceof \RuntimeException); + + $this->assertEquals('Connection to tcp://reactphp.org:80 cancelled during DNS lookup (ECONNABORTED)', $exception->getMessage()); + $this->assertEquals(defined('SOCKET_ECONNABORTED') ? SOCKET_ECONNABORTED : 103, $exception->getCode()); + } + + public function testCancelConnectWillRejectPromiseAndCancelPendingIpv6ConnectionAttemptAndPendingIpv4LookupAndCancelAttemptTimer() + { + $timer = $this->createMock(TimerInterface::class); + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->once())->method('addTimer')->with(0.1, $this->anything())->willReturn($timer); + $loop->expects($this->once())->method('cancelTimer')->with($timer); + + $cancelled = 0; + $connector = $this->createMock(ConnectorInterface::class); + $connector->expects($this->once())->method('connect')->with('tcp://[::1]:80?hostname=reactphp.org')->willReturn(new Promise(function () { }, function () use (&$cancelled) { + ++$cancelled; + throw new \RuntimeException('Ignored message'); + })); + + $resolver = $this->createMock(ResolverInterface::class); + $resolver->expects($this->exactly(2))->method('resolveAll')->withConsecutive( + ['reactphp.org', Message::TYPE_AAAA], + ['reactphp.org', Message::TYPE_A] + )->willReturnOnConsecutiveCalls( + resolve(['::1']), + new Promise(function () { }, $this->expectCallableOnce()) + ); + + $uri = 'tcp://reactphp.org:80'; + $host = 'reactphp.org'; + $parts = parse_url($uri); + + $builder = new HappyEyeBallsConnectionBuilder($loop, $connector, $resolver, $uri, $host, $parts); + + $promise = $builder->connect(); + $promise->cancel(); + + $this->assertEquals(1, $cancelled); + + $exception = null; + $promise->then(null, function ($e) use (&$exception) { + $exception = $e; + }); + + $this->assertInstanceOf(\RuntimeException::class, $exception); + assert($exception instanceof \RuntimeException); + + $this->assertEquals('Connection to tcp://reactphp.org:80 cancelled (ECONNABORTED)', $exception->getMessage()); + $this->assertEquals(defined('SOCKET_ECONNABORTED') ? SOCKET_ECONNABORTED : 103, $exception->getCode()); + } + + public function testResolveWillReturnResolvedPromiseWithEmptyListWhenDnsResolverFails() + { + $loop = $this->createMock(LoopInterface::class); + + $connector = $this->createMock(ConnectorInterface::class); + + $resolver = $this->createMock(ResolverInterface::class); + $resolver->expects($this->once())->method('resolveAll')->with('reactphp.org', Message::TYPE_A)->willReturn(reject(new \RuntimeException())); + + $uri = 'tcp://reactphp.org:80'; + $host = 'reactphp.org'; + $parts = parse_url($uri); + + $builder = new HappyEyeBallsConnectionBuilder($loop, $connector, $resolver, $uri, $host, $parts); + + $promise = $builder->resolve(Message::TYPE_A, $this->expectCallableNever()); + + $this->assertInstanceof(PromiseInterface::class, $promise); + $promise->then($this->expectCallableOnceWith([]), $this->expectCallableNever()); + } + + public function testAttemptConnectionWillConnectViaConnectorToGivenIpWithPortAndHostnameFromUriParts() + { + $loop = $this->createMock(LoopInterface::class); + + $connector = $this->createMock(ConnectorInterface::class); + $connector->expects($this->once())->method('connect')->with('tcp://10.1.1.1:80?hostname=reactphp.org')->willReturn(new Promise(function () { })); + + $resolver = $this->createMock(ResolverInterface::class); + $resolver->expects($this->never())->method('resolveAll'); + + $uri = 'tcp://reactphp.org:80'; + $host = 'reactphp.org'; + $parts = parse_url($uri); + + $builder = new HappyEyeBallsConnectionBuilder($loop, $connector, $resolver, $uri, $host, $parts); + + $builder->attemptConnection('10.1.1.1'); + } + + public function testAttemptConnectionWillConnectViaConnectorToGivenIpv6WithAllUriParts() + { + $loop = $this->createMock(LoopInterface::class); + + $connector = $this->createMock(ConnectorInterface::class); + $connector->expects($this->once())->method('connect')->with('tcp://[::1]:80/path?test=yes&hostname=reactphp.org#start')->willReturn(new Promise(function () { })); + + $resolver = $this->createMock(ResolverInterface::class); + $resolver->expects($this->never())->method('resolveAll'); + + $uri = 'tcp://reactphp.org:80/path?test=yes#start'; + $host = 'reactphp.org'; + $parts = parse_url($uri); + + $builder = new HappyEyeBallsConnectionBuilder($loop, $connector, $resolver, $uri, $host, $parts); + + $builder->attemptConnection('::1'); + } + + public function testCheckCallsRejectFunctionImmediateWithoutLeavingDanglingPromiseWhenConnectorRejectsImmediately() + { + $loop = $this->createMock(LoopInterface::class); + + $connector = $this->createMock(ConnectorInterface::class); + $connector->expects($this->once())->method('connect')->with('tcp://[::1]:80/path?test=yes&hostname=reactphp.org#start')->willReturn(reject(new \RuntimeException())); + + $resolver = $this->createMock(ResolverInterface::class); + $resolver->expects($this->never())->method('resolveAll'); + + $uri = 'tcp://reactphp.org:80/path?test=yes#start'; + $host = 'reactphp.org'; + $parts = parse_url($uri); + + $builder = new HappyEyeBallsConnectionBuilder($loop, $connector, $resolver, $uri, $host, $parts); + + $ref = new \ReflectionProperty($builder, 'connectQueue'); + if (\PHP_VERSION_ID < 80100) { + $ref->setAccessible(true); + } + $ref->setValue($builder, ['::1']); + + $builder->check($this->expectCallableNever(), function () { }); + + $ref = new \ReflectionProperty($builder, 'connectionPromises'); + if (\PHP_VERSION_ID < 80100) { + $ref->setAccessible(true); + } + $promises = $ref->getValue($builder); + + $this->assertEquals([], $promises); + } + + public function testCleanUpCancelsAllPendingConnectionAttempts() + { + $loop = $this->createMock(LoopInterface::class); + + $connector = $this->createMock(ConnectorInterface::class); + $connector->expects($this->exactly(2))->method('connect')->with('tcp://[::1]:80/path?test=yes&hostname=reactphp.org#start')->willReturnOnConsecutiveCalls( + new Promise(function () { }, $this->expectCallableOnce()), + new Promise(function () { }, $this->expectCallableOnce()) + ); + + $resolver = $this->createMock(ResolverInterface::class); + $resolver->expects($this->never())->method('resolveAll'); + + $uri = 'tcp://reactphp.org:80/path?test=yes#start'; + $host = 'reactphp.org'; + $parts = parse_url($uri); + + $builder = new HappyEyeBallsConnectionBuilder($loop, $connector, $resolver, $uri, $host, $parts); + + $ref = new \ReflectionProperty($builder, 'connectQueue'); + if (\PHP_VERSION_ID < 80100) { + $ref->setAccessible(true); + } + $ref->setValue($builder, ['::1', '::1']); + + $builder->check($this->expectCallableNever(), function () { }); + $builder->check($this->expectCallableNever(), function () { }); + + $builder->cleanUp(); + } + + public function testCleanUpCancelsAllPendingConnectionAttemptsWithoutStartingNewAttemptsDueToCancellationRejection() + { + $loop = $this->createMock(LoopInterface::class); + + $connector = $this->createMock(ConnectorInterface::class); + $connector->expects($this->once())->method('connect')->with('tcp://[::1]:80/path?test=yes&hostname=reactphp.org#start')->willReturn(new Promise(function () { }, function () { + throw new \RuntimeException(); + })); + + $resolver = $this->createMock(ResolverInterface::class); + $resolver->expects($this->never())->method('resolveAll'); + + $uri = 'tcp://reactphp.org:80/path?test=yes#start'; + $host = 'reactphp.org'; + $parts = parse_url($uri); + + $builder = new HappyEyeBallsConnectionBuilder($loop, $connector, $resolver, $uri, $host, $parts); + + $ref = new \ReflectionProperty($builder, 'connectQueue'); + if (\PHP_VERSION_ID < 80100) { + $ref->setAccessible(true); + } + $ref->setValue($builder, ['::1', '::1']); + + $builder->check($this->expectCallableNever(), function () { }); + + $builder->cleanUp(); + } + + public function testMixIpsIntoConnectQueueSometimesAssignsInOriginalOrder() + { + $loop = $this->createMock(LoopInterface::class); + $connector = $this->createMock(ConnectorInterface::class); + $resolver = $this->createMock(ResolverInterface::class); + + $uri = 'tcp://reactphp.org:80/path?test=yes#start'; + $host = 'reactphp.org'; + $parts = parse_url($uri); + + for ($i = 0; $i < 100; ++$i) { + $builder = new HappyEyeBallsConnectionBuilder($loop, $connector, $resolver, $uri, $host, $parts); + $builder->mixIpsIntoConnectQueue(['::1', '::2']); + + $ref = new \ReflectionProperty($builder, 'connectQueue'); + if (\PHP_VERSION_ID < 80100) { + $ref->setAccessible(true); + } + $value = $ref->getValue($builder); + + if ($value === ['::1', '::2']) { + break; + } + } + + $this->assertEquals(['::1', '::2'], $value); + } + + public function testMixIpsIntoConnectQueueSometimesAssignsInReverseOrder() + { + $loop = $this->createMock(LoopInterface::class); + $connector = $this->createMock(ConnectorInterface::class); + $resolver = $this->createMock(ResolverInterface::class); + + $uri = 'tcp://reactphp.org:80/path?test=yes#start'; + $host = 'reactphp.org'; + $parts = parse_url($uri); + + for ($i = 0; $i < 100; ++$i) { + $builder = new HappyEyeBallsConnectionBuilder($loop, $connector, $resolver, $uri, $host, $parts); + $builder->mixIpsIntoConnectQueue(['::1', '::2']); + + $ref = new \ReflectionProperty($builder, 'connectQueue'); + if (\PHP_VERSION_ID < 80100) { + $ref->setAccessible(true); + } + $value = $ref->getValue($builder); + + if ($value === ['::2', '::1']) { + break; + } + } + + $this->assertEquals(['::2', '::1'], $value); + } +} diff --git a/tests/HappyEyeBallsConnectorTest.php b/tests/HappyEyeBallsConnectorTest.php new file mode 100644 index 00000000..9310974c --- /dev/null +++ b/tests/HappyEyeBallsConnectorTest.php @@ -0,0 +1,348 @@ +loop = new TimerSpeedUpEventLoop(new StreamSelectLoop()); + $this->tcp = $this->createMock(ConnectorInterface::class); + $this->resolver = $this->createMock(ResolverInterface::class); + $this->connection = $this->createMock(ConnectionInterface::class); + + $this->connector = new HappyEyeBallsConnector($this->loop, $this->tcp, $this->resolver); + } + + public function testConstructWithoutLoopAssignsLoopAutomatically() + { + $connector = new HappyEyeBallsConnector(null, $this->tcp, $this->resolver); + + $ref = new \ReflectionProperty($connector, 'loop'); + if (\PHP_VERSION_ID < 80100) { + $ref->setAccessible(true); + } + $loop = $ref->getValue($connector); + + $this->assertInstanceOf(LoopInterface::class, $loop); + } + + public function testHappyFlow() + { + $first = new Deferred(); + $this->resolver->expects($this->exactly(2))->method('resolveAll')->with('example.com', $this->anything())->willReturn($first->promise()); + $connection = $this->createMock(ConnectionInterface::class); + $this->tcp->expects($this->exactly(1))->method('connect')->with('1.2.3.4:80?hostname=example.com')->willReturn(resolve($connection)); + + $promise = $this->connector->connect('example.com:80'); + $first->resolve(['1.2.3.4']); + + $resolvedConnection = null; + $promise->then(function ($value) use (&$resolvedConnection) { + $resolvedConnection = $value; + }); + + self::assertSame($connection, $resolvedConnection); + } + + public function testThatAnyOtherPendingConnectionAttemptsWillBeCanceledOnceAConnectionHasBeenEstablished() + { + $connection = $this->createMock(ConnectionInterface::class); + $lookupAttempts = [ + reject(new \Exception('error')), + resolve(['1.2.3.4', '5.6.7.8', '9.10.11.12']), + ]; + $connectionAttempts = [ + new Promise(function () {}, $this->expectCallableOnce()), + resolve($connection), + new Promise(function () {}, $this->expectCallableNever()), + ]; + $this->resolver->expects($this->exactly(2))->method('resolveAll')->with('example.com', $this->anything())->willReturnCallback(function () use (&$lookupAttempts) { + return array_shift($lookupAttempts); + }); + $this->tcp->expects($this->exactly(2))->method('connect')->with($this->isType('string'))->willReturnCallback(function () use (&$connectionAttempts) { + return array_shift($connectionAttempts); + }); + + $promise = $this->connector->connect('example.com:80'); + + $this->loop->run(); + $resolvedConnection = null; + $promise->then(function ($value) use (&$resolvedConnection) { + $resolvedConnection = $value; + }); + + self::assertSame($connection, $resolvedConnection); + } + + public function testPassByResolverIfGivenIp() + { + $this->resolver->expects($this->never())->method('resolveAll'); + $this->tcp->expects($this->once())->method('connect')->with('127.0.0.1:80')->willReturn(resolve(null)); + + $this->connector->connect('127.0.0.1:80'); + + $this->loop->run(); + } + + public function testPassByResolverIfGivenIpv6() + { + $this->resolver->expects($this->never())->method('resolveAll'); + $this->tcp->expects($this->once())->method('connect')->with('[::1]:80')->willReturn(reject(new \Exception('reject'))); + + $promise = $this->connector->connect('[::1]:80'); + + $promise->then(null, $this->expectCallableOnce()); // avoid reporting unhandled rejection + + $this->loop->run(); + } + + public function testPassThroughResolverIfGivenHost() + { + $this->resolver->expects($this->exactly(2))->method('resolveAll')->with('google.com', $this->anything())->willReturn(resolve(['1.2.3.4'])); + $this->tcp->expects($this->exactly(2))->method('connect')->with('1.2.3.4:80?hostname=google.com')->willReturn(reject(new \Exception('reject'))); + + $promise = $this->connector->connect('google.com:80'); + + $promise->then(null, $this->expectCallableOnce()); // avoid reporting unhandled rejection + + $this->loop->run(); + } + + public function testPassThroughResolverIfGivenHostWhichResolvesToIpv6() + { + $this->resolver->expects($this->exactly(2))->method('resolveAll')->with('google.com', $this->anything())->willReturn(resolve(['::1'])); + $this->tcp->expects($this->exactly(2))->method('connect')->with('[::1]:80?hostname=google.com')->willReturn(reject(new \Exception('reject'))); + + $promise = $this->connector->connect('google.com:80'); + + $promise->then(null, $this->expectCallableOnce()); // avoid reporting unhandled rejection + + $this->loop->run(); + } + + public function testPassByResolverIfGivenCompleteUri() + { + $this->resolver->expects($this->never())->method('resolveAll'); + $this->tcp->expects($this->once())->method('connect')->with('scheme://127.0.0.1:80/path?query#fragment')->willReturn(reject(new \Exception('reject'))); + + $promise = $this->connector->connect('scheme://127.0.0.1:80/path?query#fragment'); + + $promise->then(null, $this->expectCallableOnce()); // avoid reporting unhandled rejection + + $this->loop->run(); + } + + public function testPassThroughResolverIfGivenCompleteUri() + { + $this->resolver->expects($this->exactly(2))->method('resolveAll')->with('google.com', $this->anything())->willReturn(resolve(['1.2.3.4'])); + $this->tcp->expects($this->exactly(2))->method('connect')->with('scheme://1.2.3.4:80/path?query&hostname=google.com#fragment')->willReturn(reject(new \Exception('reject'))); + + $promise = $this->connector->connect('scheme://google.com:80/path?query#fragment'); + + $promise->then(null, $this->expectCallableOnce()); // avoid reporting unhandled rejection + + $this->loop->run(); + } + + public function testPassThroughResolverIfGivenExplicitHost() + { + $this->resolver->expects($this->exactly(2))->method('resolveAll')->with('google.com', $this->anything())->willReturn(resolve(['1.2.3.4'])); + $this->tcp->expects($this->exactly(2))->method('connect')->with('scheme://1.2.3.4:80/?hostname=google.de')->willReturn(reject(new \Exception('reject'))); + + $promise = $this->connector->connect('scheme://google.com:80/?hostname=google.de'); + + $promise->then(null, $this->expectCallableOnce()); // avoid reporting unhandled rejection + + $this->loop->run(); + } + + /** + * @dataProvider provideIpvAddresses + */ + public function testIpv6ResolvesFirstSoIsTheFirstToConnect(array $ipv6, array $ipv4) + { + $deferred = new Deferred(); + + $this->resolver->expects($this->exactly(2))->method('resolveAll')->withConsecutive( + ['google.com', Message::TYPE_AAAA], + ['google.com', Message::TYPE_A] + )->willReturnOnConsecutiveCalls( + $this->returnValue(resolve($ipv6)), + $this->returnValue($deferred->promise()) + ); + $this->tcp->expects($this->any())->method('connect')->with($this->stringContains(']:80/?hostname=google.com'))->willReturn(reject(new \Exception('reject'))); + + $this->connector->connect('scheme://google.com:80/?hostname=google.com'); + + $this->loop->addTimer(0.07, function () use ($deferred) { + $deferred->reject(new \RuntimeException()); + }); + + $this->loop->run(); + } + + /** + * @dataProvider provideIpvAddresses + */ + public function testIpv6DoesntResolvesWhileIpv4DoesFirstSoIpv4Connects(array $ipv6, array $ipv4) + { + $deferred = new Deferred(); + + $this->resolver->expects($this->exactly(2))->method('resolveAll')->withConsecutive( + ['google.com', Message::TYPE_AAAA], + ['google.com', Message::TYPE_A] + )->willReturnOnConsecutiveCalls( + $this->returnValue($deferred->promise()), + $this->returnValue(resolve($ipv4)) + ); + $this->tcp->expects($this->any())->method('connect')->with($this->stringContains(':80/?hostname=google.com'))->willReturn(reject(new \Exception('reject'))); + + $this->connector->connect('scheme://google.com:80/?hostname=google.com'); + + $this->loop->addTimer(0.07, function () use ($deferred) { + $deferred->reject(new \RuntimeException()); + }); + + $this->loop->run(); + } + + public function testRejectsImmediatelyIfUriIsInvalid() + { + $this->resolver->expects($this->never())->method('resolveAll'); + $this->tcp->expects($this->never())->method('connect'); + + $promise = $this->connector->connect('////'); + + $promise->then(null, $this->expectCallableOnceWithException( + \InvalidArgumentException::class, + 'Given URI "////" is invalid (EINVAL)', + defined('SOCKET_EINVAL') ? SOCKET_EINVAL : (defined('PCNTL_EINVAL') ? PCNTL_EINVAL : 22) + )); + } + + public function testRejectsWithTcpConnectorRejectionIfGivenIp() + { + $promise = reject(new \RuntimeException('Connection failed')); + $this->resolver->expects($this->never())->method('resolveAll'); + $this->tcp->expects($this->once())->method('connect')->with('1.2.3.4:80')->willReturn($promise); + + $promise = $this->connector->connect('1.2.3.4:80'); + $this->loop->addTimer(0.5, function () use ($promise) { + $promise->cancel(); + + $this->throwRejection($promise); + }); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Connection failed'); + $this->loop->run(); + } + + public function testSkipConnectionIfDnsFails() + { + $this->resolver->expects($this->exactly(2))->method('resolveAll')->with('example.invalid', $this->anything())->willReturn(reject(new \RuntimeException('DNS error'))); + $this->tcp->expects($this->never())->method('connect'); + + $promise = $this->connector->connect('example.invalid:80'); + + $this->loop->addTimer(0.5, function () use ($promise) { + $this->throwRejection($promise); + }); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Connection to tcp://example.invalid:80 failed during DNS lookup: DNS error'); + $this->loop->run(); + } + + public function testCancelDuringDnsCancelsDnsAndDoesNotStartTcpConnection() + { + $this->resolver->expects($this->exactly(2))->method('resolveAll')->with('example.com', $this->anything())->willReturnCallback(function () { + return new Promise(function () { }, $this->expectCallableExactly(1)); + }); + $this->tcp->expects($this->never())->method('connect'); + + $promise = $this->connector->connect('example.com:80'); + $this->loop->addTimer(0.05, function () use ($promise) { + $promise->cancel(); + + $this->throwRejection($promise); + }); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Connection to tcp://example.com:80 cancelled during DNS lookup (ECONNABORTED)'); + $this->expectExceptionCode(\defined('SOCKET_ECONNABORTED') ? \SOCKET_ECONNABORTED : 103); + $this->loop->run(); + } + + public function testCancelDuringTcpConnectionCancelsTcpConnectionIfGivenIp() + { + $pending = new Promise(function () { }, $this->expectCallableOnce()); + $this->resolver->expects($this->never())->method('resolveAll'); + $this->tcp->expects($this->once())->method('connect')->with('1.2.3.4:80')->willReturn($pending); + + $promise = $this->connector->connect('1.2.3.4:80'); + $this->loop->addTimer(0.1, function () use ($promise) { + $promise->cancel(); + }); + + $this->loop->run(); + } + + /** + * @internal + */ + public function throwRejection($promise) + { + $ex = null; + $promise->then(null, function ($e) use (&$ex) { + $ex = $e; + }); + + throw $ex; + } + + public static function provideIpvAddresses() + { + $ipv6 = [ + ['1:2:3:4'], + ['1:2:3:4', '5:6:7:8'], + ['1:2:3:4', '5:6:7:8', '9:10:11:12'], + ]; + $ipv4 = [ + ['1.2.3.4'], + ['1.2.3.4', '5.6.7.8'], + ['1.2.3.4', '5.6.7.8', '9.10.11.12'] + ]; + + foreach ($ipv6 as $v6) { + foreach ($ipv4 as $v4) { + yield [ + $v6, + $v4 + ]; + } + } + } +} diff --git a/tests/IntegrationTest.php b/tests/IntegrationTest.php new file mode 100644 index 00000000..361443e1 --- /dev/null +++ b/tests/IntegrationTest.php @@ -0,0 +1,399 @@ +connect('google.com:80')); + assert($conn instanceof ConnectionInterface); + + $this->assertStringContainsString(':80', $conn->getRemoteAddress()); + $this->assertNotEquals('google.com:80', $conn->getRemoteAddress()); + + $conn->write("GET / HTTP/1.0\r\n\r\n"); + + $response = $this->buffer($conn, self::TIMEOUT); + assert(!$conn->isReadable()); + + $this->assertStringMatchesFormat('HTTP/1.0%a', $response); + } + + /** @test */ + public function gettingEncryptedStuffFromGoogleShouldWork() + { + $secureConnector = new Connector([]); + + $conn = await($secureConnector->connect('tls://google.com:443')); + assert($conn instanceof ConnectionInterface); + + $conn->write("GET / HTTP/1.0\r\n\r\n"); + + $response = $this->buffer($conn, self::TIMEOUT); + assert(!$conn->isReadable()); + + $this->assertStringMatchesFormat('HTTP/1.0%a', $response); + } + + /** @test */ + public function gettingEncryptedStuffFromGoogleShouldWorkIfHostIsResolvedFirst() + { + $factory = new ResolverFactory(); + $dns = $factory->create('8.8.8.8'); + + $connector = new DnsConnector( + new SecureConnector( + new TcpConnector() + ), + $dns + ); + + $conn = await($connector->connect('google.com:443')); + assert($conn instanceof ConnectionInterface); + + $conn->write("GET / HTTP/1.0\r\n\r\n"); + + $response = $this->buffer($conn, self::TIMEOUT); + assert(!$conn->isReadable()); + + $this->assertStringMatchesFormat('HTTP/1.0%a', $response); + } + + /** @test */ + public function gettingPlaintextStuffFromEncryptedGoogleShouldNotWork() + { + $connector = new Connector([]); + + $conn = await($connector->connect('google.com:443')); + assert($conn instanceof ConnectionInterface); + + $this->assertStringContainsString(':443', $conn->getRemoteAddress()); + $this->assertNotEquals('google.com:443', $conn->getRemoteAddress()); + + $conn->write("GET / HTTP/1.0\r\n\r\n"); + + $response = $this->buffer($conn, self::TIMEOUT); + assert(!$conn->isReadable()); + + $this->assertStringNotMatchesFormat('HTTP/1.0%a', $response); + } + + public function testConnectingFailsIfConnectorUsesInvalidDnsResolverAddress() + { + if (PHP_OS === 'Darwin') { + $this->markTestSkipped('Skipped on macOS due to a bug in reactphp/dns (solved in reactphp/dns#171)'); + } + + $factory = new ResolverFactory(); + $dns = $factory->create('255.255.255.255'); + + $connector = new Connector([ + 'dns' => $dns + ]); + + $this->expectException(\RuntimeException::class); + await(timeout($connector->connect('google.com:80'), self::TIMEOUT)); + } + + public function testCancellingPendingConnectionWithoutTimeoutShouldNotCreateAnyGarbageReferences() + { + if (class_exists('React\Promise\When')) { + $this->markTestSkipped('Not supported on legacy Promise v1 API'); + } + + $connector = new Connector(['timeout' => false]); + + while (gc_collect_cycles()) { + // collect all garbage cycles + } + + $promise = $connector->connect('8.8.8.8:80'); + $promise->cancel(); + unset($promise); + + $this->assertEquals(0, gc_collect_cycles()); + } + + public function testCancellingPendingConnectionShouldNotCreateAnyGarbageReferences() + { + if (class_exists('React\Promise\When')) { + $this->markTestSkipped('Not supported on legacy Promise v1 API'); + } + + $connector = new Connector([]); + + while (gc_collect_cycles()) { + // collect all garbage cycles + } + + $promise = $connector->connect('8.8.8.8:80'); + $promise->cancel(); + unset($promise); + + $this->assertEquals(0, gc_collect_cycles()); + } + + public function testWaitingForRejectedConnectionShouldNotCreateAnyGarbageReferences() + { + if (class_exists('React\Promise\When')) { + $this->markTestSkipped('Not supported on legacy Promise v1 API'); + } + + // let loop tick for reactphp/async v4 to clean up any remaining stream resources + // @link https://github.com/reactphp/async/pull/65 reported upstream // TODO remove me once merged + if (function_exists('React\Async\async')) { + await(sleep(0)); + Loop::run(); + } + + $connector = new Connector(['timeout' => false]); + + while (gc_collect_cycles()) { + // collect all garbage cycles + } + + $wait = true; + $promise = $connector->connect('127.0.0.1:1')->then( + null, + function ($e) use (&$wait) { + $wait = false; + } + ); + + // run loop for short period to ensure we detect connection refused error + await(sleep(0.01)); + if ($wait) { + await(sleep(0.2)); + if ($wait) { + await(sleep(2.0)); + if ($wait) { + $this->fail('Connection attempt did not fail'); + } + } + } + unset($promise); + + $this->assertEquals(0, gc_collect_cycles()); + } + + public function testWaitingForConnectionTimeoutDuringDnsLookupShouldNotCreateAnyGarbageReferences() + { + if (class_exists('React\Promise\When')) { + $this->markTestSkipped('Not supported on legacy Promise v1 API'); + } + + $connector = new Connector(['timeout' => 0.001]); + + while (gc_collect_cycles()) { + // collect all garbage cycles + } + + $wait = true; + $promise = $connector->connect('google.com:80')->then( + null, + function ($e) use (&$wait) { + $wait = false; + } + ); + + // run loop for short period to ensure we detect a connection timeout error + await(sleep(0.01)); + if ($wait) { + await(sleep(0.2)); + if ($wait) { + $this->fail('Connection attempt did not fail'); + } + } + unset($promise); + + $this->assertEquals(0, gc_collect_cycles()); + } + + public function testWaitingForConnectionTimeoutDuringTcpConnectionShouldNotCreateAnyGarbageReferences() + { + if (class_exists('React\Promise\When')) { + $this->markTestSkipped('Not supported on legacy Promise v1 API'); + } + + $connector = new Connector(['timeout' => 0.000001]); + + while (gc_collect_cycles()) { + // collect all garbage cycles + } + + $wait = true; + $promise = $connector->connect('8.8.8.8:53')->then( + null, + function ($e) use (&$wait) { + $wait = false; + } + ); + + // run loop for short period to ensure we detect a connection timeout error + await(sleep(0.01)); + if ($wait) { + await(sleep(0.2)); + if ($wait) { + $this->fail('Connection attempt did not fail'); + } + } + unset($promise); + + $this->assertEquals(0, gc_collect_cycles()); + } + + public function testWaitingForInvalidDnsConnectionShouldNotCreateAnyGarbageReferences() + { + if (class_exists('React\Promise\When')) { + $this->markTestSkipped('Not supported on legacy Promise v1 API'); + } + + $connector = new Connector(['timeout' => false]); + + while (gc_collect_cycles()) { + // collect all garbage cycles + } + + $wait = true; + $promise = $connector->connect('example.invalid:80')->then( + null, + function ($e) use (&$wait) { + $wait = false; + } + ); + + // run loop for short period to ensure we detect a DNS error + await(sleep(0.01)); + if ($wait) { + await(sleep(0.2)); + if ($wait) { + await(sleep(2.0)); + if ($wait) { + $this->fail('Connection attempt did not fail'); + } + } + } + unset($promise); + + $this->assertEquals(0, gc_collect_cycles()); + } + + public function testWaitingForInvalidTlsConnectionShouldNotCreateAnyGarbageReferences() + { + if (class_exists('React\Promise\When')) { + $this->markTestSkipped('Not supported on legacy Promise v1 API'); + } + + $connector = new Connector([ + 'tls' => [ + 'verify_peer' => true + ] + ]); + + while (gc_collect_cycles()) { + // collect all garbage cycles + } + + $wait = true; + $promise = $connector->connect('tls://self-signed.badssl.com:443')->then( + null, + function ($e) use (&$wait) { + $wait = false; + } + ); + + // run loop for short period to ensure we detect a TLS error + await(sleep(0.01)); + if ($wait) { + await(sleep(0.4)); + if ($wait) { + await(sleep(self::TIMEOUT - 0.5)); + if ($wait) { + $this->fail('Connection attempt did not fail'); + } + } + } + unset($promise); + + $this->assertEquals(0, gc_collect_cycles()); + } + + public function testWaitingForSuccessfullyClosedConnectionShouldNotCreateAnyGarbageReferences() + { + if (class_exists('React\Promise\When')) { + $this->markTestSkipped('Not supported on legacy Promise v1 API'); + } + + $connector = new Connector(['timeout' => false]); + + while (gc_collect_cycles()) { + // collect all garbage cycles + } + + $promise = $connector->connect('google.com:80')->then( + function ($conn) { + $conn->close(); + } + ); + await(timeout($promise, self::TIMEOUT)); + unset($promise); + + $this->assertEquals(0, gc_collect_cycles()); + } + + public function testConnectingFailsIfTimeoutIsTooSmall() + { + $connector = new Connector([ + 'timeout' => 0.001 + ]); + + $this->expectException(\RuntimeException::class); + await(timeout($connector->connect('google.com:80'), self::TIMEOUT)); + } + + public function testSelfSignedRejectsIfVerificationIsEnabled() + { + $connector = new Connector([ + 'tls' => [ + 'verify_peer' => true + ] + ]); + + $this->expectException(\RuntimeException::class); + await(timeout($connector->connect('tls://self-signed.badssl.com:443'), self::TIMEOUT)); + } + + public function testSelfSignedResolvesIfVerificationIsDisabled() + { + $connector = new Connector([ + 'tls' => [ + 'verify_peer' => false + ] + ]); + + $conn = await(timeout($connector->connect('tls://self-signed.badssl.com:443'), self::TIMEOUT)); + assert($conn instanceof ConnectionInterface); + $conn->close(); + + // if we reach this, then everything is good + $this->assertNull(null); + } +} diff --git a/tests/LimitingServerTest.php b/tests/LimitingServerTest.php new file mode 100644 index 00000000..96d41986 --- /dev/null +++ b/tests/LimitingServerTest.php @@ -0,0 +1,222 @@ +createMock(ServerInterface::class); + $tcp->expects($this->once())->method('getAddress')->willReturn('127.0.0.1:1234'); + + $server = new LimitingServer($tcp, 100); + + $this->assertEquals('127.0.0.1:1234', $server->getAddress()); + } + + public function testPauseWillBePassedThroughToTcpServer() + { + $tcp = $this->createMock(ServerInterface::class); + $tcp->expects($this->once())->method('pause'); + + $server = new LimitingServer($tcp, 100); + + $server->pause(); + } + + public function testPauseTwiceWillBePassedThroughToTcpServerOnce() + { + $tcp = $this->createMock(ServerInterface::class); + $tcp->expects($this->once())->method('pause'); + + $server = new LimitingServer($tcp, 100); + + $server->pause(); + $server->pause(); + } + + public function testResumeWillBePassedThroughToTcpServer() + { + $tcp = $this->createMock(ServerInterface::class); + $tcp->expects($this->once())->method('resume'); + + $server = new LimitingServer($tcp, 100); + + $server->pause(); + $server->resume(); + } + + public function testResumeTwiceWillBePassedThroughToTcpServerOnce() + { + $tcp = $this->createMock(ServerInterface::class); + $tcp->expects($this->once())->method('resume'); + + $server = new LimitingServer($tcp, 100); + + $server->pause(); + $server->resume(); + $server->resume(); + } + + public function testCloseWillBePassedThroughToTcpServer() + { + $tcp = $this->createMock(ServerInterface::class); + $tcp->expects($this->once())->method('close'); + + $server = new LimitingServer($tcp, 100); + + $server->close(); + } + + public function testSocketErrorWillBeForwarded() + { + $loop = $this->createMock(LoopInterface::class); + + $tcp = new TcpServer(0, $loop); + + $server = new LimitingServer($tcp, 100); + + $server->on('error', $this->expectCallableOnce()); + + $tcp->emit('error', [new \RuntimeException('test')]); + } + + public function testSocketConnectionWillBeForwarded() + { + $connection = $this->createMock(ConnectionInterface::class); + + $loop = $this->createMock(LoopInterface::class); + + $tcp = new TcpServer(0, $loop); + + $server = new LimitingServer($tcp, 100); + $server->on('connection', $this->expectCallableOnceWith($connection)); + $server->on('error', $this->expectCallableNever()); + + $tcp->emit('connection', [$connection]); + + $this->assertEquals([$connection], $server->getConnections()); + } + + public function testSocketConnectionWillBeClosedOnceLimitIsReached() + { + $first = $this->createMock(ConnectionInterface::class); + $first->expects($this->never())->method('close'); + $second = $this->createMock(ConnectionInterface::class); + $second->expects($this->once())->method('close'); + + $loop = $this->createMock(LoopInterface::class); + + $tcp = new TcpServer(0, $loop); + + $server = new LimitingServer($tcp, 1); + $server->on('connection', $this->expectCallableOnceWith($first)); + $server->on('error', $this->expectCallableOnce()); + + $tcp->emit('connection', [$first]); + $tcp->emit('connection', [$second]); + } + + public function testPausingServerWillBePausedOnceLimitIsReached() + { + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->once())->method('addReadStream'); + $loop->expects($this->once())->method('removeReadStream'); + + $tcp = new TcpServer(0, $loop); + + $connection = $this->createMock(ConnectionInterface::class); + + $server = new LimitingServer($tcp, 1, true); + + $tcp->emit('connection', [$connection]); + } + + public function testSocketDisconnectionWillRemoveFromList() + { + $tcp = new TcpServer(0); + + $socket = stream_socket_client($tcp->getAddress()); + fclose($socket); + + $server = new LimitingServer($tcp, 100); + $server->on('connection', $this->expectCallableOnce()); + $server->on('error', $this->expectCallableNever()); + + $peer = new Promise(function ($resolve, $reject) use ($server) { + $server->on('connection', function (ConnectionInterface $connection) use ($resolve) { + $connection->on('close', function () use ($resolve) { + $resolve(null); + }); + }); + }); + + await(timeout($peer, self::TIMEOUT)); + + $this->assertEquals([], $server->getConnections()); + + $server->close(); + } + + public function testPausingServerWillEmitOnlyOneButAcceptTwoConnectionsDueToOperatingSystem() + { + $server = new TcpServer(0); + $server = new LimitingServer($server, 1, true); + $server->on('connection', $this->expectCallableOnce()); + $server->on('error', $this->expectCallableNever()); + + $peer = new Promise(function ($resolve, $reject) use ($server) { + $server->on('connection', function () use ($resolve) { + $resolve(null); + }); + }); + + $first = stream_socket_client($server->getAddress()); + $second = stream_socket_client($server->getAddress()); + + await(timeout($peer, self::TIMEOUT)); + + fclose($first); + fclose($second); + + $server->close(); + } + + public function testPausingServerWillEmitTwoConnectionsFromBacklog() + { + $server = new TcpServer(0); + $server = new LimitingServer($server, 1, true); + $server->on('error', $this->expectCallableNever()); + + $peer = new Promise(function ($resolve, $reject) use ($server) { + $connections = 0; + $server->on('connection', function (ConnectionInterface $connection) use (&$connections, $resolve) { + ++$connections; + + if ($connections >= 2) { + $resolve(null); + } + }); + }); + + $first = stream_socket_client($server->getAddress()); + fclose($first); + $second = stream_socket_client($server->getAddress()); + fclose($second); + + await(timeout($peer, self::TIMEOUT)); + + $server->close(); + } +} diff --git a/tests/SecureConnectorTest.php b/tests/SecureConnectorTest.php new file mode 100644 index 00000000..230660b4 --- /dev/null +++ b/tests/SecureConnectorTest.php @@ -0,0 +1,327 @@ +loop = $this->createMock(LoopInterface::class); + $this->tcp = $this->createMock(ConnectorInterface::class); + $this->connector = new SecureConnector($this->tcp, $this->loop); + } + + public function testConstructWithoutLoopAssignsLoopAutomatically() + { + $connector = new SecureConnector($this->tcp); + + $ref = new \ReflectionProperty($connector, 'streamEncryption'); + if (\PHP_VERSION_ID < 80100) { + $ref->setAccessible(true); + } + $streamEncryption = $ref->getValue($connector); + + $ref = new \ReflectionProperty($streamEncryption, 'loop'); + if (\PHP_VERSION_ID < 80100) { + $ref->setAccessible(true); + } + $loop = $ref->getValue($streamEncryption); + + $this->assertInstanceOf(LoopInterface::class, $loop); + } + + public function testConnectionWillWaitForTcpConnection() + { + $pending = new Promise(function () { }); + $this->tcp->expects($this->once())->method('connect')->with('example.com:80')->willReturn($pending); + + $promise = $this->connector->connect('example.com:80'); + + $this->assertInstanceOf(PromiseInterface::class, $promise); + } + + public function testConnectionWithCompleteUriWillBePassedThroughExpectForScheme() + { + $pending = new Promise(function () { }); + $this->tcp->expects($this->once())->method('connect')->with('example.com:80/path?query#fragment')->willReturn($pending); + + $this->connector->connect('tls://example.com:80/path?query#fragment'); + } + + public function testConnectionToInvalidSchemeWillReject() + { + $this->tcp->expects($this->never())->method('connect'); + + $promise = $this->connector->connect('tcp://example.com:80'); + + $promise->then(null, $this->expectCallableOnceWithException( + \InvalidArgumentException::class, + 'Given URI "tcp://example.com:80" is invalid (EINVAL)', + defined('SOCKET_EINVAL') ? SOCKET_EINVAL : (defined('PCNTL_EINVAL') ? PCNTL_EINVAL : 22) + )); + } + + public function testConnectWillRejectWithTlsUriWhenUnderlyingConnectorRejects() + { + $this->tcp->expects($this->once())->method('connect')->with('example.com:80')->willReturn(reject(new \RuntimeException( + 'Connection to tcp://example.com:80 failed: Connection refused (ECONNREFUSED)', + defined('SOCKET_ECONNREFUSED') ? SOCKET_ECONNREFUSED : 111 + ))); + + $promise = $this->connector->connect('example.com:80'); + + $exception = null; + $promise->then(null, function ($reason) use (&$exception) { + $exception = $reason; + }); + + assert($exception instanceof \RuntimeException); + $this->assertInstanceOf(\RuntimeException::class, $exception); + $this->assertEquals('Connection to tls://example.com:80 failed: Connection refused (ECONNREFUSED)', $exception->getMessage()); + $this->assertEquals(defined('SOCKET_ECONNREFUSED') ? SOCKET_ECONNREFUSED : 111, $exception->getCode()); + $this->assertInstanceOf(\RuntimeException::class, $exception->getPrevious()); + $this->assertNotEquals('', $exception->getTraceAsString()); + } + + public function testConnectWillRejectWithOriginalMessageWhenUnderlyingConnectorRejectsWithInvalidArgumentException() + { + $this->tcp->expects($this->once())->method('connect')->with('example.com:80')->willReturn(reject(new \InvalidArgumentException( + 'Invalid', + 42 + ))); + + $promise = $this->connector->connect('example.com:80'); + + $exception = null; + $promise->then(null, function ($reason) use (&$exception) { + $exception = $reason; + }); + + assert($exception instanceof \InvalidArgumentException); + $this->assertInstanceOf(\InvalidArgumentException::class, $exception); + $this->assertEquals('Invalid', $exception->getMessage()); + $this->assertEquals(42, $exception->getCode()); + $this->assertNull($exception->getPrevious()); + $this->assertNotEquals('', $exception->getTraceAsString()); + } + + public function testCancelDuringTcpConnectionCancelsTcpConnection() + { + $pending = new Promise(function () { }, $this->expectCallableOnce()); + $this->tcp->expects($this->once())->method('connect')->with('example.com:80')->willReturn($pending); + + $promise = $this->connector->connect('example.com:80'); + $promise->cancel(); + } + + public function testCancelDuringTcpConnectionCancelsTcpConnectionAndRejectsWithTcpRejection() + { + $pending = new Promise(function () { }, function () { throw new \RuntimeException( + 'Connection to tcp://example.com:80 cancelled (ECONNABORTED)', + defined('SOCKET_ECONNABORTED') ? SOCKET_ECONNABORTED : 103 + ); }); + $this->tcp->expects($this->once())->method('connect')->with('example.com:80')->willReturn($pending); + + $promise = $this->connector->connect('example.com:80'); + $promise->cancel(); + + $exception = null; + $promise->then(null, function ($reason) use (&$exception) { + $exception = $reason; + }); + + assert($exception instanceof \RuntimeException); + $this->assertInstanceOf(\RuntimeException::class, $exception); + $this->assertEquals('Connection to tls://example.com:80 cancelled (ECONNABORTED)', $exception->getMessage()); + $this->assertEquals(defined('SOCKET_ECONNABORTED') ? SOCKET_ECONNABORTED : 103, $exception->getCode()); + $this->assertInstanceOf(\RuntimeException::class, $exception->getPrevious()); + $this->assertNotEquals('', $exception->getTraceAsString()); + } + + public function testConnectionWillBeClosedAndRejectedIfConnectionIsNoStream() + { + $connection = $this->createMock(ConnectionInterface::class); + $connection->expects($this->once())->method('close'); + + $this->tcp->expects($this->once())->method('connect')->with('example.com:80')->willReturn(resolve($connection)); + + $promise = $this->connector->connect('example.com:80'); + + $exception = null; + $promise->then(null, function ($reason) use (&$exception) { + $exception = $reason; + }); + + assert($exception instanceof \UnexpectedValueException); + $this->assertInstanceOf(\UnexpectedValueException::class, $exception); + $this->assertEquals('Base connector does not use internal Connection class exposing stream resource', $exception->getMessage()); + $this->assertEquals(0, $exception->getCode()); + $this->assertNull($exception->getPrevious()); + $this->assertNotEquals('', $exception->getTraceAsString()); + } + + public function testStreamEncryptionWillBeEnabledAfterConnecting() + { + $connection = $this->createMock(Connection::class); + + $encryption = $this->createMock(StreamEncryption::class); + $encryption->expects($this->once())->method('enable')->with($connection)->willReturn(new Promise(function () { })); + + $ref = new \ReflectionProperty($this->connector, 'streamEncryption'); + if (\PHP_VERSION_ID < 80100) { + $ref->setAccessible(true); + } + $ref->setValue($this->connector, $encryption); + + $this->tcp->expects($this->once())->method('connect')->with('example.com:80')->willReturn(resolve($connection)); + + $this->connector->connect('example.com:80'); + } + + public function testConnectionWillBeRejectedIfStreamEncryptionFailsAndClosesConnection() + { + $connection = $this->createMock(Connection::class); + $connection->expects($this->once())->method('close'); + + $encryption = $this->createMock(StreamEncryption::class); + $encryption->expects($this->once())->method('enable')->willReturn(reject(new \RuntimeException('TLS error', 123))); + + $ref = new \ReflectionProperty($this->connector, 'streamEncryption'); + if (\PHP_VERSION_ID < 80100) { + $ref->setAccessible(true); + } + $ref->setValue($this->connector, $encryption); + + $this->tcp->expects($this->once())->method('connect')->with('example.com:80')->willReturn(resolve($connection)); + + $promise = $this->connector->connect('example.com:80'); + + $exception = null; + $promise->then(null, function ($reason) use (&$exception) { + $exception = $reason; + }); + + assert($exception instanceof \RuntimeException); + $this->assertInstanceOf(\RuntimeException::class, $exception); + $this->assertEquals('Connection to tls://example.com:80 failed during TLS handshake: TLS error', $exception->getMessage()); + $this->assertEquals(123, $exception->getCode()); + $this->assertNull($exception->getPrevious()); + $this->assertNotEquals('', $exception->getTraceAsString()); + } + + public function testCancelDuringStreamEncryptionCancelsEncryptionAndClosesConnection() + { + $connection = $this->createMock(Connection::class); + $connection->expects($this->once())->method('close'); + + $pending = new Promise(function () { }, function () { + throw new \Exception('Ignored'); + }); + $encryption = $this->createMock(StreamEncryption::class); + $encryption->expects($this->once())->method('enable')->willReturn($pending); + + $ref = new \ReflectionProperty($this->connector, 'streamEncryption'); + if (\PHP_VERSION_ID < 80100) { + $ref->setAccessible(true); + } + $ref->setValue($this->connector, $encryption); + + $deferred = new Deferred(); + $this->tcp->expects($this->once())->method('connect')->with('example.com:80')->willReturn($deferred->promise()); + + $promise = $this->connector->connect('example.com:80'); + $deferred->resolve($connection); + + $promise->cancel(); + + $exception = null; + $promise->then(null, function ($reason) use (&$exception) { + $exception = $reason; + }); + + assert($exception instanceof \RuntimeException); + $this->assertInstanceOf(\RuntimeException::class, $exception); + $this->assertEquals('Connection to tls://example.com:80 cancelled during TLS handshake (ECONNABORTED)', $exception->getMessage()); + $this->assertEquals(defined('SOCKET_ECONNABORTED') ? SOCKET_ECONNABORTED : 103, $exception->getCode()); + $this->assertNull($exception->getPrevious()); + $this->assertNotEquals('', $exception->getTraceAsString()); + } + + public function testRejectionDuringConnectionShouldNotCreateAnyGarbageReferences() + { + if (class_exists('React\Promise\When')) { + $this->markTestSkipped('Not supported on legacy Promise v1 API'); + } + + while (gc_collect_cycles()) { + // collect all garbage cycles + } + + $tcp = new Deferred(); + $this->tcp->expects($this->once())->method('connect')->willReturn($tcp->promise()); + + $promise = $this->connector->connect('example.com:80'); + + $promise->then(null, $this->expectCallableOnce()); // avoid reporting unhandled rejection + + $tcp->reject(new \RuntimeException()); + unset($promise, $tcp); + + $this->assertEquals(0, gc_collect_cycles()); + } + + public function testRejectionDuringTlsHandshakeShouldNotCreateAnyGarbageReferences() + { + if (class_exists('React\Promise\When')) { + $this->markTestSkipped('Not supported on legacy Promise v1 API'); + } + + while (gc_collect_cycles()) { + // collect all garbage cycles + } + + $connection = $this->createMock(Connection::class); + + $tcp = new Deferred(); + $this->tcp->expects($this->once())->method('connect')->willReturn($tcp->promise()); + + $tls = new Deferred(); + $encryption = $this->createMock(StreamEncryption::class); + $encryption->expects($this->once())->method('enable')->willReturn($tls->promise()); + + $ref = new \ReflectionProperty($this->connector, 'streamEncryption'); + if (\PHP_VERSION_ID < 80100) { + $ref->setAccessible(true); + } + $ref->setValue($this->connector, $encryption); + + $promise = $this->connector->connect('example.com:80'); + + $promise->then(null, $this->expectCallableOnce()); // avoid reporting unhandled rejection + + $tcp->resolve($connection); + $tls->reject(new \RuntimeException()); + unset($promise, $tcp, $tls); + + $this->assertEquals(0, gc_collect_cycles()); + } +} diff --git a/tests/SecureIntegrationTest.php b/tests/SecureIntegrationTest.php new file mode 100644 index 00000000..c5f3d416 --- /dev/null +++ b/tests/SecureIntegrationTest.php @@ -0,0 +1,234 @@ +server = new TcpServer(0); + $this->server = new SecureServer($this->server, null, [ + 'local_cert' => __DIR__ . '/../examples/localhost.pem' + ]); + $this->address = $this->server->getAddress(); + $this->connector = new SecureConnector(new TcpConnector(), null, ['verify_peer' => false]); + } + + /** + * @after + */ + public function tearDownServer() + { + if ($this->server !== null) { + $this->server->close(); + $this->server = null; + } + } + + public function testConnectToServer() + { + $client = await(timeout($this->connector->connect($this->address), self::TIMEOUT)); + /* @var $client ConnectionInterface */ + + $client->close(); + + // if we reach this, then everything is good + $this->assertNull(null); + } + + public function testConnectToServerEmitsConnection() + { + $promiseServer = $this->createPromiseForEvent($this->server, 'connection', $this->expectCallableOnce()); + + $promiseClient = $this->connector->connect($this->address); + + [$_, $client] = await(timeout(all([$promiseServer, $promiseClient]), self::TIMEOUT)); + /* @var $client ConnectionInterface */ + + $client->close(); + } + + public function testSendSmallDataToServerReceivesOneChunk() + { + // server expects one connection which emits one data event + $received = new Deferred(); + $this->server->on('connection', function (ConnectionInterface $peer) use ($received) { + $peer->on('data', function ($chunk) use ($received) { + $received->resolve($chunk); + }); + }); + + $client = await(timeout($this->connector->connect($this->address), self::TIMEOUT)); + /* @var $client ConnectionInterface */ + + $client->write('hello'); + + // await server to report one "data" event + $data = await(timeout($received->promise(), self::TIMEOUT)); + + $client->close(); + + $this->assertEquals('hello', $data); + } + + public function testSendDataWithEndToServerReceivesAllData() + { + // PHP can report EOF on TLS 1.3 stream before consuming all data, so + // we explicitly use older TLS version instead. + // Continue if TLS 1.3 is not supported anyway. + if ($this->supportsTls13()) { + $this->connector = new SecureConnector(new TcpConnector(), null, [ + 'verify_peer' => false, + 'crypto_method' => STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT + ]); + } + + $disconnected = new Deferred(); + $this->server->on('connection', function (ConnectionInterface $peer) use ($disconnected) { + $received = ''; + $peer->on('data', function ($chunk) use (&$received) { + $received .= $chunk; + }); + $peer->on('close', function () use (&$received, $disconnected) { + $disconnected->resolve($received); + }); + }); + + $client = await(timeout($this->connector->connect($this->address), self::TIMEOUT)); + /* @var $client ConnectionInterface */ + + $data = str_repeat('a', 200000); + $client->end($data); + + // await server to report connection "close" event + $received = await(timeout($disconnected->promise(), self::TIMEOUT)); + + $this->assertEquals(strlen($data), strlen($received)); + $this->assertEquals($data, $received); + } + + public function testSendDataWithoutEndingToServerReceivesAllData() + { + $server = $this->server; + $promise = new Promise(function ($resolve, $reject) use ($server) { + $server->on('connection', function (ConnectionInterface $connection) use ($resolve) { + $received = ''; + $connection->on('data', function ($chunk) use (&$received, $resolve) { + $received .= $chunk; + + if (strlen($received) >= 200000) { + $resolve($received); + } + }); + }); + }); + + $data = str_repeat('d', 200000); + $connecting = $this->connector->connect($this->address); + $connecting->then(function (ConnectionInterface $connection) use ($data) { + $connection->write($data); + }); + + $received = await(timeout($promise, self::TIMEOUT)); + + $this->assertEquals(strlen($data), strlen($received)); + $this->assertEquals($data, $received); + + $connecting->then(function (ConnectionInterface $connection) { + $connection->close(); + }); + } + + public function testConnectToServerWhichSendsSmallDataReceivesOneChunk() + { + $this->server->on('connection', function (ConnectionInterface $peer) { + $peer->write('hello'); + }); + + $client = await(timeout($this->connector->connect($this->address), self::TIMEOUT)); + /* @var $client ConnectionInterface */ + + // await client to report one "data" event + $receive = $this->createPromiseForEvent($client, 'data', $this->expectCallableOnceWith('hello')); + await(timeout($receive, self::TIMEOUT)); + + $client->close(); + } + + public function testConnectToServerWhichSendsDataWithEndReceivesAllData() + { + $data = str_repeat('b', 100000); + $this->server->on('connection', function (ConnectionInterface $peer) use ($data) { + $peer->end($data); + }); + + $client = await(timeout($this->connector->connect($this->address), self::TIMEOUT)); + /* @var $client ConnectionInterface */ + + // await data from client until it closes + $received = $this->buffer($client, self::TIMEOUT); + + $this->assertEquals($data, $received); + } + + public function testConnectToServerWhichSendsDataWithoutEndingReceivesAllData() + { + $data = str_repeat('c', 100000); + $this->server->on('connection', function (ConnectionInterface $peer) use ($data) { + $peer->write($data); + }); + + $connecting = $this->connector->connect($this->address); + + $promise = new Promise(function ($resolve, $reject) use ($connecting) { + $connecting->then(function (ConnectionInterface $connection) use ($resolve) { + $received = 0; + $connection->on('data', function ($chunk) use (&$received, $resolve) { + $received += strlen($chunk); + + if ($received >= 100000) { + $resolve($received); + } + }); + }, $reject); + }); + + $received = await(timeout($promise, self::TIMEOUT)); + + $this->assertEquals(strlen($data), $received); + + $connecting->then(function (ConnectionInterface $connection) { + $connection->close(); + }); + } + + private function createPromiseForEvent(EventEmitterInterface $emitter, $event, $fn) + { + return new Promise(function ($resolve) use ($emitter, $event, $fn) { + $emitter->on($event, function () use ($resolve, $fn) { + $resolve(call_user_func_array($fn, func_get_args())); + }); + }); + } +} diff --git a/tests/SecureServerTest.php b/tests/SecureServerTest.php new file mode 100644 index 00000000..dbc5c2ba --- /dev/null +++ b/tests/SecureServerTest.php @@ -0,0 +1,202 @@ +createMock(ServerInterface::class); + + $server = new SecureServer($tcp); + + $ref = new \ReflectionProperty($server, 'encryption'); + if (\PHP_VERSION_ID < 80100) { + $ref->setAccessible(true); + } + $encryption = $ref->getValue($server); + + $ref = new \ReflectionProperty($encryption, 'loop'); + if (\PHP_VERSION_ID < 80100) { + $ref->setAccessible(true); + } + $loop = $ref->getValue($encryption); + + $this->assertInstanceOf(LoopInterface::class, $loop); + } + + public function testGetAddressWillBePassedThroughToTcpServer() + { + $tcp = $this->createMock(ServerInterface::class); + $tcp->expects($this->once())->method('getAddress')->willReturn('tcp://127.0.0.1:1234'); + + $loop = $this->createMock(LoopInterface::class); + + $server = new SecureServer($tcp, $loop, []); + + $this->assertEquals('tls://127.0.0.1:1234', $server->getAddress()); + } + + public function testGetAddressWillReturnNullIfTcpServerReturnsNull() + { + $tcp = $this->createMock(ServerInterface::class); + $tcp->expects($this->once())->method('getAddress')->willReturn(null); + + $loop = $this->createMock(LoopInterface::class); + + $server = new SecureServer($tcp, $loop, []); + + $this->assertNull($server->getAddress()); + } + + public function testPauseWillBePassedThroughToTcpServer() + { + $tcp = $this->createMock(ServerInterface::class); + $tcp->expects($this->once())->method('pause'); + + $loop = $this->createMock(LoopInterface::class); + + $server = new SecureServer($tcp, $loop, []); + + $server->pause(); + } + + public function testResumeWillBePassedThroughToTcpServer() + { + $tcp = $this->createMock(ServerInterface::class); + $tcp->expects($this->once())->method('resume'); + + $loop = $this->createMock(LoopInterface::class); + + $server = new SecureServer($tcp, $loop, []); + + $server->resume(); + } + + public function testCloseWillBePassedThroughToTcpServer() + { + $tcp = $this->createMock(ServerInterface::class); + $tcp->expects($this->once())->method('close'); + + $loop = $this->createMock(LoopInterface::class); + + $server = new SecureServer($tcp, $loop, []); + + $server->close(); + } + + public function testConnectionWillBeClosedWithErrorIfItIsNotAStream() + { + $loop = $this->createMock(LoopInterface::class); + + $tcp = new TcpServer(0, $loop); + + $connection = $this->createMock(ConnectionInterface::class); + $connection->expects($this->once())->method('close'); + + $server = new SecureServer($tcp, $loop, []); + + $server->on('error', $this->expectCallableOnce()); + + $tcp->emit('connection', [$connection]); + } + + public function testConnectionWillTryToEnableEncryptionAndWaitForHandshake() + { + $loop = $this->createMock(LoopInterface::class); + + $tcp = new TcpServer(0, $loop); + + $connection = $this->createMock(Connection::class); + $connection->expects($this->once())->method('getRemoteAddress')->willReturn('tcp://127.0.0.1:1234'); + $connection->expects($this->never())->method('close'); + + $server = new SecureServer($tcp, $loop, []); + + $pending = new Promise(function () { }); + + $encryption = $this->createMock(StreamEncryption::class); + $encryption->expects($this->once())->method('enable')->willReturn($pending); + + $ref = new \ReflectionProperty($server, 'encryption'); + if (\PHP_VERSION_ID < 80100) { + $ref->setAccessible(true); + } + $ref->setValue($server, $encryption); + + $ref = new \ReflectionProperty($server, 'context'); + if (\PHP_VERSION_ID < 80100) { + $ref->setAccessible(true); + } + $ref->setValue($server, []); + + $server->on('error', $this->expectCallableNever()); + $server->on('connection', $this->expectCallableNever()); + + $tcp->emit('connection', [$connection]); + } + + public function testConnectionWillBeClosedWithErrorIfEnablingEncryptionFails() + { + $loop = $this->createMock(LoopInterface::class); + + $tcp = new TcpServer(0, $loop); + + $connection = $this->createMock(Connection::class); + $connection->expects($this->once())->method('getRemoteAddress')->willReturn('tcp://127.0.0.1:1234'); + $connection->expects($this->once())->method('close'); + + $server = new SecureServer($tcp, $loop, []); + + $error = new \RuntimeException('Original'); + + $encryption = $this->createMock(StreamEncryption::class); + $encryption->expects($this->once())->method('enable')->willReturn(reject($error)); + + $ref = new \ReflectionProperty($server, 'encryption'); + if (\PHP_VERSION_ID < 80100) { + $ref->setAccessible(true); + } + $ref->setValue($server, $encryption); + + $ref = new \ReflectionProperty($server, 'context'); + if (\PHP_VERSION_ID < 80100) { + $ref->setAccessible(true); + } + $ref->setValue($server, []); + + $error = null; + $server->on('error', $this->expectCallableOnce()); + $server->on('error', function ($e) use (&$error) { + $error = $e; + }); + + $tcp->emit('connection', [$connection]); + + $this->assertInstanceOf(\RuntimeException::class, $error); + $this->assertEquals('Connection from tcp://127.0.0.1:1234 failed during TLS handshake: Original', $error->getMessage()); + } + + public function testSocketErrorWillBeForwarded() + { + $loop = $this->createMock(LoopInterface::class); + + $tcp = new TcpServer(0, $loop); + + $server = new SecureServer($tcp, $loop, []); + + $server->on('error', $this->expectCallableOnce()); + + $tcp->emit('error', [new \RuntimeException('test')]); + } +} diff --git a/tests/SocketServerTest.php b/tests/SocketServerTest.php new file mode 100644 index 00000000..dec65628 --- /dev/null +++ b/tests/SocketServerTest.php @@ -0,0 +1,265 @@ +close(); + + $ref = new \ReflectionProperty($socket, 'server'); + if (\PHP_VERSION_ID < 80100) { + $ref->setAccessible(true); + } + $tcp = $ref->getValue($socket); + + $ref = new \ReflectionProperty($tcp, 'loop'); + if (\PHP_VERSION_ID < 80100) { + $ref->setAccessible(true); + } + $loop = $ref->getValue($tcp); + + $this->assertInstanceOf(LoopInterface::class, $loop); + } + + public function testCreateServerWithZeroPortAssignsRandomPort() + { + $socket = new SocketServer('127.0.0.1:0', []); + $this->assertNotEquals(0, $socket->getAddress()); + $socket->close(); + } + + public function testConstructorWithInvalidUriThrows() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid URI "tcp://invalid URI" given (EINVAL)'); + $this->expectExceptionCode(defined('SOCKET_EINVAL') ? SOCKET_EINVAL : (defined('PCNTL_EINVAL') ? PCNTL_EINVAL : 22)); + new SocketServer('invalid URI'); + } + + public function testConstructorWithInvalidUriWithPortOnlyThrows() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid URI given (EINVAL)'); + $this->expectExceptionCode(defined('SOCKET_EINVAL') ? SOCKET_EINVAL : (defined('PCNTL_EINVAL') ? PCNTL_EINVAL : 22)); + new SocketServer('0'); + } + + public function testConstructorWithInvalidUriWithSchemaAndPortOnlyThrows() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid URI given (EINVAL)'); + $this->expectExceptionCode(defined('SOCKET_EINVAL') ? SOCKET_EINVAL : (defined('PCNTL_EINVAL') ? PCNTL_EINVAL : 22)); + new SocketServer('tcp://0'); + } + + public function testConstructorCreatesExpectedTcpServer() + { + $socket = new SocketServer('127.0.0.1:0', []); + + $connector = new TcpConnector(); + $promise = $connector->connect($socket->getAddress()); + $promise->then($this->expectCallableOnce(), $this->expectCallableNever()); + + $connection = await(timeout($connector->connect($socket->getAddress()), self::TIMEOUT)); + + $socket->close(); + $promise->then(function (ConnectionInterface $connection) { + $connection->close(); + }); + } + + public function testConstructorCreatesExpectedUnixServer() + { + if (!in_array('unix', stream_get_transports())) { + $this->markTestSkipped('Unix domain sockets (UDS) not supported on your platform (Windows?)'); + } + + $socket = new SocketServer($this->getRandomSocketUri(), []); + + $connector = new UnixConnector(); + $connector->connect($socket->getAddress()) + ->then($this->expectCallableOnce(), $this->expectCallableNever()); + + $connection = await(timeout($connector->connect($socket->getAddress()), self::TIMEOUT)); + assert($connection instanceof ConnectionInterface); + + unlink(str_replace('unix://', '', $connection->getRemoteAddress())); + + $connection->close(); + $socket->close(); + } + + public function testConstructorThrowsForExistingUnixPath() + { + if (!in_array('unix', stream_get_transports())) { + $this->markTestSkipped('Unix domain sockets (UDS) not supported on your platform (Windows?)'); + } + + try { + new SocketServer('unix://' . __FILE__, []); + $this->fail(); + } catch (\RuntimeException $e) { + if ($e->getCode() === 0) { + // Zend PHP does not currently report a sane error + $this->assertStringEndsWith('Unknown error', $e->getMessage()); + } else { + $this->assertEquals(SOCKET_EADDRINUSE, $e->getCode()); + $this->assertStringEndsWith('Address already in use (EADDRINUSE)', $e->getMessage()); + } + } + } + + public function testConstructWithExistingFileDescriptorReturnsSameAddressAsOriginalSocketForIpv4Socket() + { + if (!is_dir('/dev/fd')) { + $this->markTestSkipped('Not supported on your platform'); + } + + $fd = FdServerTest::getNextFreeFd(); + $socket = stream_socket_server('127.0.0.1:0'); + + $server = new SocketServer('php://fd/' . $fd); + $server->pause(); + + $this->assertEquals('tcp://' . stream_socket_get_name($socket, false), $server->getAddress()); + } + + public function testEmitsErrorWhenUnderlyingTcpServerEmitsError() + { + $socket = new SocketServer('127.0.0.1:0', []); + + $ref = new \ReflectionProperty($socket, 'server'); + if (\PHP_VERSION_ID < 80100) { + $ref->setAccessible(true); + } + $tcp = $ref->getvalue($socket); + + $error = new \RuntimeException(); + $socket->on('error', $this->expectCallableOnceWith($error)); + $tcp->emit('error', [$error]); + + $socket->close(); + } + + public function testEmitsConnectionForNewConnection() + { + $socket = new SocketServer('127.0.0.1:0', []); + $socket->on('connection', $this->expectCallableOnce()); + + $peer = new Promise(function ($resolve, $reject) use ($socket) { + $socket->on('connection', function () use ($resolve) { + $resolve(null); + }); + }); + + $client = stream_socket_client($socket->getAddress()); + + await(timeout($peer, self::TIMEOUT)); + + $socket->close(); + } + + public function testDoesNotEmitConnectionForNewConnectionToPausedServer() + { + $socket = new SocketServer('127.0.0.1:0', []); + $socket->pause(); + $socket->on('connection', $this->expectCallableNever()); + + $client = stream_socket_client($socket->getAddress()); + + await(sleep(0.1)); + } + + public function testDoesEmitConnectionForNewConnectionToResumedServer() + { + $socket = new SocketServer('127.0.0.1:0', []); + $socket->pause(); + $socket->on('connection', $this->expectCallableOnce()); + + $peer = new Promise(function ($resolve, $reject) use ($socket) { + $socket->on('connection', function () use ($resolve) { + $resolve(null); + }); + }); + + $client = stream_socket_client($socket->getAddress()); + + $socket->resume(); + + await(timeout($peer, self::TIMEOUT)); + + $socket->close(); + } + + public function testDoesNotAllowConnectionToClosedServer() + { + $socket = new SocketServer('127.0.0.1:0', []); + $socket->on('connection', $this->expectCallableNever()); + $address = $socket->getAddress(); + $socket->close(); + + $client = @stream_socket_client($address); + + $this->assertFalse($client); + } + + public function testEmitsConnectionWithInheritedContextOptions() + { + $socket = new SocketServer('127.0.0.1:0', [ + 'tcp' => [ + 'backlog' => 4 + ] + ]); + + $peer = new Promise(function ($resolve, $reject) use ($socket) { + $socket->on('connection', function (ConnectionInterface $connection) use ($resolve) { + $resolve(stream_context_get_options($connection->stream)); + }); + }); + + + $client = stream_socket_client($socket->getAddress()); + + $all = await(timeout($peer, self::TIMEOUT)); + + $this->assertEquals(['socket' => ['backlog' => 4]], $all); + + $socket->close(); + } + + public function testDoesNotEmitSecureConnectionForNewPlaintextConnectionThatIsIdle() + { + $socket = new SocketServer('tls://127.0.0.1:0', [ + 'tls' => [ + 'local_cert' => __DIR__ . '/../examples/localhost.pem' + ] + ]); + $socket->on('connection', $this->expectCallableNever()); + + $client = stream_socket_client(str_replace('tls://', '', $socket->getAddress())); + + await(sleep(0.1)); + + $socket->close(); + } + + private function getRandomSocketUri() + { + return "unix://" . sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid(rand(), true) . '.sock'; + } +} diff --git a/tests/Stub/ConnectionStub.php b/tests/Stub/ConnectionStub.php new file mode 100644 index 00000000..ae440b4a --- /dev/null +++ b/tests/Stub/ConnectionStub.php @@ -0,0 +1,63 @@ +data .= $data; + + return true; + } + + public function end($data = null) + { + } + + public function close() + { + } + + public function getData() + { + return $this->data; + } + + public function getRemoteAddress() + { + return '127.0.0.1'; + } +} diff --git a/tests/Stub/ServerStub.php b/tests/Stub/ServerStub.php new file mode 100644 index 00000000..d9e74f47 --- /dev/null +++ b/tests/Stub/ServerStub.php @@ -0,0 +1,18 @@ +setAccessible(true); + } + $loop = $ref->getValue($connector); + + $this->assertInstanceOf(LoopInterface::class, $loop); + } + + /** @test */ + public function connectionToEmptyPortShouldFailWithoutCallingCustomErrorHandler() + { + $connector = new TcpConnector(); + $promise = $connector->connect('127.0.0.1:9999'); + + $error = null; + set_error_handler(function ($_, $errstr) use (&$error) { + $error = $errstr; + }); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Connection to tcp://127.0.0.1:9999 failed: Connection refused' . (function_exists('socket_import_stream') ? ' (ECONNREFUSED)' : '')); + $this->expectExceptionCode(defined('SOCKET_ECONNREFUSED') ? SOCKET_ECONNREFUSED : 111); + + try { + await(timeout($promise, self::TIMEOUT)); + + restore_error_handler(); + } catch (\Exception $e) { + restore_error_handler(); + $this->assertNull($error); + + throw $e; + } + } + + /** @test */ + public function connectionToTcpServerShouldAddResourceToLoop() + { + $loop = $this->createMock(LoopInterface::class); + $connector = new TcpConnector($loop); + + $server = new TcpServer(0, $loop); + + $valid = false; + $loop->expects($this->once())->method('addWriteStream')->with($this->callback(function ($arg) use (&$valid) { + $valid = is_resource($arg); + return true; + })); + $connector->connect($server->getAddress()); + + $this->assertTrue($valid); + } + + /** @test */ + public function connectionToTcpServerShouldSucceed() + { + $server = new TcpServer(9999); + + $connector = new TcpConnector(); + + $connection = await(timeout($connector->connect('127.0.0.1:9999'), self::TIMEOUT)); + + $this->assertInstanceOf(ConnectionInterface::class, $connection); + + $connection->close(); + $server->close(); + } + + /** @test */ + public function connectionToTcpServerShouldFailIfFileDescriptorsAreExceeded() + { + $connector = new TcpConnector(); + + /** @var string[] $_ */ + /** @var int $exit */ + $ulimit = exec('ulimit -n 2>&1', $_, $exit); + if ($exit !== 0 || $ulimit < 1) { + $this->markTestSkipped('Unable to determine limit of open files (ulimit not available?)'); + } + + $memory = ini_get('memory_limit'); + if ($memory === '-1') { + $memory = PHP_INT_MAX; + } elseif (preg_match('/^\d+G$/i', $memory)) { + $memory = ((int) $memory) * 1024 * 1024 * 1024; + } elseif (preg_match('/^\d+M$/i', $memory)) { + $memory = ((int) $memory) * 1024 * 1024; + } elseif (preg_match('/^\d+K$/i', $memory)) { + $memory = ((int) $memory) * 1024; + } + + // each file descriptor takes ~600 bytes of memory, so skip test if this would exceed memory_limit + if ($ulimit * 600 > $memory || $ulimit > 100000) { + $this->markTestSkipped('Test requires ~' . round($ulimit * 600 / 1024 / 1024) . '/' . round($memory / 1024 / 1024) . ' MiB memory with ' . $ulimit . ' file descriptors'); + } + + // dummy rejected promise to make sure autoloader has initialized all classes + class_exists(SocketServer::class, true); + class_exists(Warning::class, true); + $promise = new Promise(function () { throw new \RuntimeException('dummy'); }); + $promise->then(null, $this->expectCallableOnce()); // avoid reporting unhandled rejection + unset($promise); + + // keep creating dummy file handles until all file descriptors are exhausted + $fds = []; + for ($i = 0; $i < $ulimit; ++$i) { + $fd = @fopen('/dev/null', 'r'); + if ($fd === false) { + break; + } + $fds[] = $fd; + } + + $this->expectException(\RuntimeException::class); + await(timeout($connector->connect('127.0.0.1:9999'), self::TIMEOUT)); + } + + /** @test */ + public function connectionToInvalidNetworkShouldFailWithUnreachableError() + { + if (PHP_OS !== 'Linux' && !function_exists('socket_import_stream')) { + $this->markTestSkipped('Test requires either Linux or ext-sockets'); + } + + $enetunreach = defined('SOCKET_ENETUNREACH') ? SOCKET_ENETUNREACH : 101; + + // try to find an unreachable network by trying a couple of private network addresses + $errno = 0; + $errstr = ''; + for ($i = 0; $i < 20 && $errno !== $enetunreach; ++$i) { + $address = 'tcp://192.168.' . mt_rand(0, 255) . '.' . mt_rand(1, 254) . ':8123'; + $client = @stream_socket_client($address, $errno, $errstr, 0.1); + } + if ($client || $errno !== $enetunreach) { + $this->markTestSkipped('Expected error ' . $enetunreach . ' but got ' . $errno . ' (' . $errstr . ') for ' . $address); + } + + $connector = new TcpConnector(); + + $promise = $connector->connect($address); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Connection to ' . $address . ' failed: ' . (function_exists('socket_strerror') ? socket_strerror($enetunreach) . ' (ENETUNREACH)' : 'Network is unreachable')); + $this->expectExceptionCode($enetunreach); + await(timeout($promise, self::TIMEOUT)); + } + + /** @test */ + public function connectionToTcpServerShouldSucceedWithRemoteAdressSameAsTarget() + { + $server = new TcpServer(9999); + + $connector = new TcpConnector(); + + $connection = await(timeout($connector->connect('127.0.0.1:9999'), self::TIMEOUT)); + /* @var $connection ConnectionInterface */ + + $this->assertEquals('tcp://127.0.0.1:9999', $connection->getRemoteAddress()); + + $connection->close(); + $server->close(); + } + + /** @test */ + public function connectionToTcpServerShouldSucceedWithLocalAdressOnLocalhost() + { + $server = new TcpServer(9999); + + $connector = new TcpConnector(); + + $connection = await(timeout($connector->connect('127.0.0.1:9999'), self::TIMEOUT)); + /* @var $connection ConnectionInterface */ + + $this->assertStringContainsString('tcp://127.0.0.1:', $connection->getLocalAddress()); + $this->assertNotEquals('tcp://127.0.0.1:9999', $connection->getLocalAddress()); + + $connection->close(); + $server->close(); + } + + /** @test */ + public function connectionToTcpServerShouldSucceedWithNullAddressesAfterConnectionClosed() + { + $server = new TcpServer(9999); + + $connector = new TcpConnector(); + + $connection = await(timeout($connector->connect('127.0.0.1:9999'), self::TIMEOUT)); + /* @var $connection ConnectionInterface */ + + $server->close(); + $connection->close(); + + $this->assertNull($connection->getRemoteAddress()); + $this->assertNull($connection->getLocalAddress()); + } + + /** @test */ + public function connectionToTcpServerWillCloseWhenOtherSideCloses() + { + // immediately close connection and server once connection is in + $server = new TcpServer(0); + $server->on('connection', function (ConnectionInterface $conn) use ($server) { + $conn->close(); + $server->close(); + }); + + $once = $this->expectCallableOnce(); + $connector = new TcpConnector(); + $connector->connect($server->getAddress())->then(function (ConnectionInterface $conn) use ($once) { + $conn->write('hello'); + $conn->on('close', $once); + }); + + Loop::run(); + } + + /** @test + * @group test + */ + public function connectionToEmptyIp6PortShouldFail() + { + $connector = new TcpConnector(); + $connector + ->connect('[::1]:9999') + ->then($this->expectCallableNever(), $this->expectCallableOnce()); + + Loop::run(); + } + + /** @test */ + public function connectionToIp6TcpServerShouldSucceed() + { + try { + $server = new TcpServer('[::1]:9999'); + } catch (\Exception $e) { + $this->markTestSkipped('Unable to start IPv6 server socket (IPv6 not supported on this system?)'); + } + + $connector = new TcpConnector(); + + $connection = await(timeout($connector->connect('[::1]:9999'), self::TIMEOUT)); + /* @var $connection ConnectionInterface */ + + $this->assertEquals('tcp://[::1]:9999', $connection->getRemoteAddress()); + + $this->assertStringContainsString('tcp://[::1]:', $connection->getLocalAddress()); + $this->assertNotEquals('tcp://[::1]:9999', $connection->getLocalAddress()); + + $connection->close(); + $server->close(); + } + + /** @test */ + public function connectionToHostnameShouldFailImmediately() + { + $loop = $this->createMock(LoopInterface::class); + + $connector = new TcpConnector($loop); + $promise = $connector->connect('www.google.com:80'); + + $promise->then(null, $this->expectCallableOnceWithException( + \InvalidArgumentException::class, + 'Given URI "tcp://www.google.com:80" does not contain a valid host IP (EINVAL)', + defined('SOCKET_EINVAL') ? SOCKET_EINVAL : (defined('PCNTL_EINVAL') ? PCNTL_EINVAL : 22) + )); + } + + /** @test */ + public function connectionToInvalidPortShouldFailImmediately() + { + $loop = $this->createMock(LoopInterface::class); + + $connector = new TcpConnector($loop); + $promise = $connector->connect('255.255.255.255:12345678'); + + $promise->then(null, $this->expectCallableOnceWithException( + \InvalidArgumentException::class, + 'Given URI "tcp://255.255.255.255:12345678" is invalid (EINVAL)', + defined('SOCKET_EINVAL') ? SOCKET_EINVAL : (defined('PCNTL_EINVAL') ? PCNTL_EINVAL : 22) + )); + } + + /** @test */ + public function connectionToInvalidSchemeShouldFailImmediately() + { + $loop = $this->createMock(LoopInterface::class); + + $connector = new TcpConnector($loop); + $connector->connect('tls://google.com:443')->then( + $this->expectCallableNever(), + $this->expectCallableOnce() + ); + } + + /** @test */ + public function cancellingConnectionShouldRemoveResourceFromLoopAndCloseResource() + { + $loop = $this->createMock(LoopInterface::class); + $connector = new TcpConnector($loop); + + $server = new TcpServer(0, $loop); + $server->on('connection', $this->expectCallableNever()); + + $loop->expects($this->once())->method('addWriteStream'); + $promise = $connector->connect($server->getAddress()); + + $resource = null; + $valid = false; + $loop->expects($this->once())->method('removeWriteStream')->with($this->callback(function ($arg) use (&$resource, &$valid) { + $resource = $arg; + $valid = is_resource($arg); + return true; + })); + $promise->cancel(); + + // ensure that this was a valid resource during the removeWriteStream() call + $this->assertTrue($valid); + + // ensure that this resource should now be closed after the cancel() call + $this->assertFalse(is_resource($resource)); + } + + /** @test */ + public function cancellingConnectionShouldRejectPromise() + { + $connector = new TcpConnector(); + + $server = new TcpServer(0); + + $promise = $connector->connect($server->getAddress()); + $promise->cancel(); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Connection to ' . $server->getAddress() . ' cancelled during TCP/IP handshake (ECONNABORTED)'); + $this->expectExceptionCode(defined('SOCKET_ECONNABORTED') ? SOCKET_ECONNABORTED : 103); + + try { + await($promise); + } catch (\Exception $e) { + $server->close(); + throw $e; + } + } + + public function testCancelDuringConnectionShouldNotCreateAnyGarbageReferences() + { + if (class_exists('React\Promise\When')) { + $this->markTestSkipped('Not supported on legacy Promise v1 API'); + } + + while (gc_collect_cycles()) { + // collect all garbage cycles + } + + $loop = $this->createMock(LoopInterface::class); + $connector = new TcpConnector($loop); + $promise = $connector->connect('127.0.0.1:9999'); + + $promise->cancel(); + unset($promise); + + $this->assertEquals(0, gc_collect_cycles()); + } +} diff --git a/tests/TcpServerTest.php b/tests/TcpServerTest.php new file mode 100644 index 00000000..9007ff3b --- /dev/null +++ b/tests/TcpServerTest.php @@ -0,0 +1,376 @@ +server = new TcpServer(0); + + $this->port = parse_url($this->server->getAddress(), PHP_URL_PORT); + } + + public function testConstructWithoutLoopAssignsLoopAutomatically() + { + $server = new TcpServer(0); + + $ref = new \ReflectionProperty($server, 'loop'); + if (\PHP_VERSION_ID < 80100) { + $ref->setAccessible(true); + } + $loop = $ref->getValue($server); + + $this->assertInstanceOf(LoopInterface::class, $loop); + + $server->close(); + } + + /** + * @covers React\Socket\TcpServer::handleConnection + */ + public function testServerEmitsConnectionEventForNewConnection() + { + $client = stream_socket_client('tcp://localhost:'.$this->port); + assert($client !== false); + + $promise = new Promise(function ($resolve) { + $this->server->on('connection', $resolve); + }); + + $connection = await(timeout($promise, self::TIMEOUT)); + + $this->assertInstanceOf(ConnectionInterface::class, $connection); + } + + /** + * @covers React\Socket\TcpServer::handleConnection + */ + public function testConnectionWithManyClients() + { + $client1 = stream_socket_client('tcp://localhost:'.$this->port); + $client2 = stream_socket_client('tcp://localhost:'.$this->port); + $client3 = stream_socket_client('tcp://localhost:'.$this->port); + assert($client1 !== false && $client2 !== false && $client3 !== false); + + $this->server->on('connection', $this->expectCallableExactly(3)); + $this->tick(); + $this->tick(); + $this->tick(); + $this->tick(); + } + + public function testDataEventWillNotBeEmittedWhenClientSendsNoData() + { + $client = stream_socket_client('tcp://localhost:'.$this->port); + assert($client !== false); + + $mock = $this->expectCallableNever(); + + $this->server->on('connection', function ($conn) use ($mock) { + $conn->on('data', $mock); + }); + $this->tick(); + $this->tick(); + } + + public function testDataWillBeEmittedWithDataClientSends() + { + $client = stream_socket_client('tcp://localhost:'.$this->port); + + fwrite($client, "foo\n"); + + $mock = $this->expectCallableOnceWith("foo\n"); + + $this->server->on('connection', function ($conn) use ($mock) { + $conn->on('data', $mock); + }); + $this->tick(); + $this->tick(); + } + + public function testDataWillBeEmittedEvenWhenClientShutsDownAfterSending() + { + $client = stream_socket_client('tcp://localhost:' . $this->port); + fwrite($client, "foo\n"); + stream_socket_shutdown($client, STREAM_SHUT_WR); + + $mock = $this->expectCallableOnceWith("foo\n"); + + $this->server->on('connection', function ($conn) use ($mock) { + $conn->on('data', $mock); + }); + $this->tick(); + $this->tick(); + } + + public function testLoopWillEndWhenServerIsClosed() + { + // explicitly unset server because we already call close() + $this->server->close(); + $this->server = null; + + Loop::run(); + + // if we reach this, then everything is good + $this->assertNull(null); + } + + public function testCloseTwiceIsNoOp() + { + $this->server->close(); + $this->server->close(); + + // if we reach this, then everything is good + $this->assertNull(null); + } + + public function testGetAddressAfterCloseReturnsNull() + { + $this->server->close(); + $this->assertNull($this->server->getAddress()); + } + + public function testLoopWillEndWhenServerIsClosedAfterSingleConnection() + { + $client = stream_socket_client('tcp://localhost:' . $this->port); + assert($client !== false); + + // explicitly unset server because we only accept a single connection + // and then already call close() + $server = $this->server; + $this->server = null; + + $server->on('connection', function ($conn) use ($server) { + $conn->close(); + $server->close(); + }); + + Loop::run(); + + // if we reach this, then everything is good + $this->assertNull(null); + } + + public function testDataWillBeEmittedInMultipleChunksWhenClientSendsExcessiveAmounts() + { + $client = stream_socket_client('tcp://localhost:' . $this->port); + $stream = new DuplexResourceStream($client); + + $bytes = 1024 * 1024; + $stream->end(str_repeat('*', $bytes)); + + $mock = $this->expectCallableOnce(); + + // explicitly unset server because we only accept a single connection + // and then already call close() + $server = $this->server; + $this->server = null; + + $received = 0; + $server->on('connection', function ($conn) use ($mock, &$received, $server) { + // count number of bytes received + $conn->on('data', function ($data) use (&$received) { + $received += strlen($data); + }); + + $conn->on('end', $mock); + + // do not await any further connections in order to let the loop terminate + $server->close(); + }); + + Loop::run(); + + $this->assertEquals($bytes, $received); + } + + public function testConnectionDoesNotEndWhenClientDoesNotClose() + { + $client = stream_socket_client('tcp://localhost:'.$this->port); + assert($client !== false); + + $mock = $this->expectCallableNever(); + + $this->server->on('connection', function ($conn) use ($mock) { + $conn->on('end', $mock); + }); + $this->tick(); + $this->tick(); + } + + /** + * @covers React\Socket\Connection::end + */ + public function testConnectionDoesEndWhenClientCloses() + { + $client = stream_socket_client('tcp://localhost:'.$this->port); + + fclose($client); + + $mock = $this->expectCallableOnce(); + + $this->server->on('connection', function ($conn) use ($mock) { + $conn->on('end', $mock); + }); + $this->tick(); + $this->tick(); + } + + public function testCtorAddsResourceToLoop() + { + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->once())->method('addReadStream'); + + new TcpServer(0, $loop); + } + + public function testResumeWithoutPauseIsNoOp() + { + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->once())->method('addReadStream'); + + $server = new TcpServer(0, $loop); + $server->resume(); + } + + public function testPauseRemovesResourceFromLoop() + { + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->once())->method('removeReadStream'); + + $server = new TcpServer(0, $loop); + $server->pause(); + } + + public function testPauseAfterPauseIsNoOp() + { + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->once())->method('removeReadStream'); + + $server = new TcpServer(0, $loop); + $server->pause(); + $server->pause(); + } + + public function testCloseRemovesResourceFromLoop() + { + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->once())->method('removeReadStream'); + + $server = new TcpServer(0, $loop); + $server->close(); + } + + public function testEmitsErrorWhenAcceptListenerFailsWithoutCallingCustomErrorHandler() + { + $listener = null; + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->once())->method('addReadStream')->with($this->anything(), $this->callback(function ($cb) use (&$listener) { + $listener = $cb; + return true; + })); + + $server = new TcpServer(0, $loop); + + $exception = null; + $server->on('error', function ($e) use (&$exception) { + $exception = $e; + }); + + $this->assertNotNull($listener); + $socket = stream_socket_server('tcp://127.0.0.1:0'); + + $error = null; + set_error_handler(function ($_, $errstr) use (&$error) { + $error = $errstr; + }); + + $time = microtime(true); + $listener($socket); + $time = microtime(true) - $time; + + restore_error_handler(); + $this->assertNull($error); + + $this->assertLessThan(1, $time); + + $this->assertInstanceOf(\RuntimeException::class, $exception); + assert($exception instanceof \RuntimeException); + $this->assertStringStartsWith('Unable to accept new connection: ', $exception->getMessage()); + + return $exception; + } + + /** + * @param \RuntimeException $e + * @requires extension sockets + * @depends testEmitsErrorWhenAcceptListenerFailsWithoutCallingCustomErrorHandler + */ + public function testEmitsTimeoutErrorWhenAcceptListenerFails(\RuntimeException $exception) + { + $this->assertEquals('Unable to accept new connection: ' . socket_strerror(SOCKET_ETIMEDOUT) . ' (ETIMEDOUT)', $exception->getMessage()); + $this->assertEquals(SOCKET_ETIMEDOUT, $exception->getCode()); + } + + public function testListenOnBusyPortThrows() + { + if (DIRECTORY_SEPARATOR === '\\') { + $this->markTestSkipped('Windows supports listening on same port multiple times'); + } + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Failed to listen on "tcp://127.0.0.1:' . $this->port . '": ' . (function_exists('socket_strerror') ? socket_strerror(SOCKET_EADDRINUSE) . ' (EADDRINUSE)' : 'Address already in use')); + $this->expectExceptionCode(defined('SOCKET_EADDRINUSE') ? SOCKET_EADDRINUSE : 0); + new TcpServer($this->port); + } + + /** + * @after + * @covers React\Socket\TcpServer::close + */ + public function tearDownServer() + { + if ($this->server) { + $this->server->close(); + } + } + + /** + * This methods runs the loop for "one tick" + * + * This is prone to race conditions and as such somewhat unreliable across + * different operating systems. Running the loop until the expected events + * fire is the preferred alternative. + * + * @deprecated + */ + private function tick() + { + if (DIRECTORY_SEPARATOR === '\\') { + $this->markTestSkipped('Not supported on Windows'); + } + + await(sleep(0.0)); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 00000000..1e7a79e0 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,132 @@ +createCallableMock(); + $mock + ->expects($this->exactly($amount)) + ->method('__invoke'); + + return $mock; + } + + protected function expectCallableOnce() + { + $mock = $this->createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke'); + + return $mock; + } + + protected function expectCallableOnceWith($value) + { + $mock = $this->createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke') + ->with($value); + + return $mock; + } + + protected function expectCallableOnceWithException($type, $message = null, $code = null) + { + return $this->expectCallableOnceWith( + $this->logicalAnd( + $this->isInstanceOf($type), + $this->callback(function (\Exception $e) use ($message) { + return $message === null || $e->getMessage() === $message; + }), + $this->callback(function (\Exception $e) use ($code) { + return $code === null || $e->getCode() === $code; + }) + ) + ); + } + + protected function expectCallableNever() + { + $mock = $this->createCallableMock(); + $mock + ->expects($this->never()) + ->method('__invoke'); + + return $mock; + } + + protected function createCallableMock() + { + $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 buffer(ReadableStreamInterface $stream, $timeout) + { + if (!$stream->isReadable()) { + return ''; + } + + $buffer = await(timeout(new Promise( + function ($resolve, $reject) use ($stream) { + $buffer = ''; + $stream->on('data', function ($chunk) use (&$buffer) { + $buffer .= $chunk; + }); + + $stream->on('error', $reject); + + $stream->on('close', function () use (&$buffer, $resolve) { + $resolve($buffer); + }); + }, + function () use ($stream) { + $stream->close(); + throw new \RuntimeException(); + } + ), $timeout)); + + // let loop tick for reactphp/async v4 to clean up any remaining stream resources + // @link https://github.com/reactphp/async/pull/65 reported upstream // TODO remove me once merged + if (function_exists('React\Async\async')) { + await(sleep(0)); + } + + return $buffer; + } + + protected function supportsTls13() + { + // TLS 1.3 is supported as of OpenSSL 1.1.1 (https://www.openssl.org/blog/blog/2018/09/11/release111/) + // The OpenSSL library version can only be obtained by parsing output from phpinfo(). + // OPENSSL_VERSION_TEXT refers to header version which does not necessarily match actual library version + // see php -i | grep OpenSSL + // OpenSSL Library Version => OpenSSL 1.1.1 11 Sep 2018 + ob_start(); + phpinfo(INFO_MODULES); + $info = ob_get_clean(); + + if (preg_match('/OpenSSL Library Version => OpenSSL ([\d\.]+)/', $info, $match)) { + return version_compare($match[1], '1.1.1', '>='); + } + return false; + } +} diff --git a/tests/TimeoutConnectorTest.php b/tests/TimeoutConnectorTest.php new file mode 100644 index 00000000..2e5a1776 --- /dev/null +++ b/tests/TimeoutConnectorTest.php @@ -0,0 +1,257 @@ +createMock(ConnectorInterface::class); + + $connector = new TimeoutConnector($base, 0.01); + + $ref = new \ReflectionProperty($connector, 'loop'); + if (\PHP_VERSION_ID < 80100) { + $ref->setAccessible(true); + } + $loop = $ref->getValue($connector); + + $this->assertInstanceOf(LoopInterface::class, $loop); + } + + public function testRejectsPromiseWithoutStartingTimerWhenWrappedConnectorReturnsRejectedPromise() + { + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->never())->method('addTimer'); + $loop->expects($this->never())->method('cancelTimer'); + + $connector = $this->createMock(ConnectorInterface::class); + $connector->expects($this->once())->method('connect')->with('example.com:80')->willReturn(reject(new \RuntimeException('Failed', 42))); + + $timeout = new TimeoutConnector($connector, 5.0, $loop); + + $promise = $timeout->connect('example.com:80'); + + $exception = null; + $promise->then(null, function ($reason) use (&$exception) { + $exception = $reason; + }); + + assert($exception instanceof \RuntimeException); + $this->assertEquals('Failed', $exception->getMessage()); + $this->assertEquals(42, $exception->getCode()); + } + + public function testRejectsPromiseAfterCancellingTimerWhenWrappedConnectorReturnsPendingPromiseThatRejects() + { + $timer = $this->createMock(TimerInterface::class); + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->once())->method('addTimer')->with(5.0, $this->anything())->willReturn($timer); + $loop->expects($this->once())->method('cancelTimer')->with($timer); + + $deferred = new Deferred(); + $connector = $this->createMock(ConnectorInterface::class); + $connector->expects($this->once())->method('connect')->with('example.com:80')->willReturn($deferred->promise()); + + $timeout = new TimeoutConnector($connector, 5.0, $loop); + + $promise = $timeout->connect('example.com:80'); + + $deferred->reject(new \RuntimeException('Failed', 42)); + + $exception = null; + $promise->then(null, function ($reason) use (&$exception) { + $exception = $reason; + }); + + assert($exception instanceof \RuntimeException); + $this->assertEquals('Failed', $exception->getMessage()); + $this->assertEquals(42, $exception->getCode()); + } + + public function testResolvesPromiseWithoutStartingTimerWhenWrappedConnectorReturnsResolvedPromise() + { + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->never())->method('addTimer'); + $loop->expects($this->never())->method('cancelTimer'); + + $connection = $this->createMock(ConnectionInterface::class); + $connector = $this->createMock(ConnectorInterface::class); + $connector->expects($this->once())->method('connect')->with('example.com:80')->willReturn(resolve($connection)); + + $timeout = new TimeoutConnector($connector, 5.0, $loop); + + $promise = $timeout->connect('example.com:80'); + + $resolved = null; + $promise->then(function ($value) use (&$resolved) { + $resolved = $value; + }); + + $this->assertSame($connection, $resolved); + } + + public function testResolvesPromiseAfterCancellingTimerWhenWrappedConnectorReturnsPendingPromiseThatResolves() + { + $timer = $this->createMock(TimerInterface::class); + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->once())->method('addTimer')->with(5.0, $this->anything())->willReturn($timer); + $loop->expects($this->once())->method('cancelTimer')->with($timer); + + $deferred = new Deferred(); + $connector = $this->createMock(ConnectorInterface::class); + $connector->expects($this->once())->method('connect')->with('example.com:80')->willReturn($deferred->promise()); + + $timeout = new TimeoutConnector($connector, 5.0, $loop); + + $promise = $timeout->connect('example.com:80'); + + $connection = $this->createMock(ConnectionInterface::class); + $deferred->resolve($connection); + + $resolved = null; + $promise->then(function ($value) use (&$resolved) { + $resolved = $value; + }); + + $this->assertSame($connection, $resolved); + } + + public function testRejectsPromiseAndCancelsPendingConnectionWhenTimeoutTriggers() + { + $timerCallback = null; + $timer = $this->createMock(TimerInterface::class); + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->once())->method('addTimer')->with(0.01, $this->callback(function ($callback) use (&$timerCallback) { + $timerCallback = $callback; + return true; + }))->willReturn($timer); + $loop->expects($this->once())->method('cancelTimer')->with($timer); + + $cancelled = 0; + $connector = $this->createMock(ConnectorInterface::class); + $connector->expects($this->once())->method('connect')->with('example.com:80')->willReturn(new Promise(function () { }, function () use (&$cancelled) { + ++$cancelled; + throw new \RuntimeException(); + })); + + $timeout = new TimeoutConnector($connector, 0.01, $loop); + + $promise = $timeout->connect('example.com:80'); + + $this->assertEquals(0, $cancelled); + + $this->assertNotNull($timerCallback); + $timerCallback(); + + $this->assertEquals(1, $cancelled); + + $exception = null; + $promise->then(null, function ($reason) use (&$exception) { + $exception = $reason; + }); + + assert($exception instanceof \RuntimeException); + $this->assertEquals('Connection to example.com:80 timed out after 0.01 seconds (ETIMEDOUT)' , $exception->getMessage()); + $this->assertEquals(\defined('SOCKET_ETIMEDOUT') ? \SOCKET_ETIMEDOUT : 110, $exception->getCode()); + } + + public function testCancellingPromiseWillCancelPendingConnectionAndRejectPromise() + { + $timer = $this->createMock(TimerInterface::class); + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->once())->method('addTimer')->with(0.01, $this->anything())->willReturn($timer); + $loop->expects($this->once())->method('cancelTimer')->with($timer); + + $cancelled = 0; + $connector = $this->createMock(ConnectorInterface::class); + $connector->expects($this->once())->method('connect')->with('example.com:80')->willReturn(new Promise(function () { }, function () use (&$cancelled) { + ++$cancelled; + throw new \RuntimeException('Cancelled'); + })); + + $timeout = new TimeoutConnector($connector, 0.01, $loop); + + $promise = $timeout->connect('example.com:80'); + + $this->assertEquals(0, $cancelled); + + assert(method_exists($promise, 'cancel')); + $promise->cancel(); + + $this->assertEquals(1, $cancelled); + + $exception = null; + $promise->then(null, function ($reason) use (&$exception) { + $exception = $reason; + }); + + assert($exception instanceof \RuntimeException); + $this->assertEquals('Cancelled', $exception->getMessage()); + } + + public function testRejectionDuringConnectionShouldNotCreateAnyGarbageReferences() + { + if (class_exists('React\Promise\When')) { + $this->markTestSkipped('Not supported on legacy Promise v1 API'); + } + + while (gc_collect_cycles()) { + // collect all garbage cycles + } + + $connection = new Deferred(); + $connector = $this->createMock(ConnectorInterface::class); + $connector->expects($this->once())->method('connect')->with('example.com:80')->willReturn($connection->promise()); + + $timeout = new TimeoutConnector($connector, 0.01); + + $promise = $timeout->connect('example.com:80'); + + $promise->then(null, $this->expectCallableOnce()); // avoid reporting unhandled rejection + + $connection->reject(new \RuntimeException('Connection failed')); + unset($promise, $connection); + + $this->assertEquals(0, gc_collect_cycles()); + } + + public function testRejectionDueToTimeoutShouldNotCreateAnyGarbageReferences() + { + if (class_exists('React\Promise\When')) { + $this->markTestSkipped('Not supported on legacy Promise v1 API'); + } + + while (gc_collect_cycles()) { + // collect all garbage cycles + } + + $connection = new Deferred(function () { + throw new \RuntimeException('Connection cancelled'); + }); + $connector = $this->createMock(ConnectorInterface::class); + $connector->expects($this->once())->method('connect')->with('example.com:80')->willReturn($connection->promise()); + + $timeout = new TimeoutConnector($connector, 0); + + $promise = $timeout->connect('example.com:80'); + + $promise->then(null, $this->expectCallableOnce()); // avoid reporting unhandled rejection + + Loop::run(); + unset($promise, $connection); + + $this->assertEquals(0, gc_collect_cycles()); + } +} diff --git a/tests/TimerSpeedUpEventLoop.php b/tests/TimerSpeedUpEventLoop.php new file mode 100644 index 00000000..485fd38e --- /dev/null +++ b/tests/TimerSpeedUpEventLoop.php @@ -0,0 +1,80 @@ +loop = $loop; + } + + public function addReadStream($stream, $listener) + { + return $this->loop->addReadStream($stream, $listener); + } + + public function addWriteStream($stream, $listener) + { + return $this->loop->addWriteStream($stream, $listener); + } + + public function removeReadStream($stream) + { + return $this->loop->removeReadStream($stream); + } + + public function removeWriteStream($stream) + { + return $this->loop->removeWriteStream($stream); + } + + public function addTimer($interval, $callback) + { + return $this->loop->addTimer($interval / 10, $callback); + } + + public function addPeriodicTimer($interval, $callback) + { + return $this->loop->addPeriodicTimer($interval / 10, $callback); + } + + public function cancelTimer(TimerInterface $timer) + { + return $this->loop->cancelTimer($timer); + } + + public function futureTick($listener) + { + return $this->loop->futureTick($listener); + } + + public function addSignal($signal, $listener) + { + return $this->loop->addSignal($signal, $listener); + } + + public function removeSignal($signal, $listener) + { + return $this->loop->removeSignal($signal, $listener); + } + + public function run() + { + return $this->loop->run(); + } + + public function stop() + { + return $this->loop->stop(); + } +} \ No newline at end of file diff --git a/tests/UnixConnectorTest.php b/tests/UnixConnectorTest.php new file mode 100644 index 00000000..51abda4e --- /dev/null +++ b/tests/UnixConnectorTest.php @@ -0,0 +1,93 @@ +loop = $this->createMock(LoopInterface::class); + $this->connector = new UnixConnector($this->loop); + } + + public function testConstructWithoutLoopAssignsLoopAutomatically() + { + $connector = new UnixConnector(); + + $ref = new \ReflectionProperty($connector, 'loop'); + if (\PHP_VERSION_ID < 80100) { + $ref->setAccessible(true); + } + $loop = $ref->getValue($connector); + + $this->assertInstanceOf(LoopInterface::class, $loop); + } + + public function testInvalid() + { + $promise = $this->connector->connect('google.com:80'); + + $promise->then(null, $this->expectCallableOnceWithException( + \RuntimeException::class + )); + } + + public function testInvalidScheme() + { + $promise = $this->connector->connect('tcp://google.com:80'); + + $promise->then(null, $this->expectCallableOnceWithException( + \InvalidArgumentException::class, + 'Given URI "tcp://google.com:80" is invalid (EINVAL)', + defined('SOCKET_EINVAL') ? SOCKET_EINVAL : (defined('PCNTL_EINVAL') ? PCNTL_EINVAL : 22) + )); + } + + public function testValid() + { + if (!in_array('unix', stream_get_transports())) { + $this->markTestSkipped('Unix domain sockets (UDS) not supported on your platform (Windows?)'); + } + + // random unix domain socket path + $path = sys_get_temp_dir() . '/test' . uniqid() . '.sock'; + + // temporarily create unix domain socket server to connect to + $server = stream_socket_server('unix://' . $path, $errno, $errstr); + + // skip test if we can not create a test server (Windows etc.) + if (!$server) { + $this->markTestSkipped('Unable to create socket "' . $path . '": ' . $errstr . '(' . $errno .')'); + return; + } + + // tests succeeds if we get notified of successful connection + $promise = $this->connector->connect($path); + $promise->then($this->expectCallableOnce()); + + // remember remote and local address of this connection and close again + $remote = $local = false; + $promise->then(function(ConnectionInterface $conn) use (&$remote, &$local) { + $remote = $conn->getRemoteAddress(); + $local = $conn->getLocalAddress(); + $conn->close(); + }); + + // clean up server + fclose($server); + unlink($path); + + $this->assertNull($local); + $this->assertEquals('unix://' . $path, $remote); + } +} diff --git a/tests/UnixServerTest.php b/tests/UnixServerTest.php new file mode 100644 index 00000000..c63f0317 --- /dev/null +++ b/tests/UnixServerTest.php @@ -0,0 +1,423 @@ +markTestSkipped('Unix domain sockets (UDS) not supported on your platform (Windows?)'); + } + + $this->uds = $this->getRandomSocketUri(); + $this->server = new UnixServer($this->uds); + } + + public function testConstructWithoutLoopAssignsLoopAutomatically() + { + unlink(str_replace('unix://', '', $this->uds)); + $this->uds = $this->getRandomSocketUri(); + + $server = new UnixServer($this->uds); + + $ref = new \ReflectionProperty($server, 'loop'); + if (\PHP_VERSION_ID < 80100) { + $ref->setAccessible(true); + } + $loop = $ref->getValue($server); + + $this->assertInstanceOf(LoopInterface::class, $loop); + + $server->close(); + } + + /** + * @covers React\Socket\UnixServer::handleConnection + */ + public function testConnection() + { + $client = stream_socket_client($this->uds); + assert(is_resource($client)); + + $this->server->on('connection', $this->expectCallableOnce()); + $this->tick(); + $this->tick(); + } + + /** + * @covers React\Socket\UnixServer::handleConnection + */ + public function testConnectionWithManyClients() + { + $client1 = stream_socket_client($this->uds); + assert(is_resource($client1)); + $client2 = stream_socket_client($this->uds); + assert(is_resource($client2)); + $client3 = stream_socket_client($this->uds); + assert(is_resource($client3)); + + $this->server->on('connection', $this->expectCallableExactly(3)); + $this->tick(); + $this->tick(); + $this->tick(); + } + + public function testDataEventWillNotBeEmittedWhenClientSendsNoData() + { + $client = stream_socket_client($this->uds); + assert(is_resource($client)); + + $mock = $this->expectCallableNever(); + + $this->server->on('connection', function ($conn) use ($mock) { + $conn->on('data', $mock); + }); + $this->tick(); + $this->tick(); + } + + public function testDataWillBeEmittedWithDataClientSends() + { + $client = stream_socket_client($this->uds); + assert(is_resource($client)); + + fwrite($client, "foo\n"); + + $mock = $this->expectCallableOnceWith("foo\n"); + + $this->server->on('connection', function ($conn) use ($mock) { + $conn->on('data', $mock); + }); + $this->tick(); + $this->tick(); + } + + public function testDataWillBeEmittedEvenWhenClientShutsDownAfterSending() + { + $client = stream_socket_client($this->uds); + fwrite($client, "foo\n"); + stream_socket_shutdown($client, STREAM_SHUT_WR); + + $mock = $this->expectCallableOnceWith("foo\n"); + + $this->server->on('connection', function ($conn) use ($mock) { + $conn->on('data', $mock); + }); + $this->tick(); + $this->tick(); + } + + public function testLoopWillEndWhenServerIsClosed() + { + // explicitly unset server because we already call close() + $this->server->close(); + $this->server = null; + + Loop::run(); + + // if we reach this, then everything is good + $this->assertNull(null); + } + + public function testCloseTwiceIsNoOp() + { + $this->server->close(); + $this->server->close(); + + // if we reach this, then everything is good + $this->assertNull(null); + } + + public function testGetAddressAfterCloseReturnsNull() + { + $this->server->close(); + $this->assertNull($this->server->getAddress()); + } + + public function testLoopWillEndWhenServerIsClosedAfterSingleConnection() + { + $client = stream_socket_client($this->uds); + assert(is_resource($client)); + + // explicitly unset server because we only accept a single connection + // and then already call close() + $server = $this->server; + $this->server = null; + + $server->on('connection', function ($conn) use ($server) { + $conn->close(); + $server->close(); + }); + + Loop::run(); + + // if we reach this, then everything is good + $this->assertNull(null); + } + + public function testDataWillBeEmittedInMultipleChunksWhenClientSendsExcessiveAmounts() + { + $client = stream_socket_client($this->uds); + $stream = new DuplexResourceStream($client); + + $bytes = 1024 * 1024; + $stream->end(str_repeat('*', $bytes)); + + $mock = $this->expectCallableOnce(); + + // explicitly unset server because we only accept a single connection + // and then already call close() + $server = $this->server; + $this->server = null; + + $received = 0; + $server->on('connection', function ($conn) use ($mock, &$received, $server) { + // count number of bytes received + $conn->on('data', function ($data) use (&$received) { + $received += strlen($data); + }); + + $conn->on('end', $mock); + + // do not await any further connections in order to let the loop terminate + $server->close(); + }); + + Loop::run(); + + $this->assertEquals($bytes, $received); + } + + public function testConnectionDoesNotEndWhenClientDoesNotClose() + { + $client = stream_socket_client($this->uds); + assert(is_resource($client)); + + $mock = $this->expectCallableNever(); + + $this->server->on('connection', function ($conn) use ($mock) { + $conn->on('end', $mock); + }); + $this->tick(); + $this->tick(); + } + + /** + * @covers React\Socket\Connection::end + */ + public function testConnectionDoesEndWhenClientCloses() + { + $client = stream_socket_client($this->uds); + + fclose($client); + + $mock = $this->expectCallableOnce(); + + $this->server->on('connection', function ($conn) use ($mock) { + $conn->on('end', $mock); + }); + $this->tick(); + $this->tick(); + } + + public function testCtorAddsResourceToLoop() + { + unlink(str_replace('unix://', '', $this->uds)); + $this->uds = $this->getRandomSocketUri(); + + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->once())->method('addReadStream'); + + new UnixServer($this->uds, $loop); + } + + public function testCtorThrowsForInvalidAddressScheme() + { + $loop = $this->createMock(LoopInterface::class); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Given URI "tcp://localhost:0" is invalid (EINVAL)'); + $this->expectExceptionCode(defined('SOCKET_EINVAL') ? SOCKET_EINVAL : (defined('PCNTL_EINVAL') ? PCNTL_EINVAL : 22)); + new UnixServer('tcp://localhost:0', $loop); + } + + public function testCtorThrowsWhenPathIsNotWritableWithoutCallingCustomErrorHandler() + { + $loop = $this->createMock(LoopInterface::class); + + $error = null; + set_error_handler(function ($_, $errstr) use (&$error) { + $error = $errstr; + }); + + $this->expectException(\RuntimeException::class); + + try { + new UnixServer('/dev/null', $loop); + + restore_error_handler(); + } catch (\Exception $e) { + restore_error_handler(); + $this->assertNull($error); + + throw $e; + } + } + + public function testResumeWithoutPauseIsNoOp() + { + unlink(str_replace('unix://', '', $this->uds)); + $this->uds = $this->getRandomSocketUri(); + + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->once())->method('addReadStream'); + + $server = new UnixServer($this->uds, $loop); + $server->resume(); + } + + public function testPauseRemovesResourceFromLoop() + { + unlink(str_replace('unix://', '', $this->uds)); + $this->uds = $this->getRandomSocketUri(); + + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->once())->method('removeReadStream'); + + $server = new UnixServer($this->uds, $loop); + $server->pause(); + } + + public function testPauseAfterPauseIsNoOp() + { + unlink(str_replace('unix://', '', $this->uds)); + $this->uds = $this->getRandomSocketUri(); + + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->once())->method('removeReadStream'); + + $server = new UnixServer($this->uds, $loop); + $server->pause(); + $server->pause(); + } + + public function testCloseRemovesResourceFromLoop() + { + unlink(str_replace('unix://', '', $this->uds)); + $this->uds = $this->getRandomSocketUri(); + + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->once())->method('removeReadStream'); + + $server = new UnixServer($this->uds, $loop); + $server->close(); + } + + public function testEmitsErrorWhenAcceptListenerFailsWithoutCallingCustomErrorHandler() + { + unlink(str_replace('unix://', '', $this->uds)); + $this->uds = $this->getRandomSocketUri(); + + $listener = null; + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->once())->method('addReadStream')->with($this->anything(), $this->callback(function ($cb) use (&$listener) { + $listener = $cb; + return true; + })); + + $server = new UnixServer($this->uds, $loop); + + $exception = null; + $server->on('error', function ($e) use (&$exception) { + $exception = $e; + }); + + $this->assertNotNull($listener); + $socket = stream_socket_server('tcp://127.0.0.1:0'); + + $error = null; + set_error_handler(function ($_, $errstr) use (&$error) { + $error = $errstr; + }); + + $time = microtime(true); + $listener($socket); + $time = microtime(true) - $time; + + restore_error_handler(); + $this->assertNull($error); + + $this->assertLessThan(1, $time); + + $this->assertInstanceOf(\RuntimeException::class, $exception); + assert($exception instanceof \RuntimeException); + $this->assertStringStartsWith('Unable to accept new connection: ', $exception->getMessage()); + + return $exception; + } + + /** + * @param \RuntimeException $e + * @requires extension sockets + * @depends testEmitsErrorWhenAcceptListenerFailsWithoutCallingCustomErrorHandler + */ + public function testEmitsTimeoutErrorWhenAcceptListenerFails(\RuntimeException $exception) + { + $this->assertEquals('Unable to accept new connection: ' . socket_strerror(SOCKET_ETIMEDOUT) . ' (ETIMEDOUT)', $exception->getMessage()); + $this->assertEquals(SOCKET_ETIMEDOUT, $exception->getCode()); + } + + public function testListenOnBusyPortThrows() + { + if (DIRECTORY_SEPARATOR === '\\') { + $this->markTestSkipped('Windows supports listening on same port multiple times'); + } + + $this->expectException(\RuntimeException::class); + new UnixServer($this->uds); + } + + /** + * @after + * @covers React\Socket\UnixServer::close + */ + public function tearDownServer() + { + if ($this->server) { + $this->server->close(); + $this->server = null; + } + + assert(is_string($this->uds)); + unlink(str_replace('unix://', '', $this->uds)); + } + + private function getRandomSocketUri() + { + return "unix://" . sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid(rand(), true) . '.sock'; + } + + private function tick() + { + await(sleep(0.0)); + } +}