Commit 23bb1c9a0ef for php

commit 23bb1c9a0efe7399af86e3b6b2b1679246beabbe
Author: Frode Børli <frodeborli@users.noreply.github.com>
Date:   Wed Oct 14 19:35:31 2026 +0200

    Fix stream_select() failing unpredictably with many or large descriptors (#14452)

    Updated stream_select() so that its not limited by FD_SETSIZE anymore

diff --git a/NEWS b/NEWS
index d64960d02cb..a93cb03247e 100644
--- a/NEWS
+++ b/NEWS
@@ -122,6 +122,15 @@ PHP                                                                        NEWS
     lookup, and an unchecked CreateFileA() failure. (Ilia Alshanetsky)
   . Fix persistent stream context lifetime during shutdown (Levi Morrison)
   . Fixed bug GH-23780 (unserialize('') fails silently). (timwolla)
+  . Fixed stream_select() aborting the whole call once any watched stream's
+    descriptor exceeded FD_SETSIZE; it no longer imposes that limit, so watching
+    many descriptors no longer requires rebuilding with --enable-fd-setsize.
+    (Frode Børli)
+  . Fixed stream_select() on Windows capping the number of watched sockets at
+    FD_SETSIZE; the winsock fd_set now grows on demand. (Frode Børli)
+  . Fixed stream_select() on Windows overflowing a fixed 64-entry stack array
+    (crashing with a stack buffer overrun) when passed more than 64 pipe or file
+    handles; it now fails gracefully instead. (Frode Børli)
   . Improved performance of array_splice() when inserting without removing
     elements. (mehmetcansahin)
   . Enforce max_filter_count: limit the number of filters that can be chained
diff --git a/UPGRADING b/UPGRADING
index 61f6b492954..fd3efda9df7 100644
--- a/UPGRADING
+++ b/UPGRADING
@@ -73,6 +73,14 @@ PHP 8.7 UPGRADE NOTES
 5. Changed Functions
 ========================================

+- Standard:
+  . stream_select() no longer fails once a watched descriptor exceeds
+    FD_SETSIZE. The descriptor set now grows on demand on both POSIX (bitset)
+    and Windows (winsock fd_set), so code watching many streams works without
+    rebuilding PHP with --enable-fd-setsize. On Windows, selecting on more than
+    64 pipe or file handles no longer crashes; it fails with a warning, as that
+    path remains bounded by WaitForMultipleObjects().
+
 ========================================
 6. New Functions
 ========================================
diff --git a/ext/standard/streamsfuncs.c b/ext/standard/streamsfuncs.c
index 6502ac279e2..caf5d8f5e44 100644
--- a/ext/standard/streamsfuncs.c
+++ b/ext/standard/streamsfuncs.c
@@ -648,7 +648,7 @@ PHP_FUNCTION(stream_clear_errors)
 }

 /* {{{ stream_select related functions */
