merge: W51 —— 次级会话连接恢复自己补拉(S2);已删会话不再发注定 404 的补拉、sessionGone 的出路不再依赖网络(S7)
This commit is contained in:
commit
620a4e77fa
8 files changed
+464
-27
No files matched your search
@@ -6,6 +6,7 @@ import top.yeij.cyrene.core.auth.SessionStore
|
|||||||
import top.yeij.cyrene.core.prefs.AppSettings
|
import top.yeij.cyrene.core.prefs.AppSettings
|
||||||
import top.yeij.cyrene.core.prefs.EnumPreferences
|
import top.yeij.cyrene.core.prefs.EnumPreferences
|
||||||
import top.yeij.cyrene.core.prefs.SessionPreferences
|
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.AdminApi
|
||||||
import top.yeij.cyrene.data.api.ApiClientFactory
|
import top.yeij.cyrene.data.api.ApiClientFactory
|
||||||
import top.yeij.cyrene.data.api.ChatApi
|
import top.yeij.cyrene.data.api.ChatApi
|
||||||
@@ -36,6 +37,15 @@ class AppContainer(context: Context) {
|
|||||||
val appSettings: AppSettings = AppSettings(context.applicationContext)
|
val appSettings: AppSettings = AppSettings(context.applicationContext)
|
||||||
val authManager: AuthManager = AuthManager(sessionStore)
|
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 cachedBaseUrl: String? = null
|
||||||
private var cachedSessionsApi: SessionsApi? = null
|
private var cachedSessionsApi: SessionsApi? = null
|
||||||
|
|
||||||
|
|||||||
@@ -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<Set<String>>(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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import androidx.lifecycle.viewmodel.initializer
|
|||||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import top.yeij.cyrene.app.AppContainer
|
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.admin.AdminHomeScreen
|
||||||
import top.yeij.cyrene.ui.common.MiuixDrawer
|
import top.yeij.cyrene.ui.common.MiuixDrawer
|
||||||
import top.yeij.cyrene.ui.sessions.SessionDrawerContent
|
import top.yeij.cyrene.ui.sessions.SessionDrawerContent
|
||||||
@@ -105,12 +106,21 @@ fun ChatHomeScreen(
|
|||||||
},
|
},
|
||||||
// 当前会话在别处被删了(`404 session_not_found`)⇒ 先立起那句如实告知,再重拉列表:
|
// 当前会话在别处被删了(`404 session_not_found`)⇒ 先立起那句如实告知,再重拉列表:
|
||||||
// `refresh` 里面会把「指向已删会话」的 activeSessionId 收敛回主会话
|
// `refresh` 里面会把「指向已删会话」的 activeSessionId 收敛回主会话
|
||||||
// (`resolveActiveSession`),抽屉里那条也跟着消失。所以这里不需要自己判「该切到哪儿」。
|
// (`resolveActiveSession`),抽屉里那条也跟着消失。
|
||||||
//
|
//
|
||||||
// 顺序很重要:告知必须**先**立起来再刷新——刷新会让本层的 ChatScreen 换 key,
|
// 顺序很重要:告知必须**先**立起来再刷新——刷新会让本层的 ChatScreen 换 key,
|
||||||
// 那段组合连同挂在 VM 状态上的东西一起被丢弃重建,晚一步就什么都看不到了。
|
// 那段组合连同挂在 VM 状态上的东西一起被丢弃重建,晚一步就什么都看不到了。
|
||||||
|
//
|
||||||
|
// ⚠️ 但**不能只靠 `refresh` 把用户送回去**:它只在**拉成功**时才做回落
|
||||||
|
// (`resolveActiveSession`,且列表为空时刻意不回落;`.onFailure` 那条更是什么都不动)。
|
||||||
|
// 于是「网络正不稳 / 服务端正好抖一下」时,屏幕上那句「已切回主会话」就成了一句
|
||||||
|
// 做不到的话——用户被留在一个已经没了的会话上,没有任何出路。这正是这个告知
|
||||||
|
// 想要修掉的那一幕,所以这里**先在本端收敛**(`select` 是用户点抽屉里那一行走的
|
||||||
|
// **同一条**路,不另造机制),`refresh` 退回它本来的职责:与服务端对齐、把抽屉
|
||||||
|
// 里那条去掉。副作用与用户手点主会话进入时逐字相同(就地清未读 + 推进游标)。
|
||||||
onSessionGone = {
|
onSessionGone = {
|
||||||
sessionNotice = SESSION_GONE_MESSAGE
|
sessionNotice = SESSION_GONE_MESSAGE
|
||||||
|
sessionsViewModel.select(SessionSummaryDto.MAIN_SESSION_ID)
|
||||||
sessionsViewModel.refresh(silent = true)
|
sessionsViewModel.refresh(silent = true)
|
||||||
},
|
},
|
||||||
sessionNotice = sessionNotice,
|
sessionNotice = sessionNotice,
|
||||||
|
|||||||
@@ -719,6 +719,10 @@ data class ChatUiState(
|
|||||||
*
|
*
|
||||||
* **主会话([MAIN_SESSION_ID])不会置它**:主会话若回 404,说明部署本身出了问题,
|
* **主会话([MAIN_SESSION_ID])不会置它**:主会话若回 404,说明部署本身出了问题,
|
||||||
* 那时候该显示的是普通的错误横幅,而不是一句「已切回主会话」(那会是一句假话)。
|
* 那时候该显示的是普通的错误横幅,而不是一句「已切回主会话」(那会是一句假话)。
|
||||||
|
*
|
||||||
|
* 它同时也是**补拉的闸**([reconnectPull] 第 1 条判据):置位之后这个会话不再有任何补拉——
|
||||||
|
* 结论已经拿到了,再发一次注定还是 404。**消费者**是 `ChatScreen` 里那个按它触发的
|
||||||
|
* `LaunchedEffect`(把上层那句告知立起来、并把用户带回主会话),不是「置了就完了」的一个位。
|
||||||
*/
|
*/
|
||||||
val sessionGone: Boolean = false,
|
val sessionGone: Boolean = false,
|
||||||
) {
|
) {
|
||||||
@@ -846,6 +850,28 @@ class ChatViewModel(
|
|||||||
*/
|
*/
|
||||||
private var gapCursor: Long? = null
|
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]):**回滚**时按内容指纹记下那条用的
|
* 「没发出去的那几条」的记忆(**多槽**,见 [RetryKeyMemory]):**回滚**时按内容指纹记下那条用的
|
||||||
* 幂等键,**下一次发送**指纹相等就复用那个键。复用时**只消费命中的那一条**,别的失败记录原样
|
* 幂等键,**下一次发送**指纹相等就复用那个键。复用时**只消费命中的那一条**,别的失败记录原样
|
||||||
@@ -934,28 +960,8 @@ class ChatViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun start() {
|
private fun start() {
|
||||||
val repo = container.messageRepository
|
repository = container.messageRepository
|
||||||
repository = repo
|
loadFirstPage()
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 连接是**进程内共享**的那一条(`AppContainer.chatSocket`),本 VM 只订阅它。开与关归
|
// 连接是**进程内共享**的那一条(`AppContainer.chatSocket`),本 VM 只订阅它。开与关归
|
||||||
// 「登录态」那条路径(唯一主人是 `SessionsViewModel`,见它的类注释):`connect()` 虽然幂等,
|
// 「登录态」那条路径(唯一主人是 `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 那一刻**记下 [gapCursor](= 当时本端已有最新一条的 `createdAt`);
|
||||||
* - **重新 Connected 那一刻**用它做一次 `after` 正向增量。
|
* - **重新 Connected 那一刻**用它做一次 `after` 正向增量。
|
||||||
@@ -977,7 +1027,13 @@ class ChatViewModel(
|
|||||||
* 随即被推到「现在」,那时候再取游标就取不到那个洞了。这也正是 `after`(契约 §1 行 6)
|
* 随即被推到「现在」,那时候再取游标就取不到那个洞了。这也正是 `after`(契约 §1 行 6)
|
||||||
* 存在的理由——「再拉一页最新」永远补不回中间断掉的那一段。
|
* 存在的理由——「再拉一页最新」永远补不回中间断掉的那一段。
|
||||||
*
|
*
|
||||||
* 冷启动(Idle → Connected)不动:[gapCursor] 是 null,首屏 REST 本来就覆盖了。
|
* ⚠️ 冷启动(Idle → Connected)**不再**是「什么都不做」了:那种时候 `history` 帧只兜得住
|
||||||
|
* **主会话**(服务端固定回放主会话),而次级会话的首屏如果因为当时离线而失败过,就再没有
|
||||||
|
* 第二条安全网。所以这一档现在交给 [reconnectPull] 判——它只在「次级会话 + 首屏确实没拉到 +
|
||||||
|
* 没有洞要补」时才补一次最新一页;其余(含主会话)与今天逐字相同。
|
||||||
|
*
|
||||||
|
* 补拉**必须先判「本端已知它没了」**([sessionKnownGone]):那个会话的 VM 在会话被删之后
|
||||||
|
* 仍然活着,不判就会在每一次重连都发一次注定 `404 session_not_found` 的往返(S7)。
|
||||||
*/
|
*/
|
||||||
private fun onConnectionChanged(connection: WsState) {
|
private fun onConnectionChanged(connection: WsState) {
|
||||||
val previous = _state.value.connection
|
val previous = _state.value.connection
|
||||||
@@ -988,8 +1044,16 @@ class ChatViewModel(
|
|||||||
if (connection == WsState.Connected) {
|
if (connection == WsState.Connected) {
|
||||||
val cursor = gapCursor
|
val cursor = gapCursor
|
||||||
gapCursor = null
|
gapCursor = null
|
||||||
val repo = repository
|
when (reconnectPull(sessionId, firstPageState, cursor, sessionKnownGone())) {
|
||||||
if (cursor != null && repo != null) viewModelScope.launch { catchUp(repo, cursor) }
|
ReconnectPull.Forward -> {
|
||||||
|
val repo = repository
|
||||||
|
if (cursor != null && repo != null) viewModelScope.launch { catchUp(repo, cursor) }
|
||||||
|
}
|
||||||
|
|
||||||
|
ReconnectPull.FirstPage -> loadFirstPage()
|
||||||
|
|
||||||
|
ReconnectPull.Nothing -> Unit
|
||||||
|
}
|
||||||
// 连接恢复 ⇒ 把没连上时留下的那几条按**用户发送的先后顺序**补发(W40 主人裁决)。
|
// 连接恢复 ⇒ 把没连上时留下的那几条按**用户发送的先后顺序**补发(W40 主人裁决)。
|
||||||
// 触发点就是这里:连接状态流(`socket.state`,进程内共享的那一条)一变就重来一遍判据,
|
// 触发点就是这里:连接状态流(`socket.state`,进程内共享的那一条)一变就重来一遍判据,
|
||||||
// **没有任何轮询**(与 W38 分享落地的纪律一致:状态驱动,不按时间轮)。
|
// **没有任何轮询**(与 W38 分享落地的纪律一致:状态驱动,不按时间轮)。
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -170,6 +170,9 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
|
|||||||
socket.close()
|
socket.close()
|
||||||
readCursors.clear()
|
readCursors.clear()
|
||||||
prefs.clear()
|
prefs.clear()
|
||||||
|
// 「本端已知已删」也一样作废:它说的是**上一个环境**里那些 id 的结论,
|
||||||
|
// 换账号之后本端什么都不知道(与快照 / 已读游标同一档,见 GoneSessions 的类注释)。
|
||||||
|
container.goneSessions.clear()
|
||||||
// 手里这份列表与它的节流账本都属于上一个身份,一起作废:留着账本会让
|
// 手里这份列表与它的节流账本都属于上一个身份,一起作废:留着账本会让
|
||||||
// 重登后那一次必要的重拉被上一轮的窗口挡住;留着补拉任务会让它带着一个
|
// 重登后那一次必要的重拉被上一轮的窗口挡住;留着补拉任务会让它带着一个
|
||||||
// 已经没有令牌的身份去发请求(必然 401,还会在抽屉里挂一行错误)。
|
// 已经没有令牌的身份去发请求(必然 401,还会在抽屉里挂一行错误)。
|
||||||
@@ -385,6 +388,12 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
|
|||||||
container.sessionRepository.deleteSession(sessionId)
|
container.sessionRepository.deleteSession(sessionId)
|
||||||
.onSuccess {
|
.onSuccess {
|
||||||
prefs.removeSession(sessionId)
|
prefs.removeSession(sessionId)
|
||||||
|
// 本端**现在就知道**这个会话没了:那个会话的 ChatViewModel 还活着、还订阅着
|
||||||
|
// 共享连接,但它没有别的渠道知道这件事 —— 不记这一笔,它会在**每一次**重连
|
||||||
|
// 都发一次注定 404 的补拉(S7 登记的那一条:404 被静默吞掉,连一条线索都不留)。
|
||||||
|
// 记的是「本端已知」而不是「用户界面该怎么表现」:这不是要让谁弹一句告知
|
||||||
|
// (用户自己删的,他知道),只是让下一次补拉**事前**就判得掉。
|
||||||
|
container.goneSessions.mark(sessionId)
|
||||||
if (_state.value.activeSessionId == sessionId) {
|
if (_state.value.activeSessionId == sessionId) {
|
||||||
select(SessionSummaryDto.MAIN_SESSION_ID)
|
select(SessionSummaryDto.MAIN_SESSION_ID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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