From e4b6b0ece83ffa0dfbdc82a5db12fb1a3d229185 Mon Sep 17 00:00:00 2001 From: nasr <156965421+div0rce@users.noreply.github.com> Date: Wed, 24 Jun 2026 21:18:23 -0400 Subject: [PATCH] fix(gateway): threaded acceptor survives fd exhaustion instead of tearing down MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-2 follow-up to #140: that PR hardened the epoll transport's fd-exhaustion handling (disarm the listener, keep serving, re-arm when a descriptor frees) but left the portable threaded TcpServer asymmetric. transient_accept_errno() deliberately omits EMFILE/ENFILE, so when the process hits RLIMIT_NOFILE (reachable in the default config — max_active_connections defaults to 0, i.e. uncapped, so the server's own slow/idle clients can exhaust descriptors), accept_retry() returned -1, serve_listen_socket() broke out, and the acceptor stopped permanently even though existing connections were fine and the condition is transient (it clears the moment any client disconnects and frees an fd). The epoll path survives the identical condition. Fix: accept_retry() now detects EMFILE/ENFILE/ENOBUFS/ENOMEM separately and backs off briefly (10ms) before retrying instead of returning fatal. It cannot be added to the immediate-retry transient set — the pending connection stays in the backlog, so an instant retry would just re-fail and busy-spin at 100% CPU. The back-off lets worker threads finish and close their sockets, freeing descriptors; the acceptor auto-resumes, symmetric with the epoll listener pause/resume. make check 270/270, make tsan 20/20, CodeScene delta clean. Co-Authored-By: Claude Opus 4.8 --- src/gateway/tcp_server.cpp | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/gateway/tcp_server.cpp b/src/gateway/tcp_server.cpp index 96afc2c..359b12d 100644 --- a/src/gateway/tcp_server.cpp +++ b/src/gateway/tcp_server.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -105,14 +106,28 @@ bool transient_accept_errno() noexcept { return std::find(std::begin(kTransient), std::end(kTransient), errno) != std::end(kTransient); } -// accept() one connection, retrying transient errors. Returns a connected fd, or -1 on a genuinely -// fatal listener error (which should stop the accept loop). +// Descriptor/resource exhaustion: out of fds (process or system-wide) or kernel buffers. Like the +// epoll path, this must not tear the server down — the condition clears as clients disconnect and +// their worker threads close their sockets. It cannot be added to transient_accept_errno() (an +// immediate retry would just re-fail, since the pending connection stays in the backlog), so the +// acceptor backs off briefly between retries instead of busy-spinning. +bool fd_exhausted_errno() noexcept { + return errno == EMFILE || errno == ENFILE || errno == ENOBUFS || errno == ENOMEM; +} + +// accept() one connection. Retries transient per-connection errors immediately and resource +// exhaustion after a short back-off. Returns a connected fd, or -1 only on a genuinely fatal +// listener error (e.g. EBADF/EINVAL) that should stop the accept loop. int accept_retry(int listen_fd) { for (;;) { const int conn = ::accept(listen_fd, nullptr, nullptr); if (conn >= 0) { return conn; } + if (fd_exhausted_errno()) { + std::this_thread::sleep_for(std::chrono::milliseconds(10)); // wait for an fd to free + continue; + } if (!transient_accept_errno()) { return -1; }