Files
Cyrene-For-Android/devtools/fake-backend/ws_probe.py
T
AskaEth 86a2217858 feat(devtools): 新增可编程假后端(devtools/fake-backend),用来造「特定服务端行为」的端到端场景
为什么要它
客户端有三类行为只在特定服务端行为下才验得到,而真后端不可编排、造场景的代价高到不可做:
1. after 正向增量补洞:要造「WS 握手只回放最近 50 条、库里却有 120 条」这个洞,用真后端得灌 51 条消息、
   每条跑一次模型回合。假后端可以精确、免费、可复现地造出来(--history-replay N)。
2. 打断帧:客户端在「设置=打断 + 会话正忙」时会发 interrupt{scope:"current"}。要验它真的发出去了,
   就得看上行帧——假后端把每条上行帧原样记进 out/upstream.jsonl,这是唯一的第一现场。
3. stream_chunk 回退路径:真后端可能永远不走那条路,假后端可以用 --stream-chunk 指定走。

加了什么
- devtools/fake-backend/fake_backend.py:纯 Python 3 标准库(无第三方依赖)。实现了客户端启动到渲染
  用得到的那一段 REST(login/refresh/logout、sessions 列表与增删改、messages 分页与搜索、read、
  messages 编辑删除、meta/enums、personas、chat)加一个手写 WebSocket(GET /ws/chat?token=…,
  握手后立刻发 history 帧)。鉴权头 X-API-Version / Authorization 一律不校验,但会记进 out/http.jsonl
  (Authorization 与 ?token 只留 sha256 指纹)。
