feat(chat): 常驻 ChatViewModel 加 LRU 回收(S4)—— 一格一份 ViewModelStore,最近 3 格之外整格交出去
S4 的病根:每个访问过的会话各留一个常驻 VM(都订阅那条共享连接 ⇒ 每次重连各自发一轮 after 补拉)、各留一份消息列表(含内联 base64 图片),而删会话也不回收。此前**没有按会话 交出一格的手段**:所有 VM 塞在 Activity 级那一个 ViewModelStore 里,而它只有整体 clear(), W48 只能做到「让 key 换一代、旧实例继续留着」—— 旧实例照样活着、照样收帧。 这次把「哪几格留着」这件事单独拎出来: - 新增 `core/session/ChatSessionSlots.kt`(进程级,挂在 AppContainer 上,与 GoneSessions 同一层):一格一份 ViewModelStore。`ChatScreen` 改成 `viewModel(viewModelStoreOwner = <槽位>, key = chatViewModelKey(...))`,所以「交出去一格」 就是那一份 store 自己的 `clear()` ⇒ 那一格的 `onCleared()` + `viewModelScope` 取消 (订阅、补拉、在飞的请求一起停)。共享连接的生死一个字节都没碰:主人仍是 SessionsViewModel,`ChatViewModel.onCleared` 照旧**不** close(现在它会真的被调到, 所以这句注释也一并写成事实)。 - 新增判据 `core/session/chatSessionEvictions`(**纯函数**,VM 与测试共用): 「最近」的口径 = **最后一次成为当前会话的时刻**(由 ChatScreen 在组合到某个会话时写入); 候选要同时满足「不是主会话 ∧ 不是屏幕上那一格 ∧ 没有没交代完的事」,按最旧的先走, 保留最近 `keep` = 3 格(主人口径,含被豁免的那些占的名额——不然「最多 3 格」会变成 3 + 豁免数)。两条豁免名额之外的豁免各自都有硬理由:屏幕那一格死了会「不再更新、连 发送都静默不发」,还有没交代完的事那一格(草稿 / 排队等连接的发送)死了内容**回不来**。 - 删会话时那一格当场交出去(`SessionsViewModel.deleteSession`,与 W51 的 `goneSessions.mark` 相邻:一个是「别发注定 404 的补拉」,一个是「这一格本来就不该留」); 登出 / 换账号时 `clear()` 一格不留(与快照 / 已读游标 / GoneSessions 同档纪律)。 - 「还有没交代完的事」收的是**现读的探针**而不是打开那一刻的快照:回收发生在别的会话被 打开的那一刻,而那正是用户可能刚在前一格打了草稿、还没切走的时候。 刻意不动的红线:W48 的 `chatViewModelKey` 拼法一个字没改(新槽位的 key 里同样带账号指纹, 两道闸门各管一段);W49 的节流判据、W50 的 ProactiveIngest 判据、W51 的 reconnectPull 判据都没碰,也没有把「连接恢复」做成整表重拉。 测试:新增 `ChatSessionEvictionTest`(14 例,穷举判据的每一条边界:N=3、最旧先走、主会话 / 屏幕上那一格 / 有草稿的豁免、豁免占名额、并列退回账本顺序、结论是带账号指纹的槽位 key) 与 `ChatSessionSlotsTest`(9 例,钉住「没被收的一格还是原来那一格(切回来状态还在)」与 「被收的一格下次拿到的是新的一格」、空槽位会被下一次回收扫掉、探针现读的效果)。 同批把已经说错的注释改成事实:GoneSessions / AppContainer.chatSocket / ChatHomeScreen / ChatScreen 里那几处「VM 活在 Activity 级 ViewModelStore 里、切走 / 删会话都不回收」。
This commit is contained in:
1 parent
4fce12249e
commit
bbc47dbd31
10 files changed
+794
-36
No files matched your search
@@ -0,0 +1,230 @@
|
||||
package top.yeij.cyrene.core.session
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* 「留最近几格、其余交出去」这条判据([chatSessionEvictions])的口径。
|
||||
*
|
||||
* 钉住四件事,它们各自都是一条会出事的边界:
|
||||
* 1. **「最近」的口径是 `lastUsedAt`**(最后一次成为当前会话)——谁新谁留下,最旧的先走;
|
||||
* 2. **主会话与屏幕上那一格永不回收**,而且它们**占** `keep` 的名额(`keep` 是「本端同时留几格」
|
||||
* 的内存目标,不是「除它们之外再留几格」);
|
||||
* 3. **还有没交代完的事的格子不回收**(草稿 / 排队等连接的发送:回收=内容回不来),同样占名额
|
||||
* ——不然「最多 3 格」在豁免生效时会变成「3 + 豁免数」,那个数就没有上限了;
|
||||
* 4. 结论是**槽位的 key**(账号指纹 + 会话 id),不是会话 id:同一个会话 id 在两个账号下是两格。
|
||||
*
|
||||
* 每个用例都把 `currentKey` 显式指出来,取值按真实情形(屏幕上那一格就是刚刚打开的那一格)。
|
||||
*/
|
||||
class ChatSessionEvictionTest {
|
||||
|
||||
private val main = "web"
|
||||
|
||||
/** 一个槽位的样子;[sessionId] 单列是刻意的(key 里另带着账号指纹,见 [ChatSessionUsage])。 */
|
||||
private fun usage(
|
||||
sessionId: String,
|
||||
at: Long,
|
||||
unsent: Boolean = false,
|
||||
account: String = "acct",
|
||||
) = ChatSessionUsage(
|
||||
key = "$account|$sessionId",
|
||||
sessionId = sessionId,
|
||||
lastUsedAt = at,
|
||||
holdsUnsentWork = unsent,
|
||||
)
|
||||
|
||||
/** 账本 key(免得每个断言里手拼 `acct|` 前缀时写错一处)。 */
|
||||
private fun key(sessionId: String, account: String = "acct") = "$account|$sessionId"
|
||||
|
||||
@Test
|
||||
fun `the owner decision is three slots`() {
|
||||
// N=3 是主人拍板的口径,不是随手取的一个数(改动它会同时改掉本端常驻的内存上限)。
|
||||
assertEquals(3, CHAT_SESSION_SLOT_KEEP)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nothing is reclaimed while the budget is not exceeded`() {
|
||||
// 主会话 + 两个次级会话 = 正好 3 格。它是最常见的形态,也是「什么都不该发生」的那一档。
|
||||
val evicted = chatSessionEvictions(
|
||||
listOf(usage(main, 1), usage("web:a", 2), usage("web:b", 3)),
|
||||
mainSessionId = main,
|
||||
currentKey = key("web:b"),
|
||||
)
|
||||
assertTrue(evicted.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty ledger reclaims nothing`() {
|
||||
assertTrue(
|
||||
chatSessionEvictions(emptyList(), mainSessionId = main, currentKey = key(main)).isEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the oldest slots go first and the verdict lists them oldest first`() {
|
||||
// 主会话(最旧)+ 三个次级会话:名额 3 里主会话与屏幕上那一格各占一个 ⇒ 只能再留 1 个
|
||||
// ⇒ 最旧的那个走。(屏幕上那一格通常就是最新的那个,见下面的用例。)
|
||||
val evicted = chatSessionEvictions(
|
||||
listOf(usage(main, 1), usage("web:a", 2), usage("web:b", 3), usage("web:c", 4)),
|
||||
mainSessionId = main,
|
||||
currentKey = key("web:c"),
|
||||
)
|
||||
assertEquals(listOf(key("web:a")), evicted)
|
||||
|
||||
// 再打开一个新会话 ⇒ 又该走一个,而且结论里**先列最旧的**(调用方按这个顺序交出去)。
|
||||
val more = chatSessionEvictions(
|
||||
listOf(usage(main, 1), usage("web:a", 2), usage("web:b", 3), usage("web:c", 4), usage("web:d", 5)),
|
||||
mainSessionId = main,
|
||||
currentKey = key("web:d"),
|
||||
)
|
||||
assertEquals(listOf(key("web:a"), key("web:b")), more)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a slot that was never opened is the first to go`() {
|
||||
// lastUsedAt = 0 是「建出来还没打开过」的空槽位(组合被丢弃时留下的那种):它必然最旧。
|
||||
val evicted = chatSessionEvictions(
|
||||
listOf(usage(main, 10), usage("web:abandoned", 0), usage("web:a", 11), usage("web:b", 12)),
|
||||
mainSessionId = main,
|
||||
currentKey = key("web:b"),
|
||||
)
|
||||
assertEquals(listOf(key("web:abandoned")), evicted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the main session is never reclaimed even when it is the oldest`() {
|
||||
val evicted = chatSessionEvictions(
|
||||
listOf(usage(main, 1), usage("web:a", 2), usage("web:b", 3), usage("web:c", 4), usage("web:d", 5)),
|
||||
mainSessionId = main,
|
||||
currentKey = key("web:d"),
|
||||
)
|
||||
// 主会话是最旧的那一格,但它一个字节都不许出现在结论里(名额照旧被它占着)。
|
||||
assertTrue(main !in evicted.map { it.substringAfter('|') })
|
||||
assertEquals(listOf(key("web:a"), key("web:b")), evicted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the session on screen is never reclaimed`() {
|
||||
// 屏幕上那一格**可能**是账本里最旧的一格(它刚被打开、而别的格子刚收过心跳;或者时钟跳过)。
|
||||
// 它照样不能收:收掉它 = 正在看的那一段从此不再更新(帧收不到、`viewModelScope` 已取消
|
||||
// ⇒ 连发送都会静默不发),要等下一次重组才自己修回来。
|
||||
val evicted = chatSessionEvictions(
|
||||
listOf(
|
||||
usage(main, 1),
|
||||
usage("web:on-screen", 2),
|
||||
usage("web:a", 3),
|
||||
usage("web:b", 4),
|
||||
usage("web:c", 5),
|
||||
),
|
||||
mainSessionId = main,
|
||||
currentKey = key("web:on-screen"),
|
||||
)
|
||||
assertEquals(listOf(key("web:a"), key("web:b")), evicted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a session holding unsent work is never reclaimed`() {
|
||||
// a 是最旧的次级会话,但它手里有还没发出去的字(草稿 / 排队等连接的发送)——回收是
|
||||
// 一条回不来的动作,所以该走的是**下一个**最旧的。
|
||||
val evicted = chatSessionEvictions(
|
||||
listOf(usage(main, 1), usage("web:a", 2, unsent = true), usage("web:b", 3), usage("web:c", 4)),
|
||||
mainSessionId = main,
|
||||
currentKey = key("web:c"),
|
||||
)
|
||||
assertEquals(listOf(key("web:b")), evicted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a protected slot still eats one place of the budget`() {
|
||||
// 主会话 + 两格有草稿 + 屏幕上那一格,就已经把「同时留 3 格」用掉了 ⇒ **干净的**那一格
|
||||
// 才是该走的(豁免压过 keep,但豁免不会凭空多给名额)。
|
||||
val evicted = chatSessionEvictions(
|
||||
listOf(
|
||||
usage(main, 1),
|
||||
usage("web:a", 2, unsent = true),
|
||||
usage("web:b", 3, unsent = true),
|
||||
usage("web:c", 4),
|
||||
usage("web:on-screen", 5),
|
||||
),
|
||||
mainSessionId = main,
|
||||
currentKey = key("web:on-screen"),
|
||||
)
|
||||
assertEquals(listOf(key("web:c")), evicted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `keep counts the protected slots, so a tight budget reclaims every reclaimable one`() {
|
||||
// keep = 1 而主会话与屏幕上那一格各占一个名额 ⇒ 可回收的全部走
|
||||
//(`take` 的越界不报错,也不多收)。
|
||||
val evicted = chatSessionEvictions(
|
||||
listOf(usage(main, 1), usage("web:a", 2), usage("web:b", 3)),
|
||||
mainSessionId = main,
|
||||
currentKey = key("web:b"),
|
||||
keep = 1,
|
||||
)
|
||||
assertEquals(listOf(key("web:a")), evicted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the default budget is the owner caliber`() {
|
||||
val usages = listOf(usage(main, 1), usage("web:a", 2), usage("web:b", 3), usage("web:c", 4))
|
||||
assertEquals(
|
||||
chatSessionEvictions(usages, main, key("web:c"), keep = CHAT_SESSION_SLOT_KEEP),
|
||||
chatSessionEvictions(usages, main, key("web:c")),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ties fall back to the ledger order`() {
|
||||
// 同一毫秒并列(真机上切会话在同一毫秒内发生):退回账本顺序(先建出来的先走),
|
||||
// 于是判据对同一份账本永远给同一个结论——不会在两次打开之间随机决定收谁。
|
||||
val first = chatSessionEvictions(
|
||||
listOf(usage(main, 1), usage("web:a", 5), usage("web:b", 5), usage("web:c", 6)),
|
||||
mainSessionId = main,
|
||||
currentKey = key("web:c"),
|
||||
)
|
||||
assertEquals(listOf(key("web:a")), first)
|
||||
|
||||
val flipped = chatSessionEvictions(
|
||||
listOf(usage(main, 1), usage("web:b", 5), usage("web:a", 5), usage("web:c", 6)),
|
||||
mainSessionId = main,
|
||||
currentKey = key("web:c"),
|
||||
)
|
||||
assertEquals(listOf(key("web:b")), flipped)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the verdict carries the slot key so two accounts never share a slot`() {
|
||||
// 换账号之后同一个会话 id 是**另一格**(key 里的账号指纹分得开),而两格的 sessionId
|
||||
// 都是主会话 ⇒ 两格都受「主会话永不回收」保护,能收的只有次级会话。
|
||||
val usages = listOf(
|
||||
usage(main, 1, account = "A"),
|
||||
usage("web:s1", 2, account = "A"),
|
||||
usage(main, 3, account = "B"),
|
||||
usage("web:s1", 4, account = "B"),
|
||||
usage("web:s2", 5, account = "B"),
|
||||
)
|
||||
assertEquals(
|
||||
listOf(key("web:s1", "A"), key("web:s1", "B")),
|
||||
chatSessionEvictions(usages, mainSessionId = main, currentKey = key("web:s2", "B")),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the main session id is what the caller says it is`() {
|
||||
// 判据不认识那个常量本身(主会话 id 由装配处注入):换成别的 id 时口径跟着换,
|
||||
// 说明它比的是**入参**,而不是某处写死的 "web"。
|
||||
val usages = listOf(usage("web", 1), usage("web:a", 2), usage("web:b", 3), usage("web:c", 4))
|
||||
// 换成别的 id 之后,最旧的 `web` 那一格就不再受保护了(它是第一个该走的)。
|
||||
assertEquals(
|
||||
listOf(key("web")),
|
||||
chatSessionEvictions(usages, mainSessionId = "something-else", currentKey = key("web:c")),
|
||||
)
|
||||
// 而按真主会话的口径比,它一个字节都不许出现在结论里。
|
||||
assertEquals(
|
||||
listOf(key("web:a")),
|
||||
chatSessionEvictions(usages, mainSessionId = main, currentKey = key("web:c")),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package top.yeij.cyrene.core.session
|
||||
|
||||
import org.junit.Assert.assertNotSame
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* 会话槽位账本的粘合行为([ChatSessionSlots]):它只负责两件事——**给出去的那一格是不是同一格**
|
||||
* (切回来时状态还在)与**什么时候把一格交出去**(判断本身在 [chatSessionEvictions],这里钉的是
|
||||
* 「判断的结论真的落到了那一格上」)。
|
||||
*
|
||||
* 观测方式刻意只用 `slotFor(...) === 原来的那个对象`:槽位被交出去之后,同样的账号 + 会话 id
|
||||
* 会拿到一个**新**槽位(新的 `ViewModelStore` ⇒ 里面那个 VM 是重头建的),而没被交出去的一格
|
||||
* 必须还是原来那个对象(否则「切回来还在」就没了)。这样测的是行为,不需要为测试开任何后门。
|
||||
*
|
||||
* 这里**不往 store 里放 VM**:本测试只跑在 JVM 上,构造 / 清空一份 `ViewModelStore` 不碰任何
|
||||
* Android 与协程设施(回收那一半——`ViewModelStore.clear()` 会走 `onCleared`、取消 `viewModelScope`
|
||||
* ——靠走查:那一行是库自己的语义,项目里没有能在 JVM 上安全建起来的 VM)。
|
||||
*/
|
||||
class ChatSessionSlotsTest {
|
||||
|
||||
private val account = "http://host-a:10942/|testuser"
|
||||
|
||||
private val slots = ChatSessionSlots(mainSessionId = "web")
|
||||
|
||||
/** 打开一个会话(= `ChatScreen` 记的那一笔账),并顺手返回它的槽位。 */
|
||||
private fun open(
|
||||
sessionId: String,
|
||||
at: Long,
|
||||
holdsUnsentWork: () -> Boolean = { false },
|
||||
): ChatSessionSlot = slots.slotFor(account, sessionId).also {
|
||||
slots.onOpened(account, sessionId, holdsUnsentWork, at)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the same account and session always yields the same slot`() {
|
||||
assertSame(slots.slotFor(account, "web:a"), slots.slotFor(account, "web:a"))
|
||||
// 另一个账号 / 另一个会话各是各的格子(账号指纹就在槽位 key 里)。
|
||||
assertNotSame(slots.slotFor(account, "web:a"), slots.slotFor("other-account", "web:a"))
|
||||
assertNotSame(slots.slotFor(account, "web:a"), slots.slotFor(account, "web:b"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `taking a slot does not reclaim anything by itself`() {
|
||||
// slotFor 只是「取或建」:组合期间碰它不该有任何回收动作(回收的触发点是 onOpened)。
|
||||
val a = slots.slotFor(account, "web:a")
|
||||
val b = slots.slotFor(account, "web:b")
|
||||
val c = slots.slotFor(account, "web:c")
|
||||
slots.slotFor(account, "web:d")
|
||||
assertSame(a, slots.slotFor(account, "web:a"))
|
||||
assertSame(b, slots.slotFor(account, "web:b"))
|
||||
assertSame(c, slots.slotFor(account, "web:c"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `opening one session beyond the budget gives the oldest slot back`() {
|
||||
val main = open("web", 1)
|
||||
val a = open("web:a", 2)
|
||||
val b = open("web:b", 3)
|
||||
val c = open("web:c", 4)
|
||||
|
||||
// 主会话(最旧)占一个名额 ⇒ 次级会话里最旧的 a 让位,其余原样。
|
||||
assertSame(main, slots.slotFor(account, "web"))
|
||||
assertNotSame(a, slots.slotFor(account, "web:a"))
|
||||
assertSame(b, slots.slotFor(account, "web:b"))
|
||||
assertSame(c, slots.slotFor(account, "web:c"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the slot on screen keeps its place even when every other slot holds unsent work`() {
|
||||
// 屏幕上那一格按**身份**豁免(判据第 2 条)。没有它的话:四个格子都「不可回收」时预算
|
||||
// 会变成负数,刚打开的这一格反而成了唯一可回收的那个——正在看的那一段从此不再更新。
|
||||
open("web", 1)
|
||||
open("web:a", 2) { true }
|
||||
open("web:b", 3) { true }
|
||||
open("web:c", 4) { true }
|
||||
val onScreen = open("web:d", 5)
|
||||
|
||||
assertSame(onScreen, slots.slotFor(account, "web:d"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a slot created but never opened is swept away by the next reclaim`() {
|
||||
// 组合被丢弃时会留下「建出来、从来没打开过」的空槽位:它 lastUsedAt 仍是 0 ⇒ 必然最旧。
|
||||
open("web", 1)
|
||||
val abandonedA = slots.slotFor(account, "web:abandoned-1")
|
||||
val abandonedB = slots.slotFor(account, "web:abandoned-2")
|
||||
val kept = open("web:kept", 2)
|
||||
val kept2 = open("web:kept2", 3)
|
||||
|
||||
// 打开两个会话之后,两个空槽位先后被扫掉(每打开一格才收一格)。
|
||||
assertNotSame(abandonedA, slots.slotFor(account, "web:abandoned-1"))
|
||||
assertNotSame(abandonedB, slots.slotFor(account, "web:abandoned-2"))
|
||||
assertSame(kept, slots.slotFor(account, "web:kept"))
|
||||
assertSame(kept2, slots.slotFor(account, "web:kept2"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a slot holding unsent work is spared until it is clean`() {
|
||||
var typing = true
|
||||
val withDraft = open("web:a", 2) { typing }
|
||||
open("web", 1)
|
||||
val b = open("web:b", 3)
|
||||
val c = open("web:c", 4)
|
||||
|
||||
// a 有还没发出去的字(虽然它在可回收的那些里最旧):让位的是下一个最旧的 b。
|
||||
assertSame(withDraft, slots.slotFor(account, "web:a"))
|
||||
assertSame(c, slots.slotFor(account, "web:c"))
|
||||
|
||||
// 探针是**现读的**:草稿发出去之后,再打开一个会话时它就该让位了。
|
||||
typing = false
|
||||
open("web:d", 5)
|
||||
assertNotSame(withDraft, slots.slotFor(account, "web:a"))
|
||||
assertNotSame(b, slots.slotFor(account, "web:b"))
|
||||
assertSame(c, slots.slotFor(account, "web:c"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reclaim gives exactly one slot back`() {
|
||||
val a = open("web:a", 1)
|
||||
val b = open("web:b", 2)
|
||||
slots.reclaim(account, "web:a")
|
||||
|
||||
assertNotSame(a, slots.slotFor(account, "web:a"))
|
||||
assertSame(b, slots.slotFor(account, "web:b"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reclaim of another account leaves this one alone`() {
|
||||
val a = open("web:a", 1)
|
||||
slots.reclaim("other-account", "web:a")
|
||||
assertSame(a, slots.slotFor(account, "web:a"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clear gives every slot back`() {
|
||||
val main = open("web", 1)
|
||||
val a = open("web:a", 2)
|
||||
slots.clear()
|
||||
|
||||
assertNotSame(main, slots.slotFor(account, "web"))
|
||||
assertNotSame(a, slots.slotFor(account, "web:a"))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user