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 且一条用例都不跑。
This commit is contained in:
@ -23,12 +23,14 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { readFileSync, statfsSync } from 'node:fs';
|
||||
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 } from '../lib/tmp-space.mjs';
|
||||
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');
|
||||
@ -83,11 +85,11 @@ test('阈值有据:等于「实测用例最大写入量 × 2 + 机动」,不
|
||||
|
||||
// ─── 2. 端到端:退出码与文案 ───────────────────────────────────
|
||||
|
||||
/** 跑一次前置脚本,取 { status, stdout, stderr }。失败(非零退出)不抛。 */
|
||||
function runPreflight(env) {
|
||||
/** 跑一次前置脚本(带参数),取 { status, stdout, stderr }。失败(非零退出)不抛。 */
|
||||
function runPreflight(args = [], env = {}) {
|
||||
try {
|
||||
const stdout = execFileSync(process.execPath, [PREFLIGHT], {
|
||||
encoding: 'utf8', env: { ...process.env, ...env },
|
||||
const stdout = execFileSync(process.execPath, [PREFLIGHT, ...args], {
|
||||
encoding: 'utf8', env: { ...process.env, ...env }, stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
return { status: 0, stdout, stderr: '' };
|
||||
} catch (e) {
|
||||
@ -95,35 +97,110 @@ function runPreflight(env) {
|
||||
}
|
||||
}
|
||||
|
||||
test('端到端:空间不足的临时目录 → 退出码 2 且文案说「这是环境不足,不是断言失败」', () => {
|
||||
// 本机 `/tmp` 在写这条判据时恰好是满的(0.70 MiB 可用)。若哪天它被清空了,
|
||||
// 这一条就失去意义 —— 所以**不假设**它仍然满:先用判据自己量一次,
|
||||
// 真的够用就跳过(并说明为什么跳过),绝不让它变成一条"总在亮"或"总在绿"的假判据。
|
||||
const avail = (() => {
|
||||
try {
|
||||
const s = statfsSync(tmpdir());
|
||||
return s.bavail * s.bsize;
|
||||
} catch { return null; }
|
||||
})();
|
||||
if (avail === null || avail >= MIN_FREE_BYTES) {
|
||||
console.log(` (跳过:本机 ${tmpdir()} 当前可用 ${avail} 字节,已够用,造不出"不足"的真实环境)`);
|
||||
return;
|
||||
}
|
||||
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=/);
|
||||
|
||||
const r = runPreflight({ TMPDIR: tmpdir() });
|
||||
assert.equal(r.status, 2, '环境不足必须是 2(环境问题),不是 1(断言失败)');
|
||||
assert.match(r.stderr, /这是环境不足,不是断言失败/);
|
||||
assert.match(r.stderr, /TMPDIR=/);
|
||||
// 反向对照:喂一个充足的数 → 必须放行。
|
||||
// 没有这条,"恒报不足"的坏守卫也能让上面那条绿 —— 反面样本只挡一半。
|
||||
const ok = runPreflight(['--inject-avail=999999999']);
|
||||
assert.equal(ok.status, 0, '充足时必须放行');
|
||||
assert.match(ok.stdout, /env-preflight/);
|
||||
});
|
||||
|
||||
test('开关真的被认(不是被当成未知参数忽略掉):--inject-avail=0 必须改变判定', () => {
|
||||
// 若 `--inject-avail` 被忽略,脚本就退回"量真实临时目录"。本机 /tmp 现在是 0 字节,
|
||||
// 于是它**也会** exit 2 —— 那样上一条判据照样绿,但验的其实是机器、不是开关。
|
||||
// 这里用"充足"那个方向做判据:注入一个大数必须放行;若开关被忽略,本机当前是满的
|
||||
// ⇒ 会 exit 2 ⇒ 这条判据红。**一个只在"开关坏掉"时才红的判据。**
|
||||
const injected = runPreflight(['--inject-avail=999999999']);
|
||||
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, '真实测量不足而注入充足 ⇒ 必须按注入的走,说明开关被认');
|
||||
} else {
|
||||
// 真实测量本身就充足 ⇒ 这条没有区分力,明说而不是假装验过。
|
||||
console.log(' (本机临时目录当前充足,此条退化为弱检查:仅验 --measure 可用)');
|
||||
}
|
||||
});
|
||||
|
||||
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('直接跑 node --test 绕过前置脚本时,ENOSPC 仍被翻译成环境问题', () => {
|
||||
// 残留缺口(写进注释是必须的):`node --test 'test/*.test.mjs'` 会绕过
|
||||
// `npm test` 里的前置自检。所以 `session-scan.test.mjs` 里那条用例自己
|
||||
// 也带了一句 ENOSPC 兜底。这里直接对着**那段兜底逻辑的产物**断言:
|
||||
// 在临时目录写不进去时,抛出的错误信息里必须出现"环境"字样。
|
||||
const src = readFileSync(join(HERE, 'session-scan.test.mjs'), 'utf8');
|
||||
assert.match(src, /ENOSPC/, '那条用例必须自己兜底判 ENOSPC');
|
||||
assert.match(src, /环境/, '兜底信息里必须点明是环境问题');
|
||||
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/, '写点必须调用翻译函数');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user