Files
opendance-android/app/src/test/java/com/opendance/studio/PoseWebSocketReconnectTest.kt
T
zijunchiang 6dfd90f7ca 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/契约
把服务端做对,否则会把夹具缺陷误判成产品缺陷。
2026-09-17 05:44:03 +08:00

312 lines
14 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}