fix(reconnect): 上一次会话的关闭码污染下一次会话(v0.2.1)+ 重连链路的真实 WebSocket 集成测试

ReconnectPolicyTest(11 项)验的是**纯函数**「给定关闭码应该怎么退避」,而 ⑦
「连接频繁重连」的缺陷往往出在**调用点**。新增 PoseWebSocketReconnectTest(8 项):
用 MockWebServer.withWebSocketUpgrade 在 JVM 上起**真实** WebSocket 服务端
(能按 §3.2 回 welcome、按 §3.6 回 pong、按指定关闭码关连接),无需设备即可覆盖
「该重连的」与「不该重连的」两条分支。Robolectric 只用来提供 android.os.Build 桩。

它抓到的真实缺陷(本版修复):
  上一次会话的关闭码会污染下一次会话,导致重新开始推流后失去自动重连能力。
  复现:第一次连接被对端以 1001 关闭(按 §7 不重连,正确)→ 用户再点「开始推流」
  → 这一次是网络层失败(服务端还没起来),按 §7 **应当**重连 → 但 connect() 只重置了
  reconnectCounter 与 lastPongAtMs,**没有清 lastCloseCode** → scheduleReconnect()
  仍按 1001 判定 → 直接 stopped,**再也不会自动重连**。
  测试失败信息(修复前):
    ✗ 上一次 1001 关闭后重新连接,遇到网络失败仍应按策略重连
      AssertionError: 当前 state=STOPPED,lastCloseCode=1001,attempts=0

修复(PoseWebSocketClient.kt):
  * connect():清掉会话级关闭状态(lastCloseCode=0、lastCloseReason="");
  * onFailure():记录 recordClose(0, …)(契约 §7 中 0=未收到关闭帧 → 应重连),
    顺带让界面「最近一次断开原因」显示本次失败而不是旧值。

用例清单:握手→streaming 与 welcome 保活参数解析、按协商间隔发 ping 收 pong、
1011 按退避重连且等待落在策略 ±20% 区间、成功后计数清零、1001/1002 不重连、
主动断开不重连、上一次 1001 后再连遇网络失败仍应重连(本次回归锁)、
服务端未启动时 1.5s 内 ≤3 次尝试(退避生效)。

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

