penguin/tinyproxy

An L4 proxy designed to act as a tiny transparent shim

commit 9ffb37cdb97adb31b9dbee3579f450d722859e6b

author斟酌 鵬兄 <tgckpg@gmail.com>
date2026-06-18T11:56:47Z
subjectLOG_DEBUG since tcp_keepalive is a thing
commit 9ffb37cdb97adb31b9dbee3579f450d722859e6b
Author: 斟酌 鵬兄 <tgckpg@gmail.com>
Date:   2026-06-18T11:56:47Z

    LOG_DEBUG since tcp_keepalive is a thing
---
 src/stream_conn.c              |  10 ++--
 tests/test_tcp_idle_timeout.py | 113 +++++++++++++++++++++++++++++++++++++++++
 2 files changed, 118 insertions(+), 5 deletions(-)

diff --git a/src/stream_conn.c b/src/stream_conn.c
index ff87b2d..1271c1b 100644
--- a/src/stream_conn.c
+++ b/src/stream_conn.c
@@ -23,9 +23,9 @@ void free_conn(conn_t *conn) {
 	}
 
 	if (conn->idle_ev) {
-        event_free(conn->idle_ev);
-        conn->idle_ev = NULL;
-    }
+		event_free(conn->idle_ev);
+		conn->idle_ev = NULL;
+	}
 
 	if (conn->client != NULL) {
 		bufferevent_free(conn->client);
@@ -87,9 +87,9 @@ static void conn_idle_timeout_cb(evutil_socket_t fd, short events, void *arg)
 		return;
 	}
 
-	LOG_WARN("stream connection idle timed out",
+	LOG_DEBUG("stream connection idle timed out",
 		"listen", _LOGV_ENDPOINT(&conn->route->listen),
-		"upstream", _LOGV_ENDPOINT(&conn->route->upstream)
+		"upstream", _LOGV_ENDPOINT(&conn->route->upstream),
 	);
 
 	free_conn(conn);
diff --git a/tests/test_tcp_idle_timeout.py b/tests/test_tcp_idle_timeout.py
index ce1a8ab..d875270 100644
--- a/tests/test_tcp_idle_timeout.py
+++ b/tests/test_tcp_idle_timeout.py
@@ -10,6 +10,53 @@ from .support import (
 	start_tracked_stream_server,
 )
 
+async def delayed_response_upload_handler(
+	reader: asyncio.StreamReader,
+	writer: asyncio.StreamWriter,
+) -> None:
+	try:
+		try:
+			header = await reader.readuntil(b"\r\n\r\n")
+		except asyncio.IncompleteReadError as e:
+			# The test harness/proxy readiness check may open a TCP
+			# connection and close it without sending any request bytes.
+			# Ignore that. A partial HTTP request is still a real failure.
+			if not e.partial:
+				return
+			raise AssertionError(f"incomplete request headers: {e.partial!r}") from e
+
+		content_length = None
+		for line in header.decode("latin1", errors="replace").split("\r\n"):
+			if line.lower().startswith("content-length:"):
+				content_length = int(line.split(":", 1)[1].strip())
+				break
+
+		assert content_length is not None, "missing Content-Length"
+
+		received = 0
+		while received < content_length:
+			chunk = await reader.read(min(65536, content_length - received))
+			if not chunk:
+				break
+			received += len(chunk)
+
+		assert received == content_length, (
+			f"upload body truncated: received={received} expected={content_length}"
+		)
+
+		body = b"ok\n"
+		writer.write(
+			b"HTTP/1.1 200 OK\r\n"
+			+ f"Content-Length: {len(body)}\r\n".encode("ascii")
+			+ b"Connection: close\r\n"
+			+ b"\r\n"
+			+ body
+		)
+		await writer.drain()
+
+	finally:
+		writer.close()
+		await writer.wait_closed()
 
 async def echo_handler(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
 	try:
@@ -146,8 +193,74 @@ async def test_tcp_idle_timeout_keeps_active_connection_open() -> None:
 	finally:
 		await backend_server.close()
 
+async def test_tcp_idle_timeout_keeps_long_one_way_upload_open() -> 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(
+		delayed_response_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 = 6 * 1024 * 1024
+				chunk_size = 64 * 1024
+				delay = 0.05
+
+				req = (
+					b"PUT /v2/test/blobs/uploads/fake?digest=sha256:deadbeef HTTP/1.1\r\n"
+					b"Host: fake-registry\r\n"
+					b"User-Agent: test-buildkit/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
+
+				while sent < body_size:
+					n = min(chunk_size, body_size - sent)
+					writer.write(chunk[:n])
+					await writer.drain()
+					sent += n
+					await asyncio.sleep(delay)
+
+				resp = await asyncio.wait_for(reader.read(), timeout=5.0)
+
+				assert b"HTTP/1.1 200 OK" in resp, resp
+				assert resp.endswith(b"\r\n\r\nok\n"), resp
+			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),
 ]