From 8babd1d2bb14f543581b3c7a5ea6ecc0b098b385 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 26 Oct 2026 22:17:44 +0800 Subject: [PATCH 1/2] =?UTF-8?q?W76=EF=BC=9A=E6=8A=8A=E3=80=8C=E5=88=86?= =?UTF-8?q?=E4=BA=AB=E6=89=B9=E9=87=8F=E6=8A=95=E9=80=92=E9=9B=B6=E5=A3=B0?= =?UTF-8?q?=E6=98=8E=E3=80=8D=E6=8F=90=E6=88=90=E5=8F=AF=E5=8D=95=E6=B5=8B?= =?UTF-8?q?=E7=9A=84=E7=BA=AF=E5=87=BD=E6=95=B0=20+=20=E5=81=87=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=20http.jsonl=20=E8=AE=B0=E8=AF=B7=E6=B1=82=E4=BD=93?= =?UTF-8?q?=E6=91=98=E8=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两件事都是为了让「分享 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 字符的字符串换成 , 非 JSON 记 {bytes, note}——图片的 base64 只留长度,不会把一行撑到几 MB。 _read_body 顺带加了缓存:body 只能从 rfile 读一次,先记录再让 handler 读会得到空串。 assembleDebug + testDebugUnitTest 全绿:1674 测试 / 0 失败 / 0 错误 / 0 跳过(134 个测试类)。 --- .../yeij/cyrene/domain/share/SharePayload.kt | 25 +++++++ .../domain/share/ShareSendDeclarationTest.kt | 58 ++++++++++++++++ devtools/fake-backend/README.md | 5 +- devtools/fake-backend/fake_backend.py | 67 +++++++++++++++++-- 4 files changed, 147 insertions(+), 8 deletions(-) create mode 100644 app/src/test/kotlin/top/yeij/cyrene/domain/share/ShareSendDeclarationTest.kt diff --git a/app/src/main/kotlin/top/yeij/cyrene/domain/share/SharePayload.kt b/app/src/main/kotlin/top/yeij/cyrene/domain/share/SharePayload.kt index 3d42d8b..cc9fd1f 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/domain/share/SharePayload.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/domain/share/SharePayload.kt @@ -228,6 +228,31 @@ object ShareRules { fun truncatedImagesNotice(kept: Int, dropped: Int): String? = if (dropped > 0) "一次最多 $MAX_IMAGES 张,超出的没发,只发送前 $kept 张" else null + /** + * 一次「批量投递」里,第 [index] 条要不要声明「她忙时发消息」那个偏好(`interrupt` / `parallel`)。 + * + * ## 两个事实合起来只剩两种答案 + * - **分享一次都不声明**([fromShare]):分享是「一次把东西倒进去」,N 张图就是 N 条消息;对每张 + * 图各声明一次,第 2 条就会把第 1 条刚起的回合打掉——**批量动作自相残杀**。这是本端自己的判断 + * (后端口径 `02` F4:他们那边不需要做任何事)。 + * - **手打那条只声明第一条**(`index == 0`):一次发送切出来的第 2 条起若也声明,同样是在打断 + * 自己刚起的上一轮。声明的是「这一次投递要怎样对待正在进行的回合」,一次发送只该有**一个** + * 这样的意思。 + * + * 于是答案只有 `false`(分享,恒成立)与「只有第 0 条成立」(手打)两种形态。 + * + * ## 为什么提成一个纯函数 + * 这条边界原先散在 `ChatViewModel` 里(`applySendPreference && index == 0`,分享那条入口另传 + * `false`):两处各是半个判据、**一处都单测不到**,而「分享多图绝不互相打断」正是 W76 要一直 + * 成立的那条边界(见 `05` 的多图自相打断一节)。合并成一处判据之后,入口与投递口用的必须是 + * 同一条——与 `hasSendableContent`(`ui/chat/SendQueue.kt`)的情形完全一样。口径也与 + * [truncatedImagesNotice] 一致:两条入口共用的规则,只有一个出处。 + * + * @param fromShare 这条(这一批)是不是分享进来的 + * @param index 在这次发送切出的消息里的位置(0 = 第一条) + */ + fun declaresSendPreference(fromShare: Boolean, index: Int): Boolean = !fromShare && index == 0 + /** * 从分享 Intent 的原始字段提取内容。 * diff --git a/app/src/test/kotlin/top/yeij/cyrene/domain/share/ShareSendDeclarationTest.kt b/app/src/test/kotlin/top/yeij/cyrene/domain/share/ShareSendDeclarationTest.kt new file mode 100644 index 0000000..9ce91a0 --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/domain/share/ShareSendDeclarationTest.kt @@ -0,0 +1,58 @@ +package top.yeij.cyrene.domain.share + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * 批量投递的「声明」边界([ShareRules.declaresSendPreference])。 + * + * 钉住的是一件**不许再走回去**的事:分享进来的 N 张图会落成 N 条消息(契约里 `chat.image` 是单图 + * 字段),而这 N 条是**一次**投递动作——它们之间绝不许互相打断 / 互相取消(`02` F4 的后端口径、 + * `05` 的多图自相打断一节)。一旦某一条在忙时声明了 `interrupt`,第 2 条就会把第 1 条刚起的回合打 + * 掉;声明 `parallel` 同样把「一次倒进去」变成「几条并行回合」。 + * + * 纯函数 ⇒ 不需要组合界面、不需要设备,就能把**整批**(0 到 [ShareRules.MAX_IMAGES] - 1)逐条穷举。 + */ +class ShareSendDeclarationTest { + + @Test + fun `share never declares on any message of the batch`() { + (0 until ShareRules.MAX_IMAGES).forEach { index -> + assertFalse( + "分享的第 $index 条不许声明(声明就会打断同一批里的上一条)", + ShareRules.declaresSendPreference(fromShare = true, index = index), + ) + } + } + + @Test + fun `share stays silent even past the image limit`() { + // 超限那张本来就不发(见 truncatedImagesNotice),但判据对它也不许翻成 true: + // 一旦有人把上限放宽而忘了这条,越界位置不该成为唯一声明的那一条。 + (ShareRules.MAX_IMAGES..ShareRules.MAX_IMAGES + 3).forEach { index -> + assertFalse(ShareRules.declaresSendPreference(fromShare = true, index = index)) + } + } + + @Test + fun `the typed send declares on its first message only`() { + assertTrue( + "手打的第一条仍按「她忙时发消息」偏好投递", + ShareRules.declaresSendPreference(fromShare = false, index = 0), + ) + (1 until ShareRules.MAX_IMAGES).forEach { index -> + assertFalse( + "手打的第 $index 条声明就是在打断自己刚起的上一轮", + ShareRules.declaresSendPreference(fromShare = false, index = index), + ) + } + } + + @Test + fun `a single message batch is the same rule`() { + // 只有一条时两种入口的差别正是全部差别:手打声明、分享不声明。 + assertTrue(ShareRules.declaresSendPreference(fromShare = false, index = 0)) + assertFalse(ShareRules.declaresSendPreference(fromShare = true, index = 0)) + } +} diff --git a/devtools/fake-backend/README.md b/devtools/fake-backend/README.md index ee6e6ba..3730cca 100644 --- a/devtools/fake-backend/README.md +++ b/devtools/fake-backend/README.md @@ -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 字符的字符串换成 + `""`,非 JSON 的体记成 `{bytes, note}`。这样「发了几条、每条带不带图、有没有 `parallel`」 + 读得出来,而图片的 base64 不会把一行日志撑到几 MB。`body` 只在有请求体时出现。 挂起的上行消息(`--hold-replies`)**不进文件** —— 它只活在内存里,看 `GET /__control/state` 的 `heldMessages`。`out/*.jsonl` 仍照常记「上行帧收到了什么 / 下发帧广播了什么」。 diff --git a/devtools/fake-backend/fake_backend.py b/devtools/fake-backend/fake_backend.py index e6c63ac..d49b02e 100644 --- a/devtools/fake-backend/fake_backend.py +++ b/devtools/fake-backend/fake_backend.py @@ -281,6 +281,37 @@ def redact(value, keep: int = 8) -> str: digest = hashlib.sha256(str(value).encode("utf-8")).hexdigest()[:keep] return "".format(digest) +# 请求体摘要里「短到什么程度就原样留着」的那条线。它要装得下正文与显示名,装不下 base64。 +BODY_SUMMARY_STRING_MAX = 200 + +def summarize_body(raw) -> dict | None: + """把请求体记成一份「看得见形状、看不见内容」的摘要。 + + `POST /api/v1/chat` 的正文、图片、附件全在 body 里,而图片是一整串 base64:原样写进 + `out/http.jsonl` 会把一行记录撑到几 MB,也读不下去。所以结构照留(键、布尔、短字符串、 + 数组长度),只把超长字符串换成 ``。 + + 这样「这一条带没带 `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) From b35b1b53ab9d649057a962fa976b17179c84d132 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 26 Oct 2026 23:27:04 +0800 Subject: [PATCH 2/2] =?UTF-8?q?W76=EF=BC=9A=E4=BF=AE=E5=81=87=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=20keep-alive=20=E4=B8=8B=E8=AF=B7=E6=B1=82=E4=BD=93?= =?UTF-8?q?=E7=BC=93=E5=AD=98=E8=B7=A8=E8=AF=B7=E6=B1=82=E4=B8=B2=E7=94=A8?= =?UTF-8?q?=EF=BC=88=E8=AE=BE=E5=A4=87=E5=AE=9E=E6=B5=8B=E3=80=8C3=20?= =?UTF-8?q?=E5=BC=A0=E5=9B=BE=E5=8F=AA=E6=8A=95=E5=87=BA=202=20=E5=BC=A0?= =?UTF-8?q?=E3=80=8D=E7=9A=84=E7=9C=9F=E5=87=B6=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `protocol_version = "HTTP/1.1"` ⇒ 同一条 keep-alive 连接上的多个请求由**同一个 Handler 实例**依次处理(`handle_one_request` 循环),而上一轮给 `_read_body` 加的缓存 (`_body_read` / `_body_cache`)从不在请求之间重置。后果两条: - 第 2 个请求拿到第 1 个请求的 body ⇒ `out/http.jsonl` 里的摘要记成上一份; - 第 2 个请求自己那份 body **没人从 `rfile` 读走**,残留字节留在内核缓冲里,被下一个 请求的请求行一并吃掉 ⇒ `BaseHTTPRequestHandler` 回 `501 Unsupported method ('{"sessionId":...}POST')`。 W76 设备实测里这表现成「系统分享 3 张图,只投出 2 条消息」,很容易被误判成 App 批量 投递的缺陷。修法是在 `handle_one_request` 入口把缓存清掉,按请求隔离。 验证: 1. 重启假后端后,在同一条 keep-alive 连接上连发 3 个**不同 body** 的 `POST /api/v1/chat`,三发全 202,`out/http.jsonl` 记下三个不同 body(同一 client port)。 2. 完整设备重放(DocumentsUI 选 3 张图 → 分享 → Cyrene → 发送)三发三 202, 3 条独立消息落库。 --- devtools/fake-backend/fake_backend.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/devtools/fake-backend/fake_backend.py b/devtools/fake-backend/fake_backend.py index d49b02e..8677000 100644 --- a/devtools/fake-backend/fake_backend.py +++ b/devtools/fake-backend/fake_backend.py @@ -319,11 +319,17 @@ class JsonlLog: def __init__(self, path: str): self.path = path self.lock = threading.Lock() - os.makedirs(os.path.dirname(path), exist_ok=True) + self.dir = os.path.dirname(path) + os.makedirs(self.dir, exist_ok=True) def append(self, record: dict) -> None: line = json.dumps(record, ensure_ascii=False) with self.lock: + # 目录**每次写都补一次**:README 说「删掉 `out/` 即清空」,而人在服务跑着的时候删掉 + # 它就再也不会自己回来——之后每一条请求都会在写日志时抛 FileNotFoundError, + # 表现成「假后端活着但什么都不回」(W76 实测踩到)。重建目录只是一次 stat, + # 代价可以忽略;让它自己长回来,比要求人先停服务再删要可靠。 + os.makedirs(self.dir, exist_ok=True) with open(self.path, "a", encoding="utf-8", newline="\n") as handle: handle.write(line + "\n") @@ -1084,6 +1090,20 @@ class Handler(BaseHTTPRequestHandler): query = parse_qs(parts.query, keep_blank_values=True) return parts.path, query + def handle_one_request(self): + """每个请求进来时**先把 body 缓存清掉**。 + + `protocol_version` 是 HTTP/1.1,同一条 keep-alive 连接上的多个请求由**同一个 Handler 实例** + 依次处理(`handle_one_request` 循环),所以 `_read_body` 的缓存必须按请求隔离。少了这一步, + 第 2 个请求会拿到第 1 个请求的 body(摘要记成上一份),而它自己那份**根本没人从 `rfile` 读走** + ——残留字节留在内核缓冲里,被下一个请求的请求行一并吃掉,`BaseHTTPRequestHandler` 于是回 + `501 Unsupported method ('{"sessionId":...}POST')`。客户端表现成「3 张图只投出去 2 张」, + 而真正的锅在这里。 + """ + self._body_read = False + self._body_cache = b"" + super().handle_one_request() + def _read_body(self): """读出请求体,**并缓存**:`_handle` 记录摘要时要读它,各 handler 里的 `_parse_json_body` 随后再读——不缓存的话第二次只会读到空串(body 只能读一次)。"""