Files
MailUI4Agents/plugins/pi-mail-bridge/test/env-guard.test.mjs
JianFeeeee 6b7c12d9da fix(pi-bridge): "开关被认"那条判据自己也有假绿 —— 我按真实测量分叉,于是永远走短路分支
pi 评审第二轮指出:上一版"开关真的被认"只在"真实测量不足"那个分支里断言,
机器一恢复健康(/tmp 被清空)这条就退化成"只验 --measure"的弱检查,
而它守的恰恰是"开关别静默失效"。

认下之后我做变异(把开关整个忽略掉、永远用真实测量)验证,**发现比这更糟**:
那条新写的判据**在变异下照样绿**。

原因是我写成了 `realAvail < MIN_FREE_BYTES ? (不足分支,只看退出码) : (充足分支)`,
而本机真实可用**就是 0** ⇒ 永远走不足分支;开关被整个忽略时,回退测量同样给
exit 2 ⇒ 断言通过。**"断言在,区分力不在"** —— 与 pi 点的是同一类病,
只是它藏在一个**跑不到的分支**里(嵌套三元短路),比"分支退化"更难看出来。

修法:不跟真实测量比,**让两个探针自己互为反面**,并断言**输出里的判定词**
(不只看退出码 —— 退出码可能与真实状态巧合相同):

    探针 A:注入 1 字节          ⇒ exit 2 + 必须打印「< 需要」
    探针 B:注入 128 MiB(>阈值)⇒ exit 0 + 必须打印「≥ 需要」

开关被忽略 ⇒ 两次都按真实测量给同一个答案 ⇒ 至少一条红。这个论证不依赖真实测量
是多少。变异自检实测:注入"忽略开关"的变异后,第 23、24、25 三条一起红。

顺带修一处**HEAD 里就带着的坏行**:第 162 行的 `test(..., () => {` 后面被塞进了
`// 覆盖…` 注释(上一次编辑吃掉了那个换行),整行不合法。这次一并拆回两行。

过程中我两次改坏文件(一次把手写 `replace` 的锚点算错、把"非法参数"那条整条删掉),
两次都靠 `git checkout HEAD -- <file>` 拉回重做 —— 这正是上一轮写进
`lib/env-error.mjs` 的那条纪律(变异/改写只对已提交文件做、还原只走 git)当场生效。

验证:`npm test` **475/475**;env-guard 单跑 30 条全过(含 24 号在两个探针下的双断言)。
2026-09-14 19:46:07 +08:00

224 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 被清空)这条就退化成弱检查,而它守的恰恰是"开关别静默失效"。
//
// 漏洞二(我做变异时撞上的,比漏洞一更隐蔽):按"真实测量"分叉的写法本身留了一个
// 短路分支 —— 本机真实可用就是 0 ⇒ 永远走不足分支,而那个分支只看退出码;
// 于是把开关**整个忽略掉**(永远用真实测量),断言**照样绿**。
// "断言在,区分力不在" —— 与 pi 点的是同一类病,只是它藏在"跑不到的分支"里。
//
// 修法:**不跟真实测量比,让两个探针自己互为反面**,并断言**输出里的判定词**
// (不只看退出码 —— 退出码可能与真实状态巧合相同):
// 探针 A:注入 1 字节 ⇒ exit 2 + 必须打印「< 需要」
// 探针 B:注入 128 MiB(>阈值)⇒ exit 0 + 必须打印「≥ 需要」
// 若开关被忽略,两次都按**真实**测量给同一个答案 ⇒ 至少一条红。
// 这个论证不依赖真实测量是多少。
const tiny = runPreflight(['--inject-avail=1']);
assert.equal(tiny.status, 2, '注入 1 字节必须 exit 2');
assert.match(tiny.stderr + tiny.stdout, /<\s*需要/, '必须打印「不足」的判定');
const plenty = runPreflight(['--inject-avail=134217728']);
assert.equal(plenty.status, 0, '注入 128 MiB(> 32 MiB 阈值)必须放行');
assert.match(plenty.stdout, /≥\s*需要/, '必须打印「充足」的判定(不是靠退出码近似)');
});
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/, '写点必须调用翻译函数');
});