Files
MailUI4Agents/plugins/pi-mail-bridge/lib/tmp-space.mjs
JianFeeeee 5bc579f910 fix(pi-bridge): 按评审补三处 —— ENOSPC 只盖了一个写点、旧注释自相矛盾、兜底判据钉的是文本
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=<dir>`(只量并打印 JSON)与 `--inject-avail=<n>`(绕过测量直接判定),
  于是"不足⇒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 且一条用例都不跑。
2026-09-14 19:39:07 +08:00

95 lines
5.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 临时目录空间:**测量与判据分开**。
*
* # 为什么要有这个东西
*
* 2026-09-14 实测(本机):`/tmp` 是 tmpfs,9.8G 被占满,`bavail` 只剩
* **0.70 MiB**(`statfs` 实读)。此时跑 `npm test` 会红一条
* `★巨大的 message 行不进内存也不影响解析`,报 `ENOSPC`。
*
* 那条红**看起来像内存缺陷**(用例名里就写着"不进内存",而它恰好是往临时目录
* 写文件的用例)。下一次踩到的人会去读 `session-scan.mjs` 找内存 bug —— 找的是
* 一个**不存在**的东西。环境不足伪装成断言失败,是这个文件存在的全部理由。
*
* # 判据的形状(与仓库既有约定一致)
*
* - 这里只有**纯函数**:喂进"还剩多少字节",吐出"够不够"。测量在
* `measureAvailBytes()`(同文件导出,因为它要能被喂"不存在的目录"做反面样本),
* 纯函数才可被反面样本喂。
* - **读不到不判红**:`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;
/**
* 前置自检要求的最小可用空间。
*
* 取"实测用例写入量 × 2 + 8 MiB 机动"= 32 MiB,**不是**总容量的百分比:
* 百分比在 9.8G 的 tmpfs 上会给出一个和这套测试毫无关系的数,而这里要挡的是
* "一条 12 MiB 的用例写不进去"。×2 是因为临时目录可能同时有别的写入方
* (本机 `npm test` 之外还跑着 agent 的会话文件)。
*/
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` 表示"没测到"(读不到、平台不支持、`bsize` 为 0)。
* @returns {{ ok: boolean, known: boolean, note: string }}
*/
export function judgeSpace({ availBytes, needBytes = MIN_FREE_BYTES }) {
// `null` / `NaN` 才是"没测到"。**注意 0 不是"没测到"** ——
// `statfsSync` 说不出话时会抛(已在测量层转成 null),它若返回 0,
// 意思就是"真的一点空间都没有"(实测踩过:把 `<= 0` 一并当"不知道",
// 于是 `bavail` 只剩 712 字节时前置自检放行,接着 17 条用例 ENOSPC 全红)。
if (availBytes === null || availBytes === undefined || Number.isNaN(availBytes)) {
return { ok: true, known: false, note: '没测到可用空间(读不到或平台不支持)—— 不据此判定' };
}
if (availBytes >= needBytes) {
return { ok: true, known: true, note: `可用 ${mib(availBytes)} ≥ 需要 ${mib(needBytes)}` };
}
return {
ok: false,
known: true,
note: `可用 ${mib(availBytes)} < 需要 ${mib(needBytes)}`,
};
}