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/契约
把服务端做对,否则会把夹具缺陷误判成产品缺陷。
312 lines
14 KiB
Kotlin
312 lines
14 KiB
Kotlin
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)
|
||
}
|
||
}
|