# 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 项)。