penguin/tinyproxy

An L4 proxy designed to act as a tiny transparent shim

commit 122d4bcbc83561c8b4f134d47fd7b6694c8e5846

author斟酌 鵬兄 <tgckpg@gmail.com>
date2026-05-27T07:24:34Z
subjectconnect timeout for tcp_route
commit 122d4bcbc83561c8b4f134d47fd7b6694c8e5846
Author: 斟酌 鵬兄 <tgckpg@gmail.com>
Date:   2026-05-27T07:24:34Z

    connect timeout for tcp_route
---
 tcp_route.c                       |  37 ++++++---
 tests/run_tests.py                |   2 +
 tests/test_tcp_connect_timeout.py | 171 ++++++++++++++++++++++++++++++++++++++
 3 files changed, 198 insertions(+), 12 deletions(-)

diff --git a/tcp_route.c b/tcp_route.c
index a6f9088..a249930 100644
--- a/tcp_route.c
+++ b/tcp_route.c
@@ -103,10 +103,34 @@ static void pipe_write_cb(struct bufferevent *dst, void *arg)
 	}
 }
 
+static void set_connect_timeout(conn_t *conn, const struct route *r)
+{
+	struct timeval connect_timeout = {
+		.tv_sec = r->opts.connect_timeout_sec,
+		.tv_usec = 0,
+	};
+
+	bufferevent_set_timeouts(conn->upstream, NULL, &connect_timeout);
+}
+
+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 event_cb(struct bufferevent *bev, short events, void *arg) {
 	conn_t *conn = arg;
 
 	if (events & BEV_EVENT_CONNECTED) {
+		set_idle_timeouts(conn, conn->route);
+
 		bufferevent_enable(conn->client, EV_READ | EV_WRITE);
 		bufferevent_enable(conn->upstream, EV_READ | EV_WRITE);
 		return;
@@ -130,17 +154,6 @@ 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) {
@@ -202,7 +215,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);
+	set_connect_timeout(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 11a738d..a48b5a3 100644
--- a/tests/run_tests.py
+++ b/tests/run_tests.py
@@ -12,6 +12,7 @@ from .support import (
 )
 from . import test_tcp_basic
 from . import test_tcp_idle_timeout
+from . import test_tcp_connect_timeout
 from . import test_tcp_stress
 from . import test_tcp_backpressure
 from . import test_udp_basic
@@ -34,6 +35,7 @@ STANDALONE_TEST_MODULES = [
 	test_udp_proxy_v2,
 	test_tcp_backpressure,
 	test_tcp_idle_timeout,
+	test_tcp_connect_timeout,
 	test_udp_idle_timeout,
 ]
 
diff --git a/tests/test_tcp_connect_timeout.py b/tests/test_tcp_connect_timeout.py
new file mode 100644
index 0000000..7d754fe
--- /dev/null
+++ b/tests/test_tcp_connect_timeout.py
@@ -0,0 +1,171 @@
+import asyncio
+import os
+import time
+
+from .support import (
+	LISTEN_HOST,
+	PROXY_PORT,
+	SkipTest,
+	run_tinyproxy_with_conf,
+)
+
+
+BLACKHOLE_HOST = "192.0.2.1"
+BLACKHOLE_PORT = 65000
+
+
+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 = 5.0) -> bytes:
+	try:
+		return await asyncio.wait_for(reader.read(1), timeout=timeout)
+	except ConnectionResetError:
+		return b""
+
+
+async def test_tcp_connect_timeout_closes_client_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"{BLACKHOLE_HOST}:{BLACKHOLE_PORT} "
+		f"tcp connect_timeout=1,idle_timeout=30\n"
+	)
+
+	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:
+			start = time.monotonic()
+
+			writer.write(b"hello before upstream connect\n")
+			await writer.drain()
+
+			got = await read_one_or_eof(reader, timeout=5.0)
+			elapsed = time.monotonic() - start
+
+			assert got == b"", (
+				f"expected proxy to close client after connect timeout, got={got!r}"
+			)
+
+			assert elapsed >= 0.8, (
+				f"connect timeout fired too early: elapsed={elapsed:.3f}s"
+			)
+
+			assert elapsed < 4.0, (
+				f"connect timeout took too long: elapsed={elapsed:.3f}s"
+			)
+		finally:
+			await close_writer(writer)
+
+
+async def test_tcp_connect_timeout_does_not_replace_idle_timeout_after_connect() -> None:
+	proxy_bin = os.environ.get("TINYPROXY_BIN")
+	if not proxy_bin:
+		raise SkipTest("TINYPROXY_BIN is not set")
+
+	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()
+
+	from .support import BACKEND_PORT
+
+	conf_text = (
+		f"{LISTEN_HOST}:{PROXY_PORT} "
+		f"{LISTEN_HOST}:{BACKEND_PORT} "
+		f"tcp connect_timeout=1,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:
+				payload = b"connected path still works\n"
+
+				writer.write(payload)
+				await writer.drain()
+
+				got = await asyncio.wait_for(
+					reader.readexactly(len(payload)),
+					timeout=3.0,
+				)
+				assert got == payload, (
+					f"roundtrip mismatch: got={got!r} expected={payload!r}"
+				)
+
+				await asyncio.sleep(1.2)
+
+				payload = b"still alive after connect timeout duration\n"
+
+				writer.write(payload)
+				await writer.drain()
+
+				got = await asyncio.wait_for(
+					reader.readexactly(len(payload)),
+					timeout=3.0,
+				)
+				assert got == payload, (
+					f"connection died as if connect_timeout remained active: "
+					f"got={got!r} expected={payload!r}"
+				)
+
+				await asyncio.sleep(2.5)
+
+				got = await read_one_or_eof(reader, timeout=3.0)
+				assert got == b"", (
+					f"expected idle timeout to close established connection, got={got!r}"
+				)
+			finally:
+				await close_writer(writer)
+	finally:
+		backend_server.close()
+		await backend_server.wait_closed()
+
+
+TESTS = [
+	(
+		"test_tcp_connect_timeout_closes_client_connection",
+		test_tcp_connect_timeout_closes_client_connection,
+	),
+	(
+		"test_tcp_connect_timeout_does_not_replace_idle_timeout_after_connect",
+		test_tcp_connect_timeout_does_not_replace_idle_timeout_after_connect,
+	),
+]