-static int stream_array_to_fd_set(const HashTable *stream_array, fd_set *fds, php_socket_t *max_fd)
+static int stream_array_to_fd_set(const HashTable *stream_array, php_growable_fd_set *fds, php_socket_t *max_fd)
 {
 	zval *elem;
 	php_stream *stream;
@@ -672,7 +672,7 @@ static int stream_array_to_fd_set(const HashTable *stream_array, fd_set *fds, ph
 		 * */
 		if (SUCCESS == php_stream_cast(stream, PHP_STREAM_AS_FD_FOR_SELECT | PHP_STREAM_CAST_INTERNAL, (void*)&this_fd, 1) && this_fd != -1) {

-			PHP_SAFE_FD_SET(this_fd, fds);
+			php_growable_fd_set_add(fds, this_fd);

 			if (this_fd > *max_fd) {
 				*max_fd = this_fd;
@@ -683,19 +683,14 @@ static int stream_array_to_fd_set(const HashTable *stream_array, fd_set *fds, ph
 	return cnt ? 1 : 0;
 }

-static int stream_array_from_fd_set(zval *stream_array, const fd_set *fds)
+static int stream_array_from_fd_set(zval *stream_array, const php_growable_fd_set *fds)
 {
-	zval *elem, *dest_elem;
-	HashTable *ht;
-	php_stream *stream;
-	int ret = 0;
-	zend_string *key;
-	zend_ulong num_ind;
-
 	ZEND_ASSERT(Z_TYPE_P(stream_array) == IS_ARRAY);
-	ht = zend_new_array(zend_hash_num_elements(Z_ARRVAL_P(stream_array)));
+	HashTable *ht = zend_new_array(zend_hash_num_elements(Z_ARRVAL_P(stream_array)));
+	int ret = 0;

-	ZEND_HASH_FOREACH_KEY_VAL(Z_ARRVAL_P(stream_array), num_ind, key, elem) {
+	ZEND_HASH_FOREACH_KEY_VAL(Z_ARRVAL_P(stream_array), zend_ulong num_ind, zend_string *key, zval *elem) {
+		php_stream *stream;
 		php_socket_t this_fd;

 		ZVAL_DEREF(elem);
@@ -709,7 +704,8 @@ static int stream_array_from_fd_set(zval *stream_array, const fd_set *fds)
 		 * is not displayed.
 		 */
 		if (SUCCESS == php_stream_cast(stream, PHP_STREAM_AS_FD_FOR_SELECT | PHP_STREAM_CAST_INTERNAL, (void*)&this_fd, 1) && this_fd != SOCK_ERR) {
-			if (PHP_SAFE_FD_ISSET(this_fd, fds)) {
+			if (php_growable_fd_set_isset(fds, this_fd)) {
+				zval *dest_elem;
 				if (!key) {
 					dest_elem = zend_hash_index_update(ht, num_ind, elem);
 				} else {
@@ -780,15 +776,9 @@ static int stream_array_emulate_read_fd_set(zval *stream_array)
 PHP_FUNCTION(stream_select)
 {
 	zval *r_array, *w_array, *e_array, *zcontext = NULL;
-	struct timeval tv, *tv_p = NULL;
-	fd_set rfds, wfds, efds;
-	php_socket_t max_fd = 0;
-	int retval, sets = 0;
 	zend_long sec, usec = 0;
 	bool secnull;
 	bool usecnull = 1;
-	int set_count, max_set_count = 0;
-	php_stream_context *context = NULL;

 	ZEND_PARSE_PARAMETERS_START(4, 6)
 		Z_PARAM_ARRAY_EX2(r_array, 1, 1, 0)
@@ -800,62 +790,52 @@ PHP_FUNCTION(stream_select)
 		Z_PARAM_RESOURCE_OR_NULL(zcontext)
 	ZEND_PARSE_PARAMETERS_END();

-	FD_ZERO(&rfds);
-	FD_ZERO(&wfds);
-	FD_ZERO(&efds);
-
 	php_stream_error_operation_begin();
-	context = php_stream_context_from_zval(zcontext, 0);
+	php_stream_context *context = php_stream_context_from_zval(zcontext, 0);
+
+	/* The sets grow as streams are added, past FD_SETSIZE */
+	php_growable_fd_set rfds, wfds, efds;
+	php_growable_fd_set_init(&rfds, FD_SETSIZE);
+	php_growable_fd_set_init(&wfds, FD_SETSIZE);
+	php_growable_fd_set_init(&efds, FD_SETSIZE);

+	php_socket_t max_fd = 0;
+	int sets = 0;
 	if (r_array != NULL) {
-		set_count = stream_array_to_fd_set(Z_ARR_P(r_array), &rfds, &max_fd);
-		if (set_count > max_set_count)
-			max_set_count = set_count;
-		sets += set_count;
+		sets += stream_array_to_fd_set(Z_ARR_P(r_array), &rfds, &max_fd);
 	}
-
 	if (w_array != NULL) {
-		set_count = stream_array_to_fd_set(Z_ARR_P(w_array), &wfds, &max_fd);
-		if (set_count > max_set_count)
-			max_set_count = set_count;
-		sets += set_count;
+		sets += stream_array_to_fd_set(Z_ARR_P(w_array), &wfds, &max_fd);
 	}
-
 	if (e_array != NULL) {
-		set_count = stream_array_to_fd_set(Z_ARR_P(e_array), &efds, &max_fd);
-		if (set_count > max_set_count)
-			max_set_count = set_count;
-		sets += set_count;
+		sets += stream_array_to_fd_set(Z_ARR_P(e_array), &efds, &max_fd);
 	}

 	if (!sets) {
 		php_stream_error_operation_end(context);
 		zend_value_error("No stream arrays were passed");
-		RETURN_THROWS();
-	}
-
-	if (!PHP_SAFE_MAX_FD(max_fd, max_set_count)) {
-		RETURN_FALSE;
+		goto cleanup;
 	}

 	if (secnull && !usecnull) {
 		if (usec != 0) {
 			php_stream_error_operation_end(context);
 			zend_argument_value_error(5, "must be null when argument #4 ($seconds) is null");
-			RETURN_THROWS();
+			goto cleanup;
 		}
 	}

 	/* If seconds is not set to null, build the timeval, else we wait indefinitely */
+	struct timeval tv, *tv_p = NULL;
 	if (!secnull) {
 		if (sec < 0) {
 			php_stream_error_operation_end(context);
 			zend_argument_value_error(4, "must be greater than or equal to 0");
-			RETURN_THROWS();
+			goto cleanup;
 		} else if (usec < 0) {
 			php_stream_error_operation_end(context);
 			zend_argument_value_error(5, "must be greater than or equal to 0");
-			RETURN_THROWS();
+			goto cleanup;
 		}

 		/* Windows, Solaris and BSD do not like microsecond values which are >= 1 sec */
@@ -867,6 +847,7 @@ PHP_FUNCTION(stream_select)
 	/* slight hack to support buffered data; if there is data sitting in the
 	 * read buffer of any of the streams in the read array, let's pretend
 	 * that we selected, but return only the readable sockets */
+	int retval;
 	if (r_array != NULL) {
 		retval = stream_array_emulate_read_fd_set(r_array);
 		if (retval > 0) {
@@ -879,24 +860,37 @@ PHP_FUNCTION(stream_select)
 				zval_ptr_dtor(e_array);
 				ZVAL_EMPTY_ARRAY(e_array);
 			}
-			RETURN_LONG(retval);
+			RETVAL_LONG(retval);
+			goto cleanup;
 		}
 	}

-	retval = php_select(max_fd+1, &rfds, &wfds, &efds, tv_p);
+#ifndef PHP_WIN32
+	/* select() reads max_fd + 1 bits of each set */
+	php_growable_fd_set_reserve(&rfds, max_fd + 1);
+	php_growable_fd_set_reserve(&wfds, max_fd + 1);
+	php_growable_fd_set_reserve(&efds, max_fd + 1);
+#endif
+	retval = php_select(max_fd + 1, rfds.set, wfds.set, efds.set, tv_p);
 	php_stream_error_operation_end(context);

 	if (retval == -1) {
 		php_error_docref(NULL, E_WARNING, "Unable to select [%d]: %s (max_fd=" PHP_SOCKET_FMT ")",
 				errno, strerror(errno), max_fd);
-		RETURN_FALSE;
+		RETVAL_FALSE;
+		goto cleanup;
 	}

 	if (r_array != NULL) stream_array_from_fd_set(r_array, &rfds);
 	if (w_array != NULL) stream_array_from_fd_set(w_array, &wfds);
 	if (e_array != NULL) stream_array_from_fd_set(e_array, &efds);

-	RETURN_LONG(retval);
+	RETVAL_LONG(retval);
+
+cleanup:
+	php_growable_fd_set_destroy(&rfds);
+	php_growable_fd_set_destroy(&wfds);
+	php_growable_fd_set_destroy(&efds);
 }
 /* }}} */

diff --git a/ext/standard/tests/streams/gh9590-001.phpt b/ext/standard/tests/streams/gh9590-001.phpt
index 8f5691af1ad..e2eedbff882 100644
--- a/ext/standard/tests/streams/gh9590-001.phpt
+++ b/ext/standard/tests/streams/gh9590-001.phpt
@@ -1,10 +1,13 @@
 --TEST--
-Bug GH-9590 001 (stream_select does not abort upon exception or empty valid fd set)
+Bug GH-9590 001 (stream_select works correctly, without warning, past the traditional
+FD_SETSIZE descriptor limit on platforms where that limit has been lifted)
 --EXTENSIONS--
 posix
 --SKIPIF--
 <?php
-if (!function_exists('posix_setrlimit') || !posix_setrlimit(POSIX_RLIMIT_NOFILE, 2048, -1)) {
+// Keep the hard limit: raising it to unlimited fails even for root on Linux (fs.nr_open)
+$hard = function_exists('posix_getrlimit') ? posix_getrlimit(POSIX_RLIMIT_NOFILE)[1] : -1;
+if (!function_exists('posix_setrlimit') || !posix_setrlimit(POSIX_RLIMIT_NOFILE, 2048, is_int($hard) ? $hard : -1)) {
     die('skip Failed to set POSIX_RLIMIT_NOFILE');
 }
 if (PHP_OS_FAMILY === 'Solaris' && PHP_INT_SIZE === 8)
@@ -13,7 +16,8 @@
 --FILE--
 <?php

-posix_setrlimit(POSIX_RLIMIT_NOFILE, 2048, -1);
+$hard = posix_getrlimit(POSIX_RLIMIT_NOFILE)[1];
+posix_setrlimit(POSIX_RLIMIT_NOFILE, 2048, is_int($hard) ? $hard : -1);

 $fds = [];
 for ($i = 0; $i < 1023; $i++) {
@@ -21,19 +25,17 @@
 }

 list($a, $b) = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP);
+fwrite($b, "x");

 $r = [$a];
 $w = $e = [];
-var_dump(stream_select($r, $w, $e, PHP_INT_MAX));
+var_dump(stream_select($r, $w, $e, 30));
+var_dump(fread($a, 1));

 ?>
---EXPECTF--
-Warning: stream_select(): You MUST recompile PHP with a larger value of FD_SETSIZE.
-It is set to 1024, but you have descriptors numbered at least as high as %d.
- --enable-fd-setsize=%d is recommended, but you may want to set it
-to equal the maximum number of open files supported by your system,
-in order to avoid seeing this error again at a later date. in %s on line %d
-bool(false)
+--EXPECT--
+int(1)
+string(1) "x"
 --CLEAN--
 <?php
 for ($i = 0; $i < 1023; $i++) {
diff --git a/ext/standard/tests/streams/gh9590-002.phpt b/ext/standard/tests/streams/gh9590-002.phpt
index 2a9a7c53c67..2c2edf44ddd 100644
--- a/ext/standard/tests/streams/gh9590-002.phpt
+++ b/ext/standard/tests/streams/gh9590-002.phpt
@@ -1,10 +1,14 @@
 --TEST--
-Bug GH-9590 002 (stream_select does not abort upon exception or empty valid fd set)
+Bug GH-9590 002 (a custom error handler that turns warnings into exceptions must not be
+spuriously triggered by stream_select() past the traditional FD_SETSIZE limit, on
+platforms where that limit has been lifted)
 --EXTENSIONS--
 posix
 --SKIPIF--
 <?php
-if (!function_exists('posix_setrlimit') || !posix_setrlimit(POSIX_RLIMIT_NOFILE, 2048, -1)) {
+// Keep the hard limit: raising it to unlimited fails even for root on Linux (fs.nr_open)
+$hard = function_exists('posix_getrlimit') ? posix_getrlimit(POSIX_RLIMIT_NOFILE)[1] : -1;
+if (!function_exists('posix_setrlimit') || !posix_setrlimit(POSIX_RLIMIT_NOFILE, 2048, is_int($hard) ? $hard : -1)) {
     die('skip Failed to set POSIX_RLIMIT_NOFILE');
 }
 if (PHP_OS_FAMILY === 'Solaris' && PHP_INT_SIZE === 8)
@@ -13,7 +17,8 @@
 --FILE--
 <?php

-posix_setrlimit(POSIX_RLIMIT_NOFILE, 2048, -1);
+$hard = posix_getrlimit(POSIX_RLIMIT_NOFILE)[1];
+posix_setrlimit(POSIX_RLIMIT_NOFILE, 2048, is_int($hard) ? $hard : -1);

 $fds = [];
 for ($i = 0; $i < 1023; $i++) {
@@ -21,21 +26,19 @@
 }

 list($a, $b) = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP);
+fwrite($b, "x");

 set_error_handler(function($errno, $errstr) { throw new \Exception($errstr); });

 $r = [$a];
 $w = $e = [];
-var_dump(stream_select($r, $w, $e, PHP_INT_MAX));
+var_dump(stream_select($r, $w, $e, 30));
+var_dump(fread($a, 1));

 ?>
---EXPECTF--
-Fatal error: Uncaught Exception: stream_select(): You MUST recompile PHP with a larger value of FD_SETSIZE.
-It is set to 1024, but you have descriptors numbered at least as high as %d.
- --enable-fd-setsize=%d is recommended, but you may want to set it
-to equal the maximum number of open files supported by your system,
-in order to avoid seeing this error again at a later date. in %s:%d
-Stack trace:%a
+--EXPECT--
+int(1)
+string(1) "x"
 --CLEAN--
 <?php
 for ($i = 0; $i < 1023; $i++) {
diff --git a/ext/standard/tests/streams/gh9590-003.phpt b/ext/standard/tests/streams/gh9590-003.phpt
new file mode 100644
index 00000000000..504f0146707
--- /dev/null
+++ b/ext/standard/tests/streams/gh9590-003.phpt
@@ -0,0 +1,70 @@
+--TEST--
+Bug GH-9590 003 (stream_select past FD_SETSIZE: every set grows, and a stream in several sets is counted once per set)
+--EXTENSIONS--
+posix
+--SKIPIF--
+<?php
+// Keep the hard limit: raising it to unlimited fails even for root on Linux (fs.nr_open)
+$hard = function_exists('posix_getrlimit') ? posix_getrlimit(POSIX_RLIMIT_NOFILE)[1] : -1;
+if (!function_exists('posix_setrlimit') || !posix_setrlimit(POSIX_RLIMIT_NOFILE, 2048, is_int($hard) ? $hard : -1)) {
+    die('skip Failed to set POSIX_RLIMIT_NOFILE');
+}
+if (PHP_OS_FAMILY === 'Solaris' && PHP_INT_SIZE === 8)
+    die('skip Solaris LP64 FD_SETSIZE=65536 not practically exceedable here');
+?>
+--FILE--
+<?php
+
+$hard = posix_getrlimit(POSIX_RLIMIT_NOFILE)[1];
+posix_setrlimit(POSIX_RLIMIT_NOFILE, 2048, is_int($hard) ? $hard : -1);
+
+// A low descriptor, opened before the filler.
+list($lo, $lo_peer) = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP);
+
+$fds = [];
+for ($i = 0; $i < 1023; $i++) {
+    $fds[] = @fopen(__DIR__ . "/GH-9590-003-tmpfile.$i", 'w');
+}
+
+// A descriptor beyond FD_SETSIZE.
+list($hi, $hi_peer) = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP);
+
+fwrite($lo_peer, "y");
+fwrite($hi_peer, "x");
+
+// $hi is in all three sets and $lo in two, so the read, write and except sets
+// each have to grow past FD_SETSIZE. select() returns the number of bits set
+// across all sets: $lo and $hi readable (2) + $hi writable (1) = 3.
+$r = ['lo' => $lo, 'hi' => $hi];
+$w = ['hi' => $hi];
+$e = ['hi' => $hi, 'lo' => $lo];
+var_dump(stream_select($r, $w, $e, 30));
+var_dump(array_keys($r), array_keys($w), $e);
+
+// A beyond-FD_SETSIZE stream in the write set alone.
+$r = $e = null;
+$w = [$hi];
+var_dump(stream_select($r, $w, $e, 30));
+
+?>
+--EXPECT--
+int(3)
+array(2) {
+  [0]=>
+  string(2) "lo"
+  [1]=>
+  string(2) "hi"
+}
+array(1) {
+  [0]=>
+  string(2) "hi"
+}
+array(0) {
+}
+int(1)
+--CLEAN--
+<?php
+for ($i = 0; $i < 1023; $i++) {
+    @unlink(__DIR__ . "/GH-9590-003-tmpfile.$i");
+}
+?>
diff --git a/ext/standard/tests/streams/stream_select_win32_duplicate.phpt b/ext/standard/tests/streams/stream_select_win32_duplicate.phpt
new file mode 100644
index 00000000000..6ecee886f58
--- /dev/null
+++ b/ext/standard/tests/streams/stream_select_win32_duplicate.phpt
@@ -0,0 +1,26 @@
+--TEST--
+stream_select(): a socket listed more than once is counted once on Windows
+--SKIPIF--
+<?php
+if (PHP_OS_FAMILY !== 'Windows') die('skip Windows only');
+?>
+--FILE--
+<?php
+/* On Windows the fd_set is an array of SOCKETs, so a socket listed more than
+ * once in a set used to be stored - and counted by select() - that many times,
+ * unlike the POSIX bitset where setting the same descriptor twice is a no-op.
+ * stream_select() must de-duplicate, so the ready count matches POSIX. */
+[$a, $b] = stream_socket_pair(STREAM_PF_INET, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP);
+fwrite($b, "x"); // make $a readable
+
+$r = [$a, $a, $a]; // the same socket three times
+$w = $e = null;
+var_dump(stream_select($r, $w, $e, 2, 0)); // ready count must be 1, not 3
+var_dump(count($r)); // but all three entries are kept, as on POSIX
+
+fclose($a);
+fclose($b);
+?>
+--EXPECT--
+int(1)
+int(3)
diff --git a/ext/standard/tests/streams/stream_select_win32_many_sockets.phpt b/ext/standard/tests/streams/stream_select_win32_many_sockets.phpt
new file mode 100644
index 00000000000..1a37ed7977e
--- /dev/null
+++ b/ext/standard/tests/streams/stream_select_win32_many_sockets.phpt
@@ -0,0 +1,44 @@
+--TEST--
+stream_select(): more than FD_SETSIZE sockets on Windows (growable fd_set)
+--SKIPIF--
+<?php
+if (PHP_OS_FAMILY !== 'Windows') die('skip Windows only');
+?>
+--FILE--
+<?php
+/* On Windows the winsock fd_set is a packed array of SOCKETs capped at the
+ * compile-time FD_SETSIZE (default 256, see --enable-fd-setsize). stream_select()
+ * now grows the set on demand, so it can watch far more than FD_SETSIZE sockets,
+ * matching POSIX behaviour. Here we select on FD_SETSIZE + 50 readable sockets
+ * and expect every one to be reported ready; before the fix the winsock FD_SET
+ * macro silently dropped everything past FD_SETSIZE. */
+$target = PHP_FD_SETSIZE + 50;
+$pairs = [];
+$reads = [];
+for ($i = 0; $i < $target; $i++) {
+    $p = stream_socket_pair(STREAM_PF_INET, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP);
+    if ($p === false) {
+        break;
+    }
+    fwrite($p[0], "x");
+    $pairs[] = $p;
+    $reads[] = $p[1];
+}
+
+$r = $reads;
+$w = $e = null;
+$ready = stream_select($r, $w, $e, 2, 0);
+
+var_dump(count($reads) === $target);
+var_dump($ready === $target);
+var_dump(count($r) === $target);
+
+foreach ($pairs as $p) {
+    fclose($p[0]);
+    fclose($p[1]);
+}
+?>
+--EXPECT--
+bool(true)
+bool(true)
+bool(true)
diff --git a/ext/standard/tests/streams/stream_select_win32_max_handles.phpt b/ext/standard/tests/streams/stream_select_win32_max_handles.phpt
new file mode 100644
index 00000000000..5d0ee495d2f
--- /dev/null
+++ b/ext/standard/tests/streams/stream_select_win32_max_handles.phpt
@@ -0,0 +1,38 @@
+--TEST--
+stream_select(): >64 non-socket handles fail gracefully on Windows (no stack overflow)
+--SKIPIF--
+<?php
+if (PHP_OS_FAMILY !== 'Windows') die('skip Windows only');
+?>
+--FILE--
+<?php
+/* On Windows stream_select() multiplexes non-socket handles (pipes, files)
+ * through WaitForMultipleObjects(), which cannot wait on more than
+ * MAXIMUM_WAIT_OBJECTS (64) handles at once. php_select() used to push each
+ * handle into a fixed 64-slot stack array with no bounds check, so selecting on
+ * 65+ file/pipe handles overran the stack. It must instead fail gracefully
+ * (warning + false). */
+$files = [];
+for ($i = 0; $i < 70; $i++) {
+    $path = __DIR__ . "/stream_select_win32_max_handles_$i.tmp";
+    file_put_contents($path, "d");
+    $files[] = fopen($path, "rb");
+}
+
+$r = $files;
+$w = $e = null;
+var_dump(stream_select($r, $w, $e, 0, 0));
+
+foreach ($files as $f) {
+    fclose($f);
+}
+?>
+--EXPECTF--
+Warning: stream_select(): Unable to select [%d]: %s (max_fd=%d) in %s on line %d
+bool(false)
+--CLEAN--
+<?php
+for ($i = 0; $i < 70; $i++) {
+    @unlink(__DIR__ . "/stream_select_win32_max_handles_$i.tmp");
+}
+?>
diff --git a/ext/standard/tests/streams/stream_select_win32_multi_set.phpt b/ext/standard/tests/streams/stream_select_win32_multi_set.phpt
new file mode 100644
index 00000000000..1f1e60a3850
--- /dev/null
+++ b/ext/standard/tests/streams/stream_select_win32_multi_set.phpt
@@ -0,0 +1,51 @@
+--TEST--
+stream_select(): more than FD_SETSIZE sockets in the write and except sets on Windows (each set grows)
+--SKIPIF--
+<?php
+if (PHP_OS_FAMILY !== 'Windows') die('skip Windows only');
+?>
+--FILE--
+<?php
+/* The read, write and except sets each grow on demand, independently. Put more
+ * than FD_SETSIZE sockets in all three at once: every socket is readable (data
+ * pending) and writable, none has an exceptional condition. select() returns the
+ * number of set entries across all three sets, so each socket counts twice. */
+$target = PHP_FD_SETSIZE + 50;
+$pairs = [];
+$socks = [];
+for ($i = 0; $i < $target; $i++) {
+    $p = stream_socket_pair(STREAM_PF_INET, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP);
+    if ($p === false) {
+        break;
+    }
+    fwrite($p[0], "x");
+    $pairs[] = $p;
+    $socks[] = $p[1];
+}
+
+$r = $socks;
+$w = $socks;
+$e = $socks;
+$ready = stream_select($r, $w, $e, 2, 0);
+
+var_dump(count($socks) === $target);
+var_dump($ready === 2 * $target);
+var_dump(count($r) === $target, count($w) === $target, count($e));
+
+/* A write set alone beyond FD_SETSIZE. */
+$r = $e = null;
+$w = $socks;
+var_dump(stream_select($r, $w, $e, 2, 0) === $target);
+
+foreach ($pairs as $p) {
+    fclose($p[0]);
+    fclose($p[1]);
+}
+?>
+--EXPECT--
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+int(0)
+bool(true)
diff --git a/main/php_network.h b/main/php_network.h
index 5c2cb0acede..6904f760395 100644
--- a/main/php_network.h
+++ b/main/php_network.h
@@ -248,6 +248,166 @@ static inline bool _php_check_fd_setsize(php_socket_t *max_fd, int setsize)
 # define PHP_SAFE_MAX_FD(m, n)		_php_check_fd_setsize(&m, n)
 #endif

+/* A growable fd_set for stream_select(), without the FD_SETSIZE limit.
+ *
+ * On POSIX, fd_set is a bitset indexed by descriptor, and select() takes any nfds: the set
+ * is a bitset that grows to hold the highest descriptor added.
+ * On Windows, fd_set is an array of SOCKETs ({ u_int fd_count; SOCKET fd_array[]; }), and
+ * select() reads fd_count of them: the set is that array, grown to hold every socket added.
+ * Either way, `set` can be passed to select() as it is. */
+#ifdef PHP_WIN32
+typedef struct {
+	u_int capacity; /* SOCKET slots in set->fd_array */
+	fd_set *set;
+} php_growable_fd_set;
+
+# define PHP_GROWABLE_FD_SET_ALLOC_SIZE(cap) \
+	(offsetof(fd_set, fd_array) + (size_t)(cap) * sizeof(SOCKET))
+
+static zend_always_inline void php_growable_fd_set_init(php_growable_fd_set *s, u_int capacity)
+{
+	s->capacity = MAX(capacity, FD_SETSIZE);
+	s->set = (fd_set *) pemalloc(PHP_GROWABLE_FD_SET_ALLOC_SIZE(s->capacity), 1);
+	s->set->fd_count = 0;
+}
+
+/* Makes room for capacity sockets */
+static zend_always_inline void php_growable_fd_set_reserve(php_growable_fd_set *s, u_int capacity)
+{
+	if (capacity > s->capacity) {
+		do {
+			s->capacity *= 2;
+		} while (capacity > s->capacity);
+		s->set = (fd_set *) perealloc(s->set, PHP_GROWABLE_FD_SET_ALLOC_SIZE(s->capacity), 1);
+	}
+}
+
+static zend_always_inline void php_growable_fd_set_add(php_growable_fd_set *s, php_socket_t fd)
+{
+	/* Appends without the duplicate scan of winsock's FD_SET(); the set is
+	 * de-duplicated in one pass later by php_growable_fd_set_sort_unique(). */
+	php_growable_fd_set_reserve(s, s->set->fd_count + 1);
+	s->set->fd_array[s->set->fd_count++] = fd;
+}
+
+static zend_always_inline int php_growable_fd_set_cmp(const void *a, const void *b)
+{
+	SOCKET x = *(const SOCKET *) a;
+	SOCKET y = *(const SOCKET *) b;
+	return (x > y) - (x < y);
+}
+
+/* Sorts the sockets ascending and drops duplicates. This makes select() count a
+ * socket listed more than once (e.g. stream_select([$a, $a], ...)) a single time
+ * - as the POSIX bitset does - and lets php_growable_fd_set_isset() binary-search
+ * instead of scanning, so a whole stream_select() call is O(n log n), not O(n^2). */
+static zend_always_inline void php_growable_fd_set_sort_unique(php_growable_fd_set *s)
+{
+	u_int n = s->set->fd_count;
+	if (n < 2) {
+		return;
+	}
+	qsort(s->set->fd_array, n, sizeof(SOCKET), php_growable_fd_set_cmp);
+	u_int w = 1;
+	for (u_int r = 1; r < n; r++) {
+		if (s->set->fd_array[r] != s->set->fd_array[w - 1]) {
+			s->set->fd_array[w++] = s->set->fd_array[r];
+		}
+	}
+	s->set->fd_count = w;
+}
+
+/* Binary search; the set must have been sorted by php_growable_fd_set_sort_unique(). */
+static zend_always_inline bool php_growable_fd_set_isset(const php_growable_fd_set *s, php_socket_t fd)
+{
+	SOCKET target = (SOCKET) fd;
+	u_int lo = 0, hi = s->set->fd_count;
+	while (lo < hi) {
+		u_int mid = lo + (hi - lo) / 2;
+		SOCKET v = s->set->fd_array[mid];
+		if (v < target) {
+			lo = mid + 1;
+		} else if (v > target) {
+			hi = mid;
+		} else {
+			return true;
+		}
+	}
+	return false;
+}
+
+static zend_always_inline void php_growable_fd_set_zero(php_growable_fd_set *s)
+{
+	s->set->fd_count = 0;
+}
+
+static zend_always_inline void php_growable_fd_set_copy(php_growable_fd_set *dst, const php_growable_fd_set *src)
+{
+	php_growable_fd_set_reserve(dst, src->set->fd_count);
+	memcpy(dst->set, src->set, PHP_GROWABLE_FD_SET_ALLOC_SIZE(src->set->fd_count));
+}
+
+static zend_always_inline void php_growable_fd_set_destroy(php_growable_fd_set *s)
+{
+	if (s->set) {
+		pefree(s->set, 1);
+		s->set = NULL;
+	}
+	s->capacity = 0;
+}
+#else
+typedef struct {
+	size_t size; /* bytes in set */
+	fd_set *set;
+} php_growable_fd_set;
+
+/* Use the platform's fd_mask: on big-endian systems the bit positions depend on the word size */
+# ifdef NFDBITS
+typedef fd_mask php_growable_fd_mask;
+#  define PHP_GROWABLE_FD_SET_WORD_BITS NFDBITS
+# else
+typedef unsigned long php_growable_fd_mask;
+#  define PHP_GROWABLE_FD_SET_WORD_BITS (CHAR_BIT * sizeof(unsigned long))
+# endif
+/* select() operates on whole words so the size must be a multiple of sizeof(php_growable_fd_mask) */
+# define PHP_GROWABLE_FD_SET_SIZE(nfds) (ZEND_MM_ALIGNED_SIZE_EX((size_t)(nfds), PHP_GROWABLE_FD_SET_WORD_BITS) / CHAR_BIT)
+
+static zend_always_inline void php_growable_fd_set_init(php_growable_fd_set *s, unsigned int capacity)
+{
+	s->size = PHP_GROWABLE_FD_SET_SIZE(capacity);
+	s->set = (fd_set *) ecalloc(1, s->size);
+}
+
+/* Makes room for the descriptors below capacity */
+static zend_always_inline void php_growable_fd_set_reserve(php_growable_fd_set *s, unsigned int capacity)
+{
+	size_t size = PHP_GROWABLE_FD_SET_SIZE(capacity);
+	if (size > s->size) {
+		size_t old_size = s->size;
+		s->size = MAX(size, old_size * 2);
+		s->set = (fd_set *) erealloc(s->set, s->size);
+		memset((char *) s->set + old_size, 0, s->size - old_size);
+	}
+}
+
+static zend_always_inline void php_growable_fd_set_add(php_growable_fd_set *s, php_socket_t fd)
+{
+	php_growable_fd_set_reserve(s, fd + 1);
+	((php_growable_fd_mask *) s->set)[fd / PHP_GROWABLE_FD_SET_WORD_BITS] |= (php_growable_fd_mask) (1UL << (fd % PHP_GROWABLE_FD_SET_WORD_BITS));
+}
+
+static zend_always_inline bool php_growable_fd_set_isset(const php_growable_fd_set *s, php_socket_t fd)
+{
+	return (size_t) fd < s->size * CHAR_BIT
+		&& (((const php_growable_fd_mask *) s->set)[fd / PHP_GROWABLE_FD_SET_WORD_BITS] >> (fd % PHP_GROWABLE_FD_SET_WORD_BITS)) & 1;
+}
+
+static zend_always_inline void php_growable_fd_set_destroy(php_growable_fd_set *s)
+{
+	efree(s->set);
+}
+#endif
+

 #define PHP_SOCK_CHUNK_SIZE	8192

diff --git a/win32/select.c b/win32/select.c
index 988f037d7a4..69b452e1f32 100644
--- a/win32/select.c
+++ b/win32/select.c
@@ -16,6 +16,11 @@
 #include "php_network.h"
 #include "win32/time.h"

+/* Which of the read/write/except sets a non-socket handle belongs to. */
+#define PHP_SELECT_SET_READ   1
+#define PHP_SELECT_SET_WRITE  2
+#define PHP_SELECT_SET_EXCEPT 4
+
 /* Win32 select() will only work with sockets, so we roll our own implementation here.
  * - If you supply only sockets, this simply passes through to winsock select().
  * - If you supply file handles, there is no way to distinguish between
@@ -34,21 +39,20 @@ PHPAPI int php_select(php_socket_t max_fd, fd_set *rfds, fd_set *wfds, fd_set *e
 	ULONGLONG ms_total, limit;
 	HANDLE handles[MAXIMUM_WAIT_OBJECTS];
 	int handle_slot_to_fd[MAXIMUM_WAIT_OBJECTS];
+	int handle_dirs[MAXIMUM_WAIT_OBJECTS];
 	int n_handles = 0, i;
 	int num_read_pipes = 0;
-	fd_set sock_read, sock_write, sock_except;
-	fd_set aread, awrite, aexcept;
-	int sock_max_fd = -1;
+	php_growable_fd_set sock_read = {0}, sock_write = {0}, sock_except = {0};
+	php_growable_fd_set aread = {0}, awrite = {0}, aexcept = {0};
+	bool has_sockets = false;
 	struct timeval tvslice;
-	int retcode;
+	int retcode = -1;

 	/* As max_fd is unsigned, non socket might overflow. */
 	if (max_fd > (php_socket_t)INT_MAX) {
 		return -1;
 	}

-#define SAFE_FD_ISSET(fd, set)	(set != NULL && FD_ISSET(fd, set))
-
 	/* calculate how long we need to wait in milliseconds */
 	if (tv == NULL) {
 		ms_total = INFINITE;
@@ -57,70 +61,141 @@ PHPAPI int php_select(php_socket_t max_fd, fd_set *rfds, fd_set *wfds, fd_set *e
 		ms_total += tv->tv_usec / 1000;
 	}

-	FD_ZERO(&sock_read);
-	FD_ZERO(&sock_write);
-	FD_ZERO(&sock_except);
+	php_growable_fd_set_init(&sock_read, FD_SETSIZE);
+	php_growable_fd_set_init(&sock_write, FD_SETSIZE);
+	php_growable_fd_set_init(&sock_except, FD_SETSIZE);

-	/* build an array of handles for non-sockets */
-	for (i = 0; (uint32_t)i < max_fd; i++) {
-		if (SAFE_FD_ISSET(i, rfds) || SAFE_FD_ISSET(i, wfds) || SAFE_FD_ISSET(i, efds)) {
-			int _type;
-			int _len = sizeof(_type);
+	/* Split each input set into sockets (driven by winsock select()) and
+	 * non-socket handles (pipes/files, waited on with WaitForMultipleObjects()).
+	 * A single linear pass over the three fd_arrays; max_fd is not an iteration
+	 * bound, as a socket handle value can be very large. */
+	{
+		fd_set *in_sets[3];
+		php_growable_fd_set *sock_sets[3];
+		int set_dirs[3];

-			if (getsockopt((SOCKET)i, SOL_SOCKET, SO_TYPE, (char*)&_type, &_len) == 0 || WSAGetLastError() != WSAENOTSOCK) {
-				/* socket */
-				if (SAFE_FD_ISSET(i, rfds)) {
-					FD_SET((uint32_t)i, &sock_read);
-				}
-				if (SAFE_FD_ISSET(i, wfds)) {
-					FD_SET((uint32_t)i, &sock_write);
-				}
-				if (SAFE_FD_ISSET(i, efds)) {
-					FD_SET((uint32_t)i, &sock_except);
-				}
-				if (i > sock_max_fd) {
-					sock_max_fd = i;
-				}
-			} else {
-				handles[n_handles] = (HANDLE)(uintptr_t)_get_osfhandle(i);
-				if (handles[n_handles] != INVALID_HANDLE_VALUE) {
-					if (SAFE_FD_ISSET(i, rfds) && GetFileType(handles[n_handles]) == FILE_TYPE_PIPE) {
-						num_read_pipes++;
+		in_sets[0] = rfds; sock_sets[0] = &sock_read;   set_dirs[0] = PHP_SELECT_SET_READ;
+		in_sets[1] = wfds; sock_sets[1] = &sock_write;  set_dirs[1] = PHP_SELECT_SET_WRITE;
+		in_sets[2] = efds; sock_sets[2] = &sock_except; set_dirs[2] = PHP_SELECT_SET_EXCEPT;
+
+		for (int s = 0; s < 3; s++) {
+			fd_set *set = in_sets[s];
+
+			if (set == NULL) {
+				continue;
+			}
+			for (u_int k = 0; k < set->fd_count; k++) {
+				php_socket_t fd = set->fd_array[k];
+				int _type;
+				int _len = sizeof(_type);
+
+				if (getsockopt((SOCKET)fd, SOL_SOCKET, SO_TYPE, (char*)&_type, &_len) == 0 || WSAGetLastError() != WSAENOTSOCK) {
+					/* socket */
+					php_growable_fd_set_add(sock_sets[s], fd);
+					has_sockets = true;
+				} else {
+					HANDLE handle = (HANDLE)(uintptr_t)_get_osfhandle((int)fd);
+					int j;
+
+					if (handle == INVALID_HANDLE_VALUE) {
+						continue;
 					}
-					handle_slot_to_fd[n_handles] = i;
+					/* Dedupe the handle across the read/write/except sets; at most
+					 * MAXIMUM_WAIT_OBJECTS entries, so this scan stays short. */
+					for (j = 0; j < n_handles; j++) {
+						if (handle_slot_to_fd[j] == (int)fd) {
+							handle_dirs[j] |= set_dirs[s];
+							break;
+						}
+					}
+					if (j < n_handles) {
+						continue;
+					}
+					if (n_handles >= MAXIMUM_WAIT_OBJECTS) {
+						/* WaitForMultipleObjects() cannot wait on more than
+						 * MAXIMUM_WAIT_OBJECTS (64) handles at once. Fail
+						 * gracefully (the caller turns -1 into a warning and
+						 * false) instead of overflowing the fixed-size
+						 * handles[]/handle_slot_to_fd[] stack arrays. */
+						errno = EINVAL;
+						retcode = -1;
+						goto cleanup;
+					}
+					handles[n_handles] = handle;
+					handle_slot_to_fd[n_handles] = (int)fd;
+					handle_dirs[n_handles] = set_dirs[s];
 					n_handles++;
 				}
 			}
 		}
 	}

+	/* How many of the handles are pipes in the read set (for the data-available
+	 * peek further down). */
+	for (i = 0; i < n_handles; i++) {
+		if ((handle_dirs[i] & PHP_SELECT_SET_READ) && GetFileType(handles[i]) == FILE_TYPE_PIPE) {
+			num_read_pipes++;
+		}
+	}
+
+	/* De-duplicate each socket set (a socket may be listed more than once) and
+	 * leave it sorted - for select() and for the binary-search isset() back in
+	 * stream_array_from_fd_set(). */
+	php_growable_fd_set_sort_unique(&sock_read);
+	php_growable_fd_set_sort_unique(&sock_write);
+	php_growable_fd_set_sort_unique(&sock_except);
+
 	if (n_handles == 0) {
-		/* plain sockets only - let winsock handle the whole thing */
-		return select(-1, rfds, wfds, efds, tv);
+		/* plain sockets only - let winsock handle the whole thing. The sets are
+		 * growable, so this is no longer bounded by FD_SETSIZE. Pass NULL where
+		 * the caller's set was NULL, so the call matches the pre-split one. */
+		retcode = select(-1, rfds ? sock_read.set : NULL, wfds ? sock_write.set : NULL, efds ? sock_except.set : NULL, tv);
+		if (retcode >= 0) {
+			/* select() rewrote the arrays with the ready sockets, unordered;
+			 * re-sort so the caller can binary-search, then copy back. */
+			php_growable_fd_set_sort_unique(&sock_read);
+			php_growable_fd_set_sort_unique(&sock_write);
+			php_growable_fd_set_sort_unique(&sock_except);
+			if (rfds) {
+				memcpy(rfds, sock_read.set, PHP_GROWABLE_FD_SET_ALLOC_SIZE(sock_read.set->fd_count));
+			}
+			if (wfds) {
+				memcpy(wfds, sock_write.set, PHP_GROWABLE_FD_SET_ALLOC_SIZE(sock_write.set->fd_count));
+			}
+			if (efds) {
+				memcpy(efds, sock_except.set, PHP_GROWABLE_FD_SET_ALLOC_SIZE(sock_except.set->fd_count));
+			}
+		}
+		goto cleanup;
 	}

 	/* mixture of handles and sockets; lets multiplex between
 	 * winsock and waiting on the handles */

-	FD_ZERO(&aread);
-	FD_ZERO(&awrite);
-	FD_ZERO(&aexcept);
+	php_growable_fd_set_init(&aread, sock_read.set->fd_count);
+	php_growable_fd_set_init(&awrite, sock_write.set->fd_count);
+	php_growable_fd_set_init(&aexcept, sock_except.set->fd_count);

 	limit = GetTickCount64() + ms_total;
 	do {
 		retcode = 0;

-		if (sock_max_fd >= 0) {
-			/* overwrite the zero'd sets here; the select call
-			 * will clear those that are not active */
-			aread = sock_read;
-			awrite = sock_write;
-			aexcept = sock_except;
+		if (has_sockets) {
+			/* refresh the working copies; the select call will clear the fds
+			 * that are not active. memcpy (via _copy) instead of struct
+			 * assignment because the sets are dynamically sized. */
+			php_growable_fd_set_copy(&aread, &sock_read);
+			php_growable_fd_set_copy(&awrite, &sock_write);
+			php_growable_fd_set_copy(&aexcept, &sock_except);

 			tvslice.tv_sec = 0;
 			tvslice.tv_usec = 100000;

-			retcode = select(-1, &aread, &awrite, &aexcept, &tvslice);
+			retcode = select(-1, aread.set, awrite.set, aexcept.set, &tvslice);
+		} else {
+			php_growable_fd_set_zero(&aread);
+			php_growable_fd_set_zero(&awrite);
+			php_growable_fd_set_zero(&aexcept);
 		}
 		if (n_handles > 0) {
 			/* check handles */
@@ -144,42 +219,59 @@ PHPAPI int php_select(php_socket_t max_fd, fd_set *rfds, fd_set *wfds, fd_set *e
 				}
 				for (i = 0; i < n_handles; i++) {
 					if (WAIT_OBJECT_0 == WaitForSingleObject(handles[i], 0)) {
-						if (SAFE_FD_ISSET(handle_slot_to_fd[i], rfds)) {
+						if (handle_dirs[i] & PHP_SELECT_SET_READ) {
 							DWORD avail_read = 0;
 							if (num_read_pipes < n_handles
 								|| !PeekNamedPipe(handles[i], NULL, 0, NULL, &avail_read, NULL)
 								|| avail_read > 0
 							) {
-								FD_SET((uint32_t)handle_slot_to_fd[i], &aread);
+								php_growable_fd_set_add(&aread, (SOCKET)(uintptr_t)handle_slot_to_fd[i]);
 								retcode++;
 							}
 						}
-						if (SAFE_FD_ISSET(handle_slot_to_fd[i], wfds)) {
-							FD_SET((uint32_t)handle_slot_to_fd[i], &awrite);
+						if (handle_dirs[i] & PHP_SELECT_SET_WRITE) {
+							php_growable_fd_set_add(&awrite, (SOCKET)(uintptr_t)handle_slot_to_fd[i]);
 							retcode++;
 						}
-						if (SAFE_FD_ISSET(handle_slot_to_fd[i], efds)) {
-							FD_SET((uint32_t)handle_slot_to_fd[i], &aexcept);
+						if (handle_dirs[i] & PHP_SELECT_SET_EXCEPT) {
+							php_growable_fd_set_add(&aexcept, (SOCKET)(uintptr_t)handle_slot_to_fd[i]);
 							retcode++;
 						}
 					}
 				}
 			}
 		}
-		if (retcode == 0 && num_read_pipes == n_handles && sock_max_fd < 0) {
+		if (retcode == 0 && num_read_pipes == n_handles && !has_sockets) {
 			usleep(100);
 		}
 	} while (retcode == 0 && (ms_total == INFINITE || GetTickCount64() < limit));

+	/* Copy the results back into the caller's sets, sorted so the caller can
+	 * binary-search them. memcpy (not struct assignment) because the sets are
+	 * dynamically sized; only fd_count + the used fd_array entries are written.
+	 * This never overflows the caller's buffer: every fd in a* was present in
+	 * the corresponding input set, so a*->fd_count <= the input fd_count, which
+	 * the caller's buffer already held. */
+	php_growable_fd_set_sort_unique(&aread);
+	php_growable_fd_set_sort_unique(&awrite);
+	php_growable_fd_set_sort_unique(&aexcept);
 	if (rfds) {
-		*rfds = aread;
+		memcpy(rfds, aread.set, PHP_GROWABLE_FD_SET_ALLOC_SIZE(aread.set->fd_count));
 	}
 	if (wfds) {
-		*wfds = awrite;
+		memcpy(wfds, awrite.set, PHP_GROWABLE_FD_SET_ALLOC_SIZE(awrite.set->fd_count));
 	}
 	if (efds) {
-		*efds = aexcept;
+		memcpy(efds, aexcept.set, PHP_GROWABLE_FD_SET_ALLOC_SIZE(aexcept.set->fd_count));
 	}

+cleanup:
+	php_growable_fd_set_destroy(&sock_read);
+	php_growable_fd_set_destroy(&sock_write);
+	php_growable_fd_set_destroy(&sock_except);
+	php_growable_fd_set_destroy(&aread);
+	php_growable_fd_set_destroy(&awrite);
+	php_growable_fd_set_destroy(&aexcept);
+
 	return retcode;
 }