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 这条链上。
This commit is contained in:
2026-09-25 04:15:37 +08:00
parent 1158681f01
commit 1de93feabc
2 changed files with 134 additions and 5 deletions

View File

@ -687,9 +687,26 @@ export function apply(ctx: any, config: PluginConfig): void {
* 「新开会话」那条路,用回同一个 `mail-<session_id>` —— 而那个 id 上一次
* 已经落过盘。只能问持久化层,因为这是重启后唯一还存在的事实来源。
*
* 读不到就当作不存在:`readSession` 在会话不存在、日志不可读、replay 校验
* 不过时都会抛。三种情形里只有第一种适合 create,但后两种 resume 也一样
* 救不回来 —— 那就让 create 去报它自己的错。
* ★★★ 2026-09-25:「读不出来」**不是**「不存在」(这行曾把邮件通道整条弄断)
*
* 原来这里 `catch { return undefined }` —— 把**三种**抛出情形压成同一个
* 「磁盘上没有」。而调用方只把 `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`**(真的可以 create);
* 其余一律**抛出**(让原文错误浮到调用方,别再降级成"不存在")。
* 这与本仓反复吃到的同一形状:「我没看到」≠「不存在」——
* 一旦把读失败降级成"没有",bug 就从"报一句错"变成"静默走错分支"。
*/
async function persistedCwd(sessionId: string): Promise<string | undefined> {
const q: any = (ctx as any).get?.('sessionQuery');
@ -697,8 +714,12 @@ export function apply(ctx: any, config: PluginConfig): void {
try {
const snap = await q.readSession(sessionId);
return snap?.header?.cwd ?? '';
} catch {
return undefined;
} catch (e: any) {
// 只有「确实不在磁盘上」才允许 create;其余是**读失败**,必须出声。
if (e?.code === 'SESSION_QUERY_SESSION_NOT_FOUND') return undefined;
console.error(`[dsh-mail-bridge] 读会话 ${sessionId} 失败(**不是**"不存在",不降级为 create): `
+ `${e?.code || '(无 code)'}: ${e?.message || e}`);
throw e;
}
}

View File

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