From 1de93feabcda5ba387abcd76a02e1fe684573159 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 25 Sep 2026 04:15:37 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E6=A1=A5):=20=E3=80=8C=E8=AF=BB=E4=B8=8D?= =?UTF-8?q?=E5=87=BA=E6=9D=A5=E3=80=8D=E8=A2=AB=E5=BD=93=E6=88=90=E3=80=8C?= =?UTF-8?q?=E4=B8=8D=E5=AD=98=E5=9C=A8=E3=80=8D=E2=80=94=E2=80=94=20?= =?UTF-8?q?=E8=BF=99=E4=B8=80=E8=A1=8C=E6=8A=8A=20dsh=20=E7=9A=84=E9=82=AE?= =?UTF-8?q?=E4=BB=B6=E9=80=9A=E9=81=93=E6=95=B4=E6=9D=A1=E5=BC=84=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 这条链上。 --- plugins/dsh-mail-bridge/src/index.ts | 31 ++++- .../test/persisted-cwd-not-found.test.mjs | 108 ++++++++++++++++++ 2 files changed, 134 insertions(+), 5 deletions(-) create mode 100644 plugins/dsh-mail-bridge/test/persisted-cwd-not-found.test.mjs diff --git a/plugins/dsh-mail-bridge/src/index.ts b/plugins/dsh-mail-bridge/src/index.ts index 99199c0..e34e668 100644 --- a/plugins/dsh-mail-bridge/src/index.ts +++ b/plugins/dsh-mail-bridge/src/index.ts @@ -687,9 +687,26 @@ export function apply(ctx: any, config: PluginConfig): void { * 「新开会话」那条路,用回同一个 `mail-` —— 而那个 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 { 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; } } diff --git a/plugins/dsh-mail-bridge/test/persisted-cwd-not-found.test.mjs b/plugins/dsh-mail-bridge/test/persisted-cwd-not-found.test.mjs new file mode 100644 index 0000000..649602f --- /dev/null +++ b/plugins/dsh-mail-bridge/test/persisted-cwd-not-found.test.mjs @@ -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))), + '未变异的源码必须满足"区分"条件(否则这条自检恒假,等于没写)'); +});