fix(reconnect): 合规接收端被周期性误判「链路已死」(v0.2.2)+ 可注入计时覆盖退避封顶

一、测试抓到的真实缺陷(T-AND-017)
契约 §3.6 明确:「接收端收到任何一帧都算活动,ping **无需额外应答**即可保活,
pong 只用于诊断」—— 即"合规但不回 pong"的接收端是**合法**的。
但 PoseWebSocketClient.onOpen 里写的是 lastPongAtMs = timing.nowMs(),
于是心跳循环的守卫 if (lastPongAtMs > 0 && silentFor > limit) **恒为真**:
对任何接收端都会「判定链路已死 → 主动断链 → 重连」,默认参数
(heartbeatIntervalMs=5000)下即**每 15 秒断一次**,正是用户抱怨的「频繁重连」。

为什么以前没暴露:我们自己的接收端(Windows PoseServer)**会**回 pong
(T-WIN-020 实测 9 次 ping 全部收到 pong),这条路径在自家联调里永远走不到。

修复:onOpen 复位 lastPongAtMs = 0L(0 = 本连接还没收到过 pong),
只有「曾经回过 pong、之后又不回了」才判定链路已死 —— 这正是该诊断本来的意图。

二、可注入计时 Timing(时钟 + 等待)
契约 §7.1 的退避序列 0.5s→1→2→4→8→15s 封顶若要端到端验证,真实等待约 30 秒;
「连续 3 个心跳周期没收到 pong」也要等几十秒。抽成接口后:
  * 生产默认 SystemTiming = 真实时钟 + 真实 delay,行为与抽取前完全一致
    (10 处 System.currentTimeMillis() 与 2 处 delay() 改走 timing);
  * 测试注入「可控时钟 + 立即返回的等待」,毫秒级跑完整条序列。

三、新增 4 项 JVM 用例(PoseWebSocketBackoffTest,无需设备)
  * 退避序列在 15 秒封顶且不无限增长:连续失败下断言第 1..8 次等待分别落在
    500/1000/2000/4000/8000/15000/15000/15000 的 ±20% 区间内,且真实耗时 < 5 秒;
  * 等待走注入实现:前三次重连真实耗时 < 1.5 秒(真实退避需 3.5 秒);
  * **合规但从不回 pong 的接收端不会被判定链路已死**(修复前此处失败):
    走过 6 个心跳周期仍为 streaming、无「判定链路已死」日志;
  * 曾经回过 pong 之后停止回复时判定链路已死并重连:诊断仍然有效。

实测:./gradlew :app:testDebugUnitTest → 7 个测试类 / 68 项用例 / 0 失败(原 64)。
build/verify.sh 全绿(签名、APK 元数据 0.2.2/202、native 体检、ABI、R8、dex)。
版本号 0.2.1/201 → 0.2.2/202。

写测试踩的两个坑(已写入任务文档):
  1. 不要在瞬时重连下读 stats.nextRetryMs(等待立即返回后已被清零,与重连循环竞争),
     等待时长应取注入实现记录的事实;
  2. 虚拟时钟需要一个真实让出点(FakeTiming.await 里 2ms 真实 delay),
     否则心跳循环会在网络回调落地前把时钟推过阈值。
