diff --git a/.gitattributes b/.gitattributes index fc0be87..a60463a 100644 --- a/.gitattributes +++ b/.gitattributes @@ -4,4 +4,5 @@ /examples/ export-ignore /phpunit.xml.dist export-ignore /phpunit.xml.legacy export-ignore +/phpstan.neon.dist export-ignore /tests/ export-ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 71149ad..b0fa11b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,3 +49,28 @@ jobs: coverage: pcov - run: composer install - run: vendor/bin/phpunit --coverage-text + + PHPStan: + name: PHPStan (PHP ${{ matrix.php }} on ubuntu-24.04) + runs-on: ubuntu-24.04 + strategy: + matrix: + 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: none + - run: composer install + - run: vendor/bin/phpstan analyse --no-progress diff --git a/README.md b/README.md index 3257b68..08005e3 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ connections for [ReactPHP](https://reactphp.org/). > 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. +> Custom implementations should follow the [v3 type declaration upgrade guide](UPGRADE.md). The socket library provides re-usable interfaces for a socket-layer server and client based on the [`EventLoop`](https://github.com/reactphp/event-loop) @@ -1532,6 +1533,17 @@ If you do not want to run these, they can simply be skipped like this: vendor/bin/phpunit --exclude-group internet ``` +To check the source code with PHPStan at its maximum level, run: + +```bash +vendor/bin/phpstan analyse +``` + +Composer installs a PHPStan release compatible with your PHP version: +PHPStan 1.4 on PHP 7.1, PHPStan 1.12 on PHP 7.2–7.3, and PHPStan 2 +on PHP 7.4+. Each runs at its maximum level and analyses compatibility with PHP 7.1. +CI runs this check on the same PHP and operating system matrix as PHPUnit. + ## License MIT, see [LICENSE file](LICENSE). diff --git a/UPGRADE.md b/UPGRADE.md new file mode 100644 index 0000000..1d04c39 --- /dev/null +++ b/UPGRADE.md @@ -0,0 +1,29 @@ +# Upgrading to Socket v3 + +## Native type declarations + +Socket APIs now declare parameter and return types. Custom implementations and +subclasses must use compatible signatures: + +| Interface | Method | +| --- | --- | +| `ConnectorInterface` | `connect(string $uri): React\Promise\PromiseInterface` | +| `ServerInterface` | `getAddress(): ?string` | +| `ServerInterface` | `pause(): void`, `resume(): void`, `close(): void` | +| `ConnectionInterface` | `getRemoteAddress(): ?string`, `getLocalAddress(): ?string` | + +Connector promises still resolve with `ConnectionInterface`. Addresses may still +be `null` when a connection or server has closed or its address is unknown. + +Pass URI and path strings to connectors and servers, a `float` timeout to +`TimeoutConnector`, and an `int` or `null` connection limit and a `bool` pause flag +to `LimitingServer`. `TcpServer` still accepts a port-only string, such as `'8080'`. +Callers using `declare(strict_types=1)` must convert integer ports to strings. + +Values incompatible with the declarations now raise `TypeError` immediately. +Malformed URI strings retain the existing exception or rejected-promise behavior. +Scalar coercion continues to follow PHP's normal `strict_types` rules. + +Methods inherited from the current Stream and EventEmitter dependencies retain +compatible parameter signatures. PHPDoc describes resource handles and union +types that PHP 7.1 cannot express natively. diff --git a/composer.json b/composer.json index 9aa4d04..a0e939b 100644 --- a/composer.json +++ b/composer.json @@ -34,6 +34,7 @@ "react/stream": "^1.4" }, "require-dev": { + "phpstan/phpstan": "^1.4.10 || ^2.1", "phpunit/phpunit": "^9.6 || ^8.5 || ^7.5", "react/async": "^4.3 || ^3", "react/promise-stream": "^1.4", diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..abd1326 --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,7 @@ +parameters: + level: max + paths: + - src + phpVersion: 70100 + # Retain runtime guards for older supported Promise implementations. + treatPhpDocTypesAsCertain: false diff --git a/src/Connection.php b/src/Connection.php index 6bc8deb..7b136aa 100644 --- a/src/Connection.php +++ b/src/Connection.php @@ -23,6 +23,7 @@ class Connection extends EventEmitter implements ConnectionInterface * Internal flag whether this is a Unix domain socket (UDS) connection * * @internal + * @var bool */ public $unix = false; @@ -33,14 +34,20 @@ class Connection extends EventEmitter implements ConnectionInterface * `tls://` scheme for encrypted connections instead of `tcp://`. * * @internal + * @var bool */ public $encryptionEnabled = false; - /** @internal */ + /** + * @internal + * @var resource + */ public $stream; + /** @var DuplexResourceStream */ private $input; + /** @param resource $resource */ public function __construct($resource, LoopInterface $loop) { // Legacy PHP < 7.3.3 (and PHP < 7.2.15) suffers from a bug where feof() @@ -78,49 +85,50 @@ public function __construct($resource, LoopInterface $loop) $this->input->on('close', [$this, 'close']); } - public function isReadable() + public function isReadable(): bool { return $this->input->isReadable(); } - public function isWritable() + public function isWritable(): bool { return $this->input->isWritable(); } - public function pause() + public function pause(): void { $this->input->pause(); } - public function resume() + public function resume(): void { $this->input->resume(); } - public function pipe(WritableStreamInterface $dest, array $options = []) + /** @param array{end?: bool} $options */ + public function pipe(WritableStreamInterface $dest, array $options = []): WritableStreamInterface { return $this->input->pipe($dest, $options); } - public function write($data) + public function write($data): bool { return $this->input->write($data); } - public function end($data = null) + public function end($data = null): void { $this->input->end($data); } - public function close() + public function close(): void { $this->input->close(); $this->handleClose(); $this->removeAllListeners(); } - public function handleClose() + public function handleClose(): void { if (!\is_resource($this->stream)) { return; @@ -132,7 +140,7 @@ public function handleClose() @\stream_socket_shutdown($this->stream, \STREAM_SHUT_RDWR); } - public function getRemoteAddress() + public function getRemoteAddress(): ?string { if (!\is_resource($this->stream)) { return null; @@ -141,7 +149,7 @@ public function getRemoteAddress() return $this->parseAddress(\stream_socket_get_name($this->stream, true)); } - public function getLocalAddress() + public function getLocalAddress(): ?string { if (!\is_resource($this->stream)) { return null; @@ -150,7 +158,8 @@ public function getLocalAddress() return $this->parseAddress(\stream_socket_get_name($this->stream, false)); } - private function parseAddress($address) + /** @param string|false $address */ + private function parseAddress($address): ?string { if ($address === false) { return null; diff --git a/src/ConnectionInterface.php b/src/ConnectionInterface.php index 64613b5..04e842e 100644 --- a/src/ConnectionInterface.php +++ b/src/ConnectionInterface.php @@ -82,7 +82,7 @@ interface ConnectionInterface extends DuplexStreamInterface * * @return ?string remote address (URI) or null if unknown */ - public function getRemoteAddress(); + public function getRemoteAddress(): ?string; /** * Returns the full local address (full URI with scheme, IP and port) where this connection has been established with @@ -115,5 +115,5 @@ public function getRemoteAddress(); * @return ?string local address (URI) or null if unknown * @see self::getRemoteAddress() */ - public function getLocalAddress(); + public function getLocalAddress(): ?string; } diff --git a/src/Connector.php b/src/Connector.php index 8a5e994..2c94bf8 100644 --- a/src/Connector.php +++ b/src/Connector.php @@ -6,6 +6,7 @@ use React\Dns\Resolver\Factory as DnsFactory; use React\Dns\Resolver\ResolverInterface; use React\EventLoop\LoopInterface; +use React\Promise\PromiseInterface; use function React\Promise\reject; /** @@ -25,6 +26,7 @@ */ final class Connector implements ConnectorInterface { + /** @var array */ private $connectors = []; /** @@ -46,7 +48,7 @@ final class Connector implements ConnectorInterface * This value SHOULD NOT be given unless you're sure you want to explicitly use a * given event loop instance. * - * @param array $context + * @param array{tcp?: bool|array|ConnectorInterface, tls?: bool|array|ConnectorInterface, unix?: bool|ConnectorInterface, dns?: bool|string|DnsConfig|ResolverInterface, timeout?: bool|float, happy_eyeballs?: bool} $context * @param ?LoopInterface $loop * @throws \InvalidArgumentException for invalid arguments */ @@ -86,7 +88,7 @@ public function __construct(array $context = [], ?LoopInterface $loop = null) // 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 + $config->nameservers = ['8.8.8.8']; // @codeCoverageIgnore } } @@ -146,7 +148,7 @@ public function __construct(array $context = [], ?LoopInterface $loop = null) } } - public function connect($uri) + public function connect(string $uri): PromiseInterface { $scheme = 'tcp'; if (\strpos($uri, '://') !== false) { @@ -167,13 +169,13 @@ public function connect($uri) /** * [internal] Builds on URI from the given URI parts and ip address with original hostname as query * - * @param array $parts + * @param array{scheme?: string, host?: string, port?: int, path?: string, query?: string, fragment?: string} $parts * @param string $host * @param string $ip * @return string * @internal */ - public static function uri(array $parts, $host, $ip) + public static function uri(array $parts, string $host, string $ip): string { $uri = ''; diff --git a/src/ConnectorInterface.php b/src/ConnectorInterface.php index 1f07b75..863d020 100644 --- a/src/ConnectorInterface.php +++ b/src/ConnectorInterface.php @@ -2,6 +2,8 @@ namespace React\Socket; +use React\Promise\PromiseInterface; + /** * The `ConnectorInterface` is responsible for providing an interface for * establishing streaming connections, such as a normal TCP/IP connection. @@ -55,5 +57,5 @@ interface ConnectorInterface * Resolves with a `ConnectionInterface` on success or rejects with an `Exception` on error. * @see ConnectionInterface */ - public function connect($uri); + public function connect(string $uri): PromiseInterface; } diff --git a/src/DnsConnector.php b/src/DnsConnector.php index 4a8d1a0..ac59f92 100644 --- a/src/DnsConnector.php +++ b/src/DnsConnector.php @@ -9,7 +9,9 @@ final class DnsConnector implements ConnectorInterface { + /** @var ConnectorInterface */ private $connector; + /** @var ResolverInterface */ private $resolver; public function __construct(ConnectorInterface $connector, ResolverInterface $resolver) @@ -18,13 +20,13 @@ public function __construct(ConnectorInterface $connector, ResolverInterface $re $this->resolver = $resolver; } - public function connect($uri) + public function connect(string $uri): PromiseInterface { $original = $uri; if (\strpos($uri, '://') === false) { $uri = 'tcp://' . $uri; $parts = \parse_url($uri); - if (isset($parts['scheme'])) { + if ($parts !== false && isset($parts['scheme'])) { unset($parts['scheme']); } } else { @@ -46,17 +48,20 @@ public function connect($uri) } $promise = $this->resolver->resolve($host); + /** @var ?string $resolved */ $resolved = null; - return new Promise( + /** @var Promise $result */ + $result = new Promise( function ($resolve, $reject) use (&$promise, &$resolved, $uri, $host, $parts) { // resolve/reject with result of DNS lookup + /** @var PromiseInterface $promise */ $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) { + )->then(null, function (\Throwable $e) use ($uri) { if ($e instanceof \RuntimeException) { $message = \preg_replace('/^(Connection to [^ ]+)[&?]hostname=[^ &]+/', '$1', $e->getMessage()); $e = new \RuntimeException( @@ -71,7 +76,7 @@ function ($resolve, $reject) use (&$promise, &$resolved, $uri, $host, $parts) { if (\PHP_VERSION_ID < 80100) { $r->setAccessible(true); } - $trace = $r->getValue($e); + $trace = $e->getTrace(); // Exception trace arguments are not available on some PHP 7.4 installs // @codeCoverageIgnoreStart @@ -105,7 +110,7 @@ function ($_, $reject) use (&$promise, &$resolved, $uri) { } // (try to) cancel pending DNS lookup / connection attempt - if ($promise instanceof PromiseInterface && \method_exists($promise, 'cancel')) { + if ($promise instanceof PromiseInterface && \is_callable([$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; @@ -115,5 +120,7 @@ function ($_, $reject) use (&$promise, &$resolved, $uri) { } } ); + + return $result; } } diff --git a/src/FdServer.php b/src/FdServer.php index b00681c..0dc3e64 100644 --- a/src/FdServer.php +++ b/src/FdServer.php @@ -33,9 +33,13 @@ */ final class FdServer extends EventEmitter implements ServerInterface { + /** @var resource */ private $master; + /** @var LoopInterface */ private $loop; + /** @var bool */ private $unix = false; + /** @var bool */ private $listening = false; /** @@ -77,7 +81,7 @@ final class FdServer extends EventEmitter implements ServerInterface */ public function __construct($fd, ?LoopInterface $loop = null) { - if (\preg_match('#^php://fd/(\d+)$#', $fd, $m)) { + if (\is_string($fd) && \preg_match('#^php://fd/(\d+)$#', $fd, $m)) { $fd = (int) $m[1]; } if (!\is_int($fd) || $fd < 0 || $fd >= \PHP_INT_MAX) { @@ -91,27 +95,29 @@ public function __construct($fd, ?LoopInterface $loop = null) $errno = 0; $errstr = ''; - \set_error_handler(function ($_, $error) use (&$errno, &$errstr) { + \set_error_handler(function ($_, $error) use (&$errno, &$errstr): bool { // 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; + return true; }); - $this->master = \fopen('php://fd/' . $fd, 'r+'); + $master = \fopen('php://fd/' . $fd, 'r+'); \restore_error_handler(); - if (false === $this->master) { + if (false === $master) { throw new \RuntimeException( 'Failed to listen on FD ' . $fd . ': ' . $errstr . SocketServer::errconst($errno), $errno ); } + $this->master = $master; $meta = \stream_get_meta_data($this->master); - if (!isset($meta['stream_type']) || $meta['stream_type'] !== 'tcp_socket') { + if ($meta['stream_type'] !== 'tcp_socket') { \fclose($this->master); $errno = \defined('SOCKET_ENOTSOCK') ? \SOCKET_ENOTSOCK : 88; @@ -139,20 +145,23 @@ public function __construct($fd, ?LoopInterface $loop = null) // 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; + $this->unix = \parse_url($this->getAddress() ?? '', \PHP_URL_PORT) === false; \stream_set_blocking($this->master, false); $this->resume(); } - public function getAddress() + public function getAddress(): ?string { if (!\is_resource($this->master)) { return null; } $address = \stream_socket_get_name($this->master, false); + if ($address === false) { + return null; + } if ($this->unix === true) { return 'unix://' . $address; @@ -167,7 +176,7 @@ public function getAddress() return 'tcp://' . $address; } - public function pause() + public function pause(): void { if (!$this->listening) { return; @@ -177,15 +186,15 @@ public function pause() $this->listening = false; } - public function resume() + public function resume(): void { if ($this->listening || !\is_resource($this->master)) { return; } - $this->loop->addReadStream($this->master, function ($master) { + $this->loop->addReadStream($this->master, function () { try { - $newSocket = SocketServer::accept($master); + $newSocket = SocketServer::accept($this->master); } catch (\RuntimeException $e) { $this->emit('error', [$e]); return; @@ -195,7 +204,7 @@ public function resume() $this->listening = true; } - public function close() + public function close(): void { if (!\is_resource($this->master)) { return; @@ -206,8 +215,11 @@ public function close() $this->removeAllListeners(); } - /** @internal */ - public function handleConnection($socket) + /** + * @internal + * @param resource $socket + */ + public function handleConnection($socket): void { $connection = new Connection($socket, $this->loop); $connection->unix = $this->unix; diff --git a/src/FixedUriConnector.php b/src/FixedUriConnector.php index f83241d..19e793a 100644 --- a/src/FixedUriConnector.php +++ b/src/FixedUriConnector.php @@ -2,6 +2,8 @@ namespace React\Socket; +use React\Promise\PromiseInterface; + /** * Decorates an existing Connector to always use a fixed, preconfigured URI * @@ -21,20 +23,22 @@ */ class FixedUriConnector implements ConnectorInterface { + /** @var string */ private $uri; + /** @var ConnectorInterface */ private $connector; /** * @param string $uri * @param ConnectorInterface $connector */ - public function __construct($uri, ConnectorInterface $connector) + public function __construct(string $uri, ConnectorInterface $connector) { $this->uri = $uri; $this->connector = $connector; } - public function connect($_) + public function connect(string $_): PromiseInterface { return $this->connector->connect($this->uri); } diff --git a/src/HappyEyeBallsConnectionBuilder.php b/src/HappyEyeBallsConnectionBuilder.php index 57a94aa..8333b7d 100644 --- a/src/HappyEyeBallsConnectionBuilder.php +++ b/src/HappyEyeBallsConnectionBuilder.php @@ -31,30 +31,49 @@ final class HappyEyeBallsConnectionBuilder */ const RESOLUTION_DELAY = 0.05; + /** @var LoopInterface */ public $loop; + /** @var ConnectorInterface */ public $connector; + /** @var ResolverInterface */ public $resolver; + /** @var string */ public $uri; + /** @var string */ public $host; + /** @var array */ public $resolved = [ Message::TYPE_A => false, Message::TYPE_AAAA => false, ]; + /** @var array> */ public $resolverPromises = []; + /** @var array> */ public $connectionPromises = []; + /** @var list */ public $connectQueue = []; + /** @var ?TimerInterface */ public $nextAttemptTimer; + /** @var array{scheme?: string, host?: string, port?: int, path?: string, query?: string, fragment?: string} */ public $parts; + /** @var int */ public $ipsCount = 0; + /** @var int */ public $failureCount = 0; + /** @var ?callable */ public $resolve; + /** @var ?callable */ public $reject; + /** @var ?int */ public $lastErrorFamily; + /** @var ?string */ public $lastError6; + /** @var ?string */ public $lastError4; - public function __construct(LoopInterface $loop, ConnectorInterface $connector, ResolverInterface $resolver, $uri, $host, $parts) + /** @param array{scheme?: string, host?: string, port?: int, path?: string, query?: string, fragment?: string} $parts */ + public function __construct(LoopInterface $loop, ConnectorInterface $connector, ResolverInterface $resolver, string $uri, string $host, array $parts) { $this->loop = $loop; $this->connector = $connector; @@ -64,14 +83,17 @@ public function __construct(LoopInterface $loop, ConnectorInterface $connector, $this->parts = $parts; } - public function connect() + /** @return PromiseInterface */ + public function connect(): PromiseInterface { - return new Promise(function ($resolve, $reject) { - $lookupResolve = function ($type) use ($resolve, $reject) { + /** @var Promise $result */ + $result = new Promise(function ($resolve, $reject) { + $lookupResolve = function (int $type) use ($resolve, $reject) { return function (array $ips) use ($type, $resolve, $reject) { unset($this->resolverPromises[$type]); $this->resolved[$type] = true; + /** @var list $ips */ $this->mixIpsIntoConnectQueue($ips); // start next connection attempt if not already awaiting next @@ -89,6 +111,7 @@ public function connect() } // Otherwise delay processing IPv4 lookup until short timer passes or IPv6 resolves in the meantime + /** @var Deferred> $deferred */ $deferred = new Deferred(function () use (&$ips) { // discard all IPv4 addresses if cancelled $ips = []; @@ -113,19 +136,24 @@ public function connect() $this->cleanUp(); }); + + return $result; } /** * @internal - * @param int $type DNS query type + * @param Message::TYPE_A|Message::TYPE_AAAA $type DNS address query type * @param callable $reject - * @return \React\Promise\PromiseInterface Returns a promise that + * @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) + public function resolve(int $type, callable $reject): PromiseInterface { - return $this->resolver->resolveAll($this->host, $type)->then(null, function (\Exception $e) use ($type, $reject) { + /** @var PromiseInterface> $promise A and AAAA queries resolve to IP addresses. */ + $promise = $this->resolver->resolveAll($this->host, $type); + + return $promise->then(null, function (\Throwable $e) use ($type, $reject) { unset($this->resolverPromises[$type]); $this->resolved[$type] = true; @@ -159,9 +187,10 @@ public function resolve($type, $reject) /** * @internal */ - public function check($resolve, $reject) + public function check(callable $resolve, callable $reject): void { $ip = \array_shift($this->connectQueue); + assert($ip !== null); // start connection attempt and remember array position to later unset again $this->connectionPromises[] = $this->attemptConnection($ip); @@ -174,7 +203,7 @@ public function check($resolve, $reject) $this->cleanUp(); $resolve($connection); - }, function (\Exception $e) use ($index, $ip, $resolve, $reject) { + }, function (\Throwable $e) use ($index, $ip, $resolve, $reject) { unset($this->connectionPromises[$index]); $this->failureCount++; @@ -228,8 +257,9 @@ public function check($resolve, $reject) /** * @internal + * @return PromiseInterface */ - public function attemptConnection($ip) + public function attemptConnection(string $ip): PromiseInterface { $uri = Connector::uri($this->parts, $this->host, $ip); @@ -239,21 +269,21 @@ public function attemptConnection($ip) /** * @internal */ - public function cleanUp() + public function cleanUp(): void { // 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')) { + if (\is_callable([$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')) { + if (\is_callable([$resolverPromise, 'cancel'])) { $resolverPromise->cancel(); } } @@ -267,7 +297,7 @@ public function cleanUp() /** * @internal */ - public function hasBeenResolved() + public function hasBeenResolved(): bool { foreach ($this->resolved as $typeHasBeenResolved) { if ($typeHasBeenResolved === false) { @@ -286,19 +316,24 @@ public function hasBeenResolved() * @link https://tools.ietf.org/html/rfc8305#section-4 * * @internal + * @param list $ips */ - public function mixIpsIntoConnectQueue(array $ips) + public function mixIpsIntoConnectQueue(array $ips): void { \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); + while ($connectQueueStash || $ips) { + if ($ips) { + $ip = \array_shift($ips); + assert($ip !== null); + $this->connectQueue[] = $ip; } - if (\count($connectQueueStash) > 0) { - $this->connectQueue[] = \array_shift($connectQueueStash); + if ($connectQueueStash) { + $ip = \array_shift($connectQueueStash); + assert($ip !== null); + $this->connectQueue[] = $ip; } } } @@ -307,7 +342,7 @@ public function mixIpsIntoConnectQueue(array $ips) * @internal * @return string */ - public function error() + public function error(): string { if ($this->lastError4 === $this->lastError6) { $message = $this->lastError6; diff --git a/src/HappyEyeBallsConnector.php b/src/HappyEyeBallsConnector.php index 89ec203..c5a36e5 100644 --- a/src/HappyEyeBallsConnector.php +++ b/src/HappyEyeBallsConnector.php @@ -5,12 +5,16 @@ use React\Dns\Resolver\ResolverInterface; use React\EventLoop\Loop; use React\EventLoop\LoopInterface; +use React\Promise\PromiseInterface; use function React\Promise\reject; final class HappyEyeBallsConnector implements ConnectorInterface { + /** @var LoopInterface */ private $loop; + /** @var ConnectorInterface */ private $connector; + /** @var ResolverInterface */ private $resolver; public function __construct(?LoopInterface $loop, ConnectorInterface $connector, ResolverInterface $resolver) @@ -20,13 +24,13 @@ public function __construct(?LoopInterface $loop, ConnectorInterface $connector, $this->resolver = $resolver; } - public function connect($uri) + public function connect(string $uri): PromiseInterface { $original = $uri; if (\strpos($uri, '://') === false) { $uri = 'tcp://' . $uri; $parts = \parse_url($uri); - if (isset($parts['scheme'])) { + if ($parts !== false && isset($parts['scheme'])) { unset($parts['scheme']); } } else { diff --git a/src/LimitingServer.php b/src/LimitingServer.php index 4742e25..d75a5f1 100644 --- a/src/LimitingServer.php +++ b/src/LimitingServer.php @@ -33,12 +33,18 @@ */ class LimitingServer extends EventEmitter implements ServerInterface { + /** @var array */ private $connections = []; + /** @var ServerInterface */ private $server; + /** @var ?int */ private $limit; + /** @var bool */ private $pauseOnLimit = false; + /** @var bool */ private $autoPaused = false; + /** @var bool */ private $manuPaused = false; /** @@ -90,7 +96,7 @@ class LimitingServer extends EventEmitter implements ServerInterface * @param int|null $connectionLimit * @param bool $pauseOnLimit */ - public function __construct(ServerInterface $server, $connectionLimit, $pauseOnLimit = false) + public function __construct(ServerInterface $server, ?int $connectionLimit, bool $pauseOnLimit = false) { $this->server = $server; $this->limit = $connectionLimit; @@ -113,17 +119,17 @@ public function __construct(ServerInterface $server, $connectionLimit, $pauseOnL * * @return ConnectionInterface[] */ - public function getConnections() + public function getConnections(): array { return $this->connections; } - public function getAddress() + public function getAddress(): ?string { return $this->server->getAddress(); } - public function pause() + public function pause(): void { if (!$this->manuPaused) { $this->manuPaused = true; @@ -134,7 +140,7 @@ public function pause() } } - public function resume() + public function resume(): void { if ($this->manuPaused) { $this->manuPaused = false; @@ -145,13 +151,13 @@ public function resume() } } - public function close() + public function close(): void { $this->server->close(); } /** @internal */ - public function handleConnection(ConnectionInterface $connection) + public function handleConnection(ConnectionInterface $connection): void { // close connection if limit exceeded if ($this->limit !== null && \count($this->connections) >= $this->limit) { @@ -178,7 +184,7 @@ public function handleConnection(ConnectionInterface $connection) } /** @internal */ - public function handleDisconnection(ConnectionInterface $connection) + public function handleDisconnection(ConnectionInterface $connection): void { unset($this->connections[\array_search($connection, $this->connections)]); @@ -193,7 +199,7 @@ public function handleDisconnection(ConnectionInterface $connection) } /** @internal */ - public function handleError(\Exception $error) + public function handleError(\Exception $error): void { $this->emit('error', [$error]); } diff --git a/src/SecureConnector.php b/src/SecureConnector.php index 7626b0a..7e80a94 100644 --- a/src/SecureConnector.php +++ b/src/SecureConnector.php @@ -5,14 +5,19 @@ use React\EventLoop\Loop; use React\EventLoop\LoopInterface; use React\Promise\Promise; +use React\Promise\PromiseInterface; use function React\Promise\reject; final class SecureConnector implements ConnectorInterface { + /** @var ConnectorInterface */ private $connector; + /** @var StreamEncryption */ private $streamEncryption; + /** @var array */ private $context; + /** @param array $context */ public function __construct(ConnectorInterface $connector, ?LoopInterface $loop = null, array $context = []) { $this->connector = $connector; @@ -20,7 +25,7 @@ public function __construct(ConnectorInterface $connector, ?LoopInterface $loop $this->context = $context; } - public function connect($uri) + public function connect(string $uri): PromiseInterface { if (\strpos($uri, '://') === false) { $uri = 'tls://' . $uri; @@ -62,7 +67,7 @@ public function connect($uri) $error->getCode() ); }); - }, function (\Exception $e) use ($uri) { + }, function (\Throwable $e) use ($uri) { if ($e instanceof \RuntimeException) { $message = \preg_replace('/^Connection to [^ ]+/', '', $e->getMessage()); $e = new \RuntimeException( @@ -77,7 +82,7 @@ public function connect($uri) if (\PHP_VERSION_ID < 80100) { $r->setAccessible(true); } - $trace = $r->getValue($e); + $trace = $e->getTrace(); // Exception trace arguments are not available on some PHP 7.4 installs // @codeCoverageIgnoreStart @@ -97,7 +102,8 @@ public function connect($uri) throw $e; }); - return new Promise( + /** @var Promise $result */ + $result = new Promise( function ($resolve, $reject) use ($promise) { $promise->then($resolve, $reject); }, @@ -109,9 +115,12 @@ function ($_, $reject) use (&$promise, $uri, &$connected) { )); } + assert($promise instanceof PromiseInterface); $promise->cancel(); $promise = null; } ); + + return $result; } } diff --git a/src/SecureServer.php b/src/SecureServer.php index 7ef5d94..a5ea95c 100644 --- a/src/SecureServer.php +++ b/src/SecureServer.php @@ -52,8 +52,11 @@ */ final class SecureServer extends EventEmitter implements ServerInterface { + /** @var ServerInterface */ private $tcp; + /** @var StreamEncryption */ private $encryption; + /** @var array */ private $context; /** @@ -115,7 +118,7 @@ final class SecureServer extends EventEmitter implements ServerInterface * * @param ServerInterface|TcpServer $tcp * @param ?LoopInterface $loop - * @param array $context + * @param array $context * @see TcpServer * @link https://www.php.net/manual/en/context.ssl.php for TLS context options */ @@ -130,7 +133,7 @@ public function __construct(ServerInterface $tcp, ?LoopInterface $loop = null, a $this->encryption = new StreamEncryption($loop ?? Loop::get()); $this->context = $context; - $this->tcp->on('connection', function ($connection) { + $this->tcp->on('connection', function (ConnectionInterface $connection) { $this->handleConnection($connection); }); $this->tcp->on('error', function ($error) { @@ -138,7 +141,7 @@ public function __construct(ServerInterface $tcp, ?LoopInterface $loop = null, a }); } - public function getAddress() + public function getAddress(): ?string { $address = $this->tcp->getAddress(); if ($address === null) { @@ -148,23 +151,23 @@ public function getAddress() return \str_replace('tcp://' , 'tls://', $address); } - public function pause() + public function pause(): void { $this->tcp->pause(); } - public function resume() + public function resume(): void { $this->tcp->resume(); } - public function close() + public function close(): void { - return $this->tcp->close(); + $this->tcp->close(); } /** @internal */ - public function handleConnection(ConnectionInterface $connection) + public function handleConnection(ConnectionInterface $connection): void { if (!$connection instanceof Connection) { $this->emit('error', [new \UnexpectedValueException('Base server does not use internal Connection class exposing stream resource')]); diff --git a/src/ServerInterface.php b/src/ServerInterface.php index aa79fa1..56511c2 100644 --- a/src/ServerInterface.php +++ b/src/ServerInterface.php @@ -75,7 +75,7 @@ interface ServerInterface extends EventEmitterInterface * * @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(); + public function getAddress(): ?string; /** * Pauses accepting new incoming connections. @@ -114,7 +114,7 @@ public function getAddress(); * @see self::resume() * @return void */ - public function pause(); + public function pause(): void; /** * Resumes accepting new incoming connections. @@ -136,7 +136,7 @@ public function pause(); * @see self::pause() * @return void */ - public function resume(); + public function resume(): void; /** * Shuts down this listening socket @@ -147,5 +147,5 @@ public function resume(); * * @return void */ - public function close(); + public function close(): void; } diff --git a/src/SocketServer.php b/src/SocketServer.php index 2106ff3..a61f2ef 100644 --- a/src/SocketServer.php +++ b/src/SocketServer.php @@ -7,6 +7,7 @@ final class SocketServer extends EventEmitter implements ServerInterface { + /** @var ServerInterface */ private $server; /** @@ -26,12 +27,12 @@ final class SocketServer extends EventEmitter implements ServerInterface * given event loop instance. * * @param string $uri - * @param array $context + * @param array{tcp?: array, tls?: array, unix?: array} $context * @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($uri, array $context = [], ?LoopInterface $loop = null) + public function __construct(string $uri, array $context = [], ?LoopInterface $loop = null) { // apply default options if not explicitly given $context += [ @@ -75,22 +76,22 @@ public function __construct($uri, array $context = [], ?LoopInterface $loop = nu }); } - public function getAddress() + public function getAddress(): ?string { return $this->server->getAddress(); } - public function pause() + public function pause(): void { $this->server->pause(); } - public function resume() + public function resume(): void { $this->server->resume(); } - public function close() + public function close(): void { $this->server->close(); } @@ -107,11 +108,12 @@ public static function accept($socket) { $errno = 0; $errstr = ''; - \set_error_handler(function ($_, $error) use (&$errno, &$errstr) { + \set_error_handler(function ($_, $error) use (&$errno, &$errstr): bool { // Match errstr from PHP's warning message. // stream_socket_accept(): accept failed: Connection timed out - $errstr = \preg_replace('#.*: #', '', $error); + $errstr = \preg_replace('#.*: #', '', $error) ?? $error; $errno = self::errno($errstr); + return true; }); $newSocket = \stream_socket_accept($socket, 0); @@ -144,13 +146,11 @@ public static function accept($socket) * @copyright Copyright (c) 2023 Christian Lück, taken from https://github.com/clue/errno with permission * @codeCoverageIgnore */ - public static function errno($errstr) + public static function errno(string $errstr): int { // 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` @@ -193,7 +193,7 @@ public static function errno($errstr) * @copyright Copyright (c) 2023 Christian Lück, taken from https://github.com/clue/errno with permission * @codeCoverageIgnore */ - public static function errconst($errno) + public static function errconst(int $errno): string { // 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` diff --git a/src/StreamEncryption.php b/src/StreamEncryption.php index b03b79b..bdd2932 100644 --- a/src/StreamEncryption.php +++ b/src/StreamEncryption.php @@ -4,6 +4,7 @@ use React\EventLoop\LoopInterface; use React\Promise\Deferred; +use React\Promise\PromiseInterface; /** * This class is considered internal and its API should not be relied upon @@ -13,11 +14,14 @@ */ class StreamEncryption { + /** @var LoopInterface */ private $loop; + /** @var int */ private $method; + /** @var bool */ private $server; - public function __construct(LoopInterface $loop, $server = true) + public function __construct(LoopInterface $loop, bool $server = true) { $this->loop = $loop; $this->server = $server; @@ -45,7 +49,7 @@ public function __construct(LoopInterface $loop, $server = true) * @param Connection $stream * @return \React\Promise\PromiseInterface */ - public function enable(Connection $stream) + public function enable(Connection $stream): PromiseInterface { return $this->toggle($stream, true); } @@ -55,7 +59,7 @@ public function enable(Connection $stream) * @param bool $toggle * @return \React\Promise\PromiseInterface */ - public function toggle(Connection $stream, $toggle) + public function toggle(Connection $stream, bool $toggle): PromiseInterface { // pause actual stream instance to continue operation on raw stream socket $stream->pause(); @@ -63,6 +67,7 @@ public function toggle(Connection $stream, $toggle) // TODO: add write() event to make sure we're not sending any excessive data // cancelling this leaves this stream in an inconsistent state… + /** @var Deferred $deferred */ $deferred = new Deferred(function () { throw new \RuntimeException(); }); @@ -71,6 +76,7 @@ public function toggle(Connection $stream, $toggle) $socket = $stream->stream; // get crypto method from context options or use global setting from constructor + /** @var array{ssl?: array{crypto_method?: int}} $context */ $context = \stream_context_get_options($socket); $method = $context['ssl']['crypto_method'] ?? $this->method; @@ -106,19 +112,24 @@ public function toggle(Connection $stream, $toggle) * @param int $method * @return void */ - public function toggleCrypto($socket, Deferred $deferred, $toggle, $method) + public function toggleCrypto($socket, Deferred $deferred, bool $toggle, int $method): void { $error = null; - \set_error_handler(function ($_, $errstr) use (&$error) { + \set_error_handler(function ($_, $errstr) use (&$error): bool { $error = \str_replace(["\r", "\n"], ' ', $errstr); // remove useless function name from error message if (($pos = \strpos($error, "): ")) !== false) { - $error = \substr($error, $pos + 3); + $error = (string) \substr($error, $pos + 3); } + return true; }); - $result = \stream_socket_enable_crypto($socket, $toggle, $method); + if ($toggle) { + // Configure the full crypto bitmask through the SSL context, including combined protocol flags. + \stream_context_set_option($socket, 'ssl', 'crypto_method', $method); + } + $result = \stream_socket_enable_crypto($socket, $toggle); \restore_error_handler(); diff --git a/src/TcpConnector.php b/src/TcpConnector.php index 0949184..a2e6b0c 100644 --- a/src/TcpConnector.php +++ b/src/TcpConnector.php @@ -5,20 +5,24 @@ use React\EventLoop\Loop; use React\EventLoop\LoopInterface; use React\Promise\Promise; +use React\Promise\PromiseInterface; use function React\Promise\reject; final class TcpConnector implements ConnectorInterface { + /** @var LoopInterface */ private $loop; + /** @var array */ private $context; + /** @param array $context */ public function __construct(?LoopInterface $loop = null, array $context = []) { $this->loop = $loop ?? Loop::get(); $this->context = $context; } - public function connect($uri) + public function connect(string $uri): PromiseInterface { if (\strpos($uri, '://') === false) { $uri = 'tcp://' . $uri; @@ -77,14 +81,15 @@ public function connect($uri) if (false === $stream) { return reject(new \RuntimeException( - 'Connection to ' . $uri . ' failed: ' . $errstr . SocketServer::errconst($errno), - $errno + 'Connection to ' . $uri . ' failed: ' . $errstr . SocketServer::errconst($errno ?? 0), + $errno ?? 0 )); } // wait for connection - return new Promise(function ($resolve, $reject) use ($stream, $uri) { - $this->loop->addWriteStream($stream, function ($stream) use ($resolve, $reject, $uri) { + /** @var Promise $result */ + $result = new Promise(function ($resolve, $reject) use ($stream, $uri) { + $this->loop->addWriteStream($stream, function () use ($stream, $resolve, $reject, $uri) { $this->loop->removeWriteStream($stream); // The following hack looks like the only way to @@ -95,7 +100,8 @@ public function connect($uri) 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); + $errno = $socket === false ? 0 : \socket_get_option($socket, \SOL_SOCKET, \SO_ERROR); + $errno = \is_int($errno) ? $errno : 0; $errstr = \socket_strerror($errno); } elseif (\PHP_OS === 'Linux') { // Linux reports socket errno and errstr again when trying to write to the dead socket. @@ -103,12 +109,13 @@ public function connect($uri) // 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) { + \set_error_handler(function ($_, $error) use (&$errno, &$errstr): bool { // 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; + return true; }); \fwrite($stream, \PHP_EOL); @@ -139,5 +146,7 @@ public function connect($uri) \defined('SOCKET_ECONNABORTED') ? \SOCKET_ECONNABORTED : 103 ); }); + + return $result; } } diff --git a/src/TcpServer.php b/src/TcpServer.php index a49ca9d..069f7bc 100644 --- a/src/TcpServer.php +++ b/src/TcpServer.php @@ -32,8 +32,11 @@ */ final class TcpServer extends EventEmitter implements ServerInterface { + /** @var resource */ private $master; + /** @var LoopInterface */ private $loop; + /** @var bool */ private $listening = false; /** @@ -120,13 +123,13 @@ final class TcpServer extends EventEmitter implements ServerInterface * Passing unknown context options has no effect. * The `backlog` context option defaults to `511` unless given explicitly. * - * @param string|int $uri + * @param string $uri * @param ?LoopInterface $loop - * @param array $context + * @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 = []) + public function __construct(string $uri, ?LoopInterface $loop = null, array $context = []) { $this->loop = $loop ?? Loop::get(); @@ -158,6 +161,7 @@ public function __construct($uri, ?LoopInterface $loop = null, array $context = ); } + /** @var array{scheme: string, host: string, port: int} $parts */ if (@\inet_pton(\trim($parts['host'], '[]')) === false) { throw new \InvalidArgumentException( 'Given URI "' . $uri . '" does not contain a valid host IP (EINVAL)', @@ -165,37 +169,41 @@ public function __construct($uri, ?LoopInterface $loop = null, array $context = ); } - $this->master = @\stream_socket_server( + $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 (false === $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); + $errno = SocketServer::errno($errstr ?? ''); } throw new \RuntimeException( - 'Failed to listen on "' . $uri . '": ' . $errstr . SocketServer::errconst($errno), - $errno + 'Failed to listen on "' . $uri . '": ' . $errstr . SocketServer::errconst($errno ?? 0), + $errno ?? 0 ); } + $this->master = $master; \stream_set_blocking($this->master, false); $this->resume(); } - public function getAddress() + public function getAddress(): ?string { if (!\is_resource($this->master)) { return null; } $address = \stream_socket_get_name($this->master, false); + if ($address === false) { + return null; + } // check if this is an IPv6 address which includes multiple colons but no square brackets $pos = \strrpos($address, ':'); @@ -206,7 +214,7 @@ public function getAddress() return 'tcp://' . $address; } - public function pause() + public function pause(): void { if (!$this->listening) { return; @@ -216,15 +224,15 @@ public function pause() $this->listening = false; } - public function resume() + public function resume(): void { if ($this->listening || !\is_resource($this->master)) { return; } - $this->loop->addReadStream($this->master, function ($master) { + $this->loop->addReadStream($this->master, function () { try { - $newSocket = SocketServer::accept($master); + $newSocket = SocketServer::accept($this->master); } catch (\RuntimeException $e) { $this->emit('error', [$e]); return; @@ -234,7 +242,7 @@ public function resume() $this->listening = true; } - public function close() + public function close(): void { if (!\is_resource($this->master)) { return; @@ -245,8 +253,11 @@ public function close() $this->removeAllListeners(); } - /** @internal */ - public function handleConnection($socket) + /** + * @internal + * @param resource $socket + */ + public function handleConnection($socket): void { $this->emit('connection', [ new Connection($socket, $this->loop) diff --git a/src/TimeoutConnector.php b/src/TimeoutConnector.php index 5031a0b..28e7fa5 100644 --- a/src/TimeoutConnector.php +++ b/src/TimeoutConnector.php @@ -5,25 +5,31 @@ use React\EventLoop\Loop; use React\EventLoop\LoopInterface; use React\Promise\Promise; +use React\Promise\PromiseInterface; final class TimeoutConnector implements ConnectorInterface { + /** @var ConnectorInterface */ private $connector; + /** @var float */ private $timeout; + /** @var LoopInterface */ private $loop; - public function __construct(ConnectorInterface $connector, $timeout, ?LoopInterface $loop = null) + public function __construct(ConnectorInterface $connector, float $timeout, ?LoopInterface $loop = null) { $this->connector = $connector; $this->timeout = $timeout; $this->loop = $loop ?? Loop::get(); } - public function connect($uri) + public function connect(string $uri): PromiseInterface { $promise = $this->connector->connect($uri); - return new Promise(function ($resolve, $reject) use ($promise, $uri) { + /** @var Promise $result */ + $result = new Promise(function ($resolve, $reject) use ($promise, $uri) { + /** @var \React\EventLoop\TimerInterface|false|null $timer */ $timer = null; $promise = $promise->then(function ($v) use (&$timer, $resolve) { if ($timer) { @@ -53,16 +59,18 @@ public function connect($uri) // 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')); + assert($promise instanceof PromiseInterface && \is_callable([$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')); + assert($promise instanceof PromiseInterface && \is_callable([$promise, 'cancel'])); $promise->cancel(); $promise = null; }); + + return $result; } } diff --git a/src/UnixConnector.php b/src/UnixConnector.php index ecc6262..e5202ac 100644 --- a/src/UnixConnector.php +++ b/src/UnixConnector.php @@ -4,6 +4,7 @@ use React\EventLoop\Loop; use React\EventLoop\LoopInterface; +use React\Promise\PromiseInterface; use function React\Promise\reject; use function React\Promise\resolve; @@ -15,6 +16,7 @@ */ final class UnixConnector implements ConnectorInterface { + /** @var LoopInterface */ private $loop; public function __construct(?LoopInterface $loop = null) @@ -22,7 +24,7 @@ public function __construct(?LoopInterface $loop = null) $this->loop = $loop ?? Loop::get(); } - public function connect($path) + public function connect(string $path): PromiseInterface { if (\strpos($path, '://') === false) { $path = 'unix://' . $path; @@ -37,8 +39,8 @@ public function connect($path) if (!$resource) { return reject(new \RuntimeException( - 'Unable to connect to unix domain socket "' . $path . '": ' . $errstr . SocketServer::errconst($errno), - $errno + 'Unable to connect to unix domain socket "' . $path . '": ' . $errstr . SocketServer::errconst($errno ?? 0), + $errno ?? 0 )); } diff --git a/src/UnixServer.php b/src/UnixServer.php index 8b4e416..80c579c 100644 --- a/src/UnixServer.php +++ b/src/UnixServer.php @@ -21,8 +21,11 @@ */ final class UnixServer extends EventEmitter implements ServerInterface { + /** @var resource */ private $master; + /** @var LoopInterface */ private $loop; + /** @var bool */ private $listening = false; /** @@ -44,11 +47,11 @@ final class UnixServer extends EventEmitter implements ServerInterface * * @param string $path * @param ?LoopInterface $loop - * @param array $context + * @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($path, ?LoopInterface $loop = null, array $context = []) + public function __construct(string $path, ?LoopInterface $loop = null, array $context = []) { $this->loop = $loop ?? Loop::get(); @@ -63,16 +66,17 @@ public function __construct($path, ?LoopInterface $loop = null, array $context = $errno = 0; $errstr = ''; - \set_error_handler(function ($_, $error) use (&$errno, &$errstr) { + \set_error_handler(function ($_, $error) use (&$errno, &$errstr): bool { // 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); } + return true; }); - $this->master = \stream_socket_server( + $master = \stream_socket_server( $path, $errno, $errstr, @@ -82,18 +86,19 @@ public function __construct($path, ?LoopInterface $loop = null, array $context = \restore_error_handler(); - if (false === $this->master) { + if (false === $master) { throw new \RuntimeException( - 'Failed to listen on Unix domain socket "' . $path . '": ' . $errstr . SocketServer::errconst($errno), - $errno + 'Failed to listen on Unix domain socket "' . $path . '": ' . $errstr . SocketServer::errconst($errno ?? 0), + $errno ?? 0 ); } - \stream_set_blocking($this->master, 0); + $this->master = $master; + \stream_set_blocking($this->master, false); $this->resume(); } - public function getAddress() + public function getAddress(): ?string { if (!\is_resource($this->master)) { return null; @@ -102,7 +107,7 @@ public function getAddress() return 'unix://' . \stream_socket_get_name($this->master, false); } - public function pause() + public function pause(): void { if (!$this->listening) { return; @@ -112,15 +117,15 @@ public function pause() $this->listening = false; } - public function resume() + public function resume(): void { if ($this->listening || !is_resource($this->master)) { return; } - $this->loop->addReadStream($this->master, function ($master) { + $this->loop->addReadStream($this->master, function () { try { - $newSocket = SocketServer::accept($master); + $newSocket = SocketServer::accept($this->master); } catch (\RuntimeException $e) { $this->emit('error', [$e]); return; @@ -130,7 +135,7 @@ public function resume() $this->listening = true; } - public function close() + public function close(): void { if (!\is_resource($this->master)) { return; @@ -141,8 +146,11 @@ public function close() $this->removeAllListeners(); } - /** @internal */ - public function handleConnection($socket) + /** + * @internal + * @param resource $socket + */ + public function handleConnection($socket): void { $connection = new Connection($socket, $this->loop); $connection->unix = true; diff --git a/tests/FixedUriConnectorTest.php b/tests/FixedUriConnectorTest.php index b649b61..c80acf9 100644 --- a/tests/FixedUriConnectorTest.php +++ b/tests/FixedUriConnectorTest.php @@ -2,6 +2,7 @@ namespace React\Tests\Socket; +use React\Promise\PromiseInterface; use React\Socket\ConnectorInterface; use React\Socket\FixedUriConnector; @@ -9,11 +10,12 @@ class FixedUriConnectorTest extends TestCase { public function testWillInvokeGivenConnector() { + $promise = $this->createMock(PromiseInterface::class); $base = $this->createMock(ConnectorInterface::class); - $base->expects($this->once())->method('connect')->with('test')->willReturn('ret'); + $base->expects($this->once())->method('connect')->with('test')->willReturn($promise); $connector = new FixedUriConnector('test', $base); - $this->assertEquals('ret', $connector->connect('ignored')); + $this->assertSame($promise, $connector->connect('ignored')); } } diff --git a/tests/FunctionalSecureServerTest.php b/tests/FunctionalSecureServerTest.php index b30bbbb..a433bba 100644 --- a/tests/FunctionalSecureServerTest.php +++ b/tests/FunctionalSecureServerTest.php @@ -81,7 +81,8 @@ public function testClientUsesTls13ByDefaultWhenSupportedByOpenSSL() $server->close(); } - public function testClientUsesTls12WhenCryptoMethodIsExplicitlyConfiguredByClient() + /** @dataProvider provideClientCryptoMethods */ + public function testClientUsesExpectedTlsVersionWhenCryptoMethodIsExplicitlyConfiguredByClient($method, $expectedProtocol) { $server = new TcpServer(0); $server = new SecureServer($server, null, [ @@ -90,30 +91,47 @@ public function testClientUsesTls12WhenCryptoMethodIsExplicitlyConfiguredByClien $connector = new SecureConnector(new TcpConnector(), null, [ 'verify_peer' => false, - 'crypto_method' => STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT + 'crypto_method' => $method ]); $promise = $connector->connect($server->getAddress()); - /* @var ConnectionInterface $client */ - $client = await(timeout($promise, self::TIMEOUT)); + try { + /* @var ConnectionInterface $client */ + $client = await(timeout($promise, self::TIMEOUT)); - $this->assertInstanceOf(Connection::class, $client); - $this->assertTrue(isset($client->stream)); + $this->assertInstanceOf(Connection::class, $client); + $this->assertTrue(isset($client->stream)); + + $meta = stream_get_meta_data($client->stream); + $this->assertTrue(isset($meta['crypto']['protocol'])); + // Older PHP versions expose TLS 1.3 only through the cipher version. + $this->assertEquals($expectedProtocol, $meta['crypto']['protocol'] === 'UNKNOWN' ? $meta['crypto']['cipher_version'] : $meta['crypto']['protocol']); + } finally { + if (isset($client)) { + $client->close(); + } + $server->close(); + } + } - $meta = stream_get_meta_data($client->stream); - $this->assertTrue(isset($meta['crypto']['protocol'])); - $this->assertEquals('TLSv1.2', $meta['crypto']['protocol']); + public function provideClientCryptoMethods() + { + // PHP < 7.3 does not cap combined protocol flags at TLS 1.2 with OpenSSL 1.1.1+. + $combinedProtocol = PHP_VERSION_ID < 70300 && $this->supportsTls13() ? 'TLSv1.3' : 'TLSv1.2'; - $client->close(); - $server->close(); + return [ + 'single method' => [STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT, 'TLSv1.2'], + 'combined methods' => [STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT | STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT, $combinedProtocol] + ]; } - public function testClientUsesTls12WhenCryptoMethodIsExplicitlyConfiguredByServer() + /** @dataProvider provideServerCryptoMethods */ + public function testClientUsesExpectedTlsVersionWhenCryptoMethodIsExplicitlyConfiguredByServer($method, $expectedProtocol) { $server = new TcpServer(0); $server = new SecureServer($server, null, [ 'local_cert' => __DIR__ . '/../examples/localhost.pem', - 'crypto_method' => STREAM_CRYPTO_METHOD_TLSv1_2_SERVER + 'crypto_method' => $method ]); $connector = new SecureConnector(new TcpConnector(), null, [ @@ -121,18 +139,34 @@ public function testClientUsesTls12WhenCryptoMethodIsExplicitlyConfiguredByServe ]); $promise = $connector->connect($server->getAddress()); - /* @var ConnectionInterface $client */ - $client = await(timeout($promise, self::TIMEOUT)); + try { + /* @var ConnectionInterface $client */ + $client = await(timeout($promise, self::TIMEOUT)); - $this->assertInstanceOf(Connection::class, $client); - $this->assertTrue(isset($client->stream)); + $this->assertInstanceOf(Connection::class, $client); + $this->assertTrue(isset($client->stream)); + + $meta = stream_get_meta_data($client->stream); + $this->assertTrue(isset($meta['crypto']['protocol'])); + // Older PHP versions expose TLS 1.3 only through the cipher version. + $this->assertEquals($expectedProtocol, $meta['crypto']['protocol'] === 'UNKNOWN' ? $meta['crypto']['cipher_version'] : $meta['crypto']['protocol']); + } finally { + if (isset($client)) { + $client->close(); + } + $server->close(); + } + } - $meta = stream_get_meta_data($client->stream); - $this->assertTrue(isset($meta['crypto']['protocol'])); - $this->assertEquals('TLSv1.2', $meta['crypto']['protocol']); + public function provideServerCryptoMethods() + { + // PHP < 7.3 does not cap combined protocol flags at TLS 1.2 with OpenSSL 1.1.1+. + $combinedProtocol = PHP_VERSION_ID < 70300 && $this->supportsTls13() ? 'TLSv1.3' : 'TLSv1.2'; - $client->close(); - $server->close(); + return [ + 'single method' => [STREAM_CRYPTO_METHOD_TLSv1_2_SERVER, 'TLSv1.2'], + 'combined methods' => [STREAM_CRYPTO_METHOD_TLSv1_1_SERVER | STREAM_CRYPTO_METHOD_TLSv1_2_SERVER, $combinedProtocol] + ]; } public function testClientUsesTls10WhenCryptoMethodIsExplicitlyConfiguredByClient() diff --git a/tests/Stub/ConnectionStub.php b/tests/Stub/ConnectionStub.php index ae440b4..cd03e13 100644 --- a/tests/Stub/ConnectionStub.php +++ b/tests/Stub/ConnectionStub.php @@ -21,11 +21,11 @@ public function isWritable() return true; } - public function pause() + public function pause(): void { } - public function resume() + public function resume(): void { } @@ -47,7 +47,7 @@ public function end($data = null) { } - public function close() + public function close(): void { } @@ -56,8 +56,13 @@ public function getData() return $this->data; } - public function getRemoteAddress() + public function getRemoteAddress(): ?string { return '127.0.0.1'; } + + public function getLocalAddress(): ?string + { + return null; + } } diff --git a/tests/Stub/ServerStub.php b/tests/Stub/ServerStub.php index d9e74f4..25ab1d0 100644 --- a/tests/Stub/ServerStub.php +++ b/tests/Stub/ServerStub.php @@ -7,12 +7,20 @@ class ServerStub extends EventEmitter implements ServerInterface { - public function getAddress() + public function getAddress(): ?string { return '127.0.0.1:80'; } - public function close() + public function close(): void + { + } + + public function pause(): void + { + } + + public function resume(): void { } } diff --git a/tests/TypeDeclarationsTest.php b/tests/TypeDeclarationsTest.php new file mode 100644 index 0000000..4a02c27 --- /dev/null +++ b/tests/TypeDeclarationsTest.php @@ -0,0 +1,67 @@ +createMock(ConnectorInterface::class); + $base->expects($this->never())->method('connect'); + $connector = new Connector(['tcp' => $base, 'dns' => false, 'timeout' => false]); + + $this->expectException(\TypeError::class); + $connector->connect([]); + } + + public function testServerRejectsInvalidUriTypeBeforeListening() + { + $this->expectException(\TypeError::class); + new SocketServer(null); + } + + public function testTimeoutRejectsNonNumericValue() + { + $connector = $this->createMock(ConnectorInterface::class); + + $this->expectException(\TypeError::class); + new TimeoutConnector($connector, 'invalid'); + } + + public function testLimitingServerRejectsFractionalLimit() + { + $server = $this->createMock(ServerInterface::class); + $server->expects($this->never())->method('on'); + + $this->expectException(\TypeError::class); + new LimitingServer($server, 1.5); + } + + public function testCustomServerSupportsTypedInterfaceAndUnlimitedConnections() + { + $base = new ServerStub(); + $server = new LimitingServer($base, null); + $connection = new ConnectionStub(); + $base->emit('connection', [$connection]); + + $this->assertSame([$connection], $server->getConnections()); + $this->assertSame('127.0.0.1:80', $server->getAddress()); + $this->assertSame('127.0.0.1', $connection->getRemoteAddress()); + $this->assertNull($connection->getLocalAddress()); + + $server->pause(); + $server->resume(); + $server->close(); + } +}