W76:把「分享批量投递零声明」提成可单测的纯函数 + 假后端 http.jsonl 记请求体摘要

两件事都是为了让「分享 3 张图 = 3 条消息、零 interrupt」这条结论有据可查——
一条是代码侧的构造性保证,一条是设备侧的证据工具。

1) ShareRules.declaresSendPreference(fromShare, index)(纯函数)
   「这一次投递要不要按『她忙时发消息』偏好声明 interrupt / parallel」原先分在两处手写:
   ChatViewModel.sendShared 传 false、emitPlannedMessages 里 && index == 0。两处各是半个判据、
   一处都单测不到,而这条边界正是 W25 修掉的「分享多图自相打断」(第 2 张把第 1 张刚起的回合
   打掉,见 02 的 F4 与 05 那一节)。提成一处判据后语义不变,穷举整批(0..MAX_IMAGES-1 及越界)
   由 ShareSendDeclarationTest 钉住:分享恒 false、手打只在第 0 条 true。
   ChatViewModel 不在本次 scope,接线那一行没动(已作为 finding 上报,纯函数暂为唯一出处)。

2) 假后端 out/http.jsonl 记请求体摘要(不只是 headers)
   分享多图走 REST 不走 WS(ChatSendRouting.channelFor:hasImage ⇒ Rest),所以「发了几条、
   每条带的是正文还是图、有没有 parallel」只能从 HTTP 流水里读。此前它只记 headers,
   现在加 body 摘要:结构照留(键 / 布尔 / 数组长度),超过 200 字符的字符串换成 <N chars>,
   非 JSON 记 {bytes, note}——图片的 base64 只留长度,不会把一行撑到几 MB。
   _read_body 顺带加了缓存:body 只能从 rfile 读一次,先记录再让 handler 读会得到空串。

assembleDebug + testDebugUnitTest 全绿:1674 测试 / 0 失败 / 0 错误 / 0 跳过(134 个测试类)。
This commit is contained in:
AskaEth committed 2026-10-26 22:17:44 +08:00
1 parent 4d25d9ec69
commit 8babd1d2bb
4 files changed
+147 -8

No files matched your search

