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)。
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
# 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 项)。
|
||||
Reference in New Issue
Block a user