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:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user