Files
Cyrene-For-Android/devtools/ws-block-proxy/README.md
T
AskaEth 4f860aa499 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。
2026-10-05 22:25:57 +08:00

3.4 KiB
Raw Blame History

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. 用法

python ws_block_proxy.py <listen_port> <target_port>
  • <listen_port>:代理自己监听的端口(App 要连的那个口,见下面的 adb reverse)
  • <target_port>:真后端在本机监听的端口

典型用法(真后端在 10942,代理听 18098):

# 终端 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 是真后端的常规端口。