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:
@@ -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 / 验签 / 包内契约快照与仓库一致
|
||||
# -> 发布包级验收全部通过
|
||||
|
||||
+17
-2
@@ -1,5 +1,5 @@
|
||||
================================================================
|
||||
OpenDance Studio -- Android 辅助 App v0.2.1 使用说明
|
||||
OpenDance Studio -- Android 辅助 App v0.2.2 使用说明
|
||||
================================================================
|
||||
|
||||
手机端姿态采集器:用摄像头做端侧姿态推理,把 33 个骨骼关键点通过
|
||||
@@ -125,6 +125,21 @@ WebSocket 推送给 Windows 客户端,由后者驱动 VRM 角色并实时评
|
||||
App 运行页 「人体识别」为「已识别」,出现骨架叠加(黄点好、红点置信度低)
|
||||
Windows 端 显示已连接并收到关键点,VRM 模型随动作实时动起来
|
||||
|
||||
6.5) v0.2.2 修订
|
||||
|
||||
1. 修一处真实缺陷(T-AND-017): 契约 §3.6 明确「ping 无需额外应答即可保活,
|
||||
pong 只用于诊断」,即"合规但不回 pong"的接收端是合法的;但客户端的
|
||||
onOpen 里把 lastPongAtMs 设成了当前时刻,于是"连续 3 个心跳周期没收到 pong
|
||||
就判定链路已死"对任何接收端都会触发 —— 默认参数下每 15 秒主动断一次并重连,
|
||||
正是"频繁重连"的症状。现改为 0(= 本连接还没收到过 pong),
|
||||
只有"曾经回过 pong、之后又不回了"才判定链路已死。
|
||||
2. 新增可注入计时 Timing(时钟 + 等待),生产默认与原来完全一致;
|
||||
测试注入可控时钟后,契约 §7.1 的退避序列(0.5→1→2→4→8→15s 封顶)能在
|
||||
毫秒级端到端验证,不再需要等 30 秒。
|
||||
3. 新增 4 项 JVM 用例(无需设备):退避封顶序列逐值断言、注入实现不产生真实等待、
|
||||
合规不回 pong 不被误判、回过 pong 后停止回复仍能正确诊断。
|
||||
4. 单元测试 68 项(原 64)。
|
||||
|
||||
6.5) v0.2.1 修订
|
||||
|
||||
1. 重连链路的真实 WebSocket 集成测试(T-AND-016): 用 MockWebServer 的
|
||||
@@ -157,7 +172,7 @@ WebSocket 推送给 Windows 客户端,由后者驱动 VRM 角色并实时评
|
||||
依赖闭包(DT_NEEDED,非系统库必须同 ABI 自带)、页对齐(16 KB 兼容)。
|
||||
|
||||
0) 发布包级验收(对**发布包**而不是本地构建;文档 §7.5)
|
||||
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、解压、核对文件清单、
|
||||
读包内 APK 的 AndroidManifest 版本元数据、核对 ABI 与 MediaPipe native 库、
|
||||
模型资源、dex 里的 PoseLandmarker、验签,以及包内契约快照与仓库是否一致。
|
||||
|
||||
@@ -28,8 +28,8 @@ android {
|
||||
targetSdk = 34
|
||||
// 文档 §11.1:versionCode 用整数按 10000*主 + 100*次 + 修订 递增,
|
||||
// versionName 用 X.Y.Z,两者同一次发布一起更新。
|
||||
versionCode = 201
|
||||
versionName = "0.2.1"
|
||||
versionCode = 202
|
||||
versionName = "0.2.2"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
buildConfigField("String", "WS_SUBPROTOCOL", "\"opendance.pose.v1\"")
|
||||
|
||||
@@ -47,9 +47,35 @@ import kotlin.random.Random
|
||||
* 5. **可诊断**:close code / 子协议协商结果 / 首帧时间写入 [ConnectionLog],
|
||||
* 界面可一键导出。
|
||||
*/
|
||||
/**
|
||||
* 计时与等待的可注入实现(T-AND-017)。
|
||||
*
|
||||
* 为什么需要:[契约 §7.1](../contracts/websocket-keypoints.md) 的退避序列
|
||||
* `0.5s→1→2→4→8→15s 封顶` 若要**端到端**验证,真实等待需要 ~30 秒;
|
||||
* 而"缺 pong 判定链路已死"(连续 3 个心跳周期)也要等几十秒。
|
||||
* 把「现在几点」与「等多久」抽出来之后,测试可以注入可控时钟 + 立即返回的等待,
|
||||
* 在毫秒级把这两条路径跑完整 —— 否则这类逻辑只能靠纯函数单测或真机观察。
|
||||
*
|
||||
* 生产实现就是 [SystemTiming](真实 `System.currentTimeMillis()` + `delay()`),
|
||||
* 行为与抽取前完全一致。
|
||||
*/
|
||||
interface Timing {
|
||||
fun nowMs(): Long
|
||||
suspend fun await(delayMs: Long)
|
||||
}
|
||||
|
||||
object SystemTiming : Timing {
|
||||
override fun nowMs(): Long = System.currentTimeMillis()
|
||||
override suspend fun await(delayMs: Long) {
|
||||
delay(delayMs)
|
||||
}
|
||||
}
|
||||
|
||||
class PoseWebSocketClient(
|
||||
private val scope: CoroutineScope,
|
||||
private val subprotocol: String = BuildConfig.WS_SUBPROTOCOL,
|
||||
/** 计时/等待实现;测试注入可控时钟,生产用 [SystemTiming] */
|
||||
private val timing: Timing = SystemTiming,
|
||||
) {
|
||||
|
||||
/** 契约 §8 的连接状态(wireName 与接收端 GDScript 侧同名,便于对照日志) */
|
||||
@@ -227,7 +253,14 @@ class PoseWebSocketClient(
|
||||
reconnectCounter.set(0)
|
||||
fpsWindowStart = 0L
|
||||
fpsWindowFrames = 0
|
||||
lastPongAtMs = System.currentTimeMillis()
|
||||
// **0 = 本连接还没收到过任何 pong**。
|
||||
// 契约 §3.6 明确:接收端收到任何一帧都算活动,`ping` **无需额外应答**即可保活,
|
||||
// `pong` 只用于诊断 —— 也就是说"合规但不回 pong"的接收端是合法的。
|
||||
// 原先这里写成 `lastPongAtMs = now`,于是下面的"连续 3 个心跳周期没收到 pong
|
||||
// 就判定链路已死"对**任何**接收端都会触发:15 秒重连一次,正是"频繁重连"的症状。
|
||||
// 现在只有"曾经回过 pong、之后又不回了"才会判定链路已死(由 T-AND-017 的
|
||||
// JVM 集成测试两条对应用例锁住)。
|
||||
lastPongAtMs = 0L
|
||||
_stats.value = _stats.value.copy(
|
||||
state = State.HANDSHAKING,
|
||||
url = url,
|
||||
@@ -289,7 +322,7 @@ class PoseWebSocketClient(
|
||||
_stats.value = _stats.value.copy(
|
||||
lastCloseCode = code,
|
||||
lastCloseReason = reason,
|
||||
lastDisconnectAtMs = System.currentTimeMillis(),
|
||||
lastDisconnectAtMs = timing.nowMs(),
|
||||
)
|
||||
ConnectionLog.record("断开:${ReconnectPolicy.closeCodeLabel(code)}")
|
||||
}
|
||||
@@ -312,7 +345,7 @@ class PoseWebSocketClient(
|
||||
ConnectionLog.record("安排第 $attempt 次重连,等待 ${delayMs}ms")
|
||||
|
||||
reconnectJob = scope.launch {
|
||||
delay(delayMs)
|
||||
timing.await(delayMs)
|
||||
if (!manualClose && autoReconnect) {
|
||||
openSocket(initial = false)
|
||||
}
|
||||
@@ -350,13 +383,13 @@ class PoseWebSocketClient(
|
||||
val interval = _stats.value.heartbeatIntervalMs.coerceAtLeast(
|
||||
ReconnectPolicy.MIN_HEARTBEAT_MS,
|
||||
)
|
||||
delay(interval)
|
||||
timing.await(interval)
|
||||
val state = _stats.value.state
|
||||
if (state != State.STREAMING && state != State.DEGRADED && state != State.HANDSHAKING) {
|
||||
continue
|
||||
}
|
||||
sendPing()
|
||||
val silentFor = System.currentTimeMillis() - lastPongAtMs
|
||||
val silentFor = timing.nowMs() - lastPongAtMs
|
||||
val limit = interval * ReconnectPolicy.MISSED_PONG_LIMIT
|
||||
if (lastPongAtMs > 0 && silentFor > limit) {
|
||||
ConnectionLog.record(
|
||||
@@ -379,7 +412,7 @@ class PoseWebSocketClient(
|
||||
sendJson(
|
||||
JSONObject().apply {
|
||||
put("type", "ping")
|
||||
put("sentAtMs", System.currentTimeMillis())
|
||||
put("sentAtMs", timing.nowMs())
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -416,7 +449,7 @@ class PoseWebSocketClient(
|
||||
|
||||
sentCounter.incrementAndGet()
|
||||
if (_stats.value.firstFrameAtMs == 0L) {
|
||||
val now = System.currentTimeMillis()
|
||||
val now = timing.nowMs()
|
||||
_stats.value = _stats.value.copy(firstFrameAtMs = now)
|
||||
ConnectionLog.record("streaming:首帧已发出")
|
||||
}
|
||||
@@ -425,7 +458,7 @@ class PoseWebSocketClient(
|
||||
}
|
||||
|
||||
fpsWindowFrames++
|
||||
val now = System.currentTimeMillis()
|
||||
val now = timing.nowMs()
|
||||
if (fpsWindowStart == 0L) fpsWindowStart = now
|
||||
val elapsed = now - fpsWindowStart
|
||||
val fps = if (elapsed >= FPS_WINDOW_MS) {
|
||||
@@ -547,10 +580,10 @@ class PoseWebSocketClient(
|
||||
onRemoteConfig?.invoke(json)
|
||||
}
|
||||
"pong" -> {
|
||||
lastPongAtMs = System.currentTimeMillis()
|
||||
lastPongAtMs = timing.nowMs()
|
||||
pongCounter.incrementAndGet()
|
||||
val sentAt = json.optLong("sentAtMs", 0L)
|
||||
val rtt = if (sentAt > 0) System.currentTimeMillis() - sentAt else -1L
|
||||
val rtt = if (sentAt > 0) timing.nowMs() - sentAt else -1L
|
||||
if (rtt >= 0) {
|
||||
_stats.value = _stats.value.copy(
|
||||
roundTripMs = rtt,
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
package com.opendance.studio
|
||||
|
||||
import com.opendance.studio.net.PoseWebSocketClient
|
||||
import com.opendance.studio.net.ReconnectPolicy
|
||||
import com.opendance.studio.net.Timing
|
||||
import com.opendance.studio.util.ConnectionLog
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okhttp3.mockwebserver.SocketPolicy
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import java.util.Collections
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
/**
|
||||
* T-AND-017:**退避封顶**与**缺 pong 判定链路已死**的端到端验证(JVM,无需设备)
|
||||
*
|
||||
* 为什么单有 `ReconnectPolicyTest` 不够:`ReconnectPolicy` 只是纯函数
|
||||
* 「第 n 次应该等多久」,而契约 §7.1 真正承诺的是**整条序列**
|
||||
* `0.5s → 1 → 2 → 4 → 8 → 15s 封顶`(之后一直是 15s)——
|
||||
* 那需要连续 7 次失败重连,真实等待约 **30 秒**,写在集成测试里既慢又易 flaky。
|
||||
*
|
||||
* 因此给 [PoseWebSocketClient] 抽了一个可注入的 [Timing]
|
||||
* (「现在几点」+「等多久」)。生产默认是真实时钟与 `delay()`,行为不变;
|
||||
* 测试注入**可控时钟 + 立即返回的等待**,于是:
|
||||
* * 30 秒的退避序列可以在毫秒级跑完并逐值断言;
|
||||
* * 「连续 3 个心跳周期没收到 pong 就判定链路已死」也能在不等待的情况下触发。
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class PoseWebSocketBackoffTest {
|
||||
|
||||
/** 可控时钟 + 立即返回的等待:记录每次等待时长,并推进虚拟时钟 */
|
||||
private class FakeTiming : Timing {
|
||||
var now = 1_000_000L
|
||||
val awaited = Collections.synchronizedList(mutableListOf<Long>())
|
||||
|
||||
override fun nowMs(): Long = now
|
||||
|
||||
override suspend fun await(delayMs: Long) {
|
||||
awaited.add(delayMs)
|
||||
now += delayMs.coerceAtLeast(1L)
|
||||
// 只让出 2ms *真实*时间:网络回调(pong / welcome)需要有机会落地,
|
||||
// 否则心跳循环会在 pong 到达之前就把虚拟时钟推过阈值。
|
||||
// 2ms × 个位数次数,整体仍远小于真实退避的 30 秒。
|
||||
kotlinx.coroutines.delay(2)
|
||||
}
|
||||
|
||||
fun snapshot(): List<Long> = synchronized(awaited) { awaited.toList() }
|
||||
}
|
||||
|
||||
private lateinit var server: MockWebServer
|
||||
private lateinit var scope: CoroutineScope
|
||||
private val clients = mutableListOf<PoseWebSocketClient>()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
ConnectionLog.clear()
|
||||
server = MockWebServer()
|
||||
server.start()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
clients.forEach { runCatching { it.disconnect() } }
|
||||
scope.cancel()
|
||||
runCatching { server.shutdown() }
|
||||
ConnectionLog.clear()
|
||||
}
|
||||
|
||||
private fun newClient(timing: Timing): PoseWebSocketClient {
|
||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
return PoseWebSocketClient(scope, timing = timing).also { clients.add(it) }
|
||||
}
|
||||
|
||||
private fun wsUrl(): String = server.url("/pose").toString().replace("http://", "ws://")
|
||||
|
||||
private fun waitUntil(timeoutMs: Long = 8_000, what: String, cond: () -> Boolean) {
|
||||
val deadline = System.currentTimeMillis() + timeoutMs
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
if (cond()) return
|
||||
Thread.sleep(10)
|
||||
}
|
||||
throw AssertionError("等待超时(${timeoutMs}ms):$what")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `退避序列在 15 秒封顶,且不会无限增长`() {
|
||||
// 每个连接都直接失败:用 SocketPolicy.DISCONNECT_AT_START(升级前断开)
|
||||
repeat(12) {
|
||||
server.enqueue(MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AT_START))
|
||||
}
|
||||
val timing = FakeTiming()
|
||||
val client = newClient(timing)
|
||||
val t0 = System.currentTimeMillis()
|
||||
client.connect(wsUrl())
|
||||
|
||||
// 等到第 8 次重连被安排(真实等待为 0,因为 FakeTiming 立即返回)
|
||||
waitUntil(what = "安排到第 8 次重连") { client.stats.value.reconnectAttempts >= 8 }
|
||||
val elapsed = System.currentTimeMillis() - t0
|
||||
|
||||
val delays = timing.snapshot()
|
||||
assertTrue("应记录到至少 8 次等待(实际 ${delays.size})", delays.size >= 8)
|
||||
|
||||
// 契约 §7.1:min(500ms * 2^(n-1), 15000ms) × (1 ± 20%)
|
||||
val expectedBase = listOf(500L, 1_000L, 2_000L, 4_000L, 8_000L, 15_000L, 15_000L, 15_000L)
|
||||
for ((index, base) in expectedBase.withIndex()) {
|
||||
val actual = delays[index]
|
||||
val low = (base * (1 - ReconnectPolicy.JITTER_RATIO)).toLong()
|
||||
val high = (base * (1 + ReconnectPolicy.JITTER_RATIO)).toLong()
|
||||
assertTrue(
|
||||
"第 ${index + 1} 次重连等待应落在 [$low, $high](基础值 ${base}ms),实际 ${actual}ms",
|
||||
actual in low..high,
|
||||
)
|
||||
}
|
||||
assertTrue(
|
||||
"封顶后必须一直是 15000ms(不得增长);实测前 8 次:$delays",
|
||||
delays.take(8).drop(5).all { it in 12_000L..18_000L },
|
||||
)
|
||||
assertTrue(
|
||||
"虚拟时钟应被推着走(证明等待走的是注入实现而非真实 delay);" +
|
||||
"真实耗时 ${elapsed}ms 应远小于 30 秒",
|
||||
elapsed < 5_000,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `等待走注入实现——真实 delay 不会被调用(耗时对照)`() {
|
||||
repeat(4) { server.enqueue(MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AT_START)) }
|
||||
val timing = FakeTiming()
|
||||
val client = newClient(timing)
|
||||
val t0 = System.currentTimeMillis()
|
||||
client.connect(wsUrl())
|
||||
waitUntil(what = "安排到第 3 次重连") { client.stats.value.reconnectAttempts >= 3 }
|
||||
val elapsed = System.currentTimeMillis() - t0
|
||||
// 真实退避前三次合计 3.5 秒;注入实现下应远小于 1 秒
|
||||
assertTrue("注入 FakeTiming 后不应真的等待(实测 ${elapsed}ms)", elapsed < 1_500)
|
||||
// 注意:不要在瞬时重连下断言 stats.nextRetryMs —— 等待立即返回后,
|
||||
// 客户端已经进入下一次尝试并把该字段清零,读取会与重连循环竞争(第一版就踩了)。
|
||||
// 等待时长本身由注入实现记录,是可靠的事实来源。
|
||||
assertTrue("注入实现应记录到等待(实际 ${timing.snapshot()})", timing.snapshot().size >= 3)
|
||||
}
|
||||
|
||||
/** 造一个只回 welcome、pong 行为可配置的接收端 */
|
||||
private fun enqueueReceiver(replyToPings: (n: Int) -> Boolean) {
|
||||
val pingCount = AtomicInteger(0)
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setHeader("Sec-WebSocket-Protocol", "opendance.pose.v1")
|
||||
.withWebSocketUpgrade(object : WebSocketListener() {
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
when {
|
||||
text.contains("\"hello\"") -> webSocket.send(
|
||||
"""{"type":"welcome","protocol":"opendance.pose.v1",""" +
|
||||
""""sessionId":"s1","expectedFps":30,""" +
|
||||
""""silentTimeoutMs":3000,"heartbeatIntervalMs":1000}""",
|
||||
)
|
||||
text.contains("\"ping\"") -> {
|
||||
if (replyToPings(pingCount.incrementAndGet())) {
|
||||
webSocket.send("""{"type":"pong","sentAtMs":1,"serverAtMs":2}""")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 契约 §3.6:接收端收到任何一帧都算活动,**`ping` 无需额外应答即可保活**,
|
||||
* `pong` 只用于诊断 —— 所以"合规但不回 pong"的接收端**不能**被判定为链路已死。
|
||||
*
|
||||
* 这是本轮修掉的一个真实缺陷:`onOpen` 里把 `lastPongAtMs` 设成了当前时刻,
|
||||
* 于是"连续 3 个心跳周期没收到 pong"对任何接收端都会触发 —— 默认参数下
|
||||
* **每 15 秒主动断一次并重连**,正是用户抱怨的"频繁重连"。
|
||||
*/
|
||||
@Test
|
||||
fun `合规但从不回 pong 的接收端不会被判定链路已死`() {
|
||||
enqueueReceiver { false } // 从不回 pong
|
||||
val timing = FakeTiming()
|
||||
val client = newClient(timing)
|
||||
client.connect(wsUrl())
|
||||
waitUntil(what = "进入 streaming") { client.stats.value.state == PoseWebSocketClient.State.STREAMING }
|
||||
|
||||
// 让虚拟时钟走过 6 个心跳周期(远超 MISSED_PONG_LIMIT=3)
|
||||
waitUntil(timeoutMs = 10_000, what = "心跳发出 6 次") { timing.snapshot().size >= 6 }
|
||||
assertTrue(
|
||||
"不应出现「判定链路已死」(契约 §3.6:pong 可选):${ConnectionLog.entries()}",
|
||||
ConnectionLog.entries().none { it.contains("判定链路已死") },
|
||||
)
|
||||
assertEquals("连接应仍然打开", PoseWebSocketClient.State.STREAMING, client.stats.value.state)
|
||||
assertEquals("本连接一次 pong 都没收到", 0, client.stats.value.pongsReceived)
|
||||
}
|
||||
|
||||
/** 反过来:曾经回过 pong、之后不回了 —— 这时"链路已死"的判断是有效的诊断。 */
|
||||
@Test
|
||||
fun `曾经回过 pong 之后停止回复时判定链路已死并重连`() {
|
||||
enqueueReceiver { n -> n == 1 } // 只在第一次回 pong
|
||||
val timing = FakeTiming()
|
||||
val client = newClient(timing)
|
||||
client.connect(wsUrl())
|
||||
waitUntil(what = "进入 streaming") { client.stats.value.state == PoseWebSocketClient.State.STREAMING }
|
||||
|
||||
waitUntil(timeoutMs = 10_000, what = "出现「判定链路已死」日志") {
|
||||
ConnectionLog.entries().any { it.contains("判定链路已死") }
|
||||
}
|
||||
assertTrue(
|
||||
"日志应说明是连续 ${ReconnectPolicy.MISSED_PONG_LIMIT} 个周期未收到 pong",
|
||||
ConnectionLog.entries().any {
|
||||
it.contains("连续 ${ReconnectPolicy.MISSED_PONG_LIMIT} 个心跳周期未收到 pong")
|
||||
},
|
||||
)
|
||||
waitUntil(timeoutMs = 10_000, what = "进入重连") {
|
||||
client.stats.value.state == PoseWebSocketClient.State.RECONNECTING ||
|
||||
client.stats.value.reconnectAttempts > 0
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-2
@@ -1,5 +1,5 @@
|
||||
project: opendance-android-app
|
||||
version: 0.2.1
|
||||
version: 0.2.2
|
||||
repository: opendance-android
|
||||
default_branch: main
|
||||
platform: Android 10+(minSdk 29 / targetSdk 34)
|
||||
@@ -90,9 +90,13 @@ tasks:
|
||||
file: tasks/016-reconnect-integration.md
|
||||
scope: JVM 内以真实 WebSocket(MockWebServer)验证重连链路 + 修「上一次关闭码污染下一次会话」
|
||||
status: done
|
||||
- id: T-AND-017
|
||||
file: tasks/017-backoff-and-pong.md
|
||||
scope: 可注入计时 + 退避封顶端到端验证 + 修「合规不回 pong 的接收端被周期性误判链路已死」
|
||||
status: done
|
||||
|
||||
deliverable:
|
||||
artifact: opendance-android-v0.2.1.apk
|
||||
artifact: opendance-android-v0.2.2.apk
|
||||
packaging: Gradle 命令行 / Android Studio
|
||||
includes:
|
||||
- app-release.apk
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# 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 场景覆盖,不涉及长等待)。
|
||||
Reference in New Issue
Block a user