penguin/tinyproxy

An L4 proxy designed to act as a tiny transparent shim

commit a696a695888e04eaf3a84328509968990e79bed8

author斟酌 鵬兄 <tgckpg@gmail.com>
date2026-05-26T21:19:30Z
subjectFixed OOM issue
commit a696a695888e04eaf3a84328509968990e79bed8
Author: 斟酌 鵬兄 <tgckpg@gmail.com>
Date:   2026-05-26T21:19:30Z

    Fixed OOM issue
---
 tcp_route.c                    |  47 +++++++--
 tests/run_tests.py             |   2 +
 tests/test_tcp_backpressure.py | 231 +++++++++++++++++++++++++++++++++++++++++
 3 files changed, 272 insertions(+), 8 deletions(-)

diff --git a/tcp_route.c b/tcp_route.c
index 62d8d5d..a6f9088 100644
--- a/tcp_route.c
+++ b/tcp_route.c
@@ -16,6 +16,9 @@
 #include "route.h"
 #include "tcp_route.h"
 
+#define BEV_READ_HIGH_WATER (256 * 1024)
+#define BEV_WRITE_RESUME_WATER (128 * 1024)
+
 struct tcp_route_ctx {
 	struct event_base *accept_base;
 	struct worker *worker;
@@ -57,9 +60,9 @@ static void free_conn(conn_t *conn) {
 	free(conn);
 }
 
-static void pipe_read_cb(struct bufferevent *src, void *arg) {
+static void pipe_read_cb(struct bufferevent *src, void *arg)
+{
 	conn_t *conn = arg;
-
 	struct bufferevent *dst;
 
 	if (src == conn->client) {
@@ -74,6 +77,30 @@ static void pipe_read_cb(struct bufferevent *src, void *arg) {
 	struct evbuffer *output = bufferevent_get_output(dst);
 
 	evbuffer_add_buffer(output, input);
+
+	if (evbuffer_get_length(output) >= BEV_READ_HIGH_WATER) {
+		bufferevent_disable(src, EV_READ);
+	}
+}
+
+static void pipe_write_cb(struct bufferevent *dst, void *arg)
+{
+	conn_t *conn = arg;
+	struct bufferevent *src;
+
+	if (dst == conn->client) {
+		src = conn->upstream;
+	} else if (dst == conn->upstream) {
+		src = conn->client;
+	} else {
+		return;
+	}
+
+	struct evbuffer *output = bufferevent_get_output(dst);
+
+	if (evbuffer_get_length(output) < BEV_WRITE_RESUME_WATER) {
+		bufferevent_enable(src, EV_READ);
+	}
 }
 
 static void event_cb(struct bufferevent *bev, short events, void *arg) {
@@ -137,10 +164,13 @@ static void worker_adopt_client_fd(struct worker *w, struct accepted_client *ac)
 		return;
 	}
 
+	bufferevent_setwatermark(conn->client, EV_READ, 0, BEV_READ_HIGH_WATER);
+	bufferevent_setwatermark(conn->upstream, EV_READ, 0, BEV_READ_HIGH_WATER);
+
 	bufferevent_setcb(
 		conn->client,
 		pipe_read_cb,
-		NULL,
+		pipe_write_cb,
 		event_cb,
 		conn
 	);
@@ -148,7 +178,7 @@ static void worker_adopt_client_fd(struct worker *w, struct accepted_client *ac)
 	bufferevent_setcb(
 		conn->upstream,
 		pipe_read_cb,
-		NULL,
+		pipe_write_cb,
 		event_cb,
 		conn
 	);
@@ -259,14 +289,15 @@ static void accept_cb(
 	dispatch_client_fd(ctx->worker, &ac);
 }
 
-static void accept_error_cb(struct evconnlistener *listener, void *arg) {
-	struct event_base *base = arg;
+static void accept_error_cb(struct evconnlistener *listener, void *arg)
+{
+	struct tcp_route_ctx *ctx = arg;
 	int err = EVUTIL_SOCKET_ERROR();
 
 	LOG_ERROR("accept error", "err", _LOGV(evutil_socket_error_to_string(err)));
 
-	evconnlistener_free(listener);
-	event_base_loopexit(base, NULL);
+	evconnlistener_disable(listener);
+	event_base_loopexit(ctx->accept_base, NULL);
 }
 
 int start_tcp_route(
diff --git a/tests/run_tests.py b/tests/run_tests.py
index 4837fc0..11a738d 100644
--- a/tests/run_tests.py
+++ b/tests/run_tests.py
@@ -13,6 +13,7 @@ from .support import (
 from . import test_tcp_basic
 from . import test_tcp_idle_timeout
 from . import test_tcp_stress
+from . import test_tcp_backpressure
 from . import test_udp_basic
 from . import test_udp_idle_timeout
 from . import test_haproxy_proxy_v2
@@ -31,6 +32,7 @@ UDP_PROXY_TEST_MODULES = [
 STANDALONE_TEST_MODULES = [
 	test_haproxy_proxy_v2,
 	test_udp_proxy_v2,
+	test_tcp_backpressure,
 	test_tcp_idle_timeout,
 	test_udp_idle_timeout,
 ]
diff --git a/tests/test_tcp_backpressure.py b/tests/test_tcp_backpressure.py
new file mode 100644
index 0000000..5089172
--- /dev/null
+++ b/tests/test_tcp_backpressure.py
@@ -0,0 +1,231 @@
+import asyncio
+import os
+
+from .support import (
+	LISTEN_HOST,
+	BACKEND_PORT,
+	PROXY_PORT,
+	SkipTest,
+	run_tinyproxy_with_conf,
+)
+
+BACKPRESSURE_MAX_BYTES = 32 * 1024 * 1024
+BACKPRESSURE_CHUNK_SIZE = 64 * 1024
+BACKPRESSURE_DRAIN_TIMEOUT = 0.5
+
+
+async def close_writer(writer: asyncio.StreamWriter) -> None:
+	try:
+		writer.close()
+		await writer.wait_closed()
+	except ConnectionResetError:
+		pass
+
+async def abort_writer(writer: asyncio.StreamWriter) -> None:
+	transport = writer.transport
+	transport.abort()
+
+	# Let the event loop process connection_lost().
+	await asyncio.sleep(0)
+
+async def blackhole_handler(
+	reader: asyncio.StreamReader,
+	writer: asyncio.StreamWriter,
+) -> None:
+	try:
+		# Intentionally do not read.
+		#
+		# This simulates an upstream that accepted the TCP connection but
+		# stopped draining. Without proxy-side backpressure, tinyproxy may keep
+		# reading from the client and buffer data until memory grows without
+		# bound.
+		await asyncio.sleep(3600)
+	finally:
+		await close_writer(writer)
+
+async def push_until_drain_blocks(
+	writer: asyncio.StreamWriter,
+	max_bytes: int = BACKPRESSURE_MAX_BYTES,
+	chunk_size: int = BACKPRESSURE_CHUNK_SIZE,
+	drain_timeout: float = BACKPRESSURE_DRAIN_TIMEOUT,
+) -> int:
+	chunk = b"x" * chunk_size
+	written = 0
+
+	while written < max_bytes:
+		writer.write(chunk)
+		written += len(chunk)
+
+		try:
+			await asyncio.wait_for(writer.drain(), timeout=drain_timeout)
+		except asyncio.TimeoutError:
+			return written
+		except ConnectionError:
+			return written
+		except BrokenPipeError:
+			return written
+
+		await asyncio.sleep(0)
+
+	return written
+
+async def test_tcp_backpressure_when_upstream_does_not_read() -> 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=30\n"
+	)
+
+	stop_backend = asyncio.Event()
+
+	async def blackhole_handler(
+		reader: asyncio.StreamReader,
+		writer: asyncio.StreamWriter,
+	) -> None:
+		try:
+			# Intentionally do not read from reader.
+			await stop_backend.wait()
+		finally:
+			await abort_writer(writer)
+
+	backend_server = await asyncio.start_server(
+		blackhole_handler,
+		LISTEN_HOST,
+		BACKEND_PORT,
+		backlog=128,
+	)
+
+	writer: asyncio.StreamWriter | None = None
+
+	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)
+
+			written = await asyncio.wait_for(
+				push_until_drain_blocks(writer),
+				timeout=10.0,
+			)
+
+			assert written < BACKPRESSURE_MAX_BYTES, (
+				"proxy accepted too much client data while upstream was not reading; "
+				"this likely means TCP backpressure/watermarks are missing. "
+				f"wrote={written} max={BACKPRESSURE_MAX_BYTES}"
+			)
+
+			_ = reader
+	finally:
+		if writer is not None:
+			await abort_writer(writer)
+
+		stop_backend.set()
+
+		backend_server.close()
+		await asyncio.wait_for(backend_server.wait_closed(), timeout=3.0)
+
+async def test_tcp_backpressure_when_client_does_not_read() -> 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=30\n"
+	)
+
+	backend_result: asyncio.Future[tuple[str, int]] = (
+		asyncio.get_running_loop().create_future()
+	)
+
+	async def pushing_backend_handler(
+		reader: asyncio.StreamReader,
+		writer: asyncio.StreamWriter,
+	) -> None:
+		try:
+			# Intentionally do not wait for a client trigger.
+			#
+			# This test is about upstream -> proxy -> non-reading client.
+			# Waiting for a trigger byte makes the test depend on the
+			# client->upstream direction, which is unrelated and flaky here.
+			_ = reader
+
+			written = await push_until_drain_blocks(writer)
+
+			if not backend_result.done():
+				if written < BACKPRESSURE_MAX_BYTES:
+					backend_result.set_result(("blocked", written))
+				else:
+					backend_result.set_result(("unbounded", written))
+		except ConnectionError:
+			if not backend_result.done():
+				backend_result.set_result(("blocked", 0))
+		except BrokenPipeError:
+			if not backend_result.done():
+				backend_result.set_result(("blocked", 0))
+		except Exception as e:
+			if not backend_result.done():
+				backend_result.set_exception(e)
+		finally:
+			await abort_writer(writer)
+
+	backend_server = await asyncio.start_server(
+		pushing_backend_handler,
+		LISTEN_HOST,
+		BACKEND_PORT,
+		backlog=128,
+	)
+
+	writer: asyncio.StreamWriter | None = None
+
+	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)
+
+			# Intentionally do not read from reader.
+			_ = reader
+
+			status, written = await asyncio.wait_for(
+				backend_result,
+				timeout=10.0,
+			)
+
+			assert status == "blocked", (
+				"proxy accepted too much upstream data while client was not reading; "
+				"this likely means reverse-direction TCP backpressure/watermarks "
+				"are missing. "
+				f"status={status} wrote={written} max={BACKPRESSURE_MAX_BYTES}"
+			)
+	finally:
+		if writer is not None:
+			await abort_writer(writer)
+
+		backend_server.close()
+		await asyncio.wait_for(backend_server.wait_closed(), timeout=3.0)
+
+
+TESTS = [
+	(
+		"test_tcp_backpressure_when_upstream_does_not_read",
+		test_tcp_backpressure_when_upstream_does_not_read,
+	),
+	(
+		"test_tcp_backpressure_when_client_does_not_read",
+		test_tcp_backpressure_when_client_does_not_read,
+	),
+]