Files
MailUI4Agents/plugins/dsh-mail-bridge/test/persisted-cwd-not-found.test.mjs
JianFeeeee 1de93feabc fix(桥): 「读不出来」被当成「不存在」—— 这一行把 dsh 的邮件通道整条弄断
`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 这条链上。
2026-09-25 04:15:37 +08:00

109 lines
5.9 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.

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))),
'未变异的源码必须满足"区分"条件(否则这条自检恒假,等于没写)');
});