feat(W46): 发送路由带上原因,并加一行 CyreneRoute 诊断日志

为什么要把「原因」也带出来
- 结果只有 Ws / Rest / Queue 三档,而「为什么是这一档」在设备上完全看不见。W45 那轮真机回归
  就卡在这里:那条消息进了补发队列、网络侧一条请求都没有,而「零请求 + 进队列」在判据里只可能
  来自 Queue 那一档;可顶栏同时显示「重连中…」。两者都只是结论,事后无法分辨「判据读到
  Offline」与「判据读了别的值」。
- 于是把判据抽成 sendRouteDecisionFor(wsDispatched, connection) -> (route, reason):reason 就是
  原来那三条规则各自的分支标识(FrameDispatched / ConnectionOffline / ConnectionNotReady)。
  sendRouteFor 现在读它的 route,所以两处不可能分叉。判据、优先级、行为逐字未改。

那一行日志(tag = CyreneRoute,Log.d,只在 DEBUG 构建打)
- 打点:ChatViewModel.sendThroughLadder 一进入就一笔,覆盖阶梯的三个分支(帧交出去 / 试 REST /
  直接进队列),一行 key=value、空格分隔:
  wsDispatched=... socket=<ChatSocket.state.value,权威值> mirror=<顶栏读的那个镜像>
  wsObject=<present|absent> route=... reason=... outcome=... fromQueue=...
- socket 与 mirror 必须同时打出来:mirror 是 collect 过来的一格,只会滞后、不会超前,两者不一致
  就说明顶栏那一刻显示的是上一格 —— 这正是「顶栏重连中…」与「这条进了队列」能同时出现的形态。
- 这是开发期诊断仪,不是产品行为:字段增删不构成兼容问题、不为它写测试(要钉的是纯函数那张全表)。
  不打 token / 请求体 / 正文 / localId。

顺带(同一轮的可观测性)
- ChatSocket 新增只读属性 hasLiveSocket(webSocket != null),只喂日志,不是产品逻辑的输入。
- 复核 WsState.Offline 的写点:ChatSocket 里只有两处(runConnectionLoop 的 ConfigMissing,以及
  endWithAuthFailure),close() 写的是 Idle;非 401 的握手失败(代理回 503)走 Closed ->
  Reconnecting,进不了这一档。

测试
- SendFallbackTest 新增两例:五档连接状态 × wsDispatched 真假的全表逐个钉住结果 + 原因,并钉住
  sendRouteFor 与判定同值;另一个钉住三个 reason 都可达、没有第四种组合。全量 1155 例,
  0 失败 0 错误。
This commit is contained in:
AskaEth committed 2026-10-05 19:50:28 +08:00
1 parent b8399b5949
commit d546f31755
4 files changed
+189 -9

No files matched your search

