Commit 1c196d0ee56 for php.net

commit 1c196d0ee5670b0e9873fad49dda5464b8294707
Author: Ilija Tovilo <ilija.tovilo@me.com>
Date:   Wed Jun 17 00:19:31 2026 +0200

    Fixed timeout for supplemental read at end of a blocking stream in SSL stream wrapper

    This issue is related to GH-22332:

    > Let's assume we have a stream with 5 queued bytes. When calling fread($s, 1),
    > the underlying buffered stream will request a chunk of up to 8192 bytes
    > immediately, but only return the requested 1 byte back to the reader. If the
    > reader then requests fread($s, 10), _php_stream_read() will first return
    > anything that was buffered but not yet read.
    >
    > If the requested length exceeds the number of buffered bytes (as is the case
    > above), another read call is issued. This call will return nothing, because
    > the stream only provides 4 more readable bytes, all of which are buffered.
    > php_openssl_handle_ssl_error() (called by php_openssl_sockop_io()) will then
    > incorrectly set last_status to WANT_READ, even though we've already read the
    > remaining data.
    >
    > Furthermore, stream_select() can cause the same issue via
    > php_openssl_sockop_cast(castas: PHP_STREAM_AS_FD_FOR_SELECT), which pre-fills
    > the read buffer on SSL_pending() > 0. The subsequent fread() will lead to the
    > same condition as above.
    >
    > There's a second issue here. If the stream is blocking, the supplement read
    > will block for the duration of the timeout. This will be addressed in a second
    > PR.

    This addresses the last paragraph. Avoid a blocking read when we already have
    buffered data, as we may have reached the end of the stream and will wait in
    vain.

    Closes GH-22346

diff --git a/NEWS b/NEWS
index d03a77b01d9..feb9221c2e0 100644
--- a/NEWS
+++ b/NEWS
@@ -14,6 +14,10 @@ PHP                                                                        NEWS
   . Fixed IntlChar methods leaving stale global error state after successful
     calls. (Xuyang Zhang)

+- OpenSSL:
+  . Fixed timeout for supplemental read at end of a blocking stream in SSL
+    stream wrapper. (ilutov)
+
 - PDO_ODBC:
   . Fixed bug GH-20726 (Crash with ODBC connection pooling when the DSN
     carries no credentials). (iliaal)
diff --git a/ext/openssl/tests/stream_supplemental_read_timeout.phpt b/ext/openssl/tests/stream_supplemental_read_timeout.phpt
new file mode 100644
index 00000000000..0e36f146bf9
--- /dev/null
+++ b/ext/openssl/tests/stream_supplemental_read_timeout.phpt
@@ -0,0 +1,90 @@
+--TEST--
+Timeout for supplemental read at end of a blocking stream in SSL stream wrapper
+--EXTENSIONS--
+openssl
+--SKIPIF--
+<?php
+if (!function_exists("proc_open")) die("skip no proc_open");
+?>
+--FILE--
+<?php
+$certFile = __DIR__ . DIRECTORY_SEPARATOR . 'crypto_supplemental_read_timeout.pem.tmp';
+$peerName = 'crypto-supplemental-read-timeout';
+
+$serverCode = <<<'CODE'
+    $ctx = stream_context_create(['ssl' => ['local_cert' => '%s']]);
+    $flags = STREAM_SERVER_BIND|STREAM_SERVER_LISTEN;
+    $server = stream_socket_server("tls://127.0.0.1:0", $errno, $errstr, $flags, $ctx);
+    phpt_notify_server_start($server);
+
+    $conn = stream_socket_accept($server, 30);
+
+    fwrite($conn, "hello\n");
+
+    phpt_wait();
+    fclose($conn);
+CODE;
+$serverCode = sprintf($serverCode, $certFile);
+
+$clientCode = <<<'CODE'
+    $ctx = stream_context_create(['ssl' => [
+        'verify_peer' => false,
+        'verify_peer_name' => false,
+        'peer_name' => '%s',
+    ]]);
+
+    $client = stream_socket_client("tls://{{ ADDR }}", $errno, $errstr, 30, STREAM_CLIENT_CONNECT, $ctx);
+    stream_set_blocking($client, true);
+    stream_set_timeout($client, 5);
+    $start = hrtime(true);
+
+    $buf = '';
+    $read = [$client];
+    $write = $except = null;
+    while (true) {
+        if (!stream_select($read, $write, $except, 5)) {
+            break;
+        }
+
+        // Initially, read only the first char, then request more than is stored
+        // in the buffer, triggering a supplemental read.
+        $chunk = fread($client, strlen($buf) === 0 ? 1 : 10);
+        if ($chunk === '' || $chunk === false) {
+            /* A non-application record (e.g. a TLS 1.3 session ticket) may arrive first. */
+            if (feof($client)) {
+                break;
+            }
+        } else {
+            $buf .= $chunk;
+            if (strlen($buf) >= 6) {
+                break;
+            }
+        }
+        $read = [$client];
+        $write = $except = null;
+    }
+
+    echo trim($buf), "\n";
+
+    $diff = (hrtime(true) - $start) / 1e9;
+    var_dump($diff < 4.0);
+
+    phpt_notify();
+    fclose($client);
+CODE;
+$clientCode = sprintf($clientCode, $peerName);
+
+include 'CertificateGenerator.inc';
+$certificateGenerator = new CertificateGenerator();
+$certificateGenerator->saveNewCertAsFileWithKey($peerName, $certFile);
+
+include 'ServerClientTestCase.inc';
+ServerClientTestCase::getInstance()->run($clientCode, $serverCode);
+?>
+--CLEAN--
+<?php
+@unlink(__DIR__ . DIRECTORY_SEPARATOR . 'crypto_supplemental_read_timeout.pem.tmp');
+?>
+--EXPECT--
+hello
+bool(true)
diff --git a/ext/openssl/xp_ssl.c b/ext/openssl/xp_ssl.c
index ffacd8a107b..77c98f65b51 100644
--- a/ext/openssl/xp_ssl.c
+++ b/ext/openssl/xp_ssl.c
@@ -2069,7 +2069,11 @@ static ssize_t php_openssl_sockop_io(int read, php_stream *stream, char *buf, si

 	/* Only do this if SSL is active. */
 	if (sslsock->ssl_active) {
-		int retry = 1;
+		/* We have already returned some buffered data. Don't retry and don't
+		 * block. We're just trying to fill the buffer more, but the stream might
+		 * be empty, so we don't want to wait in vain. */
+		bool supplemental = stream->has_buffered_data;
+		int retry = !supplemental;
 		struct timeval start_time;
 		struct timeval *timeout = NULL;
 		int began_blocked = sslsock->s.is_blocked;
@@ -2082,11 +2086,11 @@ static ssize_t php_openssl_sockop_io(int read, php_stream *stream, char *buf, si
 		}

 		/* never use a timeout with non-blocking sockets */
-		if (began_blocked) {
+		if (began_blocked && !supplemental) {
 			timeout = &sslsock->s.timeout;
 		}

-		if (timeout) {
+		if (timeout || supplemental) {
 			php_openssl_set_blocking(sslsock, 0);
 		}

@@ -2160,7 +2164,7 @@ static ssize_t php_openssl_sockop_io(int read, php_stream *stream, char *buf, si
 				}

 				/* Don't loop indefinitely in non-blocking mode if no data is available */
-				if (began_blocked == 0) {
+				if (began_blocked == 0 || supplemental) {
 					break;
 				}