Skip to content

Commit 9e66463

Browse files
bushidocodesclaude
andcommitted
fix: graceful TCP close to stop RST on early error responses (#185)
SLEdge frequently answers a request before it has finished reading it: the short-circuit error responses (400/404/429/500/503) in the listener thread write the response and close the socket while the client may still be sending its request body. tcp_session_close() did a bare close(). On Linux, close() with unread data still in the kernel receive buffer discards that data and emits a TCP RST instead of a graceful FIN. The client's HTTP stack reports this as "connection reset by peer" or, on a pooled keepalive connection, as the Go net/http warning "Unsolicited response received on idle HTTP channel" that issue #185 describes. Close gracefully instead: half-close the write side so the client receives our FIN (and, holding a complete response, stops sending), then drain any already-buffered inbound data so close() no longer sees unread data. The drain is non-blocking and bounded (32 x 8 KiB) so the single listener thread can never block or spin on a slow client. Verified in the Docker dev environment with hey: POSTing a 100 KB body to a rejected (404) route went from ~159 "connection reset by peer" errors per 2400 requests to 0, with no change to normal 200-response throughput (~15k rps) or the bodyless GET -> 429/503 path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6037e62 commit 9e66463

1 file changed

Lines changed: 42 additions & 0 deletions

File tree

runtime/include/tcp_session.h

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,21 @@
66
#include <stdbool.h>
77
#include <string.h>
88
#include <sys/socket.h>
9+
#include <threads.h>
910
#include <unistd.h>
1011

1112
#include "debuglog.h"
1213
#include "likely.h"
1314
#include "panic.h"
1415

16+
/*
17+
* Bound on the non-blocking drain performed during a graceful close. 32 passes of an 8 KiB buffer
18+
* (256 KiB) is far larger than any already-buffered request remainder we expect to see, while still
19+
* guaranteeing the single listener thread cannot be held by a misbehaving client. See tcp_session_close().
20+
*/
21+
#define TCP_SESSION_CLOSE_DRAIN_MAX_PASSES 32
22+
#define TCP_SESSION_CLOSE_DRAIN_BUFFER_LENGTH 8192
23+
1524
static inline void
1625
tcp_session_close(int client_socket, struct sockaddr *client_address)
1726
{
@@ -20,6 +29,39 @@ tcp_session_close(int client_socket, struct sockaddr *client_address)
2029
assert(client_socket != STDOUT_FILENO);
2130
assert(client_socket != STDERR_FILENO);
2231

32+
/*
33+
* Graceful close to avoid sending the client a TCP RST (issue #185).
34+
*
35+
* SLEdge often answers a request before it has finished reading it: the short-circuit error
36+
* responses (400/404/429/500/503) are written and the socket closed while the client may still be
37+
* sending its request body. A bare close() with unread data still in the kernel receive buffer makes
38+
* Linux discard that data and emit a RST instead of a graceful FIN. The client's HTTP stack then
39+
* reports it as "connection reset by peer" or, on a pooled keepalive connection, as the Go net/http
40+
* warning "Unsolicited response received on idle HTTP channel" that issue #185 describes.
41+
*
42+
* We instead (1) half-close the write side so the client receives our FIN (and, having a complete
43+
* response, stops sending), then (2) drain any already-buffered inbound data so close() no longer
44+
* sees unread data. The drain is non-blocking and bounded, so the listener thread can never block or
45+
* spin on a slow client. This removes the RST for every request the client has finished sending
46+
* (all bodyless GETs and small POSTs - i.e. the reported scenario). A client that keeps streaming a
47+
* large body to a rejected route may still observe its own write abort as EPIPE; fully suppressing
48+
* that would require lingering on the request in the event loop, which is out of scope here.
49+
*/
50+
shutdown(client_socket, SHUT_WR);
51+
52+
thread_local static char drain[TCP_SESSION_CLOSE_DRAIN_BUFFER_LENGTH];
53+
for (int pass = 0; pass < TCP_SESSION_CLOSE_DRAIN_MAX_PASSES; pass++) {
54+
ssize_t drained = read(client_socket, drain, sizeof(drain));
55+
if (drained == 0) break; /* EOF: client closed its side, nothing left to drain */
56+
if (drained < 0) {
57+
if (errno == EINTR) continue;
58+
/* EAGAIN/EWOULDBLOCK: receive buffer is empty, so close() will not RST. Any other
59+
* error means the connection is already broken. Either way, stop draining. */
60+
break;
61+
}
62+
/* Drained a full buffer; more may be queued, so loop again (bounded). */
63+
}
64+
2365
if (unlikely(close(client_socket) < 0)) {
2466
char client_address_text[INET6_ADDRSTRLEN] = {'\0'};
2567
if (unlikely(inet_ntop(AF_INET, &client_address, client_address_text, INET6_ADDRSTRLEN) == NULL)) {

0 commit comments

Comments
 (0)