From 8323154b2aae8f06acd323442a0da2c92f0f8512 Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Mon, 7 Sep 2026 22:25:46 -0400 Subject: [PATCH 1/2] Add an option to set a handshake timeout. --- src/FreeDSx/Socket/HasSocketOptions.php | 14 ++++++++++++++ src/FreeDSx/Socket/Socket.php | 15 +++++++++++++-- src/FreeDSx/Socket/SocketOptionsInterface.php | 5 +++++ 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/FreeDSx/Socket/HasSocketOptions.php b/src/FreeDSx/Socket/HasSocketOptions.php index cac6ba6..67e0328 100644 --- a/src/FreeDSx/Socket/HasSocketOptions.php +++ b/src/FreeDSx/Socket/HasSocketOptions.php @@ -59,6 +59,8 @@ trait HasSocketOptions private int $timeoutRead = 15; + private int $timeoutHandshake = 5; + private int $timeoutWrite = 0; /** @@ -269,6 +271,18 @@ public function getTimeoutRead(): int return $this->timeoutRead; } + public function setTimeoutHandshake(int $seconds): self + { + $this->timeoutHandshake = $seconds; + + return $this; + } + + public function getTimeoutHandshake(): int + { + return $this->timeoutHandshake; + } + public function setTimeoutWrite(int $seconds): self { $this->timeoutWrite = $seconds; diff --git a/src/FreeDSx/Socket/Socket.php b/src/FreeDSx/Socket/Socket.php index 63f0c3f..fdc302a 100644 --- a/src/FreeDSx/Socket/Socket.php +++ b/src/FreeDSx/Socket/Socket.php @@ -19,6 +19,7 @@ use FreeDSx\Socket\Tls\Certificate; use OpenSSLCertificate; +use function error_get_last; use function fclose; use function fread; use function fwrite; @@ -220,11 +221,21 @@ public function encrypt(bool $encrypt): static $stream, true, ); - $result = stream_socket_enable_crypto( + // Bounded on its own, since the read timeout belongs to an idle session and can be far longer. + stream_set_timeout( + $stream, + $this->options->getTimeoutHandshake(), + ); + + // Suppressed so a peer that stalls the handshake cannot make the process write to stderr. + $result = @stream_socket_enable_crypto( $stream, $encrypt, $this->options->getSslCryptoMethod(), ); + $lastError = error_get_last(); + + $this->setStreamOpts(); stream_set_blocking( $stream, false, @@ -234,7 +245,7 @@ public function encrypt(bool $encrypt): static throw new ConnectionException(sprintf( 'Unable to %s encryption on TCP connection. %s', $encrypt ? 'enable' : 'disable', - $this->errorMessage, + $lastError['message'] ?? $this->errorMessage, )); } $this->isEncrypted = $encrypt; diff --git a/src/FreeDSx/Socket/SocketOptionsInterface.php b/src/FreeDSx/Socket/SocketOptionsInterface.php index 66b1e19..2f09038 100644 --- a/src/FreeDSx/Socket/SocketOptionsInterface.php +++ b/src/FreeDSx/Socket/SocketOptionsInterface.php @@ -48,6 +48,11 @@ public function getTimeoutConnect(): int; public function getTimeoutRead(): int; + /** + * Seconds a TLS handshake may take. + */ + public function getTimeoutHandshake(): int; + public function getTimeoutWrite(): int; public function getWriteTimeoutEnforcer(): WriteTimeoutEnforcerInterface; From e3f9a0ef769418df97fca9aa4dbf4c9397305a68 Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Tue, 8 Sep 2026 11:15:54 -0400 Subject: [PATCH 2/2] Wait for readability before a blocking read. --- src/FreeDSx/Socket/Socket.php | 87 +++++++++++++++++++++++++++++++++++ tests/unit/SocketTest.php | 40 ++++++++++++++++ 2 files changed, 127 insertions(+) diff --git a/src/FreeDSx/Socket/Socket.php b/src/FreeDSx/Socket/Socket.php index fdc302a..4ee6913 100644 --- a/src/FreeDSx/Socket/Socket.php +++ b/src/FreeDSx/Socket/Socket.php @@ -21,13 +21,17 @@ use function error_get_last; use function fclose; +use function fmod; use function fread; use function fwrite; use function is_array; +use function is_resource; +use function microtime; use function sprintf; use function stream_context_create; use function stream_context_get_options; use function stream_get_meta_data; +use function stream_select; use function stream_set_blocking; use function stream_set_timeout; use function stream_socket_client; @@ -41,6 +45,8 @@ */ class Socket { + private const MICROSECONDS_PER_SECOND = 1_000_000; + protected bool $isEncrypted = false; /** @@ -81,6 +87,17 @@ public function __construct( public function read(bool $block = true): string|false { $stream = $this->getStream(); + + // Bytes decrypted into the TLS buffer are off the wire already. + $buffered = $block && $this->isEncrypted + ? $this->readWithoutBlocking($stream) + : ''; + if ($buffered !== '') { + return $buffered; + } + if ($block) { + $this->awaitReadable($stream); + } stream_set_blocking($stream, $block); $data = fread( @@ -109,6 +126,76 @@ public function read(bool $block = true): string|false : $data; } + /** + * Take whatever is already buffered without waiting. + * + * @param resource $stream + * @return string Whatever is in the buffer. Empty string when nothing is. + */ + private function readWithoutBlocking($stream): string + { + stream_set_blocking($stream, false); + + $data = fread( + $stream, + $this->options->getBufferSize(), + ); + + stream_set_blocking($stream, true); + + return $data === false + ? '' + : $data; + } + + /** + * Wait for the socket to become readable. This enforces the read timeout that the blocking read would. + * + * Waiting here returns on EINTR and lets the caller run its signal handlers. + * + * @param resource $stream + * @throws IdleTimeoutException + */ + private function awaitReadable($stream): void + { + $timeout = $this->options->getTimeoutRead(); + $deadline = $timeout > 0 + ? microtime(true) + $timeout + : null; + + while (true) { + $remaining = $deadline === null + ? null + : $deadline - microtime(true); + + if ($remaining !== null && $remaining <= 0) { + throw new IdleTimeoutException(sprintf( + 'The connection was idle for longer than the read timeout of %d seconds.', + $timeout, + )); + } + + $read = [$stream]; + $write = []; + $except = []; + + $ready = @stream_select( + $read, + $write, + $except, + $remaining === null ? null : (int) $remaining, + $remaining === null ? 0 : (int) (fmod($remaining, 1.0) * self::MICROSECONDS_PER_SECOND), + ); + + if ($ready > 0) { + return; + } + if (!is_resource($stream)) { + return; + } + } + } + /** * @param resource $stream */ diff --git a/tests/unit/SocketTest.php b/tests/unit/SocketTest.php index 1a47507..9b89e6d 100644 --- a/tests/unit/SocketTest.php +++ b/tests/unit/SocketTest.php @@ -285,6 +285,46 @@ public function test_it_should_throw_an_idle_timeout_when_a_blocking_read_exceed $subject->read(); } + public function test_a_blocking_read_dispatches_a_signal_that_arrives_while_it_waits(): void + { + if (!function_exists('pcntl_async_signals')) { + self::markTestSkipped('The pcntl extension is required to deliver a signal mid-read.'); + } + + [$local, $remote] = $this->createSocketPair(); + $subject = new Socket( + $local, + (new SocketOptions())->setTimeoutRead(5), + ); + + $handled = false; + pcntl_async_signals(true); + pcntl_signal( + SIGALRM, + function () use (&$handled, $remote): void { + $handled = true; + fwrite($remote, 'woke'); + }, + ); + pcntl_alarm(1); + + try { + $data = $subject->read(); + } finally { + pcntl_alarm(0); + pcntl_signal(SIGALRM, SIG_DFL); + } + + self::assertTrue( + $handled, + 'The signal handler never ran.', + ); + self::assertSame( + 'woke', + $data, + ); + } + public function test_it_should_not_throw_an_idle_timeout_on_a_non_blocking_read(): void { [$local] = $this->createSocketPair();