Commit 3081f7f5ae0 for php.net

commit 3081f7f5ae0db7e2ce970d08739f6c816c0fd639
Author: Jakub Zelenka <bukka@php.net>
Date:   Wed Aug 5 20:15:42 2026 +0200

    Add stream socket so_linger context option (#20808)

    This adds so_linger stream socket context options that is used to set
    the lingering time in seconds. If the value is lower or equal to 0,
    then the lingering is disabled.

    Co-authored-by: David Carlier <devnexen@gmail.com>

diff --git a/NEWS b/NEWS
index cdfca0fbf0f..0f3ba67434d 100644
--- a/NEWS
+++ b/NEWS
@@ -607,7 +607,8 @@ PHP                                                                        NEWS
   . Added so_keepalive, tcp_keepidle, tcp_keepintvl and tcp_keepcnt stream
     socket context options. (Jakub Zelenka)
   . Added so_reuseaddr streams context socket option that allows disabling
-    address reuse. (Jakub Zelenka)
+    address resuse. (Jakub Zelenka)
+  . Added so_linger stream socket context option. (Jakub Zelenka)
   . Fixed bug GH-20370 (User stream filters could violate typed property
     constraints). (alexandre-daubois)
   . Allowed filtered streams to be casted as fd for select. (Jakub Zelenka)
diff --git a/UPGRADING b/UPGRADING
index 9389c7b04b2..ced36548070 100644
--- a/UPGRADING
+++ b/UPGRADING
@@ -382,6 +382,10 @@ PHP 8.6 UPGRADE NOTES
   . Added stream socket context options so_keepalive, tcp_keepidle,
     tcp_keepintvl and tcp_keepcnt that allow setting socket keepalive
     options.
+  . Added stream socket context option so_linger that sets SO_LINGER on TCP
+    sockets. A positive value enables lingering for that many seconds, zero
+    or a negative value disables it. Values above 65535 are clamped as the
+    linger time is limited to an unsigned short on some platforms.
   . Allowed casting casting filtered streams as file descriptor for select.
   . Added the "write_seek_mode stream" filter parameter for the bz2, iconv,
     zlib, and string stream filters. This parameter must be set via an
diff --git a/ext/standard/tests/network/so_linger.phpt b/ext/standard/tests/network/so_linger.phpt
new file mode 100644
index 00000000000..ee0561b7738
--- /dev/null
+++ b/ext/standard/tests/network/so_linger.phpt
@@ -0,0 +1,82 @@
+--TEST--
+stream_socket_server() and stream_socket_client() SO_LINGER context option test
+--EXTENSIONS--
+sockets
+--FILE--
+<?php
+// Test server with SO_LINGER enabled
+$server_context = stream_context_create([
+    'socket' => [
+        'so_linger' => 10,
+    ]
+]);
+
+$server = stream_socket_server("tcp://127.0.0.1:0", $errno, $errstr,
+    STREAM_SERVER_BIND | STREAM_SERVER_LISTEN, $server_context);
+
+if (!$server) {
+    die('Unable to create server');
+}
+
+$addr = stream_socket_get_name($server, false);
+$port = (int)substr(strrchr($addr, ':'), 1);
+
+// Test client with SO_LINGER enabled
+$client_context = stream_context_create([
+    'socket' => [
+        'so_linger' => 8,
+    ]
+]);
+
+$client = stream_socket_client("tcp://127.0.0.1:$port", $errno, $errstr, 30,
+    STREAM_CLIENT_CONNECT, $client_context);
+
+if (!$client) {
+    die('Unable to create client');
+}
+
+$accepted = stream_socket_accept($server, 1);
+
+if (!$accepted) {
+    die('Unable to accept connection');
+}
+
+// macOS expresses SO_LINGER in ticks, SO_LINGER_SEC in seconds.
+$so_linger = defined('SO_LINGER_SEC') ? SO_LINGER_SEC : SO_LINGER;
+
+// Verify the listening socket
+$listen_sock = socket_import_stream($server);
+$listen_linger = socket_get_option($listen_sock, SOL_SOCKET, $so_linger);
+echo "Listen SO_LINGER\n";
+var_dump($listen_linger['l_onoff'] > 0);
+var_dump($listen_linger['l_linger']);
+
+// Verify server side (accepted connection, inherits from the listening socket)
+$server_sock = socket_import_stream($accepted);
+$server_linger = socket_get_option($server_sock, SOL_SOCKET, $so_linger);
+echo "Server SO_LINGER\n";
+var_dump($server_linger['l_onoff'] > 0);
+var_dump($server_linger['l_linger']);
+
+// Verify client side
+$client_sock = socket_import_stream($client);
+$client_linger = socket_get_option($client_sock, SOL_SOCKET, $so_linger);
+echo "Client SO_LINGER\n";
+var_dump($client_linger['l_onoff'] > 0);
+var_dump($client_linger['l_linger']);
+
+fclose($accepted);
+fclose($client);
+fclose($server);
+
+?>
+--EXPECT--
+Listen SO_LINGER
+bool(true)
+int(10)
+Server SO_LINGER
+bool(true)
+int(10)
+Client SO_LINGER
+bool(true)
+int(8)
diff --git a/main/network.c b/main/network.c
index b4d3ded1519..836abaeac7d 100644
--- a/main/network.c
+++ b/main/network.c
@@ -535,6 +535,24 @@ php_socket_t php_network_bind_socket_to_local_addr_ex(const char *host, unsigned

 		/* Set socket values if provided */
 		if (sockvals != NULL) {
+#ifdef SO_LINGER
+			if (sockvals->mask & PHP_SOCKVAL_SO_LINGER) {
+				/* l_linger is an unsigned short on Windows, so clamp rather than
+				 * truncate: a truncated value may still be in range and would then
+				 * be applied silently (e.g. 65536 becoming 0, an abortive close). */
+				unsigned short secs = sockvals->linger > USHRT_MAX
+					? USHRT_MAX : (unsigned short)sockvals->linger;
+				struct linger linger_val = {
+					.l_onoff = (sockvals->linger > 0),
+					.l_linger = sockvals->linger > 0 ? secs : 0
+				};
+#ifdef SO_LINGER_SEC
+				setsockopt(sock, SOL_SOCKET, SO_LINGER_SEC, (char*)&linger_val, sizeof(linger_val));
+#else
+				setsockopt(sock, SOL_SOCKET, SO_LINGER, (char*)&linger_val, sizeof(linger_val));
+#endif
+			}
+#endif
 #if defined(TCP_KEEPIDLE)
 			if (sockvals->mask & PHP_SOCKVAL_TCP_KEEPIDLE) {
 				setsockopt(sock, IPPROTO_TCP, TCP_KEEPIDLE, (char*)&sockvals->keepalive.keepidle, sizeof(sockvals->keepalive.keepidle));
@@ -1021,6 +1039,24 @@ php_socket_t php_network_connect_socket_to_host_ex(const char *host, unsigned sh

 		/* Set socket values if provided */
 		if (sockvals != NULL) {
+#ifdef SO_LINGER
+			if (sockvals->mask & PHP_SOCKVAL_SO_LINGER) {
+				/* l_linger is an unsigned short on Windows, so clamp rather than
+				 * truncate: a truncated value may still be in range and would then
+				 * be applied silently (e.g. 65536 becoming 0, an abortive close). */
+				unsigned short secs = sockvals->linger > USHRT_MAX
+					? USHRT_MAX : (unsigned short)sockvals->linger;
+				struct linger linger_val = {
+					.l_onoff = (sockvals->linger > 0),
+					.l_linger = sockvals->linger > 0 ? secs : 0
+				};
+#ifdef SO_LINGER_SEC
+				setsockopt(sock, SOL_SOCKET, SO_LINGER_SEC, (char*)&linger_val, sizeof(linger_val));
+#else
+				setsockopt(sock, SOL_SOCKET, SO_LINGER, (char*)&linger_val, sizeof(linger_val));
+#endif
+			}
+#endif
 #if defined(TCP_KEEPIDLE)
 			if (sockvals->mask & PHP_SOCKVAL_TCP_KEEPIDLE) {
 				setsockopt(sock, IPPROTO_TCP, TCP_KEEPIDLE, (char*)&sockvals->keepalive.keepidle, sizeof(sockvals->keepalive.keepidle));
diff --git a/main/php_network.h b/main/php_network.h
index e6d3009a6c8..c966b7af005 100644
--- a/main/php_network.h
+++ b/main/php_network.h
@@ -269,12 +269,14 @@ typedef struct {
 #define PHP_SOCKVAL_TCP_KEEPIDLE  (1 << 1)
 #define PHP_SOCKVAL_TCP_KEEPCNT   (1 << 2)
 #define PHP_SOCKVAL_TCP_KEEPINTVL (1 << 3)
+#define PHP_SOCKVAL_SO_LINGER     (1 << 4)

 #define PHP_SOCKVAL_IS_SET(sockvals, opt) ((sockvals)->mask & (opt))

 typedef struct {
 	unsigned int mask;
 	int tcp_nodelay;
+	int linger;
 	struct {
 		int keepidle;
 		int keepcnt;
diff --git a/main/streams/xp_socket.c b/main/streams/xp_socket.c
index 3eef731544d..a9e2fa2b1d0 100644
--- a/main/streams/xp_socket.c
+++ b/main/streams/xp_socket.c
@@ -759,6 +759,16 @@ static inline int php_tcp_sockop_bind(php_stream *stream, php_netstream_data_t *
 	}
 #endif

+#ifdef SO_LINGER
+	if (PHP_STREAM_XPORT_IS_TCP(stream)
+		&& PHP_STREAM_CONTEXT(stream)
+		&& (tmpzval = php_stream_context_get_option(PHP_STREAM_CONTEXT(stream), "socket", "so_linger")) != NULL
+	) {
+		sockvals.mask |= PHP_SOCKVAL_SO_LINGER;
+		sockvals.linger = (int)zval_get_long(tmpzval);
+	}
+#endif
+
 #ifdef SO_KEEPALIVE
 	if (PHP_STREAM_XPORT_IS_TCP(stream) /* SO_KEEPALIVE is only applicable for TCP */
 		&& PHP_STREAM_CONTEXT(stream)
@@ -887,6 +897,16 @@ static inline int php_tcp_sockop_connect(php_stream *stream, php_netstream_data_
 		sockopts |= STREAM_SOCKOP_TCP_NODELAY;
 	}

+#ifdef SO_LINGER
+	if (PHP_STREAM_XPORT_IS_TCP(stream)
+		&& PHP_STREAM_CONTEXT(stream)
+		&& (tmpzval = php_stream_context_get_option(PHP_STREAM_CONTEXT(stream), "socket", "so_linger")) != NULL
+	) {
+		sockvals.mask |= PHP_SOCKVAL_SO_LINGER;
+		sockvals.linger = (int)zval_get_long(tmpzval);
+	}
+#endif
+
 #ifdef SO_KEEPALIVE
 	if (PHP_STREAM_XPORT_IS_TCP(stream) /* SO_KEEPALIVE is only applicable for TCP */
 		&& PHP_STREAM_CONTEXT(stream)