From 35557d4f8d95041621d9a4be4b745157e06442f4 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 28 Sep 2026 09:57:41 +0800 Subject: [PATCH] =?UTF-8?q?test(pi=E6=A1=A5):=20=E8=A1=A5=E4=B8=A4?= =?UTF-8?q?=E6=A0=BC=E5=88=A4=E6=8D=AE=20=E2=80=94=E2=80=94=20BoundedSet?= =?UTF-8?q?=20=E6=B7=98=E6=B1=B0=E4=BD=BF=E3=80=8C=E9=87=8D=E5=90=AF?= =?UTF-8?q?=E6=89=8D=E4=B8=A2=E3=80=8D=E7=9A=84=E5=89=8D=E6=8F=90=E7=AB=99?= =?UTF-8?q?=E4=B8=8D=E4=BD=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `markDelivered` 的注释写「标不上不该让投递失败……代价只是下次重启可能再投 一次」。opencode 独立复核指出这句话的前提站不住,我认这个判断。 「下次重启」把正确性押在「重启 ⇒ 内存全丢 ⇒ 从库里重来」上。但 `deliveredMails` 是 `BoundedSet(MAX_TRACKED_MAILS=2000)`,**运行期就主动淘汰**, 不需要重启。于是有一条更窄的重投路径: 投递 X ⇒ add(X) → POST /mail/read 失败(库里仍是 unread) → X 超过 2000 被淘汰(内存忘了,库没忘) → catchUp 按 unread 捞回 X,内存 has() 挡不住 ⇒ 重投 正是 2026-09-26 那个症状本身(重投回声),只是窗口窄得多。 两格分别锁住前提与后果,都不靠注释断言: ⑤ `deliveredMails 确实是有界的` —— 从 index.mjs 取上限常量名,回 bounded.js 取其值并断言有限。有限 ⇒ 运行期会淘汰 ⇒ 「靠重启才丢」不成立。 ⑥ `淘汰只丢内存、不回写库` —— 覆盖 BoundedSet.add 的整个淘汰循环, 断言其中无 /mail/read|markDelivered|post|status|client;并对照 markDelivered 的失败分支只打日志、不重试不回滚。 将来若让淘汰也落库,这格会红,提醒改的是注释而不是加静音。 变异自测(三个变异各被对应格抓住,非自说自话): 改成无界 `new Set()` → ⑤ 红 淘汰路径里回写库 → ⑥ 红 markDelivered 失败后 setTimeout 重试 → ⑥ 红 判据自带的两个坑留在注释里(第一版「怎么变异都不判红」的原因): 必须锚在 BoundedSet 的 add 上,否则裸 /add\(value\)/ 会先命中文件前面 BoundedMap 的同形 add;淘汰循环要带尾巴({0,320}? 惰性量词会在第一个终点 就停),否则紧跟其后的落库代码永远落在窗口之外,结构上不可能判红。 全量 526 格通过。未修 —— 本提交只把已知窗口钉成可判红的判据, 不动 `markDelivered` 的行为(改行为是另一次决定,且应先决定淘汰时 要不要落库)。 Co-Authored-By: pi --- .../test/delivery-marks-read.test.mjs | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/plugins/pi-mail-bridge/test/delivery-marks-read.test.mjs b/plugins/pi-mail-bridge/test/delivery-marks-read.test.mjs index 489d2c2..0d47234 100644 --- a/plugins/pi-mail-bridge/test/delivery-marks-read.test.mjs +++ b/plugins/pi-mail-bridge/test/delivery-marks-read.test.mjs @@ -22,6 +22,8 @@ * 就是一条绕过标已读的重投路径(这正是缺陷的形状) * ② markDelivered 必须真的 POST /mail/read * ③ 三个投递点(SSE / 补投 / 决策回执)都走它 + * ④ 标已读失败时"再投一次"的说法只有在**内存集合一起丢**时成立 + * (见下面「淘汰只丢内存不丢库」那条) */ import { test } from 'node:test'; import assert from 'node:assert/strict'; @@ -34,6 +36,11 @@ const src = readFileSync( 'utf8', ); +const boundedSrc = readFileSync( + join(dirname(fileURLToPath(import.meta.url)), '..', 'lib', 'bounded.js'), + 'utf8', +); + test('★ deliveredMails.add 只允许出现在 markDelivered 内部', () => { /* 这一条是整组判据的核心。 @@ -97,3 +104,61 @@ test('判据自检:反例(别处 add、不标已读)必须判红', () => { assert.ok(/deliveredMails\.add\(/.test(before), '反例确实会 add'); assert.ok(!/mail\/read/.test(before), '反例确实不标已读 ⇒ 会判红'); }); + +/** + * ★ markDelivered 注释里「失败不阻塞」的前提,**只在内存集合也一起丢时成立**。 + * + * 注释原文(index.mjs:143 起): + * 「标不上不该让投递失败(正文已经在 worker 手里,代价只是下次重启可能再投一次, + * 与旧行为一致、不更差)」 + * + * 「下次重启」这四个字把正确性押在"重启 ⇒ 内存全丢 ⇒ 从库里重来"上。 + * 但 `deliveredMails` 是**有界**集合(`BoundedSet`,MAX_TRACKED_MAILS=2000), + * **运行期就会主动淘汰**。于是存在一条不需要重启的路径: + * + * 1. 投递 mail_X ⇒ `deliveredMails.add(X)`(内存记下) + * 2. POST /mail/read **失败** ⇒ 库里 status 仍是 unread + * 3. 之后 X 因超过 2000 被 BoundedSet **淘汰**(内存忘了,库没忘) + * 4. `catchUp` 按 status=unread 捞回 X ⇒ 内存 `has()` 挡不住(已淘汰)⇒ **重投** + * + * 结果就是 2026-09-26 那个症状本身(重投回声),只是窗口窄得多。 + * + * 下面两条分别锁住:淘汰是真实的(不靠注释断言),以及淘汰只丢内存不丢库。 + */ +test('deliveredMails 确实是有界的(运行期会主动淘汰)', () => { + const i = src.indexOf('const deliveredMails = new BoundedSet('); + assert.ok(i > 0, 'deliveredMails 必须是 BoundedSet —— 无界就谈不上淘汰'); + const m = src.slice(i).match(/new BoundedSet\((\w+)\)/); + assert.ok(m, '取得到上限常量名'); + + const cap = boundedSrc.match(new RegExp(`export const ${m[1]} = (\\d+)`)); + assert.ok(cap, `bounded.js 里导出了 ${m[1]}`); + // 有限 ⇒ 运行期会淘汰 ⇒ 「下次重启才丢」这个前提站不住 + assert.ok(Number(cap[1]) < Infinity, `${m[1]} 是有限值(当前 ${cap[1]})⇒ 不是靠重启才丢`); +}); + +test('淘汰只丢内存、不回写库 ⇒ 标已读失败 + 淘汰 会重投(已知窗口)', () => { + // BoundedSet 淘汰路径里没有 /mail/read —— 也就是说淘汰**不会**把 + // 被淘汰的那封补标成已读。这条断言的作用是把上面第 2、3 步钉死: + // 若将来有人让淘汰也落库,这个窗口就自动关上了,届时这条应当判红提醒改注释。 + // ★ 两个坑叠在一起才导致第一版「怎么变异都不判红」,留注释免得再犯: + // ① 必须锚在 BoundedSet 的 add 上:裸 /add\(value\)/ 会先命中文件前面 + // BoundedMap 的同形 add,非贪婪匹配选错了类; + // ② 必须覆盖**整个淘汰循环**、且用捕获组取到结尾:`/this\.evicted\+\+/` + // 本身就匹配全了(没有 [^]* 尾巴),{0,320}? 惰性量词在第一个终点就停, + // 于是紧跟其后的落库代码**永远落在窗口之外**,断言结构上不可能判红。 + const setClass = boundedSrc.slice(boundedSrc.indexOf('export class BoundedSet')); + assert.ok(setClass.length > 0, '找得到 BoundedSet 类'); + // 抓整个 while 淘汰循环:从 while 到 return this + const evictLoop = setClass.match(/while \(this\.set\.size > this\.limit\) \{[\s\S]*?\n {4}\}/); + assert.ok(evictLoop, 'BoundedSet.add 里有淘汰循环(说明淘汰真会发生)'); + assert.ok(!/\/mail\/read|markDelivered|post\(|status|client/.test(evictLoop[0]), + '淘汰循环只丢内存、不写库 ⇒ 「post 失败 + 淘汰」= 重投。本测试锁住这个窗口;' + + '若将来让淘汰也落库,这条会判红,届时应改的是 markDelivered 的注释。'); + + // 对照:markDelivered 里的失败分支只打日志,不重试也不回滚。 + const fn = src.slice(src.indexOf('function markDelivered('), src.indexOf('\n}', src.indexOf('function markDelivered('))); + assert.match(fn, /\.catch\(/, 'post 失败被吞掉(这是「不更差」的前提来源)'); + assert.ok(!/retr(y|ies)|setTimeout|unmark/.test(fn), + '失败后不重试、不回滚 ⇒ 依赖「重启时内存全丢」兜底,而淘汰会提前破坏这个前提'); +});