diff --git a/README.md b/README.md index c2d15dc..7508565 100644 --- a/README.md +++ b/README.md @@ -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 实现**逐位一致** | | `./gradlew assembleRelease` | OK 生成**已签名** APK(28.6 MB) | | 签名校验 | 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 未压缩) | | 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) | +## 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 @@ -261,7 +282,7 @@ app/src/main/java/com/opendance/studio/ 问题大多出在 native 库打包上,而这几项**无设备也能查**: ```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 页对齐 # -> ✓ 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`。发布前对包再跑一次: ```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 # / 模型资源 / dex 中的 PoseLandmarker / 验签 / 包内契约快照与仓库一致 # -> 发布包级验收全部通过 @@ -285,13 +306,13 @@ bash build/accept-release.sh dist-release/opendance-android-v0.2.0.zip **产物侧复核(T-AND-013)**:`versionName` 是**构建配置**,而安装界面 / 应用信息页 / `dumpsys package` 读的是 **APK 的 `AndroidManifest`**。构建缓存、复用旧 APK、 -打包时拷错文件都会造成「配置全对、产物是旧版」,配置层校验抓不到(Windows 端 -v0.2.1 就踩过同类坑)。因此新增: +打包时拷错文件都会造成「配置全对、产物是旧版」,配置层校验抓不到(**Windows 客户端** +的 v0.2.1 就踩过同类坑:配置文件全一致,exe 属性页显示 0.1.0)。因此新增: ```bash bash build/check-apk-version.sh app/build/outputs/apk/release/app-release.apk -# -> AndroidManifest versionName = 0.2.0 / versionCode = 200 -# -> ✓ APK 版本元数据一致:0.2.0(versionCode 200) +# -> AndroidManifest versionName = 0.2.1 / versionCode = 201 +# -> ✓ APK 版本元数据一致:0.2.1(versionCode 201) ``` `build/check_apk_version.py` 直接解析二进制 AXML,**不需要 Android SDK / aapt2** diff --git a/README.txt b/README.txt index a2e0afc..f746b53 100644 --- a/README.txt +++ b/README.txt @@ -1,5 +1,5 @@ ================================================================ -OpenDance Studio -- Android 辅助 App v0.2.0 使用说明 +OpenDance Studio -- Android 辅助 App v0.2.1 使用说明 ================================================================ 手机端姿态采集器:用摄像头做端侧姿态推理,把 33 个骨骼关键点通过 @@ -125,6 +125,19 @@ WebSocket 推送给 Windows 客户端,由后者驱动 VRM 角色并实时评 App 运行页 「人体识别」为「已识别」,出现骨架叠加(黄点好、红点置信度低) 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) 自检清单 ./gradlew testDebugUnitTest @@ -144,7 +157,7 @@ WebSocket 推送给 Windows 客户端,由后者驱动 VRM 角色并实时评 依赖闭包(DT_NEEDED,非系统库必须同 ABI 自带)、页对齐(16 KB 兼容)。 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、解压、核对文件清单、 读包内 APK 的 AndroidManifest 版本元数据、核对 ABI 与 MediaPipe native 库、 模型资源、dex 里的 PoseLandmarker、验签,以及包内契约快照与仓库是否一致。 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 992dce7..21e5ef0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -28,8 +28,8 @@ android { targetSdk = 34 // 文档 §11.1:versionCode 用整数按 10000*主 + 100*次 + 修订 递增, // versionName 用 X.Y.Z,两者同一次发布一起更新。 - versionCode = 200 - versionName = "0.2.0" + versionCode = 201 + versionName = "0.2.1" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" buildConfigField("String", "WS_SUBPROTOCOL", "\"opendance.pose.v1\"") @@ -225,6 +225,11 @@ dependencies { testImplementation("junit:junit:4.13.2") 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,用于验证 // 「进入运行页即崩溃」这类只在真机/运行时才暴露的缺陷 testImplementation("org.robolectric:robolectric:4.13") diff --git a/app/src/main/java/com/opendance/studio/net/PoseWebSocketClient.kt b/app/src/main/java/com/opendance/studio/net/PoseWebSocketClient.kt index 6f25133..bc749e2 100644 --- a/app/src/main/java/com/opendance/studio/net/PoseWebSocketClient.kt +++ b/app/src/main/java/com/opendance/studio/net/PoseWebSocketClient.kt @@ -146,6 +146,12 @@ class PoseWebSocketClient( this.autoReconnect = autoReconnect reconnectCounter.set(0) 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)") val host = runCatching { URI(targetUrl).host }.getOrNull() if (host != null && !isIpLiteral(host)) { @@ -268,6 +274,9 @@ class PoseWebSocketClient( stopHeartbeat() // response != null 时说明 HTTP 升级被拒(例如路径/子协议不对) val httpCode = response?.code ?: 0 + // 记成 0(未收到关闭帧):契约 §7 里 0 属于「异常断开 → 应重连」, + // 同时让界面「最近一次断开原因」显示的是**本次**失败,而不是上一次的旧值。 + recordClose(0, if (httpCode > 0) "HTTP $httpCode:$reason" else reason) update( state = State.STOPPED, error = if (httpCode > 0) "HTTP $httpCode:$reason" else reason, diff --git a/app/src/test/java/com/opendance/studio/PoseWebSocketReconnectTest.kt b/app/src/test/java/com/opendance/studio/PoseWebSocketReconnectTest.kt new file mode 100644 index 0000000..3a819ac --- /dev/null +++ b/app/src/test/java/com/opendance/studio/PoseWebSocketReconnectTest.kt @@ -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() + + /** 服务端侧收到过的文本消息(hello / ping / stats / bye) */ + private val serverTexts = CopyOnWriteArrayList() + /** 服务端侧见过的连接次数(含重连) */ + 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) + } +} diff --git a/project.yaml b/project.yaml index 0ce992f..deb6ced 100644 --- a/project.yaml +++ b/project.yaml @@ -1,5 +1,5 @@ project: opendance-android-app -version: 0.2.0 +version: 0.2.1 repository: opendance-android default_branch: main platform: Android 10+(minSdk 29 / targetSdk 34) @@ -86,9 +86,13 @@ tasks: file: tasks/015-native-libs.md scope: APK native 库体检(ELF 类型 / DT_NEEDED 闭包 / 16 KB 页对齐),纯标准库实现 status: done + - id: T-AND-016 + file: tasks/016-reconnect-integration.md + scope: JVM 内以真实 WebSocket(MockWebServer)验证重连链路 + 修「上一次关闭码污染下一次会话」 + status: done deliverable: - artifact: opendance-android-v0.2.0.apk + artifact: opendance-android-v0.2.1.apk packaging: Gradle 命令行 / Android Studio includes: - app-release.apk diff --git a/tasks/016-reconnect-integration.md b/tasks/016-reconnect-integration.md new file mode 100644 index 0000000..bcd54ec --- /dev/null +++ b/tasks/016-reconnect-integration.md @@ -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 秒以上。