feat(proxy-tunnel): 实现隧道连接池与 TCP 底层优化

Rust 端:
- 支持每个 server 多条并行 WebSocket 连接 (tunnel_connections 配置)
- 手动控制 TCP 连接: connect/handshake 超时、keepalive、NODELAY (socket2)
- 预构建共享 TLS ClientConfig 避免每次重连重新解析根证书
- 增加 stale timeout 检测无数据连接,智能 backoff 按连接存活时长重置
- 每条连接独立 reconnect 计数器,仅主连接 (conn_idx=0) 发送心跳
- dispatcher 的错误帧和 PONG 改用 try_send 避免阻塞读循环
- stream_handler 增加 frame 发送超时保护防止写阻塞

Python 端:
- TunnelManager 改为连接池,按 least-loaded 策略分配请求
- handle_incoming_frame 按连接实例路由,unregister 精确移除单条连接
- 心跳和 PONG 回复改为 fire-and-forget 避免阻塞主读循环
- send_frame 增加超时保护防止 TCP 写阻塞级联
- WebSocket 先 accept 再认证,auth 加超时
- 调整 idle timeout (90s) 和 ping 间隔 (15s)
This commit is contained in:
fawney19
2026-02-27 21:25:42 +08:00
parent a174cf1b02
commit ee356c5e6e
13 changed files with 600 additions and 135 deletions

View File

@@ -23,11 +23,11 @@ router = APIRouter()
# 单帧最大 64 MB -- AI API 请求体可能包含多张 base64 图片,需要足够余量
_MAX_FRAME_SIZE = 64 * 1024 * 1024
# WebSocket 空闲超时(秒)-- proxy 端 WebSocket ping 间隔 15s + 心跳 30s180s 提供充足余量
_IDLE_TIMEOUT = 180.0
# WebSocket 空闲超时(秒)-- 需覆盖客户端 stale_timeout(45s) + 重连延迟(最长30s) 的窗口期
_IDLE_TIMEOUT = 90.0
# 服务端应用层 ping 间隔(秒)-- 确保即使 proxy 端心跳延迟,连接也不会因中间代理空闲超时而断开
_SERVER_PING_INTERVAL = 30.0
# 服务端应用层 ping 间隔(秒)-- 与客户端 ping 间隔一致,确保高频心跳
_SERVER_PING_INTERVAL = 15.0
async def _authenticate(ws: WebSocket) -> tuple[str, str] | None:
@@ -78,22 +78,26 @@ async def _authenticate(ws: WebSocket) -> tuple[str, str] | None:
@router.websocket("/api/internal/proxy-tunnel")
async def proxy_tunnel_ws(ws: WebSocket) -> None:
"""aether-proxy tunnel WebSocket 端点"""
# 先 accept避免认证DB/Redis慢时卡在握手阶段导致网关返回 502。
await ws.accept()
try:
auth = await _authenticate(ws)
auth = await asyncio.wait_for(_authenticate(ws), timeout=10.0)
except asyncio.TimeoutError:
logger.warning("tunnel auth timeout")
await ws.close(code=4002, reason="authentication timeout")
return
except Exception as e:
logger.warning("tunnel auth error: {}", e)
await ws.accept()
await ws.close(code=4002, reason="authentication error")
return
if not auth:
await ws.accept()
await ws.close(code=4001, reason="unauthorized")
return
node_id: str = auth[0]
node_name: str = auth[1]
await ws.accept()
manager = get_tunnel_manager()
conn = TunnelConnection(node_id, node_name, ws)
@@ -129,7 +133,7 @@ async def proxy_tunnel_ws(ws: WebSocket) -> None:
logger.warning("tunnel frame decode error from {}: {}", node_id, e)
continue
await manager.handle_incoming_frame(node_id, frame)
await manager.handle_incoming_frame(conn, frame)
except WebSocketDisconnect:
logger.info("tunnel WebSocket disconnected: node_id={}", node_id)
@@ -137,8 +141,11 @@ async def proxy_tunnel_ws(ws: WebSocket) -> None:
logger.error("tunnel WebSocket error for node_id={}: {}", node_id, e)
finally:
ping_task.cancel()
manager.unregister(node_id)
await _update_tunnel_status(node_id, connected=False)
manager.unregister(conn)
if not manager.has_tunnel(node_id):
await _update_tunnel_status(node_id, connected=False)
else:
logger.info("tunnel connection closed but pool still active: node_id={}", node_id)
async def _ping_loop(conn: TunnelConnection) -> None: