Files
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

91 lines
3.3 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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()