From 4f860aa49976181b81df85ed25c5b56b13724940 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 5 Oct 2026 22:25:57 +0800 Subject: [PATCH] =?UTF-8?q?fix(chat):=20W48=20=E8=81=8A=E5=A4=A9=E9=A1=B5?= =?UTF-8?q?=20VM=20key=20=E5=B8=A6=E4=B8=8A=E8=B4=A6=E5=8F=B7=E6=8C=87?= =?UTF-8?q?=E7=BA=B9=EF=BC=8C=E4=BF=AE=E6=8D=A2=E7=8E=AF=E5=A2=83=E5=90=8E?= =?UTF-8?q?=E4=BB=8D=E6=98=BE=E7=A4=BA=E4=B8=8A=E4=B8=AA=E7=8E=AF=E5=A2=83?= =?UTF-8?q?=E7=9A=84=E4=BC=9A=E8=AF=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 缺陷:换账号或换服务器后,聊天页仍然摆着上一个环境的消息。成因是 ChatScreen 里那个 ChatViewModel 的 owner 是 Activity 级的 ViewModelStore,而登出只是把 ChatHomeScreen 移出组合、并不清理那个 store(ChatViewModel.onCleared 是空的,全仓也没有 ViewModelStore.clear())。 key 里只有 sessionId 时,换环境再进来 viewModel() 会把上一个环境那个还活着的实例原样取回来, 它继续收着那个环境的帧,屏幕上就是别的服务器的消息。主会话的 id 恒为 web,两次登录必然撞上 同一个 key,所以这不是理论风险。 为什么 VM key 要带账号指纹:指纹回答的是「这份本地状态属于哪个环境」。把它抽成纯函数 accountFingerprint(baseUrl, username),与 SessionPreferences 判断快照作废、 SessionsViewModel 落盘快照用的是同一份口径(SessionsViewModel 原本就地拼的那串改成调用 同一个函数,免得两套判据分叉——分叉的那一侧就是漏出去的那一侧)。同一个环境里切会话, key 未变,各会话的状态照旧各留一份(这正是「切回来状态还在」依赖的);环境一变(换了服务器 或换了账号),key 就变,取到的一定是新实例。 会话内搜索那个小 VM 是同一类残留:它持有的命中就是消息正文,换环境后在主会话上重开搜索, 面板里就是别的服务器的消息。它的 key 同样带上指纹。 为什么本轮不回收旧 VM:要真正回收掉上一个环境那个实例,得自定义 ViewModelStoreOwner 或直接 清 store,而「切回来状态还在」这条体验正绑在 store 不清这件事上,属于要主人拍板的设计取舍。 本轮只保证换环境后取到的是新 VM,回收这件事不动。 顺带的零成本纠错(只改注释的成因描述,行为一字未动):ChatHomeScreen / ChatScreen / ChatViewModel 里几处「onSessionGone 一被调用,那个会话的 VM 连同它的状态立刻被销毁」是错的—— 变的是 key,被销毁的只是承载横幅的那段组合;VM 实例仍在 Activity 级的 store 里活着并继续收帧。 「横幅必须挂在上一层」那条结论本身是对的,原样保留;改的只是它前面的理由。 同时把 devtools 里那个窄代理的路径写对(此前注释指向一个并不存在的路径)。 开发工具挪出会被 clean 扫掉的位置:app/build/e2e-shots/ws_block_proxy.py 躺在 gitignored 的 app/build/ 里,一次 gradlew clean 就没了。挪进 devtools/ws-block-proxy/(与 fake-backend 同级同风格)并配 README,写清用途、用法(python ws_block_proxy.py 18098 10942,配 adb reverse tcp:10942 tcp:18098)与它最有价值的判据:调试时 Web 前端直连后端、不经过 adb reverse,所以代理日志里的每一行都只可能来自 App。脚本行为未改,也没有硬编码任何凭据。 新增单测 ChatViewModelKeyTest 钉住拼法:同账号同地址 => 同 key;换地址 / 换账号 / 换会话 => 不同 key;username 缺失时退化成 baseUrl 一段,换地址仍然分得开。 验证:gradlew.bat assembleDebug 与 gradlew.bat testDebugUnitTest, BUILD SUCCESSFUL;单测 1164 条,failures 0、errors 0、skipped 0。 --- .../cyrene/core/prefs/SessionPreferences.kt | 16 +++- .../top/yeij/cyrene/ui/chat/ChatHomeScreen.kt | 13 +-- .../top/yeij/cyrene/ui/chat/ChatScreen.kt | 37 ++++++-- .../top/yeij/cyrene/ui/chat/ChatViewModel.kt | 12 +-- .../top/yeij/cyrene/ui/chat/SendFallback.kt | 2 +- .../cyrene/ui/chat/SessionSearchViewModel.kt | 6 +- .../cyrene/ui/sessions/SessionsViewModel.kt | 4 +- .../cyrene/ui/chat/ChatViewModelKeyTest.kt | 75 ++++++++++++++++ .../yeij/cyrene/ui/chat/SendFallbackTest.kt | 2 +- devtools/ws-block-proxy/README.md | 72 +++++++++++++++ devtools/ws-block-proxy/ws_block_proxy.py | 90 +++++++++++++++++++ 11 files changed, 308 insertions(+), 21 deletions(-) create mode 100644 app/src/test/kotlin/top/yeij/cyrene/ui/chat/ChatViewModelKeyTest.kt create mode 100644 devtools/ws-block-proxy/README.md create mode 100644 devtools/ws-block-proxy/ws_block_proxy.py diff --git a/app/src/main/kotlin/top/yeij/cyrene/core/prefs/SessionPreferences.kt b/app/src/main/kotlin/top/yeij/cyrene/core/prefs/SessionPreferences.kt index cb6ac1e..008e0e3 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/core/prefs/SessionPreferences.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/core/prefs/SessionPreferences.kt @@ -20,7 +20,7 @@ private val Context.sessionDataStore: DataStore by preferencesDataS * * @param snapshot 上次成功拉取的会话列表;服务端不可用时用它先渲染 * @param snapshotAt 快照时间(用于「同步于 N 分钟前」) - * @param fingerprint `baseUrl + username`;换服务器/账号后快照作废 + * @param fingerprint [accountFingerprint]:换服务器/账号后快照作废 * @param activeSessionId 上次停留的会话 */ data class SessionPrefs( @@ -35,6 +35,20 @@ data class SessionPrefs( snapshot.takeIf { this.fingerprint == fingerprint }.orEmpty() } +/** + * 账号指纹(纯函数):`baseUrl + username`。回答的是「**这份本地状态属于哪个环境**」。 + * + * 抽成一个函数的理由是**全仓只能有一份口径**:这类判据一旦各拼一套字符串,差一个分隔符 + * 就会在某个闸门上漏过去——而漏过去的后果是把别的服务器/账号的内容摆到屏幕上。 + * 现在的两个消费者是 [SessionPrefs.snapshotFor](会话快照作不作废)与聊天页的 VM key + * (`chatViewModelKey`:那个 VM 是不是本环境的)。改口径就是改**两者**,不要就地另拼。 + * + * 取用户名而不是角色:角色是同一个人在服务端被授的权限,账号身份由**服务器地址 + 用户名** + * 共同确定;要让指纹对不上,只能是换了服务器或换了账号,那两件事都该让本地状态作废。 + */ +internal fun accountFingerprint(baseUrl: String, username: String?): String = + "$baseUrl|${username.orEmpty()}" + class SessionPreferences(context: Context) { private val json = ApiClientFactory.json 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 4a507f9..41a03bd 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 @@ -27,8 +27,11 @@ private const val SESSION_GONE_NOTICE_MILLIS = 8_000L * 会话在别处被删掉时那句如实告知。 * * 它必须由**这一层**持有,不能挂在 `ChatViewModel` 上:`onSessionGone` 一被调用, - * `activeSessionId` 就会被收敛回主会话,那个会话的 VM 连同它的状态**立刻被销毁**, - * 挂在上面的横幅一个字都来不及显示(真机实测过)。这一层的 `remember` 活得比它久。 + * `activeSessionId` 就会被收敛回主会话,ChatScreen 的 VM key(含会话 id)随之变化 —— + * 变的是 key,于是**承载横幅的那段组合被销毁重建**;那个 VM 实例本身住在 Activity 级的 + * ViewModelStore 里,**仍然活着、继续收帧**(连登出都不回收它,见 `chatViewModelKey` 的 KDoc)。 + * 横幅挂在 VM 状态上的话活不过这一帧、一个字都来不及显示(真机实测过)。 + * 这一层的 `remember` 活得比它久。 */ private const val SESSION_GONE_MESSAGE = "这个会话在服务端已经不存在了(可能已在别处删除),已切回主会话" @@ -52,7 +55,7 @@ fun ChatHomeScreen( var showAbout by remember { mutableStateOf(false) } // 当前会话在别处被删掉时那句告知(见 [SESSION_GONE_MESSAGE])。放在这一层是因为承载它的 - // 会话 VM 会在切回主会话的瞬间被销毁。 + // 那段组合(ChatScreen)会在切回主会话的瞬间被销毁重建,而这里的 remember 活得比它久。 var sessionNotice by remember { mutableStateOf(null) } LaunchedEffect(sessionNotice) { @@ -104,8 +107,8 @@ fun ChatHomeScreen( // `refresh` 里面会把「指向已删会话」的 activeSessionId 收敛回主会话 // (`resolveActiveSession`),抽屉里那条也跟着消失。所以这里不需要自己判「该切到哪儿」。 // - // 顺序很重要:告知必须**先**立起来再刷新——刷新会让本层的 ChatScreen 换 key、 - // 把那个会话的 VM 销毁掉,晚一步就什么都看不到了。 + // 顺序很重要:告知必须**先**立起来再刷新——刷新会让本层的 ChatScreen 换 key, + // 那段组合连同挂在 VM 状态上的东西一起被丢弃重建,晚一步就什么都看不到了。 onSessionGone = { sessionNotice = SESSION_GONE_MESSAGE sessionsViewModel.refresh(silent = true) diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt index 6619d3c..6b08fe7 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt @@ -58,6 +58,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import top.yeij.cyrene.app.AppContainer import top.yeij.cyrene.app.ShareIntake +import top.yeij.cyrene.core.prefs.accountFingerprint import top.yeij.cyrene.data.repository.DocumentCacheStore import top.yeij.cyrene.data.repository.ImageCacheStore import top.yeij.cyrene.data.ws.WsState @@ -108,6 +109,21 @@ private const val SEARCH_NOTICE_MILLIS = 8_000L /** 输入栏下方预留的高度:避开屏幕圆角,同时承载忙碌提示。 */ private val InputBottomSpacer = 22.dp +/** + * 聊天页那个 [ChatViewModel] 的 key(纯函数):**账号指纹 + 会话 id**。 + * + * `viewModel(key = ...)` 的 owner 是 **Activity 级的 ViewModelStore**,而登出只把本组合移出、 + * **不清理那个 store**([ChatViewModel.onCleared] 是空的,全仓也没有 `ViewModelStore.clear()`)。 + * key 里只放 sessionId 的话,换账号/换服务器再进来时,`viewModel()` 会把**上一个环境** + * 那个还活着的实例原样取回来——它会继续收那个环境的帧,屏幕上摆的就是别的服务器的消息。 + * 指纹进 key 之后,环境一变就取到新实例;同一个环境里切会话仍然各留一份状态(key 未变)。 + * + * [accountFingerprint] 与 [SessionPreferences] / `SessionsViewModel` 是**同一份**口径: + * 别在这里另拼一套字符串——两套判据早晚会分叉,而分叉的那一侧就是漏出去的那一侧。 + */ +internal fun chatViewModelKey(accountFingerprint: String, sessionId: String): String = + "$accountFingerprint|$sessionId" + /** * 选文件时交给系统选择器的 MIME 列表。 * @@ -147,8 +163,16 @@ fun ChatScreen( onSessionGone: () -> Unit = {}, sessionNotice: String? = null, ) { + // 账号指纹:**必须进下面那两个 VM 的 key**。登出只是把本组合移出(见 CyreneApp 的 when), + // 而这两个 VM 住在 Activity 级的 ViewModelStore 里、**不会随登出回收**——key 里不带指纹的话, + // 换账号/换服务器再进来会按同一个 key 取回上一个环境那个还活着的实例,屏幕上摆的就是 + // 别的服务器的消息。`remember` 每次进入本组合重算一次(换环境必经登出 ⇒ 本组合被销毁重建), + // 所以不会拿着上一份指纹;不在每次重组时读加密存储是因为流式期间重组非常频繁。 + val account = remember { + accountFingerprint(container.authManager.currentBaseUrl, container.sessionStore.load().username) + } val viewModel: ChatViewModel = viewModel( - key = sessionId, + key = chatViewModelKey(account, sessionId), factory = viewModelFactory { initializer { ChatViewModel(container, sessionId) } }, ) val state by viewModel.state.collectAsState() @@ -159,9 +183,10 @@ fun ChatScreen( // 所以这里不会出现「已经主会话还往上跳一次」的回环。 // // ⚠️ **那句解释不能挂在这个 VM 上**:`onSessionGone` 一被调用,上层就会把 activeSessionId 收敛回 - // 主会话,`key = sessionId` 随之变化 ⇒ **这个 VM 连同它的状态一起被销毁**,挂在上面的横幅 - // 一个字都来不及显示(真机实测过:切回主会话干净利落,用户完全不知道为什么)。 - // 所以文案由 [sessionNotice] 从上层注入(那个状态活得比本 VM 久)。 + // 主会话,VM key 里的会话 id 随之变化 ⇒ **承载它的那段组合被销毁重建**。(变的是 key, + // 不是 VM:那个实例仍在 Activity 级的 ViewModelStore 里活着、继续收帧——见上面 key 的 KDoc。) + // 挂在 VM 状态上的横幅活不过这一帧,一个字都来不及显示(真机实测过:切回主会话干净利落, + // 用户完全不知道为什么)。所以文案由 [sessionNotice] 从上层注入(那个状态活得比本 VM 久)。 LaunchedEffect(state.sessionGone) { if (state.sessionGone) onSessionGone() } @@ -175,7 +200,9 @@ fun ChatScreen( // 待执行的定位请求。命中在窗口里时先关面板再滚,交给这个 effect 在组合完成之后做。 var pendingScroll by remember { mutableStateOf(null) } val searchViewModel: SessionSearchViewModel = viewModel( - key = "session-search:$sessionId", + // 与上面那个 VM 同样的理由:搜索命中也是**上一个环境的聊天内容**,key 里不带账号指纹的话, + // 换环境后在主会话(id 恒为 web)上重开搜索,面板里就是别的服务器的消息。 + key = "session-search:${chatViewModelKey(account, sessionId)}", factory = viewModelFactory { initializer { SessionSearchViewModel(container, sessionId) } }, 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 141e86c..7419fdc 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 @@ -959,8 +959,8 @@ class ChatViewModel( // 连接是**进程内共享**的那一条(`AppContainer.chatSocket`),本 VM 只订阅它。开与关归 // 「登录态」那条路径(唯一主人是 `SessionsViewModel`,见它的类注释):`connect()` 虽然幂等, - // 但主人只有一个——本 VM 会随「切到别的会话」被销毁,让它插手就等于让一个屏幕的生死 - // 去掐全 App 的连接。 + // 但主人只有一个——本 VM 的 key 一换(切会话 / 换账号)就只是另起一个实例,旧实例仍留在 + // Activity 级的 `ViewModelStore` 里,让它插手就等于让一个屏幕的生死去掐全 App 的连接。 val ws = container.chatSocket socket = ws viewModelScope.launch { ws.state.collect(::onConnectionChanged) } @@ -2905,9 +2905,11 @@ class ChatViewModel( fun dismissError() = _state.update { it.copy(error = null) } /** - * **刻意不关连接**:`socket` 是**全 App 共享**的那一条(`AppContainer.chatSocket`),而本 VM 随 - * 「切到别的会话」就会被销毁。在 `onCleared` 里 close 会连抽屉的未读、管理页的三帧、别的会话的 - * 流式一起掐断——连接的开与关只由**登录态**表达(主人是 `SessionsViewModel`,见它的类注释)。 + * **刻意不关连接**:`socket` 是**全 App 共享**的那一条(`AppContainer.chatSocket`),而本 VM + * 并不随切会话 / 登出消失——它的 key 一换只是另起一个实例,旧实例仍留在 Activity 级的 + * `ViewModelStore` 里(见 `chatViewModelKey` 的 KDoc),`onCleared` 要等整个 Activity 结束才来。 + * 在 `onCleared` 里 close 会连抽屉的未读、管理页的三帧、别的会话的流式一起掐断——连接的 + * 开与关只由**登录态**表达(主人是 `SessionsViewModel`,见它的类注释)。 */ override fun onCleared() { super.onCleared() diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/SendFallback.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/SendFallback.kt index 6cab0b0..ac3f0db 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/SendFallback.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/SendFallback.kt @@ -122,7 +122,7 @@ enum class RestFailureLanding { * * ## 为什么是「直接试 REST」(W45 主人的定案) * W41 的阶梯是「WS 未就绪 ⇒ **先探 `GET /api/v1/health`** ⇒ 通才回落 REST」。真机回归 - * (`devtools/ws_block_proxy.py` 只掐 `/ws/chat`、HTTP 全放行)发现消息**进了队列而不是回落 + * (`devtools/ws-block-proxy/ws_block_proxy.py` 只掐 `/ws/chat`、HTTP 全放行)发现消息**进了队列而不是回落 * REST**;而 `ProbeClientTest` 证明那条链在 JVM 上是通的,`Unreachable` 生在**连接之前**,三种 * 成因(建 client 抛 / 连上但一个字没写 / ConnectException)**在外同形** ⇒ 那一轮无法定论。 * diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/SessionSearchViewModel.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/SessionSearchViewModel.kt index 54a050c..c0d46f7 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/SessionSearchViewModel.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/SessionSearchViewModel.kt @@ -48,8 +48,10 @@ data class SessionSearchUiState( * 会话内搜索的小 ViewModel:**只属于聊天页的搜索态**,与 [ChatViewModel] 完全不相干—— * 搜索不改变消息窗、不碰 WS、不发消息,所以它不该住进那个正在被另一个模块改动的 VM 里。 * - * 作用域由调用方决定(`viewModel(key = "session-search:")`),因此每个会话各有一份, - * 切回来时上一次的查询与结果还在。取数一律走既有的 `MessageRepository`。 + * 作用域由调用方决定(`viewModel(key = "session-search:<账号指纹>|")`),因此每个会话各有一份、 + * 切回来时上一次的查询与结果还在;而**换账号/换服务器后指纹变了 ⇒ 取到的是新实例**, + * 面板里不会残留上一个环境的命中(那些命中就是别的服务器的消息正文)。 + * 取数一律走既有的 `MessageRepository`。 * * 并发:`generation` 是「谁的结果才算数」的代际号。搜索是**用户连打两次回车**就会并发的地方, * 加上翻页与首批可以同时存在,没有代际号就会出现「先发的慢请求后到,把新关键词的结果顶掉」。 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 96d4726..ef6da79 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 @@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import top.yeij.cyrene.app.AppContainer import top.yeij.cyrene.core.auth.SessionState +import top.yeij.cyrene.core.prefs.accountFingerprint import top.yeij.cyrene.data.api.CyreneException import top.yeij.cyrene.data.api.dto.SessionSummaryDto import top.yeij.cyrene.data.ws.ChatSocket @@ -394,8 +395,9 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() { readCursors[sessionId] = maxOf(readCursors[sessionId] ?: 0L, readAt) } + /** 与聊天页的 VM key 用**同一份**口径([accountFingerprint]),不要就地另拼字符串。 */ private fun fingerprint(): String = - "${container.authManager.currentBaseUrl}|${container.sessionStore.load().username.orEmpty()}" + accountFingerprint(container.authManager.currentBaseUrl, container.sessionStore.load().username) } internal fun SessionSummaryDto.toItem(): SessionItem = SessionItem( diff --git a/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ChatViewModelKeyTest.kt b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ChatViewModelKeyTest.kt new file mode 100644 index 0000000..cf6dc84 --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ChatViewModelKeyTest.kt @@ -0,0 +1,75 @@ +package top.yeij.cyrene.ui.chat + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test +import top.yeij.cyrene.core.prefs.accountFingerprint + +/** + * 聊天页那两个 VM 的 key 里**必须带账号指纹**。 + * + * 要钉住的事:这个 VM 的 owner 是 Activity 级的 `ViewModelStore`,登出只是把 ChatScreen + * 移出组合、**不回收 store 里的实例**(全仓没有 `ViewModelStore.clear()`)。所以只要 key 不变, + * `viewModel()` 就会把**上一个环境**那个还活着的实例原样取回来——它继续收着上个服务器的帧, + * 屏幕上摆的就是别的环境的消息。判据因此很直接:**环境一变,key 必须变**;环境没变, + * key 必须不变(否则同一个环境里切一下会话 / 重组几次就把状态丢了)。 + * + * 纯粹的 seam 测试:`viewModel()` 与 Android 的 store 拖不进 JVM 测试,这里只钉拼法。 + */ +class ChatViewModelKeyTest { + + private val hostA = "http://host-a:10942/" + private val hostB = "http://host-b:10942/" + + @Test + fun `same account on the same server yields the same key`() { + val first = chatViewModelKey(accountFingerprint(hostA, "testuser"), "web") + val again = chatViewModelKey(accountFingerprint(hostA, "testuser"), "web") + + assertEquals(first, again) + } + + @Test + fun `another server yields another key`() { + val here = chatViewModelKey(accountFingerprint(hostA, "testuser"), "web") + + assertNotEquals(here, chatViewModelKey(accountFingerprint(hostB, "testuser"), "web")) + } + + @Test + fun `another account on the same server yields another key`() { + val mine = chatViewModelKey(accountFingerprint(hostA, "testuser"), "web") + + assertNotEquals(mine, chatViewModelKey(accountFingerprint(hostA, "someone-else"), "web")) + } + + @Test + fun `another session inside the same account yields another key`() { + val main = chatViewModelKey(accountFingerprint(hostA, "testuser"), "web") + + // 同一个环境里每个会话各留一份状态:key 必须分得开,否则切会话会把上一个会话的消息留下来。 + assertNotEquals(main, chatViewModelKey(accountFingerprint(hostA, "testuser"), "web:sub-1")) + } + + @Test + fun `a missing username still cannot collide with a blank one on another server`() { + // 登录状态里 username 缺失(老安装 / 异常路径)时指纹退化成 baseUrl 一段: + // 地址不同仍然分得开,这才是这条缺陷最要紧的一半。 + assertNotEquals( + chatViewModelKey(accountFingerprint(hostA, null), "web"), + chatViewModelKey(accountFingerprint(hostB, null), "web"), + ) + // 缺失与空串是同一个意思,不该拼出两种 key(否则同一次登录的两个时刻会各是各的)。 + assertEquals( + accountFingerprint(hostA, null), + accountFingerprint(hostA, ""), + ) + } + + @Test + fun `the account fingerprint is baseUrl plus username, the existing caliber`() { + // 口径与 SessionPreferences / SessionsViewModel 同一份:就地改这里会同时改掉两处闸门。 + assertEquals("http://host-a:10942/|testuser", accountFingerprint(hostA, "testuser")) + assertEquals("http://host-a:10942/|", accountFingerprint(hostA, null)) + } +} diff --git a/app/src/test/kotlin/top/yeij/cyrene/ui/chat/SendFallbackTest.kt b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/SendFallbackTest.kt index e059af3..242ff91 100644 --- a/app/src/test/kotlin/top/yeij/cyrene/ui/chat/SendFallbackTest.kt +++ b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/SendFallbackTest.kt @@ -19,7 +19,7 @@ import top.yeij.cyrene.domain.chat.SendChannel * * ## 背景:W45 把探测换成了直接尝试(主人的定案) * W41 的阶梯是「WS 未就绪 ⇒ 先探 `GET /api/v1/health` ⇒ 通才回落 REST」。真机回归 - * (`devtools/ws_block_proxy.py` 只掐 `/ws/chat`、HTTP 全放行)发现消息进了队列而不是回落 REST, + * (`devtools/ws-block-proxy/ws_block_proxy.py` 只掐 `/ws/chat`、HTTP 全放行)发现消息进了队列而不是回落 REST, * 而 `ProbeClientTest` 证明那条链在 JVM 上是通的、`Unreachable` 生在**连接之前**,三种成因在外 * 同形 ⇒ 无法定论。判据与论证见 [sendRouteFor]。 * diff --git a/devtools/ws-block-proxy/README.md b/devtools/ws-block-proxy/README.md new file mode 100644 index 0000000..9cb9c1d --- /dev/null +++ b/devtools/ws-block-proxy/README.md @@ -0,0 +1,72 @@ +# WS 断、HTTP 通的窄代理(`devtools/ws-block-proxy/`) + +一个**只掐 `/ws/chat`、其余 HTTP 一律放行**的窄代理:用来把「WebSocket 断了,但 REST 还通」 +这个场景变成可复现、可取证的状态。纯 Python 3 标准库,零第三方依赖。 + +它**不是**产品的一部分,也**不是**后端行为的权威来源。它只做一件事:把发往 `/ws/chat` +的请求就地回 `503`(`ws blocked by test proxy`),别的请求原样转发给真后端,并把 +**每一行的去向**打到 stdout。 + +它**不做什么**:不鉴权、不改写请求体、不落盘、不重试、不实现任何业务端点。 +转发时逐行丢掉 hop-by-hop 头(`Connection` / `Upgrade` / `Transfer-Encoding` 等), +请求体按 `Content-Length` 读全再转发——**只要真后端还听得懂,它就照转**。 + +--- + +## 1. 用途 + +W41 的「三级阶梯」(WS → 健康探测 → REST 回落)只在**WS 连不上**时才会走。真实的断线 +不可控也不好复现,用这个代理把 WS 单独掐掉即可:客户端应该表现为「重连中…」(而不是「离线」), +然后按阶梯回落 `POST /api/v1/chat` 把消息发出去。 + +排查时的用处同样直接:**想知道 App 到底发了什么、发没发出去**,看代理日志就行。 + +## 2. 用法 + +```bash +python ws_block_proxy.py +``` + +- ``:代理自己监听的端口(**App 要连的那个口**,见下面的 `adb reverse`) +- ``:真后端在本机监听的端口 + +典型用法(真后端在 10942,代理听 18098): + +```bash +# 终端 A:代理听 18098,转发给 127.0.0.1:10942 +cd /d/Project/Code/Uni/Cyrene-For-Android/devtools/ws-block-proxy +python ws_block_proxy.py 18098 10942 + +# 终端 B:把设备上的 10942 引到宿主机的 18098(即代理) +adb reverse tcp:10942 tcp:18098 +``` + +App 侧不用改任何配置:它照旧连 `127.0.0.1:10942`,被 `adb reverse` 送到代理。 + +日志(stdout)长这样: + +``` +proxy 18098 -> 127.0.0.1:10942, blocking /ws/chat # 启动 +BLOCKED GET /ws/chat?token=... # WS 被掐(App 会退避重试) +PASS GET /api/v1/sessions/web/messages?limit=50 -> 200 +PASS POST /api/v1/chat -> 200 +ERR GET /api/v1/health -> ConnectionRefusedError(...) # 真后端没起来时 +``` + +收尾:撤掉端口映射(`adb reverse --remove tcp:10942`)、Ctrl-C 停代理。 +**用完记得撤**——留着的话别的会话连真后端也会经过这个「掐 WS」的口。 + +## 3. 关键性质(它最有价值的一条) + +**调试期间 Web 前端是直连后端(`127.0.0.1:10942`)的,不经过 `adb reverse`。** +所以设备侧的口只有 App 会用,**代理日志里的每一行都只可能来自 App** —— +不存在「这条请求到底是谁发的」这个问题。这是分辨「App 到底发了什么」最干净的判据: +要确认某次发送走的是 WS 还是 REST 回落,看日志里有没有对应的 `PASS POST /api/v1/chat` 就够了, +不需要在客户端加任何日志。 + +## 4. 注意 + +- **日志里会带查询串**(例如 `/ws/chat?token=...`),贴给别人之前先抹掉凭据。 + 脚本本身**不硬编码任何凭据**,也不写任何文件。 +- 它**不是**后端行为的事实来源:要判断真后端怎么回,去看真后端本身。 +- 端口随便挑,但**别让代理和真后端抢同一个口**;`10942` 是真后端的常规端口。 diff --git a/devtools/ws-block-proxy/ws_block_proxy.py b/devtools/ws-block-proxy/ws_block_proxy.py new file mode 100644 index 0000000..750453a --- /dev/null +++ b/devtools/ws-block-proxy/ws_block_proxy.py @@ -0,0 +1,90 @@ +"""W41 回归用的窄代理:HTTP 全放行,只把 /ws/chat 掐掉。 + +用途:模拟「WS 断了但 HTTP 通」——W41 三级阶梯应该在这种情况下 +探测 /api/v1/health 成功、回落 POST /api/v1/chat 把消息发出去。 + +仅本机开发用;这个文件**在 git 里**(`devtools/ws-block-proxy/`),不再放 `app/build/`—— +那里被 gitignore,一次 `gradlew clean` 就把它扫没了。用法见同目录的 README.md。 +用法:python ws_block_proxy.py +""" + +import http.client +import socketserver +import sys + +LISTEN = int(sys.argv[1]) +TARGET = int(sys.argv[2]) +BLOCK = "/ws/chat" + +HOP_BY_HOP = { + "connection", "keep-alive", "proxy-authenticate", "proxy-authorization", + "te", "trailers", "transfer-encoding", "upgrade", +} + + +class Handler(socketserver.BaseRequestHandler): + def handle(self): + self.request.settimeout(30) + buf = b"" + while b"\r\n\r\n" not in buf: + chunk = self.request.recv(4096) + if not chunk: + return + buf += chunk + head, _, rest = buf.partition(b"\r\n\r\n") + lines = head.decode("latin-1").split("\r\n") + method, path, _ = lines[0].split(" ", 2) + + if path.split("?")[0] == BLOCK: + body = b"ws blocked by test proxy" + self.request.sendall( + b"HTTP/1.1 503 Service Unavailable\r\n" + b"Content-Type: text/plain\r\n" + b"Content-Length: " + str(len(body)).encode() + b"\r\n\r\n" + body + ) + print("BLOCKED %s %s" % (method, path), flush=True) + return + + headers = {} + for line in lines[1:]: + k, _, v = line.partition(":") + if k.lower() in HOP_BY_HOP: + continue + headers[k.strip()] = v.strip() + + length = int(headers.get("Content-Length") or 0) + body = rest + while len(body) < length: + more = self.request.recv(length - len(body)) + if not more: + break + body += more + + conn = http.client.HTTPConnection("127.0.0.1", TARGET, timeout=60) + try: + conn.request(method, path, body=body if length else None, headers=headers) + resp = conn.getresponse() + payload = resp.read() + out = ["HTTP/1.1 %d %s" % (resp.status, resp.reason)] + for k, v in resp.getheaders(): + if k.lower() in HOP_BY_HOP or k.lower() == "content-length": + continue + out.append("%s: %s" % (k, v)) + out.append("Content-Length: %d" % len(payload)) + self.request.sendall(("\r\n".join(out) + "\r\n\r\n").encode("latin-1") + payload) + print("PASS %s %s -> %d" % (method, path, resp.status), flush=True) + except Exception as exc: # noqa: BLE001 + self.request.sendall(b"HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n") + print("ERR %s %s -> %r" % (method, path, exc), flush=True) + finally: + conn.close() + + +class Server(socketserver.ThreadingTCPServer): + allow_reuse_address = True + daemon_threads = True + + +if __name__ == "__main__": + print("proxy %d -> 127.0.0.1:%d, blocking %s" % (LISTEN, TARGET, BLOCK), flush=True) + Server(("127.0.0.1", LISTEN), Handler).serve_forever()