fix(chat): W48 聊天页 VM key 带上账号指纹,修换环境后仍显示上个环境的会话
缺陷:换账号或换服务器后,聊天页仍然摆着上一个环境的消息。成因是 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。
This commit is contained in:
1 parent
df8df67742
commit
4f860aa499
11 files changed
+308
-21
No files matched your search
@@ -20,7 +20,7 @@ private val Context.sessionDataStore: DataStore<Preferences> 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
|
||||
|
||||
@@ -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<String?>(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)
|
||||
|
||||
@@ -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<Int?>(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) }
|
||||
},
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)**在外同形** ⇒ 那一轮无法定论。
|
||||
*
|
||||
|
||||
@@ -48,8 +48,10 @@ data class SessionSearchUiState(
|
||||
* 会话内搜索的小 ViewModel:**只属于聊天页的搜索态**,与 [ChatViewModel] 完全不相干——
|
||||
* 搜索不改变消息窗、不碰 WS、不发消息,所以它不该住进那个正在被另一个模块改动的 VM 里。
|
||||
*
|
||||
* 作用域由调用方决定(`viewModel(key = "session-search:<id>")`),因此每个会话各有一份,
|
||||
* 切回来时上一次的查询与结果还在。取数一律走既有的 `MessageRepository`。
|
||||
* 作用域由调用方决定(`viewModel(key = "session-search:<账号指纹>|<id>")`),因此每个会话各有一份、
|
||||
* 切回来时上一次的查询与结果还在;而**换账号/换服务器后指纹变了 ⇒ 取到的是新实例**,
|
||||
* 面板里不会残留上一个环境的命中(那些命中就是别的服务器的消息正文)。
|
||||
* 取数一律走既有的 `MessageRepository`。
|
||||
*
|
||||
* 并发:`generation` 是「谁的结果才算数」的代际号。搜索是**用户连打两次回车**就会并发的地方,
|
||||
* 加上翻页与首批可以同时存在,没有代际号就会出现「先发的慢请求后到,把新关键词的结果顶掉」。
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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]。
|
||||
*
|
||||
|
||||
@@ -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 <listen_port> <target_port>
|
||||
```
|
||||
|
||||
- `<listen_port>`:代理自己监听的端口(**App 要连的那个口**,见下面的 `adb reverse`)
|
||||
- `<target_port>`:真后端在本机监听的端口
|
||||
|
||||
典型用法(真后端在 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` 是真后端的常规端口。
|
||||
@@ -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 <listen_port> <target_port>
|
||||
"""
|
||||
|
||||
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()
|
||||
Reference in new issue
Block a user