From 9980fb2b28f5445af69a89b4e9c3d3f76b702af8 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 6 Oct 2026 09:03:22 +0800 Subject: [PATCH] =?UTF-8?q?fix(chat):=20=E6=AC=A1=E7=BA=A7=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E8=A1=A5=E4=B8=8A=E3=80=8C=E8=BF=9E=E6=8E=A5=E6=81=A2?= =?UTF-8?q?=E5=A4=8D=E8=87=AA=E5=B7=B1=E8=A1=A5=E6=8B=89=E3=80=8D=EF=BC=88?= =?UTF-8?q?S2=EF=BC=89=EF=BC=9B=E5=B7=B2=E5=88=A0=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E5=8F=91=E6=B3=A8=E5=AE=9A=20404=20=E7=9A=84?= =?UTF-8?q?=E8=A1=A5=E6=8B=89=EF=BC=88S7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 的主动消息消费路径。 --- .../top/yeij/cyrene/app/AppContainer.kt | 10 ++ .../yeij/cyrene/core/session/GoneSessions.kt | 47 ++++++ .../top/yeij/cyrene/ui/chat/ChatHomeScreen.kt | 12 +- .../top/yeij/cyrene/ui/chat/ChatViewModel.kt | 116 ++++++++++--- .../top/yeij/cyrene/ui/chat/ReconnectPull.kt | 88 ++++++++++ .../cyrene/ui/sessions/SessionsViewModel.kt | 9 + .../cyrene/core/session/GoneSessionsTest.kt | 50 ++++++ .../yeij/cyrene/ui/chat/ReconnectPullTest.kt | 159 ++++++++++++++++++ 8 files changed, 464 insertions(+), 27 deletions(-) create mode 100644 app/src/main/kotlin/top/yeij/cyrene/core/session/GoneSessions.kt create mode 100644 app/src/main/kotlin/top/yeij/cyrene/ui/chat/ReconnectPull.kt create mode 100644 app/src/test/kotlin/top/yeij/cyrene/core/session/GoneSessionsTest.kt create mode 100644 app/src/test/kotlin/top/yeij/cyrene/ui/chat/ReconnectPullTest.kt diff --git a/app/src/main/kotlin/top/yeij/cyrene/app/AppContainer.kt b/app/src/main/kotlin/top/yeij/cyrene/app/AppContainer.kt index 453e753..d93959d 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/app/AppContainer.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/app/AppContainer.kt @@ -6,6 +6,7 @@ import top.yeij.cyrene.core.auth.SessionStore import top.yeij.cyrene.core.prefs.AppSettings import top.yeij.cyrene.core.prefs.EnumPreferences import top.yeij.cyrene.core.prefs.SessionPreferences +import top.yeij.cyrene.core.session.GoneSessions import top.yeij.cyrene.data.api.AdminApi import top.yeij.cyrene.data.api.ApiClientFactory import top.yeij.cyrene.data.api.ChatApi @@ -36,6 +37,15 @@ class AppContainer(context: Context) { val appSettings: AppSettings = AppSettings(context.applicationContext) val authManager: AuthManager = AuthManager(sessionStore) + /** + * 本端已知「服务端没有这个会话」的那几个 id(**进程级**,与 [chatSocket] 同一层)。 + * + * 跨两个 VM 的唯一共享面:写它的是 `SessionsViewModel`(本端唯一的删除入口),读它的是 + * **每一个** `ChatViewModel`(它们各留一个常驻 VM,删会话不回收)。它拦下的是那一轮注定 + * `404 session_not_found` 的补拉 —— 理由与「为什么不持久化」见 [GoneSessions] 的类注释。 + */ + val goneSessions: GoneSessions = GoneSessions() + private var cachedBaseUrl: String? = null private var cachedSessionsApi: SessionsApi? = null diff --git a/app/src/main/kotlin/top/yeij/cyrene/core/session/GoneSessions.kt b/app/src/main/kotlin/top/yeij/cyrene/core/session/GoneSessions.kt new file mode 100644 index 0000000..a987186 --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/core/session/GoneSessions.kt @@ -0,0 +1,47 @@ +package top.yeij.cyrene.core.session + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update + +/** + * 本端**已经知道服务端没有这个会话**的那几个会话 id(进程级、只在内存里、只增不减)。 + * + * ## 它回答的是哪一个问题 + * 「这个 id 还值不值得我发一次请求」。`ChatViewModel` 每个访问过的会话各留一个常驻 VM + * (切走 / 删会话都不回收),而它们都订阅同一条共享连接 ⇒ **每次重连各自会去补拉一轮**。会话 + * 在本端被删掉之后,那个 VM 并不知道(它没有别的渠道知道),于是**每一个**重连都发一次注定 + * `404 session_not_found` 的补拉,404 被静默吞掉(S7 登记的那一条)。 + * + * 补拉**事前**就能判掉它,靠的就是这里:删除是**本端自己做的**,那一刻就已经知道结果了, + * 没有任何理由等下一条请求回来才「发现」。于是这一位把「已知不存在」从「一次注定失败的往返」 + * 提前成「一次本地查询」——**不是**「重连时少拉一点」的省流量优化,而是不让一个注定失败的请求 + * 上路(它会占一条连接、写一行日志、并把 `sessionGone` 变成一个只能靠 404 才置得起来的位)。 + * + * ## 为什么是进程级、为什么**不**持久化 + * - 进程级(挂在 `AppContainer` 上,与那条共享连接同一层):写它的是 `SessionsViewModel` + * (本端唯一的删除入口),读它的是**别的** VM —— 两个 VM 之间没有别的共享面。 + * - **不持久化**:它只是「这一趟进程里已经知道的事」。重启之后本端的会话列表会重新从服务端拉 + * ([top.yeij.cyrene.core.prefs.SessionPreferences] 那份快照本来就会整体替换),「这个会话还在不在」 + * 以那一次服务端答复为准;把 id 落盘只会让一份陈旧记忆挡掉重启后的第一次拉取。 + * - **换账号要清**([clear]):id 是服务端铸的,理论上不会跨账号撞车,但这一位说的是「本端 + * 已经知道」,而换账号之后本端**什么都不知道** —— 留着上个环境的结论,是那种「差一个分隔符 + * 就漏过去」的同一类错误(先例:`accountFingerprint` 的注释)。 + */ +class GoneSessions { + + private val ids = MutableStateFlow>(emptySet()) + + /** 记下「服务端已经没有这个会话了」。空串当无效值丢掉(那不是任何会话的 id)。 */ + fun mark(sessionId: String) { + if (sessionId.isBlank()) return + ids.update { it + sessionId } + } + + /** 本端是否**已经知道**它没了。只回答「知道不知道」,不回答「在不在」——不知道的一律 `false`。 */ + fun contains(sessionId: String): Boolean = sessionId in ids.value + + /** 换账号 / 登出:本端重新回到「什么都不知道」。 */ + fun clear() { + ids.value = emptySet() + } +} diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatHomeScreen.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatHomeScreen.kt index 41a03bd..56d807c 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatHomeScreen.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatHomeScreen.kt @@ -13,6 +13,7 @@ import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory import kotlinx.coroutines.delay import top.yeij.cyrene.app.AppContainer +import top.yeij.cyrene.data.api.dto.SessionSummaryDto import top.yeij.cyrene.ui.admin.AdminHomeScreen import top.yeij.cyrene.ui.common.MiuixDrawer import top.yeij.cyrene.ui.sessions.SessionDrawerContent @@ -105,12 +106,21 @@ fun ChatHomeScreen( }, // 当前会话在别处被删了(`404 session_not_found`)⇒ 先立起那句如实告知,再重拉列表: // `refresh` 里面会把「指向已删会话」的 activeSessionId 收敛回主会话 - // (`resolveActiveSession`),抽屉里那条也跟着消失。所以这里不需要自己判「该切到哪儿」。 + // (`resolveActiveSession`),抽屉里那条也跟着消失。 // // 顺序很重要:告知必须**先**立起来再刷新——刷新会让本层的 ChatScreen 换 key, // 那段组合连同挂在 VM 状态上的东西一起被丢弃重建,晚一步就什么都看不到了。 + // + // ⚠️ 但**不能只靠 `refresh` 把用户送回去**:它只在**拉成功**时才做回落 + // (`resolveActiveSession`,且列表为空时刻意不回落;`.onFailure` 那条更是什么都不动)。 + // 于是「网络正不稳 / 服务端正好抖一下」时,屏幕上那句「已切回主会话」就成了一句 + // 做不到的话——用户被留在一个已经没了的会话上,没有任何出路。这正是这个告知 + // 想要修掉的那一幕,所以这里**先在本端收敛**(`select` 是用户点抽屉里那一行走的 + // **同一条**路,不另造机制),`refresh` 退回它本来的职责:与服务端对齐、把抽屉 + // 里那条去掉。副作用与用户手点主会话进入时逐字相同(就地清未读 + 推进游标)。 onSessionGone = { sessionNotice = SESSION_GONE_MESSAGE + sessionsViewModel.select(SessionSummaryDto.MAIN_SESSION_ID) sessionsViewModel.refresh(silent = true) }, sessionNotice = sessionNotice, diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt index af0832b..c40a59d 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt @@ -719,6 +719,10 @@ data class ChatUiState( * * **主会话([MAIN_SESSION_ID])不会置它**:主会话若回 404,说明部署本身出了问题, * 那时候该显示的是普通的错误横幅,而不是一句「已切回主会话」(那会是一句假话)。 + * + * 它同时也是**补拉的闸**([reconnectPull] 第 1 条判据):置位之后这个会话不再有任何补拉—— + * 结论已经拿到了,再发一次注定还是 404。**消费者**是 `ChatScreen` 里那个按它触发的 + * `LaunchedEffect`(把上层那句告知立起来、并把用户带回主会话),不是「置了就完了」的一个位。 */ val sessionGone: Boolean = false, ) { @@ -846,6 +850,28 @@ class ChatViewModel( */ private var gapCursor: Long? = null + /** + * 本会话的**首屏**([loadFirstPage] 那一次)走到哪儿了;判据见 [reconnectPull]。 + * + * 这里不能用「`messages` 是不是空的」替代:服务端**真的**没有消息时列表也是空的,那样每一个 + * 空的次级会话都会在每次重连被整表重拉一次——那正是 S4/S7 这一类问题的来源。反过来,首屏在 + * 飞的时候([FirstPageState.Loading])也不能算「没拉到」,否则连接恢复会与首屏那次抢跑。 + * + * 只在 [loadFirstPage] 的两个出口写(成功 / 失败),没有第三个地方碰它。 + */ + private var firstPageState: FirstPageState = FirstPageState.Loading + + /** + * 本端**已经知道**这个会话在服务端没了。两个来源,合成同一个事实: + * - 本端自己把它删了([AppContainer.goneSessions],`SessionsViewModel.deleteSession` 记的); + * - 已经见过 `404 session_not_found`([ChatUiState.sessionGone])。 + * + * 唯一用途是**拦下一轮注定 404 的补拉**([reconnectPull] 第 1 条判据):那个 VM 在会话被删之后 + * 仍然活着、仍然订阅共享连接,不判就会在每一次重连都发一次注定失败的往返。 + */ + private fun sessionKnownGone(): Boolean = + _state.value.sessionGone || container.goneSessions.contains(sessionId) + /** * 「没发出去的那几条」的记忆(**多槽**,见 [RetryKeyMemory]):**回滚**时按内容指纹记下那条用的 * 幂等键,**下一次发送**指纹相等就复用那个键。复用时**只消费命中的那一条**,别的失败记录原样 @@ -934,28 +960,8 @@ class ChatViewModel( } private fun start() { - val repo = container.messageRepository - repository = repo - - viewModelScope.launch { - repo.loadLatest(sessionId) - .onSuccess { page -> - oldestLoadedAt = page.oldestCreatedAt - _state.update { - it.copy( - messages = mergeMessages(it.messages, page.messages), - loading = false, - hasMore = page.hasMore, - error = null, - ) - } - } - .onFailure { e -> - // 首屏拉取失败是**真错误** ⇒ 位写 false(红色),别继承上一条柔和告知。 - _state.update { it.copy(loading = false, error = e.userMessage(), errorIsQueueFullNotice = false) } - markSessionGoneIfNeeded(e) - } - } + repository = container.messageRepository + loadFirstPage() // 连接是**进程内共享**的那一条(`AppContainer.chatSocket`),本 VM 只订阅它。开与关归 // 「登录态」那条路径(唯一主人是 `SessionsViewModel`,见它的类注释):`connect()` 虽然幂等, @@ -968,7 +974,51 @@ class ChatViewModel( } /** - * 连接状态变化:除了照抄给界面,还要顺手维护「断线期间漏了哪一段」这个游标。 + * 拉最新一页(**首屏**)。 + * + * 两个调用点:init 那一次,以及**连接恢复时给次级会话补的那一次**([reconnectPull] 的 + * [ReconnectPull.FirstPage],S2)。两条路共用这一份代码是刻意的:补出来的结果必须与首屏 + * **逐字段同形**(`oldestLoadedAt` / `hasMore` / `loading` / `error` 一个都不能少),各写一遍 + * 迟早会在其中某个字段上分叉——而分叉的那一侧就是「屏幕上有内容却一直说没有更多」这种怪象。 + * + * [firstPageState] 在**发请求之前**就置成 [FirstPageState.Loading]:从那一刻起本端就「有一次 + * 首屏在飞」,重连判据因此在首屏还没回来时不会另发一次同样的请求(S3 那条窄窗口的时序 + * 一个字节都不动)。 + * + * `loading = true` 在这里是**无条件**写的:首屏那一次它本来就是初值 `true`(写一次等于没写), + * 而补拉那一次屏幕正处于「什么都没有、也不在加载」的状态——把它翻回「加载中…」才是如实的, + * 否则用户看到的是「还没有消息」停在原地。列表非空时这一位不可见(渲染只在 `items` 为空时看它)。 + */ + private fun loadFirstPage() { + val repo = repository ?: return + firstPageState = FirstPageState.Loading + _state.update { it.copy(loading = true) } + viewModelScope.launch { + repo.loadLatest(sessionId) + .onSuccess { page -> + firstPageState = FirstPageState.Loaded + oldestLoadedAt = page.oldestCreatedAt + _state.update { + it.copy( + messages = mergeMessages(it.messages, page.messages), + loading = false, + hasMore = page.hasMore, + error = null, + ) + } + } + .onFailure { e -> + firstPageState = FirstPageState.Failed + // 首屏拉取失败是**真错误** ⇒ 位写 false(红色),别继承上一条柔和告知。 + _state.update { it.copy(loading = false, error = e.userMessage(), errorIsQueueFullNotice = false) } + markSessionGoneIfNeeded(e) + } + } + } + + /** + * 连接状态变化:除了照抄给界面,还要顺手维护「断线期间漏了哪一段」这个游标,并在连接恢复 + * 那一刻决定**这一轮要不要补拉、补哪一段**(判据是纯函数 [reconnectPull])。 * * - **离开 Connected 那一刻**记下 [gapCursor](= 当时本端已有最新一条的 `createdAt`); * - **重新 Connected 那一刻**用它做一次 `after` 正向增量。 @@ -977,7 +1027,13 @@ class ChatViewModel( * 随即被推到「现在」,那时候再取游标就取不到那个洞了。这也正是 `after`(契约 §1 行 6) * 存在的理由——「再拉一页最新」永远补不回中间断掉的那一段。 * - * 冷启动(Idle → Connected)不动:[gapCursor] 是 null,首屏 REST 本来就覆盖了。 + * ⚠️ 冷启动(Idle → Connected)**不再**是「什么都不做」了:那种时候 `history` 帧只兜得住 + * **主会话**(服务端固定回放主会话),而次级会话的首屏如果因为当时离线而失败过,就再没有 + * 第二条安全网。所以这一档现在交给 [reconnectPull] 判——它只在「次级会话 + 首屏确实没拉到 + + * 没有洞要补」时才补一次最新一页;其余(含主会话)与今天逐字相同。 + * + * 补拉**必须先判「本端已知它没了」**([sessionKnownGone]):那个会话的 VM 在会话被删之后 + * 仍然活着,不判就会在每一次重连都发一次注定 `404 session_not_found` 的往返(S7)。 */ private fun onConnectionChanged(connection: WsState) { val previous = _state.value.connection @@ -988,8 +1044,16 @@ class ChatViewModel( if (connection == WsState.Connected) { val cursor = gapCursor gapCursor = null - val repo = repository - if (cursor != null && repo != null) viewModelScope.launch { catchUp(repo, cursor) } + when (reconnectPull(sessionId, firstPageState, cursor, sessionKnownGone())) { + ReconnectPull.Forward -> { + val repo = repository + if (cursor != null && repo != null) viewModelScope.launch { catchUp(repo, cursor) } + } + + ReconnectPull.FirstPage -> loadFirstPage() + + ReconnectPull.Nothing -> Unit + } // 连接恢复 ⇒ 把没连上时留下的那几条按**用户发送的先后顺序**补发(W40 主人裁决)。 // 触发点就是这里:连接状态流(`socket.state`,进程内共享的那一条)一变就重来一遍判据, // **没有任何轮询**(与 W38 分享落地的纪律一致:状态驱动,不按时间轮)。 diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ReconnectPull.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ReconnectPull.kt new file mode 100644 index 0000000..4ade5bf --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ReconnectPull.kt @@ -0,0 +1,88 @@ +package top.yeij.cyrene.ui.chat + +/** + * 本会话的**首屏**(`GET /messages` 最新一页,[ChatViewModel.start] 那一次)走到哪儿了。 + * + * 它存在的唯一理由是让「重连时要不要再拉一次首屏」这件事**判得出来**,而这里必须能分辨 + * **三个**状态、不能只用一个「有没有数据」的位: + * + * - [Loading]:首屏那次**还在飞**。这一刻再发一次同样的请求就是白拉一次,更要紧的是它会把 + * 「首屏还没回来」这件事伪造成「首屏失败了」——而那正好是 W49 / S3 那条窄窗口的时序, + * **本轮一个字节都不改**(见 [reconnectPull] 第 3 条)。 + * - [Loaded]:成功过。哪怕服务端真的**就是**没有消息(`hasMore=false`、列表空),也是成功过—— + * 所以「空会话」不该被读成「没拉到」(这正是「用有没有数据当判据」会踩的坑:每个空次级会话 + * 都会在每次重连被整表重拉一次,而那正是 S4/S7 这一类问题的来源)。 + * - [Failed]:确实没拿到(离线冷启动、超时、服务端错)。**只有这一档**才值得在连接恢复时补。 + */ +internal enum class FirstPageState { + Loading, + Loaded, + Failed, +} + +/** 连接恢复那一刻这一轮该做什么([reconnectPull] 的取值)。 */ +internal enum class ReconnectPull { + + /** + * 既有行为,一字未动:用掉线那一刻记下的 [ChatViewModel.gapCursor] 做一次 `after` 正向增量。 + */ + Forward, + + /** + * **本轮新增**:本端首屏从来没成功过,而这是**次级会话** —— 拉一次最新一页。 + * + * 它补的就是 S2 登记的那个洞:进次级会话时 WS 未连(冷启动离线)⇒ 首屏那次 REST 也失败 + * ⇒ 此后连接恢复**没有任何人补拉**,屏幕长期空着「还没有消息」,直到用户手动切走再回来。 + */ + FirstPage, + + /** 什么都不做。 */ + Nothing, +} + +/** + * 连接刚刚恢复(`WsState.Connected`)时这一轮该做什么(**纯函数**,VM 与测试共用)。 + * + * ## 为什么次级会话需要它,而主会话不需要 + * 主会话有**两条**安全网:握手回放 `history`(最近 50 条,服务端固定回放主会话, + * `ws_hub_conn.go` 写死 `replayHistory(MainSessionID)`)与 `after` 正向增量。次级会话**只有后者** + * ——`history` 帧里根本没有它的消息(`isForSession` 会把它们整帧滤掉)。于是「首屏没拿到 + + * 之后连接恢复」这个组合下,主会话被回放兜住,次级会话什么都没有。 + * + * ⚠️ 所以这里的修法**不是**照抄主会话那条(给次级会话要 `history` 是改服务端的契约面),而是走 + * **本项目自己那条 REST 补拉**:连接恢复时,如果本端首屏从来没成功过,就自己去拉一次最新一页。 + * + * ## 判据与优先级(自上而下,先命中先返回) + * 1. [sessionKnownGone] ⇒ [ReconnectPull.Nothing]。**注定 404 的补拉一个都不发**(S7 ①): + * 这一位由两个来源合成——本端自己删过它(`GoneSessions`),或已经有了 `404 session_not_found` + * 的结论([ChatUiState.sessionGone])。前者让「每次重连一次注定失败的往返」变成零次;后者让 + * 「发现之后还在反复撞」收敛成**恰好一次**。 + * 2. `gapCursor != null` ⇒ [ReconnectPull.Forward]:既有行为,**一个字都不改**。有一个断线窗口 + * 要补时,`after` 就是对的(它比首屏更精确:只补那个洞)。也正因为这一档在前面, + * 「本轮新增的那条路」只在今天**什么都不做**的那一刻才会走。 + * 3. 首屏 [FirstPageState.Failed] **且**不是主会话 ⇒ [ReconnectPull.FirstPage]。 + * - **Failed 才算**:`Loading` 时刻再拉一次会与首屏那次抢跑(并且会把 S3 那条「history 帧一到就 + * `loading=false`」的时序改掉);`Loaded` 之后再拉就是「每次重连整表重拉」,正是 S4/S7 的成因。 + * - **主会话除外**:它由握手 `history` 兜住(见上),在这里再补一条 REST 只会变成**第二套** + * 补口径——两套判据迟早分叉,而分叉的那一侧就是漏出去的那一侧。主会话的兜底行为因此 + * **逐字不变**。 + * 4. 其余 ⇒ [ReconnectPull.Nothing]。 + * + * 判据里除了这四样什么都不看(读不到 VM 状态、不读表),才能穷举、才能在 JVM 上单测 + * (先例:`ui.sessions.silentRefreshStep`、`ui.chat.shareLanding`)。 + * + * @param sessionId 本 VM 的会话(比较是**等值**:主会话 `web` 与次级 `web:xxx` 是两个会话)。 + * @param gapCursor 掉线那一刻记下的 `after` 游标;`null` = 没有需要补的洞。 + * @param sessionKnownGone 本端已知「服务端没有这个会话了」——本地删过,或者已经 404 过。 + */ +internal fun reconnectPull( + sessionId: String, + firstPage: FirstPageState, + gapCursor: Long?, + sessionKnownGone: Boolean, +): ReconnectPull = when { + sessionKnownGone -> ReconnectPull.Nothing + gapCursor != null -> ReconnectPull.Forward + firstPage == FirstPageState.Failed && sessionId != MAIN_SESSION_ID -> ReconnectPull.FirstPage + else -> ReconnectPull.Nothing +} diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/sessions/SessionsViewModel.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/sessions/SessionsViewModel.kt index 7dd59d7..2dc2f0e 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/sessions/SessionsViewModel.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/sessions/SessionsViewModel.kt @@ -170,6 +170,9 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() { socket.close() readCursors.clear() prefs.clear() + // 「本端已知已删」也一样作废:它说的是**上一个环境**里那些 id 的结论, + // 换账号之后本端什么都不知道(与快照 / 已读游标同一档,见 GoneSessions 的类注释)。 + container.goneSessions.clear() // 手里这份列表与它的节流账本都属于上一个身份,一起作废:留着账本会让 // 重登后那一次必要的重拉被上一轮的窗口挡住;留着补拉任务会让它带着一个 // 已经没有令牌的身份去发请求(必然 401,还会在抽屉里挂一行错误)。 @@ -385,6 +388,12 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() { container.sessionRepository.deleteSession(sessionId) .onSuccess { prefs.removeSession(sessionId) + // 本端**现在就知道**这个会话没了:那个会话的 ChatViewModel 还活着、还订阅着 + // 共享连接,但它没有别的渠道知道这件事 —— 不记这一笔,它会在**每一次**重连 + // 都发一次注定 404 的补拉(S7 登记的那一条:404 被静默吞掉,连一条线索都不留)。 + // 记的是「本端已知」而不是「用户界面该怎么表现」:这不是要让谁弹一句告知 + // (用户自己删的,他知道),只是让下一次补拉**事前**就判得掉。 + container.goneSessions.mark(sessionId) if (_state.value.activeSessionId == sessionId) { select(SessionSummaryDto.MAIN_SESSION_ID) } diff --git a/app/src/test/kotlin/top/yeij/cyrene/core/session/GoneSessionsTest.kt b/app/src/test/kotlin/top/yeij/cyrene/core/session/GoneSessionsTest.kt new file mode 100644 index 0000000..0dc0fde --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/core/session/GoneSessionsTest.kt @@ -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")) + } +} diff --git a/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ReconnectPullTest.kt b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ReconnectPullTest.kt new file mode 100644 index 0000000..d34674f --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ReconnectPullTest.kt @@ -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, + ), + ) + } +}