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 + 人话。
232 lines
12 KiB
JavaScript
232 lines
12 KiB
JavaScript
/**
|
||
* 「临时目录空间不足必须被说出来,而不是伪装成断言失败」的判据。
|
||
*
|
||
* # 缺陷现场(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/, '写点必须调用翻译函数');
|
||
});
|