From 2c5c8630ce6b3d9bee1f2660c35c7e50d0e0faa2 Mon Sep 17 00:00:00 2001 From: Shuvro Roy Date: Fri, 18 Sep 2026 00:11:47 +0600 Subject: [PATCH 1/6] feat!: add native API types and PHPStan max-level analysis Declare parameter and return types across Socket interfaces and implementations, and document promise, option, resource and state types. Add a dedicated PHPStan 2 tool project and CI job while retaining the library's PHP 7.1 dependency and syntax compatibility. Cover invalid argument types and custom interface implementations, update promise mocks, and document the v3 migration requirements. Refs: https://github.com/reactphp/socket/issues/312 BREAKING CHANGE: Custom Socket interface implementations must declare the new compatible method types. Strict callers must pass URI strings and correctly typed timeout and connection-limit arguments. --- .gitattributes | 2 + .github/workflows/ci.yml | 13 +++++ .gitignore | 2 + README.md | 11 +++++ UPGRADE.md | 29 +++++++++++ phpstan.neon.dist | 7 +++ src/Connection.php | 35 +++++++++----- src/ConnectionInterface.php | 4 +- src/Connector.php | 12 +++-- src/ConnectorInterface.php | 4 +- src/DnsConnector.php | 17 +++++-- src/FdServer.php | 40 +++++++++------ src/FixedUriConnector.php | 8 ++- src/HappyEyeBallsConnectionBuilder.php | 65 ++++++++++++++++++------- src/HappyEyeBallsConnector.php | 6 ++- src/LimitingServer.php | 24 +++++---- src/SecureConnector.php | 17 +++++-- src/SecureServer.php | 19 +++++--- src/ServerInterface.php | 8 +-- src/SocketServer.php | 24 ++++----- src/StreamEncryption.php | 22 ++++++--- src/TcpConnector.php | 23 ++++++--- src/TcpServer.php | 42 ++++++++++------ src/TimeoutConnector.php | 18 +++++-- src/UnixConnector.php | 8 +-- src/UnixServer.php | 40 +++++++++------ tests/FixedUriConnectorTest.php | 6 ++- tests/Stub/ConnectionStub.php | 13 +++-- tests/Stub/ServerStub.php | 12 ++++- tests/TypeDeclarationsTest.php | 67 ++++++++++++++++++++++++++ tools/phpstan/composer.json | 5 ++ 31 files changed, 444 insertions(+), 159 deletions(-) create mode 100644 UPGRADE.md create mode 100644 phpstan.neon.dist create mode 100644 tests/TypeDeclarationsTest.php create mode 100644 tools/phpstan/composer.json diff --git a/.gitattributes b/.gitattributes index fc0be872..d9cd9cb6 100644 --- a/.gitattributes +++ b/.gitattributes @@ -4,4 +4,6 @@ /examples/ export-ignore /phpunit.xml.dist export-ignore /phpunit.xml.legacy export-ignore +/phpstan.neon.dist export-ignore /tests/ export-ignore +/tools/ export-ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 71149ad0..393ee7bb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,19 @@ on: pull_request: jobs: + PHPStan: + name: PHPStan + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.5' + coverage: none + - run: composer install + - run: composer install --working-dir=tools/phpstan + - run: tools/phpstan/vendor/bin/phpstan analyse --no-progress + PHPUnit: name: PHPUnit (PHP ${{ matrix.php }} on ${{ matrix.os }}) runs-on: ${{ matrix.os }} diff --git a/.gitignore b/.gitignore index c8153b57..6fea781c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ /composer.lock /vendor/ +/tools/phpstan/composer.lock +/tools/phpstan/vendor/ diff --git a/README.md b/README.md index 3257b688..ba1dc806 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,16 @@ 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, use PHP 7.4+ and run: + +```bash +composer install --working-dir=tools/phpstan +tools/phpstan/vendor/bin/phpstan analyse +``` + +PHPStan is installed separately so the library and its test suite continue to +support PHP 7.1. The analysis targets this minimum supported PHP version. + ## License MIT, see [LICENSE file](LICENSE). diff --git a/UPGRADE.md b/UPGRADE.md new file mode 100644 index 00000000..1d04c393 --- /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/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 00000000..abd1326d --- /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 6bc8deb3..7b136aa8 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 64613b58..04e842ed 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 8a5e994d..2c94bf80 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 1f07b753..863d0207 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 4a8d1a03..ff7f2962 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,7 +20,7 @@ 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) { @@ -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 b00681c2..0dc3e649 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 f83241d6..19e793ae 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 57a94aae..663e7b0c 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 ($connectionPromise instanceof PromiseInterface && \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 ($resolverPromise instanceof PromiseInterface && \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,8 +316,9 @@ 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); @@ -307,7 +338,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 89ec203a..d185eb85 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,7 +24,7 @@ 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) { diff --git a/src/LimitingServer.php b/src/LimitingServer.php index 4742e252..d75a5f1d 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 7626b0a6..7e80a949 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 7ef5d94d..a5ea95c3 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 aa79fa17..56511c2a 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 2106ff36..a61f2ef4 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 b03b79b8..fa879f8c 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,21 @@ 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); + // PHP accepts bitwise combinations of crypto methods; PHPStan only lists individual constants and a few masks. + $result = \stream_socket_enable_crypto($socket, $toggle, $method); // @phpstan-ignore argument.type \restore_error_handler(); diff --git a/src/TcpConnector.php b/src/TcpConnector.php index 0949184e..a2e6b0c5 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 a49ca9d1..42762cb3 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(); @@ -165,37 +168,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 +213,7 @@ public function getAddress() return 'tcp://' . $address; } - public function pause() + public function pause(): void { if (!$this->listening) { return; @@ -216,15 +223,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 +241,7 @@ public function resume() $this->listening = true; } - public function close() + public function close(): void { if (!\is_resource($this->master)) { return; @@ -245,8 +252,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 5031a0b6..28e7fa50 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 ecc62620..e5202acb 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 8b4e416b..80c579cb 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 b649b61a..c80acf99 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/Stub/ConnectionStub.php b/tests/Stub/ConnectionStub.php index ae440b4a..cd03e133 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 d9e74f47..25ab1d0b 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 00000000..4a02c271 --- /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(); + } +} diff --git a/tools/phpstan/composer.json b/tools/phpstan/composer.json new file mode 100644 index 00000000..2c8976d5 --- /dev/null +++ b/tools/phpstan/composer.json @@ -0,0 +1,5 @@ +{ + "require-dev": { + "phpstan/phpstan": "^2.1" + } +} From 33169ab7fa33752f9d4c43226bc75efbeaedc5a7 Mon Sep 17 00:00:00 2001 From: Shuvro Roy Date: Fri, 18 Sep 2026 00:22:56 +0600 Subject: [PATCH 2/6] ci: run PHPStan across supported PHP and OS versions Match PHPUnit's 20-job PHP and operating system matrix. Require PHPStan from the root Composer development dependencies with ^1.4.10 || ^2.1, and remove the separate tools project. Keep maximum-level analysis compatible with PHPStan 1.4, 1.12 and 2 by making parser and queue invariants explicit and handling the version-dependent crypto bitmask signature in configuration. --- .gitattributes | 1 - .github/workflows/ci.yml | 24 +++++++++++++++++++----- .gitignore | 2 -- README.md | 11 ++++++----- composer.json | 1 + phpstan.neon.dist | 7 +++++++ src/DnsConnector.php | 2 +- src/HappyEyeBallsConnectionBuilder.php | 18 +++++++++++------- src/HappyEyeBallsConnector.php | 2 +- src/StreamEncryption.php | 3 +-- src/TcpServer.php | 1 + tools/phpstan/composer.json | 5 ----- 12 files changed, 48 insertions(+), 29 deletions(-) delete mode 100644 tools/phpstan/composer.json diff --git a/.gitattributes b/.gitattributes index d9cd9cb6..a60463a2 100644 --- a/.gitattributes +++ b/.gitattributes @@ -6,4 +6,3 @@ /phpunit.xml.legacy export-ignore /phpstan.neon.dist export-ignore /tests/ export-ignore -/tools/ export-ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 393ee7bb..96b50b6f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,17 +6,31 @@ on: jobs: PHPStan: - name: PHPStan - runs-on: ubuntu-24.04 + name: PHPStan (PHP ${{ matrix.php }} on ${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: + - ubuntu-24.04 + 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: '8.5' + php-version: ${{ matrix.php }} coverage: none - run: composer install - - run: composer install --working-dir=tools/phpstan - - run: tools/phpstan/vendor/bin/phpstan analyse --no-progress + - run: vendor/bin/phpstan analyse --no-progress PHPUnit: name: PHPUnit (PHP ${{ matrix.php }} on ${{ matrix.os }}) diff --git a/.gitignore b/.gitignore index 6fea781c..c8153b57 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,2 @@ /composer.lock /vendor/ -/tools/phpstan/composer.lock -/tools/phpstan/vendor/ diff --git a/README.md b/README.md index ba1dc806..08005e33 100644 --- a/README.md +++ b/README.md @@ -1533,15 +1533,16 @@ 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, use PHP 7.4+ and run: +To check the source code with PHPStan at its maximum level, run: ```bash -composer install --working-dir=tools/phpstan -tools/phpstan/vendor/bin/phpstan analyse +vendor/bin/phpstan analyse ``` -PHPStan is installed separately so the library and its test suite continue to -support PHP 7.1. The analysis targets this minimum supported PHP version. +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 diff --git a/composer.json b/composer.json index 9aa4d04d..a0e939b4 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 index abd1326d..8a3ef14e 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -5,3 +5,10 @@ parameters: phpVersion: 70100 # Retain runtime guards for older supported Promise implementations. treatPhpDocTypesAsCertain: false + # The crypto bitmask signature is already correct in older PHPStan releases. + reportUnmatchedIgnoredErrors: false + ignoreErrors: + # PHP accepts combined crypto flags; some PHPStan versions only list individual constants. + - + message: '#^Parameter \#3 \$crypto_method of function stream_socket_enable_crypto expects .+, int given\.$#' + path: src/StreamEncryption.php diff --git a/src/DnsConnector.php b/src/DnsConnector.php index ff7f2962..ac59f925 100644 --- a/src/DnsConnector.php +++ b/src/DnsConnector.php @@ -26,7 +26,7 @@ public function connect(string $uri): PromiseInterface 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/HappyEyeBallsConnectionBuilder.php b/src/HappyEyeBallsConnectionBuilder.php index 663e7b0c..8333b7d0 100644 --- a/src/HappyEyeBallsConnectionBuilder.php +++ b/src/HappyEyeBallsConnectionBuilder.php @@ -276,14 +276,14 @@ public function cleanUp(): void // cancel pending connection attempts foreach ($this->connectionPromises as $connectionPromise) { - if ($connectionPromise instanceof PromiseInterface && \is_callable([$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 && \is_callable([$resolverPromise, 'cancel'])) { + if (\is_callable([$resolverPromise, 'cancel'])) { $resolverPromise->cancel(); } } @@ -324,12 +324,16 @@ public function mixIpsIntoConnectQueue(array $ips): void $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; } } } diff --git a/src/HappyEyeBallsConnector.php b/src/HappyEyeBallsConnector.php index d185eb85..c5a36e5d 100644 --- a/src/HappyEyeBallsConnector.php +++ b/src/HappyEyeBallsConnector.php @@ -30,7 +30,7 @@ public function connect(string $uri): PromiseInterface 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/StreamEncryption.php b/src/StreamEncryption.php index fa879f8c..657f327b 100644 --- a/src/StreamEncryption.php +++ b/src/StreamEncryption.php @@ -125,8 +125,7 @@ public function toggleCrypto($socket, Deferred $deferred, bool $toggle, int $met return true; }); - // PHP accepts bitwise combinations of crypto methods; PHPStan only lists individual constants and a few masks. - $result = \stream_socket_enable_crypto($socket, $toggle, $method); // @phpstan-ignore argument.type + $result = \stream_socket_enable_crypto($socket, $toggle, $method); \restore_error_handler(); diff --git a/src/TcpServer.php b/src/TcpServer.php index 42762cb3..069f7bcb 100644 --- a/src/TcpServer.php +++ b/src/TcpServer.php @@ -161,6 +161,7 @@ public function __construct(string $uri, ?LoopInterface $loop = null, array $con ); } + /** @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)', diff --git a/tools/phpstan/composer.json b/tools/phpstan/composer.json deleted file mode 100644 index 2c8976d5..00000000 --- a/tools/phpstan/composer.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "require-dev": { - "phpstan/phpstan": "^2.1" - } -} From 01bca49b645017aa698dfecfd6279b3b68c759bc Mon Sep 17 00:00:00 2001 From: Shuvro Roy Date: Fri, 18 Sep 2026 00:24:47 +0600 Subject: [PATCH 3/6] Update ci.yml --- .github/workflows/ci.yml | 30 ++++++++++++++---------------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 96b50b6f..dd428b7f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,13 +5,14 @@ on: pull_request: jobs: - PHPStan: - name: PHPStan (PHP ${{ matrix.php }} on ${{ matrix.os }}) + 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 @@ -28,18 +29,19 @@ jobs: - uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} - coverage: none + coverage: ${{ matrix.php < 8.0 && 'xdebug' || 'pcov' }} + ini-file: development - run: composer install - - run: vendor/bin/phpstan analyse --no-progress + - 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: - name: PHPUnit (PHP ${{ matrix.php }} on ${{ matrix.os }}) - runs-on: ${{ matrix.os }} + PHPStan: + name: PHPStan (PHP ${{ matrix.php }}) + runs-on: ubuntu-24.04 strategy: matrix: - os: - - ubuntu-24.04 - - windows-2022 php: - 8.5 - 8.4 @@ -56,13 +58,9 @@ jobs: - uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} - coverage: ${{ matrix.php < 8.0 && 'xdebug' || 'pcov' }} - ini-file: development + coverage: none - 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 }} + - run: vendor/bin/phpstan analyse --no-progress PHPUnit-macOS: name: PHPUnit (macOS) From 21b3a713da5770c8c3890837922a4991d83cdd72 Mon Sep 17 00:00:00 2001 From: Shuvro Roy Date: Fri, 18 Sep 2026 00:32:24 +0600 Subject: [PATCH 4/6] chore: update PHPStan job name to include OS version --- .github/workflows/ci.yml | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd428b7f..b0fa11b6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,8 +37,21 @@ jobs: - 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 + PHPStan: - name: PHPStan (PHP ${{ matrix.php }}) + name: PHPStan (PHP ${{ matrix.php }} on ubuntu-24.04) runs-on: ubuntu-24.04 strategy: matrix: @@ -61,16 +74,3 @@ jobs: coverage: none - run: composer install - run: vendor/bin/phpstan analyse --no-progress - - 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 From 13cb13d4fec33163f2ab8fa1f818d178b3743db6 Mon Sep 17 00:00:00 2001 From: Shuvro Roy Date: Fri, 18 Sep 2026 00:43:00 +0600 Subject: [PATCH 5/6] fix: remove PHPStan error suppressions for TLS methods Configure the crypto method bitmask through the SSL context before enabling TLS, using the context fallback supported since before PHP 7.1. This keeps combined protocol flags working without relying on PHPStan's incomplete third-argument signature. Remove ignoreErrors and reportUnmatchedIgnoredErrors overrides, and cover both single and combined TLS methods for client and server connections. --- phpstan.neon.dist | 7 ------- src/StreamEncryption.php | 6 +++++- tests/FunctionalSecureServerTest.php | 26 ++++++++++++++++++++++---- 3 files changed, 27 insertions(+), 12 deletions(-) diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 8a3ef14e..abd1326d 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -5,10 +5,3 @@ parameters: phpVersion: 70100 # Retain runtime guards for older supported Promise implementations. treatPhpDocTypesAsCertain: false - # The crypto bitmask signature is already correct in older PHPStan releases. - reportUnmatchedIgnoredErrors: false - ignoreErrors: - # PHP accepts combined crypto flags; some PHPStan versions only list individual constants. - - - message: '#^Parameter \#3 \$crypto_method of function stream_socket_enable_crypto expects .+, int given\.$#' - path: src/StreamEncryption.php diff --git a/src/StreamEncryption.php b/src/StreamEncryption.php index 657f327b..bdd29320 100644 --- a/src/StreamEncryption.php +++ b/src/StreamEncryption.php @@ -125,7 +125,11 @@ public function toggleCrypto($socket, Deferred $deferred, bool $toggle, int $met 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/tests/FunctionalSecureServerTest.php b/tests/FunctionalSecureServerTest.php index b30bbbb5..afc6613b 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 testClientUsesTls12WhenCryptoMethodIsExplicitlyConfiguredByClient($method) { $server = new TcpServer(0); $server = new SecureServer($server, null, [ @@ -90,7 +91,7 @@ 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()); @@ -108,12 +109,21 @@ public function testClientUsesTls12WhenCryptoMethodIsExplicitlyConfiguredByClien $server->close(); } - public function testClientUsesTls12WhenCryptoMethodIsExplicitlyConfiguredByServer() + public function provideClientCryptoMethods() + { + return [ + 'single method' => [STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT], + 'combined methods' => [STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT | STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT] + ]; + } + + /** @dataProvider provideServerCryptoMethods */ + public function testClientUsesTls12WhenCryptoMethodIsExplicitlyConfiguredByServer($method) { $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, [ @@ -135,6 +145,14 @@ public function testClientUsesTls12WhenCryptoMethodIsExplicitlyConfiguredByServe $server->close(); } + public function provideServerCryptoMethods() + { + return [ + 'single method' => [STREAM_CRYPTO_METHOD_TLSv1_2_SERVER], + 'combined methods' => [STREAM_CRYPTO_METHOD_TLSv1_1_SERVER | STREAM_CRYPTO_METHOD_TLSv1_2_SERVER] + ]; + } + public function testClientUsesTls10WhenCryptoMethodIsExplicitlyConfiguredByClient() { $server = new TcpServer(0); From acb8c18bf4ec67d4fb320bc06eab4c9444a53bb1 Mon Sep 17 00:00:00 2001 From: Shuvro Roy Date: Fri, 18 Sep 2026 01:07:03 +0600 Subject: [PATCH 6/6] test: fix legacy TLS expectations and socket cleanup Account for PHP 7.1 and 7.2 negotiating TLS 1.3 with combined crypto methods when OpenSSL supports it, including their UNKNOWN protocol metadata. Always close client and server sockets in finally blocks so failed assertions cannot leave later event-loop tests hanging. --- tests/FunctionalSecureServerTest.php | 72 +++++++++++++++++----------- 1 file changed, 44 insertions(+), 28 deletions(-) diff --git a/tests/FunctionalSecureServerTest.php b/tests/FunctionalSecureServerTest.php index afc6613b..a433bba1 100644 --- a/tests/FunctionalSecureServerTest.php +++ b/tests/FunctionalSecureServerTest.php @@ -82,7 +82,7 @@ public function testClientUsesTls13ByDefaultWhenSupportedByOpenSSL() } /** @dataProvider provideClientCryptoMethods */ - public function testClientUsesTls12WhenCryptoMethodIsExplicitlyConfiguredByClient($method) + public function testClientUsesExpectedTlsVersionWhenCryptoMethodIsExplicitlyConfiguredByClient($method, $expectedProtocol) { $server = new TcpServer(0); $server = new SecureServer($server, null, [ @@ -95,30 +95,38 @@ public function testClientUsesTls12WhenCryptoMethodIsExplicitlyConfiguredByClien ]); $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']); + try { + /* @var ConnectionInterface $client */ + $client = await(timeout($promise, self::TIMEOUT)); - $client->close(); - $server->close(); + $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(); + } } 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'; + return [ - 'single method' => [STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT], - 'combined methods' => [STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT | STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT] + '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] ]; } /** @dataProvider provideServerCryptoMethods */ - public function testClientUsesTls12WhenCryptoMethodIsExplicitlyConfiguredByServer($method) + public function testClientUsesExpectedTlsVersionWhenCryptoMethodIsExplicitlyConfiguredByServer($method, $expectedProtocol) { $server = new TcpServer(0); $server = new SecureServer($server, null, [ @@ -131,25 +139,33 @@ public function testClientUsesTls12WhenCryptoMethodIsExplicitlyConfiguredByServe ]); $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']); + try { + /* @var ConnectionInterface $client */ + $client = await(timeout($promise, self::TIMEOUT)); - $client->close(); - $server->close(); + $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(); + } } 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'; + return [ - 'single method' => [STREAM_CRYPTO_METHOD_TLSv1_2_SERVER], - 'combined methods' => [STREAM_CRYPTO_METHOD_TLSv1_1_SERVER | STREAM_CRYPTO_METHOD_TLSv1_2_SERVER] + '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] ]; }