commit 2ef6ced4187b74d5bb9fb5a2d2403af069bdaea9
| author | 斟酌 鵬兄 <tgckpg@gmail.com> |
| date | 2026-06-06T10:21:06Z |
| subject | Prevent client holds conn for too long after close |
commit 2ef6ced4187b74d5bb9fb5a2d2403af069bdaea9
Author: 斟酌 鵬兄 <tgckpg@gmail.com>
Date: 2026-06-06T10:21:06Z
Prevent client holds conn for too long after close
---
devtools/hold_clients.py | 80 +++++++++++++++++++++++++++++++++++++++++++++++
docs/tinyproxy.conf.5.scd | 3 +-
src/route.h | 2 ++
src/stream_conn.c | 25 +++++++++++++--
4 files changed, 107 insertions(+), 3 deletions(-)
diff --git a/devtools/hold_clients.py b/devtools/hold_clients.py
new file mode 100755
index 0000000..e28303f
--- /dev/null
+++ b/devtools/hold_clients.py
@@ -0,0 +1,80 @@
+#!/usr/bin/env python3
+import argparse
+import asyncio
+import resource
+import socket
+import time
+
+
+REQ = b"GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"
+
+
+async def one_client(i: int, host: str, port: int, hold: float, read_response: bool) -> None:
+ reader, writer = await asyncio.open_connection(host, port)
+
+ writer.write(REQ)
+ await writer.drain()
+
+ if read_response:
+ # Read until server half-closes its write side.
+ # Important: we do NOT close our side afterward.
+ try:
+ await asyncio.wait_for(reader.read(-1), timeout=10.0)
+ except asyncio.TimeoutError:
+ pass
+
+ if i % 1000 == 0:
+ print(f"opened/holding {i}")
+
+ # Hold socket open. No writer.close(), no EOF, no RST.
+ await asyncio.sleep(hold)
+
+
+async def main() -> None:
+ ap = argparse.ArgumentParser()
+ ap.add_argument("--host", default="127.0.0.1")
+ ap.add_argument("--port", type=int, default=12800)
+ ap.add_argument("-n", "--connections", type=int, default=10000)
+ ap.add_argument("-c", "--concurrency", type=int, default=1000)
+ ap.add_argument("--hold", type=float, default=300.0)
+ ap.add_argument("--no-read-response", action="store_true")
+ args = ap.parse_args()
+
+ soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
+ print(f"nofile soft={soft} hard={hard}")
+ print(
+ f"target={args.host}:{args.port} "
+ f"connections={args.connections} concurrency={args.concurrency} hold={args.hold}s"
+ )
+
+ sem = asyncio.Semaphore(args.concurrency)
+ started = time.monotonic()
+ created = 0
+
+ async def runner(i: int) -> None:
+ nonlocal created
+ async with sem:
+ created += 1
+ await one_client(
+ i,
+ args.host,
+ args.port,
+ args.hold,
+ read_response=not args.no_read_response,
+ )
+
+ tasks = [asyncio.create_task(runner(i)) for i in range(1, args.connections + 1)]
+
+ while True:
+ done = sum(1 for t in tasks if t.done())
+ elapsed = time.monotonic() - started
+ print(f"{elapsed:.1f}s created={created} done={done} holding~={created - done}")
+ if done == len(tasks):
+ break
+ await asyncio.sleep(1.0)
+
+ await asyncio.gather(*tasks)
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/docs/tinyproxy.conf.5.scd b/docs/tinyproxy.conf.5.scd
index 24b44d9..7d0c36a 100644
--- a/docs/tinyproxy.conf.5.scd
+++ b/docs/tinyproxy.conf.5.scd
@@ -79,7 +79,8 @@ builtin close
- UDP: this is equivalent to discard/drop because UDP has no connection to close.
buitin http_ok
- Returns "HTTP/1.1 200 OK\\r\\nContent-Length: 2\\r\\nConnection: close\\r\\n\\r\\nOK" then close.
+ Returns "HTTP/1.1 200 OK\\r\\nContent-Length: 2\\r\\nConnection: close\\r\\n\\r\\nOK".
+ Waits for client to close the connection.
Useful for probing
diff --git a/src/route.h b/src/route.h
index 454796f..e75b284 100644
--- a/src/route.h
+++ b/src/route.h
@@ -14,6 +14,8 @@
#define BEV_READ_HIGH_WATER (256 * 1024)
#define BEV_WRITE_RESUME_WATER (128 * 1024)
+#define CLOSE_WAIT_TIMEOUT_SEC 2
+
#define UDP_MAX_PACKET 65535
enum broadcast_reply_mode {
diff --git a/src/stream_conn.c b/src/stream_conn.c
index 43bdd20..22be8fb 100644
--- a/src/stream_conn.c
+++ b/src/stream_conn.c
@@ -100,6 +100,19 @@ static void drain_client_then_close(conn_t *conn)
}
}
+/*
+ * Finish the server/client response side without aborting the TCP connection.
+ *
+ * An empty libevent output buffer only means libevent handed the bytes to the
+ * kernel. It does not prove the peer application has read the response yet.
+ * Under high connection churn, immediately freeing/closing the socket after
+ * output drain can still be observed by strict clients as a TCP reset.
+ *
+ * Half-close the write side to signal that no more bytes will be sent, then
+ * keep EV_READ enabled so the peer can close, reset, or hit the idle timeout.
+ *
+ * This is TCP shutdown robustness, not an HTTP protocol requirement.
+ */
void finish_client_write(conn_t *conn)
{
evutil_socket_t fd = bufferevent_getfd(conn->client);
@@ -115,10 +128,18 @@ void finish_client_write(conn_t *conn)
bufferevent_disable(conn->client, EV_WRITE);
/*
- * Keep EV_READ enabled so we can observe client EOF instead of
- * closing with unread data and causing RST on some platforms.
+ * Keep reading briefly after half-closing the write side so the peer can
+ * close cleanly. Do not wait for the full idle timeout here; otherwise a
+ * client that never sends EOF/RST can pin this connection too long.
*/
bufferevent_enable(conn->client, EV_READ);
+
+ struct timeval close_wait_timeout = {
+ .tv_sec = CLOSE_WAIT_TIMEOUT_SEC,
+ .tv_usec = 0,
+ };
+
+ bufferevent_set_timeouts(conn->client, &close_wait_timeout, NULL);
}
void stream_client_event_cb(struct bufferevent *bev, short events, void *arg)