# 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 场景覆盖,不涉及长等待)。