踩坑:第一版 MockWebServer 夹具的 101 响应没有回显 Sec-WebSocket-Protocol
(RFC 6455 要求),客户端记录的 negotiatedProtocol 为空导致断言失败 —— 那是
**夹具**不合规而非客户端问题,已在夹具里补上。写「真实对端」夹具时要按 RFC/契约
把服务端做对,否则会把夹具缺陷误判成产品缺陷。
This commit is contained in:
2026-09-17 05:44:03 +08:00
parent 95429e8ce3
commit 6dfd90f7ca
7 changed files with 462 additions and 16 deletions
+31 -10
View File
@@ -9,20 +9,41 @@ OpenDance Studio **阶段三**: Android 辅助 App(Kotlin + CameraX + MediaPipe
## 状态 ## 状态
**阶段三已完成**: T-AND-001 -> T-AND-013 全部落地(含重连状态机、MediaPipe 崩溃修复、APK 产物版本元数据校验)。 **阶段三已完成**: T-AND-001 -> T-AND-016 全部落地(含重连状态机与**真实 WebSocket 重连链路测试**、MediaPipe 崩溃修复、APK 产物版本元数据校验、native 库体检)。
| 验收项 | 结果 | | 验收项 | 结果 |
|--|--| |--|--|
| 单元测试 | OK **56 项全部通过**(JVM,无需设备;新增重连策略与 MediaPipe 诊断用例) | | 单元测试 | OK **64 项全部通过**(JVM,无需设备;含 MockWebServer 真实 WebSocket 的重连链路集成测试) |
| **跨端金标准** | OK Kotlin 序列化器与 Web 端 TypeScript 实现**逐位一致** | | **跨端金标准** | OK Kotlin 序列化器与 Web 端 TypeScript 实现**逐位一致** |
| `./gradlew assembleRelease` | OK 生成**已签名** APK(28.6 MB) | | `./gradlew assembleRelease` | OK 生成**已签名** APK(28.6 MB) |
| 签名校验 | OK `apksigner` 退出码 0, v2 方案,CN=OpenDance Studio Dev | | 签名校验 | OK `apksigner` 退出码 0, v2 方案,CN=OpenDance Studio Dev |
| 包信息 | OK `com.opendance.studio` v0.2.0, minSdk 29(Android 10+)/ targetSdk 34 | | 包信息 | OK `com.opendance.studio` v0.2.1 (versionCode 201), minSdk 29(Android 10+)/ targetSdk 34 |
| 模型随包分发 | OK `assets/pose_landmarker_lite.task` 5.7 MB(Stored 未压缩) | | 模型随包分发 | OK `assets/pose_landmarker_lite.task` 5.7 MB(Stored 未压缩) |
| ABI 覆盖 | OK arm64-v8a + armeabi-v7a(debug 额外含 x86/x86_64) | | ABI 覆盖 | OK arm64-v8a + armeabi-v7a(debug 额外含 x86/x86_64) |
| APK 版本元数据 | OK 直接读产物 `AndroidManifest` 的 `versionName=0.2.0`/`versionCode=200`,与 `project.yaml` 一致(T-AND-013) | | APK 版本元数据 | OK 直接读产物 `AndroidManifest` 的 `versionName=0.2.1`/`versionCode=201`,与 `project.yaml` 一致(T-AND-013) |
| native 库体检 | OK ELF 类型匹配双 ABI、`DT_NEEDED` 闭包完整(无缺失依赖)、`PT_LOAD` 页对齐 `0x4000`(16 KB 页兼容)、`zipalign -P 16` 通过(T-AND-015) | | native 库体检 | OK ELF 类型匹配双 ABI、`DT_NEEDED` 闭包完整(无缺失依赖)、`PT_LOAD` 页对齐 `0x4000`(16 KB 页兼容)、`zipalign -P 16` 通过(T-AND-015) |
## v0.2.1 修订(重连链路的真实 WebSocket 集成测试 + 一处真实缺陷)
**缺陷**:上一次会话的关闭码会**污染下一次会话**,导致重新开始推流后失去自动重连能力。
复现路径:第一次连接被对端以 `1001` 关闭(按契约 §7 **不重连**,正确)→ 用户再次点
「开始推流」→ 这一次是**网络层失败**(服务端还没起来),按 §7 **应当**重连 →
但 `connect()` 只重置了重连计数与 pong 时间,**没有清 `lastCloseCode`**,
`scheduleReconnect()` 仍按 1001 判定 → 直接 `stopped`,**再也不会自动重连**。
**修复**:`connect()` 清掉会话级关闭状态(`lastCloseCode = 0`、`lastCloseReason = ""`);
`onFailure()` 记录 `recordClose(0, …)`(0 = 未收到关闭帧 → 应重连),
顺带让界面「最近一次断开原因」显示本次失败而不是旧值。
**为什么之前没发现**:`ReconnectPolicyTest`(11 项)验的是**纯函数**「给定关闭码怎么退避」,
而缺陷在**调用点**(关闭码没被正确重置)。新增
`app/src/test/java/com/opendance/studio/PoseWebSocketReconnectTest.kt`(8 项):
用 `MockWebServer.withWebSocketUpgrade` 在 JVM 上起**真实** WebSocket 服务端,
覆盖「该重连的」(1011 / 服务器未启动)与「不该重连的」(1000/1001/1002 / 主动断开)两条分支,
并断言重连等待取自策略且落在 ±20% 区间、成功后计数清零。
```
## 快速开始 ## 快速开始
```bash ```bash
@@ -261,7 +282,7 @@ app/src/main/java/com/opendance/studio/
问题大多出在 native 库打包上,而这几项**无设备也能查**: 问题大多出在 native 库打包上,而这几项**无设备也能查**:
```bash ```bash
python3 build/check_native_libs.py dist-release/opendance-android-v0.2.0/app-release.apk python3 build/check_native_libs.py dist-release/opendance-android-v0.2.1/app-release.apk
# -> 每个 ABI 下每个 .so 的 ELF 类型 / DT_NEEDED 闭包 / PT_LOAD 页对齐 # -> 每个 ABI 下每个 .so 的 ELF 类型 / DT_NEEDED 闭包 / PT_LOAD 页对齐
# -> ✓ native 库体检通过:arm64-v8a, armeabi-v7a | ELF 类型匹配、依赖闭包完整、16 KB 页对齐 # -> ✓ native 库体检通过:arm64-v8a, armeabi-v7a | ELF 类型匹配、依赖闭包完整、16 KB 页对齐
``` ```
@@ -274,7 +295,7 @@ python3 build/check_native_libs.py dist-release/opendance-android-v0.2.0/app-rel
而用户装的是 zip 里的 `app-release.apk`。发布前对包再跑一次: 而用户装的是 zip 里的 `app-release.apk`。发布前对包再跑一次:
```bash ```bash
bash build/accept-release.sh dist-release/opendance-android-v0.2.0.zip bash build/accept-release.sh dist-release/opendance-android-v0.2.1.zip
# -> sha256 / 文件清单 / AndroidManifest versionName+versionCode / ABI+MediaPipe native # -> sha256 / 文件清单 / AndroidManifest versionName+versionCode / ABI+MediaPipe native
# / 模型资源 / dex 中的 PoseLandmarker / 验签 / 包内契约快照与仓库一致 # / 模型资源 / dex 中的 PoseLandmarker / 验签 / 包内契约快照与仓库一致
# -> 发布包级验收全部通过 # -> 发布包级验收全部通过
@@ -285,13 +306,13 @@ bash build/accept-release.sh dist-release/opendance-android-v0.2.0.zip
**产物侧复核(T-AND-013)**:`versionName` 是**构建配置**,而安装界面 / 应用信息页 / **产物侧复核(T-AND-013)**:`versionName` 是**构建配置**,而安装界面 / 应用信息页 /
`dumpsys package` 读的是 **APK 的 `AndroidManifest`**。构建缓存、复用旧 APK、 `dumpsys package` 读的是 **APK 的 `AndroidManifest`**。构建缓存、复用旧 APK、
打包时拷错文件都会造成「配置全对、产物是旧版」,配置层校验抓不到(Windows 端 打包时拷错文件都会造成「配置全对、产物是旧版」,配置层校验抓不到(**Windows 客户端**
v0.2.1 就踩过同类坑)。因此新增: 的 v0.2.1 就踩过同类坑:配置文件全一致,exe 属性页显示 0.1.0)。因此新增:
```bash ```bash
bash build/check-apk-version.sh app/build/outputs/apk/release/app-release.apk bash build/check-apk-version.sh app/build/outputs/apk/release/app-release.apk
# -> AndroidManifest versionName = 0.2.0 / versionCode = 200 # -> AndroidManifest versionName = 0.2.1 / versionCode = 201
# -> ✓ APK 版本元数据一致:0.2.0(versionCode 200) # -> ✓ APK 版本元数据一致:0.2.1(versionCode 201)
``` ```
`build/check_apk_version.py` 直接解析二进制 AXML,**不需要 Android SDK / aapt2** `build/check_apk_version.py` 直接解析二进制 AXML,**不需要 Android SDK / aapt2**
+15 -2
View File
@@ -1,5 +1,5 @@
================================================================ ================================================================
OpenDance Studio -- Android 辅助 App v0.2.0 使用说明 OpenDance Studio -- Android 辅助 App v0.2.1 使用说明
================================================================ ================================================================
手机端姿态采集器:用摄像头做端侧姿态推理,把 33 个骨骼关键点通过 手机端姿态采集器:用摄像头做端侧姿态推理,把 33 个骨骼关键点通过
@@ -125,6 +125,19 @@ WebSocket 推送给 Windows 客户端,由后者驱动 VRM 角色并实时评
App 运行页 「人体识别」为「已识别」,出现骨架叠加(黄点好、红点置信度低) App 运行页 「人体识别」为「已识别」,出现骨架叠加(黄点好、红点置信度低)
Windows 端 显示已连接并收到关键点,VRM 模型随动作实时动起来 Windows 端 显示已连接并收到关键点,VRM 模型随动作实时动起来
6.5) v0.2.1 修订
1. 重连链路的真实 WebSocket 集成测试(T-AND-016): 用 MockWebServer 的
withWebSocketUpgrade 在 JVM 上起真实 WebSocket 服务端,覆盖「该重连的」
(异常关闭码 1011 / 服务端未启动)与「不该重连的」(1000/1001/1002 /
用户主动断开)两条分支,并断言重连等待取自策略、落在 ±20% 区间、
成功后计数清零。无需设备。
2. 修一处真实缺陷: 上一次会话的关闭码会污染下一次会话 —— 第一次被 1001 关闭后
用户重新开始推流,若这一次是网络层失败,客户端仍按 1001 判定「不重连」,
于是再也不会自动重连,只能反复手点。现在 connect() 会清掉会话级关闭状态,
网络失败按「未收到关闭帧(0)」记录并按 §7 重连。
3. 单元测试 64 项(原 56 项)。
7) 自检清单 7) 自检清单
./gradlew testDebugUnitTest ./gradlew testDebugUnitTest
@@ -144,7 +157,7 @@ WebSocket 推送给 Windows 客户端,由后者驱动 VRM 角色并实时评
依赖闭包(DT_NEEDED,非系统库必须同 ABI 自带)、页对齐(16 KB 兼容)。 依赖闭包(DT_NEEDED,非系统库必须同 ABI 自带)、页对齐(16 KB 兼容)。
0) 发布包级验收(对**发布包**而不是本地构建;文档 §7.5) 0) 发布包级验收(对**发布包**而不是本地构建;文档 §7.5)
bash build/accept-release.sh dist-release/opendance-android-v0.2.0.zip bash build/accept-release.sh dist-release/opendance-android-v0.2.1.zip
预期末行「发布包级验收全部通过」。它会校验 sha256、解压、核对文件清单、 预期末行「发布包级验收全部通过」。它会校验 sha256、解压、核对文件清单、
读包内 APK 的 AndroidManifest 版本元数据、核对 ABI 与 MediaPipe native 库、 读包内 APK 的 AndroidManifest 版本元数据、核对 ABI 与 MediaPipe native 库、
模型资源、dex 里的 PoseLandmarker、验签,以及包内契约快照与仓库是否一致。 模型资源、dex 里的 PoseLandmarker、验签,以及包内契约快照与仓库是否一致。
+7 -2
View File
@@ -28,8 +28,8 @@ android {
targetSdk = 34 targetSdk = 34
// 文档 §11.1:versionCode 用整数按 10000*主 + 100*次 + 修订 递增, // 文档 §11.1:versionCode 用整数按 10000*主 + 100*次 + 修订 递增,
// versionName 用 X.Y.Z,两者同一次发布一起更新。 // versionName 用 X.Y.Z,两者同一次发布一起更新。
versionCode = 200 versionCode = 201
versionName = "0.2.0" versionName = "0.2.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
buildConfigField("String", "WS_SUBPROTOCOL", "\"opendance.pose.v1\"") buildConfigField("String", "WS_SUBPROTOCOL", "\"opendance.pose.v1\"")
@@ -225,6 +225,11 @@ dependencies {
testImplementation("junit:junit:4.13.2") testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlin:kotlin-test:2.0.21") testImplementation("org.jetbrains.kotlin:kotlin-test:2.0.21")
// MockWebServer:JVM 上起**真实** WebSocket 服务端(支持 withWebSocketUpgrade),
// 用来验证「断线 → 按契约 §7.1 退避重连 / 特定关闭码不重连」这条只在真机上
// 才暴露的链路。版本与 okhttp 保持一致,避免协议实现偏斜。
testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
// Robolectric:在 JVM 上跑 Android 框架 + Compose,用于验证 // Robolectric:在 JVM 上跑 Android 框架 + Compose,用于验证
// 「进入运行页即崩溃」这类只在真机/运行时才暴露的缺陷 // 「进入运行页即崩溃」这类只在真机/运行时才暴露的缺陷
testImplementation("org.robolectric:robolectric:4.13") testImplementation("org.robolectric:robolectric:4.13")
@@ -146,6 +146,12 @@ class PoseWebSocketClient(
this.autoReconnect = autoReconnect this.autoReconnect = autoReconnect
reconnectCounter.set(0) reconnectCounter.set(0)
lastPongAtMs = 0L lastPongAtMs = 0L
// **每次新会话都清掉上一次的关闭码**:`scheduleReconnect()` 是按
// `lastCloseCode` 判断「该不该重连」的(契约 §7:1000/1001/1002 不重连),
// 若这里不清,上一次的 1001 会把本次会话的**网络失败**也判成「对端主动关闭」,
// 于是新版一启动就不再自动重连,用户只能反复手点。
// 由 T-AND-016 的 JVM 集成测试(MockWebServer + 真实 WebSocket)复现并锁住。
_stats.value = _stats.value.copy(lastCloseCode = 0, lastCloseReason = "")
ConnectionLog.record("connect() -> $targetUrl(autoReconnect=$autoReconnect)") ConnectionLog.record("connect() -> $targetUrl(autoReconnect=$autoReconnect)")
val host = runCatching { URI(targetUrl).host }.getOrNull() val host = runCatching { URI(targetUrl).host }.getOrNull()
if (host != null && !isIpLiteral(host)) { if (host != null && !isIpLiteral(host)) {
@@ -268,6 +274,9 @@ class PoseWebSocketClient(
stopHeartbeat() stopHeartbeat()
// response != null 时说明 HTTP 升级被拒(例如路径/子协议不对) // response != null 时说明 HTTP 升级被拒(例如路径/子协议不对)
val httpCode = response?.code ?: 0 val httpCode = response?.code ?: 0
// 记成 0(未收到关闭帧):契约 §7 里 0 属于「异常断开 → 应重连」,
// 同时让界面「最近一次断开原因」显示的是**本次**失败,而不是上一次的旧值。
recordClose(0, if (httpCode > 0) "HTTP $httpCode:$reason" else reason)
update( update(
state = State.STOPPED, state = State.STOPPED,
error = if (httpCode > 0) "HTTP $httpCode:$reason" else reason, error = if (httpCode > 0) "HTTP $httpCode:$reason" else reason,
@@ -0,0 +1,311 @@
package com.opendance.studio
import com.opendance.studio.net.PoseWebSocketClient
import com.opendance.studio.net.ReconnectPolicy
import com.opendance.studio.util.ConnectionLog
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import okhttp3.OkHttpClient
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
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.concurrent.CopyOnWriteArrayList
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
/**
* T-AND-016:**重连链路**的 JVM 集成测试(真实 WebSocket,不需要设备)
*
* 为什么单有 [com.opendance.studio.ReconnectPolicyTest] 不够:
* `ReconnectPolicy` 是纯函数,测的是「给定关闭码应该怎么退避」;
* 而 ⑦「频繁重连」的缺陷往往出在**调用点**——关闭码是否被正确记录、
* 重连计数是否在成功后清零、上一次的关闭码会不会污染下一次会话。
* 这些只有把**真实的 OkHttp WebSocket 接到一个真实的服务端**上才能验出来。
*
* 这里用 MockWebServer 的 `withWebSocketUpgrade` 起真服务端:
* * 能按契约 §3.2 回 `welcome`(含 silentTimeoutMs / heartbeatIntervalMs);
* * 能按契约 §3.6 回 `pong`;
* * 能用**指定关闭码**关连接(1001 对端离开 / 1011 内部错误 / …),
* 从而分别验证「不重连」与「按退避重连」两条分支。
*
* 注:Robolectric 只用来提供 `android.os.Build.*` 等桩(客户端构造时会读机型名)。
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class PoseWebSocketReconnectTest {
private lateinit var server: MockWebServer
private lateinit var scope: CoroutineScope
private val clients = mutableListOf<PoseWebSocketClient>()
/** 服务端侧收到过的文本消息(hello / ping / stats / bye) */
private val serverTexts = CopyOnWriteArrayList<String>()
/** 服务端侧见过的连接次数(含重连) */
private val serverOpens = AtomicInteger(0)
private var welcomeSilentTimeoutMs = 4_500L
private var closeCodeOnHello = 0
private var closeAfterHelloDelayMs = 0L
@Before
fun setUp() {
ConnectionLog.clear()
serverTexts.clear()
serverOpens.set(0)
server = MockWebServer()
server.start()
}
@After
fun tearDown() {
clients.forEach { runCatching { it.disconnect() } }
scope.cancel()
runCatching { server.shutdown() }
ConnectionLog.clear()
}
// ------------------------------------------------------------------ 夹具
private fun enqueueWebSocket() {
server.enqueue(
// 必须回显子协议:RFC 6455 要求服务端在 101 响应里带上选中的
// Sec-WebSocket-Protocol,客户端据此记录「协商结果」。
// (第一版夹具没带,客户端 negotiatedProtocol 就是空串 —— 那是**夹具**不合规,
// 不是客户端的问题;这条断言因此改成先在夹具里把服务端做对。)
MockResponse()
.setHeader("Sec-WebSocket-Protocol", "opendance.pose.v1")
.withWebSocketUpgrade(object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
serverOpens.incrementAndGet()
}
override fun onMessage(webSocket: WebSocket, text: String) {
serverTexts.add(text)
when {
text.contains("\"hello\"") -> {
webSocket.send(
"""{"type":"welcome","protocol":"opendance.pose.v1",""" +
""""sessionId":"sess-$serverOpens","expectedFps":30,""" +
""""silentTimeoutMs":$welcomeSilentTimeoutMs,""" +
""""heartbeatIntervalMs":${ReconnectPolicy.heartbeatIntervalMs(welcomeSilentTimeoutMs)}}""",
)
if (closeCodeOnHello != 0) {
// 模拟「对端主动关闭 / 服务重启」等场景
Thread {
Thread.sleep(closeAfterHelloDelayMs)
webSocket.close(closeCodeOnHello, "test-close-$closeCodeOnHello")
}.start()
}
}
text.contains("\"ping\"") -> {
webSocket.send("""{"type":"pong","sentAtMs":1,"serverAtMs":2}""")
}
}
}
}),
)
}
private fun newClient(): PoseWebSocketClient {
scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
return PoseWebSocketClient(scope).also { clients.add(it) }
}
private fun wsUrl(): String = server.url("/pose").toString().replace("http://", "ws://")
/** 轮询等待条件成立(避免依赖固定 sleep 造成的偶发失败) */
private fun waitFor(timeoutMs: Long = 6_000, what: String, cond: () -> Boolean): Boolean {
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
if (cond()) return true
Thread.sleep(25)
}
throw AssertionError("等待超时(${timeoutMs}ms):$what")
}
private fun waitUntilOrFalse(timeoutMs: Long, cond: () -> Boolean): Boolean {
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
if (cond()) return true
Thread.sleep(25)
}
return false
}
// ------------------------------------------------------------------ 用例
@Test
fun `握手成功后进入 streaming 并解析 welcome 的保活参数`() {
enqueueWebSocket()
val client = newClient()
client.connect(wsUrl())
waitFor(what = "进入 streaming") { client.stats.value.state == PoseWebSocketClient.State.STREAMING }
val stats = client.stats.value
assertTrue("sessionId 应来自 welcome(实际 '${stats.serverSessionId}')", stats.serverSessionId.isNotEmpty())
assertEquals("opendance.pose.v1", stats.negotiatedProtocol)
assertEquals(welcomeSilentTimeoutMs, stats.silentTimeoutMs)
assertEquals(ReconnectPolicy.heartbeatIntervalMs(welcomeSilentTimeoutMs), stats.heartbeatIntervalMs)
assertTrue(
"hello 必须带 deviceId(契约 §3.1,接收端据此去重)",
serverTexts.any { it.contains("\"type\":\"hello\"") && it.contains("\"deviceId\"") },
)
}
@Test
fun `按协商的心跳间隔发送应用层 ping 并收到 pong`() {
enqueueWebSocket()
val client = newClient()
client.connect(wsUrl())
waitFor(what = "进入 streaming") { client.stats.value.state == PoseWebSocketClient.State.STREAMING }
waitFor(timeoutMs = 8_000, what = "收到 pong") { client.stats.value.pongsReceived >= 1 }
assertTrue(
"服务端必须真的收到 ping 文本帧(协议层 ping 对应用层不可见,不能替代)",
serverTexts.any { it.contains("\"type\":\"ping\"") },
)
}
@Test
fun `异常关闭码(1011)会按退避策略重连且成功后计数清零`() {
closeCodeOnHello = 1011
enqueueWebSocket() // 第一次连接:握手后立刻被服务端以 1011 关闭
enqueueWebSocket() // 重连后的第二次连接
val client = newClient()
client.connect(wsUrl())
// 第一次连接被关闭 → 进入 reconnecting 且尝试次数为 1,等待时间落在策略区间内
waitFor(what = "进入 reconnecting") { client.stats.value.state == PoseWebSocketClient.State.RECONNECTING }
val firstAttempt = client.stats.value.reconnectAttempts
assertEquals(1, firstAttempt)
val expected = ReconnectPolicy.baseDelayMs(1) // 500ms
val low = (expected * (1 - ReconnectPolicy.JITTER_RATIO)).toLong() // 400ms
val high = (expected * (1 + ReconnectPolicy.JITTER_RATIO)).toLong() // 600ms
assertTrue(
"第 1 次重连等待应落在策略区间 [$low, $high](实际 ${client.stats.value.nextRetryMs})",
client.stats.value.nextRetryMs in low..high,
)
// 真的重连上了(服务端看到第 2 次 upgrade),并且计数清零、状态回到 streaming
waitFor(timeoutMs = 8_000, what = "第二次连接建立") { serverOpens.get() >= 2 }
waitFor(what = "重连后回到 streaming") { client.stats.value.state == PoseWebSocketClient.State.STREAMING }
assertEquals("连接成功后重连计数必须清零(契约 §7.1)", 0, client.stats.value.reconnectAttempts)
}
@Test
fun `1001 对端离开不重连`() {
closeCodeOnHello = 1001
enqueueWebSocket()
enqueueWebSocket()
val client = newClient()
client.connect(wsUrl())
waitFor(what = "进入 stopped") { client.stats.value.state == PoseWebSocketClient.State.STOPPED }
assertEquals(1001, client.stats.value.lastCloseCode)
assertTrue("1001 不应重连(契约 §7)", client.stats.value.reconnectAttempts == 0)
val reconnected = waitUntilOrFalse(2_000) { serverOpens.get() >= 2 }
assertTrue("1001 之后不允许再建立新连接", !reconnected)
assertTrue(
"日志里应记录「不重连」便于真机排障",
ConnectionLog.entries().any { it.contains("不重连") },
)
}
@Test
fun `1002 协议错误不重连`() {
closeCodeOnHello = 1002
enqueueWebSocket()
enqueueWebSocket()
val client = newClient()
client.connect(wsUrl())
waitFor(what = "进入 stopped") { client.stats.value.state == PoseWebSocketClient.State.STOPPED }
assertEquals(1002, client.stats.value.lastCloseCode)
assertTrue("1002 不应重连(重连也不会成功)", !waitUntilOrFalse(2_000) { serverOpens.get() >= 2 })
}
@Test
fun `用户主动 disconnect 不重连`() {
enqueueWebSocket()
enqueueWebSocket()
val client = newClient()
client.connect(wsUrl())
waitFor(what = "进入 streaming") { client.stats.value.state == PoseWebSocketClient.State.STREAMING }
client.disconnect()
waitFor(what = "进入 stopped") { client.stats.value.state == PoseWebSocketClient.State.STOPPED }
assertTrue("主动断开不应重连", !waitUntilOrFalse(2_000) { serverOpens.get() >= 2 })
}
/**
* 本用例是 ⑦ 的关键回归:**上一次会话的关闭码不能污染下一次会话**。
*
* 复现路径(修复前):
* 1. 第一次连接被对端以 1001 关闭 → lastCloseCode = 1001,按契约不重连(正确);
* 2. 用户再次点「开始推流」→ connect();
* 3. 这一次是**网络层失败**(服务端还没起来 / 被拒),应当按 §7 重连;
* 4. 但 `connect()` 没有清理 lastCloseCode,scheduleReconnect() 仍按 1001 判定
* → 直接 stopped,**再也不会自动重连**,用户只能反复手点。
*/
@Test
fun `上一次 1001 关闭后重新连接,遇到网络失败仍应按策略重连`() {
// 1. 第一次:对端以 1001 关闭
closeCodeOnHello = 1001
enqueueWebSocket()
val client = newClient()
client.connect(wsUrl())
waitFor(what = "第一次连接进入 stopped") { client.stats.value.state == PoseWebSocketClient.State.STOPPED }
assertEquals(1001, client.stats.value.lastCloseCode)
// 2. 用户重新开始推流,但这一轮服务端 connect 就会失败(直接关掉 MockWebServer)
closeCodeOnHello = 0
server.shutdown()
client.connect(server.url("/pose").toString().replace("http://", "ws://"))
// 3. 网络失败必须触发重连(进入 reconnecting 或至少 attempts > 0)
val recovered = waitUntilOrFalse(6_000) {
client.stats.value.state == PoseWebSocketClient.State.RECONNECTING ||
client.stats.value.reconnectAttempts > 0
}
assertTrue(
"上一次的 1001 不应影响本次会话:网络失败后必须按 §7 重连" +
"(当前 state=${client.stats.value.state}," +
"lastCloseCode=${client.stats.value.lastCloseCode}," +
"attempts=${client.stats.value.reconnectAttempts})",
recovered,
)
}
@Test
fun `服务端未启动时的连续重连尝试遵守退避(不会疯狂重连)`() {
server.shutdown() // 没有服务端
val client = newClient()
val t0 = System.currentTimeMillis()
client.connect(server.url("/pose").toString().replace("http://", "ws://"))
waitFor(timeoutMs = 6_000, what = "至少安排一次重连") { client.stats.value.reconnectAttempts >= 1 }
// 1.5 秒内不应超过 3 次尝试:0.5s → 1s → 2s,说明退避生效
Thread.sleep(1_500)
val attempts = client.stats.value.reconnectAttempts
val elapsed = System.currentTimeMillis() - t0
assertTrue(
"退避必须生效:${elapsed}ms 内只应有少量尝试(实际 $attempts)",
attempts <= 3,
)
assertNotNull(client.stats.value.lastError)
}
}
+6 -2
View File
@@ -1,5 +1,5 @@
project: opendance-android-app project: opendance-android-app
version: 0.2.0 version: 0.2.1
repository: opendance-android repository: opendance-android
default_branch: main default_branch: main
platform: Android 10+(minSdk 29 / targetSdk 34) platform: Android 10+(minSdk 29 / targetSdk 34)
@@ -86,9 +86,13 @@ tasks:
file: tasks/015-native-libs.md file: tasks/015-native-libs.md
scope: APK native 库体检(ELF 类型 / DT_NEEDED 闭包 / 16 KB 页对齐),纯标准库实现 scope: APK native 库体检(ELF 类型 / DT_NEEDED 闭包 / 16 KB 页对齐),纯标准库实现
status: done status: done
- id: T-AND-016
file: tasks/016-reconnect-integration.md
scope: JVM 内以真实 WebSocket(MockWebServer)验证重连链路 + 修「上一次关闭码污染下一次会话」
status: done
deliverable: deliverable:
artifact: opendance-android-v0.2.0.apk artifact: opendance-android-v0.2.1.apk
packaging: Gradle 命令行 / Android Studio packaging: Gradle 命令行 / Android Studio
includes: includes:
- app-release.apk - app-release.apk
+83
View File
@@ -0,0 +1,83 @@
# T-AND-016:重连链路的 JVM 集成测试(真实 WebSocket)+ 修一处真实缺陷
- **状态**: done
- **依赖**: T-AND-011(连接状态机与重连)
- **文档**: v1.6 §6.5 / §7.6
- **契约**: `websocket-keypoints 0.2.0` §7 / §7.1 / §8
## 为什么需要
`ReconnectPolicyTest`(11 项)验的是**纯函数**「给定关闭码应该怎么退避」;
但 ⑦「连接频繁重连」的缺陷往往出在**调用点**:关闭码有没有被正确记录、
重连计数有没有在成功后清零、上一次的状态会不会污染下一次会话。
这些只有把**真实的 OkHttp WebSocket 接到真实服务端**上才能验出来。
因此新增 `PoseWebSocketReconnectTest`(8 项):用 `MockWebServer.withWebSocketUpgrade`
在 JVM 上起**真实** WebSocket 服务端(能按 §3.2 回 `welcome`、按 §3.6 回 `pong`、
按指定关闭码关连接),无需设备即可覆盖两条分支:**该重连的**与**不该重连的**。
Robolectric 只用来提供 `android.os.Build.*` 桩。
## 它抓到的真实缺陷(本版修复)
**上一次会话的关闭码会污染下一次会话,导致新版一启动就失去自动重连能力。**
复现路径:
1. 第一次连接被对端以 `1001` 关闭 → `lastCloseCode = 1001`,按契约 §7 **不重连**(正确);
2. 用户再次点「开始推流」→ `connect()`;
3. 这一次是**网络层失败**(服务端还没起来 / 被拒),按 §7 **应当**重连;
4. 但 `connect()` 只重置了 `reconnectCounter` 与 `lastPongAtMs`,**没有清 `lastCloseCode`**;
`scheduleReconnect()` 仍按 1001 判定 → 直接 `stopped`,**再也不会自动重连**。
测试输出的失败信息(修复前):
```
✗ 上一次 1001 关闭后重新连接,遇到网络失败仍应按策略重连
java.lang.AssertionError: 上一次的 1001 不应影响本次会话:网络失败后必须按 §7 重连
(当前 state=STOPPED,lastCloseCode=1001,attempts=0)
```
**修复**(`PoseWebSocketClient.kt`):
| 位置 | 改动 |
|--|--|
| `connect()` | 清掉会话级关闭状态:`lastCloseCode = 0`、`lastCloseReason = ""`(每次新会话都从干净状态开始) |
| `onFailure()` | 记录 `recordClose(0, …)`(契约 §7 中 0 = 未收到关闭帧 → 应重连),同时让界面「最近一次断开原因」显示**本次**失败而不是旧值 |
## 用例清单(8 项)
| 用例 | 验的是什么 |
|--|--|
| 握手成功 → `streaming`,解析 `welcome` 的 `sessionId` / 保活参数,`hello` 带 `deviceId` | §3.1 / §3.2 发送端一侧 |
| 按协商间隔发送应用层 `ping` 并收到 `pong` | §1.1 / §3.6(协议层 ping 不能替代) |
| 异常关闭码 `1011` → 按退避重连、等待时间落在策略区间、成功后计数清零 | §7 / §7.1 / §8 |
| `1001` 对端离开 → `stopped` 且**不重连**,日志记录「不重连」 | §7 |
| `1002` 协议错误 → **不重连** | §7 |
| 用户主动 `disconnect()` → **不重连** | §7 |
| **上一次 1001 后再连、遇网络失败仍应重连** | 本次修复的缺陷(回归锁) |
| 服务端未启动时的连续尝试遵守退避(1.5 s 内 ≤3 次) | §7.1「不会疯狂重连」 |
## 实测
```
$ ./gradlew :app:testDebugUnitTest
BUILD SUCCESSFUL
测试类 6 个,用例 64 项,失败 0,跳过 0
(新增 8 项;修复前该项失败,其余 7 项通过)
```
`build/verify.sh` 全绿(含签名、版本元数据、native 库体检、ABI、R8、dex)。
## 踩坑记录
**夹具也要合规**:第一版 MockWebServer 的 101 响应没有回显
`Sec-WebSocket-Protocol`(RFC 6455 要求服务端带上选中的子协议),于是客户端记录的
`negotiatedProtocol` 是空串,断言失败。那是**夹具**不合规,不是客户端问题 ——
已在夹具里补上 `setHeader("Sec-WebSocket-Protocol", …)`。
写这类"真实对端"夹具时,要按 RFC / 契约把服务端做对,否则会把夹具缺陷误判成产品缺陷。
## 未覆盖(诚实声明)
- 真机 Wi-Fi 抖动、切网、进程被杀、Doze 省电等**真实断网场景**仍需设备实测;
- 契约 §7.1 的**封顶 15 s** 行为由纯函数单测覆盖(穷举 1..8 次),
集成测试只验证「等待时间取自策略且落在 ±20% 区间」,否则单测要跑 30 秒以上。