+4 -1
View File
@@ -474,8 +474,11 @@ curl -s -X POST "$B/__control/messages?session=web&count=1&role=user&text=&docum
- `out/upstream.jsonl` —— **每条上行帧一行**:`{ts, epochMs, client, type, sessionId, raw, frame}`。
`raw` 是客户端发来的**原样文本**。「客户端到底发没发那一帧」以这里为准。
- `out/downstream.jsonl` —— 每条下发帧:`{ts, to, origin, raw}`。`origin` 会写清是 `history`、`control:reply` 还是某次广播。
- `out/http.jsonl` —— 每条 HTTP 请求:`{method, path, query, headers}`。
- `out/http.jsonl` —— 每条 HTTP 请求:`{method, path, query, headers, body}`。
`Authorization` 只留 sha256 指纹、WS 的 `?token=` 同样只留指纹 —— 就算它是假 token,也没理由把凭据原文写进可能被 commit 的文件。
`body` 是**请求体的摘要**,不是原文:结构照留(键、布尔、数字、数组长度),超过 200 字符的字符串换成
`"<N chars>"`,非 JSON 的体记成 `{bytes, note}`。这样「发了几条、每条带不带图、有没有 `parallel`」
读得出来,而图片的 base64 不会把一行日志撑到几 MB。`body` 只在有请求体时出现。
挂起的上行消息(`--hold-replies`)**不进文件** —— 它只活在内存里,看 `GET /__control/state` 的
`heldMessages`。`out/*.jsonl` 仍照常记「上行帧收到了什么 / 下发帧广播了什么」。
+60 -7
View File
@@ -281,6 +281,37 @@ def redact(value, keep: int = 8) -> str:
digest = hashlib.sha256(str(value).encode("utf-8")).hexdigest()[:keep]
return "<redacted sha256:{}>".format(digest)
# 请求体摘要里「短到什么程度就原样留着」的那条线。它要装得下正文与显示名,装不下 base64。
BODY_SUMMARY_STRING_MAX = 200
def summarize_body(raw) -> dict | None:
"""把请求体记成一份「看得见形状、看不见内容」的摘要。
`POST /api/v1/chat` 的正文、图片、附件全在 body 里,而图片是一整串 base64:原样写进
`out/http.jsonl` 会把一行记录撑到几 MB,也读不下去。所以结构照留(键、布尔、短字符串、
数组长度),只把超长字符串换成 `<N chars>`。
这样「这一条带没带 `image`」「`parallel` 这个键在不在」「三张图是不是三条消息、正文挂在
第一条上」全都读得出来,而图片字节本身不落盘——与 [redact] 同一条纪律:日志记事实,不记凭据,
也不记大块内容。
"""
if not raw:
return None
try:
parsed = json.loads(raw.decode("utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError):
return {"bytes": len(raw), "note": "not json"}
return _summarize_value(parsed)
def _summarize_value(value):
if isinstance(value, dict):
return {key: _summarize_value(item) for key, item in value.items()}
if isinstance(value, list):
return [_summarize_value(item) for item in value]
if isinstance(value, str) and len(value) > BODY_SUMMARY_STRING_MAX:
return "<{} chars>".format(len(value))
return value
class JsonlLog:
"""一行一条 JSON 的追加日志。每次写都开一次文件,崩了也不丢已写部分。"""
@@ -599,11 +630,15 @@ class Store:
with self.lock:
self.downstream_count += 1
def record_http(self, handler_label: str, method: str, path: str, query: dict, headers) -> dict:
"""把收到的请求头**记下来**(鉴权头不校验,但要看得见)。
def record_http(self, handler_label: str, method: str, path: str, query: dict, headers, body=b"") -> dict:
"""把收到的请求头与请求体的**摘要**记下来(鉴权头不校验,但要看得见)。
`Authorization` 只留指纹:那是客户端当前会话的凭据,即使假后端只看 HTTP 2xx,
也没有理由把它原文写进一个可能被 commit 的文件。
[body] 走 [summarize_body]:图片那串 base64 只留长度,其余结构原样——「这一次发了几条
消息、每一条带的是正文还是图、有没有声明 `parallel`」这些产品问题,从此在
`out/http.jsonl` 里读得出来,不用再去猜。
"""
recorded_headers = {}
for key, value in headers.items():
@@ -623,6 +658,9 @@ class Store:
"query": query,
"headers": recorded_headers,
}
summary = summarize_body(body)
if summary is not None:
record["body"] = summary
self.http_log.append(record)
with self.lock:
self.http_count += 1
@@ -1047,16 +1085,23 @@ class Handler(BaseHTTPRequestHandler):
return parts.path, query
def _read_body(self):
"""读出请求体,**并缓存**:`_handle` 记录摘要时要读它,各 handler 里的
`_parse_json_body` 随后再读——不缓存的话第二次只会读到空串(body 只能读一次)。"""
if getattr(self, "_body_read", False):
return self._body_cache
self._body_read = True
self._body_cache = b""
length = self.headers.get("Content-Length")
if not length:
return b""
return self._body_cache
try:
size = int(length)
except ValueError:
return b""
return self._body_cache
if size <= 0:
return b""
return self.rfile.read(size) or b""
return self._body_cache
self._body_cache = self.rfile.read(size) or b""
return self._body_cache
def _parse_json_body(self):
raw = self._read_body()
@@ -1103,7 +1148,15 @@ class Handler(BaseHTTPRequestHandler):
def _handle(self, method: str):
path, query = self._split()
self.store.record_http(self._label(), method, path, query, self.headers)
# 请求体**先读一遍**(`_read_body` 会缓存它),记录时才能带上摘要:body 只能从 rfile 读
# 一次,若先记录、再让 handler 去读,摘要那一份就永远是空的。只对 `/api/v1/**` 的写方法
# 这么做——`/__control/**` 是测试者自己敲的 curl,不用记。
body = (
self._read_body()
if path.startswith("/api/v1/") and method in ("POST", "PATCH", "PUT", "DELETE")
else b""
)
self.store.record_http(self._label(), method, path, query, self.headers, body=body)
try:
if path == "/ws/chat":
self._handle_ws(method, query)