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:
AskaEth committed 2026-10-05 15:49:48 +08:00
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() } }
}
}
}