Files
MailUI4Agents/plugins/pi-mail-bridge/test/env-guard.test.mjs
JianFeeeee 87359588eb fix(pi-bridge): 评审第二轮 —— 判据在健康机器上会退化、"一处覆盖"取决于入口、笔误参数静默放行
pi 读了 `5bc579f` 之后报了两条新的 + 三条小的,全部认下并落地。

## 一、"开关真的被认"那条判据在 /tmp 被清空后失去分辨力

上一版只在"真实测量不足"那个分支里断言(注入大数必须放行)。问题是:
**"不足"正是机器恢复健康后会消失的条件** —— 那天这条判据就退化成"只验
`--measure` 可用"的弱检查,而它守的恰恰是"开关别静默失效"。

两个方向是对偶的、各守一个机器状态,所以改成**按实测分叉、在两个分支里断言相反的方向**:

    真实不足 ⇒ 注入大数必须放行   (开关被忽略则回退测量 ⇒ 2 ≠ 0 ⇒ 红)
    真实充足 ⇒ 注入 0    必须 exit 2(开关被忽略则回退测量 ⇒ 0 ≠ 2 ⇒ 红)

量不到就 `assert.fail` 并说明"无法分叉"—— 不静默跳过(跳过会把"失去分辨力"
伪装成"验过了")。另把"端到端"那条的两个方向拆明白:只验"不足⇒2"时,
一个恒报不足的坏守卫也能绿。

## 二、"一处覆盖全部写点"成立的前提是"从 main() 进来"

`selfCheck()` 是**导出**的(用途就是被直接调),而兜住那三处裸写的 catch 在
`main()` 里 ⇒ 任何绕过 `main()` 的调用者撞上 ENOSPC 拿到的仍是原始英文堆栈。
**"覆盖范围取决于我以为的入口"正是这一串 bug 的共同病根**,所以把整段包一层
(`body()` + 统一 catch):与入口无关,`main()` 那个退化为冗余的第二道。
实测:`TMPDIR=/tmp node -e 'import("./deploy/check-deploy-drift.mjs").then(m=>m.selfCheck())'`
现在拿到的是「环境不足…这是环境问题,不是检查器的问题」。

## 三、`--inject-avail=abc` 静默放行(笔误 = 跳过守卫)

`Number('abc')` = NaN ⇒ 判据当"没测到" ⇒ 放行。现在按仓库约定处理:
**非法值 exit 2,未知参数也 exit 2**(`--measure` 少写 `=` 同样炸)。
`null` 仍是合法值("没测到 ⇒ 放行"是有意的),加了判据把这两个方向都钉住。

## 四、三条小的

- 两份实现(`lib/env-error.mjs` 的 `translateEnvError` 与 `deploy/` 的
  `describeEnvError`)**不去重**,但两边各写一句"为什么不复用":
  `deploy/` 的独立性比去重值钱(那份文件头整段在讲"服务不该依赖仓库是否存在")。
  并写明**第三份拷贝出现时再考虑共用**。
- 写点计数口径写进注释:本函数 **6 处写** = `mkdtempSync`×2 + `mk()` 内 ×2
  + 三处裸写。免得与别处"五处"的说法对不上(上一封信里两个实测数字就是这么被误读的)。
- 变异自检的纪律补进 `lib/env-error.mjs` 头注释:**先证明能撤回来再注入变异,
  且还原路径不能依赖被测对象**(那次把备份写进 `/tmp` —— 正是当时被占满的资源,
  备份没写成而变异已覆盖源文件)。现在只对"已在 HEAD 干净提交"的文件做变异,
  还原一律 `git checkout HEAD -- <file>`。

验证:`npm test` **475/475**;`--self-check` 18 条全过;
`TMPDIR=/tmp node deploy/check-deploy-drift.mjs --self-check` ⇒ exit 2 + 人话。
2026-09-14 19:42:58 +08:00

