commit 7597a697a32b024fe6a303444e53f87531165134
| author | 斟酌 鵬兄 <tgckpg@gmail.com> |
| date | 2026-05-27T07:51:24Z |
| subject | Added keep_live for tcp_route |
commit 7597a697a32b024fe6a303444e53f87531165134
Author: 斟酌 鵬兄 <tgckpg@gmail.com>
Date: 2026-05-27T07:51:24Z
Added keep_live for tcp_route
---
.github/workflows/cmake-multi-platform.yml | 1 +
Dockerfile | 2 +-
tcp_route.c | 30 +++++
tests/run_tests.py | 2 +
tests/test_tcp_connect_timeout.py | 13 +-
tests/test_tcp_keep_alive.py | 202 +++++++++++++++++++++++++++++
6 files changed, 243 insertions(+), 7 deletions(-)
diff --git a/.github/workflows/cmake-multi-platform.yml b/.github/workflows/cmake-multi-platform.yml
index ad40c20..120174c 100644
--- a/.github/workflows/cmake-multi-platform.yml
+++ b/.github/workflows/cmake-multi-platform.yml
@@ -30,6 +30,7 @@ jobs:
libevent-dev \
libevent-static \
python3 \
+ strace \
haproxy
- &build_static
diff --git a/Dockerfile b/Dockerfile
index e612879..4be0800 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -11,7 +11,7 @@ COPY [ "*.c", "*.h", "*.conf", "Makefile", "/src" ]
RUN make all STATIC=1
RUN --mount=type=cache,target=/var/cache/apk,sharing=locked \
- apk add --update-cache haproxy
+ apk add --update-cache haproxy strace
COPY tests ./tests
RUN make test STATIC=1
diff --git a/tcp_route.c b/tcp_route.c
index a249930..bf89054 100644
--- a/tcp_route.c
+++ b/tcp_route.c
@@ -124,6 +124,16 @@ static void set_idle_timeouts(conn_t *conn, const struct route *r)
bufferevent_set_timeouts(conn->upstream, &idle_timeout, &idle_timeout);
}
+static int set_socket_keepalive(evutil_socket_t fd, const struct route *r)
+{
+ int v = r->opts.keep_alive ? 1 : 0;
+
+ if (setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, (const char *)&v, sizeof(v)) < 0) {
+ return -errno;
+ }
+
+ return 0;
+}
static void event_cb(struct bufferevent *bev, short events, void *arg) {
conn_t *conn = arg;
@@ -177,6 +187,13 @@ static void worker_adopt_client_fd(struct worker *w, struct accepted_client *ac)
return;
}
+ int rc = set_socket_keepalive(ac->fd, r);
+ if (rc < 0) {
+ LOG_WARN("failed to enable client TCP keepalive",
+ "err", _LOGV(strerror(-rc))
+ );
+ }
+
bufferevent_setwatermark(conn->client, EV_READ, 0, BEV_READ_HIGH_WATER);
bufferevent_setwatermark(conn->upstream, EV_READ, 0, BEV_READ_HIGH_WATER);
@@ -226,6 +243,19 @@ static void worker_adopt_client_fd(struct worker *w, struct accepted_client *ac)
return;
}
+ if (r->opts.keep_alive) {
+ evutil_socket_t upstream_fd = bufferevent_getfd(conn->upstream);
+
+ if (upstream_fd >= 0) {
+ int rc = set_socket_keepalive(upstream_fd, r);
+ if (rc < 0) {
+ LOG_WARN("failed to enable upstream TCP keepalive",
+ "err", _LOGV(strerror(-rc))
+ );
+ }
+ }
+ }
+
if (r->opts.proxy_v2) {
struct sockaddr_in local_addr;
socklen_t local_len = sizeof(local_addr);
diff --git a/tests/run_tests.py b/tests/run_tests.py
index a48b5a3..1123e56 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_connect_timeout
+from . import test_tcp_keep_alive
from . import test_tcp_stress
from . import test_tcp_backpressure
from . import test_udp_basic
@@ -35,6 +36,7 @@ STANDALONE_TEST_MODULES = [
test_udp_proxy_v2,
test_tcp_backpressure,
test_tcp_idle_timeout,
+ test_tcp_keep_alive,
test_tcp_connect_timeout,
test_udp_idle_timeout,
]
diff --git a/tests/test_tcp_connect_timeout.py b/tests/test_tcp_connect_timeout.py
index 7d754fe..c999256 100644
--- a/tests/test_tcp_connect_timeout.py
+++ b/tests/test_tcp_connect_timeout.py
@@ -10,6 +10,7 @@ from .support import (
)
+CONNECT_TIMEOUT_PROXY_PORT = PROXY_PORT + 20
BLACKHOLE_HOST = "192.0.2.1"
BLACKHOLE_PORT = 65000
@@ -35,7 +36,7 @@ async def test_tcp_connect_timeout_closes_client_connection() -> None:
raise SkipTest("TINYPROXY_BIN is not set")
conf_text = (
- f"{LISTEN_HOST}:{PROXY_PORT} "
+ f"{LISTEN_HOST}:{CONNECT_TIMEOUT_PROXY_PORT} "
f"{BLACKHOLE_HOST}:{BLACKHOLE_PORT} "
f"tcp connect_timeout=1,idle_timeout=30\n"
)
@@ -44,10 +45,10 @@ async def test_tcp_connect_timeout_closes_client_connection() -> None:
proxy_bin=proxy_bin,
conf_text=conf_text,
listen_host=LISTEN_HOST,
- listen_port=PROXY_PORT,
+ listen_port=CONNECT_TIMEOUT_PROXY_PORT,
proto="tcp",
):
- reader, writer = await asyncio.open_connection(LISTEN_HOST, PROXY_PORT)
+ reader, writer = await asyncio.open_connection(LISTEN_HOST, CONNECT_TIMEOUT_PROXY_PORT)
try:
start = time.monotonic()
@@ -94,7 +95,7 @@ async def test_tcp_connect_timeout_does_not_replace_idle_timeout_after_connect()
from .support import BACKEND_PORT
conf_text = (
- f"{LISTEN_HOST}:{PROXY_PORT} "
+ f"{LISTEN_HOST}:{CONNECT_TIMEOUT_PROXY_PORT} "
f"{LISTEN_HOST}:{BACKEND_PORT} "
f"tcp connect_timeout=1,idle_timeout=2\n"
)
@@ -111,10 +112,10 @@ async def test_tcp_connect_timeout_does_not_replace_idle_timeout_after_connect()
proxy_bin=proxy_bin,
conf_text=conf_text,
listen_host=LISTEN_HOST,
- listen_port=PROXY_PORT,
+ listen_port=CONNECT_TIMEOUT_PROXY_PORT,
proto="tcp",
):
- reader, writer = await asyncio.open_connection(LISTEN_HOST, PROXY_PORT)
+ reader, writer = await asyncio.open_connection(LISTEN_HOST, CONNECT_TIMEOUT_PROXY_PORT)
try:
payload = b"connected path still works\n"
diff --git a/tests/test_tcp_keep_alive.py b/tests/test_tcp_keep_alive.py
new file mode 100644
index 0000000..4d2c5ac
--- /dev/null
+++ b/tests/test_tcp_keep_alive.py
@@ -0,0 +1,202 @@
+import asyncio
+import os
+import platform
+import shutil
+import subprocess
+import tempfile
+from pathlib import Path
+
+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 wait_for_tcp_listen(host: str, port: int, timeout: float = 5.0) -> None:
+ deadline = asyncio.get_running_loop().time() + timeout
+ last_error = None
+
+ while asyncio.get_running_loop().time() < deadline:
+ try:
+ reader, writer = await asyncio.open_connection(host, port)
+ writer.close()
+ await writer.wait_closed()
+ return
+ except OSError as e:
+ last_error = e
+ await asyncio.sleep(0.05)
+
+ raise RuntimeError(f"timed out waiting for {host}:{port}: {last_error}")
+
+
+async def test_tcp_keep_alive_roundtrip() -> 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 keep_alive,idle_timeout=5\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"keep-alive smoke test\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}"
+ )
+ finally:
+ await close_writer(writer)
+ finally:
+ backend_server.close()
+ await backend_server.wait_closed()
+
+
+async def test_tcp_keep_alive_sets_socket_option() -> None:
+ if platform.system() != "Linux":
+ raise SkipTest("strace keep_alive test only runs on Linux")
+
+ strace_bin = shutil.which("strace")
+ if not strace_bin:
+ raise SkipTest("strace is not installed")
+
+ 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 keep_alive,idle_timeout=5\n"
+ )
+
+ backend_server = await asyncio.start_server(
+ echo_handler,
+ LISTEN_HOST,
+ BACKEND_PORT,
+ backlog=128,
+ )
+
+ with tempfile.TemporaryDirectory() as td:
+ td_path = Path(td)
+ conf_path = td_path / "tinyproxy.conf"
+ trace_path = td_path / "strace.log"
+
+ conf_path.write_text(conf_text)
+
+ proxy = subprocess.Popen(
+ [
+ strace_bin,
+ "-f",
+ "-e",
+ "trace=setsockopt",
+ "-o",
+ str(trace_path),
+ proxy_bin,
+ "-c",
+ str(conf_path),
+ ],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ )
+
+ try:
+ await wait_for_tcp_listen(LISTEN_HOST, PROXY_PORT)
+
+ reader, writer = await asyncio.open_connection(LISTEN_HOST, PROXY_PORT)
+
+ try:
+ payload = b"trigger upstream connect\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}"
+ )
+ finally:
+ await close_writer(writer)
+ finally:
+ proxy.terminate()
+
+ try:
+ proxy.wait(timeout=3.0)
+ except subprocess.TimeoutExpired:
+ proxy.kill()
+ proxy.wait(timeout=3.0)
+
+ backend_server.close()
+ await backend_server.wait_closed()
+
+ trace_text = trace_path.read_text(errors="replace")
+
+ keepalive_count = trace_text.count("SO_KEEPALIVE")
+
+ assert keepalive_count >= 2, (
+ "expected SO_KEEPALIVE to be enabled on both client and upstream sockets; "
+ f"found {keepalive_count} calls\n\nstrace output:\n{trace_text}"
+ )
+
+
+TESTS = [
+ ("test_tcp_keep_alive_roundtrip", test_tcp_keep_alive_roundtrip),
+ ("test_tcp_keep_alive_sets_socket_option", test_tcp_keep_alive_sets_socket_option),
+]