Files
zijunchiang 7ebbaef631 feat(net,pose): 连接状态机与保活对齐 + MediaPipe 崩溃修复(T-AND-010/011/012)
契约 websocket-keypoints 0.1.0 -> 0.2.0(向后兼容,子协议名与帧 version 不变;
先在 opendance-web 修改并提升版本,再同步三端快照)。

T-AND-011:连接状态异常、频繁重连(查实 3 个根因)
1. 保活用错了层:Windows 端有 5s 静默超时(只看应用层收到的帧),而本端只有
   OkHttp 的协议层 ping —— 它由底层库自动应答、**不暴露给应用层**,根本不能续命。
   画面里没人时 5s 必被踢、再重连,循环往复。
   - 契约新增 §1.1(welcome/config 声明 silentTimeoutMs 与 heartbeatIntervalMs,
     且心跳必须 < 静默超时的一半)与 §3.6(应用层 ping/pong);
   - Windows 端超时 5s -> 15s、期望心跳 5s,并在 welcome/config 中下发;
   - 本端按声明发 ping,连续 3 个周期无 pong 才判定链路已死并主动重连。
2. 不该重连时也重连:契约新增 §7 关闭码策略,1000/1001/1002 与主动停止一律不重连。
3. frame_id 恒为 0:PoseDetector.detect() 算了 safeTimestamp 却忘了写回
   lastTimestampMs,接收端的丢包检测因此完全失效。已修。

其他对齐:
- 契约新增 §8 连接状态机 idle -> resolving -> connecting -> handshaking ->
  streaming -> degraded -> reconnecting -> stopped,两端同名便于对照日志;
  resolving 只对主机名生效,局域网直连 IP 直接进 connecting;
- 退避与抖动按 §7.1 冻结:0.5s -> 1 -> 2 -> 4 -> 8 -> 15s 封顶 + ±20% 抖动;
- 契约新增 §5 设备去重:hello 携带稳定 deviceId(ANDROID_ID 的 SHA-256 前 16 位,
  不含硬件序列号),Windows 端据此去重、旧连接以 1000 关闭;
- 新增 util/ConnectionLog(环形缓冲,最近 500 条:状态迁移 / close code /
  子协议协商 / 首帧时间 / 保活参数),运行页可一键导出(FileProvider)。

T-AND-012:MediaPipe 初始化崩溃(ExceptionInInitializerError: null)
- 诊断:新增 pose/PoseDiagnostics 展开 cause 链取真因(通常 UnsatisfiedLinkError)、
  区分 native 加载失败、比对设备 ABI 与 APK 内 ABI 并给出可执行结论;
- native 解压:packaging { jniLibs { useLegacyPackaging = true } }(AGP 8 release
  默认 extractNativeLibs=false,MediaPipe JNI 对它不兼容);
- R8:补 -keepclasseswithmembernames 的 native 方法保留规则,并 -printseeds 输出
  seeds 供发布前核对(实测保留 20169 条 MediaPipe 条目);
- 构建期新增 verifyReleaseAbi:release ABI 必须恰为 arm64-v8a/armeabi-v7a、
  每个 ABI 下必须有 MediaPipe native 库、模型资源必须存在;
- 未换库:诊断指向集成配置而非 MediaPipe 本身,保留 33 点契约零成本。

T-AND-010:版本号展示与单一来源
- 版本唯一来源 versionName(= project.yaml 的 version);versionCode 按
  10000*主+100*次+修订 递增(0.2.0 -> 200);
- 契约版本改为构建期解析 contracts/VERSION 后注入 BuildConfig(六项各自独立
  递增,原先的单个 CONTRACT_VERSION 常量已失真);界面不再手写任何版本号;
- 新增 checkReleaseConsistency 并挂到 preBuild,三者不一致直接 BUILD FAILED。

README/DSH/THIRD_PARTY/tasks 同步更新(含「导出连接日志」「保活参数」
「最近断开原因」等新增可观察项与排查表)。

测试:56 项全部通过(新增 ReconnectPolicyTest 11 项、PoseDiagnosticsTest 12 项);
build/verify.sh 全通过(单元测试 -> 构建 -> 签名/ABI/native/seeds/dex 校验)
2026-09-17 01:10:06 +08:00