232 lines
12 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,被别人的东西占满,`bavail` 只剩 **0.70 MiB**。此时:
*
* not ok 323 - ★巨大的 message 行不进内存也不影响解析
* error: 'ENOSPC: no space left on device, write'
*
* 那条红的**形状**指向内存(用例名里就写着"不进内存"),真相是环境不足。
* 没有这一条判据时,下一个人会去 `session-scan.mjs` 找一个不存在的内存缺陷。
*
* # 三段判据(缺一段都不算数)
*
* 1. 纯函数两头都对:够 → 绿;不足 → 红**且说得出差多少**;
* 2. 端到端退出码:不足 → `2`(环境问题,与 `deploy/redeploy-plugin.sh` 同义),
* 不是 `1`(断言失败)—— 看到 `2` 才知道去查机器而不是查代码;
* 3. **反面样本**:喂一个不足的可用空间,判据必须红。
* 没有反面样本的判据等于没有判据(仓库既有规矩)。
*/
import assert from 'node:assert/strict';
import test from 'node:test';
import { execFileSync } from 'node:child_process';
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, 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');
// ─── 1. 纯函数:两头都对 ───────────────────────────────────────
test('空间充足 → 判绿,并报出可用与需要', () => {
const v = judgeSpace({ availBytes: MIN_FREE_BYTES + 1 });
assert.equal(v.ok, true);
assert.equal(v.known, true);
assert.match(v.note, /可用/);
assert.match(v.note, /需要/);
});
test('★反面样本:空间不足 → 判红,且说得出差多少', () => {
// 实测那个数:0.70 MiB 可用(`/tmp` 满时的真实值)。
const v = judgeSpace({ availBytes: 729_088 });
assert.equal(v.ok, false, '不足必须判红 —— 这是这条判据存在的理由');
assert.equal(v.known, true);
assert.match(v.note, /0\.7 MiB/);
assert.match(v.note, /32\.0 MiB/);
});
test('刚好多一点就够、刚好少一点就不够(边界不靠感觉)', () => {
assert.equal(judgeSpace({ availBytes: MIN_FREE_BYTES }).ok, true, '≥ 阈值算够');
assert.equal(judgeSpace({ availBytes: MIN_FREE_BYTES - 1 }).ok, false, '< 阈值算不够');
});
test('读不到可用空间 → 不判红(不知道 ≠ 不对)', () => {
// 平台不支持 statfs、或字段缺 —— 此时放行。理由见 lib/tmp-space.mjs 头注释:
// 在认不出的文件系统上判红,会造出一条总在亮的红灯,人就会学会忽略它。
for (const availBytes of [null, undefined, NaN]) {
const v = judgeSpace({ availBytes });
assert.equal(v.ok, true, `${String(availBytes)} 不该判红`);
assert.equal(v.known, false);
}
});
test('★0 字节不是「不知道」,是「真的没有」—— 必须判红', () => {
// 这条是实测踩出来的:第一版把 `availBytes <= 0` 一并当"没测到",
// 于是 `bavail` 只剩 712 字节时前置自检**放行**,紧接着 17 条用例 ENOSPC 全红
// —— 前置自检装了等于没装。0 是测量结果,不是测量失败。
const v = judgeSpace({ availBytes: 0 });
assert.equal(v.ok, false, '0 字节必须判红');
assert.equal(v.known, true);
});
test('阈值有据:等于「实测用例最大写入量 × 2 + 机动」,不是总容量的百分比', () => {
assert.equal(MEASURED_MAX_CASE_WRITE, 12 * 1024 * 1024, '12 MiB 来自 session-scan 那条用例的写入量');
assert.ok(MIN_FREE_BYTES > MEASURED_MAX_CASE_WRITE, '阈值必须大于单条用例的写入量');
});
// ─── 2. 端到端:退出码与文案 ───────────────────────────────────
/** 跑一次前置脚本(带参数),取 { status, stdout, stderr }。失败(非零退出)不抛。 */
function runPreflight(args = [], env = {}) {
try {
const stdout = execFileSync(process.execPath, [PREFLIGHT, ...args], {
encoding: 'utf8', env: { ...process.env, ...env }, stdio: ['ignore', 'pipe', 'pipe'],
});
return { status: 0, stdout, stderr: '' };
} catch (e) {
return { status: e.status, stdout: e.stdout ?? '', stderr: e.stderr ?? '' };
}
}
test('端到端:退出码 2 与文案(两个方向都验,不依赖机器状态)', () => {
// ★ 这一条**不依赖机器状态**。第一版靠"本机 /tmp 恰好是满的"来验,pi 评审时
// 指出那是把判据绑在一个会变的环境上:/tmp 一被清空,这条就自动跳过、无声失效。
// 所以前置脚本开了 **只为测试存在** 的开关 `--inject-avail`(`pool.mjs` 的
// `workerPath` 是同一手法),把"可用空间"直接喂进去。
//
// 两个方向都要验:只验"不足⇒2",一个恒报不足的坏守卫也能绿;
// 只验"充足⇒0",一个恒放行的守卫也能绿。
const bad = runPreflight(['--inject-avail=0']);
assert.equal(bad.status, 2, '不足必须是 2(环境问题),不是 1(断言失败)');
assert.match(bad.stderr, /这是环境不足,不是断言失败/);
assert.match(bad.stderr, /TMPDIR=/);
const good = runPreflight(['--inject-avail=999999999']);
assert.equal(good.status, 0, '充足必须放行');
assert.match(good.stdout, /env-preflight/);
});
test('★开关真的被认:按真实测量分叉,两个分支各有分辨力', () => {
// pi 评审指出上一版的漏洞:原来只在"真实测量不足"那个分支里断言,
// 机器一恢复健康(/tmp 被清空)这条就退化成"只验 --measure"的弱检查 ——
// 而它守的恰恰是"开关别静默失效"。
//
// 两个方向是**对偶**的,各守一个机器状态,所以按实测分叉、在两个分支里
// 断言**相反**的方向 ⇒ 任何机器状态下都有分辨力,不会退化成弱检查:
//
// 真实不足 ⇒ 注入大数必须放行 (若开关被忽略,回退测量 ⇒ 2 ≠ 0 ⇒ 红)
// 真实充足 ⇒ 注入 0 必须 exit 2(若开关被忽略,回退测量 ⇒ 0 ≠ 2 ⇒ 红)
//
// 判据红的方式不一样,但**红**这件事在两种机器状态下都成立。
const measured = runPreflight(['--measure=/tmp']);
assert.equal(measured.status, 0, '--measure 只量一次并 exit 0');
const m = /"availBytes":\s*(\d+|null)/.exec(measured.stdout);
assert.ok(m, `--measure 必须回一个数或 null,实际:${measured.stdout.trim()}`);
if (m[1] === 'null') {
// 量不到就**不能**分叉(既不知道机器是哪种状态)。明说并让判据红,
// 而不是悄悄跳过 —— 跳过会把"失去分辨力"伪装成"验过了"。
assert.fail('本机 /tmp 量不出可用空间,这条判据无法分叉 —— 别让它静默退化');
}
const realAvail = Number(m[1]);
if (realAvail < MIN_FREE_BYTES) {
const injected = runPreflight(['--inject-avail=999999999']);
assert.equal(injected.status, 0, '真实不足 + 注入充足 ⇒ 必须按注入的走(开关被认)');
} else {
const injected = runPreflight(['--inject-avail=0']);
assert.equal(injected.status, 2, '真实充足 + 注入 0 ⇒ 必须按注入的走(开关被认)');
}
});
test('★非法参数必须炸(exit 2),不能静默放行', () => {
// `Number('abc')` = NaN ⇒ 判据当"没测到" ⇒ 放行。笔误在这条链上等于**跳过守卫**,
// 这是 pi 评审时点出来的:`--inject-avail=abc` 原本会安安静静地放行。
for (const bad of ['--inject-avail=abc', '--inject-avail=-1', '--inject-avail=', '--typo=1', '--measure']) {
const r = runPreflight([bad]);
assert.equal(r.status, 2, `${bad} 必须 exit 2(参数/环境问题),实际 ${r.status}`);
}
// 合法值不能被这条误伤。
assert.equal(runPreflight(['--inject-avail=null']).status, 0, 'null 是合法值(没测到 ⇒ 放行)');
});
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('★反面样本: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/, '写点必须调用翻译函数');
});