Files
opendance-android/tasks/017-backoff-and-pong.md
T
zijunchiang c0d0937023 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),
     否则心跳循环会在网络回调落地前把时钟推过阈值。
2026-09-18 02:34:54 +08:00

83 lines
4.5 KiB
Markdown
Raw 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-017:退避封顶与「pong 可选」语义的 JVM 验证(+ 修一处真实缺陷)
- **状态**: done
- **依赖**: T-AND-011(连接状态机与重连)/ T-AND-016(重连链路集成测试)
- **文档**: v1.6 §6.5 / §7.6.1
- **契约**: `websocket-keypoints 0.2.0` §1.1 / §3.6 / §7.1
## 一、可注入的计时(`Timing`)
契约 §7.1 的退避序列 `0.5s→1→2→4→8→15s 封顶` 若要**端到端**验证,真实等待约 **30 秒**;
「连续 3 个心跳周期没收到 pong」也要等几十秒。这类时限逻辑写在测试里既慢又易 flaky,
于是给 `PoseWebSocketClient` 抽了一个可注入的 `Timing`:
```kotlin
interface Timing {
fun nowMs(): Long
suspend fun await(delayMs: Long)
}
object SystemTiming : Timing { // 生产默认:真实时钟 + 真实 delay
override fun nowMs(): Long = System.currentTimeMillis()
override suspend fun await(delayMs: Long) { delay(delayMs) }
}
```
构造函数新增 `timing: Timing = SystemTiming`,行为与抽取前**完全一致**(10 处
`System.currentTimeMillis()` 与 2 处 `delay()` 改走 `timing`)。
测试注入「可控时钟 + 立即返回的等待」,于是:
* 30 秒的退避序列在**毫秒级**跑完并逐值断言(0.5/1/2/4/8/15/15s,各自 ±20% 抖动区间);
* 「缺 pong 判定链路已死」不用等待即可触发。
## 二、测试抓到的真实缺陷:**合规的接收端会被周期性误判为链路已死**
契约 §3.6 明确:
> 接收端收到任何一帧(二进制或文本)都算活动,`ping` **无需额外应答**即可保活;
> `pong` 只用于诊断。
也就是说「合规但不回 `pong`」的接收端是**合法**的。但 `onOpen` 里写的是:
```kotlin
lastPongAtMs = timing.nowMs() // ← 缺陷:连接一建立就等于"刚收到过 pong"
```
于是心跳循环里的守卫 `if (lastPongAtMs > 0 && silentFor > limit)` **恒为真**,
对**任何**接收端都会触发「判定链路已死 → 主动断链 → 重连」:
默认参数(`heartbeatIntervalMs = 5000`)下即 **每 15 秒断一次**,
正是用户抱怨的「频繁重连」。
**为什么以前没暴露**:我们自己的接收端(Windows `PoseServer`)**会**回 `pong`
(T-WIN-020 实测 9 次 ping 全部收到 pong),所以这条路径在自家联调里永远走不到;
只有契约允许的另一种实现才会踩。
**修复**:`onOpen` 复位为 `lastPongAtMs = 0L`(0 = 本连接还没收到过 pong)。
只有「曾经回过 pong、之后又不回了」才判定链路已死 —— 这正是该诊断本来的意图。
## 三、用例(`PoseWebSocketBackoffTest`,4 项)
| 用例 | 验的是什么 |
|--|--|
| 退避序列在 15 秒封顶,且不会无限增长 | 注入 `FakeTiming` 后用真实 MockWebServer 制造连续失败,断言第 1..8 次等待分别落在 500/1000/2000/4000/8000/15000/15000/15000 ±20% 内;**且真实耗时 < 5 秒** |
| 等待走注入实现——真实 delay 不会被调用 | 注入实现下前三次重连的真实耗时应 < 1.5 秒(真实退避需要 3.5 秒) |
| **合规但从不回 pong 的接收端不会被判定链路已死** | 契约 §3.6 语义:走过 6 个心跳周期后仍为 `streaming`、无「判定链路已死」日志(**修复前此处失败**) |
| 曾经回过 pong 之后停止回复时判定链路已死并重连 | 诊断仍然有效:日志出现「连续 3 个心跳周期未收到 pong」,随后进入重连 |
实测:`./gradlew :app:testDebugUnitTest` → **68 项用例 / 0 失败**(原 64)。
## 四、写测试踩的两个坑
1. **不要在瞬时重连下读 `stats.nextRetryMs`**:等待立即返回后,客户端已经进入下一次
尝试并把该字段清零,断言会与重连循环竞争(第一版就失败了)。
等待时长应取**注入实现记录的事实**,而不是状态字段的瞬时值。
2. **虚拟时钟需要一个真实让出点**:`FakeTiming.await` 若只推虚拟时钟而不让出,
心跳循环会在网络回调(`pong` / `welcome`)落地之前就把时钟推过阈值,
于是「回过一次 pong」的用例永远等不到那次 pong。加 2ms 真实 `delay` 即可,
整体耗时仍远小于真实退避的 30 秒。
## 五、未覆盖(诚实声明)
* 真机 Wi-Fi 抖动 / 切网 / 进程被杀 / Doze 省电仍需设备实测;
* `Timing` 只覆盖「时钟与等待」;OkHttp 自身的连接超时/写超时仍走真实时间
(那部分由 MockWebServer 场景覆盖,不涉及长等待)。