diff --git a/src/FreeDSx/Socket/Exception/ConnectionException.php b/src/FreeDSx/Socket/Exception/ConnectionException.php index 4205ed6..b8bd946 100644 --- a/src/FreeDSx/Socket/Exception/ConnectionException.php +++ b/src/FreeDSx/Socket/Exception/ConnectionException.php @@ -1,4 +1,7 @@ */ -class ConnectionException extends \Exception +final class ConnectionException extends Exception { } diff --git a/src/FreeDSx/Socket/Exception/PartialMessageException.php b/src/FreeDSx/Socket/Exception/PartialMessageException.php index 65affd6..5a9fbf5 100644 --- a/src/FreeDSx/Socket/Exception/PartialMessageException.php +++ b/src/FreeDSx/Socket/Exception/PartialMessageException.php @@ -1,4 +1,7 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Socket; + +use InvalidArgumentException; + +/** + * Shared socket option functionality. + */ +trait HasSocketOptions +{ + private Transport $transport = Transport::Tcp; + + private int $port = 389; + + private bool $useSsl = false; + + private int $sslCryptoMethod = + STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT + | STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT + | STREAM_CRYPTO_METHOD_TLS_CLIENT; + + private string $sslCiphers = 'DEFAULT'; + + private bool $sslValidateCert = true; + + private ?bool $sslAllowSelfSigned = null; + + private ?string $sslCaCert = null; + + private ?string $sslCert = null; + + private ?string $sslCertKey = null; + + private ?string $sslCertPassphrase = null; + + private ?string $sslPeerName = null; + + private int $timeoutConnect = 3; + + private int $timeoutRead = 15; + + private int $bufferSize = 8192; + + public function setTransport(Transport $transport): self + { + $this->transport = $transport; + + return $this; + } + + public function getTransport(): Transport + { + return $this->transport; + } + + public function setPort(int $port): self + { + $this->port = $port; + + return $this; + } + + public function getPort(): int + { + return $this->port; + } + + public function setUseSsl(bool $useSsl): self + { + $this->useSsl = $useSsl; + + return $this; + } + + public function isUseSsl(): bool + { + return $this->useSsl; + } + + public function setSslCryptoMethod(int $sslCryptoMethod): self + { + $this->sslCryptoMethod = $sslCryptoMethod; + + return $this; + } + + public function getSslCryptoMethod(): int + { + return $this->sslCryptoMethod; + } + + public function setSslCiphers(string $sslCiphers): self + { + $this->sslCiphers = $sslCiphers; + + return $this; + } + + public function getSslCiphers(): string + { + return $this->sslCiphers; + } + + public function setSslValidateCert(bool $sslValidateCert): self + { + $this->sslValidateCert = $sslValidateCert; + + return $this; + } + + public function isSslValidateCert(): bool + { + return $this->sslValidateCert; + } + + public function setSslAllowSelfSigned(?bool $sslAllowSelfSigned): self + { + $this->sslAllowSelfSigned = $sslAllowSelfSigned; + + return $this; + } + + public function getSslAllowSelfSigned(): ?bool + { + return $this->sslAllowSelfSigned; + } + + public function setSslCaCert(?string $sslCaCert): self + { + $this->sslCaCert = $sslCaCert; + + return $this; + } + + public function getSslCaCert(): ?string + { + return $this->sslCaCert; + } + + public function setSslCert(?string $sslCert): self + { + $this->sslCert = $sslCert; + + return $this; + } + + public function getSslCert(): ?string + { + return $this->sslCert; + } + + public function setSslCertKey(?string $sslCertKey): self + { + $this->sslCertKey = $sslCertKey; + + return $this; + } + + public function getSslCertKey(): ?string + { + return $this->sslCertKey; + } + + public function setSslCertPassphrase(?string $sslCertPassphrase): self + { + $this->sslCertPassphrase = $sslCertPassphrase; + + return $this; + } + + public function getSslCertPassphrase(): ?string + { + return $this->sslCertPassphrase; + } + + public function setSslPeerName(?string $sslPeerName): self + { + $this->sslPeerName = $sslPeerName; + + return $this; + } + + public function getSslPeerName(): ?string + { + return $this->sslPeerName; + } + + public function setTimeoutConnect(int $seconds): self + { + $this->timeoutConnect = $seconds; + + return $this; + } + + public function getTimeoutConnect(): int + { + return $this->timeoutConnect; + } + + public function setTimeoutRead(int $seconds): self + { + $this->timeoutRead = $seconds; + + return $this; + } + + public function getTimeoutRead(): int + { + return $this->timeoutRead; + } + + public function setBufferSize(int $bufferSize): self + { + if ($bufferSize < 1) { + throw new InvalidArgumentException('Buffer size must be a positive integer.'); + } + $this->bufferSize = $bufferSize; + + return $this; + } + + public function getBufferSize(): int + { + return $this->bufferSize; + } + + /** + * @return array + */ + public function toStreamContextSslOptions(): array + { + $opts = [ + 'allow_self_signed' => $this->sslAllowSelfSigned ?? false, + 'verify_peer' => $this->sslValidateCert, + 'verify_peer_name' => $this->sslValidateCert, + 'capture_peer_cert' => true, + 'capture_peer_cert_chain' => true, + 'crypto_method' => $this->sslCryptoMethod, + 'ciphers' => $this->sslCiphers, + ]; + + if ($this->sslCaCert !== null) { + $opts['cafile'] = $this->sslCaCert; + } + if ($this->sslCert !== null) { + $opts['local_cert'] = $this->sslCert; + } + if ($this->sslCertKey !== null) { + $opts['local_pk'] = $this->sslCertKey; + } + if ($this->sslCertPassphrase !== null) { + $opts['passphrase'] = $this->sslCertPassphrase; + } + if ($this->sslPeerName !== null) { + $opts['peer_name'] = $this->sslPeerName; + } + if (!$this->sslValidateCert) { + $opts['allow_self_signed'] = true; + } + + return $opts; + } +} diff --git a/src/FreeDSx/Socket/PduInterface.php b/src/FreeDSx/Socket/PduInterface.php index 303e818..17a4f28 100644 --- a/src/FreeDSx/Socket/PduInterface.php +++ b/src/FreeDSx/Socket/PduInterface.php @@ -1,4 +1,7 @@ |null $pduClass */ - public function __construct(Socket $socket, EncoderInterface $encoder, ?string $pduClass = null) - { + public function __construct( + Socket $socket, + protected EncoderInterface $encoder, + protected ?string $pduClass = null, + ) { if ($pduClass !== null && !\is_subclass_of($pduClass, PduInterface::class)) { throw new \RuntimeException(sprintf( 'The class "%s" must implement "%s", but it does not.', $pduClass, - PduInterface::class + PduInterface::class, )); } - $this->encoder = $encoder; - $this->pduClass = $pduClass; parent::__construct($socket); } - /** - * {@inheritdoc} - */ - protected function decode($bytes): Message + protected function decode(string $bytes): Message { try { $asn1 = $this->encoder->decode($bytes); @@ -67,11 +56,10 @@ protected function decode($bytes): Message return $message; } - /** - * {@inheritdoc} - */ - protected function constructMessage(Message $message, ?int $id = null) - { + protected function constructMessage( + Message $message, + ?int $id = null, + ): mixed { if ($this->pduClass === null) { throw new \RuntimeException('You must either define a PDU class or override getPdu().'); } diff --git a/src/FreeDSx/Socket/Queue/Buffer.php b/src/FreeDSx/Socket/Queue/Buffer.php index fa3362d..c9b318e 100644 --- a/src/FreeDSx/Socket/Queue/Buffer.php +++ b/src/FreeDSx/Socket/Queue/Buffer.php @@ -1,4 +1,7 @@ */ -class Buffer +readonly final class Buffer { - /** - * @var string - */ - protected $bytes; - - /** - * @var int - */ - protected $endsAt; - - /** - * @param string $bytes - * @param int $endsAt - */ - public function __construct($bytes, int $endsAt) - { - $this->bytes = $bytes; - $this->endsAt = $endsAt; + public function __construct( + protected string $bytes, + protected int $endsAt, + ) { } - /** - * @return string - */ - public function bytes() + public function bytes(): string { return $this->bytes; } - /** - * @return int - */ public function endsAt(): int { return $this->endsAt; diff --git a/src/FreeDSx/Socket/Queue/Message.php b/src/FreeDSx/Socket/Queue/Message.php index bcb7c2d..0cbed4e 100644 --- a/src/FreeDSx/Socket/Queue/Message.php +++ b/src/FreeDSx/Socket/Queue/Message.php @@ -1,4 +1,7 @@ */ -class Message +readonly class Message { - /** - * @var mixed - */ - protected $message; - - /** - * @var null|int - */ - protected $lastPosition; - - /** - * @param mixed $message The message object as the result of the socket data. - * @param null|int $lastPosition the last position of the byte stream after this message. - */ - public function __construct($message, ?int $lastPosition = null) - { - $this->message = $message; - $this->lastPosition = $lastPosition; + public function __construct( + protected mixed $message, + protected ?int $lastPosition = null, + ) { } - /** - * Get the message object as the result of the socket data. - * - * @return mixed - */ - public function getMessage() + public function getMessage(): mixed { return $this->message; } - /** - * Get the last position of the byte stream after this message. - * - * @return null|int - */ public function getLastPosition(): ?int { return $this->lastPosition; diff --git a/src/FreeDSx/Socket/Queue/MessageQueue.php b/src/FreeDSx/Socket/Queue/MessageQueue.php index 73183fb..799e5be 100644 --- a/src/FreeDSx/Socket/Queue/MessageQueue.php +++ b/src/FreeDSx/Socket/Queue/MessageQueue.php @@ -1,4 +1,7 @@ socket = $socket; } /** - * @param int|null $id - * @return Generator + * @return Generator * @throws ConnectionException */ public function getMessages(?int $id = null): Generator @@ -59,6 +46,17 @@ public function getMessages(?int $id = null): Generator } } + /** + * @throws ConnectionException + */ + public function getMessage(?int $id = null): mixed + { + return $this->constructMessage( + $this->readOneMessage(), + $id, + ); + } + /** * @throws ConnectionException * @throws PartialMessageException @@ -76,7 +74,7 @@ private function readOneMessage(): Message try { return $this->consume(); - } catch (PartialMessageException $exception) { + } catch (PartialMessageException) { $this->addToAvailableBufferOrFail(); } } @@ -99,8 +97,8 @@ protected function addToAvailableBufferOrFail(): void protected function addToConsumableBuffer(): void { if ($this->hasAvailableBuffer()) { - $buffer = $this->unwrap((string)$this->buffer); - $this->buffer = substr((string)$this->buffer, $buffer->endsAt()); + $buffer = $this->unwrap($this->buffer); + $this->buffer = substr($this->buffer, $buffer->endsAt()); $this->toConsume .= $buffer->bytes(); } } @@ -112,28 +110,27 @@ protected function hasBuffer(): bool protected function hasAvailableBuffer(): bool { - return strlen((string)$this->buffer) !== 0; + return strlen($this->buffer) !== 0; } protected function hasConsumableBuffer(): bool { - return strlen((string)$this->toConsume) !== 0; + return strlen($this->toConsume) !== 0; } /** - * @return Message|null * @throws PartialMessageException */ - protected function consume(): ?Message + protected function consume(): Message { $message = null; try { $message = $this->decode($this->toConsume); - $lastPos = (int)$message->getLastPosition(); + $lastPos = (int) $message->getLastPosition(); $this->toConsume = substr( $this->toConsume, - $lastPos + $lastPos, ); } catch (PartialMessageException $exception) { # If we have available buffer, it might have what we need. Attempt to add it. Otherwise let it bubble... @@ -152,15 +149,11 @@ protected function consume(): ?Message return $message; } - /** - * @param string $bytes - * @return Buffer - */ - protected function unwrap($bytes) : Buffer + protected function unwrap(string $bytes): Buffer { return new Buffer( $bytes, - strlen($bytes) + strlen($bytes), ); } @@ -168,36 +161,17 @@ protected function unwrap($bytes) : Buffer * Decode the bytes to an object. If you have a partial object, throw the PartialMessageException and the queue * will attempt to append more data to the buffer. * - * @param string $bytes - * @return Message * @throws PartialMessageException */ - protected abstract function decode($bytes) : Message; - - /** - * @param int|null $id - * @return mixed - * @throws ConnectionException - */ - public function getMessage(?int $id = null) - { - return $this->constructMessage( - $this->readOneMessage(), - $id - ); - } + abstract protected function decode(string $bytes): Message; /** * Retrieve the message object from the message. Allow for special construction / validation if needed. - * - * @param Message $message - * @param int|null $id - * @return mixed */ protected function constructMessage( Message $message, - ?int $id = null - ) { + ?int $id = null, + ): mixed { return $message->getMessage(); } } diff --git a/src/FreeDSx/Socket/Socket.php b/src/FreeDSx/Socket/Socket.php index 9c28fbf..74e6d3f 100644 --- a/src/FreeDSx/Socket/Socket.php +++ b/src/FreeDSx/Socket/Socket.php @@ -1,4 +1,7 @@ - */ - protected $sslOptsMap = [ - 'ssl_allow_self_signed' => 'allow_self_signed', - 'ssl_ca_cert' => 'cafile', - 'ssl_crypto_method' => 'crypto_method', - 'ssl_ciphers' => 'ciphers', - 'ssl_peer_name' => 'peer_name', - 'ssl_cert' => 'local_cert', - 'ssl_cert_key' => 'local_pk', - 'ssl_cert_passphrase' => 'passphrase', - ]; - - /** - * @var array - */ - protected $sslOpts = [ - 'allow_self_signed' => false, - 'verify_peer' => true, - 'verify_peer_name' => true, - 'capture_peer_cert' => true, - 'capture_peer_cert_chain' => true, - ]; + protected string $errorMessage = ''; - /** - * @var array - */ - protected $options = [ - 'transport' => 'tcp', - 'port' => 389, - 'use_ssl' => false, - 'ssl_crypto_method' => STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT | STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT | STREAM_CRYPTO_METHOD_TLS_CLIENT, - 'ssl_ciphers' => 'DEFAULT', - 'ssl_validate_cert' => true, - 'ssl_allow_self_signed' => null, - 'ssl_ca_cert' => null, - 'ssl_peer_name' => null, - 'timeout_connect' => 3, - 'timeout_read' => 15, - 'buffer_size' => 8192, - ]; + protected int $errorNumber = 0; /** * @param resource|null $resource - * @param array $options */ - public function __construct($resource = null, array $options = []) - { + public function __construct( + $resource = null, + protected readonly SocketOptionsInterface $options = new SocketOptions(), + ) { $this->socket = $resource; - $this->options = array_merge($this->options, $options); - if (!in_array($this->options['transport'], self::TRANSPORTS, true)) { - throw new \RuntimeException(sprintf( - 'The transport "%s" is not valid. It must be one of: %s', - $this->options['transport'], - implode(',', self::TRANSPORTS) - )); - } if ($this->socket !== null) { $this->setStreamOpts(); } } - /** - * @param bool $block - * @return string|false - */ - public function read(bool $block = true) + public function read(bool $block = true): string|false { stream_set_blocking($this->socket, $block); + $data = fread( $this->socket, - $this->options['buffer_size'] + $this->options->getBufferSize(), ); if (!$block) { stream_set_blocking( $this->socket, - true + true, ); } @@ -147,32 +80,21 @@ public function read(bool $block = true) : $data; } - /** - * @param string $data - * @return $this - */ - public function write(string $data) + public function write(string $data): static { - @\fwrite($this->socket, $data); + @fwrite($this->socket, $data); return $this; } - /** - * @param bool $block - * @return $this - */ - public function block(bool $block) + public function block(bool $block): static { stream_set_blocking($this->socket, $block); return $this; } - /** - * @return bool - */ - public function isConnected() : bool + public function isConnected(): bool { if ($this->socket === null) { return false; @@ -180,7 +102,7 @@ public function isConnected() : bool // Slight optimization. The feof() method should be more accurate and unix socket should be less likely. // In PHP 8.2 feof is not accurate for checking a UNIX socket. - if ($this->options['transport'] !== 'unix') { + if ($this->options->getTransport() !== Transport::Unix) { return !@\feof($this->socket); } @@ -188,21 +110,15 @@ public function isConnected() : bool return \is_resource($this->socket); } - /** - * @return bool - */ - public function isEncrypted() : bool + public function isEncrypted(): bool { return $this->isEncrypted; } - /** - * @return $this - */ - public function close() + public function close(): static { if ($this->socket !== null) { - \stream_socket_shutdown($this->socket, STREAM_SHUT_RDWR); + stream_socket_shutdown($this->socket, STREAM_SHUT_RDWR); } $this->socket = null; $this->isEncrypted = false; @@ -214,21 +130,23 @@ public function close() /** * Enable/Disable encryption on the TCP connection stream. * - * @param bool $encrypt - * @return $this * @throws ConnectionException */ - public function encrypt(bool $encrypt) + public function encrypt(bool $encrypt): static { stream_set_blocking($this->socket, true); - $result = stream_socket_enable_crypto($this->socket, $encrypt, $this->options['ssl_crypto_method']); + $result = stream_socket_enable_crypto( + $this->socket, + $encrypt, + $this->options->getSslCryptoMethod(), + ); stream_set_blocking($this->socket, false); - if ((bool) $result == false) { + if ($result !== true) { throw new ConnectionException(sprintf( 'Unable to %s encryption on TCP connection. %s', $encrypt ? 'enable' : 'disable', - $this->errorMessage + $this->errorMessage, )); } $this->isEncrypted = $encrypt; @@ -237,51 +155,48 @@ public function encrypt(bool $encrypt) } /** - * @param string $host - * @return $this * @throws ConnectionException */ - public function connect(string $host) + public function connect(string $host): static { - $transport = $this->options['transport']; - if ($transport === 'tcp' && (bool) $this->options['use_ssl'] === true) { - $transport = 'ssl'; - } - - $uri = $transport . '://' . $host; - - if ($transport !== 'unix') { - $uri .= ':' . $this->options['port']; + $transport = $this->options->getTransport(); + $scheme = $transport === Transport::Tcp && $this->options->isUseSsl() + ? 'ssl' + : $transport->value; + + $uri = $scheme . '://' . $host; + if ($transport !== Transport::Unix) { + $uri .= ':' . $this->options->getPort(); } + $errorNumber = 0; + $errorMessage = ''; $socket = @stream_socket_client( $uri, - $this->errorNumber, - $this->errorMessage, - $this->options['timeout_connect'], + $errorNumber, + $errorMessage, + $this->options->getTimeoutConnect(), STREAM_CLIENT_CONNECT, - $this->createSocketContext() + $this->createSocketContext(), ); + $this->errorNumber = $errorNumber; + $this->errorMessage = $errorMessage; + if ($socket === false) { throw new ConnectionException(sprintf( 'Unable to connect to %s: %s', $host, - $this->errorMessage + $this->errorMessage, )); } $this->socket = $socket; $this->setStreamOpts(); - $this->isEncrypted = $this->options['use_ssl']; + $this->isEncrypted = $this->options->isUseSsl(); return $this; } - /** - * Get the options set for the socket. - * - * @return array - */ - public function getOptions() : array + public function getOptions(): SocketOptionsInterface { return $this->options; } @@ -289,13 +204,12 @@ public function getOptions() : array /** * Create a socket by connecting to a specific host. * - * @param string $host - * @param array $options - * @return Socket * @throws ConnectionException */ - public static function create(string $host, array $options = []) : Socket - { + public static function create( + string $host, + ?SocketOptions $options = null, + ): Socket { return (new self(null, $options))->connect($host); } @@ -303,50 +217,52 @@ public static function create(string $host, array $options = []) : Socket * Create a UNIX based socket. * * @param string $file The full path to the unix socket. - * @param array $options Any additional options. - * @return Socket * @throws ConnectionException */ public static function unix( string $file, - array $options = [] + ?SocketOptions $options = null, ): Socket { - return self::create( - $file, - array_merge( - $options, - ['transport' => 'unix'] - ) - ); + $options ??= new SocketOptions(); + $options->setTransport(Transport::Unix); + + return self::create($file, $options); } /** * Create a TCP based socket. * - * @param string $host - * @param array $options - * @return Socket * @throws ConnectionException */ - public static function tcp(string $host, array $options = []) : Socket - { - return self::create($host, array_merge($options, ['transport' => 'tcp'])); + public static function tcp( + string $host, + SocketOptions $options = new SocketOptions(), + ): Socket { + $options->setTransport(Transport::Tcp); + + return self::create( + $host, + $options, + ); } /** * Create a UDP based socket. * - * @param string $host - * @param array $options - * @return Socket * @throws ConnectionException */ - public static function udp(string $host, array $options = []) : Socket - { - return self::create($host, array_merge($options, [ - 'transport' => 'udp', - 'buffer_size' => 65507, - ])); + public static function udp( + string $host, + SocketOptions $options = new SocketOptions(), + ): Socket { + $options + ->setTransport(Transport::Udp) + ->setBufferSize(65507); + + return self::create( + $host, + $options, + ); } /** @@ -354,21 +270,8 @@ public static function udp(string $host, array $options = []) : Socket */ protected function createSocketContext() { - $sslOpts = $this->sslOpts; - foreach ($this->sslOptsMap as $optName => $sslOptsName) { - if (isset($this->options[$optName])) { - $sslOpts[$sslOptsName] = $this->options[$optName]; - } - } - if ($this->options['ssl_validate_cert'] === false) { - $sslOpts = array_merge($sslOpts, [ - 'allow_self_signed' => true, - 'verify_peer' => false, - 'verify_peer_name' => false, - ]); - } - $this->context = \stream_context_create([ - 'ssl' => $sslOpts, + $this->context = stream_context_create([ + 'ssl' => $this->options->toStreamContextSslOptions(), ]); return $this->context; @@ -377,8 +280,11 @@ protected function createSocketContext() /** * Sets options on the stream that must be done after it is a resource. */ - protected function setStreamOpts() : void + protected function setStreamOpts(): void { - stream_set_timeout($this->socket, $this->options['timeout_read']); + stream_set_timeout( + $this->socket, + $this->options->getTimeoutRead(), + ); } } diff --git a/src/FreeDSx/Socket/SocketOptions.php b/src/FreeDSx/Socket/SocketOptions.php new file mode 100644 index 0000000..5453dd0 --- /dev/null +++ b/src/FreeDSx/Socket/SocketOptions.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Socket; + +/** + * Client-side configuration consumed by Socket. + */ +final class SocketOptions implements SocketOptionsInterface +{ + use HasSocketOptions; +} diff --git a/src/FreeDSx/Socket/SocketOptionsInterface.php b/src/FreeDSx/Socket/SocketOptionsInterface.php new file mode 100644 index 0000000..ad86eb1 --- /dev/null +++ b/src/FreeDSx/Socket/SocketOptionsInterface.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Socket; + +/** + * Read-only contract Socket consumes when given configuration. + */ +interface SocketOptionsInterface +{ + public function getTransport(): Transport; + + public function getPort(): int; + + public function isUseSsl(): bool; + + public function getSslCryptoMethod(): int; + + public function getSslCiphers(): string; + + public function isSslValidateCert(): bool; + + public function getSslAllowSelfSigned(): ?bool; + + public function getSslCaCert(): ?string; + + public function getSslCert(): ?string; + + public function getSslCertKey(): ?string; + + public function getSslCertPassphrase(): ?string; + + public function getSslPeerName(): ?string; + + public function getTimeoutConnect(): int; + + public function getTimeoutRead(): int; + + public function getBufferSize(): int; + + /** + * @return array + */ + public function toStreamContextSslOptions(): array; +} diff --git a/src/FreeDSx/Socket/SocketPool.php b/src/FreeDSx/Socket/SocketPool.php index 0b6247b..950754d 100644 --- a/src/FreeDSx/Socket/SocketPool.php +++ b/src/FreeDSx/Socket/SocketPool.php @@ -1,4 +1,7 @@ */ class SocketPool { - /** - * @var array - */ - protected $options = [ - 'servers' => [], - 'port' => 389, - 'timeout_connect' => 1, - ]; - - /** - * @var list - */ - protected $socketOpts = [ - 'use_ssl', - 'ssl_validate_cert', - 'ssl_allow_self_signed', - 'ssl_ca_cert', - 'ssl_cert', - 'ssl_peer_name', - 'timeout_connect', - 'timeout_read', - 'port', - 'transport', - ]; - - /** - * @param array $options - */ - public function __construct(array $options) + public function __construct(protected SocketPoolOptions $options) { - $this->options = \array_merge($this->options, $options); } /** * @throws ConnectionException */ - public function connect(string $hostname = '') : Socket + public function connect(string $hostname = ''): Socket { - $hosts = ($hostname !== '') ? [$hostname] : (array) $this->options['servers']; + $hosts = $hostname !== '' + ? [$hostname] + : $this->options->getServers(); $lastEx = null; $socket = null; @@ -65,10 +42,10 @@ public function connect(string $hostname = '') : Socket try { $socket = Socket::create( $host, - $this->getSocketOptions() + $this->options->getSocket() ); break; - } catch (\Exception $e) { + } catch (Throwable $e) { $lastEx = $e; } } @@ -76,26 +53,10 @@ public function connect(string $hostname = '') : Socket if ($socket === null) { throw new ConnectionException(sprintf( 'Unable to connect to server(s): %s', - implode(',', $hosts) + implode(',', $hosts), ), 0, $lastEx); } return $socket; } - - /** - * @return array - */ - protected function getSocketOptions() : array - { - $opts = []; - - foreach ($this->socketOpts as $name) { - if (isset($this->options[$name])) { - $opts[$name] = $this->options[$name]; - } - } - - return $opts; - } } diff --git a/src/FreeDSx/Socket/SocketPoolOptions.php b/src/FreeDSx/Socket/SocketPoolOptions.php new file mode 100644 index 0000000..74675c9 --- /dev/null +++ b/src/FreeDSx/Socket/SocketPoolOptions.php @@ -0,0 +1,63 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Socket; + +/** + * Configuration consumed by SocketPool. Composes a SocketOptions used for each + * Socket the pool creates. + */ +final class SocketPoolOptions +{ + /** + * @var list + */ + private array $servers = []; + + private SocketOptions $socket; + + public function __construct(?SocketOptions $socket = null) + { + $this->socket = $socket ?? (new SocketOptions())->setTimeoutConnect(1); + } + + /** + * @param list $servers + */ + public function setServers(array $servers): self + { + $this->servers = $servers; + + return $this; + } + + /** + * @return list + */ + public function getServers(): array + { + return $this->servers; + } + + public function setSocket(SocketOptions $socket): self + { + $this->socket = $socket; + + return $this; + } + + public function getSocket(): SocketOptions + { + return $this->socket; + } +} diff --git a/src/FreeDSx/Socket/SocketServer.php b/src/FreeDSx/Socket/SocketServer.php index 61608d8..75f4d88 100644 --- a/src/FreeDSx/Socket/SocketServer.php +++ b/src/FreeDSx/Socket/SocketServer.php @@ -1,4 +1,7 @@ + * TCP/UDP/UNIX socket server to accept client connections. */ class SocketServer extends Socket { /** - * Supported transport types. - */ - public const TRANSPORTS = [ - 'tcp', - 'udp', - 'unix', - ]; - - /** - * @var array + * @var list */ - protected $serverOpts = [ - 'use_ssl' => false, - 'ssl_cert' => null, - 'ssl_cert_key' => null, - 'ssl_cert_passphrase' => null, - 'ssl_ciphers' => 'DEFAULT', - 'ssl_crypto_method' => STREAM_CRYPTO_METHOD_TLSv1_2_SERVER | STREAM_CRYPTO_METHOD_TLSv1_1_SERVER | STREAM_CRYPTO_METHOD_TLS_SERVER, - 'ssl_validate_cert' => false, - 'idle_timeout' => 600, - ]; + protected array $clients = []; - /** - * @var Socket[] - */ - protected $clients = []; + public function __construct(?SocketServerOptions $options = null) + { + parent::__construct(null, $options ?? new SocketServerOptions()); + } - /** - * @param array $options - */ - public function __construct(array $options = []) + public function getOptions(): SocketServerOptions { - parent::__construct( - null, - \array_merge( - $this->serverOpts, - $options - ) - ); - if (!\in_array($this->options['transport'], self::TRANSPORTS, true)) { - throw new \RuntimeException(sprintf( - 'The transport "%s" is not valid. It must be one of: %s', - $this->options['transport'], - implode(',', self::TRANSPORTS) - )); - } + \assert($this->options instanceof SocketServerOptions); + + return $this->options; } /** * Create the socket server and bind to a specific port to listen for clients. * - * @param string $ip - * @param int|null $port - * @return $this * @throws ConnectionException - * @internal param string $ip */ - public function listen(string $ip, ?int $port): self - { + public function listen( + string $ip, + ?int $port, + ): static { + $transport = $this->options->getTransport(); + $flags = STREAM_SERVER_BIND; - if ($this->options['transport'] !== 'udp') { + if ($transport !== Transport::Udp) { $flags |= STREAM_SERVER_LISTEN; } - $transport = $this->options['transport']; - if ($transport === 'tcp' && $this->options['use_ssl'] === true) { - $transport = 'ssl'; - } + $scheme = $transport === Transport::Tcp && $this->options->isUseSsl() + ? 'ssl' + : $transport->value; - if ($transport !== 'unix' && $port === null) { + if ($transport !== Transport::Unix && $port === null) { throw new ConnectionException('The port must be set if not using a unix based socket.'); } - $uri = $transport.'://'.$ip; - if ($port !== null && $transport !== 'unix') { + $uri = $scheme . '://' . $ip; + if ($port !== null && $transport !== Transport::Unix) { $uri .= ':' . $port; } - $socket = @\stream_socket_server( + $errorNumber = 0; + $errorMessage = ''; + $socket = @stream_socket_server( $uri, - $this->errorNumber, - $this->errorMessage, + $errorNumber, + $errorMessage, $flags, - $this->createSocketContext() + $this->createSocketContext(), ); + $this->errorNumber = $errorNumber; + $this->errorMessage = $errorMessage; + if ($socket === false) { throw new ConnectionException(sprintf( 'Unable to open %s socket (%s): %s', - \strtoupper($this->options['transport']), + \strtoupper($this->options->getTransport()->value), $this->errorNumber, - $this->errorMessage + $this->errorMessage, )); } $this->socket = $socket; @@ -120,67 +99,77 @@ public function listen(string $ip, ?int $port): self public function accept(float $timeout = -1.0): ?Socket { - $socket = @\stream_socket_accept($this->socket, $timeout); - if (\is_resource($socket)) { - $socket = new Socket($socket, \array_merge($this->options, [ - 'timeout_read' => $this->options['idle_timeout'] - ])); - $this->clients[] = $socket; + if ($this->socket === null) { + return null; } - return $socket instanceof Socket ? $socket : null; + $accepted = @stream_socket_accept($this->socket, $timeout); + if (!is_resource($accepted)) { + return null; + } + + $client = new Socket( + $accepted, + self::optionsForAcceptedClient($this->getOptions()), + ); + $this->clients[] = $client; + + return $client; + } + + private static function optionsForAcceptedClient(SocketServerOptions $server): SocketOptionsInterface + { + $clone = clone $server; + $clone->setTimeoutRead($server->getIdleTimeout()); + + return $clone; } /** * Receive data from a UDP based socket. Optionally get the IP address the data was received from. * * @todo Buffer size should be adjustable. Max UDP packet size is 65507. Currently this avoids possible truncation. - * @param null $ipAddress - * @return null|string */ - public function receive(&$ipAddress = null) + public function receive(?string &$ipAddress = null): ?string { $this->block(true); - return \stream_socket_recvfrom( + $data = stream_socket_recvfrom( $this->socket, 65507, 0, - $ipAddress + $ipAddress, ); + + return $data === false ? null : $data; } /** - * @return Socket[] + * @return list */ public function getClients(): array { return $this->clients; } - /** - * @param Socket $socket - */ public function removeClient(Socket $socket): void { - if (($index = \array_search($socket, $this->clients, true)) !== false) { + $index = array_search($socket, $this->clients, true); + if ($index !== false) { unset($this->clients[$index]); + $this->clients = array_values($this->clients); } } /** - * Create the socket server. Binds and listens on a specific port + * Create the socket server. Binds and listens on a specific port. * - * @param string $ip - * @param int|null $port - * @param array $options - * @return SocketServer * @throws ConnectionException */ public static function bind( string $ip, ?int $port, - array $options = [] + ?SocketServerOptions $options = null, ): SocketServer { return (new self($options))->listen( $ip, @@ -191,70 +180,56 @@ public static function bind( /** * Create a TCP based socket server. * - * @param string $ip - * @param int $port - * @param array $options - * @return SocketServer * @throws ConnectionException */ public static function bindTcp( string $ip, int $port, - array $options = [] + SocketServerOptions $options = new SocketServerOptions(), ): SocketServer { - return static::bind( + $options->setTransport(Transport::Tcp); + + return self::bind( $ip, $port, - \array_merge( - $options, - ['transport' => 'tcp'] - ) + $options, ); } /** - * Created a UDP based socket server. + * Create a UDP based socket server. * - * @param string $ip - * @param int $port - * @param array $options - * @return SocketServer * @throws ConnectionException */ public static function bindUdp( string $ip, int $port, - array $options = [] + SocketServerOptions $options = new SocketServerOptions(), ): SocketServer { - return static::bind( + $options->setTransport(Transport::Udp); + + return self::bind( $ip, $port, - \array_merge( - $options, - ['transport' => 'udp'] - ) + $options, ); } /** - * Created a UNIX based socket server. + * Create a UNIX based socket server. * - * @param string $socketFile - * @param array $options - * @return SocketServer * @throws ConnectionException */ public static function bindUnix( string $socketFile, - array $options = [] + SocketServerOptions $options = new SocketServerOptions(), ): SocketServer { - return static::bind( + $options->setTransport(Transport::Unix); + + return self::bind( $socketFile, null, - \array_merge( - $options, - ['transport' => 'unix'] - ) + $options, ); } } diff --git a/src/FreeDSx/Socket/SocketServerOptions.php b/src/FreeDSx/Socket/SocketServerOptions.php new file mode 100644 index 0000000..51b7111 --- /dev/null +++ b/src/FreeDSx/Socket/SocketServerOptions.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Socket; + +/** + * Server-side configuration consumed by SocketServer. + */ +final class SocketServerOptions implements SocketOptionsInterface +{ + use HasSocketOptions; + + private int $idleTimeout = 600; + + public function __construct() + { + $this->setSslValidateCert(false); + $this->setSslCryptoMethod( + STREAM_CRYPTO_METHOD_TLSv1_2_SERVER + | STREAM_CRYPTO_METHOD_TLSv1_1_SERVER + | STREAM_CRYPTO_METHOD_TLS_SERVER, + ); + } + + public function setIdleTimeout(int $seconds): self + { + $this->idleTimeout = $seconds; + + return $this; + } + + public function getIdleTimeout(): int + { + return $this->idleTimeout; + } +} diff --git a/src/FreeDSx/Socket/Transport.php b/src/FreeDSx/Socket/Transport.php new file mode 100644 index 0000000..266530e --- /dev/null +++ b/src/FreeDSx/Socket/Transport.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Socket; + +enum Transport: string +{ + case Tcp = 'tcp'; + case Udp = 'udp'; + case Unix = 'unix'; +} diff --git a/tests/unit/SocketOptionsTest.php b/tests/unit/SocketOptionsTest.php new file mode 100644 index 0000000..25c59d8 --- /dev/null +++ b/tests/unit/SocketOptionsTest.php @@ -0,0 +1,129 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\Unit\FreeDSx\Socket; + +use FreeDSx\Socket\SocketOptions; +use FreeDSx\Socket\SocketPoolOptions; +use FreeDSx\Socket\SocketServerOptions; +use InvalidArgumentException; +use PHPUnit\Framework\TestCase; + +final class SocketOptionsTest extends TestCase +{ + public function test_it_should_translate_set_ssl_fields_into_stream_context_keys(): void + { + $opts = (new SocketOptions()) + ->setSslCaCert('/tmp/ca.pem') + ->setSslCert('/tmp/cert.pem') + ->setSslCertKey('/tmp/key.pem') + ->setSslCertPassphrase('s3cret') + ->setSslPeerName('foo.example.com'); + + $ctx = $opts->toStreamContextSslOptions(); + + self::assertSame('/tmp/ca.pem', $ctx['cafile']); + self::assertSame('/tmp/cert.pem', $ctx['local_cert']); + self::assertSame('/tmp/key.pem', $ctx['local_pk']); + self::assertSame('s3cret', $ctx['passphrase']); + self::assertSame('foo.example.com', $ctx['peer_name']); + } + + public function test_it_should_omit_unset_ssl_fields_from_stream_context(): void + { + $ctx = (new SocketOptions())->toStreamContextSslOptions(); + + self::assertArrayNotHasKey('cafile', $ctx); + self::assertArrayNotHasKey('local_cert', $ctx); + self::assertArrayNotHasKey('local_pk', $ctx); + self::assertArrayNotHasKey('passphrase', $ctx); + self::assertArrayNotHasKey('peer_name', $ctx); + } + + public function test_it_should_relax_validation_when_validate_cert_is_off(): void + { + $ctx = (new SocketOptions()) + ->setSslValidateCert(false) + ->toStreamContextSslOptions(); + + self::assertFalse($ctx['verify_peer']); + self::assertFalse($ctx['verify_peer_name']); + self::assertTrue($ctx['allow_self_signed']); + } + + public function test_it_should_carry_the_validate_cert_flag_when_enabled(): void + { + $ctx = (new SocketOptions())->toStreamContextSslOptions(); + + self::assertTrue($ctx['verify_peer']); + self::assertTrue($ctx['verify_peer_name']); + self::assertFalse($ctx['allow_self_signed']); + } + + public function test_set_buffer_size_must_be_positive(): void + { + $this->expectException(InvalidArgumentException::class); + + (new SocketOptions())->setBufferSize(0); + } + + public function test_server_options_should_disable_certificate_validation_by_default(): void + { + self::assertFalse((new SocketServerOptions())->isSslValidateCert()); + } + + public function test_server_options_should_use_server_side_crypto_method_by_default(): void + { + self::assertSame( + STREAM_CRYPTO_METHOD_TLSv1_2_SERVER + | STREAM_CRYPTO_METHOD_TLSv1_1_SERVER + | STREAM_CRYPTO_METHOD_TLS_SERVER, + (new SocketServerOptions())->getSslCryptoMethod(), + ); + } + + public function test_server_options_should_default_idle_timeout_to_600(): void + { + self::assertSame(600, (new SocketServerOptions())->getIdleTimeout()); + } + + public function test_pool_options_default_to_one_second_connect_timeout(): void + { + self::assertSame(1, (new SocketPoolOptions())->getSocket()->getTimeoutConnect()); + } + + public function test_pool_options_respect_provided_socket_options(): void + { + $custom = (new SocketOptions())->setTimeoutConnect(7); + + self::assertSame(7, (new SocketPoolOptions($custom))->getSocket()->getTimeoutConnect()); + } + + public function test_pool_options_default_to_an_empty_servers_list(): void + { + self::assertSame( + [], + (new SocketPoolOptions())->getServers() + ); + } + + public function test_pool_options_round_trip_servers(): void + { + $opts = (new SocketPoolOptions())->setServers(['a', 'b', 'c']); + + self::assertSame( + ['a', 'b', 'c'], + $opts->getServers(), + ); + } +} diff --git a/tests/unit/SocketPoolTest.php b/tests/unit/SocketPoolTest.php index 7172ae8..e95ea48 100644 --- a/tests/unit/SocketPoolTest.php +++ b/tests/unit/SocketPoolTest.php @@ -13,7 +13,10 @@ namespace Tests\Unit\FreeDSx\Socket; +use FreeDSx\Socket\SocketOptions; use FreeDSx\Socket\SocketPool; +use FreeDSx\Socket\SocketPoolOptions; +use FreeDSx\Socket\Transport; use PHPUnit\Framework\TestCase; final class SocketPoolTest extends TestCase @@ -47,10 +50,11 @@ public function test_it_should_respect_the_transport_type_when_connecting(): voi } $this->unixServer = $server; - $subject = new SocketPool([ - 'servers' => [$this->unixPath], - 'transport' => 'unix', - ]); + $subject = new SocketPool( + (new SocketPoolOptions( + (new SocketOptions())->setTransport(Transport::Unix), + ))->setServers([$this->unixPath]), + ); self::assertTrue($subject->connect()->isConnected()); } diff --git a/tests/unit/SocketServerTest.php b/tests/unit/SocketServerTest.php index 43f6d1b..5ddbb59 100644 --- a/tests/unit/SocketServerTest.php +++ b/tests/unit/SocketServerTest.php @@ -15,7 +15,10 @@ use FreeDSx\Socket\Exception\ConnectionException; use FreeDSx\Socket\Socket; +use FreeDSx\Socket\SocketOptions; use FreeDSx\Socket\SocketServer; +use FreeDSx\Socket\SocketServerOptions; +use FreeDSx\Socket\Transport; use PHPUnit\Framework\TestCase; final class SocketServerTest extends TestCase @@ -41,7 +44,7 @@ protected function tearDown(): void public function test_it_should_throw_a_connection_exception_if_it_cannot_listen_on_the_ip_and_port(): void { - $this->subject = new SocketServer([]); + $this->subject = new SocketServer(); $this->expectException(ConnectionException::class); @@ -60,8 +63,8 @@ public function test_it_should_construct_a_tcp_based_socket_server(): void $this->subject = SocketServer::bindTcp('0.0.0.0', 33389); self::assertSame( - 'tcp', - $this->subject->getOptions()['transport'] + Transport::Tcp, + $this->subject->getOptions()->getTransport(), ); self::assertTrue($this->subject->isConnected()); $this->subject->close(); @@ -72,7 +75,10 @@ public function test_it_should_construct_a_udp_based_socket_server(): void { $this->subject = SocketServer::bindUdp('0.0.0.0', 33389); - self::assertSame('udp', $this->subject->getOptions()['transport']); + self::assertSame( + Transport::Udp, + $this->subject->getOptions()->getTransport(), + ); } public function test_it_should_construct_a_unix_based_socket_server(): void @@ -82,8 +88,8 @@ public function test_it_should_construct_a_unix_based_socket_server(): void $this->subject = SocketServer::bindUnix($this->testSocket); self::assertSame( - 'unix', - $this->subject->getOptions()['transport'] + Transport::Unix, + $this->subject->getOptions()->getTransport(), ); self::assertTrue($this->subject->isConnected()); $this->subject->close(); @@ -94,12 +100,38 @@ public function test_it_should_receive_data(): void { $this->subject = SocketServer::bindUdp('0.0.0.0', 33389); - $client = Socket::udp('127.0.0.1', ['port' => 33389]); + $client = Socket::udp( + '127.0.0.1', + (new SocketOptions())->setPort(33389), + ); $client->write('foo'); - self::assertSame( - 'foo', - $this->subject->receive() + self::assertSame('foo', $this->subject->receive()); + } + + public function test_it_should_apply_idle_timeout_to_accepted_client_read_timeout(): void + { + $this->subject = SocketServer::bindTcp( + '127.0.0.1', + 33389, + (new SocketServerOptions())->setIdleTimeout(42), + ); + + $client = Socket::tcp( + '127.0.0.1', + (new SocketOptions())->setPort(33389), ); + + try { + $accepted = $this->subject->accept(1.0); + + self::assertNotNull($accepted); + self::assertSame( + 42, + $accepted->getOptions()->getTimeoutRead() + ); + } finally { + $client->close(); + } } } diff --git a/tests/unit/SocketTest.php b/tests/unit/SocketTest.php index 7b3a7c8..cd713c9 100644 --- a/tests/unit/SocketTest.php +++ b/tests/unit/SocketTest.php @@ -14,6 +14,8 @@ namespace Tests\Unit\FreeDSx\Socket; use FreeDSx\Socket\Socket; +use FreeDSx\Socket\SocketOptions; +use FreeDSx\Socket\Transport; use PHPUnit\Framework\TestCase; final class SocketTest extends TestCase @@ -53,32 +55,36 @@ protected function tearDown(): void } } - public function test_it_should_get_the_options_for_the_socket(): void + public function test_it_should_get_the_default_options_for_the_socket(): void { $subject = new Socket(); + $options = $subject->getOptions(); + self::assertSame(Transport::Tcp, $options->getTransport()); + self::assertSame(389, $options->getPort()); + self::assertFalse($options->isUseSsl()); self::assertSame( - [ - 'transport' => 'tcp', - 'port' => 389, - 'use_ssl' => false, - 'ssl_crypto_method' => STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT | STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT | STREAM_CRYPTO_METHOD_TLS_CLIENT, - 'ssl_ciphers' => 'DEFAULT', - 'ssl_validate_cert' => true, - 'ssl_allow_self_signed' => null, - 'ssl_ca_cert' => null, - 'ssl_peer_name' => null, - 'timeout_connect' => 3, - 'timeout_read' => 15, - 'buffer_size' => 8192, - ], - $subject->getOptions(), + STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT + | STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT + | STREAM_CRYPTO_METHOD_TLS_CLIENT, + $options->getSslCryptoMethod(), ); + self::assertSame('DEFAULT', $options->getSslCiphers()); + self::assertTrue($options->isSslValidateCert()); + self::assertNull($options->getSslAllowSelfSigned()); + self::assertNull($options->getSslCaCert()); + self::assertNull($options->getSslPeerName()); + self::assertSame(3, $options->getTimeoutConnect()); + self::assertSame(15, $options->getTimeoutRead()); + self::assertSame(8192, $options->getBufferSize()); } public function test_it_should_create_a_socket(): void { - $subject = Socket::create('www.google.com', ['port' => 80]); + $subject = Socket::create( + 'www.google.com', + (new SocketOptions())->setPort(80), + ); self::assertTrue($subject->isConnected()); } @@ -89,36 +95,45 @@ public function test_it_should_create_a_unix_based_socket(): void $subject = Socket::unix($path); - self::assertSame('unix', $subject->getOptions()['transport']); + self::assertSame(Transport::Unix, $subject->getOptions()->getTransport()); } public function test_it_should_create_a_tcp_based_socket(): void { - self::assertSame( - 'tcp', - Socket::tcp('www.google.com', ['port' => 80])->getOptions()['transport'], + $subject = Socket::tcp( + 'www.google.com', + (new SocketOptions())->setPort(80), ); + + self::assertSame(Transport::Tcp, $subject->getOptions()->getTransport()); } public function test_it_should_create_a_udp_based_socket(): void { - self::assertSame( - 'udp', - Socket::udp('8.8.8.8', ['port' => 53])->getOptions()['transport'], + $subject = Socket::udp( + '8.8.8.8', + (new SocketOptions())->setPort(53), ); + + self::assertSame(Transport::Udp, $subject->getOptions()->getTransport()); } public function test_it_should_have_a_default_buffer_size_of_65507_for_UDP(): void { - self::assertSame( - 65507, - Socket::udp('8.8.8.8', ['port' => 53])->getOptions()['buffer_size'], + $subject = Socket::udp( + '8.8.8.8', + (new SocketOptions())->setPort(53), ); + + self::assertSame(65507, $subject->getOptions()->getBufferSize()); } public function test_it_should_tell_whether_or_not_it_is_connected_for_tcp(): void { - $subject = Socket::tcp('www.google.com', ['port' => 80]); + $subject = Socket::tcp( + 'www.google.com', + (new SocketOptions())->setPort(80), + ); self::assertTrue($subject->isConnected()); $subject->close(); @@ -127,7 +142,10 @@ public function test_it_should_tell_whether_or_not_it_is_connected_for_tcp(): vo public function test_it_should_tell_whether_or_not_it_is_connected_for_udp(): void { - $subject = Socket::udp('www.google.com', ['port' => 53]); + $subject = Socket::udp( + 'www.google.com', + (new SocketOptions())->setPort(53), + ); self::assertTrue($subject->isConnected()); $subject->close(); @@ -149,7 +167,10 @@ public function test_it_should_return_at_most_buffer_size_bytes_per_read(): void [$local, $remote] = $this->createSocketPair(); fwrite($remote, '0123456789'); - $subject = new Socket($local, ['buffer_size' => 4]); + $subject = new Socket( + $local, + (new SocketOptions())->setBufferSize(4), + ); self::assertSame('0123', $subject->read()); self::assertSame('4567', $subject->read());