From eaa0da24d24be57707077e8d0463a89815c4de4d Mon Sep 17 00:00:00 2001 From: Chris Speciale Date: Fri, 22 Aug 2025 10:03:54 -0400 Subject: [PATCH 1/4] [fix] Socket: Fix stale read/write indexes in `_hx_std_socket_poll_events` on error/empty sets Previously, `_hx_std_socket_poll_events` could exit early on `select()/poll()` errors without resetting the ready index arrays. This left stale values in `p->ridx/p->widx`, causing incorrect results when the socket set was empty or when sockets were removed. In Haxe this manifested as phantom entries (e.g. index "1" still marked ready after deregistration, or `poll([])` returning `[null]`). This change ensures `p->ridx[0]` and `p->widx[0]` are always initialized to `-1` at function entry, and remain consistent even if `select()/poll()` returns an error. With this fix, empty socket sets no longer yield spurious ready indexes, and poll() on an empty registry correctly returns `[]` without mutating the input array. --- src/hx/libs/std/Socket.cpp | 104 ++++++++++++++++++++----------------- 1 file changed, 56 insertions(+), 48 deletions(-) diff --git a/src/hx/libs/std/Socket.cpp b/src/hx/libs/std/Socket.cpp index fe77cdc46..258dd754d 100644 --- a/src/hx/libs/std/Socket.cpp +++ b/src/hx/libs/std/Socket.cpp @@ -1224,62 +1224,70 @@ Array _hx_std_socket_poll_prepare( Dynamic pdata, Array rsocks Update the read/write flags arrays that were created with [socket_poll_prepare]. **/ -void _hx_std_socket_poll_events( Dynamic pdata, double timeout ) +void _hx_std_socket_poll_events(Dynamic pdata, double timeout) { - polldata *p = val_poll(pdata); - - #ifdef NEKO_WINDOWS - memcpy(p->outr,p->fdr,FDSIZE(p->fdr->fd_count)); - memcpy(p->outw,p->fdw,FDSIZE(p->fdw->fd_count)); + polldata *p = val_poll(pdata); - struct timeval t; - struct timeval *tt = init_timeval(timeout,&t); + // Clear at entry so any early return leaves a clean -1 sentinel. + p->ridx[0] = -1; + p->widx[0] = -1; - hx::EnterGCFreeZone(); - if( select(0/* Ignored */, p->fdr->fd_count ? p->outr : 0, p->fdw->fd_count ?p->outw : 0,NULL,tt) == SOCKET_ERROR ) - { - hx::ExitGCFreeZone(); - return; - } - hx::ExitGCFreeZone(); +#ifdef NEKO_WINDOWS + memcpy(p->outr, p->fdr, FDSIZE(p->fdr->fd_count)); + memcpy(p->outw, p->fdw, FDSIZE(p->fdw->fd_count)); - int k = 0; - for(int i=0;ifdr->fd_count;i++) - if( FD_ISSET(p->fdr->fd_array[i],p->outr) ) - p->ridx[k++] = i; - p->ridx[k] = -1; + struct timeval t; + struct timeval *tt = init_timeval(timeout, &t); - k = 0; - for(int i=0;ifdw->fd_count;i++) - if( FD_ISSET(p->fdw->fd_array[i],p->outw) ) - p->widx[k++] = i; - p->widx[k] = -1; + hx::EnterGCFreeZone(); + if (select(0, p->fdr->fd_count ? p->outr : 0, + p->fdw->fd_count ? p->outw : 0, + NULL, tt) == SOCKET_ERROR) + { + hx::ExitGCFreeZone(); + // ridx/widx already set to -1 at entry + return; + } + hx::ExitGCFreeZone(); - #else + int k = 0; + for (int i=0; ifdr->fd_count; i++) + if (FD_ISSET(p->fdr->fd_array[i], p->outr)) + p->ridx[k++] = i; + p->ridx[k] = -1; - int tot = p->rcount + p->wcount; - hx::EnterGCFreeZone(); - POSIX_LABEL(poll_events_again); - if( poll(p->fds,tot,(int)(timeout * 1000)) < 0 ) - { - HANDLE_EINTR(poll_events_again); - hx::ExitGCFreeZone(); - return; - } - hx::ExitGCFreeZone(); + k = 0; + for (int i=0; ifdw->fd_count; i++) + if (FD_ISSET(p->fdw->fd_array[i], p->outw)) + p->widx[k++] = i; + p->widx[k] = -1; - int k = 0; - int i = 0; - for(i=0;ircount;i++) - if( p->fds[i].revents & (POLLIN|POLLHUP) ) - p->ridx[k++] = i; - p->ridx[k] = -1; - k = 0; - for(;ifds[i].revents & (POLLOUT|POLLHUP) ) - p->widx[k++] = i - p->rcount; - p->widx[k] = -1; - #endif +#else + int tot = p->rcount + p->wcount; + hx::EnterGCFreeZone(); +poll_events_again: + if (poll(p->fds, tot, (int)(timeout * 1000)) < 0) + { + if (errno == EINTR) goto poll_events_again; + hx::ExitGCFreeZone(); + // ridx/widx already set to -1 at entry + return; + } + hx::ExitGCFreeZone(); + + int k = 0; + int i = 0; + for (i=0; ircount; i++) + if (p->fds[i].revents & (POLLIN | POLLHUP)) + p->ridx[k++] = i; + p->ridx[k] = -1; + + k = 0; + for (; ifds[i].revents & (POLLOUT | POLLHUP)) + p->widx[k++] = i - p->rcount; + p->widx[k] = -1; +#endif } From 42fb3c6f9d985042b1eca8f0be3f601e8c565b2e Mon Sep 17 00:00:00 2001 From: Chris Speciale Date: Fri, 22 Aug 2025 10:53:38 -0400 Subject: [PATCH 2/4] [fix] Socket: Surface close/error reliably in _hx_std_socket_poll_events Windows: pass/handle exceptfds in select() and on SOCKET_ERROR surface current read fds for cleanup. POSIX: include POLLERR|POLLNVAL in readiness. Ensures peer FIN/RST/error is observed by default once-per-frame events(0) loops without scanning or keepalives as it should. --- src/hx/libs/std/Socket.cpp | 132 ++++++++++++++++++++++--------------- 1 file changed, 80 insertions(+), 52 deletions(-) diff --git a/src/hx/libs/std/Socket.cpp b/src/hx/libs/std/Socket.cpp index 258dd754d..eafce592b 100644 --- a/src/hx/libs/std/Socket.cpp +++ b/src/hx/libs/std/Socket.cpp @@ -1226,71 +1226,99 @@ Array _hx_std_socket_poll_prepare( Dynamic pdata, Array rsocks **/ void _hx_std_socket_poll_events(Dynamic pdata, double timeout) { - polldata *p = val_poll(pdata); + polldata *p = val_poll(pdata); - // Clear at entry so any early return leaves a clean -1 sentinel. - p->ridx[0] = -1; - p->widx[0] = -1; + p->ridx[0] = -1; + p->widx[0] = -1; #ifdef NEKO_WINDOWS - memcpy(p->outr, p->fdr, FDSIZE(p->fdr->fd_count)); - memcpy(p->outw, p->fdw, FDSIZE(p->fdw->fd_count)); + memcpy(p->outr, p->fdr, FDSIZE(p->fdr->fd_count)); + memcpy(p->outw, p->fdw, FDSIZE(p->fdw->fd_count)); - struct timeval t; - struct timeval *tt = init_timeval(timeout, &t); + fd_set oute; + FD_ZERO(&oute); + if (p->fdr->fd_count) + { + for (u_int i = 0; i < p->fdr->fd_count; ++i) + FD_SET(p->fdr->fd_array[i], &oute); + } - hx::EnterGCFreeZone(); - if (select(0, p->fdr->fd_count ? p->outr : 0, - p->fdw->fd_count ? p->outw : 0, - NULL, tt) == SOCKET_ERROR) - { - hx::ExitGCFreeZone(); - // ridx/widx already set to -1 at entry - return; - } - hx::ExitGCFreeZone(); + struct timeval t; + struct timeval *tt = init_timeval(timeout, &t); - int k = 0; - for (int i=0; ifdr->fd_count; i++) - if (FD_ISSET(p->fdr->fd_array[i], p->outr)) - p->ridx[k++] = i; - p->ridx[k] = -1; + hx::EnterGCFreeZone(); + int sel = select( + 0, + p->fdr->fd_count ? p->outr : 0, + p->fdw->fd_count ? p->outw : 0, + p->fdr->fd_count ? &oute : 0, + tt); + if (sel == SOCKET_ERROR) + { + hx::ExitGCFreeZone(); - k = 0; - for (int i=0; ifdw->fd_count; i++) - if (FD_ISSET(p->fdw->fd_array[i], p->outw)) - p->widx[k++] = i; - p->widx[k] = -1; + int k = 0; + for (u_int i = 0; i < p->fdr->fd_count; ++i) + p->ridx[k++] = i; + p->ridx[k] = -1; + + return; + } + hx::ExitGCFreeZone(); + + int k = 0; + for (u_int i = 0; i < p->fdr->fd_count; ++i) + { + SOCKET fd = p->fdr->fd_array[i]; + if (FD_ISSET(fd, p->outr) || FD_ISSET(fd, &oute)) + p->ridx[k++] = i; + } + p->ridx[k] = -1; + + k = 0; + for (u_int i = 0; i < p->fdw->fd_count; ++i) + { + if (FD_ISSET(p->fdw->fd_array[i], p->outw)) + p->widx[k++] = i; + } + p->widx[k] = -1; #else - int tot = p->rcount + p->wcount; - hx::EnterGCFreeZone(); + int tot = p->rcount + p->wcount; + + hx::EnterGCFreeZone(); poll_events_again: - if (poll(p->fds, tot, (int)(timeout * 1000)) < 0) - { - if (errno == EINTR) goto poll_events_again; - hx::ExitGCFreeZone(); - // ridx/widx already set to -1 at entry - return; - } - hx::ExitGCFreeZone(); - - int k = 0; - int i = 0; - for (i=0; ircount; i++) - if (p->fds[i].revents & (POLLIN | POLLHUP)) - p->ridx[k++] = i; - p->ridx[k] = -1; - - k = 0; - for (; ifds[i].revents & (POLLOUT | POLLHUP)) - p->widx[k++] = i - p->rcount; - p->widx[k] = -1; + if (poll(p->fds, tot, (int)(timeout * 1000)) < 0) + { + if (errno == EINTR) + goto poll_events_again; + hx::ExitGCFreeZone(); + + return; + } + hx::ExitGCFreeZone(); + + int k = 0; + int i = 0; + + for (i = 0; i < p->rcount; ++i) + { + if (p->fds[i].revents & (POLLIN | POLLHUP | POLLERR | POLLNVAL)) + p->ridx[k++] = i; + } + p->ridx[k] = -1; + + k = 0; + + for (; i < tot; ++i) + { + if (p->fds[i].revents & (POLLOUT | POLLHUP | POLLERR | POLLNVAL)) + p->widx[k++] = i - p->rcount; + } + p->widx[k] = -1; #endif } - /** socket_poll : 'socket array -> 'poll -> timeout:float -> 'socket array From 0528ac9e69ea23f62bec6f7394e2233524d07591 Mon Sep 17 00:00:00 2001 From: Chris Speciale Date: Thu, 20 Aug 2026 08:32:26 -0400 Subject: [PATCH 3/4] [test] Socket: cover poll ready-index reporting The existing testPoll only checks that polling an empty set returns; its timing assertions are commented out and it ends in Assert.pass(). It never reads readIndexes or writeIndexes, which is where both bugs in this branch lived. testPollReportsReadableSocket covers the first: a listening socket with a connection waiting to be accepted has to be reported readable. _hx_std_socket_poll_events could return without reporting it, leaving the caller to conclude nothing had arrived. Measured before and after the fix on Windows: readIndexes[0] was -1 with a connection pending, where select() on the same socket at the same moment saw it. testPollClearsIndexesBetweenCalls covers the second: the ready lists are the caller's only result, so they have to describe the poll that just ran rather than the one before it. Polling with nothing registered makes select() fail, which is the path that used to return early and leave the previous call's indexes in place; the test asserts both lists come back as -1. Both are bounded retry loops rather than single polls, because readiness is not required to appear on the first pass -- the assertion is that it appears at all. --- test/std/Test.hx | 74 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/test/std/Test.hx b/test/std/Test.hx index 4923bfce3..c1eb1756e 100644 --- a/test/std/Test.hx +++ b/test/std/Test.hx @@ -502,6 +502,80 @@ class Test extends utest.Test } + // A listening socket with a connection waiting to be accepted is readable. + // _hx_std_socket_poll_events used to be able to return without reporting it, + // leaving the caller to conclude nothing had arrived. + function testPollReportsReadableSocket() + { + log("Test poll reports a readable socket"); + + var server = new Socket(); + server.bind(new Host("127.0.0.1"), 0); + server.listen(1); + + var client = new Socket(); + client.connect(new Host("127.0.0.1"), server.host().port); + + var poll = new Poll(4); + var readable = -1; + + // Polled in a loop because the connection is not required to be visible + // on the first pass; the point is that it becomes visible at all. + for(attempt in 0...20) + { + poll.prepare([server], []); + poll.events(0.05); + readable = poll.readIndexes[0]; + if (readable == 0) + break; + Sys.sleep(0.01); + } + + v('readIndexes[0] = ${readable}'); + Assert.equals(0, readable, "Listening socket with a pending connection was not reported readable"); + + client.close(); + server.close(); + } + + // The ready lists are the caller's only result, so they have to describe the + // poll that just ran and not the one before it. An early return that left + // them untouched reported the previous call's sockets as ready again. + function testPollClearsIndexesBetweenCalls() + { + log("Test poll clears stale indexes"); + + var server = new Socket(); + server.bind(new Host("127.0.0.1"), 0); + server.listen(1); + + var client = new Socket(); + client.connect(new Host("127.0.0.1"), server.host().port); + + var poll = new Poll(4); + + for(attempt in 0...20) + { + poll.prepare([server], []); + poll.events(0.05); + if (poll.readIndexes[0] == 0) + break; + Sys.sleep(0.01); + } + Assert.equals(0, poll.readIndexes[0], "Setup failed: socket never became readable"); + + // Nothing is registered now, so nothing can be ready. + poll.prepare([], []); + poll.events(0.01); + + v('after empty poll: readIndexes[0] = ${poll.readIndexes[0]}, writeIndexes[0] = ${poll.writeIndexes[0]}'); + Assert.equals(-1, poll.readIndexes[0], "Read index survived a poll with no sockets registered"); + Assert.equals(-1, poll.writeIndexes[0], "Write index survived a poll with no sockets registered"); + + client.close(); + server.close(); + } + function testUdpSocket() { log("Test UdpSocket"); From 7e88e2a85927402f51cab1f2ee2c2660abfea0d9 Mon Sep 17 00:00:00 2001 From: Chris Speciale Date: Thu, 20 Aug 2026 08:47:34 -0400 Subject: [PATCH 4/4] [fix] Socket: size fd_set copies by fd_array offset, not by fd_count FDSIZE was sizeof(u_int) + n * sizeof(SOCKET). fd_array does not begin at sizeof(u_int): on 64-bit Windows SOCKET is eight bytes, so fd_set carries four bytes of padding after fd_count and the array starts at offset 8. Every memcpy of an fd_set therefore copied four bytes too few, truncating the first handle by half and leaving the remainder as whatever malloc had returned. select() was consequently handed something that was not a socket and failed with WSAENOTSOCK on every call. Confirmed by instrumenting the error branch: [poll] SOCKET_ERROR wsa=10038 rcount=0 wcount=1 The read path hid it. On select() failure the error branch marks every registered read socket ready, so poll appeared to work for reads while reporting readiness it had not measured -- an idle connection with nothing to read came back readable, and a caller woke to read nothing for as long as it stayed idle. Writes have no such fallback, so they simply never reported at all. Sizing the copy by offsetof(fd_set, fd_array) fixes all three: writes are reported, reads are reported because select actually said so, and an idle socket is correctly not ready. 32-bit was unaffected, where SOCKET is four bytes and no padding exists. Two tests come with it. testPollReportsWritableSocket covers the case that never worked. testPollDoesNotReportIdleSocketAsReadable covers the false positive, and is the one that distinguishes a working select from the error path pretending: it fails whenever poll answers from the fallback rather than from the call. --- src/hx/libs/std/Socket.cpp | 7 ++++- test/std/Test.hx | 64 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/src/hx/libs/std/Socket.cpp b/src/hx/libs/std/Socket.cpp index 6d2850934..b22093538 100644 --- a/src/hx/libs/std/Socket.cpp +++ b/src/hx/libs/std/Socket.cpp @@ -25,7 +25,12 @@ typedef INT (WSAAPI *inet_pton_func)( INT Family, PCSTR pszAddrString, PVOID pAddrBuf); typedef PCSTR (WSAAPI *inet_ntop_func)(INT Family, PVOID pAddr, PSTR pStringBuf, size_t StringBufSize); -# define FDSIZE(n) (sizeof(u_int) + (n) * sizeof(SOCKET)) +// fd_array does not begin at sizeof(u_int). On 64-bit Windows SOCKET is +// eight bytes, so fd_set carries four bytes of padding after fd_count and +// the array starts at offset 8. Sizing the copy as 4 + n*8 truncated the +// first handle by half and left the rest as whatever malloc returned, so +// select() saw a handle that was not a socket and failed with WSAENOTSOCK. +# define FDSIZE(n) (offsetof(fd_set, fd_array) + (n) * sizeof(SOCKET)) # define SHUT_WR SD_SEND # define SHUT_RD SD_RECEIVE # define SHUT_RDWR SD_BOTH diff --git a/test/std/Test.hx b/test/std/Test.hx index c1eb1756e..ba40dd873 100644 --- a/test/std/Test.hx +++ b/test/std/Test.hx @@ -576,6 +576,70 @@ class Test extends utest.Test server.close(); } + // A connected socket with room in its send buffer is writable, and the + // write list is filled by the same pass that fills the read list. + function testPollReportsWritableSocket() + { + log("Test poll reports a writable socket"); + + var server = new Socket(); + server.bind(new Host("127.0.0.1"), 0); + server.listen(1); + + var client = new Socket(); + client.connect(new Host("127.0.0.1"), server.host().port); + var peer = server.accept(); + + var poll = new Poll(4); + var writable = -1; + + for(attempt in 0...20) + { + poll.prepare([], [client]); + poll.events(0.05); + writable = poll.writeIndexes[0]; + if (writable == 0) + break; + Sys.sleep(0.01); + } + + v('writeIndexes[0] = ${writable}'); + Assert.equals(0, writable, "Connected socket was not reported writable"); + + peer.close(); + client.close(); + server.close(); + } + + // Readiness has to mean readiness. When select() failed the error path + // marked every registered read socket ready, so an idle connection looked + // readable and a caller woke up to read nothing -- for as long as it stayed + // idle. + function testPollDoesNotReportIdleSocketAsReadable() + { + log("Test poll does not invent readability"); + + var server = new Socket(); + server.bind(new Host("127.0.0.1"), 0); + server.listen(1); + + var client = new Socket(); + client.connect(new Host("127.0.0.1"), server.host().port); + var peer = server.accept(); + + // Nothing has been sent either way, so neither end has anything to read. + var poll = new Poll(4); + poll.prepare([client], []); + poll.events(0.05); + + v('readIndexes[0] = ${poll.readIndexes[0]}'); + Assert.equals(-1, poll.readIndexes[0], "Idle socket with no pending data was reported readable"); + + peer.close(); + client.close(); + server.close(); + } + function testUdpSocket() { log("Test UdpSocket");