penguin/tinyproxy

An L4 proxy designed to act as a tiny transparent shim

commit 2ef6ced4187b74d5bb9fb5a2d2403af069bdaea9

author斟酌 鵬兄 <tgckpg@gmail.com>
date2026-06-06T10:21:06Z
subjectPrevent 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)