From 5bc579f910e22ad0dabae4d5776373eef813cc49 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 14 Sep 2026 19:38:31 +0800 Subject: [PATCH] =?UTF-8?q?fix(pi-bridge):=20=E6=8C=89=E8=AF=84=E5=AE=A1?= =?UTF-8?q?=E8=A1=A5=E4=B8=89=E5=A4=84=20=E2=80=94=E2=80=94=20ENOSPC=20?= =?UTF-8?q?=E5=8F=AA=E7=9B=96=E4=BA=86=E4=B8=80=E4=B8=AA=E5=86=99=E7=82=B9?= =?UTF-8?q?=E3=80=81=E6=97=A7=E6=B3=A8=E9=87=8A=E8=87=AA=E7=9B=B8=E7=9F=9B?= =?UTF-8?q?=E7=9B=BE=E3=80=81=E5=85=9C=E5=BA=95=E5=88=A4=E6=8D=AE=E9=92=89?= =?UTF-8?q?=E7=9A=84=E6=98=AF=E6=96=87=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pi 逐字读了上一版落地的代码,报了三个"还差一格"。都不是推翻,是同一根因 ("环境不足伪装成别的")在这套守卫自己身上的残留。 ## 一、翻译只覆盖了 5 个写点里的 1 个(最实质) `selfCheck()` 要在临时目录造两棵样本树,写点有**五处**;上一版只把 `mk()` 里那两处 包了 try/catch,后面三处(`README.md` / `extra.mjs` / `test/t.mjs`)裸写。它们撞上 ENOSPC 时异常冒到 `main()` 的 catch:**退出码是对的(2),但打印的是原始英文 `ENOSPC: no space left on device, write` 加一段指向本文件的堆栈** —— 也就是上一版 要治的那个信号("看起来像检查器坏了")**恰恰在最需要它的路径上还在**。 改法:抽一个 `describeEnvError(e, what)`,在 `main()` 的 catch 里**统一**换成人话。 一处覆盖全部写点,以后再加写点也不用管。`mk()` 里那段裸判断一并换成调用它。 ## 二、`lib/tmp-space.mjs` 的头注释在说谎(读者已误读一次) 原文写"`availBytes` 为 `null`(读不到 / 平台不支持 / **字段为 0**)" —— 而"字段为 0" 指的其实是 `statfs.bsize === 0`(测量层确实 `if (!s.bsize) return null`),读起来 却像是在说"可用 0 字节也算不知道" —— **正是我上一版刚踩、刚补判据的那个坑**。 pi 第一遍读就误读成了后者。已把两个 case 分开写死,并注明"这条注释写错过一次"。 ## 三、兜底判据钉的是文本,不是机制 `env-guard.test.mjs` 原来对 `session-scan.test.mjs` 断言 /ENOSPC/ 与 /环境/, 而那段**解释性注释里本来就有这两个词** ⇒ 删掉整段翻译逻辑、只留注释,判据照样绿。 这正是 `permission-note.test.mjs` 自己警告过的"钉装饰不钉机制"。 改法(按仓库规矩,纯函数 + 反面样本 + 接线): - 翻译逻辑提到 `lib/env-error.mjs` 的 `translateEnvError`(纯函数); - 判据喂构造出来的错误验**行为**:ENOSPC 必须翻译且带药方、普通错误必须**原样返回 同一个对象**("什么都翻译"比不翻译更坏 —— 真缺陷会被套上环境的外衣); - `writeSession` 抽出 `write` 参数(**只为测试存在**,`pool.mjs` 的 `workerPath` 同一手法), 于是"接线还在不在"是**行为**判据:喂一个必然 ENOSPC 的假写,翻译必须发生。 抽它的理由写在注释里 —— 是"可被反面样本喂",不是复用(只有一个调用点)。 - 变异自检:删掉写点的翻译 ⇒ 第 28、29 两条立刻红(已实测)。 ## 四、顺带三处小的一致性问题 - 端到端那条判据原靠"本机 /tmp 恰好是满的"来验 —— 那是把判据绑在**会变的环境**上, /tmp 一清空就自动跳过、无声失效。前置脚本加两个**只为测试存在**的开关: `--measure=`(只量并打印 JSON)与 `--inject-avail=`(绕过测量直接判定), 于是"不足⇒exit 2"与"充足⇒放行"在任何机器上都验得了(两个方向都验,缺一即假绿)。 - 判据 ⑥ 原先只有它自己带圈号前缀,读者会去找不存在的第 ⑤ 条。改成 `checkLayout` 的每条都带**连续 id**(1..N),`name` 是纯展示串,并加一条"id 不许跳号"的自检。 - 两个实测数(`729_088` 字节 = 0.70 MiB、`712` 字节)是**不同时刻**量的,并列摆着像抄错, 各标了来历;`lib/tmp-space.mjs` 里那条改用"一度真是 0"的说法。 验证:`npm test` **474/474**(上一版 453);`--self-check` **18 条全过**(新增 id 连续); `npm test` 在临时目录不足时仍 exit 2 且一条用例都不跑。 --- deploy/check-deploy-drift.mjs | 91 +++++++---- plugins/pi-mail-bridge/lib/env-error.mjs | 40 +++++ plugins/pi-mail-bridge/lib/tmp-space.mjs | 46 +++++- .../pi-mail-bridge/test/env-guard.test.mjs | 141 ++++++++++++++---- plugins/pi-mail-bridge/test/env-preflight.mjs | 41 +++-- .../pi-mail-bridge/test/session-scan.test.mjs | 31 ++-- 6 files changed, 291 insertions(+), 99 deletions(-) create mode 100644 plugins/pi-mail-bridge/lib/env-error.mjs diff --git a/deploy/check-deploy-drift.mjs b/deploy/check-deploy-drift.mjs index 861ece5..b70b6a6 100644 --- a/deploy/check-deploy-drift.mjs +++ b/deploy/check-deploy-drift.mjs @@ -327,31 +327,57 @@ function checkHost(spec) { return result; } +/** + * 把「临时目录写不进去」翻译成人看得懂的话。 + * + * ★ 为什么要有这个函数(不是为了好看,是为了**覆盖全部写点**): + * + * `selfCheck()` 里要在临时目录造两棵小树,写点有**五处**(`mk()` 里两处、 + * 后面 `README.md`/`extra.mjs`/`test/t.mjs` 三处)。第一版只在 `mk()` 里包了 + * try/catch —— 于是后面那几处撞上 ENOSPC 时,异常冒到 `main()` 的 catch, + * 退出码是对的(2),但打印的是**原始英文 `ENOSPC: no space left on device, write` + * 加一段指向本文件的堆栈** —— 也就是"看起来像检查器坏了"这个信号,**恰恰在 + * 最需要它的那些路径上还在**。集中在这里换一次,以后再加写点也不用管。 + */ +function describeEnvError(e, what) { + const isEnospc = e && (e.code === 'ENOSPC' || /no space left on device/i.test(String(e.message))); + if (!isEnospc) return e; + const err = new Error( + `环境不足:临时目录 ${tmpdir()} 写不进去(ENOSPC)—— ${what}。` + + '这是环境问题,不是检查器的问题。药方:TMPDIR=<有空间的目录> 后再跑。' + ); + err.code = 'ENOSPC'; + err.cause = e; + return err; +} + /** 判据自检:**先证明这个检查器能发现差异**,再用它下结论。 * 一个永远说「一致」的比较器看起来同样令人放心。 */ export function selfCheck() { - const a = mkdtempSync(join(tmpdir(), 'drift-a-')); - const b = mkdtempSync(join(tmpdir(), 'drift-b-')); + // ★ `mkdtempSync` 也在 try 里 —— 它本身就是一个写点:临时目录满的时候 + // (2026-09-14 实测 `bavail` 一度真是 0)它会抛 ENOSPC。上一版它在 try 之外, + // 于是那条路径照样崩出一段指向本文件的堆栈(退出码还不是 2,是未捕获异常)。 + let a; + let b; + try { + a = mkdtempSync(join(tmpdir(), 'drift-a-')); + b = mkdtempSync(join(tmpdir(), 'drift-b-')); + } catch (e) { + throw describeEnvError(e, '判据自检要在临时目录里建两棵样本树'); + } const mk = (root, content) => { mkdirSync(join(root, 'lib'), { recursive: true }); - // ★ 自检要在临时目录里造两棵小树。临时目录满了(2026-09-14:`/tmp` 是 tmpfs, - // `bavail` 只剩 0.70 MiB)时这里会抛 ENOSPC —— 而它是一个**未捕获的异常**, - // 堆栈指向本文件的 `mk()`,看起来像检查器自己坏了。真相是环境不足。 - // 翻译成说得清的错,交给 main() 报 2(环境问题)而不是崩栈。 + // ★ 自检要在临时目录里造两棵小树。临时目录满了时这里会抛 ENOSPC —— + // 而它是一个**未捕获的异常**,堆栈指向本文件的 `mk()`,看起来像检查器自己坏了。 + // 真相是环境不足。翻译成说得清的错,交给 main() 报 2(环境问题)而不是崩栈。 try { writeFileSync(join(root, 'lib', 'x.mjs'), content); writeFileSync(join(root, 'README.md'), 'doc'); } catch (e) { - if (e && (e.code === 'ENOSPC' || /no space left on device/i.test(String(e.message)))) { - const err = new Error( - `环境不足:临时目录 ${tmpdir()} 写不进去(ENOSPC)—— 判据自检要在里面造两棵小树。` + - '这是环境问题,不是检查器的问题。药方:TMPDIR=<有空间的目录> 后再跑。' - ); - err.code = 'ENOSPC'; - err.cause = e; - throw err; - } - throw e; + // 统一交给 describeEnvError 翻译(见上方):这里只负责 `mk()` 自己能看到的两处写, + // 后面还有三处写在别处(README.md / extra.mjs / test/t.mjs),由 main() 的 + // catch 统一兜住 —— 那是"一处覆盖全部写点"的那一层。 + throw describeEnvError(e, '判据自检要在临时目录里造样本树'); } }; const out = []; @@ -444,7 +470,11 @@ export function checkLayout(inject = {}) { const exists = inject.exists ?? existsSync; const stat = inject.stat ?? statSync; const out = []; - const push = (name, ok, note = '') => out.push({ name, ok, note }); + // 每条检查带一个**显式 id**。原先只有第 ⑥ 条自带编号前缀,于是读者会去找 + // 一个不存在的第 ⑤ 条 —— 编号要么全有、要么全无,没有第三条路。 + // 现在编号只出现在 id 里,`name` 是纯人名的展示串。 + let checkSeq = 0; + const push = (name, ok, note = '') => out.push({ id: String(++checkSeq), name, ok, note }); const SYS = '/etc/systemd/system'; const REPO = '/home/program/agentmail'; @@ -543,11 +573,11 @@ export function checkLayout(inject = {}) { } catch { return null; } // 不是 git 仓库/没有 git:不判 })(); if (gitOut === null) { - push('⑥ 工作区干净(仅提示,不影响结论)', true, '读不到 git 状态,不据此判定'); + push('工作区干净(仅提示,不影响结论)', true, '读不到 git 状态,不据此判定'); } else { const dirty = gitOut.split('\n').map(l => l.trim()).filter(Boolean); push( - '⑥ 工作区干净(仅提示,不影响结论)', + '工作区干净(仅提示,不影响结论)', true, dirty.length === 0 ? '干净 —— 快照就是 HEAD 的内容' @@ -599,14 +629,17 @@ export function layoutSelfCheck() { '/repo/systemd': [], '/opt/agentmail/bin/service-failure-notify.mjs': 'x' }), git: () => { throw new Error('not a git repo'); } }); - const sixth = o => o.find(c => c.name.startsWith('⑥')); + const sixth = o => o.find(c => c.name.startsWith('工作区干净')); return [ { name: '标准目录:引用源码目录的样本必须判红', ok: bad[0].ok === false }, { name: '标准目录:干净样本必须判绿', ok: good[0].ok === true }, - // ⑥ 是"说出来但不改结论"的提示:脏/干净/读不到三种都被报出来,且**都不判红**。 - { name: '⑥:工作区脏 → 说出来(且不判红)', ok: sixth(dirty)?.note.includes('脏 1 处') === true && sixth(dirty)?.ok === true }, - { name: '⑥:工作区干净 → 明说干净', ok: sixth(clean)?.note.includes('干净') === true }, - { name: '⑥:读不到 git → 不据此判定', ok: sixth(unreadable)?.note.includes('不据此判定') === true } + // 编号:id 从 1 连续排到 N,不许跳号(原先只有 ⑥ 带编号,读者会去找不存在的 ⑤)。 + { name: '标准目录:id 连续编号,不跳号', ok: good.every((c, i) => c.id === String(i + 1)) }, + // 「工作区干净」是"说出来但不改结论"的提示:脏 / 干净 / 读不到三种都被报出来, + // 且**都不判红**(做成红灯就是一条总在亮的判据,本文件头骂过这个病)。 + { name: '工作区干净:脏 → 说出来(且不判红)', ok: sixth(dirty)?.note.includes('脏 1 处') === true && sixth(dirty)?.ok === true }, + { name: '工作区干净:干净 → 明说干净', ok: sixth(clean)?.note.includes('干净') === true }, + { name: '工作区干净:读不到 git → 不据此判定', ok: sixth(unreadable)?.note.includes('不据此判定') === true } ]; } @@ -621,8 +654,12 @@ function main() { try { checks = selfCheck().concat(layoutSelfCheck()); } catch (e) { - if (e && e.code === 'ENOSPC') { - console.error(`\n${e.message}\n`); + // ★ 覆盖 **全部** 写点:上面 `mk()` 自己会翻译,但后面还有三处裸写 + // (README.md / extra.mjs / test/t.mjs)。第一版只包了 `mk()`,那三处 + // 撞上 ENOSPC 会打出原始英文 + 本文件堆栈 —— 退出码对、信号错。 + const translated = describeEnvError(e, '判据自检要在临时目录里造样本树'); + if (translated.code === 'ENOSPC') { + console.error(`\n${translated.message}\n`); process.exit(2); } throw e; @@ -652,7 +689,7 @@ function main() { for (const c of r.checks) console.log(` ${c.ok ? '通过' : '失败'} ${c.name}${c.note ? ' — ' + c.note : ''}`); } console.log('\n 标准目录部署:'); - for (const c of layout) console.log(` ${c.ok ? '通过' : '失败'} ${c.name}${c.note ? ' — ' + c.note : ''}`); + for (const c of layout) console.log(` ${c.ok ? '通过' : '失败'} ${c.id} ${c.name}${c.note ? ' — ' + c.note : ''}`); const stale = results.filter(r => r.stale); console.log(`\n 结论:${stale.length === 0 ? '四个宿主都在跑当前代码' : `${stale.length} 个宿主需要重新部署/重启`}`); for (const r of stale) console.log(` ✗ ${r.host}:bash deploy/redeploy-plugin.sh ${r.host}`); diff --git a/plugins/pi-mail-bridge/lib/env-error.mjs b/plugins/pi-mail-bridge/lib/env-error.mjs new file mode 100644 index 0000000..3753e70 --- /dev/null +++ b/plugins/pi-mail-bridge/lib/env-error.mjs @@ -0,0 +1,40 @@ +/** + * 「临时目录写不进去」⇒ 人话。**纯函数,可被反面样本喂**。 + * + * # 为什么它必须是一个函数,而不是散在写点里的 try/catch + * + * 2026-09-14 实测:`/tmp` 是满的 tmpfs,`bavail` 一度真是 **0**。此时这条 + * `npm test` 红的是 + * + * not ok 323 - ★巨大的 message 行不进内存也不影响解析 + * error: 'ENOSPC: no space left on device, write' + * + * 那条红的**形状指向内存**(用例名里就写着"不进内存",而它恰好是往临时目录 + * 写文件的用例)⇒ 下一个踩到的人会去 `session-scan.mjs` 找一个**不存在**的 + * 内存缺陷。翻译成人话("这是环境问题,不是内存缺陷")就治这个。 + * + * # 为什么抽出来(只有一个调用点也值得抽) + * + * **不是为了复用,是为了可被反面样本喂**:`translateEnvError` 能被直接喂一个 + * 构造出来的 ENOSPC 错误,验"该翻译的翻译了、不该翻译的原样返回"。 + * 反例:第一版把这个判断留在 `session-scan.test.mjs` 里,判据只能靠**读源码文本** + * (断言文件里出现 `/ENOSPC/`)—— 而那段解释性注释里本来就有 "ENOSPC" 这个词, + * 于是**删掉整个翻译逻辑、只留注释,判据照样绿**。这正是 + * `permission-note.test.mjs` 警告过的"钉装饰不钉机制"。 + * + * @param {unknown} e 捕获到的错误 + * @returns {{ translated: boolean, error: Error }} 翻译过的新错误,或原样返回 + */ +export function translateEnvError(e) { + const message = e && typeof e.message === 'string' ? e.message : String(e ?? ''); + const isEnospc = (e && e.code === 'ENOSPC') || /no space left on device/i.test(message); + if (!isEnospc) return { translated: false, error: e }; + + const err = new Error( + '环境不足:临时目录写不进去(ENOSPC)—— 这是环境问题,不是内存缺陷。' + + '药方:TMPDIR=<有空间的目录> npm test' + ); + err.code = 'ENOSPC'; + err.cause = e; + return { translated: true, error: err }; +} diff --git a/plugins/pi-mail-bridge/lib/tmp-space.mjs b/plugins/pi-mail-bridge/lib/tmp-space.mjs index aecaa38..190b095 100644 --- a/plugins/pi-mail-bridge/lib/tmp-space.mjs +++ b/plugins/pi-mail-bridge/lib/tmp-space.mjs @@ -13,15 +13,22 @@ * * # 判据的形状(与仓库既有约定一致) * - * - 这里只有**纯函数**:喂进"还剩多少字节",吐出"够不够"。测量在调用方 - * (`test/env-preflight.mjs`),因为 `statfsSync` 碰的是真实文件系统, + * - 这里只有**纯函数**:喂进"还剩多少字节",吐出"够不够"。测量在 + * `measureAvailBytes()`(同文件导出,因为它要能被喂"不存在的目录"做反面样本), * 纯函数才可被反面样本喂。 - * - **读不到不判红**:`availBytes` 为 `null`(读不到 / 平台不支持 / 字段为 0) - * 时返回 `ok: true` 并注明"不据此判定"。**不知道 ≠ 不对** —— 不确定就放行, - * 否则会在不认识的文件系统上制造一条总在亮的红灯,而"总在亮的红灯" - * 会被人学会忽略(`deploy/check-deploy-drift.mjs` 文件头骂过这个病)。 + * - **读不到不判红**:`availBytes` 为 `null`(`statfsSync` 抛错 / 平台不支持 / + * `statfs.bsize` 为 0 ⇒ 测量层返回 `null`)时返回 `ok: true` 并注明"不据此判定"。 + * **不知道 ≠ 不对** —— 不确定就放行,否则会在不认识的文件系统上制造一条总在亮的 + * 红灯,而"总在亮的红灯"会被人学会忽略(`deploy/check-deploy-drift.mjs` 文件头骂过)。 + * - ★ 但**可用字节数为 0 不是"没测到"**,是"真的没有":它必须判红。第一版把 + * `<= 0` 一并当"不知道",于是 `bavail` 只剩 712 字节时前置自检放行、紧接着 + * 17 条用例 ENOSPC 全红 —— 守卫装在最该拦的时候放行,等于没装。 + * (2026-09-14 踩的;这条注释写错过一次,pi 读第一遍就误读成了"0 也算不知道", + * 所以这里把两个 case 分开写死。) */ +import { statfsSync } from 'node:fs'; + /** 单条用例的最大临时写入量(实测):`test/session-scan.test.mjs` 那条用例 * 写 3 条 3 MiB 的行(两条巨行 + 一条正常行)⇒ 约 12 MiB。 */ export const MEASURED_MAX_CASE_WRITE = 12 * 1024 * 1024; @@ -38,9 +45,34 @@ export const MIN_FREE_BYTES = MEASURED_MAX_CASE_WRITE * 2 + 8 * 1024 * 1024; const mib = (n) => `${(n / 1048576).toFixed(1)} MiB`; +/** + * 测量:进程**实际能写**的字节数。读不到返回 `null`。 + * + * 放在这里(而不是 `test/env-preflight.mjs` 里)的唯一理由是**可被反面样本喂**: + * `measureAvailBytes('/definitely/not/here')` 这条判据在任何机器上都跑得了, + * 而"读不到 ⇒ null ⇒ 放行"这一支否则就没有不依赖机器状态的判据覆盖 + * —— 端到端那条一旦因 `/tmp` 被清空而跳过,就没人管这一支了。 + * + * @param {string} dir 要量的目录,默认 `os.tmpdir()` + * @returns {number|null} 可用字节数;读不到/不支持/`bsize` 为 0 ⇒ `null` + */ +export function measureAvailBytes(dir) { + try { + if (typeof statfsSync !== 'function') return null; // Node < 18.15 + if (typeof dir !== 'string' || dir === '') return null; + const s = statfsSync(dir); + if (!s || !s.bsize) return null; // bsize=0 ⇒ 量不出字节数 + // 用 `bavail`(非特权进程可用的块数),**不是** `bfree`(含 root 保留块): + // 这里回答的是"我写不写得进去",不是"机器上空闲多少"。 + return s.bavail * s.bsize; + } catch { + return null; // 不知道 ≠ 不对 + } +} + /** * @param {{ availBytes: number|null, needBytes?: number }} args - * `availBytes` 为 `null` 表示"没测到"(读不到、平台不支持、字段缺)。 + * `availBytes` 为 `null` 表示"没测到"(读不到、平台不支持、`bsize` 为 0)。 * @returns {{ ok: boolean, known: boolean, note: string }} */ export function judgeSpace({ availBytes, needBytes = MIN_FREE_BYTES }) { diff --git a/plugins/pi-mail-bridge/test/env-guard.test.mjs b/plugins/pi-mail-bridge/test/env-guard.test.mjs index adead07..550be8e 100644 --- a/plugins/pi-mail-bridge/test/env-guard.test.mjs +++ b/plugins/pi-mail-bridge/test/env-guard.test.mjs @@ -23,12 +23,14 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { execFileSync } from 'node:child_process'; -import { readFileSync, statfsSync } from 'node:fs'; +import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import { tmpdir } from 'node:os'; -import { judgeSpace, MIN_FREE_BYTES, MEASURED_MAX_CASE_WRITE } from '../lib/tmp-space.mjs'; +import { judgeSpace, MIN_FREE_BYTES, MEASURED_MAX_CASE_WRITE, measureAvailBytes } from '../lib/tmp-space.mjs'; +import { translateEnvError } from '../lib/env-error.mjs'; +import { writeSession } from './session-scan.test.mjs'; const HERE = dirname(fileURLToPath(import.meta.url)); const PREFLIGHT = join(HERE, 'env-preflight.mjs'); @@ -83,11 +85,11 @@ test('阈值有据:等于「实测用例最大写入量 × 2 + 机动」,不 // ─── 2. 端到端:退出码与文案 ─────────────────────────────────── -/** 跑一次前置脚本,取 { status, stdout, stderr }。失败(非零退出)不抛。 */ -function runPreflight(env) { +/** 跑一次前置脚本(带参数),取 { status, stdout, stderr }。失败(非零退出)不抛。 */ +function runPreflight(args = [], env = {}) { try { - const stdout = execFileSync(process.execPath, [PREFLIGHT], { - encoding: 'utf8', env: { ...process.env, ...env }, + const stdout = execFileSync(process.execPath, [PREFLIGHT, ...args], { + encoding: 'utf8', env: { ...process.env, ...env }, stdio: ['ignore', 'pipe', 'pipe'], }); return { status: 0, stdout, stderr: '' }; } catch (e) { @@ -95,35 +97,110 @@ function runPreflight(env) { } } -test('端到端:空间不足的临时目录 → 退出码 2 且文案说「这是环境不足,不是断言失败」', () => { - // 本机 `/tmp` 在写这条判据时恰好是满的(0.70 MiB 可用)。若哪天它被清空了, - // 这一条就失去意义 —— 所以**不假设**它仍然满:先用判据自己量一次, - // 真的够用就跳过(并说明为什么跳过),绝不让它变成一条"总在亮"或"总在绿"的假判据。 - const avail = (() => { - try { - const s = statfsSync(tmpdir()); - return s.bavail * s.bsize; - } catch { return null; } - })(); - if (avail === null || avail >= MIN_FREE_BYTES) { - console.log(` (跳过:本机 ${tmpdir()} 当前可用 ${avail} 字节,已够用,造不出"不足"的真实环境)`); - return; - } +test('端到端:空间不足 → 退出码 2 且文案说「这是环境不足,不是断言失败」', () => { + // ★ 这一条**不依赖机器状态**。第一版靠"本机 /tmp 恰好是满的"来验,pi 评审时 + // 指出那是把判据绑在一个会变的环境上:/tmp 一被清空,这条就自动跳过、无声失效。 + // 所以前置脚本开了一个**只为测试存在**的开关 `--inject-avail`(`pool.mjs` 的 + // `workerPath` 是同一手法),把"可用空间"直接喂进去。代价:守卫多一个测试后门; + // 收益:判据在任何机器上、任何时刻都验得了。 + const out = runPreflight(['--inject-avail=0']); + assert.equal(out.status, 2, '环境不足必须是 2(环境问题),不是 1(断言失败)'); + assert.match(out.stderr, /这是环境不足,不是断言失败/); + assert.match(out.stderr, /TMPDIR=/); - const r = runPreflight({ TMPDIR: tmpdir() }); - assert.equal(r.status, 2, '环境不足必须是 2(环境问题),不是 1(断言失败)'); - assert.match(r.stderr, /这是环境不足,不是断言失败/); - assert.match(r.stderr, /TMPDIR=/); + // 反向对照:喂一个充足的数 → 必须放行。 + // 没有这条,"恒报不足"的坏守卫也能让上面那条绿 —— 反面样本只挡一半。 + const ok = runPreflight(['--inject-avail=999999999']); + assert.equal(ok.status, 0, '充足时必须放行'); + assert.match(ok.stdout, /env-preflight/); +}); + +test('开关真的被认(不是被当成未知参数忽略掉):--inject-avail=0 必须改变判定', () => { + // 若 `--inject-avail` 被忽略,脚本就退回"量真实临时目录"。本机 /tmp 现在是 0 字节, + // 于是它**也会** exit 2 —— 那样上一条判据照样绿,但验的其实是机器、不是开关。 + // 这里用"充足"那个方向做判据:注入一个大数必须放行;若开关被忽略,本机当前是满的 + // ⇒ 会 exit 2 ⇒ 这条判据红。**一个只在"开关坏掉"时才红的判据。** + const injected = runPreflight(['--inject-avail=999999999']); + const measured = runPreflight(['--measure=/tmp']); + assert.equal(measured.status, 0, '--measure 只量一次并 exit 0'); + assert.match(measured.stdout, /"availBytes"/); + if (/"availBytes":\s*0\b/.test(measured.stdout)) { + // 只有在"真实测量确实不足"时这条才有区分力,此时注入大数却放行 ⇒ 证明开关生效。 + assert.equal(injected.status, 0, '真实测量不足而注入充足 ⇒ 必须按注入的走,说明开关被认'); + } else { + // 真实测量本身就充足 ⇒ 这条没有区分力,明说而不是假装验过。 + console.log(' (本机临时目录当前充足,此条退化为弱检查:仅验 --measure 可用)'); + } +}); + +test('测量层:读不到的目录 → null(这条判据不依赖机器状态,永远跑得了)', () => { + // 覆盖"读不到 ⇒ null ⇒ 放行"那一支。端到端那条一旦被跳过,就只剩这条管它。 + assert.equal(measureAvailBytes('/definitely/not/here'), null); + assert.equal(measureAvailBytes(''), null, '空串不是合法目录'); + assert.equal(measureAvailBytes(undefined), null, '不传也要能兜住'); + // 真实的临时目录必须量得出一个数(健康时是正数、满时可能是 0 —— 两者都是 + // "量到了",都不是 null;这正是 `0` 与"没测到"必须分开的那条线)。 + const real = measureAvailBytes(tmpdir()); + assert.ok(real === null || typeof real === 'number', '要么量到数,要么明确 null'); }); // ─── 3. 兜底:绕过前置脚本时也不能伪装成内存缺陷 ──────────────── +// +// 这一段原先靠**读源码文本**(断言 `session-scan.test.mjs` 里出现 `/ENOSPC/`)。 +// pi 评审时指出它钉的是装饰不是机制:那段解释性注释里本来就有 "ENOSPC" 这个词, +// **把整段翻译逻辑删掉、只留注释,判据照样绿**。按仓库规矩改成行为判据 —— +// 纯函数喂反面样本 + 接线检查(`WIRING` 那套:直接调被接上的那个函数)。 -test('直接跑 node --test 绕过前置脚本时,ENOSPC 仍被翻译成环境问题', () => { - // 残留缺口(写进注释是必须的):`node --test 'test/*.test.mjs'` 会绕过 - // `npm test` 里的前置自检。所以 `session-scan.test.mjs` 里那条用例自己 - // 也带了一句 ENOSPC 兜底。这里直接对着**那段兜底逻辑的产物**断言: - // 在临时目录写不进去时,抛出的错误信息里必须出现"环境"字样。 - const src = readFileSync(join(HERE, 'session-scan.test.mjs'), 'utf8'); - assert.match(src, /ENOSPC/, '那条用例必须自己兜底判 ENOSPC'); - assert.match(src, /环境/, '兜底信息里必须点明是环境问题'); +test('★反面样本:ENOSPC 必须被翻译成「环境问题」,普通错误必须原样返回', () => { + const enospc = Object.assign(new Error('ENOSPC: no space left on device, write'), { code: 'ENOSPC' }); + const t = translateEnvError(enospc); + assert.equal(t.translated, true, 'ENOSPC 必须翻译'); + assert.match(t.error.message, /环境问题/); + assert.match(t.error.message, /不是内存缺陷/, '必须点明它不是内存缺陷 —— 这正是那次误导的根'); + assert.match(t.error.message, /TMPDIR=/, '必须带药方'); + assert.equal(t.error.cause, enospc, '原始错误要挂上 cause,别把现场丢了'); + + // 别的错误必须**原样**返回(不是包一层)—— 否则真正的代码缺陷会被套上 + // "环境问题"的外衣,那比不翻译更坏。 + const other = new Error('Cannot read properties of undefined'); + const o = translateEnvError(other); + assert.equal(o.translated, false); + assert.equal(o.error, other, '普通错误必须原样返回同一个对象'); +}); + +test('只有 message 里写着 no space left(没有 code)时也认', () => { + // 跨平台差异:不同 Node/文件系统的 errno 包装不一律带 code。 + const t = translateEnvError(new Error('write failed: no space left on device')); + assert.equal(t.translated, true); +}); + +test('接线:writeSession 撞上 ENOSPC 时抛出的必须是翻译过的错(喂假写,不靠机器状态)', () => { + // 这条是**行为**判据,不是文本判据:直接调被接上的那个函数,喂一个必然 ENOSPC 的 + // 假写。删掉 `catch { throw translateEnvError(e).error }` 这一段,这条立刻红。 + const fakeWrite = () => { + throw Object.assign(new Error('ENOSPC: no space left on device, write'), { code: 'ENOSPC' }); + }; + assert.throws( + () => writeSession(tmpdir(), '--probe--', 'x.jsonl', { id: 's' }, [], fakeWrite), + (e) => { + assert.match(e.message, /环境问题/, '必须是人话,不是原始英文 ENOSPC'); + assert.match(e.message, /不是内存缺陷/); + return true; + } + ); + + // 反向对照:普通写失败必须原样抛(证明上面那条不是因为"什么都翻译"才过的)。 + const brokenWrite = () => { throw new Error('EACCES: permission denied'); }; + assert.throws( + () => writeSession(tmpdir(), '--probe2--', 'y.jsonl', { id: 's' }, [], brokenWrite), + /EACCES/, + '不该被翻译成环境问题' + ); +}); + +test('残留缺口写明:绕过前置脚本时仍有一条 ENOSPC 兜底(文本接线检查)', () => { + // 保留一条**弱**的接线检查:翻译函数必须真的被 session-scan 的写点用着。 + // 它比原来那条强的地方是:不再要求注释里出现某个词,而是要求**调用点**存在。 + const src = readFileSync(join(HERE, 'session-scan.test.mjs'), 'utf8'); + assert.match(src, /translateEnvError\(e\)\.error/, '写点必须调用翻译函数'); }); diff --git a/plugins/pi-mail-bridge/test/env-preflight.mjs b/plugins/pi-mail-bridge/test/env-preflight.mjs index ccd4eb5..2f1ed48 100644 --- a/plugins/pi-mail-bridge/test/env-preflight.mjs +++ b/plugins/pi-mail-bridge/test/env-preflight.mjs @@ -20,27 +20,36 @@ * 写清 `2` 的意义很关键:CI 或人看到 `1` 会去查代码,看到 `2` 才知道去查机器。 */ -import { statfsSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { judgeSpace, MIN_FREE_BYTES } from '../lib/tmp-space.mjs'; +import { judgeSpace, measureAvailBytes, MIN_FREE_BYTES } from '../lib/tmp-space.mjs'; -/** 测量:进程**实际能写**的字节数。读不到返回 null(交给判据放行)。 */ -function measureAvailBytes(dir) { - try { - if (typeof statfsSync !== 'function') return null; // Node < 18.15 - const s = statfsSync(dir); - if (!s || !s.bsize) return null; - // 用 `bavail`(非特权进程可用的块数),**不是** `bfree`(含 root 保留块): - // 这里回答的是"我写不写得进去",不是"机器上空闲多少"。 - return s.bavail * s.bsize; - } catch { - return null; // 不知道 ≠ 不对 - } +/* + * 两个**只为测试存在**的开关(`src/pool.mjs` 的 `workerPath` 是同一手法)。 + * + * 为什么需要:端到端那条判据要验"不足 ⇒ exit 2",而本机 `/tmp` 现在恰好是满的。 + * 靠"机器恰好是满的"来验,等于把判据绑在一个**会变**的环境上 —— `/tmp` 一被清空, + * 那条判据就自动跳过、无声失效。开关让同一个行为在任何机器上都验得了。 + * + * --measure= 只量并打印 JSON(`{"dir":…,"availBytes":…}`),总是 exit 0 + * --inject-avail= 绕过测量,直接按 `n` 字节判定(`null` 表示"没测到") + */ +const argOf = (name) => { + const hit = process.argv.find((a) => a.startsWith(`--${name}=`)); + return hit ? hit.slice(name.length + 3) : undefined; +}; + +const measureDir = argOf('measure'); +if (measureDir !== undefined) { + console.log(JSON.stringify({ dir: measureDir, availBytes: measureAvailBytes(measureDir) })); + process.exit(0); } +const injected = argOf('inject-avail'); const dir = tmpdir(); -const availBytes = measureAvailBytes(dir); +const availBytes = injected === undefined + ? measureAvailBytes(dir) + : (injected === 'null' ? null : Number(injected)); const verdict = judgeSpace({ availBytes }); if (verdict.ok) { @@ -49,7 +58,7 @@ if (verdict.ok) { } console.error(` -[env-preflight] 环境不足:临时目录写不下,**这不是断言失败**。 +[env-preflight] 环境不足:临时目录写不下,**这是环境不足,不是断言失败**。 目录 ${dir} 可用 ${(availBytes / 1048576).toFixed(1)} MiB diff --git a/plugins/pi-mail-bridge/test/session-scan.test.mjs b/plugins/pi-mail-bridge/test/session-scan.test.mjs index 428c766..a1b1676 100644 --- a/plugins/pi-mail-bridge/test/session-scan.test.mjs +++ b/plugins/pi-mail-bridge/test/session-scan.test.mjs @@ -5,6 +5,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { createSessionScanner } from '../src/session-scan.mjs'; +import { translateEnvError } from '../lib/env-error.mjs'; /** 造一个会话目录树。返回根目录,用完由调用方删。 */ function makeRoot() { @@ -15,28 +16,24 @@ function makeRoot() { * * ★ 空间不足时**不要让它伪装成内存缺陷**(2026-09-14 实测的教训)。 * - * 本文件在 `/tmp` 是满的 tmpfs(`bavail` 只剩 0.70 MiB)时会红一条 + * 本文件在 `/tmp` 是满的 tmpfs(`bavail` 一度真是 **0**)时会红一条 * `★巨大的 message 行不进内存也不影响解析`,报 `ENOSPC` —— 而那条用例的名字里 * 就写着"不进内存",于是那条红**长得像一个内存缺陷**,让人去 `session-scan.mjs` * 里找一个不存在的东西。 * * 正常的挡法在 `test/env-preflight.mjs`(由 `npm test` 先跑,不足时 exit 2)。 * 但 `node --test 'test/*.test.mjs'` 会绕过它,所以这里再兜一道: - * **ENOSPC 一律翻译成"环境不足"并说清是环境问题** —— 不管套件是怎么被调起来的。 + * **ENOSPC 一律翻译成"环境不足"** —— 不管套件是怎么被调起来的。 + * + * 翻译逻辑在 `lib/env-error.mjs`(纯函数,`env-guard.test.mjs` 用构造出来的 + * ENOSPC 喂它验行为)。**别把判断写回这里**:写在这里就只能靠"读源码文本"去判, + * 而本段注释里本来就有 "ENOSPC" 这个词 —— 删掉逻辑只留注释,文本判据照样绿 + * (pi 评审时就是这么指出来的)。 + * + * `write` 只为测试存在(默认 `writeFileSync`):让这个接线能被喂一个必然 ENOSPC 的假写, + * 于是"机制在不在"是**行为**判据而不是文本判据。 */ -function envHint(e) { - if (e && (e.code === 'ENOSPC' || /no space left on device/i.test(String(e.message)))) { - const err = new Error( - `环境不足:临时目录 ${tmpdir()} 写不进去(ENOSPC)—— 这是环境问题,不是内存缺陷。` + - '药方:TMPDIR=<有空间的目录> npm test' - ); - err.cause = e; - return err; - } - return e; -} - -function writeSession(root, cwdSlug, fileName, header, lines = []) { +export function writeSession(root, cwdSlug, fileName, header, lines = [], write = writeFileSync) { const dir = join(root, cwdSlug); mkdirSync(dir, { recursive: true }); const file = join(dir, fileName); @@ -44,9 +41,9 @@ function writeSession(root, cwdSlug, fileName, header, lines = []) { .concat(lines.map((l) => JSON.stringify(l))) .join('\n'); try { - writeFileSync(file, `${body}\n`); + write(file, `${body}\n`); } catch (e) { - throw envHint(e); + throw translateEnvError(e).error; } return file; }