一、测试抓到的真实缺陷(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),
否则心跳循环会在网络回调落地前把时钟推过阈值。
4.5 KiB
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:
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 里写的是:
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)。
四、写测试踩的两个坑
- 不要在瞬时重连下读
stats.nextRetryMs:等待立即返回后,客户端已经进入下一次 尝试并把该字段清零,断言会与重连循环竞争(第一版就失败了)。 等待时长应取注入实现记录的事实,而不是状态字段的瞬时值。 - 虚拟时钟需要一个真实让出点:
FakeTiming.await若只推虚拟时钟而不让出, 心跳循环会在网络回调(pong/welcome)落地之前就把时钟推过阈值, 于是「回过一次 pong」的用例永远等不到那次 pong。加 2ms 真实delay即可, 整体耗时仍远小于真实退避的 30 秒。
五、未覆盖(诚实声明)
- 真机 Wi-Fi 抖动 / 切网 / 进程被杀 / Doze 省电仍需设备实测;
Timing只覆盖「时钟与等待」;OkHttp 自身的连接超时/写超时仍走真实时间 (那部分由 MockWebServer 场景覆盖,不涉及长等待)。