merge: W44 —— 判定 W41 缺口:A(503 判成 Offline)已否;B 只到形态,那个 Unreachable 生在连接之前,需一次设备取证(含 ProbeClientTest 与 createProbe 的 DEBUG 日志)

This commit is contained in:
AskaEth committed 2026-10-05 15:50:19 +08:00
commit 2bee0b52b0
7 files changed
+322 -3

No files matched your search

@@ -85,21 +85,46 @@ object ApiClientFactory {
/**
* 连通性探测专用客户端(W41):整通调用一个**短上限**([PROBE_TIMEOUT_MILLIS]),
* 不注入令牌、不参与 401 续期、不记日志。
* 不注入令牌、不参与 401 续期。
*
* 与上面那个通用 [create] 分开,理由三条:
* 1. **超时是这条路的语义本身**。共用客户端的 10s 连接 / 60s 读超时是给真请求定的,探测等不了
* 那么久——判据见 [PROBE_TIMEOUT_MILLIS]。`callTimeout` 把「解析 DNS + 连接 + 写 + 读」
* 整通一起框住,只会更严不会更松:**探测永远不会比这个上限更慢**。
*
* ⚠️ 这条「整通」也**含连接**,而本客户端从不复用连接池(理由 3)⇒ 每一次探测都要现做一次
* TCP 握手,而这次握手的时间预算只有 [PROBE_TIMEOUT_MILLIS],**比共用客户端给的 10s 连接
* 预算紧 4 倍**。连接比这个上限慢时(慢网、经 `adb reverse` 的模拟器、半死的代理),
* OkHttp **一个字节都还没写出去**就超时了 ⇒ 服务端/中间设备**看不到任何请求**,只看到一个
* 连上又不说话的连接。诊断真机现象时必须先分清这一档(判据与步骤见
* [top.yeij.cyrene.data.repository.ConnectivityRepository.probe])。
* 2. **不带令牌**。`GET /api/v1/health` 是公开路由(不鉴权),探测要回答的是「后端在不在」,
* 而不是「我这个令牌还有效吗」——带着一个过期令牌去问,只会把「后端好好的」读成不通。
* 不挂 [AuthInterceptor] 同时也就没有「401 ⇒ 续期 ⇒ 再试」那串额外往返。
* 3. **不共享连接池**:探测的成败不该被真请求的连接状况带着走(半死的 keep-alive 连接恰好是
* 「要探测一下」的典型场景),自己一个池更干净。代价是一个只在探测时被用到的客户端。
*
* ## 为什么 DEBUG 下要给它挂上日志(W44)
* 这条路的失败**默认是无声的**:它的结论被折进一个 `Reachability.Unreachable`,
* 最后只表现为「消息进了队列」。W41 的真机回归就栽在这里——代理日志里一条 `/api/v1/health`
* 都没有,而「压根没探」与「探了但死在连接阶段(请求一个字节都没写出去)」在外部**完全同形**,
* 于是那次回归无法定论。BASIC 记的是**请求行 + 响应行 + 失败原因**:它落在连接**之前**
* (`-->` 先于 `chain.proceed()`),所以「有没有探」与「死在连接还是死在回执」当场可分。
* 它在这条路上不泄露任何东西:探测不带令牌、也没有请求体(与 [create] 挂日志时对凭据的顾虑
* 无关,这里没有凭据要涂抹)。
*/
fun <T : Any> createProbe(baseUrl: String, service: Class<T>): T {
val client = OkHttpClient.Builder()
.callTimeout(Duration.ofMillis(PROBE_TIMEOUT_MILLIS))
.apply {
if (BuildConfig.DEBUG) {
addInterceptor(
HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BASIC
},
)
}
}
.build()
return Retrofit.Builder()
@@ -32,6 +32,41 @@ import kotlinx.coroutines.withContext
* 没有缓存、也没有「上次探测时间」之类的记录:结论只对**探测那一刻**成立(这正是探测这个词的
* 意思),缓存它就会拿一个几秒前的乐观结论去发一条这一刻发不出去的消息。代价上也不成立——
* 每条消息**最多探一次**(只有 WS 未就绪那一刻才问),而健康端点就是一次内存读。
*
* ## 这条链真正是怎么走的(W44 逐段核对,真机回归的判据依据)
* 1. `ChatViewModel.probeReachability`(`ui/chat/ChatViewModel.kt`)**每次都现取一次** client:
* `container.connectivityRepository` → `AppContainer.healthApi`(按 `authManager.currentBaseUrl`
* 缓存,地址一变就重建)→ [top.yeij.cyrene.data.api.ApiClientFactory.createProbe]。这一步在
* **主线程**上完成,且**它在 `try` 里**——建 client 抛的一切(地址为空 / 格式不合法会让
* `Retrofit.baseUrl` 直接抛)都折成 [Reachability.Unreachable][top.yeij.cyrene.ui.chat.Reachability],
* 连一个字节都不会发出去。
* 2. 请求本体:[HealthApi.health] 是 `GET <当前 baseUrl>/api/v1/health`,**不带令牌、不带任何
* 自定义头**(不挂 `AuthInterceptor`,因此也没有 `X-API-Version`)——该端点在服务端挂在公开
* 路由上(`Cyrene_v2/internal/services/gateway/routes.go:51`),`rest.go:439` 的 `writeJSON`
* 把它包成 `{"data":{"status":"ok"}}`,正好是 [top.yeij.cyrene.data.api.ApiEnvelope] + `HealthDto`
* 要的形状。
* 3. 成败判据就一条:`apiCall` 拿到 **2xx 且 `data` 非空** ⇒ `Result.success(true)`;非 2xx、
* 「2xx 但回执解析不出来」、连不上、超时、抛异常 ⇒ `Result.failure`。上层
* [top.yeij.cyrene.ui.chat.reachabilityOf] 再把它压成两档,**fail-closed**。
*
* ## 失败时它在外面长什么样:三种成因**完全同形**(都只是「消息进了队列」)
* - **(甲) 请求压根没出去**:`createProbe` / `Retrofit.baseUrl` 在发出前抛异常(baseUrl 为空或
* 格式不合法)。此时 socket 上一个字节都没有。
* - **(乙) 连上了但一个字都没写**:`createProbe` 的 `callTimeout` 是**整通上限**(含连接),
* 而探测从不复用连接池 ⇒ 每次都要现做一次 TCP 握手,预算只有 2.5 秒(`PROBE_TIMEOUT_MILLIS`),
* 比共用客户端的 10 秒**连接**预算紧 4 倍。连接比它慢时 OkHttp 在写请求之前就放弃了:
* 对端只看到「连上又不说话」,**日志里一条请求行都不会有**。
* - **(丙) 请求出去了、对端也回了非 2xx**(或回执解析不出来):这一档**会**在对端留下记录。
*
* ⇒ 所以「代理日志里没有 `/api/v1/health`」**不能**单独推出「App 没探过」:(甲)(乙) 都不留痕,
* 只有 (丙) 留痕。要判定必须看**客户端这一侧**(W44 起 DEBUG 下探测客户端挂了 BASIC 日志,
* 见 [top.yeij.cyrene.data.api.ApiClientFactory.createProbe]):
* - logcat 里**没有** `--> GET .../api/v1/health` ⇒ (甲):死在建 client 那一步,去查 App 里
* 配置的服务器地址(空 / 少了 scheme / 多了非法字符);
* - 有 `-->` 且随后是 `<-- HTTP FAILED: java.io.InterruptedIOException: timeout` ⇒ (乙):
* 2.5 秒的整通上限被连接吃掉了(`ConnectException` 则是对端根本没在听,那是「路真的断了」,
* 与客户端无关);
* - 有 `-->` 且随后是 `<-- 2xx/非 2xx` ⇒ (丙):网络那一段没问题,看回执本身。
*/
class ConnectivityRepository(
private val api: HealthApi,
@@ -20,6 +20,15 @@ enum class WsState {
/** 连接断开,正在按退避策略等待重连。 */
Reconnecting,
/** 不可恢复(握手 401 / 缺少 token 或地址),不会自动重连。 */
/**
* 不可恢复(握手 401 / 缺少 token 或地址),不会自动重连。
*
* ⚠️ **握手拿到「非 101 且非 401」的响应不属于这一档**:那种失败(例如中间设备/代理直接回
* `503`、`502`,或握手被打断)在 [ChatSocket.isAuthFailure] 里判不出鉴权失败 ⇒ 走
* [Reconnecting] 按退避**继续重连**。判据只认 `response.code == 401`(拿不到 response 时再退化
* 成消息文本里含 `401`),所以「代理回 503」时 socket 的真值是 [Reconnecting],不是 [Offline]。
* 这一条在 W41/W44 的发送阶梯上很要命:它决定了「WS 未就绪」时该不该去探一次健康端点
* ([top.yeij.cyrene.ui.chat.sendRouteFor] 只在真 [Offline] 上跳过探测结论)。
*/
Offline,
}
@@ -1601,6 +1601,11 @@ class ChatViewModel(
// 未就绪 ⇒ 先探一次再决定(W41)。探测失败 / 超时一律按不通处理
// ([reachabilityOf] 的 fail-closed)⇒ 落回 W40 的队列与那套 UI,不新造。
//
// 探测是**作为实参**算出来的 ⇒ 四档未就绪**都会真的发一次请求**,包括 [WsState.Offline]
// 那一档([sendRouteFor] 在那里不看这个值,结论被丢掉:路由结果与不探逐字相同,
// 代价只是白发一次请求)。写在这里是因为「Offline 连探测都不做」这句话只在判据层面
// 成立,读代码时极易当成「那条路上不产生请求」——W44 的诊断正是卡在这个歧义上。
WsSendOutcome.Queued -> when (
sendRouteFor(wsDispatched = false, connection = state, reachability = probeReachability())
) {
@@ -1627,6 +1632,14 @@ class ChatViewModel(
* (用户修好地址、连接一就绪就自动补发),**不是**让一次发送把异常抛到界面上去。
*
* 取消照旧抛出去(它是取消,不是「探测失败」,吞掉会让协程无法被取消)。
*
* ## 一个 `Unreachable` 有三种成因,而它们在外面**完全同形**(W44 判定结论)
* 「请求压根没出去」「连上了但一个字节都没写(连接吃掉了 2.5 秒的整通上限)」「请求出去了、
* 对端回了非 2xx」在这里都被折成同一个值,最后只表现为「消息进了队列」——W41 的真机回归
* 就是这么卡住的(代理日志里一条 `/api/v1/health` 都没有,却分不清是哪一档)。三档的成因、
* 怎么用 logcat 分开,逐条写在 [ConnectivityRepository] 的类注释里(那份是本条链的判据来源)。
*
* 注意 `catch` 只认 `Exception`:**断言 / OOM 之类不会**被吞成「不通」,它们照旧抛出去。
*/
private suspend fun probeReachability(): Reachability = try {
reachabilityOf(container.connectivityRepository.probe())
@@ -83,12 +83,19 @@ enum class RestFailureLanding {
* ## 判据与优先级
* 1. `wsDispatched == true` ⇒ [SendRoute.Ws]。帧已经交给 socket,连接状态与探测结论都**不再参与**
* 判断(`sendChat` 判的就是自己的状态)。**成功路径零改动**:不打标记、不说告知、不写任何状态。
* 2. 连接是 [WsState.Offline] ⇒ [SendRoute.Queue],**连探测都不做**。这一档的语义是「握手 401 且
* 2. 连接是 [WsState.Offline] ⇒ [SendRoute.Queue],**判据不看探测结论**。这一档的语义是「握手 401 且
* 续期也失败」(终态、不会自动重连),也就是说**会话已知是死的**。这时候去探测毫无意义:健康
* 端点**不鉴权**(`Cyrene_v2/internal/services/gateway/routes.go:51` 把它挂在公开路由里),它必然
* 回 200,于是判成 [Reachable]、发出一次注定 401 的 REST,最后把 W40 那句「已排队不会丢」换成
* 一次回滚 + 一句会误导人的鉴权错误。这一档该做的是让用户重新登录 / 检查服务器地址
* ([SEND_QUEUED_OFFLINE_NOTICE]),队列照旧留着。
*
* ⚠️ **「不看」不等于「没探」**:调用方(`ChatViewModel.sendThroughLadder`)是**先算出
* `reachability` 再把它喂给本函数**的,所以在 [WsState.Offline] 上那一次探测**照样会发出去**,
* 只是它的结论在这里被丢掉。行为上无害(路由结果与不探逐字相同,本文件的用例已经用
* `null` / [Reachable] / [Unreachable] 三种取值钉住),代价是**白发一次请求**。这条真实行为
* 写在这里是因为它很容易被读成「Offline 时不发请求」——W44 的诊断就差点栽在这个歧义上
* (见 [top.yeij.cyrene.data.repository.ConnectivityRepository.probe] 的三种成因一节)。
* 3. 其余三档未就绪([WsState.Idle] / [WsState.Connecting] / [WsState.Reconnecting])且探测是
* [Reachability.Reachable] ⇒ [SendRoute.Rest]:回落 REST,把这条发出去。
* 4. 其余一切(未就绪 + 探测不通 / 超时 / 抛异常 / 还没探过 = `null`)⇒ [SendRoute.Queue]。
@@ -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() } }
}
}
}
+30
View File
@@ -1076,3 +1076,33 @@ curl -s -X PATCH "$BASE/api/v1/sessions/$SID" -H "Authorization: Bearer $TOK" \
> ### 另外两轮的失败原因(免得下次又猜)
> 第 1、2 轮之所以「什么都没发生」:**前台根本不是我们的 App,而是 `com.android.settings/.SubSettings`**(`dumpsys window` 看出来的)。所以我的点击全落在设置页上。
> ⇒ 教训升级:**不只要确认输入框聚焦,还要先确认前台就是我们的 App**(`dumpsys window | grep mCurrentFocus`)。
> ## W44(2026-10-06 · W41 那个「缺口」的判定:A 已否,B 只到形态,根因要一次设备取证)
>
> 分支 `feat/W44-probe-diagnosis`(基线 `866a3c3`)。本轮**没动任何语义**:`sendRouteFor` 的 fail-closed、探测的 2.5 秒上限、三档路由一个字都没改——判定的结论是「代码这条路是对的,失败生在连接阶段」,而那一档在本机看不出成因。
>
> ### 1. 假设 A(503 被判成 Offline、界面镜像滞后)——**否**
> `ChatSocket.isAuthFailure`(`app/src/main/kotlin/top/yeij/cyrene/data/ws/ChatSocket.kt:320-323`)**只认 `response.code == 401`**(拿不到 response 时才退化成消息文本含 401);非 101 的握手响应(代理回 503)落到 `SessionEnd.Closed` ⇒ `Reconnecting` + 退避重连(同文件 `:301-304`、`:230-235`)。而顶栏副标题「重连中…」正是 `WsState.Reconnecting` 的文案(`ui/chat/ChatScreen.kt:918`,`Offline` 那档是「离线」)。
> ⇒ 那一刻 socket 的真值就是 `Reconnecting`,`sendRouteFor` 在这一档**会**看探测结论,界面镜像也没有滞后。A 不成立。
>
> ### 2. 假设 B ——**形态成立,但「这条链根本发不出请求」不成立**
> - 调用点是**无条件求值的实参**:`sendRouteFor(wsDispatched = false, connection = state, reachability = probeReachability())`(`ui/chat/ChatViewModel.kt:1614`)⇒ 只要走到 `WsSendOutcome.Queued`,探测**一定被调用**(连 `Offline` 那一档也会,`sendRouteFor` 只是把结论丢掉——这一点与本轮前的 KDoc 说法相反,已改)。
> - 新增 `app/src/test/kotlin/top/yeij/cyrene/data/api/ProbeClientTest.kt`(6 例,真 OkHttp + 真 Retrofit + JDK `HttpServer`,与 `ChatRepositoryTest` 同一手法):证实这条链**能**在真 socket 上留下 `GET /api/v1/health`、**不带 `Authorization`**、200 ⇒ `Reachable`、503 ⇒ `Unreachable`、2xx 但回执解析不出来 ⇒ `Unreachable`、黑洞服务端在 2.5 秒上限处失败。⇒ 「探测压根发不出去」不是代码事实。
> - 但真机代理日志里一条 `/api/v1/health` 都没有 ⇒ 那个 `Unreachable` 是在**连接之前**产生的。而它在外面**完全同形**(三种成因写在 `data/repository/ConnectivityRepository.kt` 类注释新增的那一节):
> - **(甲) 建 client 就抛**:baseUrl 为空或格式不合法时 `Retrofit.baseUrl` 直接抛,被 `probeReachability` 的 `catch (Exception)` 折成不通。socket 上一个字节都没有。
> - **(乙) 连上了、一个字都没写**:`createProbe` 的 `callTimeout` 是**整通上限(含连接)**,而探测**从不复用连接池** ⇒ 每次都要现做一次冷握手,预算只有 2.5 秒,比共用客户端的 10 秒**连接**预算紧 4 倍。连接比它慢时 OkHttp 在写请求之前就放弃了 ⇒ 对端只看到「连上又不说话」,**日志里一条请求行都不会有**。
> - **(丙) 请求出去了、回执非 2xx**(或回执解析不出来):**只有这一档在对端留痕**。
> ⇒ 所以「代理日志里没有 health 那一行」**推不出**「App 没探过」;(甲)(乙) 都不留痕。上一轮结论里那句「App 从来没探过健康端点」需要收回一半:**没探过是可能的,(甲)(乙) 也同样符合全部现有证据**。
>
> ### 3. 本轮改动(除一处 DEBUG 日志外,全部是文档/测试;无行为变化)
> - 新增测试 `app/src/test/kotlin/top/yeij/cyrene/data/api/ProbeClientTest.kt`(6 例)。
> - `data/api/ApiClientFactory.kt` 的 `createProbe`:**DEBUG 下挂 BASIC 日志**(与通用 `create` 同一约定)。这是本轮要的**诊断仪**:`-->` 打在连接**之前**,所以「到底有没有探」与「死在连接还是死在回执」当场可分。探测无令牌、无请求体,日志不涉凭据。
> - KDoc 失真修正:`ui/chat/SendFallback.kt` 的 `sendRouteFor`(「连探测都不做」只在判据层面成立)、`ChatViewModel` 的调用点与 `probeReachability`、`ConnectivityRepository` 补「三种成因 + logcat 判据」、`data/ws/WsState.kt` 的 `Offline` 补「非 401 的握手失败不属于这一档」。
>
> ### 4. 下一步(要设备,一次就能定论)
> 1. 同一套场景重跑(`ws_block_proxy.py` + `adb reverse tcp:10942 tcp:18098`),在输入栏发一条纯文本。
> 2. 看**发消息那一刻**的 `adb logcat -s OkHttp`(探针客户端走 OkHttp 的 Platform 日志):
> - **没有** `--> GET http://127.0.0.1:10942/api/v1/health` ⇒ **(甲)**:死在建 client,去查 App 里配置的服务器地址(空 / 缺 scheme / 有非法字符)。
> - 有 `-->` 后跟 `HTTP FAILED: java.io.InterruptedIOException: timeout` ⇒ **(乙)**:2.5 秒整通上限被连接吃掉。(**不许**为此放宽 fail-closed;要不要让探测的连接预算与链路对齐,是主人裁决。)
> - 有 `-->` 后跟 `HTTP FAILED: java.net.ConnectException` ⇒ 对端根本没在听(`adb reverse` 被冲掉 / 代理没起)⇒ **这不是 App 的 bug**。
> 3. 同时确认**发完消息之后**代理日志里还有没有新的 `BLOCKED /ws/chat`:没有就说明路已经断了(⇒ 第 2 条的第三种),那一轮阶梯其实从没在「HTTP 通」的条件下被考过 —— W40 那一轮就是用 `adb kill-server`/`adb root` 造「真断线」的,这两件都会把 `adb reverse` 一起冲掉(见本文件 W39/W40 两处踩坑记录)。