From 39fc7c15d905422c80704a8188ea76e8f6425175 Mon Sep 17 00:00:00 2001 From: Luke Howard Date: Wed, 8 Jul 2026 12:22:21 +1000 Subject: [PATCH 1/2] Socket: treat read()/recv()==0 as EOF before consulting errno 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. --- FlyingSocks/Sources/Socket.swift | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/FlyingSocks/Sources/Socket.swift b/FlyingSocks/Sources/Socket.swift index a72ef79..df39b11 100644 --- a/FlyingSocks/Sources/Socket.swift +++ b/FlyingSocks/Sources/Socket.swift @@ -257,9 +257,12 @@ public struct Socket: Sendable, Hashable { private func read(into buffer: UnsafeMutablePointer, length: Int) throws -> Int { let count = Socket.read(file.rawValue, buffer, length) guard count > 0 else { - if errno == EWOULDBLOCK { + // count == 0 is EOF; errno is only valid after a -1 return. + if count == 0 { + throw SocketError.disconnected + } else if errno == EWOULDBLOCK { throw SocketError.blocked - } else if errnoSignalsDisconnected() || count == 0 { + } else if errnoSignalsDisconnected() { throw SocketError.disconnected } else { throw SocketError.makeFailed("Read") @@ -292,9 +295,12 @@ public struct Socket: Sendable, Hashable { Socket.recvfrom(file.rawValue, buffer, length, 0, $0, &size) } guard count > 0 else { - if errno == EWOULDBLOCK { + // count == 0 is EOF; errno is only valid after a -1 return. + if count == 0 { + throw SocketError.disconnected + } else if errno == EWOULDBLOCK { throw SocketError.blocked - } else if errnoSignalsDisconnected() || count == 0 { + } else if errnoSignalsDisconnected() { throw SocketError.disconnected } else { throw SocketError.makeFailed("RecvFrom") @@ -357,9 +363,12 @@ public struct Socket: Sendable, Hashable { } guard count > 0 else { - if errno == EWOULDBLOCK || errno == EAGAIN { + // count == 0 is EOF; errno is only valid after a -1 return. + if count == 0 { + throw SocketError.disconnected + } else if errno == EWOULDBLOCK || errno == EAGAIN { throw SocketError.blocked - } else if errnoSignalsDisconnected() || count == 0 { + } else if errnoSignalsDisconnected() { throw SocketError.disconnected } else { throw SocketError.makeFailed("RecvMsg") From a196c2e5115914de450180945ef96dfc0fd234b8 Mon Sep 17 00:00:00 2001 From: Luke Howard Date: Tue, 14 Jul 2026 12:38:56 +1000 Subject: [PATCH 2/2] SocketPool+ePoll: retry epoll_wait on EINTR instead of failing 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) Claude-Session: https://claude.ai/code/session_017Kkgk9vUQAZUTMUnbus1RR --- FlyingSocks/Sources/SocketPool+ePoll.swift | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/FlyingSocks/Sources/SocketPool+ePoll.swift b/FlyingSocks/Sources/SocketPool+ePoll.swift index 7034e84..1a134ec 100644 --- a/FlyingSocks/Sources/SocketPool+ePoll.swift +++ b/FlyingSocks/Sources/SocketPool+ePoll.swift @@ -31,6 +31,13 @@ #if canImport(CSystemLinux) import CSystemLinux +#if canImport(Glibc) +import Glibc +#elseif canImport(Musl) +import Musl +#elseif canImport(Android) +import Android +#endif public extension AsyncSocketPool where Self == SocketPool { static func ePoll(triggering: ePoll.TriggerMode = .edge, maxEvents limit: Int = 20, logger: some Logging = .disabled) -> SocketPool { @@ -147,6 +154,11 @@ public struct ePoll: EventQueue { var events = Array(repeating: epoll_event(), count: eventsLimit) let status = CSystemLinux.epoll_wait(file.rawValue, &events, Int32(eventsLimit), -1) guard status > 0 else { + // EINTR (signal) is not a failure: report no events so the caller + // polls again rather than tearing down the server. + if status == -1 && errno == EINTR { + return [] + } throw SocketError.makeFailed("epoll wait") }