Skip to content

Socket: treat read()/recv()==0 as EOF before consulting errno#224

Open
lhoward wants to merge 2 commits into
swhitty:mainfrom
PADL:lhoward/read-eof-errno-misclassification
Open

Socket: treat read()/recv()==0 as EOF before consulting errno#224
lhoward wants to merge 2 commits into
swhitty:mainfrom
PADL:lhoward/read-eof-errno-misclassification

Conversation

@lhoward

@lhoward lhoward commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

A read()/recvfrom()/recvmsg() return of 0 is an orderly EOF, not an error, and does not set errno. The error-classification guard checked errno == EWOULDBLOCK before count == 0:

guard count > 0 else {
    if errno == EWOULDBLOCK { throw SocketError.blocked }
    else if errnoSignalsDisconnected() || count == 0 { throw SocketError.disconnected }
    ...
}

So an EOF read whose errno was left as EWOULDBLOCK by an earlier would-block read on the same thread is misclassified as .blocked instead of .disconnected. The reader then re-suspends waiting for more data instead of closing.

Because errno is per-thread, this only manifests under concurrency (a would-block read on one connection contaminating an EOF read on another). The consequences:

  • The connection never closes — it lingers in CLOSE-WAIT (fd leak).
  • epoll keeps re-reporting the readable EOF socket, so the pool spins re-arming (EPOLL_CTL_ADD/DEL) and re-reading it (read()==0), pegging CPU.

Reproducible with a handful of concurrent half-closed keep-alive connections (shutdown(SHUT_WR), held open): the server pins a core and accumulates CLOSE-WAIT sockets. With the fix, CPU stays at 0 and no sockets leak.

Fix: check count == 0 first; errno is only meaningful after a -1 return. Applied to read, recvFrom, and recvMsg.

read()/recvfrom()/recvmsg() returning 0 is an orderly EOF, not an error,
and does not set errno. The error classification checked errno ==
EWOULDBLOCK before count == 0, so an EOF read whose errno was left as
EWOULDBLOCK by an earlier would-block read on the same thread was
misclassified as .blocked instead of .disconnected.

The reader then re-suspended waiting for more data instead of closing.
Under concurrency (errno is per-thread) this both leaked the connection
(CLOSE-WAIT, never closed) and, because epoll re-reports the readable
EOF socket, spun the pool re-arming and re-reading it, pegging CPU.

Check count == 0 first; errno is only meaningful after a -1 return.
@codecov

codecov Bot commented Jul 8, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 86.66667% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 92.91%. Comparing base (0a6f327) to head (a196c2e).

Files with missing lines Patch % Lines
FlyingSocks/Sources/Socket.swift 86.66% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #224      +/-   ##
==========================================
- Coverage   92.92%   92.91%   -0.01%     
==========================================
  Files          71       71              
  Lines        3719     3728       +9     
==========================================
+ Hits         3456     3464       +8     
- Misses        263      264       +1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

@lhoward

lhoward commented Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

FWIW this appears to be the final fix in my Claude-assisted to quest to eliminate FlyingFox-related CPU usage spikes and FD leaks on Linux.

lhoward added a commit to PADL/OpenSRP that referenced this pull request Jul 14, 2026
epoll_wait returns -1/EINTR when a signal interrupts it, which is not a
fatal condition. getNotifications() treated any non-positive return as a
failure and threw SocketError.makeFailed("epoll wait"), tearing down the
server ("epoll wait(4): Interrupted system call"). Return no events on
EINTR so the caller polls again.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Kkgk9vUQAZUTMUnbus1RR
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant