Files
opendance-windows/tasks/015-keepalive-dedup.md
T
zijunchiang c3a227c6df feat(net): 连接保活 / 设备去重 / 连接状态机(T-WIN-015,契约 websocket-keypoints 0.2.0)
与 Android 端 T-AND-011「两端一起改」:契约先在 opendance-web 修改并提升到
websocket-keypoints 0.2.0(向后兼容:子协议名与二进制帧 version 均不变,
只新增可选控制字段与一种新控制类型),再同步三端快照。

修掉本端的 3 处根因/放大器:
1. 静默超时 5s 过短且未声明,客户端无从得知。OkHttp 的协议层 ping 由底层库
   自动应答、**不暴露给应用层**,无法刷新 last_seen_ms,于是"画面里没人"时
   每 5 秒必然踢一次连接 -> 客户端重连 -> 再被踢。
   - CLIENT_TIMEOUT_MS 5s -> 15s,新增 HEARTBEAT_INTERVAL_MS = 5s;
   - welcome_payload() 在 welcome 中声明 silentTimeoutMs / heartbeatIntervalMs,
     config 广播同样带上(契约 §1.1:心跳必须 < 静默超时的一半,5000*2 < 15000);
   - 处理应用层 ping 并回 pong(回显 sentAtMs + serverAtMs,供对端算 RTT)。
2. 反复重连被计成多台设备。按契约 §5 用 hello.deviceId 去重:同一设备只保留
   最新连接,旧连接以 close(1000) 关闭——刻意用 1000,因为对端按契约 §7
   收到 1000 不会重连,否则会形成"挤掉-重连-再挤掉"的循环;
   新增 device_count() 与 device_replaced 信号,界面一律显示去重后的设备数。
3. 只有 handshaken 布尔量,日志看不出卡在哪一步。按契约 §8 逐客户端维护与
   Android 端同名的状态(connecting -> handshaking -> streaming -> degraded,
   主动停止/超时/被替换为 stopped),并记录 close code/reason、协商子协议、
   连接/握手/首帧时间;degraded 保持时长做成可覆盖字段便于测试。

验收测试(tests/test_pose_server.gd 新增 7 项,总计 720 通过 / 0 失败):
welcome 声明保活参数、ping->pong 回显、同 deviceId 去重(旧连接 1000 关闭且
设备数仍为 1)、8 个状态名与契约一致、状态随握手/首帧推进到 streaming、
丢帧进入 degraded 后恢复、snapshot 暴露设备数与保活参数。

同时更新 README/README.txt/tasks 与 Credits 的契约版本(0.2.0)。
2026-09-17 01:11:48 +08:00

5.1 KiB
Raw Blame History

T-WIN-015 连接保活、设备去重与状态机

  • 状态: done
  • 依赖: T-WIN-003
  • 文档: v1.6 §6.5(T-AND-011 要求"两端一起改,协议与状态机对齐")
  • 契约: websocket-keypoints 0.1.0 → 0.2.0

背景

Android 端反馈「连接状态异常、频繁重连」。按文档 §6.5 的要求,这不是单端问题, 必须两端一起改。核对本端后确认本端有 3 处是根因或放大器:

根因 1:静默超时 5 秒过短,且客户端无从得知

CLIENT_TIMEOUT_MS 原为 5000ms,且没有在 welcome 里声明。

  • 客户端只能用 OkHttp 的协议层 ping 兜底 —— 而协议层 ping 由底层库自动应答, 本端应用层看不到,last_seen_ms 不会因此刷新;
  • 结果:画面里没有人(关键点帧稀疏)时,只要客户端那一秒的 stats 稍慢, 5 秒一到就被本端 close(1001, "timeout") 踢掉 → 客户端重连 → 再被踢。

