Files
opendance-windows/tasks/015-keepalive-dedup.md
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

90 lines
5.1 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 项)。