From 9d422c3ef0565724452a84c50d8911017f3c9187 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Sun, 19 Apr 2026 09:39:27 +0200 Subject: [PATCH] fix: drain connection on close per RFC 9112 section 9.6 Avoids TCP RST truncating the response tail when unread request data (body, pipelined bytes, trailers) sits in the kernel recv buffer at close time. Half-closes write, linger-reads (bounded 2s / 64 KB), then closes. --- gunicorn/util.py | 34 ++++++++++++++++++++++++++++++++++ gunicorn/workers/gthread.py | 17 ++++++++--------- gunicorn/workers/sync.py | 9 ++------- 3 files changed, 44 insertions(+), 16 deletions(-) diff --git a/gunicorn/util.py b/gunicorn/util.py index 11e285d5..d5730105 100644 --- a/gunicorn/util.py +++ b/gunicorn/util.py @@ -279,6 +279,40 @@ def close(sock): pass +def close_graceful(sock, timeout=2.0, max_drain=65536): + """Close a TCP socket following RFC 9112 section 9.6. + + Half-closes the write side to send FIN, then lingers on the read side + to drain the kernel recv buffer until the peer closes or a cap is hit, + then fully closes. This avoids the kernel sending RST (truncating the + last response segment) when unread request data remains in the buffer. + """ + try: + try: + sock.shutdown(socket.SHUT_WR) + except OSError: + return + deadline = time.monotonic() + timeout + drained = 0 + while drained < max_drain: + remaining = deadline - time.monotonic() + if remaining <= 0: + break + try: + sock.settimeout(remaining) + data = sock.recv(4096) + except (socket.timeout, OSError): + break + if not data: + break + drained += len(data) + finally: + try: + sock.close() + except OSError: + pass + + try: from os import closerange except ImportError: diff --git a/gunicorn/workers/gthread.py b/gunicorn/workers/gthread.py index 490d1ad0..cd5f86f5 100644 --- a/gunicorn/workers/gthread.py +++ b/gunicorn/workers/gthread.py @@ -15,7 +15,6 @@ import errno import os import queue import selectors -import socket import ssl import sys import time @@ -121,8 +120,12 @@ class TConn: finally: sel.close() - def close(self): - util.close(self.sock) + def close(self, graceful=False): + if graceful: + self.sock.setblocking(True) + util.close_graceful(self.sock) + else: + util.close(self.sock) class PollableMethodQueue: @@ -435,7 +438,7 @@ class ThreadWorker(base.Worker): partial(self.on_client_socket_readable, conn)) else: self.nr_conns -= 1 - conn.close() + conn.close(graceful=True) except Exception: self.nr_conns -= 1 conn.close() @@ -693,11 +696,7 @@ class ThreadWorker(base.Worker): # If the requests have already been sent, we should close the # connection to indicate the error. self.log.exception("Error handling request") - try: - conn.sock.shutdown(socket.SHUT_RDWR) - conn.sock.close() - except OSError: - pass + util.close_graceful(conn.sock) raise StopIteration() raise finally: diff --git a/gunicorn/workers/sync.py b/gunicorn/workers/sync.py index 763f8652..dc731060 100644 --- a/gunicorn/workers/sync.py +++ b/gunicorn/workers/sync.py @@ -7,7 +7,6 @@ from datetime import datetime import errno import os import select -import socket import ssl import sys @@ -164,7 +163,7 @@ class SyncWorker(base.Worker): except BaseException as e: self.handle_error(req, client, addr, e) finally: - util.close(client) + util.close_graceful(client) def handle_request(self, listener, req, client, addr): environ = {} @@ -203,11 +202,7 @@ class SyncWorker(base.Worker): # If the requests have already been sent, we should close the # connection to indicate the error. self.log.exception("Error handling request") - try: - client.shutdown(socket.SHUT_RDWR) - client.close() - except OSError: - pass + util.close_graceful(client) raise StopIteration() raise finally: