与 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)。
5.1 KiB
5.1 KiB
T-WIN-015 连接保活、设备去重与状态机
- 状态: done
- 依赖: T-WIN-003
- 文档: v1.6 §6.5(T-AND-011 要求"两端一起改,协议与状态机对齐")
- 契约:
websocket-keypoints0.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 项)。