test(probe)+docs: W44 判定 W41 缺口——A(把 503 判成 Offline)已否;B 只到形态(那个 Unreachable 生在连接之前,三种成因在外面同形,需一次设备取证)
判定 A:否。ChatSocket.isAuthFailure 只认 HTTP 401(ChatSocket.kt:320-323),非 101 的握手响应
(代理回 503)走 SessionEnd.Closed ⇒ Reconnecting 退避重连(ChatSocket.kt:301-304 / 230-235);
而顶栏「重连中…」正是 Reconnecting 的文案(ChatScreen.kt:918,Offline 那档是「离线」)。
⇒ 那一刻 socket 真值就是 Reconnecting,sendRouteFor 在这一档会看探测结论,镜像也没滞后。
判定 B:形态成立,但「这条链根本发不出请求」不成立。
- 调用点是无条件求值的实参(ChatViewModel.kt:1614)⇒ Queued 这一档必然调用探测,
连 Offline 那档也会(sendRouteFor 只是把结论丢掉,与本轮前的 KDoc 说法相反,已改)。
- 新增 ProbeClientTest(6 例,真 OkHttp + 真 Retrofit + JDK HttpServer):这条链能在真 socket 上
留下 GET /api/v1/health、不带 Authorization、200 ⇒ Reachable、503 ⇒ Unreachable、
2xx 但回执解析不出来 ⇒ Unreachable、黑洞服务端在 2.5 秒整通上限处失败
⇒「探测压根发不出去」不是代码事实。
- 真机代理日志里一条 health 都没有 ⇒ 那个 Unreachable 生在连接之前。三种成因在外面完全同形:
(甲) 建 client 就抛(baseUrl 空/非法,Retrofit.baseUrl 直接抛);
(乙) 连上了但一个字都没写(callTimeout 是整通上限含连接,而探测不复用连接池 ⇒ 每次冷握手,
预算只有 2.5 秒,比共用客户端的 10 秒连接预算紧 4 倍);
(丙) 请求出去了、回执非 2xx(只有这一档在对端留痕)。
⇒「代理日志没有 health 那一行」推不出「App 没探过」;(甲)(乙) 都不留痕。
本轮改动(除一处 DEBUG 日志外全是文档与测试,无行为变化):
- 新增 app/src/test/kotlin/top/yeij/cyrene/data/api/ProbeClientTest.kt(6 例)。
- ApiClientFactory.createProbe:DEBUG 下挂 BASIC 日志(与通用 create 同一约定)。这是本轮要的
诊断仪——--> 打在连接之前,让「到底有没有探」与「死在连接还是死在回执」当场可分;探测不带令牌、
没有请求体,日志不涉凭据。
- KDoc 修正「连探测都不做」这处失真(SendFallback 的 sendRouteFor、ChatViewModel 的调用点与
probeReachability),把「三种成因 + logcat 判据」写进 ConnectivityRepository 类注释,
并给 WsState.Offline 补上「非 401 的握手失败不属于这一档」。
- sendRouteFor 的 fail-closed、探测的 2.5 秒上限、三档路由一个字都没改;下一步要设备取证,
步骤写在 devdocs/05 的 W44 一节。
验证:assembleDebug 成功;testDebugUnitTest 1156 例 / 0 失败 / 0 错误(新增 6 例全绿)。
This commit is contained in:
1 parent
866a3c37cd
commit
06028cc352
7 files changed
+322
-3
No files matched your search
@@ -0,0 +1,200 @@
|
||||
package top.yeij.cyrene.data.api
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange
|
||||
import com.sun.net.httpserver.HttpServer
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.core.error.CyreneError
|
||||
import top.yeij.cyrene.data.repository.ConnectivityRepository
|
||||
import top.yeij.cyrene.ui.chat.Reachability
|
||||
import top.yeij.cyrene.ui.chat.reachabilityOf
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.ServerSocket
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/**
|
||||
* W41 连通性探测那条链的**运行时接线**:`ApiClientFactory.createProbe` → [HealthApi] →
|
||||
* `ConnectivityRepository.probe` → `reachabilityOf`。
|
||||
*
|
||||
* 端到端跑**真 OkHttp + 真 Retrofit + 真 HTTP 服务端**(JDK 自带的 `com.sun.net.httpserver`,
|
||||
* 与 [top.yeij.cyrene.data.repository.ChatRepositoryTest] 同一套做法,不引任何新依赖)。
|
||||
*
|
||||
* ## 它为什么存在
|
||||
* W41 的真机回归里,`GET /api/v1/health` 在代理日志里**一条都没有**:WS 断(`Reconnecting`)、
|
||||
* HTTP 通(sessions / messages / enums / personas 全 200),这条阶梯却选了队列而不是回落 REST。
|
||||
* 那一轮只验过**纯判据**(`sendRouteFor` / `reachabilityOf` 是纯函数,全绿),
|
||||
* 「这条链真能发出请求吗」**一次都没被跑过**——README 里那句「纯判据全绿、接线却断了」说的就是这件事。
|
||||
* 本文件补上这一段:**探测必须真的在 socket 上留下一个 `GET /api/v1/health`**,而不只是判据算对。
|
||||
*
|
||||
* ## 它钉住的四条事实(每一条都是判据,不是描述)
|
||||
* 1. 请求**真的发出去**,且落到 `GET <baseUrl>api/v1/health`(baseUrl 是建 client 时按当前服务器
|
||||
* 地址现给的,不是空)。
|
||||
* 2. 探测**不带令牌**(`Authorization` 缺席;该端点不鉴权,带一个过期令牌去问会把「后端好好的」
|
||||
* 读成不通)——这是 [ApiClientFactory.createProbe] 与通用 [ApiClientFactory.create] 的**唯一**
|
||||
* 语义差别,必须逐字钉住。
|
||||
* 3. `200 + {"data":{...}}` ⇒ `probe()` 回 `Result.success(true)` ⇒ [Reachability.Reachable];
|
||||
* 非 2xx 与「连不上」⇒ `Result.failure` ⇒ [Reachability.Unreachable](fail-closed)。
|
||||
* 4. 服务端**接了连接却不回话**时,探测在 [PROBE_TIMEOUT_MILLIS] 量级返回失败——它是一道
|
||||
* **整通上限**(`callTimeout`),绝不会把用户的发送挂在一次等不到头的探测上。
|
||||
*/
|
||||
class ProbeClientTest {
|
||||
|
||||
private val backend = FakeHealthBackend()
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
backend.close()
|
||||
}
|
||||
|
||||
private fun healthApi(baseUrl: String = backend.baseUrl) =
|
||||
ApiClientFactory.createProbe(baseUrl, HealthApi::class.java)
|
||||
|
||||
@Test
|
||||
fun `the probe really puts a GET api v1 health on the wire`() = runBlocking {
|
||||
backend.reply(200, """{"data":{"status":"ok"}}""")
|
||||
|
||||
healthApi().health()
|
||||
|
||||
// 请求行不含 HTTP 版本(HttpExchange 只给 method + URI),判据是「方法 + 路径」。
|
||||
val line = backend.requestLines.single()
|
||||
assertEquals("GET /api/v1/health", line)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the probe carries no token because the endpoint is public`() = runBlocking {
|
||||
backend.reply(200, """{"data":{"status":"ok"}}""")
|
||||
|
||||
healthApi().health()
|
||||
|
||||
assertNull(backend.headers.single()["Authorization"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a 200 with data is reachable`() = runBlocking {
|
||||
backend.reply(200, """{"data":{"status":"ok"}}""")
|
||||
|
||||
val probed = ConnectivityRepository(healthApi()).probe()
|
||||
|
||||
assertEquals(Result.success(true), probed)
|
||||
assertEquals(Reachability.Reachable, reachabilityOf(probed))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a non 2xx is unreachable`() = runBlocking {
|
||||
backend.reply(503, """{"error":{"code":"unavailable","message":"down"}}""")
|
||||
|
||||
val probed = ConnectivityRepository(healthApi()).probe()
|
||||
|
||||
assertTrue("非 2xx 该是失败,实际 $probed", probed.isFailure)
|
||||
assertEquals(Reachability.Unreachable, reachabilityOf(probed))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a 2xx without data is unreachable rather than a false reachable`() = runBlocking {
|
||||
// 回执解析不出来 = 「后端没回我一句我能读懂的话」⇒ fail-closed 到不通。
|
||||
backend.reply(200, "{}")
|
||||
|
||||
val probed = ConnectivityRepository(healthApi()).probe()
|
||||
|
||||
assertEquals(
|
||||
CyreneError.Local(CyreneError.LocalKind.Parse),
|
||||
(probed.exceptionOrNull() as CyreneException).error,
|
||||
)
|
||||
assertEquals(Reachability.Unreachable, reachabilityOf(probed))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a server that accepts but never answers trips the call timeout`() = runBlocking {
|
||||
// 接住连接、一个字都不回:模拟「半死的代理 / 黑洞服务端」。
|
||||
// 判据有两头——**必须失败**(fail-closed),且**在 callTimeout 量级返回**
|
||||
// (它是整通上限;绝不能退化到共用客户端那条 60s 读超时上,那会把用户的发送挂住)。
|
||||
BlackHole().use { hole ->
|
||||
val started = System.currentTimeMillis()
|
||||
|
||||
val probed = ConnectivityRepository(
|
||||
ApiClientFactory.createProbe(hole.baseUrl, HealthApi::class.java),
|
||||
).probe()
|
||||
|
||||
val elapsed = System.currentTimeMillis() - started
|
||||
assertTrue("该失败,实际 $probed", probed.isFailure)
|
||||
assertEquals(Reachability.Unreachable, reachabilityOf(probed))
|
||||
assertTrue(
|
||||
"该在探测上限附近返回,实际 ${elapsed}ms",
|
||||
elapsed >= PROBE_TIMEOUT_MILLIS - 500,
|
||||
)
|
||||
assertTrue(
|
||||
"绝不该等到共用客户端那条 60s 读超时,实际 ${elapsed}ms",
|
||||
elapsed < 15_000,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** 照着 `reply(...)` 回话的假健康端点:记下**请求行原文**与请求头,绝不校验鉴权。 */
|
||||
private inner class FakeHealthBackend : AutoCloseable {
|
||||
|
||||
private val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0).apply {
|
||||
executor = Executors.newFixedThreadPool(2)
|
||||
createContext("/api/v1/health", ::handle)
|
||||
start()
|
||||
}
|
||||
|
||||
val baseUrl: String get() = "http://127.0.0.1:${server.address.port}/"
|
||||
|
||||
val requestLines = CopyOnWriteArrayList<String>()
|
||||
val headers = CopyOnWriteArrayList<Map<String, String>>()
|
||||
|
||||
@Volatile
|
||||
private var status = 200
|
||||
|
||||
@Volatile
|
||||
private var body = "{}"
|
||||
|
||||
fun reply(status: Int, body: String) {
|
||||
this.status = status
|
||||
this.body = body
|
||||
}
|
||||
|
||||
private fun handle(exchange: HttpExchange) {
|
||||
requestLines += "${exchange.requestMethod} ${exchange.requestURI}"
|
||||
headers += exchange.requestHeaders.entries.associate { it.key to it.value.joinToString(", ") }
|
||||
val payload = body.toByteArray(Charsets.UTF_8)
|
||||
exchange.responseHeaders.add("Content-Type", "application/json")
|
||||
exchange.sendResponseHeaders(status, payload.size.toLong())
|
||||
exchange.responseBody.use { it.write(payload) }
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
server.stop(0)
|
||||
}
|
||||
}
|
||||
|
||||
/** 只接连接、不回话的服务端(`callTimeout` 的判据用它)。 */
|
||||
private class BlackHole : AutoCloseable {
|
||||
|
||||
private val server = ServerSocket(0)
|
||||
private val held = CopyOnWriteArrayList<java.net.Socket>()
|
||||
|
||||
/** 收连接的后台线程:`accept` 之后什么都不做,把连接攥住。 */
|
||||
private val accepter = Thread {
|
||||
runCatching {
|
||||
while (!server.isClosed) held += server.accept()
|
||||
}
|
||||
}.apply {
|
||||
isDaemon = true
|
||||
start()
|
||||
}
|
||||
|
||||
val baseUrl: String get() = "http://127.0.0.1:${server.localPort}/"
|
||||
|
||||
override fun close() {
|
||||
runCatching { server.close() }
|
||||
accepter.interrupt()
|
||||
held.forEach { runCatching { it.close() } }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user