Commit 614c62872ac for php
commit 614c62872ac48cf04ac674f9f44633c01750a3b6
Author: Jakub Zelenka <bukka@php.net>
Date: Mon Oct 5 11:35:05 2026 +0200
Fix TLS stream EOF detection after close_notify with stale errno (#24132)
php_openssl_handle_ssl_error() sets errno to EAGAIN on SSL_ERROR_WANT_READ
and SSL_ERROR_WANT_WRITE, and php_openssl_sockop_io() reads it back to avoid
marking a non-blocking read that needs to wait as EOF. OpenSSL resets errno
before every recv() on POSIX systems, but on Windows it uses the Winsock
error state instead and never touches errno, so the EAGAIN stays set across
every later successful read. A close_notify received afterwards returned
SSL_ERROR_ZERO_RETURN but did not set stream->eof, and feof() stayed false
while the TCP connection was still open.
Decide EOF from the SSL error code, which already says whether the
operation just needs to wait, instead of from errno.
diff --git a/NEWS b/NEWS
index 40393c15fb7..91ed81e4018 100644
--- a/NEWS
+++ b/NEWS
@@ -120,6 +120,8 @@ PHP NEWS
- OpenSSL:
. Fixed stream_socket_enable_crypto() leaving the socket non-blocking
after a handshake timeout. (Ilia Alshanetsky)
+ . Fixed feof() on a TLS stream staying false after a close_notify on Windows.
+ (Jakub Zelenka)
- PCNTL:
. Fixed pcntl_signal_dispatch() dropping the queued signals when it runs while
diff --git a/ext/openssl/tests/stream_eof_after_close_notify.phpt b/ext/openssl/tests/stream_eof_after_close_notify.phpt
new file mode 100644
index 00000000000..4755e171544
--- /dev/null
+++ b/ext/openssl/tests/stream_eof_after_close_notify.phpt
@@ -0,0 +1,58 @@
+--TEST--
+feof() is true after a TLS close_notify even if an earlier read left errno set to EAGAIN
+--EXTENSIONS--
+openssl
+--SKIPIF--
+<?php
+if (!function_exists("proc_open")) die("skip no proc_open");
+?>
+--FILE--
+<?php
+$certFile = __DIR__ . DIRECTORY_SEPARATOR . 'stream_eof_after_close_notify.pem.tmp';
+
+$serverCode = <<<'CODE'
+ $serverCtx = stream_context_create(['ssl' => ['local_cert' => '%s']]);
+ $sock = stream_socket_server("tls://127.0.0.1:0", $errno, $errstr,
+ STREAM_SERVER_BIND | STREAM_SERVER_LISTEN, $serverCtx);
+ phpt_notify_server_start($sock);
+
+ $link = stream_socket_accept($sock);
+ /* Let the client block in fread() first, so its SSL_read() sees WANT_READ */
+ phpt_wait();
+ usleep(100000);
+ fwrite($link, "data");
+ /* close_notify only, the TCP connection stays open */
+ stream_socket_enable_crypto($link, false);
+ phpt_wait();
+ fclose($link);
+CODE;
+$serverCode = sprintf($serverCode, $certFile);
+
+$clientCode = <<<'CODE'
+ $clientCtx = stream_context_create(['ssl' => [
+ 'verify_peer' => false,
+ 'verify_peer_name' => false,
+ ]]);
+ $sock = stream_socket_client("tls://{{ ADDR }}", $errno, $errstr, 2, STREAM_CLIENT_CONNECT, $clientCtx);
+
+ phpt_notify();
+ var_dump(fread($sock, 4));
+ var_dump(fread($sock, 4));
+ var_dump(feof($sock));
+ phpt_notify();
+CODE;
+
+include 'CertificateGenerator.inc';
+(new CertificateGenerator())->saveNewCertAsFileWithKey('stream_eof_after_close_notify', $certFile);
+
+include 'ServerClientTestCase.inc';
+ServerClientTestCase::getInstance()->run($clientCode, $serverCode);
+?>
+--CLEAN--
+<?php
+@unlink(__DIR__ . DIRECTORY_SEPARATOR . 'stream_eof_after_close_notify.pem.tmp');
+?>
+--EXPECT--
+string(4) "data"
+string(0) ""
+bool(true)
diff --git a/ext/openssl/xp_ssl.c b/ext/openssl/xp_ssl.c
index b564be8cad5..125b2242380 100644
--- a/ext/openssl/xp_ssl.c
+++ b/ext/openssl/xp_ssl.c
@@ -2186,9 +2186,11 @@ static ssize_t php_openssl_sockop_io(int read, php_stream *stream, char *buf, si
retry = 1;
}
- /* Also, on reads, we may get this condition on an EOF. We should check properly. */
if (read) {
- stream->eof = (retry == 0 && errno != EAGAIN && !SSL_pending(sslsock->ssl_handle));
+ /* EOF unless the SSL layer just needs to wait. */
+ stream->eof = (retry == 0
+ && err != SSL_ERROR_WANT_READ && err != SSL_ERROR_WANT_WRITE
+ && !SSL_pending(sslsock->ssl_handle));
}
/* Don't loop indefinitely in non-blocking mode if no data is available */