`persistedCwd()` 原来是 `catch { return undefined }`,把 readSession 的**三种**
抛出情形压成同一个「磁盘上没有」。调用方只把 `undefined` 读作"可以 create":
读失败(格式迁移拒绝 / 日志损坏)→ 当成不存在 → 走 create
→ 磁盘上**确实有**那个 id ⇒ `session "…" already exists`
⇒ 该会话的邮件全投不进去,而日志里只有 create 的错,
**真正的读失败被那个 catch 吃掉了**。
2026-09-19 DSH 升到 0.1.5-rc.2 后 40 个 `mail-*` 会话全部命中。
修法:`readSession` 的报错**本来就带可区分的 code**
(`dsh-session-query` 的 `notFound()` 给 `SESSION_QUERY_SESSION_NOT_FOUND`;
格式/损坏给 `SESSION_QUERY_CORRUPT_SESSION` / `SESSION_QUERY_PERSISTENCE_FAILED`)。
现在**只有 `SESSION_QUERY_SESSION_NOT_FOUND` 返回 `undefined`**,其余一律抛出,
让原文错误浮到调用方 —— 不再降级成"不存在"。
判据 `test/persisted-cwd-not-found.test.mjs`(2 条,已进 `npm test` 门禁)钉的是
**区分本身**,不是"有没有 try/catch"。三个变异逐个验过:
① catch 改回无条件 `return undefined` ⇒ 红
② 任何抛错都返回 undefined ⇒ 红
③ `throw e` 提到 code 判断之前("不存在"也抛 ⇒ create 不可达)⇒ 红
★ 变异③第一次**没落在目标上**:全局正则命中了文件里第一个无关的
`catch (e: any)`,判据全绿 —— 于是把它写成自检里的一条断言(变异必须真的落下),
避免这条自检本身变成恒真的假判据。
顺带记两个事实:
- `src/index.ts` 是桥的真源,`dist/` 是部署产物(`.gitignore` 忽略);已 `tsc` 重建并在产物里复验。
- 姊妹桥(pi/opencode/zcode)不含 `persistedCwd`,本缺陷只在 dsh 这条链上。
109 lines
5.9 KiB
JavaScript
109 lines
5.9 KiB
JavaScript
import assert from 'node:assert/strict';
|
||
import { readFileSync } from 'node:fs';
|
||
import { dirname, join } from 'node:path';
|
||
import { test } from 'node:test';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
/*
|
||
* 「读不出来」不是「不存在」—— 这一行曾把 dsh 的邮件通道**整条弄断**。
|
||
*
|
||
* 事故(2026-09-19,DSH 升到 0.1.5-rc.2 后):`persistedCwd()` 的
|
||
* `catch { return undefined }` 把三种抛出情形压成同一个「磁盘上没有」,而调用方
|
||
* 只把 `undefined` 读作"可以 create"。于是格式迁移拒绝 → 当成不存在 → 走 create
|
||
* → 磁盘上**确实有**那个 id ⇒ `session "…" already exists` ⇒ 该会话的邮件
|
||
* 全部投不进去。40 个 `mail-*` 会话全部命中,且日志里只有 create 的错,
|
||
* **真正的读失败被那个 catch 吃掉了**。
|
||
*
|
||
* 为什么这个形状值得单独钉:它在"不报错的错"这一族里最典型 ——
|
||
* 读失败被降级成"没有",bug 就从"报一句错"变成"静默走错分支"。
|
||
* 本仓已经为同一形状付过几次学费(判据自匹配、`read_thread` 会话作用域、
|
||
* 「取链里第一张证书」),所以这里用判据把区别固定下来。
|
||
*
|
||
* 判据钉的是**区分本身**(哪些 code 允许 create、哪些必须抛),
|
||
* 而不是"有没有 try/catch"——后者在错误修法下照样能通过。
|
||
*/
|
||
|
||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||
const SRC = readFileSync(join(HERE, '..', 'src', 'index.ts'), 'utf8');
|
||
|
||
/** 剥掉注释后再断言"代码里有什么"(本仓规范:判代码读剥离版,判理由读原文) */
|
||
const codeOnly = src =>
|
||
src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/[^\n]*/g, '$1');
|
||
|
||
/** 取某个函数/分支的正文(按花括号配对,不看窗口) */
|
||
function blockAt(src, needle) {
|
||
const at = src.indexOf(needle);
|
||
if (at < 0) return '';
|
||
const open = src.indexOf('{', at);
|
||
let depth = 0;
|
||
for (let i = open; i < src.length; i++) {
|
||
if (src[i] === '{') depth++;
|
||
else if (src[i] === '}') { depth--; if (depth === 0) return src.slice(open + 1, i); }
|
||
}
|
||
return '';
|
||
}
|
||
|
||
const FN = 'async function persistedCwd(';
|
||
/** dsh-session-query 的分类里,「确实不存在」是**唯一**允许 create 的那个 */
|
||
const ABSENT = 'SESSION_QUERY_SESSION_NOT_FOUND';
|
||
|
||
test('★ persistedCwd 必须区分「不存在」与「读不出来」(只有前者能 create)', () => {
|
||
assert.ok(SRC.indexOf(FN) >= 0, '找不到 persistedCwd —— 结构变了,判据要跟着改');
|
||
const body = codeOnly(blockAt(SRC, FN));
|
||
assert.ok(body.length > 50, 'persistedCwd 正文太短,八成是结构变了');
|
||
|
||
// ① 存在性判据:必须**只**对"确实不存在"这个 code 返回 undefined
|
||
assert.match(body, new RegExp(`code\\s*===\\s*['"\`]${ABSENT}['"\`]`),
|
||
`只有 ${ABSENT} 才允许当成"磁盘上没有"。`
|
||
+ '少了这个 code 判断,就是把"读失败"和"不存在"又压回同一个返回值 —— '
|
||
+ '那正是让 40 个会话的邮件全投不进去的那一行。');
|
||
assert.match(body, /return undefined/,
|
||
'「不存在」这一支仍要返回 undefined(它是调用方 create 的唯一依据)');
|
||
|
||
// ② 非存在性:读失败必须**抛出**,不许静默吞掉
|
||
assert.match(body, /throw e\b/,
|
||
'读失败必须抛出(让原文错误浮到调用方)—— 降级成 undefined 会让 bug '
|
||
+ '从"报一句错"变成"静默走错分支",而日志里只剩 create 那句误导人的错。');
|
||
|
||
// ③ 顺序/可达性:throw 不能被无条件提前(否则"不存在"也走不到 create)
|
||
const codeAt = body.indexOf(ABSENT);
|
||
const throwAt = body.indexOf('throw e');
|
||
assert.ok(codeAt >= 0 && throwAt > codeAt,
|
||
'`throw e` 必须在 code 判断**之后** —— 反过来的话,连"确实不存在"也会抛,'
|
||
+ 'create 分支永远不可达(邮件会话再也建不出来)。');
|
||
|
||
// ④ 残余风险:catch 里不许出现无条件的 `return undefined`
|
||
// (写法上最容易的错:加了 code 判断,却把 return 留在最前面)
|
||
const catchBlock = body.slice(body.indexOf('catch'));
|
||
assert.doesNotMatch(catchBlock, /catch\s*\([^)]*\)\s*\{\s*return undefined\s*;?\s*\}/,
|
||
'catch 里不许无条件 `return undefined` —— 那等于把这次的修复又改回去了。');
|
||
});
|
||
|
||
/*
|
||
* 变异自检:判据本身要能红。
|
||
*
|
||
* 不复用被测源码,而是喂给同一套断言"变异后的文本"—— 判据写松了(例如只找
|
||
* `return undefined` 就算了)这里会立刻暴露。
|
||
*/
|
||
test('★ 判据自检:把修复回退成事故版本,本文件必须能发现', () => {
|
||
// 只在 persistedCwd **这个函数内部**换掉 catch —— 用全局正则会命中文件里
|
||
// 第一个无关的 `catch (e: any)`(本仓实测踩过:变异没落在目标上,
|
||
// 自检于是变成恒真的假判据)。
|
||
const at = SRC.indexOf(FN);
|
||
const body = blockAt(SRC, FN);
|
||
const bodyStart = SRC.indexOf('{', at) + 1;
|
||
const mutated = SRC.slice(0, bodyStart)
|
||
+ body.replace(/catch \(e: any\) \{[\s\S]*\n \}/, 'catch {\n return undefined;\n }')
|
||
+ SRC.slice(bodyStart + body.length);
|
||
assert.notEqual(mutated, SRC, '变异必须真的落下去(否则下面那条自检恒真,等于没写)');
|
||
const mutatedBody = codeOnly(blockAt(mutated, FN));
|
||
const distinguishes = body => new RegExp(`code\\s*===\\s*['"\`]${ABSENT}['"\`]`).test(body)
|
||
&& /throw e\b/.test(body);
|
||
assert.equal(distinguishes(mutatedBody), false,
|
||
'把 catch 改回无条件的 `return undefined`(事故版本)后,"区分"条件必须为假 —— '
|
||
+ '若这里仍为真,说明判据松到发现不了这个回归。');
|
||
// 反向:原样源码必须为真(否则这条自检恒假,等于没写)
|
||
assert.ok(distinguishes(codeOnly(blockAt(SRC, FN))),
|
||
'未变异的源码必须满足"区分"条件(否则这条自检恒假,等于没写)');
|
||
});
|