CVE-2026-117350 prompted me to look into getting rid of select() in favour of using poll() on all platforms.
The main advantages of doing this would be to get rid of our artificially low file descriptor limit, and to use the same (security-critical) logic on our less-tested platforms as on our main platform.
This also allows to get rid of some platform-specific quirks, such as Windows requiring that we register the exception set, therefore subscribing us to be notified of TCP urgent data and causing unwanted behaviour. But this point is in my opinion less salient, as the move would likely trade some platform-specific quirks for some others.
Work to switch over to poll() was started by @pstratem in #14336, which (thankfully) introduced Linux support. MacOS / BSD support was initially part of the PR, but out of an abundance of caution was then left out after @sdaftuar and @jamesob encountered issues in testing an earlier version of the code. Windows support was not included since WSAPoll was malfunctioning.
Windows has since fixed WSAPoll in May 2020 (Windows 10 version 2004). BSD always worked. And for macOS, the latest investigation i'm aware of is [that of @jamesob](/bitcoin-bitcoin/14336/#issuecomment-437384408) in #14336. As far as i can tell, the diagnostic that knote_fdfind ignores the difference in filter is incorrect, as kev->filter == kn->kn_filter is checked (link to same commit as James' comment). The misbehaviour seems to be instead caused by how the problematic iteration of the PR listed each socket multiple times, and macOS' poll() implementation collides them in the kqueue (i.e. {fd, POLLIN} followed by {fd, POLLERR|POLLHUP} loses the POLLIN) [^1]. I have verified this by running a reproducer on a macOS VM and having an LLM read the sources.
I have extensively tested a poll implementation, and ran probes, on macOS, Windows and BSD VMs. So i'm fairly confident poll() support isn't broken on any of these platforms (anymore). However...
The events reported by poll() sometimes vary depending on the platform, and this is a reason why this is an RFC issue and not a slam-dunk PR. If we are going to refactor the poll() logic to support the different events reported on each platform, we should aim for consistent WaitMany reporting across platforms. But what behaviour do we want?
I think there is two consistent positions:
- Fail optimistically. Poll events are only hints. On a read EOF, send whatever we have left before disconnecting. On a write error, read whatever was left in the buffer.
- Fail closed. Disconnect on any reported event that is not plain readiness, as well as on EOF or a read/write error.
The former avoids unnecessarily dropping a potentially useful message to/from a peer. The latter doesn't, but helps avoid getting tricked into reading when we shouldn't (CVE-2026-117350) or waking but not being able to make progress (#36688). Bitcoin Core does not strictly follow either, so we don't benefit from always reading/writing as much as possible, and have also exposed ourselves to the aforementioned bugs.
Our knee jerk reaction after the concerning vulnerability may be to just fail closed everywhere, but we should also consider that we now also use our socket implementation for the HTTP server, for which the opposite behaviour may be preferable (for instance it would make sense to respond to a client that sends a request and closes its side of the connection).
Thoughts?
To assist this discussion, here are tables from my probe comparing the events reported by select() today versus what poll() would report for each of FreeBSD / macOS / Windows. For select(): R/W/E means readfds/writefds/exceptfds comes back set. For poll(): IN/OUT/HUP/ERR are just the POLL suffixes.
FreeBSD
| Peer did | select() RecvEvent | poll() RecvEvent | select() SendEvent | poll() SendEvent | select() RecvEvent|SendEvent | poll() RecvEvent|SendEvent |
|---|---|---|---|---|---|---|
| idle | – | – | W | OUT | W | OUT |
| urgent byte | E | – | W | OUT | WE | OUT |
| FIN or graceful close | R | IN | W | OUT | RW | IN OUT |
| RST | R | IN HUP | W | HUP | RW | IN HUP |
| data | R | IN | W | OUT | RW | IN OUT |
| data + urgent byte | RE | IN | W | OUT | RWE | IN OUT |
| data + FIN or graceful close | R | IN | W | OUT | RW | IN OUT |
| data + RST | R | IN HUP | W | HUP | RW | IN HUP |
| failed connect | R | IN HUP | W | HUP | RW | IN HUP |
macOS
| Peer did | select() RecvEvent | poll() RecvEvent | select() SendEvent | poll() SendEvent | select() RecvEvent|SendEvent | poll() RecvEvent|SendEvent |
|---|---|---|---|---|---|---|
| idle | – | – | W | OUT | W | OUT |
| urgent byte | E | – | W | OUT | WE | OUT |
| FIN or graceful close | R | IN HUP | W | OUT | RW | IN HUP |
| RST | R | IN HUP | W | HUP | RW | IN HUP |
| data | R | IN | W | OUT | RW | IN OUT |
| data + urgent byte | RE | IN | W | OUT | RWE | IN OUT |
| data + FIN or graceful close | R | IN HUP | W | OUT | RW | IN HUP |
| data + RST | R | IN HUP | W | HUP | RW | IN HUP |
| failed connect | R | IN HUP | W | HUP | RW | IN HUP |
Windows
Note that on Windows POLLIN corresponds to RDNORM | RDBAND. So requesting POLLIN will ask to be woken up for both events, but checking for a reported POLLIN (reported_event & POLLIN) will match when either of the two is set.
| Peer did | select() RecvEvent | poll() RecvEvent | select() SendEvent | poll() SendEvent | select() RecvEvent|SendEvent | poll() RecvEvent|SendEvent |
|---|---|---|---|---|---|---|
| idle | – | – | W | OUT | W | OUT |
| idle; our send buffer full | – | – | – | – | – | – |
| urgent byte | E | IN | W | OUT | WE | IN OUT |
| urgent byte; our send buffer full | E | IN | – | – | E | IN |
| FIN or graceful close | R | HUP | W | OUT HUP | RW | OUT HUP |
| FIN; our send buffer full | R | HUP | – | HUP | R | HUP |
| RST | R | ERR HUP | W | OUT ERR HUP | RW | OUT ERR HUP |
| data | R | IN | W | OUT | RW | IN OUT |
| data; our send buffer full | R | IN | – | – | R | IN |
| data + urgent byte | RE | IN | W | OUT | RWE | IN OUT |
| data + urgent byte; our send buffer full | RE | IN | – | – | RE | IN |
| data + FIN or graceful close | R | IN HUP | W | OUT HUP | RW | IN OUT HUP |
| data + FIN; our send buffer full | R | IN HUP | – | HUP | R | IN HUP |
| data + RST | R | ERR HUP | W | OUT ERR HUP | RW | OUT ERR HUP |
| failed connect | E | – | – | OUT ERR HUP | E | OUT ERR HUP |
[^1]: For more details, here is the call chain that would lead to the observed issue:
- Each pollfd becomes a kevent tagged with its array index, and POLLHUP counts as read interest: sys_generic.c#L1745-L1758
- The lookup matches on (kqueue, fd, filter), so a second read registration finds the existing filter: kern_event.c#L6808-L6811
- Re-registering overwrites the filter’s tag with the new entry’s index: kern_event.c#L4249-L4253
- The result goes to the tagged entry, masked by that entry’s own events: sys_generic.c#L1855-L1878