- 控制端点 /__control/**(不碰 /api/v1/**):灌消息(不调模型)、下发 session_busy/idle/interrupted、
  触发一轮回复、pending_full、定向 error、任意帧、踢掉客户端 WS、404 session_not_found、复位、看状态。
  目的是让「造场景」与「看结果」都不需要人手点 UI。
- devtools/fake-backend/ws_probe.py:最小 WebSocket 探针(标准库手写握手 + 帧收发 + ping),
  用来证明握手返回 101 / 收到 history 帧,并往连接上发任意上行帧对账。
- devtools/fake-backend/README.md:起法(Windows + Git Bash)、客户端怎么指过来(adb reverse +
  http://127.0.0.1:18080)、每个场景的可复制命令序列、端点速查、形状出处表、以及「它不做什么」。

边界(README 里也写明了)
不校验鉴权、不落盘成持久数据、不调模型、不是产品的一部分、不实现契约全集(admin 面、附件上传、
observability 推送等一律 404),并且不要拿它当后端行为的事实来源——所有响应形状都是照客户端代码
读出来的(README 第 9 节逐条列出文件:行号),不代表真后端真的这么回。
默认端口 18080,刻意避开真后端的 10942。

形状来源(举几个关键例子):信封 ApiEnvelope.kt:8;鉴权头 ApiClientFactory.kt:115-118;
WS 地址 ChatSocket.kt:287-295;SessionSummaryDto 与排序 SessionDto.kt:16-50 加线上夹具
SessionDtoRealPayloadTest.kt:36-38;after 升序 / before 倒序 / q 与 after 互斥 SessionsApi.kt:71-101;
limit 默认 50 上限 200 MessageRepository.kt:149-150;枚举表逐字取自 MetaDtoRealPayloadTest.kt:24;
404 session_not_found ChatViewModel.kt:1802-1808;stream_chunk 回退 WsEvent.kt:205-228。

验证(本机实跑,输出见提交后的报告)
curl 打通 login/sessions/messages(limit/before/after/q 与三个 400 互斥面)、meta/enums、personas;
ws_probe 握手拿到 101 与匹配的 Sec-WebSocket-Accept,并收到 history 帧;灌 120 条后握手只回放最近 50 条
(msg-000071..msg-000120),用 after 游标正向走两页正好补回 msg-000021..msg-000120、第三页为空;
--stream-chunk 下下发的是 stream_chunk×3 + stream_end(没有 message.* 帧);打断帧原样落进
out/upstream.jsonl;ping/pong 与 HTTP keep-alive 均正常。
2026-10-05 08:01:48 +08:00

216 lines
8.1 KiB
Python
Raw 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.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""最小 WebSocket 探针(标准库手写握手 + 帧收发),配合 fake_backend.py 用。
用途:
- 证明 `/ws/chat` 能握手(打印 101 与响应头),并且握手后能收到 `history` 帧;
- 往连接上发任意上行帧(`--send`),再去 out/upstream.jsonl 里对账——这是「客户端到底发没发那一帧」的独立第二现场;
- 监听 N 秒,把服务端下发的每一帧原样打出来。
python ws_probe.py --port 18080 --listen 3
python ws_probe.py --port 18080 --send '{"type":"interrupt","data":{"sessionId":"web","scope":"current"}}' --listen 2
"""
import argparse
import base64
import hashlib
import json
import os
import socket
import struct
import sys
import time
for _stream in (sys.stdout, sys.stderr):
try:
_stream.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
def encode_text_frame(text: str, mask: bytes = b"\x37\xfa\x21\x3d") -> bytes:
payload = text.encode("utf-8")
header = bytearray([0x80 | 0x1])
length = len(payload)
if length < 126:
header.append(0x80 | length)
elif length < 65536:
header.append(0x80 | 126)
header += struct.pack(">H", length)
else:
header.append(0x80 | 127)
header += struct.pack(">Q", length)
masked = bytes(byte ^ mask[i % 4] for i, byte in enumerate(payload))
return bytes(header) + mask + masked
class RawReader:
"""裸 socket 上的定长读。
刻意**不用** `socket.makefile()`:给一个 BufferedReader 设过超时之后它会永久变成
「cannot read from timed out object」,于是「监听 2 秒」这种用法第二帧就读不到了。
"""
def __init__(self, sock: socket.socket):
self.sock = sock
self.buffer = b""
def read_exact(self, count: int):
while len(self.buffer) < count:
chunk = self.sock.recv(65536)
if not chunk:
return None
self.buffer += chunk
head, self.buffer = self.buffer[:count], self.buffer[count:]
return head
def read_line(self):
while b"\r\n" not in self.buffer:
chunk = self.sock.recv(65536)
if not chunk:
return None
self.buffer += chunk
line, self.buffer = self.buffer.split(b"\r\n", 1)
return line
class Probe:
def __init__(self, host: str, port: int, path: str):
self.sock = socket.create_connection((host, port), timeout=10)
key = base64.b64encode(os.urandom(16)).decode("ascii")
request = (
"GET {} HTTP/1.1\r\n"
"Host: {host}:{port}\r\n"
"Upgrade: websocket\r\n"
"Connection: Upgrade\r\n"
"Sec-WebSocket-Key: {key}\r\n"
"Sec-WebSocket-Version: 13\r\n"
"\r\n"
).format(path, host=host, port=port, key=key)
self.sock.sendall(request.encode("ascii"))
self.reader = RawReader(self.sock)
status = self.reader.read_line().decode("latin-1").strip()
self.sock.settimeout(None)
print("<<< " + status)
headers = {}
while True:
line = self.reader.read_line().decode("latin-1")
if line == "":
break
name, _, value = line.partition(":")
headers[name.strip()] = value.strip()
print("<<< " + line.strip())
expected = base64.b64encode(hashlib.sha1((key + WS_GUID).encode("ascii")).digest()).decode("ascii")
code = status.split()[1] if len(status.split()) > 1 else ""
if code != "101":
raise SystemExit("handshake failed: " + status)
if headers.get("Sec-WebSocket-Accept") != expected:
raise SystemExit("bad Sec-WebSocket-Accept: " + str(headers.get("Sec-WebSocket-Accept")))
print("<<< handshake OK (101, Sec-WebSocket-Accept matches)")
self.closed = False
def send_json(self, payload: str) -> None:
self.sock.sendall(encode_text_frame(payload))
print(">>> " + payload)
def send_ping(self, payload: bytes = b"probe-ping") -> None:
header = bytearray([0x80 | 0x9])
length = len(payload)
if length < 126:
header.append(0x80 | length)
else:
header.append(0x80 | 126)
header += struct.pack(">H", length)
mask = b"\x11\x22\x33\x44"
masked = bytes(byte ^ mask[i % 4] for i, byte in enumerate(payload))
self.sock.sendall(bytes(header) + mask + masked)
print(">>> ping (payload={})".format(payload.decode("latin-1")))
def _read_exact(self, count: int):
return self.reader.read_exact(count)
def listen(self, seconds: float, label: str = "downstream") -> int:
deadline = time.time() + seconds
frames = 0
self.sock.settimeout(0.25)
try:
while time.time() < deadline:
try:
head = self._read_exact(2)
except socket.timeout:
continue
if not head or len(head) < 2:
print("<<< connection closed by peer")
self.closed = True
break
opcode = head[0] & 0x0F
length = head[1] & 0x7F
if length == 126:
length = struct.unpack(">H", self._read_exact(2))[0]
elif length == 127:
length = struct.unpack(">Q", self._read_exact(8))[0]
payload = self._read_exact(length) if length else b""
if payload is None:
print("<<< connection closed by peer")
self.closed = True
break
if opcode == 0x1:
frames += 1
print("<<< frame {} [{}] {}".format(frames, label, payload.decode("utf-8", "replace")))
elif opcode == 0x8:
code = struct.unpack(">H", payload[:2])[0] if len(payload) >= 2 else 0
reason = payload[2:].decode("utf-8", "replace")
print("<<< close from server code={} reason={}".format(code, reason))
self.closed = True
break
elif opcode == 0x9:
print("<<< ping")
elif opcode == 0xA:
print("<<< pong")
finally:
self.sock.settimeout(None)
if frames == 0:
print("<<< (这 {} 秒里没有收到任何文本帧)".format(seconds))
return frames
def close(self) -> None:
try:
self.sock.sendall(bytes([0x80 | 0x8, 0x02]) + struct.pack(">H", 1000))
except OSError:
pass
try:
self.sock.close()
except OSError:
pass
def main(argv=None) -> int:
parser = argparse.ArgumentParser(description="最小 WebSocket 探针")
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=18080)
parser.add_argument("--path", default="/ws/chat?token=probe-token")
parser.add_argument("--send", action="append", default=[], help="连接建立后发送的帧(JSON 字符串,可重复)")
parser.add_argument("--ping", action="store_true", help="发一个 ping 帧,验证服务端回 pong(OkHttp 30s 心跳走的就是它)")
parser.add_argument("--listen", type=float, default=3.0, help="监听秒数(默认 3)")
args = parser.parse_args(argv)
probe = Probe(args.host, args.port, args.path)
print("--- history 帧应当就在这里 ---")
probe.listen(min(args.listen, 1.0), "initial")
for payload in args.send:
json.loads(payload) # 先自己校验一次,免得把坏 JSON 发出去
probe.send_json(payload)
if args.ping:
probe.send_ping()
if args.send or args.ping:
print("--- 上行帧已发出,等待服务端回帧 ---")
probe.listen(args.listen, "after-send")
probe.close()
return 0
if __name__ == "__main__":
sys.exit(main())