fix(chat): 次级会话补上「连接恢复自己补拉」(S2);已删会话不再发注定 404 的补拉(S7)

## S2 —— 次级会话没有握手 history 这第二条安全网

- 新增纯判据 `ui/chat/ReconnectPull.kt`:连接恢复那一刻该补哪一段
  (Forward / FirstPage / Nothing)。判据只看四样:会话 id、首屏状态、掉线游标、
  「本端已知它没了」。
- `ChatViewModel`:把 init 那次首屏 `loadLatest` 提成 `loadFirstPage()`,init 与补拉
  **共用一份代码**(补出来的结果必须与首屏逐字段同形);新增 `FirstPageState` 作为判据输入。

为什么这么定:
- **为什么不是照抄主会话的 history**:服务端固定回放主会话(`ws_hub_conn.go`),要它回放
  次级会话就是改契约面。次级走的是本项目已有的那条 REST 口径(拉最新一页)。
- **为什么用「首屏从没成功过」而不是「本端有没有数据」**:服务端真的没有消息时列表**也是空的**,
  拿空列表当判据会让每一个空次级会话在每次重连都被整表重拉 —— 那正是 S4/S7 这一类问题的成因。
- **为什么 Loading 不算失败**:首屏还在飞时再发一次同样的请求会与它抢跑,还会改掉
  「history 帧一到就 loading=false」那段时序(S3 本轮**一个字节都不动**)。
- **为什么主会话排除在外**:它有 history 兜底,在那里再补一条 REST 就是给同一件事造第二套口径。
  主会话的兜底行为因此**逐字不变**(有洞走 Forward,其余 Nothing)。
- 补拉那一次把 `loading` 翻回真:屏幕上原本是「还没有消息」停在原地,如实应该是「加载中…」。

## S7 ① —— 注定 404 的补拉不再发生

- 新增 `core/session/GoneSessions.kt`(进程级、**不持久化**、登出/换账号时清空):本端已知
  「服务端没有这个会话」的 id 集合。写入口是 `SessionsViewModel.deleteSession` 成功那一刻
  (本端唯一的删除入口),读入口是**每一个** `ChatViewModel`。
- 判据第 1 条:已知它没了 ⇒ 什么都不发。于是本地删过的会话从此**零次**注定失败的往返;
  已经 404 过的会话从「每次重连各撞一次」收敛成**恰好一次**(`sessionGone` 同时成为补拉的闸)。

## S7 ② —— sessionGone 的出路不再依赖网络

那个位的消费者**一直都在**(`ChatScreen` 按它触发的 `LaunchedEffect` → `ChatHomeScreen` 的
`onSessionGone`:一句如实告知 + 带回主会话),所以本轮补的是它真正的缺口 —— 原来只靠 `refresh`
**成功**之后的 `resolveActiveSession` 回落(拉失败、或列表为空时刻意不回落 ⇒ 什么都不动)。
于是「已切回主会话」在网络不稳 / 服务端抖一下时是一句**做不到的话**,用户被留在已经没了的会话上、
没有任何出路。现在先在本端 `select(主会话)`(与用户点抽屉里那一行走的是**同一条**路,不另造机制),
`refresh` 退回本职:与服务端对齐、把抽屉里那条去掉。

## 未做 / 未验

- **没有真机验收**(任务书禁碰 adb):S2 的「连接恢复后次级会话自己补上」只有纯判据单测。
- 「REST 首屏失败、但 WS 从头到尾没断(因此没有任何状态变化可挂)」这一格**不在本轮范围内**,
  仍是已知缺口。
- 全程未碰 S3、主会话兜底与游标口径、W48 的 VM key、W49 的节流判据、W50 的主动消息消费路径。
This commit is contained in:
AskaEth committed 2026-10-06 09:03:22 +08:00
1 parent a9cc3c5737
commit 9980fb2b28
8 files changed
+464 -27

No files matched your search

