From b9bc7bf852ba7e27c91371fedf7f2fb3c4ded0ba Mon Sep 17 00:00:00 2001 From: kafkiansky Date: Sun, 11 Oct 2026 19:00:31 +0300 Subject: [PATCH] Fix read stall on TLS streams with non-select drivers On a TLS stream feof() checks liveness with SSL_peek(), which moves a record that has just arrived from the socket into the OpenSSL buffer. If that happens between the empty direct read and enabling the watcher, the socket is no longer readable and drivers polling the descriptor (uv, ev, event) never invoke the watcher, so the read waits forever although the data is buffered. StreamSelectDriver is not affected because stream_select() checks SSL_pending(). Read once more after feof() reports the stream is still open. --- src/ReadableResourceStream.php | 50 ++++++++++++++++++++++------------ 1 file changed, 33 insertions(+), 17 deletions(-) diff --git a/src/ReadableResourceStream.php b/src/ReadableResourceStream.php index 4e7b01d..79cede9 100644 --- a/src/ReadableResourceStream.php +++ b/src/ReadableResourceStream.php @@ -183,23 +183,8 @@ public function read(?Cancellation $cancellation = null, ?int $limit = null): ?s \assert($this->resource !== null); - \set_error_handler(self::$errorHandler); - - try { - // Attempt a direct read because PHP may buffer data, e.g. in TLS buffers. - if ($this->useSingleRead) { - $data = \fread($this->resource, $limit); - } else { - $data = \stream_get_contents($this->resource, $limit); - } - } finally { - \restore_error_handler(); - } - - \assert( - $data !== false, - "Trying to read from a previously fclose()'d resource. Do NOT manually fclose() resources the loop still has a reference to." - ); + // Attempt a direct read because PHP may buffer data, e.g. in TLS buffers. + $data = $this->readDirectly($limit); if ($data === '') { if (\feof($this->resource)) { @@ -208,6 +193,13 @@ public function read(?Cancellation $cancellation = null, ?int $limit = null): ?s return null; } + // On a TLS stream feof() calls SSL_peek(), which moves a record that has just arrived from the socket + // into the OpenSSL buffer. The socket is then no longer readable, so the watcher would never be invoked + // by drivers polling the descriptor (all except StreamSelectDriver, as stream_select() checks SSL_pending()). + $data = $this->readDirectly($limit); + } + + if ($data === '') { $this->chunkSize = $limit; EventLoop::enable($this->callbackId); $this->suspension = EventLoop::getSuspension(); @@ -334,6 +326,30 @@ public function __destruct() } } + private function readDirectly(int $limit): string + { + \assert($this->resource !== null); + + \set_error_handler(self::$errorHandler); + + try { + if ($this->useSingleRead) { + $data = \fread($this->resource, $limit); + } else { + $data = \stream_get_contents($this->resource, $limit); + } + } finally { + \restore_error_handler(); + } + + \assert( + $data !== false, + "Trying to read from a previously fclose()'d resource. Do NOT manually fclose() resources the loop still has a reference to." + ); + + return $data; + } + /** * Nulls reference to resource, marks stream unreadable, and succeeds any pending read with null. */