io: use poll() instead of select() to avoid an FD_SETSIZE hang (fixes #231)#1018
io: use poll() instead of select() to avoid an FD_SETSIZE hang (fixes #231)#1018dr-who wants to merge 2 commits into
Conversation
b1000bd to
689ffbd
Compare
tridge
left a comment
There was a problem hiding this comment.
Automated review — generated by OpenAI Codex. Findings were spot-checked
against the code but have not been hand-verified in full; please treat them as
review input rather than a final verdict.
Thanks for tackling #231 — moving the I/O readiness loops off select()/fd_set
is the right direction, and the transfer-path conversion is largely sound: the
fixed pollfd[3] set can't overflow, the message-before-data priority and
partial-progress / short read-write / EINTR / EAGAIN semantics are preserved,
it builds cleanly, and the new highfd-hang test is RED on master (aborts with
a fortify "bit out of range … FD_SETSIZE") and GREEN with the patch.
A few things should be addressed before this lands, though — two of them
significant:
1. High — a negative I/O timeout becomes an infinite poll() wait
set_io_timeout() can yield a negative select_timeout (e.g. secs == -3 →
allowed_lull == -1 → select_timeout == -1), and MSG_IO_TIMEOUT accepts an
arbitrary signed peer value (it calls set_io_timeout(val) whenever
val < io_timeout). All three waits now pass select_timeout * 1000 to
poll(); a negative millisecond count means wait forever on Linux, which
bypasses keepalives and timeout enforcement entirely. Previously select()
rejected the negative timeval with EINVAL, so the loop and check_timeout()
still ran. A hostile or buggy peer can therefore stall the other side
indefinitely. ((io_timeout + 1) is also a signed-overflow path for
INT_MAX.) Suggest rejecting peer values <= 0, computing allowed_lull
overflow-safe (secs / 2 + secs % 2), and clamping the final poll timeout to
0..60000.
2. High — the same FD_SETSIZE bug remains in the daemon accept loop
start_accept_loop() in socket.c still stores listening sockets in an
fd_set and calls select(). If rsync --daemon itself starts with enough
descriptors open, open_socket_in() returns listener fds >= FD_SETSIZE and the
same out-of-bounds FD_SET() occurs at daemon startup. This PR fixes high
accepted-connection fds once the transfer reaches perform_io, but not the
listener path, and the new test only runs a local transfer so it doesn't cover
it. Either convert the accept loop too, or narrow the PR's claim and track the
remaining path as a companion fix.
3. Medium — revents != 0 isn't the requested-readiness test; invalid-fd handling drifts
safe_read, safe_write, and the three multiplexer checks treat any non-zero
revents as ordinary readiness. poll() reports POLLERR/POLLHUP/POLLNVAL
even when unrequested, and an invalid fd yields a successful poll with
POLLNVAL — not poll() == -1 && errno == EBADF — so the new EBADF branches
are effectively dead. Consequences: safe_read/safe_write shift from
RERR_FILEIO at the readiness call to RERR_STREAMIO during the I/O attempt;
perform_io may service other ready fds before noticing the bad one; and an
invalid ff_forward_fd is passed to forward_filesfrom_data(), where EBADF is
read as EOF and an EOF marker is sent instead of failing. Suggest handling
POLLNVAL explicitly and using role-specific masks
(POLLIN|POLLERR|POLLHUP, POLLOUT|POLLERR|POLLHUP). The old exception sets
(POLLPRI) are also silently dropped, so the "behavior-preserving" claim isn't
exact.
4. Medium — in_fd == out_fd is entered twice
Direct daemon connections use one fd bidirectionally (client_run(fd, fd, …)),
and the patch adds two pollfd rows for it (one POLLIN, one POLLOUT). This
is fine on current systems, but Cygwin through 3.3.5 had a bug that copied
readiness between duplicate poll entries with different masks (fixed in 3.3.6);
combined with the any-revents test, a permanently-writable socket can spuriously
trigger the read path and cause EAGAIN loops. Merging the identical fd into one
row with OR-ed events (or role-specific revents masks) avoids this and also
satisfies "no fd double-counted."
5. Medium — poll() is now an undeclared platform requirement
The patch adds <poll.h> but configure.ac checks neither the header nor the
function. Modern Cygwin/Solaris have poll(), but rsync still carries support for
older/proprietary systems, and poll() is absent on some targets (e.g. native
MinGW/MSVC, HPE NonStop) with behavioral quirks on some older AIX/macOS special
files. Suggest adding AC_CHECK_HEADERS([poll.h]) / AC_CHECK_FUNCS([poll]) and
either failing configure with a clear minimum-platform message, using a
replacement, or keeping a bounded select() fallback that rejects
fd >= FD_SETSIZE rather than invoking UB.
6. Test — valid on Linux but not portable as written
highfd-hang_test.py hardcodes FD_SETSIZE = 1024. On 64-bit Solaris that value
is 65536, so opening up to fd 1104 doesn't cross the limit and the test passes
vacuously (false green). Obtaining the build's actual FD_SETSIZE from a small
compiled helper would make it robust; it's also worth describing the pre-fix
result as "hang or abort due to UB," since on a fortified glibc master aborts
rather than hangs. The fd-construction approach itself (opening continuously,
close_fds=False) is sound on Linux.
Happy to see this land once the timeout handling (#1) and the readiness/POLLNVAL
semantics (#3) are sorted; #2 is a scope call worth deciding explicitly.
tridge
left a comment
There was a problem hiding this comment.
thanks for looking at this. I agree we should move to poll. I did a codex review which found a few issues, see comment above. I'd like to get this into 3.5.0 if I can, sorry it took me a while to get to this.
…syncProject#231) rsync's I/O loops (safe_read, safe_write, and the main perform_io multiplexer) waited for readiness with select() and fd_set bitmaps. An fd_set can only represent descriptors below FD_SETSIZE (1024 with glibc). When rsync is started with many descriptors already open -- e.g. inherited from a parent process that leaked fds, a high "ulimit -n", or a busy daemon -- its own socket and pipe fds get allocated at or above 1024. FD_SET() and FD_ISSET() then index past the end of the fixed-size fd_set, which is undefined behavior: select() reports the fd as ready, but FD_ISSET() reads the out-of-bounds bit as 0, so the read or write never happens and rsync spins at 100% CPU forever with no progress. This is the long-standing "rsync hangs at 100% CPU on large systems" report, and it matches the MemorySanitizer use-of-uninitialized-value seen in perform_io. Convert the three loops to poll(), which identifies descriptors by value in a small array and has no FD_SETSIZE ceiling, so a high-numbered fd works fine. rsync only ever waits on a handful of fds (at most three in perform_io: in_fd, out_fd, and the files-from forward fd), so poll() is as fast as -- or faster than -- select() here; the select()-vs-poll() cost gap only appears when watching thousands of descriptors, which rsync never does. The remaining select(0, ...) call is a pure timed sleep with no fds and is unaffected. The conversion is behavior-preserving: the same max_fd bookkeeping decides when there is nothing to wait on, the per-fd readiness checks map to the matching pollfd revents, and the timeout is the same (now expressed in milliseconds). testsuite/highfd-hang_test.py reproduces the hang deterministically by opening enough inheritable dummy fds to push rsync's descriptors past FD_SETSIZE before an ordinary transfer; it hangs (caught by a timeout) on the select() code and passes instantly with poll().
Follow-up to the FD_SETSIZE fix, covering the points raised in review. Negative/overflowing I/O timeouts. set_io_timeout() could produce a negative select_timeout (a peer-supplied MSG_IO_TIMEOUT value was applied unchecked), and every wait now passes select_timeout * 1000 to poll(), where a negative millisecond count means "wait forever" -- so a hostile or buggy peer could stall the other side and bypass keepalives entirely. select() used to reject that with EINVAL, which kept the loop and check_timeout() running. Clamp a negative argument to 0, compute allowed_lull without overflowing near INT_MAX (secs / 2 + secs % 2), ignore a non-positive MSG_IO_TIMEOUT value, and funnel all three waits through poll_timeout_ms(), which keeps the count positive and bounded. The daemon accept loop had the same fd_set overflow. start_accept_loop() still stored listening sockets in an fd_set, so a daemon started with enough descriptors already open got listener fds >= FD_SETSIZE and hit the same undefined behaviour at startup -- verified: with the old code a transfer through such a daemon yields nothing, with this change it succeeds. Converted it to poll() as well. Readiness testing. Treating any non-zero revents as ordinary readiness was wrong: poll() reports POLLERR/POLLHUP/POLLNVAL unrequested, and an invalid fd shows up as POLLNVAL on a successful poll() rather than -1/EBADF, which left the EBADF branches dead and let an invalid ff_forward_fd reach forward_filesfrom_data() (where EBADF reads as EOF). Use role-specific masks (POLL_RD_BITS / POLL_WR_BITS), handle POLLNVAL explicitly in all three loops, and request POLLPRI so select()'s old exception set is not silently dropped. A bidirectional fd is no longer entered twice. A direct daemon connection uses one fd for both directions; it now occupies a single pollfd row with OR-ed events instead of two rows carrying different masks, which also avoids the Cygwin < 3.3.6 duplicate-entry readiness bug. poll() is now a declared requirement: configure.ac checks for poll.h and poll(), failing with a clear message rather than leaving it implicit. The test no longer hardcodes FD_SETSIZE (1024 on glibc but 65536 on 64-bit Solaris, where it would have opened too few fds and passed vacuously); it asks the C library for the real value via a small compiled probe and skips if that is unavailable. Its description now also covers the fortified-libc case, where the pre-fix result is an abort rather than a hang.
689ffbd to
7ef165d
Compare
|
Thanks — that's a genuinely useful review, and all six were real. Pushed as a follow-up commit ( 1 (High) — negative timeout became an infinite
2 (High) — same bug in the daemon accept loop. You're right, and I'd wrongly scoped the PR. Rather than narrow the claim I converted 3 (Medium) — 4 (Medium) — 5 (Medium) — undeclared platform requirement. Added 6 (Test) — not portable as written. Good catch on the vacuous green; hardcoding 1024 would indeed have opened too few fds on 64-bit Solaris. The test now obtains the real Full suite: 108 passed, 0 failed. The high-fd test still fails on master and passes with the change. |
Problem (#231)
rsync's I/O loops —
safe_read,safe_write, and the mainperform_iomultiplexer — waited for readiness withselect()andfd_setbitmaps. Anfd_setcan only represent descriptors belowFD_SETSIZE(1024 with glibc).When rsync is started with many descriptors already open — inherited from a parent that leaked fds, a high
ulimit -n, or a busy daemon — its own socket/pipe fds get allocated at or above 1024.FD_SET()/FD_ISSET()then index past the end of the fixed-sizefd_set, which is undefined behavior:select()reports the fd ready, butFD_ISSET()reads the out-of-bounds bit as 0, so the read/write never happens and rsync spins at 100% CPU forever with no progress.This is the long-standing "rsync hangs at 100% CPU" report, and it explains the MemorySanitizer use-of-uninitialized-value in
perform_ioin the issue thread (the OOBFD_ISSETread) and Wayne's own hypothesis there ("maybe iobuf.in is greater than the defaultFD_SETSIZE"). The-vvvcorrelation was a red herring — the message-backlog deadlock is separately mitigated by the dynamiciobuf.msggrowth.Deterministic reproduction
Pre-open ~1100 inheritable fds so rsync's descriptors land above
FD_SETSIZE, then run any transfer:straceshowspselect6(1106, [1105], …) = 1returning "ready" instantly in a tight loop; one process pegged at 100% CPU in stateR; 0 files transferred.Fix
Convert the three loops to
poll(), which identifies descriptors by value in a small array and has noFD_SETSIZEceiling, so a high-numbered fd works fine.perform_io:in_fd,out_fd, the files-from forward fd). The "poll is slower than select" effect only appears when watching thousands of descriptors; at N≤3poll()is equal-to or faster thanselect()(it skips theFD_ZERObitmap clears).epoll/kqueuewould be more code, unportable, and actually slower for such a tiny, changing fd set.max_fdbookkeeping decides when there's nothing to wait on; eachFD_ISSETmaps to the matchingpollfd.revents; the timeout is unchanged (now in milliseconds). The lone remainingselect(0, …)is a pure timed sleep with no fds and is left as-is.Verified: with the fix, transfers complete correctly with 1100 and 5000 pre-opened fds (and the data matches); a normal low-fd transfer is unchanged.
Test
testsuite/highfd-hang_test.pyopens enough inheritable dummy fds to push rsync's descriptors pastFD_SETSIZE, then runs an ordinary transfer withclose_fds=False. The hang is an infinite spin, so the instant-pass / never-finish cross-over is binary rather than a timing race. It hangs (caught by a timeout) on theselect()code and passes instantly withpoll(), and also verifies the transferred files are correct. It skips cleanly ifRLIMIT_NOFILEcan't be raised aboveFD_SETSIZE.Full suite: 107 passed, 6 skipped, 0 failed.