Skip to content

Commit 9398932

Browse files
committed
Windows: grow stream_select() fd_set on demand and cap handle count
On Windows the winsock fd_set is a packed array of SOCKETs bounded by the compile-time FD_SETSIZE, so stream_select() silently dropped sockets past that limit. Replace the fixed fd_set on the socket path with a growable {capacity; fd_set} that doubles via realloc, giving parity with POSIX for socket-heavy code. FD_ISSET/FD_ZERO/select() only read fd_count, so the grown set is passed to them unchanged; php_select()'s internal working sets grow too and the struct-assignment copies become memcpy. The POSIX fd_bigset path and php_select()'s fd_set* ABI are unchanged. Also cap the non-socket (pipe/file) handle path at MAXIMUM_WAIT_OBJECTS (64) inside php_select() itself: it previously pushed each handle into a fixed 64-entry stack array with no bound, crashing the process with a stack buffer overrun (STATUS_STACK_BUFFER_OVERRUN) when given 65+ pipe or file handles. It now returns an error so the caller reports a warning and false. Fixing it in php_select() also protects the direct caller in sapi/cli/php_cli_server.c. Adds two Windows-only phpt tests: one selecting on more than FD_SETSIZE sockets, and one asserting 65+ handles fail gracefully instead of crashing.
1 parent d2b1dec commit 9398932

7 files changed

Lines changed: 243 additions & 39 deletions

File tree

‎NEWS‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,12 @@ PHP NEWS
9595
. Fixed three Windows-only proc_open() defects: an uninitialized
9696
PROCESS_INFORMATION, an indeterminate comspec pointer after a failed
9797
lookup, and an unchecked CreateFileA() failure. (Ilia Alshanetsky)
98+
. Fixed stream_select() on Windows overflowing a fixed 64-entry stack array
99+
(crashing with a stack buffer overrun) when passed more than 64 pipe or file
100+
handles; it now fails gracefully instead. (Frode Børli)
101+
. Removed the FD_SETSIZE ceiling on the number of sockets stream_select() can
102+
watch on Windows; the winsock fd_set now grows on demand, matching POSIX.
103+
(Frode Børli)
98104

99105
- Zlib:
100106
. Fixed inflate_init() dropping the preset dictionary for raw streams with

‎UPGRADING‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1070,6 +1070,13 @@ PHP 8.6 UPGRADE NOTES
10701070
. The OpenSSL library used by the official Windows builds has been upgraded
10711071
to OpenSSL 4.
10721072

1073+
- Standard:
1074+
. stream_select() no longer caps the number of sockets at FD_SETSIZE; the
1075+
winsock fd_set now grows on demand, so socket-heavy code (event loops,
1076+
servers) behaves like it does on POSIX. Selecting on more than 64 pipe or
1077+
file handles no longer crashes the process; it fails with a warning instead,
1078+
as that path is still bounded by the WaitForMultipleObjects() limit.
1079+
10731080
========================================
10741081
13. Other Changes
10751082
========================================