This commit is contained in:
2026-09-18 02:34:54 +08:00
parent 6dfd90f7ca
commit c0d0937023
7 changed files with 408 additions and 21 deletions
+27 -5
View File
@@ -9,20 +9,42 @@ OpenDance Studio **阶段三**: Android 辅助 App(Kotlin + CameraX + MediaPipe
## 状态
**阶段三已完成**: T-AND-001 -> T-AND-016 全部落地(含重连状态机与**真实 WebSocket 重连链路测试**、MediaPipe 崩溃修复、APK 产物版本元数据校验、native 库体检)。
**阶段三已完成**: T-AND-001 -> T-AND-017 全部落地(含重连状态机与**真实 WebSocket 重连链路测试**、MediaPipe 崩溃修复、APK 产物版本元数据校验、native 库体检)。
| 验收项 | 结果 |
|--|--|
| 单元测试 | OK **64 项全部通过**(JVM,无需设备;含 MockWebServer 真实 WebSocket 的重连链路集成测试) |
| 单元测试 | OK **68 项全部通过**(JVM,无需设备;含 MockWebServer 真实 WebSocket 的重连链路集成测试) |
| **跨端金标准** | OK Kotlin 序列化器与 Web 端 TypeScript 实现**逐位一致** |
| `./gradlew assembleRelease` | OK 生成**已签名** APK(28.6 MB) |
| 签名校验 | OK `apksigner` 退出码 0, v2 方案,CN=OpenDance Studio Dev |
| 包信息 | OK `com.opendance.studio` v0.2.1 (versionCode 201), minSdk 29(Android 10+)/ targetSdk 34 |
| 包信息 | OK `com.opendance.studio` v0.2.2 (versionCode 202), minSdk 29(Android 10+)/ targetSdk 34 |
| 模型随包分发 | OK `assets/pose_landmarker_lite.task` 5.7 MB(Stored 未压缩) |
| ABI 覆盖 | OK arm64-v8a + armeabi-v7a(debug 额外含 x86/x86_64) |
| APK 版本元数据 | OK 直接读产物 `AndroidManifest` 的 `versionName=0.2.1`/`versionCode=201`,与 `project.yaml` 一致(T-AND-013) |
| APK 版本元数据 | OK 直接读产物 `AndroidManifest` 的 `versionName=0.2.2`/`versionCode=202`,与 `project.yaml` 一致(T-AND-013) |
| native 库体检 | OK ELF 类型匹配双 ABI、`DT_NEEDED` 闭包完整(无缺失依赖)、`PT_LOAD` 页对齐 `0x4000`(16 KB 页兼容)、`zipalign -P 16` 通过(T-AND-015) |
## v0.2.2 修订(退避封顶端到端验证 + 修「合规接收端被周期性误判链路已死」)
**缺陷(T-AND-017)**:契约 §3.6 明确「接收端收到任何一帧都算活动,`ping` **无需额外应答**
即可保活,`pong` 只用于诊断」—— 即"合规但不回 `pong`"的接收端是**合法**的。
但 `onOpen` 里写的是 `lastPongAtMs = now`,于是心跳循环的守卫
`if (lastPongAtMs > 0 && silentFor > limit)` **恒为真**:对任何接收端都会
「判定链路已死 → 主动断链 → 重连」,默认参数下**每 15 秒一次** —— 正是"频繁重连"的症状。
**为什么以前没暴露**:我们自己的 Windows 接收端**会**回 `pong`,这条路径在自家联调里走不到。
**修复**:`onOpen` 复位 `lastPongAtMs = 0L`(0 = 本连接还没收到过 pong),
只有"曾经回过 pong、之后又不回了"才判定链路已死。
**新增可注入计时 `Timing`**(时钟 + 等待,生产默认与原来完全一致):
契约 §7.1 的退避序列真实等待约 30 秒,写在测试里既慢又易 flaky。
注入可控时钟后,`PoseWebSocketBackoffTest`(4 项,无需设备)可以在**毫秒级**断言
`0.5→1→2→4→8→15→15s`(各 ±20% 抖动区间)并确认没有真实等待;
另有两条用例分别锁住「合规不回 pong **不**被误判」与「回过 pong 后停止回复仍能正确诊断」。
```
$ ./gradlew :app:testDebugUnitTest
测试类 7 个,用例 68 项,失败 0 # 原 64 项
```
## v0.2.1 修订(重连链路的真实 WebSocket 集成测试 + 一处真实缺陷)
**缺陷**:上一次会话的关闭码会**污染下一次会话**,导致重新开始推流后失去自动重连能力。
@@ -295,7 +317,7 @@ python3 build/check_native_libs.py dist-release/opendance-android-v0.2.1/app-rel
而用户装的是 zip 里的 `app-release.apk`。发布前对包再跑一次:
```bash
bash build/accept-release.sh dist-release/opendance-android-v0.2.1.zip
bash build/accept-release.sh dist-release/opendance-android-v0.2.2.zip
# -> sha256 / 文件清单 / AndroidManifest versionName+versionCode / ABI+MediaPipe native
# / 模型资源 / dex 中的 PoseLandmarker / 验签 / 包内契约快照与仓库一致
# -> 发布包级验收全部通过