From f3a956601a9694284710f6fdb6737b7008093153 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B3=E6=99=97?= Date: Tue, 7 Jul 2026 03:43:03 -0700 Subject: [PATCH] =?UTF-8?q?feat(platform):=20=E6=8E=A7=E5=88=B6=E8=BF=9E?= =?UTF-8?q?=E6=8E=A5=20WS=20ping/pong=20=E4=BF=9D=E6=B4=BB=20+=20=E5=8D=8A?= =?UTF-8?q?=E5=BC=80=E6=8E=A2=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 长时间不访问平台后第一次访问失败的元凶:控制连接空闲期被 LB/NAT 静默掐成半开,daemon 这头发现不了(心跳是单向应用消息、掩盖不了平台→daemon 方向断),访问时平台发 open-stream 到不了 daemon → 首次失败。加 WS 层 ping/pong:空闲也定时 ping(平台自动回 pong,双向有流量 防 idle 掐断),一个周期没等到 pong 就 terminate 触发重连(~30-60s 发现,不用干等几分钟 TCP 超时)。 真机 e2e 验证:连上后跨 2 个 ping 周期保活稳定(无误判/无崩溃);kill -STOP 冻住平台模拟半开, daemon ~52s 内探到 ping 无 pong 并重连。 --- src/platform/tunnel-client.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/platform/tunnel-client.ts b/src/platform/tunnel-client.ts index 6dbdbde27..70fef879a 100644 --- a/src/platform/tunnel-client.ts +++ b/src/platform/tunnel-client.ts @@ -65,6 +65,9 @@ const DATA_DIAL_OVERALL_DEADLINE_MS = 6_000; // ~35% 的黑洞。单条超时给足 TLS 握手时间(好连接 ~40ms,黑洞则等满超时判负)。 const CONTROL_DIAL_PARALLEL = 3; const CONTROL_DIAL_TIMEOUT_MS = 5_000; +// 控制连接 WS 层保活:空闲时也定时 ping(保持链路有流量,避免被 LB/NAT idle 掐成半开); +// 一个周期内没等到 pong 就判半死、terminate 触发重连(不用干等几分钟 TCP 超时)。 +const CONTROL_PING_MS = 30_000; // 本机可供平台服务端「直连反代」的候选地址(内网 IPv4:dashboardPort)。平台够得着就直连本机 // dashboard、绕过隧道(省掉 daemon 拨号/ECMP 赌/跨 pod 转发);够不着自动退回隧道。仅内网地址、 @@ -93,6 +96,7 @@ export function startPlatformTunnelClient(opts: TunnelClientOptions): TunnelClie // 当前一轮控制连接并行拨号中「在拨/在等」的候选(胜出/停止时用于清理)。 let controlDials: Set | null = null; let heartbeat: NodeJS.Timeout | null = null; + let pingTimer: NodeJS.Timeout | null = null; let reconnectTimer: NodeJS.Timeout | null = null; let backoff = BACKOFF_MIN_MS; // 本机平台团队(成员关系下沉到部署本地) @@ -217,6 +221,22 @@ export function startPlatformTunnelClient(opts: TunnelClientOptions): TunnelClie sendRegister(sock); heartbeat = setInterval(() => sendHeartbeat(sock), HEARTBEAT_MS); + // WS ping/pong 保活 + 半开探测。心跳是 daemon→平台的单向应用消息,掩盖不了「平台→daemon 方向 + // 被 idle 掐断」;WS ping 由平台自动回 pong,双向都有流量→防 idle 掐断,且丢 pong 能快速判半开。 + let pongAlive = true; + sock.on('pong', () => { pongAlive = true; }); + pingTimer = setInterval(() => { + if (sock.readyState !== WebSocket.OPEN) return; + if (!pongAlive) { + // 上一周期的 ping 没等到 pong → 连接多半半开了,主动断开触发重连。 + opts.log('控制连接 ping 无 pong,判定半开,重连'); + try { sock.terminate(); } catch { /* ignore */ } // → 'close' → cleanupSock + scheduleReconnect + return; + } + pongAlive = false; + try { sock.ping(); } catch { /* ignore */ } + }, CONTROL_PING_MS); + sock.on('message', (data) => { let msg: { type?: string; streamId?: string; teamId?: string; teamName?: string; rev?: string; teams?: unknown[] }; try { @@ -259,6 +279,10 @@ export function startPlatformTunnelClient(opts: TunnelClientOptions): TunnelClie clearInterval(heartbeat); heartbeat = null; } + if (pingTimer) { + clearInterval(pingTimer); + pingTimer = null; + } } function scheduleReconnect(): void {