82 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-AND-011 连接状态与重连修复
- **状态**: done
- **依赖**: T-AND-005、T-AND-006
- **文档**: v1.6 §6.5;契约 `websocket-keypoints` **0.2.0**(本次提升)
## 问题
「连接状态异常、频繁重连」。逐条核对原实现后,找到 **3 个真实根因**,
都不是"退避参数没调好"这种表象问题:
### 根因 1:保活用错了层(最要命)
- 服务端(Windows `pose_server.gd`)有 `CLIENT_TIMEOUT_MS = 5000` 的**静默超时**:
5 秒内没有收到**应用层**的数据帧或文本帧就 `close(1001, "timeout")`;
- App 端只有 OkHttp 的 `pingInterval = 15s`。**WebSocket 协议层的 ping 由底层库
自动应答,不会暴露给接收端的应用层** —— 也就是说它根本不能续命;
- 结果:只要画面里没有人(或相机暂停)而 `stats` 又没发出去,5 秒后必然被踢,
然后重连,循环往复 —— 这就是"频繁重连"。
**修复(两端一起改)**:
- 契约新增 §1.1:接收端必须在 `welcome`/`config` 中声明 `silentTimeoutMs` 与
`heartbeatIntervalMs`,且 **`heartbeatIntervalMs < silentTimeoutMs / 2`**;
- 新增 §3.6 应用层 `ping` / `pong`(协议层 ping 不可见,必须自己定义);
- Windows 端超时从 5s 提到 **15s**、期望心跳 **5s**,并在 `welcome`/`config` 中下发;
- App 端按声明值周期发 `ping`,连续 3 个周期没收到 `pong` 才判定链路已死
并主动重连(而不是干等)。
### 根因 2:不该重连的时候也重连
原 `scheduleReconnect()` 只看 `manualClose`:服务端因重启发的 `1001`、
协议不匹配的 `1002`、乃至收到 `bye` 之后的正常 `1000`,都会触发重连。
**修复**:契约新增 §7 关闭码策略,App 用 `ReconnectPolicy.shouldReconnect()` 判定 ——
`1000` / `1001` / `1002` 与主动停止一律**不**重连。
### 根因 3:frame_id 恒为 0
`PoseDetector.detect()` 计算出 `safeTimestamp` 后**忘了写回 `lastTimestampMs`**,
于是 `handleResult()` 里 `frameId = lastTimestampMs and 0x7FFFFFFF` 永远是 0 ——
接收端据此做的丢包检测完全失效,`frames_dropped` 恒为 0。顺带修掉。
## 其他对齐项
| 项 | 做法 |
|--|--|
| 状态机同名 | 契约新增 §8:`idle → resolving → connecting → handshaking → streaming → degraded → reconnecting → stopped`;App 的 `State` 枚举带 `wireName`,Windows 端 `PoseServer.ClientState` 同名,日志可直接对照 |
| resolving | 主机名不是 IP 字面量时先解析(`InetAddress`),局域网直连 IP 直接进 connecting |
| 退避与抖动 | 契约新增 §7.1 冻结:0.5s→1→2→4→8→15s 封顶 + **±20% 抖动**(避免同网段多机同时重连打尖峰) |
| 设备去重 | 契约新增 §5:hello 携带稳定 `deviceId`(`ANDROID_ID` 的 SHA-256 前 16 位,**不含硬件序列号**);Windows 端按它去重,旧连接以 `1000` 关闭(旧连接不重连),设备数按去重后计数 |
| 可诊断 | 新增 `ConnectionLog`(进程内环形缓冲,最近 500 条):状态迁移、close code/reason、子协议协商结果、首帧时间、保活参数;运行页「导出连接日志」经 FileProvider 分享 |
| 界面 | 运行页显示状态机名、协商子协议、最近断开(close code + 中文原因)、保活参数、心跳 RTT、重试倒计时 |
## 契约变更(`websocket-keypoints` 0.1.0 → 0.2.0)
按文档 §7.2,先在 `opendance-web/contracts/websocket-keypoints.md` 修改并提升版本,
再同步本仓库快照(三端逐字节一致)。
**向后兼容**:子协议名与二进制帧 `version` 均不变,只**新增可选**控制字段与
一种新控制类型;仅实现 v0.1.0 的一端仍可互通(它不发 `ping`、不读
`silentTimeoutMs`,但每帧关键点 + 每秒 `stats` 已足够保活)。
## 验收(文档 §6.5)
| 验收项 | 结果 |
|--|--|
| 连续推流 30 分钟不出现非预期重连 | ⚠️ 需真机 30 分钟实测(本环境无设备);根因 1 已消除,且保活节奏有单测锁定 |
| 首次连接与断网恢复各有明确日志与状态 | ✅ 状态机 + `ConnectionLog`(可导出);`resolving/connecting/handshaking/streaming` 各有记录 |
| 服务端主动关闭 / 客户端主动停止**不**触发重连 | ✅ 契约 §7 策略 + `ReconnectPolicyTest` 逐码断言 |
| Windows 端设备数与手机端一致(重复连接去重) | ✅ 两端都按 `deviceId` 去重;Windows 端 `test_duplicate_device_id_is_deduplicated` 断言旧连接被 `1000` 关闭且设备数仍为 1 |
| 两端协议(子协议名、帧格式、心跳)与契约一致 | ✅ 契约 0.2.0 已冻结;两端快照 md5 相同 |
## 测试
- `ReconnectPolicyTest`(11 项):退避序列逐值、抖动上下界、契约 §7 关闭码策略、
心跳安全性(严格小于静默超时的一半)、越界与非法输入。
- Windows 端 `tests/test_pose_server.gd` 新增 7 项:welcome 声明保活参数、
ping→pong 回显、同 deviceId 去重、状态名与契约一致、
状态随握手/首帧推进到 streaming、丢帧进入 degraded 后恢复、
snapshot 暴露设备数与保活参数。