修复:

  • 超时提到 15000ms(关键点帧与每秒 stats 都算活动,15 秒足够容忍 相机暂停 / 画面无人 / 推理短暂卡顿,又不会让死连接长期占用);
  • 新增 HEARTBEAT_INTERVAL_MS = 5000;
  • 两者通过 welcome_payload() 在 welcome 中声明,并在 config 广播中重复, 客户端据此定节奏(契约 §1.1 要求心跳 < 静默超时的一半:5000×2 < 15000 ✓);
  • 新增 ping 处理:收到应用层 ping 回 pong(回显 sentAtMs,另带 serverAtMs), 客户端用 now - sentAtMs 估算 RTT。

根因 2:反复重连被计成多台设备

原先只按"连接数"显示,手机闪断后立刻重连会短暂出现"已连接 2 台"。

修复(契约 §5):

  • hello 新增稳定 deviceId(Android 端取 ANDROID_ID 的 SHA-256 前 16 位, 不含任何硬件序列号);
  • _dedup_devices() 在每轮 _poll_clients() 之后运行:同一 deviceId 只保留 最新连接,旧连接以 close(1000, "replaced by new connection") 关闭 —— 用 1000 是刻意的:按契约 §7,对端收到 1000 不会重连, 否则会形成"新连接挤掉旧连接 → 旧连接重连 → 再挤掉"的循环;
  • 新增 device_count()(去重后设备数)与 device_replaced 信号; 界面与 snapshot() 一律显示去重后的数字。

根因 3:没有可对照的状态机与断开原因

原先只有一个 handshaken 布尔量,日志里看不出"卡在哪一步"。

修复(契约 §8):逐客户端维护与 Android 端同名的状态 (ClientState + state_name()),并记录 close_code / close_reason / selected_protocol / connected_at_ms / handshake_at_ms / first_frame_at_ms:

CONNECTING --(HTTP 升级完成)--> HANDSHAKING --(收到 hello / 首帧)--> STREAMING
                                    ^                                  |
                                    |                            (出现丢帧)
                                    +-------- DEGRADED <---------------+
STOPPED <- 主动 stop() / 收到 bye / 1000 / 1001 / 静默超时 / 被同设备新连接替换

DEGRADED 的保持时长 degraded_hold_ms 默认 3000ms,期间无新丢帧即回到 STREAMING(做成实例字段,便于测试不依赖真实等待)。

改动清单

文件 改动
src/net/pose_server.gd 超时 5s→15s + 心跳常量;welcome_payload();ping→pong;_dedup_devices();ClientState/state_name();clients_info();device_count();close code/reason 与各时间点记录
src/autoload/pose_receiver.gd connected_device_count() / clients_info() / silent_timeout_ms() / heartbeat_interval_ms()
src/ui/game_screen.gd 「已连接 N 台」改用去重后的设备数
src/ui/main_menu.gd 「查看接收状态」增加设备数、保活参数与逐客户端状态
src/ui/credits.gd 契约版本字典同步到 websocket-keypoints 0.2.0
contracts/ 快照同步(与 opendance-web / opendance-android 逐字节一致)

验收(契约 §7 / §8 + 文档 §6.5)

验收项 结果
服务端声明静默超时与期望心跳,心跳 < 超时的一半 ✅ test_welcome_declares_keepalive_parameters 断言 heartbeat*2 < timeout
应用层 ping 能续命 ✅ test_ping_gets_pong_with_echoed_timestamp(协议层 ping 对应用层不可见,故必须有应用层 ping)
同一设备重复连接被去重,旧连接不重连 ✅ test_duplicate_device_id_is_deduplicated:旧连接被 1000 关闭、设备数仍为 1
状态名与两端一致 ✅ test_states_follow_contract_names 逐项核对 8 个状态
状态随握手/首帧推进 ✅ test_client_state_progresses_to_streaming:handshaking → streaming
丢帧进入 degraded 后可恢复 ✅ test_frame_gap_marks_degraded_then_recovers
snapshot 暴露设备数与保活参数 ✅ test_snapshot_exposes_keepalive_and_device_count

Windows 端无头测试:720 通过 / 0 失败(其中 test_pose_server.gd 新增 7 项)。