penguin/tinyproxy

An L4 proxy designed to act as a tiny transparent shim

commit 92c2285c3ab614414b1a14db99e83fbc4fb571f7

author斟酌 鵬兄 <tgckpg@gmail.com>
date2026-05-26T20:09:08Z
subjectidle timeout for tcp_route
commit 92c2285c3ab614414b1a14db99e83fbc4fb571f7
Author: 斟酌 鵬兄 <tgckpg@gmail.com>
Date:   2026-05-26T20:09:08Z

    idle timeout for tcp_route
---
 TODO.md                        |   3 -
 tcp_route.c                    |  20 +++++-
 tests/run_tests.py             |   2 +
 tests/test_tcp_idle_timeout.py | 152 +++++++++++++++++++++++++++++++++++++++++
 4 files changed, 173 insertions(+), 4 deletions(-)

diff --git a/TODO.md b/TODO.md
index 2e256a1..954d35e 100644
--- a/TODO.md
+++ b/TODO.md
@@ -3,6 +3,3 @@
 
 ## Forwarding
 * IPv6 <-> IPv4
-
-## options
-* udp timeout
diff --git a/tcp_route.c b/tcp_route.c
index c84184d..62d8d5d 100644
--- a/tcp_route.c
+++ b/tcp_route.c
@@ -85,7 +85,13 @@ static void event_cb(struct bufferevent *bev, short events, void *arg) {
 		return;
 	}
 
-	if (events & (BEV_EVENT_EOF | BEV_EVENT_ERROR | BEV_EVENT_TIMEOUT)) {
+	if (events & BEV_EVENT_TIMEOUT) {
+		LOG_WARN("connection timed out");
+		free_conn(conn);
+		return;
+	}
+
+	if (events & (BEV_EVENT_EOF | BEV_EVENT_ERROR)) {
 		if (events & BEV_EVENT_ERROR) {
 			int err = EVUTIL_SOCKET_ERROR();
 			LOG_ERROR("connection error", "err", _LOGV(evutil_socket_error_to_string(err)));
@@ -97,6 +103,17 @@ static void event_cb(struct bufferevent *bev, short events, void *arg) {
 	(void)bev;
 }
 
+static void set_idle_timeouts(conn_t *conn, const struct route *r)
+{
+	struct timeval idle_timeout = {
+		.tv_sec = r->opts.idle_timeout_sec,
+		.tv_usec = 0,
+	};
+
+	bufferevent_set_timeouts(conn->client, &idle_timeout, &idle_timeout);
+	bufferevent_set_timeouts(conn->upstream, &idle_timeout, &idle_timeout);
+}
+
 static void worker_adopt_client_fd(struct worker *w, struct accepted_client *ac) {
 	conn_t *conn = calloc(1, sizeof(*conn));
 	if (conn == NULL) {
@@ -155,6 +172,7 @@ static void worker_adopt_client_fd(struct worker *w, struct accepted_client *ac)
 	 */
 	bufferevent_disable(conn->client, EV_READ);
 
+	set_idle_timeouts(conn, r);
 	if (bufferevent_socket_connect(
 			conn->upstream,
 			(struct sockaddr *)&upstream_addr,
diff --git a/tests/run_tests.py b/tests/run_tests.py
index 8d3a523..3ee68b7 100644
--- a/tests/run_tests.py
+++ b/tests/run_tests.py
@@ -11,6 +11,7 @@ from .support import (
 	run_default_udp_tinyproxy,
 )
 from . import test_tcp_basic
+from . import test_tcp_idle_timeout
 from . import test_tcp_stress
 from . import test_udp_basic
 from . import test_haproxy_proxy_v2
@@ -29,6 +30,7 @@ UDP_PROXY_TEST_MODULES = [
 STANDALONE_TEST_MODULES = [
 	test_haproxy_proxy_v2,
 	test_udp_proxy_v2,
+	test_tcp_idle_timeout,
 ]
 
 
diff --git a/tests/test_tcp_idle_timeout.py b/tests/test_tcp_idle_timeout.py
new file mode 100644
index 0000000..fe3080f
--- /dev/null
+++ b/tests/test_tcp_idle_timeout.py
@@ -0,0 +1,152 @@
+import asyncio
+import os
+
+from .support import (
+	LISTEN_HOST,
+	BACKEND_PORT,
+	PROXY_PORT,
+	SkipTest,
+	run_tinyproxy_with_conf,
+)
+
+
+async def echo_handler(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
+	try:
+		while True:
+			data = await reader.read(65536)
+			if not data:
+				break
+
+			writer.write(data)
+			await writer.drain()
+	finally:
+		writer.close()
+		await writer.wait_closed()
+
+
+async def close_writer(writer: asyncio.StreamWriter) -> None:
+	try:
+		writer.close()
+		await writer.wait_closed()
+	except ConnectionResetError:
+		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)
+	except ConnectionResetError:
+		return b""
+
+
+async def test_tcp_idle_timeout_closes_idle_connection() -> None:
+	proxy_bin = os.environ.get("TINYPROXY_BIN")
+	if not proxy_bin:
+		raise SkipTest("TINYPROXY_BIN is not set")
+
+	conf_text = (
+		f"{LISTEN_HOST}:{PROXY_PORT} "
+		f"{LISTEN_HOST}:{BACKEND_PORT} "
+		f"tcp idle_timeout=1\n"
+	)
+
+	backend_server = await asyncio.start_server(
+		echo_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:
+				payload = b"before idle timeout\n"
+
+				writer.write(payload)
+				await writer.drain()
+
+				got = await asyncio.wait_for(
+					reader.readexactly(len(payload)),
+					timeout=3.0,
+				)
+				assert got == payload, (
+					f"initial roundtrip mismatch: got={got!r} expected={payload!r}"
+				)
+
+				await asyncio.sleep(2.0)
+
+				got = await read_one_or_eof(reader, timeout=3.0)
+				assert got == b"", (
+					f"expected idle connection to close, got={got!r}"
+				)
+			finally:
+				await close_writer(writer)
+	finally:
+		backend_server.close()
+		await backend_server.wait_closed()
+
+
+async def test_tcp_idle_timeout_keeps_active_connection_open() -> None:
+	proxy_bin = os.environ.get("TINYPROXY_BIN")
+	if not proxy_bin:
+		raise SkipTest("TINYPROXY_BIN is not set")
+
+	conf_text = (
+		f"{LISTEN_HOST}:{PROXY_PORT} "
+		f"{LISTEN_HOST}:{BACKEND_PORT} "
+		f"tcp idle_timeout=2\n"
+	)
+
+	backend_server = await asyncio.start_server(
+		echo_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:
+				for i in range(3):
+					payload = f"still-active-{i}\n".encode()
+
+					writer.write(payload)
+					await writer.drain()
+
+					got = await asyncio.wait_for(
+						reader.readexactly(len(payload)),
+						timeout=3.0,
+					)
+					assert got == payload, (
+						f"active roundtrip {i} mismatch: "
+						f"got={got!r} expected={payload!r}"
+					)
+
+					await asyncio.sleep(1.0)
+			finally:
+				await close_writer(writer)
+	finally:
+		backend_server.close()
+		await backend_server.wait_closed()
+
+
+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),
+]
\ No newline at end of file