@@ -0,0 +1,50 @@
package top.yeij.cyrene.core.session
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* 「本端已知它没了」这一位的口径([GoneSessions])。
*
* 它只回答「知道不知道」:`mark` 是一个只进不退的记账,`contains` 对**没记过**的一律 `false`
* (不知道 = 照旧去问一次服务端,绝不拿「没记过」当「还在」之外的任何结论);`clear` 是换账号 /
* 登出时的作废(上一个环境里那些 id 的结论不能留给下一个环境用)。
*/
class GoneSessionsTest {
@Test
fun `an unmarked session is not known to be gone`() {
// 「不知道」必须什么都不拦:补拉的闸只认**已经知道**的结论,否则离线冷启动那次
// 首屏补拉会被一个空集合莫名其妙地挡下来。
assertFalse(GoneSessions().contains("web:never-deleted"))
}
@Test
fun `marking a session makes it known`() {
val gone = GoneSessions()
gone.mark("web:deleted")
assertTrue(gone.contains("web:deleted"))
assertFalse(gone.contains("web:other"))
}
@Test
fun `marking is idempotent and never a blank id`() {
val gone = GoneSessions()
gone.mark("web:deleted")
gone.mark("web:deleted")
assertTrue(gone.contains("web:deleted"))
// 空串不是任何会话的 id(坏帧 / 缺字段),记下来只会让一个不是会话的东西永远占着这一位。
gone.mark("")
assertFalse(gone.contains(""))
}
@Test
fun `clear forgets everything`() {
val gone = GoneSessions()
gone.mark("web:deleted")
gone.clear()
assertFalse(gone.contains("web:deleted"))
}
}
@@ -0,0 +1,159 @@
package top.yeij.cyrene.ui.chat
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* 连接恢复那一刻**这一轮该怎么补**([reconnectPull])。
*
* 钉住两条本轮要收口的登记项,以及它们的边界:
* - **S2**:次级会话没有握手 `history` 这第二条安全网 ⇒ 「首屏没拉到 + 之后连接恢复」时必须
* **自己去补一次最新一页**;而**主会话**不许多补这条路(它有 `history`,再补就是第二套口径)。
* - **S7 ①**:本端已知会话没了的那些 VM,**一次补拉都不许发**(注定 404,且会被静默吞掉)。
*
* 另外两条边界是「不要为了修一个洞开一个新洞」:
* - 首屏**还在飞**时不许补(否则连接恢复会与首屏那次抢跑,还会改掉 S3 那条窄窗口的时序);
* - 首屏**成功过**之后不许补(哪怕服务端真的没有消息、列表是空的 —— 那正是「每次重连整表重拉」
* 的成因,S4/S7 这一类问题的来源)。
*
* 有断线窗口([gapCursor][reconnectPull] 非空)时走既有的正向增量,本轮一个字都没改。
*/
class ReconnectPullTest {
// —— S2:次级会话的那一次补拉 ——
@Test
fun `a sub session whose first page failed catches up on reconnect`() {
// S2 的正题:冷启动离线进次级会话 ⇒ 首屏 REST 失败 ⇒ 连接恢复时界面还空着。
// 主会话这时候由握手 history 兜住,次级会话只有这条路。
assertEquals(
ReconnectPull.FirstPage,
reconnectPull(
sessionId = "web:1234",
firstPage = FirstPageState.Failed,
gapCursor = null,
sessionKnownGone = false,
),
)
}
@Test
fun `the main session never takes the first page route`() {
// 主会话已经有 history 这条安全网;在这里再补一条 REST 就是给同一件事造第二套口径,
// 而分叉的那一侧就是漏出去的那一侧。主会话的兜底行为必须逐字不变。
assertEquals(
ReconnectPull.Nothing,
reconnectPull(
sessionId = MAIN_SESSION_ID,
firstPage = FirstPageState.Failed,
gapCursor = null,
sessionKnownGone = false,
),
)
}
@Test
fun `a first page still in flight is not re-issued`() {
// 冷启动的正常时序:首屏那次还在飞时就重连上了 —— 不许再发一次同样的请求。
// 这一条同时保证「history 帧一到就 loading=false」那段时序一个字节都没动(S3 不动)。
assertEquals(
ReconnectPull.Nothing,
reconnectPull(
sessionId = "web:1234",
firstPage = FirstPageState.Loading,
gapCursor = null,
sessionKnownGone = false,
),
)
}
@Test
fun `a first page that already succeeded is never pulled again`() {
// 关键的一条:判据是「首屏**成功过**」,不是「列表里有没有消息」——服务端真的没有消息的
// 次级会话也是「成功过」,每次都重拉它就是把重连做成了整表重拉。
assertEquals(
ReconnectPull.Nothing,
reconnectPull(
sessionId = "web:empty",
firstPage = FirstPageState.Loaded,
gapCursor = null,
sessionKnownGone = false,
),
)
}
// —— 既有行为:有洞就补洞 ——
@Test
fun `a gap window keeps the existing forward pull`() {
// 掉线那一刻记下了游标 ⇒ 照旧走 after 正向增量(比首屏更精确:只补那个洞)。
assertEquals(
ReconnectPull.Forward,
reconnectPull(
sessionId = "web:1234",
firstPage = FirstPageState.Failed,
gapCursor = 1_700_000_000_000L,
sessionKnownGone = false,
),
)
assertEquals(
ReconnectPull.Forward,
reconnectPull(
sessionId = MAIN_SESSION_ID,
firstPage = FirstPageState.Loaded,
gapCursor = 1L,
sessionKnownGone = false,
),
)
}
// —— S7 ①:注定 404 的补拉不再发生 ——
@Test
fun `a session the client already knows is gone never pulls`() {
// 三个入口都要堵住:有洞的(每次重连都会撞一次)、首屏失败的、以及什么都不做的。
// 「已知它没了」是一个位(本地删过 或 已经 404 过),判据只认这一个事实。
for (firstPage in FirstPageState.entries) {
for (cursor in listOf(null, 1_700_000_000_000L)) {
assertEquals(
"firstPage=$firstPage cursor=$cursor",
ReconnectPull.Nothing,
reconnectPull(
sessionId = "web:deleted",
firstPage = firstPage,
gapCursor = cursor,
sessionKnownGone = true,
),
)
}
}
}
@Test
fun `a gone main session is still gated`() {
// 主会话不会置 sessionGone(部署问题走普通错误横幅),但判据本身不该依赖调用方
// 「记得别传 true」——它只回答「已知没了就别发」,与是哪个会话无关。
assertEquals(
ReconnectPull.Nothing,
reconnectPull(
sessionId = MAIN_SESSION_ID,
firstPage = FirstPageState.Loaded,
gapCursor = 7L,
sessionKnownGone = true,
),
)
}
@Test
fun `nothing happens when there is nothing to do`() {
assertEquals(
ReconnectPull.Nothing,
reconnectPull(
sessionId = "web:1234",
firstPage = FirstPageState.Loaded,
gapCursor = null,
sessionKnownGone = false,
),
)
}
}