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), + '失败后不重试、不回滚 ⇒ 依赖「重启时内存全丢」兜底,而淘汰会提前破坏这个前提'); +});