Fix stream_select() failing unpredictably (and crashing on Windows) with many descriptors - #14452
frodeborli wants to merge 28 commits into
Conversation
…descriptor numbers without recompiling PHP on non-Windows OS, by passing the select() system call dynamically generated fd_sets with a custom fd_bigset struct type
…ow if those functions could be referenced by other extensions.
|
The testing complained that I had left behind two functions which I didn't dare to remove in case somebody else was using them. I have updated the pr. |
…rent architecture than what I developed this on.
|
I'm not sure why the tests timed out or failed; it does not seem to be related to my patch. |
| return ret; | ||
| } | ||
| /* }}} */ | ||
|
|
There was a problem hiding this comment.
Can this be tested from useland when more than 1024 file descriptors are created/used?
There was a problem hiding this comment.
Also when there is less than 1024, but at least one has a number > 1024
There was a problem hiding this comment.
@mvorisek I used this code to test:
<?php
// Create a large number of file descriptors
$files = [];
$r = $w = $e = [];
for ($i = 0; $i < 20000; $i++) {
$fileName = tempnam(sys_get_temp_dir(), 'testfile');
$fp = fopen($fileName, 'r');
if ($fp === false) {
die("Failed to open file: $fileName\n");
}
$files[] = ['name' => $fileName, 'handle' => $fp];
$r[] = $fp;
}
// Check the number of file descriptors
$res = stream_select($r, $w, $e, 0, 0);
var_dump($res, $r);
// Clean up
foreach ($files as $file) {
fclose($file['handle']);
unlink($file['name']);
}There was a problem hiding this comment.
A function doing dup2() can probably be added to zend_test, allowing to zend_test_dup2(1, 9999).
There was a problem hiding this comment.
@TimWolla Is zend_test a mechanism to add special functions to the PHP runtime during testing? I think it would be nice to have zend_test_get_fd($resource), but perhaps that already exists?
There was a problem hiding this comment.
Is zend_test a mechanism to add special functions to the PHP runtime during testing?
Yes, exactly. It is meant to provide helpers to test stuff that would otherwise be untestable. You can find it in https://github.com/php/php-src/tree/master/ext/zend_test and can enable it at build time using --enable-zend-test.
…est file descriptor id and allocates a correct size.
|
I will look over the PR thoroughly later today or tomorrow morning. Can anybody tell me how to build this in a stricter mode? Several commits could have been avoided by me if only my environment would let me know about unused functions etc, instead of waiting for the github CI process to check for me. |
Speaking of CI, you can get inspiration from it if you like. |
|
The only feedback I would give is to please update your editor to respect the It looks like you've converted a lot of tab characters to spaces, so the diff is much bigger than it should be. |
|
I am having trouble because stream wrappers emit warnings without respecting the silent cast argument of php_stream_cast... So a phpt test does not generate the exact same output anymore after my patch. |
|
The cause of the extra warnings in |
|
Now it seems all tests succeeded, except Windows 64 bit. Not sure how to proceed. I think the php_select macro refers to a win32 implementation which is meant to be compatible with select() in Linux and therefore should work. |
| } | ||
|
|
||
| if (!PHP_SAFE_MAX_FD(max_fd, max_set_count)) { | ||
| if (max_set_count == 0) { |
There was a problem hiding this comment.
Are we missing FD_BIGSET_FREE before returning?
There was a problem hiding this comment.
I'm not sure where you mean; I think FD_BIGSET_FREE is everywhere it is needed now, but there was a few places they were missing before the last push.
There was a problem hiding this comment.
We've already called FD_BIGSET_ZERO by this point, so the memory is allocated. On this line, we are about to return without freeing that memory; thus I think we need to free it.
That being said, I'm not quite sure how we would get here.
… the PR from github.
|
in any case it would fall back to the current behavior
Sendt fra Outlook for iOS<https://aka.ms/o0ukef>
________________________________
Fra: Rob Landers ***@***.***>
Sendt: Friday, June 7, 2024 4:36:36 PM
Til: php/php-src ***@***.***>
Kopi: Frode Børli ***@***.***>; Author ***@***.***>
Emne: Re: [php/php-src] Updated the stream_select() function so that it supports bigger file … (PR #14452)
@withinboredom commented on this pull request.
________________________________
In ext/standard/streamsfuncs.c<#14452 (comment)>:
+ FD_BIGSET_ZERO(&rfds, max_fds);
+ FD_BIGSET_ZERO(&wfds, max_fds);
+ FD_BIGSET_ZERO(&efds, max_fds);
See man sysconf<https://www.man7.org/linux/man-pages/man3/sysconf.3.html>:
The return value of sysconf() is one of the following:
On error, -1 is returned and errno<https://www.man7.org/linux/man-pages/man3/errno.3.html> is set to indicate the
error (for example, EINVAL, indicating that name is invalid).
If name corresponds to a maximum or minimum limit, and that
limit is indeterminate, -1 is returned and errno<https://www.man7.org/linux/man-pages/man3/errno.3.html> is not
changed. (To distinguish an indeterminate limit from an
error, set errno<https://www.man7.org/linux/man-pages/man3/errno.3.html> to zero before the call, and then check
whether errno<https://www.man7.org/linux/man-pages/man3/errno.3.html> is nonzero when -1 is returned.)
If name corresponds to an option, a positive value is returned
if the option is supported, and -1 is returned if the option
is not supported.
Otherwise, the current value of the option or limit is
returned. This value will not be more restrictive than the
corresponding value that was described to the application in
<unistd.h> or <limits.h> when the application was compiled.
So, theoretically, if uname -n unlimited were possible, you could also receive a -1 too. AFAIK, it isn't possible to set the open file limit to unlimited though. (I could be wrong, but I'm 95% sure).
—
Reply to this email directly, view it on GitHub<#14452 (comment)>, or unsubscribe<https://github.com/notifications/unsubscribe-auth/AARRLUDB2NEIVS4KIIK75JTZGHAPJAVCNFSM6AAAAABIWFGFUGVHI2DSMVQWIX3LMV43YUDVNRWFEZLROVSXG5CSMV3GSZLXHMZDCMBUG42TENJUGQ>.
You are receiving this because you authored the thread.Message ID: ***@***.***>
|
|
I think it will be very rare with file descriptors above 131k, at least for the next couple of years. Perhaps the best solution is to reallocate on the fly if we see fds above 131k and use a single pass. 131k is extremely high for a single process so I think it will be rare to encounter it.
|
| size_t largest = rfds.size; | ||
| if (largest < wfds.size) largest = wfds.size; | ||
| if (largest < efds.size) largest = efds.size; |
There was a problem hiding this comment.
I think we can use max_fd to directly compute the largest size?
There was a problem hiding this comment.
Something like:
while (max_fd > rfds.size) fd_bigset_double_size(&rfds);
I was just not confident about possibly off by one error here...
There was a problem hiding this comment.
We can use the same condition as in FD_BIGSET_ENSURE_CAPACITY(), or pre-compute the largest size with:
size_t num_fds = max_fd + 1;
size_t largest = (num_fds + 7) / 8;
|
@arnaud-lb Thank you for your detailed review. I'm merging the latest php-src and pushing an updated PR with all your suggestions. I was looking into Windows which apparently only has FD_SETSIZE 64 by default, and it uses an entirely different fd_set structure. Not sure how this is handled inside PHP and if my approach breaks it? I've not got a Windows C development environment, but I can look into it if you can give some hints perhaps? |
|
Oh right, it looks like this will break on Windows. We use WinSock select() according to this comment, so fd_set is as defined here: https://learn.microsoft.com/en-us/windows/win32/api/winsock/ns-winsock-fd_set on Windows. It represents an array of handles rather than a bitset, and the limit is the number of watched fds rather than the max fd value. We can use the same strategy as for other platforms, but use the fd count to decide whether to grow the set. We have to define the FD_BIGSET_ macros differently on windows: However there is a (non-enforced) hard limit of php_select() copies It would make sense to do part of this work later in a separate PR, as long as this PR doesn't break windows. E.g. don't support more than FD_SETSIZE fds for now on Windows.
I sometimes use a Windows VM from https://developer.microsoft.com/en-us/windows/downloads/virtual-machines/ (The VirtualBox ones work well. If you use virt-manager you may be able to convert them to KVM but that's not trivial.). This page describes how to build on windows: https://wiki.php.net/internals/windows/stepbystepbuild_sdk_2 |
bukka
left a comment
There was a problem hiding this comment.
I will take a proper look later but could you please in the meantime fix the coding style.
|
btw I have a real Windows env so I can test it then too. |
…onfig This unblocks the review that stalled in July 2024: arnaud-lb pointed out that casting the POSIX fd_bigset (a growable bitset) to a native fd_set would corrupt memory on Windows, since WinSock's fd_set is an array of SOCKET handles capped at FD_SETSIZE rather than a bitset indexed by descriptor number. Windows now keeps the original, unmodified fd_set based implementation (guarded by #ifdef PHP_WIN32), so the unlimited-fd support only changes behavior on POSIX where select() already has no such limitation. Also reindents the fd_bigset-related code from spaces to tabs per .editorconfig, addressing ramsey's outstanding review comment. A follow-up could implement arnaud-lb's suggested growable Windows fd_set design (php-src#14452 review thread) to bring parity there. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Reconciles with ~1.5 years of upstream development, including the mechanical stream_select() refactor in b46c5e6 (const HashTable* params, dropped dead IS_ARRAY checks) and the new optional $context parameter / stream error API additions. The only real conflict was in ext/standard/streamsfuncs.c, resolved by applying the same modernization to our fd_bigset (POSIX) and native fd_set (Windows) code paths. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1. FD_BIGSET_ENSURE_CAPACITY used `if` instead of `while`, so a single fd jump larger than 2x the current capacity (reachable once the process's real fd limit exceeds the 131072 cap applied to the initial allocation) only doubled once, leaving the buffer too small for the fd actually being set -- an out-of-bounds heap write. Confirmed with a standalone ASan reproducer (heap-buffer-overflow). 2. FD_BIGSET_ZERO sized the buffer as ceil(num_fds/8) bytes, but select() operates on whole `long`-sized words at the syscall boundary (Linux's FDS_BYTES()/NFDBITS: it always touches ceil(nfds/(8*sizeof(long)))*sizeof(long) bytes for each fd_set argument). Whenever num_fds wasn't a multiple of 8*sizeof(long) (64 on 64-bit builds), our buffer was up to 7 bytes short, so select() read/wrote past the allocation into adjacent heap memory. Confirmed with a guard-page reproducer: the kernel returned EFAULT when the extra bytes landed on an unmapped page, proving it does touch memory beyond a too-small buffer; with a normal heap allocation those same bytes would instead silently corrupt whatever object happens to sit next to it. Both fixed by growing via a loop (not a single doubling) and by rounding the allocation up to a whole `long`, which doubling then preserves. Verified against the full ext/standard/tests/streams suite (no regressions) and manually with 300,000 real file descriptors (exceeding the threshold where a single doubling was insufficient). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…g over it These tests (arnaud-lb, phpGH-9602/80232de0e4b) verified that stream_select() fails loudly and immediately once any watched descriptor exceeds FD_SETSIZE, instead of silently dropping it from the fd_set and blocking forever on a socket it can no longer see -- that was the real phpGH-9590 bug: a silently-dropped fd caused an indefinite, unexplained hang. Our fd_bigset removes the cause of that bug on POSIX rather than just detecting it: a descriptor past 1024 is no longer dropped, it's tracked correctly. So the warning path these tests expected is now dead code for this scenario, and running the tests unmodified exposed exactly the failure mode they were designed to prevent: with nothing ever written to the watched socket and no signal to stop it, `stream_select()` now genuinely blocks on the (correctly-registered) high fd for the full PHP_INT_MAX timeout instead of bailing out early, timing out CI after 2 minutes per test on FreeBSD. Updated both to assert the actual guarantee this PR provides: writing to the paired socket and confirming stream_select() correctly reports it as readable even past the old limit, with a finite timeout. Verified manually against the patched build (posix_setrlimit's hard-limit-raise via -1 fails as EPERM in this sandbox, so run-tests.php's own SKIPIF skips them here, but the exact --FILE-- logic was run standalone with an explicit hard limit and produces the new --EXPECT-- exactly). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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.
…ording Add the missing NEWS/UPGRADING entry for the POSIX side of the stream_select() change (the limit is now lifted on POSIX as well as Windows, not just Windows), correct the UPGRADING note that implied POSIX was already unbounded, and trim the max-handles test docblock to a plain description of the behavior it guards.
…imited # Conflicts: # NEWS # UPGRADING
|
The WINDOWS_X64_ZTS failure here is unrelated to this change. The build succeeds (vs18, /WX), and the only failing test out of ~23.7k is ext/standard/tests/file/GHSA-9f67-6fw4-hpfp-win32.phpt, which fails during its own cleanup (Warning: rmdir(): Directory not empty). That test is currently failing on master too — e.g. the Windows job in run 35732538919 — so it's a pre-existing issue independent of this PR. The stream_select() changes compile cleanly and the new stream_select_win32_* tests pass. |
…rocess/Windows simplification - phasync-ext (github.com/phasync/phasync-ext, MIT, PHP 8.3+) exists and is tested: a real Zend extension delivering a growable stream_select() with no FD_SETSIZE ceiling, plus transparent async hooks for tcp/unix/ssl sockets, proc_open() pipes, and the sleep() family. Verified locally: all 7 of its phpt tests pass against the built module. This supersedes the FFI-based plan for directions A and B; the FFI research is kept as the record of the reasoning, not deleted. - php/php-src#14452 (stream_select_unlimited): live status folded in from the coordinating session's own report -- code-complete, Windows verified on real hardware and CI, the one red CI job diagnosed as an unrelated pre-existing master-wide flake, still awaiting maintainer review. - GH-16889 (Windows pipe polling, shipped in PHP 8.5) and the io/poll RFC and Scheduler ABI pre-RFC researched and folded in. - Process/Windows: dropped the planned custom fork()/exec()/pipe() process launcher -- PosixProcessRunner already just uses proc_open() + stream_select(), so this is now a version-floor decision, not an engineering project, once the above land. - New sections: the clustering/string-broadcast primitive and how it resolves D2, and a checklist for narrowing the core (Psr/*, FastCGI, the PDO wrapper, fork()). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NEVK2ZKB8msUGY6D5gp9F9
|
Excited to pick this back up — apologies for the long silence since last year, some personal circumstances kept me away from this for a while. Good to be back on it. Rebased and pushed 2 days ago (test run 35733334346) — CI is green across Linux (x64/x32, ASAN/DEBUG), Alpine, macOS ARM64, FreeBSD, and CircleCI arm. The one red job, Re the ABI break label: I believe this is the path-based labeler matching @bukka — thanks for taking another look. @arnaud-lb — tagging you too since you did most of the substantive review back in 2024 (the FD_BIGSET design, the Windows |
|
@frodeborli Is this still worth the effort now that we have https://wiki.php.net/rfc/poll_api to replace |
|
Thanks @arnaud-lb. I checked master: the poll API added And it is a real bug, not just a limit. Past I'd also argue the two are complementary rather than redundant. The fix is implemented and green across platforms, and the Windows growable |
The existing phpGH-9590 tests only put descriptors in the read set, but each of the three fd_sets grows independently. Put a descriptor beyond FD_SETSIZE in all three sets (plus a low one in two of them) and check select()'s count of set bits across the sets, the returned arrays, and a write-set-only select.
stream_select_win32_many_sockets.phpt only exercises the read set, but on Windows each of the three fd_sets is a separately grown handle array. Put PHP_FD_SETSIZE + 50 sockets in the read, write and except sets at once and check select()'s count of entries across all sets (each socket is readable and writable, so it counts twice), plus a write-set-only select. Verified on the Windows CI build (x64 ZTS and x86 NTS).
|
@bukka The tabs-vs-spaces issue from your review is fixed; the diff now uses tabs throughout. I've also added tests for descriptors beyond FD_SETSIZE in the write and except sets, on both POSIX (gh9590-003) and Windows (stream_select_win32_multi_set, verified on the Windows CI build). Until now only the read set was covered. Since this is a bug fix (stack corruption past FD_SETSIZE), I think it's still needed alongside Io\Poll, and it doesn't conflict with it. I looked at implementing stream_select() on top of epoll instead, but it's a poor fit: stream_select() is stateless, since every call passes fresh arrays. So each call would need epoll_create1(), one epoll_ctl() per descriptor, epoll_wait() and close(). That's n+2 syscalls where select() needs one. poll() changes the semantics in small ways: select() counts a descriptor once per set it's ready in while poll() counts it once, an invalid fd becomes POLLNVAL instead of EBADF, and timeouts drop from microseconds to milliseconds. I ran into exactly those differences in a poll()-based stream_select() in an extension. Keeping select() with a growable fd_set is the smallest change that fixes the bug while keeping stream_select()'s behaviour identical. Io\Poll remains the right tool for new code. Could you take another look when you have time? |
stream_select()has a correctness bug: once any watched stream's descriptor exceedsFD_SETSIZE, the call emits a warning and returnsfalse— aborting the entire select, including the low-numbered descriptors that would have worked. PHP userland has no control over or visibility into which integer a stream's descriptor gets, so this failure is effectively nondeterministic from the script's perspective: the same code works or fails depending on incidental descriptor numbering. The only escape was recompiling with--enable-fd-setsize.This fixes the root cause:
stream_select()no longer uses a fixed-sizefd_setand is no longer bounded byFD_SETSIZE. No recompilation needed.POSIX: builds a heap bitset sized to the descriptors in use, rounded to the word granularity
select()reads/writes.Windows: the winsock
fd_setis a packedSOCKETarray bounded byFD_SETSIZE; the socket path now uses a growablefd_setthat doubles on demand.php_select()'s exportedfd_set *ABI is unchanged.Also fixes a Windows crash: on the non-socket (pipe/file) path,
php_select()pushed each handle into a fixed 64-entry stack array with no bounds check, so more than 64 handles overran it. That path is bounded byWaitForMultipleObjects()'sMAXIMUM_WAIT_OBJECTS(64) limit anyway, so it now returns an error (caller warns and returnsfalse) instead. Fixed insidephp_select(), sosapi/cli/php_cli_server.cis covered too.Scope:
stream_select()only.--enable-fd-setsizeremains meaningful forsocket_select(), mysqlnd, the FPMselectbackend, and other consumers, which are unchanged.Tests:
ext/standard/tests/streamspasses on Linux, FreeBSD, macOS, Windows.gh9590-001/002.phptupdated to assert correct selection past the old limit. Two Windows-only tests added.This is a bug fix, not a behavior change, and I'd like it considered for all supported branches. The differences an existing script can observe — the return value, the removed
E_WARNING, and blocking instead of returning immediately — occur only on calls that previously emitted anE_WARNINGand returnedfalse, i.e. calls PHP was already reporting as failures. Any call that was selecting successfully (all descriptors belowFD_SETSIZE) behaves identically. There is no warning-free, working call whose behavior changes. The Windows >64-handle case is an outright crash fix. On that basis I believe it qualifies for backport to the active stable branches rather than master only.The patch does not apply cleanly to the older branches as-is (
stream_select()was refactored on master, and the Windows pipe handling differs across versions), so I'm happy to provide adapted patches per branch if backporting is wanted.