fix(websocket): treat EAGAIN as "no data yet", not as a disconnect, in the socket transport - #549
Open
freitasjca wants to merge 1 commit into
Open
Conversation
This was referenced Aug 21, 2026
Closed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
WebSocket receive does not work on the epoll provider. The handshake
succeeds and the client sees
101 Switching Protocols, so the connection looksestablished — but nothing the client sends is ever delivered, and the server
writes a stray HTTP response onto the upgraded socket.
THorseWebSocketSocketTransport.Readtreats any non-positiverecvresultas a closed connection. On a non-blocking socket,
recvreturns-1withEAGAIN/EWOULDBLOCKto mean "no data available right now" — the normalstate of an idle WebSocket peer, not a disconnect.
Horse.Provider.Epoll.pas:3101sets every accepted client socket toO_NONBLOCK, as epoll requires. So the firstReadafter the upgrade marks theconnection dead and the upgrader's read loop breaks on its first iteration,
about a millisecond after the
101.The defect
src/Horse.Provider.Socket.WebSocket.pasEAGAIN,EWOULDBLOCKandEINTRappear nowhere in the unit. Only twooutcomes actually end a connection:
recv = 0— orderly shutdown by the peerrecv < 0with an errno that is notEAGAIN/EWOULDBLOCK/EINTREverything else means "wait and retry".
Knock-on effect
Upgradereturns as soon as the loop breaks, the route handler returns, and theHTTP pipeline resumes — writing a full response onto a socket already handed to
WebSocket:
A client mid-frame receives HTTP text where a frame should be. This contradicts
.agents/AGENTS.md: "O ciclo de vida da requisição HTTP encerra-se com oupgrade."
It disappears with this fix, since the loop no longer exits early. A separate
guard — refusing to write to a socket after upgrade even if the handler returns
normally — may still be worth adding, but is out of scope here.
The change
Readnow distinguishes the three cases and parks inselect()while idle, soa quiet peer costs no CPU:
Two private helpers are added.
WouldBlockreadsfpgeterrno/WSAGetLastError/errno;WaitReadableis aselect()with a 250 ms tick(a timeout is not a disconnect — the loop simply retries, so the tick only
bounds how often
FIsClosedis re-checked).Both carry the full four-way guard: FPC/Delphi × Windows/POSIX.
usesgainsBaseUnix(FPC/POSIX),WinSock2(FPC/Windows), andPosix.Errno/Posix.SysSelect/Posix.SysTime(Delphi/POSIX); Delphi/Windowsalready had
Winapi.WinSock2.One note on the Windows branch:
Winapi.WinSock2exposesFD_SETas a type,not the macro-style procedure, so
fd_count/fd_arrayare filled in directly.No signature or behavioural change on a blocking socket —
recvwaits andreturns
> 0, so neither helper is ever reached.Testing
A minimal Horse WebSocket echo server (echoing from inside
OnMessage) drivenby a dependency-free RFC 6455 client sending one masked
"ola"text frame. Theecho can only appear if the callback runs. The route handler is instrumented
around
Res.UpgradeToWebSocket, which does not return until the read loop ends— so the elapsed time distinguishes "loop never waited" from "loop waited".
Upgradereturns in 1 ms, raw HTTP on the socketScope is epoll only. IOCP imports the same transport unit and was the
obvious candidate for the same defect, but leaves its accepted sockets blocking
— there is no
ioctlsocket,FIONBIOorWSAEventSelectanywhere in thatprovider, because overlapped I/O does not need non-blocking sockets. Rows 3–4
verify both that IOCP never had the defect and that this change is inert there.
Indy uses its own transport and is untouched.
Notes
The existing
TestWebSocketDataExchangeintests/src/tests/Tests.Integration.WebSocket.pascovers this path, but does notrun under either provider that would catch it — happy to follow up with a
regression test if you would like one, and to hear where you would prefer it to
live.