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 + 人话。
This commit is contained in:
@ -351,20 +351,44 @@ function describeEnvError(e, what) {
|
||||
return err;
|
||||
}
|
||||
|
||||
/** 判据自检:**先证明这个检查器能发现差异**,再用它下结论。
|
||||
* 一个永远说「一致」的比较器看起来同样令人放心。 */
|
||||
/**
|
||||
* 判据自检:**先证明这个检查器能发现差异**,再用它下结论。
|
||||
* 一个永远说「一致」的比较器看起来同样令人放心。
|
||||
*
|
||||
* ★ **本函数自己保证"环境不足说人话",与从哪个入口调它无关。**
|
||||
*
|
||||
* 上一版把"覆盖全部写点"那一层放在 `main()` 的 catch 里 —— 但本函数是**导出的**
|
||||
* (导出的用途就是被别人直接调,例如 `--self-check` 之外的自检脚本)。任何绕过
|
||||
* `main()` 的调用者,在那几处裸写撞上 ENOSPC 时拿到的仍是原始英文 + 本文件堆栈。
|
||||
* pi 评审时指出:**"一处覆盖全部写点"成立的前提是"从 main() 进来"** ——
|
||||
* 而"覆盖范围取决于我以为的入口"正是这一串 bug 的共同病根。
|
||||
*
|
||||
* 所以这里把整段包一层(`body()` + 统一 catch),主进程那个 catch 退化为
|
||||
* 冗余的第二道 —— 冗余是故意的。
|
||||
*
|
||||
* 为什么不去用 `plugins/pi-mail-bridge/lib/env-error.mjs` 的 `translateEnvError`
|
||||
* (判据相同、措辞不同,看起来该合并):**`deploy/` 的独立性比去重值钱**。
|
||||
* 本文件头整段就在讲"服务不该依赖仓库是否存在",`deploy/` 下的工具同理 ——
|
||||
* 让它 import 插件目录里的模块,等于把部署工具绑死在插件的目录结构上。
|
||||
* 判据(`code === 'ENOSPC'` / 消息里含 no space left)是稳定的,措辞各自合适即可。
|
||||
* **若哪天出现第三份拷贝,再考虑共用** —— 两份还撑得住。
|
||||
*
|
||||
* 写点计数口径(免得与别处的说法对不上):本函数共 **6 处写**
|
||||
* = `mkdtempSync` ×2 + `mk()` 内 `writeFileSync` ×2 + 三处裸写
|
||||
* (`b/README.md`、`b/lib/extra.mjs`、`a/test/t.mjs`)。
|
||||
*/
|
||||
export function selfCheck() {
|
||||
// ★ `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-'));
|
||||
return body();
|
||||
} catch (e) {
|
||||
throw describeEnvError(e, '判据自检要在临时目录里建两棵样本树');
|
||||
throw describeEnvError(e, '判据自检要在临时目录里造样本树');
|
||||
}
|
||||
|
||||
function body() {
|
||||
// `mkdtempSync` 本身就是一个写点:临时目录满的时候(2026-09-14 实测 `bavail`
|
||||
// 一度真是 0)它会抛 ENOSPC。上一版它在 try 之外,于是那条路径连退出码 2 都拿不到。
|
||||
const a = mkdtempSync(join(tmpdir(), 'drift-a-'));
|
||||
const b = mkdtempSync(join(tmpdir(), 'drift-b-'));
|
||||
const mk = (root, content) => {
|
||||
mkdirSync(join(root, 'lib'), { recursive: true });
|
||||
// ★ 自检要在临时目录里造两棵小树。临时目录满了时这里会抛 ENOSPC ——
|
||||
@ -374,10 +398,9 @@ export function selfCheck() {
|
||||
writeFileSync(join(root, 'lib', 'x.mjs'), content);
|
||||
writeFileSync(join(root, 'README.md'), 'doc');
|
||||
} catch (e) {
|
||||
// 统一交给 describeEnvError 翻译(见上方):这里只负责 `mk()` 自己能看到的两处写,
|
||||
// 后面还有三处写在别处(README.md / extra.mjs / test/t.mjs),由 main() 的
|
||||
// catch 统一兜住 —— 那是"一处覆盖全部写点"的那一层。
|
||||
throw describeEnvError(e, '判据自检要在临时目录里造样本树');
|
||||
// 交给最外层那个统一 catch(见函数头注释)——**不要**在这里单独翻译,
|
||||
// 否则"覆盖范围"又变成"取决于哪一处写",就是这套 bug 的病根。
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
const out = [];
|
||||
@ -449,6 +472,7 @@ export function selfCheck() {
|
||||
rmSync(b, { recursive: true, force: true });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -22,6 +22,21 @@
|
||||
* 于是**删掉整个翻译逻辑、只留注释,判据照样绿**。这正是
|
||||
* `permission-note.test.mjs` 警告过的"钉装饰不钉机制"。
|
||||
*
|
||||
* # 为什么不去和 `deploy/check-deploy-drift.mjs` 的 `describeEnvError` 合并
|
||||
*
|
||||
* 判据相同、措辞不同,看起来该合并 —— 但不合:**`deploy/` 的独立性比去重值钱**
|
||||
* (那份文件头整段在讲"服务不该依赖仓库是否存在",`deploy/` 下的工具同理,
|
||||
* 让它 import 插件目录里的模块等于把它绑死在插件的目录结构上)。
|
||||
* **第三份拷贝出现时再考虑共用。**
|
||||
*
|
||||
* # 变异自检的纪律(这条是踩出来的)
|
||||
*
|
||||
* 验证本函数"真的被接上"要注入变异(删掉调用、看判据红不红)。做这件事时:
|
||||
* **先证明你能撤回来,再注入**;而且**还原路径不能依赖被测对象** ——
|
||||
* 2026-09-14 那次把备份写进 `/tmp`(正是当时被占满的那个资源),备份没写成,
|
||||
* 变异却已经覆盖了源文件。现在只对"已在 HEAD 里干净提交"的文件做变异,
|
||||
* 还原一律走 `git checkout HEAD -- <file>`。
|
||||
*
|
||||
* @param {unknown} e 捕获到的错误
|
||||
* @returns {{ translated: boolean, error: Error }} 翻译过的新错误,或原样返回
|
||||
*/
|
||||
|
||||
@ -97,44 +97,69 @@ function runPreflight(args = [], env = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
test('端到端:空间不足 → 退出码 2 且文案说「这是环境不足,不是断言失败」', () => {
|
||||
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=/);
|
||||
// 所以前置脚本开了 **只为测试存在** 的开关 `--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 ok = runPreflight(['--inject-avail=999999999']);
|
||||
assert.equal(ok.status, 0, '充足时必须放行');
|
||||
assert.match(ok.stdout, /env-preflight/);
|
||||
const good = runPreflight(['--inject-avail=999999999']);
|
||||
assert.equal(good.status, 0, '充足必须放行');
|
||||
assert.match(good.stdout, /env-preflight/);
|
||||
});
|
||||
|
||||
test('开关真的被认(不是被当成未知参数忽略掉):--inject-avail=0 必须改变判定', () => {
|
||||
// 若 `--inject-avail` 被忽略,脚本就退回"量真实临时目录"。本机 /tmp 现在是 0 字节,
|
||||
// 于是它**也会** exit 2 —— 那样上一条判据照样绿,但验的其实是机器、不是开关。
|
||||
// 这里用"充足"那个方向做判据:注入一个大数必须放行;若开关被忽略,本机当前是满的
|
||||
// ⇒ 会 exit 2 ⇒ 这条判据红。**一个只在"开关坏掉"时才红的判据。**
|
||||
const injected = runPreflight(['--inject-avail=999999999']);
|
||||
test('★开关真的被认:按真实测量分叉,两个分支各有分辨力', () => {
|
||||
// pi 评审指出上一版的漏洞:原来只在"真实测量不足"那个分支里断言,
|
||||
// 机器一恢复健康(/tmp 被清空)这条就退化成"只验 --measure"的弱检查 ——
|
||||
// 而它守的恰恰是"开关别静默失效"。
|
||||
//
|
||||
// 两个方向是**对偶**的,各守一个机器状态,所以按实测分叉、在两个分支里
|
||||
// 断言**相反**的方向 ⇒ 任何机器状态下都有分辨力,不会退化成弱检查:
|
||||
//
|
||||
// 真实不足 ⇒ 注入大数必须放行 (若开关被忽略,回退测量 ⇒ 2 ≠ 0 ⇒ 红)
|
||||
// 真实充足 ⇒ 注入 0 必须 exit 2(若开关被忽略,回退测量 ⇒ 0 ≠ 2 ⇒ 红)
|
||||
//
|
||||
// 判据红的方式不一样,但**红**这件事在两种机器状态下都成立。
|
||||
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, '真实测量不足而注入充足 ⇒ 必须按注入的走,说明开关被认');
|
||||
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 {
|
||||
// 真实测量本身就充足 ⇒ 这条没有区分力,明说而不是假装验过。
|
||||
console.log(' (本机临时目录当前充足,此条退化为弱检查:仅验 --measure 可用)');
|
||||
const injected = runPreflight(['--inject-avail=0']);
|
||||
assert.equal(injected.status, 2, '真实充足 + 注入 0 ⇒ 必须按注入的走(开关被认)');
|
||||
}
|
||||
});
|
||||
|
||||
test('测量层:读不到的目录 → null(这条判据不依赖机器状态,永远跑得了)', () => {
|
||||
// 覆盖"读不到 ⇒ null ⇒ 放行"那一支。端到端那条一旦被跳过,就只剩这条管它。
|
||||
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, '不传也要能兜住');
|
||||
|
||||
@ -39,6 +39,17 @@ const argOf = (name) => {
|
||||
return hit ? hit.slice(name.length + 3) : undefined;
|
||||
};
|
||||
|
||||
/** 未知参数一律拒绝(exit 2,与"环境/参数问题"同义)。
|
||||
* 静默忽略未知参数会让 `--inject-avail=abc` 这类笔误退化成"没测到 ⇒ 放行" ——
|
||||
* 在这条链上就等于**悄悄跳过守卫**。拼错参数必须炸,不能忍。 */
|
||||
const KNOWN = ['--measure', '--inject-avail'];
|
||||
for (const a of process.argv.slice(2)) {
|
||||
if (!KNOWN.some((k) => a.startsWith(`${k}=`))) {
|
||||
console.error(`[env-preflight] 不认识这个参数:${a}\n用法:--measure=<dir> | --inject-avail=<字节数|null>`);
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
const measureDir = argOf('measure');
|
||||
if (measureDir !== undefined) {
|
||||
console.log(JSON.stringify({ dir: measureDir, availBytes: measureAvailBytes(measureDir) }));
|
||||
@ -47,9 +58,20 @@ if (measureDir !== undefined) {
|
||||
|
||||
const injected = argOf('inject-avail');
|
||||
const dir = tmpdir();
|
||||
const availBytes = injected === undefined
|
||||
? measureAvailBytes(dir)
|
||||
: (injected === 'null' ? null : Number(injected));
|
||||
let availBytes;
|
||||
if (injected === undefined) {
|
||||
availBytes = measureAvailBytes(dir);
|
||||
} else if (injected === 'null') {
|
||||
availBytes = null;
|
||||
} else {
|
||||
availBytes = Number(injected);
|
||||
// `Number('abc')` = NaN ⇒ 判据会当成"没测到"而**放行**。笔误在这条链上等于跳过守卫,
|
||||
// 所以非法值按参数错误处理(exit 2),不给它静默放行的机会。
|
||||
if (!Number.isFinite(availBytes) || availBytes < 0) {
|
||||
console.error(`[env-preflight] --inject-avail 的值不合法:${injected}(要字节数或 null)`);
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
const verdict = judgeSpace({ availBytes });
|
||||
|
||||
if (verdict.ok) {
|
||||
|
||||
Reference in New Issue
Block a user