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:
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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user