‎ext/standard/streamsfuncs.c‎

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -799,7 +799,7 @@ static int stream_array_from_fd_bigset(zval *stream_array, const fd_bigset *fds)
799799
#else /* PHP_WIN32 */
800800
/* {{{ Windows keeps the original native fd_set based implementation for now (capped at FD_SETSIZE); see the
801801
* comment above the fd_bigset typedef for why. */
802-
static int stream_array_to_fd_set(const HashTable *stream_array, fd_set *fds, php_socket_t *max_fd)
802+
static int stream_array_to_fd_set(const HashTable *stream_array, php_growable_fd_set *fds, php_socket_t *max_fd)
803803
{
804804
zval *elem;
805805
php_stream *stream;
@@ -823,7 +823,7 @@ static int stream_array_to_fd_set(const HashTable *stream_array, fd_set *fds, ph
823823
* */
824824
if (SUCCESS == php_stream_cast(stream, PHP_STREAM_AS_FD_FOR_SELECT | PHP_STREAM_CAST_INTERNAL, (void*)&this_fd, 1) && this_fd != -1) {
825825

826-
PHP_SAFE_FD_SET(this_fd, fds);
826+
php_growable_fd_set_add(fds, (SOCKET)this_fd);
827827

828828
if (this_fd > *max_fd) {
829829
*max_fd = this_fd;
@@ -834,7 +834,7 @@ static int stream_array_to_fd_set(const HashTable *stream_array, fd_set *fds, ph
834834
return cnt ? 1 : 0;
835835
}
836836

837-
static int stream_array_from_fd_set(zval *stream_array, const fd_set *fds)
837+
static int stream_array_from_fd_set(zval *stream_array, const php_growable_fd_set *fds)
838838
{
839839
zval *elem, *dest_elem;
840840
HashTable *ht;
@@ -860,7 +860,7 @@ static int stream_array_from_fd_set(zval *stream_array, const fd_set *fds)
860860
* is not displayed.
861861
*/
862862
if (SUCCESS == php_stream_cast(stream, PHP_STREAM_AS_FD_FOR_SELECT | PHP_STREAM_CAST_INTERNAL, (void*)&this_fd, 1) && this_fd != SOCK_ERR) {
863-
if (PHP_SAFE_FD_ISSET(this_fd, fds)) {
863+
if (FD_ISSET(this_fd, fds->set)) {
864864
if (!key) {
865865
dest_elem = zend_hash_index_update(ht, num_ind, elem);
866866
} else {
@@ -936,7 +936,11 @@ static int stream_array_emulate_read_fd_set(zval *stream_array)
936936
FD_BIGSET_FREE(&efds); \
937937
} while (0)
938938
#else
939-
# define STREAM_SELECT_FREE_SETS() do {} while (0)
939+
# define STREAM_SELECT_FREE_SETS() do { \
940+
php_growable_fd_set_destroy(&rfds); \
941+
php_growable_fd_set_destroy(&wfds); \
942+
php_growable_fd_set_destroy(&efds); \
943+
} while (0)
940944
#endif
941945

942946
/* {{{ Runs the select() system call on the sets of streams with a timeout specified by tv_sec and tv_usec */
@@ -947,7 +951,7 @@ PHP_FUNCTION(stream_select)
947951
#ifndef PHP_WIN32
948952
fd_bigset rfds, wfds, efds;
949953
#else
950-
fd_set rfds, wfds, efds;
954+
php_growable_fd_set rfds, wfds, efds;
951955
#endif
952956
php_socket_t max_fd = 0;
953957
int retval, sets = 0;
@@ -1011,9 +1015,11 @@ PHP_FUNCTION(stream_select)
10111015
sets += set_count;
10121016
}
10131017
#else
1014-
FD_ZERO(&rfds);
1015-
FD_ZERO(&wfds);
1016-
FD_ZERO(&efds);
1018+
/* Size the growable sets to each array's element count (clamped to at least
1019+
* FD_SETSIZE); they still grow on demand, so this is only a starting guess. */
1020+
php_growable_fd_set_init(&rfds, r_array != NULL ? zend_hash_num_elements(Z_ARR_P(r_array)) : 0);
1021+
php_growable_fd_set_init(&wfds, w_array != NULL ? zend_hash_num_elements(Z_ARR_P(w_array)) : 0);
1022+
php_growable_fd_set_init(&efds, e_array != NULL ? zend_hash_num_elements(Z_ARR_P(e_array)) : 0);
10171023

10181024
if (r_array != NULL) {
10191025
set_count = stream_array_to_fd_set(Z_ARR_P(r_array), &rfds, &max_fd);
@@ -1116,7 +1122,7 @@ PHP_FUNCTION(stream_select)
11161122

11171123
retval = php_select(max_fd + 1, (fd_set *) rfds.fds_bits, (fd_set *) wfds.fds_bits, (fd_set *) efds.fds_bits, tv_p);
11181124
#else
1119-
retval = php_select(max_fd + 1, &rfds, &wfds, &efds, tv_p);
1125+
retval = php_select(max_fd + 1, rfds.set, wfds.set, efds.set, tv_p);
11201126
#endif
11211127
php_stream_error_operation_end(context);
11221128

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
--TEST--
2+
stream_select(): more than FD_SETSIZE sockets on Windows (growable fd_set)
3+
--SKIPIF--
4+
<?php
5+
if (PHP_OS_FAMILY !== 'Windows') die('skip Windows only');
6+
?>
7+
--FILE--
8+
<?php
9+
/* On Windows the winsock fd_set is a packed array of SOCKETs capped at the
10+
* compile-time FD_SETSIZE (default 256, see --enable-fd-setsize). stream_select()
11+
* now grows the set on demand, so it can watch far more than FD_SETSIZE sockets,
12+
* matching POSIX behaviour. Here we select on FD_SETSIZE + 50 readable sockets
13+
* and expect every one to be reported ready; before the fix the winsock FD_SET
14+
* macro silently dropped everything past FD_SETSIZE. */
15+
$target = PHP_FD_SETSIZE + 50;
16+
$pairs = [];
17+
$reads = [];
18+
for ($i = 0; $i < $target; $i++) {
19+
$p = stream_socket_pair(STREAM_PF_INET, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP);
20+
if ($p === false) {
21+
break;
22+
}
23+
fwrite($p[0], "x");
24+
$pairs[] = $p;
25+
$reads[] = $p[1];
26+
}
27+
28+
$r = $reads;
29+
$w = $e = null;
30+
$ready = stream_select($r, $w, $e, 2, 0);
31+
32+
var_dump(count($reads) === $target);
33+
var_dump($ready === $target);
34+
var_dump(count($r) === $target);
35+
36+
foreach ($pairs as $p) {
37+
fclose($p[0]);
38+
fclose($p[1]);
39+
}
40+
?>
41+
--EXPECT--
42+
bool(true)
43+
bool(true)
44+
bool(true)
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
--TEST--
2+
stream_select(): >64 non-socket handles fail gracefully on Windows (no stack overflow)
3+
--SKIPIF--
4+
<?php
5+
if (PHP_OS_FAMILY !== 'Windows') die('skip Windows only');
6+
?>
7+
--FILE--
8+
<?php
9+
/* On Windows stream_select() multiplexes non-socket handles (pipes, files)
10+
* through WaitForMultipleObjects(), which cannot wait on more than
11+
* MAXIMUM_WAIT_OBJECTS (64) handles at once. php_select() used to push each
12+
* handle into a fixed 64-slot stack array with no bounds check, so selecting on
13+
* 65+ file/pipe handles overran the stack and crashed the process with
14+
* STATUS_STACK_BUFFER_OVERRUN (0xC0000409). It must instead fail gracefully
15+
* (warning + false). This test crashes every unpatched PHP build on Windows and
16+
* passes only once the cap is in place. */
17+
$files = [];
18+
for ($i = 0; $i < 70; $i++) {
19+
$path = __DIR__ . "/stream_select_win32_max_handles_$i.tmp";
20+
file_put_contents($path, "d");
21+
$files[] = fopen($path, "rb");
22+
}
23+
24+
$r = $files;
25+
$w = $e = null;
26+
var_dump(stream_select($r, $w, $e, 0, 0));
27+
28+
foreach ($files as $f) {
29+
fclose($f);
30+
}
31+
?>
32+
--EXPECTF--
33+
Warning: stream_select(): Unable to select [%d]: %s (max_fd=%d) in %s on line %d
34+
bool(false)
35+
--CLEAN--
36+
<?php
37+
for ($i = 0; $i < 70; $i++) {
38+
@unlink(__DIR__ . "/stream_select_win32_max_handles_$i.tmp");
39+
}
40+
?>

‎main/php_network.h‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,75 @@ static inline bool _php_check_fd_setsize(php_socket_t *max_fd, int setsize)
248248
# define PHP_SAFE_MAX_FD(m, n) _php_check_fd_setsize(&m, n)
249249
#endif
250250

251+
#ifdef PHP_WIN32
252+
/* {{{ Growable winsock fd_set.
253+
*
254+
* On Windows fd_set is a *packed array* of SOCKETs ({ u_int fd_count; SOCKET
255+
* fd_array[FD_SETSIZE]; }), not a bitset, so the traditional FD_SETSIZE ceiling
256+
* caps the *number* of sockets rather than their descriptor value.
257+
* php_growable_fd_set lifts that limit by keeping the winsock fd_set on the heap
258+
* and doubling fd_array on demand. Because select()/FD_ISSET/FD_ZERO only ever
259+
* read fd_count, `set` can be handed to them directly at any size. Windows only;
260+
* the POSIX build grows a bitset (fd_bigset) instead - see
261+
* ext/standard/streamsfuncs.c. */
262+
typedef struct {
263+
u_int capacity; /* number of SOCKET slots backing set->fd_array */
264+
fd_set *set; /* heap block laid out as a winsock fd_set of `capacity` slots */
265+
} php_growable_fd_set;
266+
267+
# define PHP_GROWABLE_FD_SET_ALLOC_SIZE(cap) \
268+
(offsetof(fd_set, fd_array) + (size_t)(cap) * sizeof(SOCKET))
269+
270+
static zend_always_inline void php_growable_fd_set_init(php_growable_fd_set *s, u_int capacity)
271+
{
272+
if (capacity < FD_SETSIZE) {
273+
capacity = FD_SETSIZE;
274+
}
275+
s->capacity = capacity;
276+
s->set = (fd_set *) pemalloc(PHP_GROWABLE_FD_SET_ALLOC_SIZE(capacity), 1);
277+
s->set->fd_count = 0;
278+
}
279+
280+
static zend_always_inline void php_growable_fd_set_destroy(php_growable_fd_set *s)
281+
{
282+
if (s->set) {
283+
pefree(s->set, 1);
284+
s->set = NULL;
285+
}
286+
s->capacity = 0;
287+
}
288+
289+
static zend_always_inline void php_growable_fd_set_zero(php_growable_fd_set *s)
290+
{
291+
s->set->fd_count = 0;
292+
}
293+
294+
static zend_always_inline void php_growable_fd_set_reserve(php_growable_fd_set *s, u_int needed)
295+
{
296+
if (needed > s->capacity) {
297+
do {
298+
s->capacity *= 2;
299+
} while (needed > s->capacity);
300+
s->set = (fd_set *) perealloc(s->set, PHP_GROWABLE_FD_SET_ALLOC_SIZE(s->capacity), 1);
301+
}
302+
}
303+
304+
static zend_always_inline void php_growable_fd_set_add(php_growable_fd_set *s, SOCKET fd)
305+
{
306+
/* fds within a single stream_select() array are already unique, so we skip
307+
* the O(n) duplicate scan that winsock's FD_SET macro performs. */
308+
php_growable_fd_set_reserve(s, s->set->fd_count + 1);
309+
s->set->fd_array[s->set->fd_count++] = fd;
310+
}
311+
312+
static zend_always_inline void php_growable_fd_set_copy(php_growable_fd_set *dst, const php_growable_fd_set *src)
313+
{
314+
php_growable_fd_set_reserve(dst, src->set->fd_count);
315+
memcpy(dst->set, src->set, PHP_GROWABLE_FD_SET_ALLOC_SIZE(src->set->fd_count));
316+
}
317+
/* }}} */
318+
#endif
319+
251320

252321
#define PHP_SOCK_CHUNK_SIZE 8192
253322

0 commit comments

Comments
 (0)