penguin/tinyproxy

An L4 proxy designed to act as a tiny transparent shim

commit 01644820644afe572e2a20baa7f3ad9c7f301e1a

author斟酌 鵬兄 <tgckpg@gmail.com>
date2026-06-18T12:55:35Z
subjectMoved timeout logs into conn_idle_timeout_cb
commit 01644820644afe572e2a20baa7f3ad9c7f301e1a
Author: 斟酌 鵬兄 <tgckpg@gmail.com>
Date:   2026-06-18T12:55:35Z

    Moved timeout logs into conn_idle_timeout_cb
---
 src/stream_conn.c              |  68 +++++++++++++++-----------
 tests/test_tcp_idle_timeout.py | 106 +++++++++++++++++++++++++++++++++++++++--
 2 files changed, 144 insertions(+), 30 deletions(-)

diff --git a/src/stream_conn.c b/src/stream_conn.c
index 1271c1b..d705d6c 100644
--- a/src/stream_conn.c
+++ b/src/stream_conn.c
@@ -17,6 +17,8 @@
 #include <event2/bufferevent.h>
 #include <event2/buffer.h>
 
+#define SOCKADDR_STRLEN 128
+
 void free_conn(conn_t *conn) {
 	if (conn == NULL) {
 		return;
@@ -70,6 +72,15 @@ static void schedule_conn_idle_timer(conn_t *conn)
 	evtimer_add(conn->idle_ev, &tv);
 }
 
+static size_t bev_output_len(struct bufferevent *bev)
+{
+	if (bev == NULL) {
+		return 0;
+	}
+
+	return evbuffer_get_length(bufferevent_get_output(bev));
+}
+
 static void conn_idle_timeout_cb(evutil_socket_t fd, short events, void *arg)
 {
 	(void)events;
@@ -87,9 +98,34 @@ static void conn_idle_timeout_cb(evutil_socket_t fd, short events, void *arg)
 		return;
 	}
 
-	LOG_DEBUG("stream connection idle timed out",
-		"listen", _LOGV_ENDPOINT(&conn->route->listen),
-		"upstream", _LOGV_ENDPOINT(&conn->route->upstream),
+	const struct route *r = conn->route;
+
+	char peer_buf[SOCKADDR_STRLEN];
+
+	size_t client_output = bev_output_len(conn->client);
+	size_t upstream_output = bev_output_len(conn->upstream);
+
+	if (client_output == 0 && upstream_output == 0) {
+		LOG_DEBUG("stream idle timed out",
+			"listen", _LOGV_ENDPOINT(&r->listen),
+			"upstream", _LOGV_ENDPOINT(&r->upstream),
+			"client_addr", _LOGV_SOCKADDR(&conn->peer_addr, conn->peer_addr_len,
+				peer_buf, sizeof(peer_buf)),
+			"client_sni", _LOGV(r->opts.sni_sniff ? stream_sniff_log_sni(&conn->sniff) : "")
+		);
+
+		free_conn(conn);
+		return;
+	}
+
+	LOG_WARN("stream I/O stalled timed out",
+		"client_output", _LOGV(client_output),
+		"upstream_output", _LOGV(upstream_output),
+		"listen", _LOGV_ENDPOINT(&r->listen),
+		"upstream", _LOGV_ENDPOINT(&r->upstream),
+		"client_addr", _LOGV_SOCKADDR(&conn->peer_addr, conn->peer_addr_len,
+			peer_buf, sizeof(peer_buf)),
+		"client_sni", _LOGV(r->opts.sni_sniff ? stream_sniff_log_sni(&conn->sniff) : "")
 	);
 
 	free_conn(conn);
@@ -132,15 +168,6 @@ static int set_socket_keepalive(evutil_socket_t fd, const struct route *r)
 	return 0;
 }
 
-static size_t bev_output_len(struct bufferevent *bev)
-{
-	if (bev == NULL) {
-		return 0;
-	}
-
-	return evbuffer_get_length(bufferevent_get_output(bev));
-}
-
 static bool client_has_pending_output(conn_t *conn)
 {
 	return conn->client != NULL && bev_output_len(conn->client) > 0;
@@ -282,7 +309,7 @@ void stream_upstream_event_cb(struct bufferevent *bev, short events, void *arg)
 	conn_t *conn = arg;
 	const struct route *r = conn->route;
 
-	char peer_buf[128];
+	char peer_buf[SOCKADDR_STRLEN];
 
 	if (events & BEV_EVENT_CONNECTED) {
 		conn->upstream_connected = true;
@@ -306,20 +333,7 @@ void stream_upstream_event_cb(struct bufferevent *bev, short events, void *arg)
 			goto out_free;
 		}
 
-		if (!client_has_pending_output(conn) && bev_output_len(conn->upstream) == 0) {
-			LOG_DEBUG("stream idle timed out",
-				"listen", _LOGV_ENDPOINT(&r->listen),
-				"upstream", _LOGV_ENDPOINT(&r->upstream),
-				"client_addr", _LOGV_SOCKADDR(&conn->peer_addr, conn->peer_addr_len,
-					peer_buf, sizeof(peer_buf)),
-				"client_sni", _LOGV(r->opts.sni_sniff ? stream_sniff_log_sni(&conn->sniff) : "")
-			);
-			goto out_free;
-		}
-
-		LOG_WARN("stream I/O stalled timed out",
-			"client_output", _LOGV(bev_output_len(conn->client)),
-			"upstream_output", _LOGV(bev_output_len(conn->upstream)),
+		LOG_ERROR("unreachable code reached at BEV_EVENT_TIMEOUT",
 			"listen", _LOGV_ENDPOINT(&r->listen),
 			"upstream", _LOGV_ENDPOINT(&r->upstream),
 			"client_addr", _LOGV_SOCKADDR(&conn->peer_addr, conn->peer_addr_len,
diff --git a/tests/test_tcp_idle_timeout.py b/tests/test_tcp_idle_timeout.py
index d875270..12c121e 100644
--- a/tests/test_tcp_idle_timeout.py
+++ b/tests/test_tcp_idle_timeout.py
@@ -58,6 +58,29 @@ async def delayed_response_upload_handler(
 		writer.close()
 		await writer.wait_closed()
 
+async def stalled_upload_handler(
+	reader: asyncio.StreamReader,
+	writer: asyncio.StreamWriter,
+) -> None:
+	try:
+		try:
+			await reader.readuntil(b"\r\n\r\n")
+		except asyncio.IncompleteReadError as e:
+			# Ignore readiness/preflight connections from the test harness.
+			if not e.partial:
+				return
+			raise AssertionError(f"incomplete request headers: {e.partial!r}") from e
+
+		# Read a little bit so the request is definitely established,
+		# then stop consuming the upload body. This should eventually
+		# backpressure tinyproxy and trigger the stalled I/O timeout.
+		await reader.readexactly(64 * 1024)
+
+		await asyncio.sleep(10.0)
+	finally:
+		writer.close()
+		await writer.wait_closed()
+
 async def echo_handler(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
 	try:
 		while True:
@@ -71,15 +94,13 @@ async def echo_handler(reader: asyncio.StreamReader, writer: asyncio.StreamWrite
 		writer.close()
 		await writer.wait_closed()
 
-
 async def close_writer(writer: asyncio.StreamWriter) -> None:
 	try:
 		writer.close()
 		await writer.wait_closed()
-	except ConnectionResetError:
+	except OSError:
 		pass
 
-
 async def read_one_or_eof(reader: asyncio.StreamReader, timeout: float = 3.0) -> bytes:
 	try:
 		return await asyncio.wait_for(reader.read(1), timeout=timeout)
@@ -259,8 +280,87 @@ async def test_tcp_idle_timeout_keeps_long_one_way_upload_open() -> None:
 	finally:
 		await backend_server.close()
 
+async def test_tcp_stalled_timeout_should_close() -> None:
+	proxy_bin = os.environ.get("TINYPROXY_BIN")
+	if not proxy_bin:
+		raise SkipTest("TINYPROXY_BIN is not set")
+
+	conf_text = (
+		f"listen"
+		f" tcp {LISTEN_HOST}:{PROXY_PORT}"
+		f" tcp {LISTEN_HOST}:{BACKEND_PORT}"
+		f" idle_timeout=2\n"
+	)
+
+	backend_server = await start_tracked_stream_server(
+		stalled_upload_handler,
+		LISTEN_HOST,
+		BACKEND_PORT,
+		backlog=128,
+	)
+
+	try:
+		async with run_tinyproxy_with_conf(
+			proxy_bin=proxy_bin,
+			conf_text=conf_text,
+			listen_host=LISTEN_HOST,
+			listen_port=PROXY_PORT,
+			proto="tcp",
+		):
+			reader, writer = await asyncio.open_connection(LISTEN_HOST, PROXY_PORT)
+
+			try:
+				body_size = 512 * 1024 * 1024
+				chunk_size = 1024 * 1024
+
+				req = (
+					b"PUT /v2/test/blobs/uploads/stalled?digest=sha256:deadbeef HTTP/1.1\r\n"
+					b"Host: fake-registry\r\n"
+					b"User-Agent: test-buildkit/stalled-repro\r\n"
+					b"Content-Type: application/octet-stream\r\n"
+					+ f"Content-Length: {body_size}\r\n".encode("ascii")
+					+ b"Connection: close\r\n"
+					+ b"\r\n"
+				)
+
+				writer.write(req)
+				await writer.drain()
+
+				chunk = b"x" * chunk_size
+				sent = 0
+				closed = False
+
+				deadline = asyncio.get_running_loop().time() + 8.0
+
+				while asyncio.get_running_loop().time() < deadline:
+					try:
+						writer.write(chunk)
+						await asyncio.wait_for(writer.drain(), timeout=3.0)
+						sent += len(chunk)
+					except (
+						ConnectionResetError,
+						BrokenPipeError,
+						asyncio.TimeoutError,
+					):
+						closed = True
+						break
+
+				if not closed:
+					got = await read_one_or_eof(reader, timeout=1.0)
+					closed = got == b""
+
+				assert closed, (
+					f"expected stalled upload to be closed by idle timeout; "
+					f"sent={sent}"
+				)
+			finally:
+				await close_writer(writer)
+	finally:
+		await backend_server.close()
+
 TESTS = [
 	("test_tcp_idle_timeout_closes_idle_connection", test_tcp_idle_timeout_closes_idle_connection),
 	("test_tcp_idle_timeout_keeps_active_connection_open", test_tcp_idle_timeout_keeps_active_connection_open),
 	("test_tcp_idle_timeout_keeps_long_one_way_upload_open", test_tcp_idle_timeout_keeps_long_one_way_upload_open),
+	("test_tcp_stalled_timeout_should_close", test_tcp_stalled_timeout_should_close),
 ]