@@ -26,7 +26,7 @@ import top.yeij.cyrene.domain.chat.SendChannel
* 现在的判据里**没有任何探测输入**:路由只看「帧交出去了没有 + 这一刻的连接状态」两个事实,
* REST 那一次的产物由 [restAttemptLanding] 判落点。
*
* ## 本测试钉住六件事
* ## 本测试钉住七件事
* 1. **优先级**([sendRouteFor]):帧交出去了 ⇒ 走 WS(连接状态不再参与);未就绪且不是
* [WsState.Offline] ⇒ 直接试 REST;[WsState.Offline] ⇒ 进队列、**一个请求都不发**。
* 2. **收下了就不进队列**:受理 / 续接排队 / 幂等重复([ChatAck.isAccepted] 那一族)⇒
@@ -36,6 +36,9 @@ import top.yeij.cyrene.domain.chat.SendChannel
* 4. **服务端给过裁决 ⇒ 回滚**:拒绝的回执(`409` / `500` / `503` / `400`)与 401 / 配置类异常。
* 5. **两个超时**:连接超时落在 2~3 秒(无网时的等待上界),整通上限另设且不许拉长。
* 6. **这条阶梯到不了「REST 专属」的消息**(有图 / 有附件一律在进入阶梯之前就被判成 REST)。
* 7. **「为什么是这一档」**([sendRouteDecisionFor],W46):五档连接状态 × `wsDispatched` 真假那
* 十个格子逐个钉住**结果 + 原因**。结果是给代码看的,原因是给**设备上的日志**看的——真机上
* 看不见判据读到的是哪个连接状态,而那正是排查「发送为什么进了队列」时唯一要分的东西。
*/
class SendFallbackTest {
@@ -124,6 +127,69 @@ class SendFallbackTest {
}
}
// —— 一之二、判据的「原因」(W46 可观测性) ——
@Test
fun `the whole table carries which rule decided it`() {
// 结果只有三档,而「**为什么**是这一档」在真机上看不见:W45 那轮真机回归里,那条消息进了
// 队列、网络侧一条请求都没有,而「零请求 + 进队列」在判据里只可能来自 Queue;可顶栏同时
// 显示「重连中…」。只看结果分不出「判据读到 Offline」与「判据读了别的值」,所以要有个地方
// 能把依据本身取出来——同时打给 logcat(tag `CyreneRoute`)与这条全表。
//
// 表还是五档 × 帧交出去了没有那十个格子,只是每一格多钉一个原因;并顺手钉住
// `sendRouteFor` 就是它的 `.route`(两处**不可能**分叉,日志里那一档与真正走的那一档必然
// 是同一次判定的结果)。
val table = listOf(
Triple(true, WsState.Idle, SendRouteDecision(SendRoute.Ws, SendRouteReason.FrameDispatched)),
Triple(true, WsState.Connecting, SendRouteDecision(SendRoute.Ws, SendRouteReason.FrameDispatched)),
Triple(true, WsState.Reconnecting, SendRouteDecision(SendRoute.Ws, SendRouteReason.FrameDispatched)),
Triple(true, WsState.Connected, SendRouteDecision(SendRoute.Ws, SendRouteReason.FrameDispatched)),
Triple(true, WsState.Offline, SendRouteDecision(SendRoute.Ws, SendRouteReason.FrameDispatched)),
Triple(false, WsState.Idle, SendRouteDecision(SendRoute.Rest, SendRouteReason.ConnectionNotReady)),
Triple(false, WsState.Connecting, SendRouteDecision(SendRoute.Rest, SendRouteReason.ConnectionNotReady)),
Triple(false, WsState.Reconnecting, SendRouteDecision(SendRoute.Rest, SendRouteReason.ConnectionNotReady)),
Triple(false, WsState.Connected, SendRouteDecision(SendRoute.Rest, SendRouteReason.ConnectionNotReady)),
// 唯一能在**一个请求都不发**的前提下进队列的那一格(也正是排查时要认出来的那一格)。
Triple(false, WsState.Offline, SendRouteDecision(SendRoute.Queue, SendRouteReason.ConnectionOffline)),
)
table.forEach { (dispatched, connection, expected) ->
val decision = sendRouteDecisionFor(wsDispatched = dispatched, connection = connection)
assertEquals(
"dispatched=$dispatched / $connection 的结果与依据",
expected,
decision,
)
assertEquals(
"dispatched=$dispatched / $connection 上 sendRouteFor 必须与判定同值",
expected.route,
sendRouteFor(wsDispatched = dispatched, connection = connection),
)
}
}
@Test
fun `every reason is reachable and each rule owns exactly one`() {
// 原因这一栏与三条规则**一一对应**:每一个取值都必须能在全表里被取到(将来加了规则却没人
// 标出来,这条会红),而且「结果 + 原因」不会出现两种组合指向同一个结果这种糊法。
val reachable = listOf(true, false).flatMap { dispatched ->
WsState.entries.map { sendRouteDecisionFor(dispatched, it).reason }
}.toSet()
assertEquals(SendRouteReason.entries.toSet(), reachable)
val combos = listOf(true, false).flatMap { dispatched ->
WsState.entries.map { sendRouteDecisionFor(dispatched, it) }
}.toSet()
// 十格只有三组(结果,原因):`Ws` 一格、`Rest` 一格、`Queue` 一格,没有第四种组合。
assertEquals(
setOf(
SendRouteDecision(SendRoute.Ws, SendRouteReason.FrameDispatched),
SendRouteDecision(SendRoute.Rest, SendRouteReason.ConnectionNotReady),
SendRouteDecision(SendRoute.Queue, SendRouteReason.ConnectionOffline),
),
combos,
)
}
// —— 二、直接试 REST 的落点 ——
@Test