Skip to content

错误处理接口:在 0.2.13 基线上重建失败语义 - #17

Open
KuramiWan wants to merge 1 commit into
eleckoi:mainfrom
KuramiWan:feat/error-handling-v0213
Open

KuramiWan wants to merge 1 commit into
eleckoi:mainfrom
KuramiWan:feat/error-handling-v0213

Conversation

@KuramiWan

@KuramiWan KuramiWan commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Summary

这套设计回答一个问题:一个失败从产生到用户看见,要过六条边界(领域 → Remote 出口 → 子进程 / 运行时插件 → IPC → 渲染层),怎么让它不丢语义,同时让三个读者各读到该读的东西——用户读一句话,开发者读一行日志,程序读一个码。

今天的问题正好相反:失败在第二条边界上就只剩一句话。用户看到开发者写的中文句,程序拿不到码、只能比字符串,日志里没有能回去查的东西。所以这套设计做四件事,外加一个执行者。

一个失败的一生

① 领域抛出        DesktopError(码, 开发者诊断, 参数化详情)
                    │  码与详情从这里开始存在
② Remote 出口      原样过网关:失败自己就是合法载体,不被折成 gateway/internal
③ 子进程 fatal 帧   带码、详情、栈;子进程挂了也有码,不是一句 message
④ 运行时插件出口    同一个出口:一行日志(动作键 + 码 + 文案 + 详情)
⑤ Electron IPC     失败作为对象返回,不跨进程抛(Error 跨进程只剩 message)
⑥ 渲染层           唯一解析点:码 → 用户句;通道 → 就地 or 对话框
                    │  用户读 text,开发者读 diagnostic,程序读 code
                    ▼
              「找不到预设。」+ 可复制的码与详情

一、用户可见文案只有一个来源,而且不在抛出点。
抛出点保留原中文句,但它是开发者诊断——进日志,可以带 id 和路径。用户读的那句话由渲染层按码给:

- throw new Error('找不到这个预设:' + id)                        // 用户会读到这句话
+ throw new DesktopError(ELECKOI_FAILURE_CODES.agentPresetNotFound,
+                       `找不到这个预设:${id}`, { what: '预设' }) // 日志读到这句;用户读「找不到预设。」

好处有三个:同一个失败在聊天窗口和设置面板不会出现两种说法;改文案只改一处;「用户读到开发者诊断」这种事故在结构上不会再发生。这是对仓库「文案就地写中文」惯例的明确例外。

二、码表是内部枚举,不是对外契约。
29 条码 + 一个兜底码,形态 eleckoi/<域>/<原因>:加码自由,改名要一次全局替换;不承诺第三方或 Android 按码实现——实测生产代码里按 eleckoi/* 分支的地方是 0 处,没有消费者就不该有承诺。兜底码的规则是不猜:认不出的失败折成 eleckoi/internal,把原码记进 details.what。猜成某个域码,等于给调用方一个假的可判别失败。

三、严重度是场景的属性,不是失败的属性。
同一个 io/failed,在读一张角色卡时是功能内失败,在启动读配置时是整个应用的失败。所以通道默认由码给(5 条应用级码:plugin/load-failed、db/schema-mismatch、storage/corrupt、sender/untrusted、lifecycle/disposed),调用点只能上调——解析函数的参数里根本没有下调方向,「只能上调」是结构保证。两条通道:inline 就地失败面 + 重试;app 对话框 / 窗口级提示。

四、日志一行读完,不用回查码表。
一行是「动作键 + 码 + 开发者诊断 + 参数化详情 + 时间」:

动作 conversations.delete 失败:[eleckoi/lookup/not-found] 找不到这个会话:abc(what: 会话)

动作键是有语义的操作名,不是随机 id。启动写一行、正常退出写一行——有 start 没 end,就是崩溃或强杀。日志自己写不了就降级 stdout + 一条 stderr,不让日志成为应用起不来的原因。

两条硬约束撑住上面这些:失败按形状识别,不用 instanceof(dsh-product-data 把契约包内联了,跨 bundle 副本原型不同,instanceof 必假,码与详情会整条丢掉);过边界不重新包信封,客户端拆 Remote 结果时直接 throw result.error,保留官方失败实例。明确不做:冻结公开契约、按失败原因决定恢复策略、给副窗口补 toast、引入 lint / 格式化链、全面 Result 化、运行时错误服务。

闸是这套设计的执行者。 check:error-contract 四条规则:禁止丢码、契约违例必须带 issues、空 catch 必须具名(failureAlreadyPublished / rejectionIsAwaitedElsewhere / bestEffort)、不许裸抛。四条都是绝对值,不建豁免账本;只看代码位置,作者脚本示例里出现的 throw new Error 不会被误判。

这份 diff 里是什么。 373 个文件:55 个是新地基(契约、折叠、闸、失败面组件、日志);180 个是把裸抛换成带码抛出——同一句式替换,其中 127 个文件只做这一件事;77 个是接线(preload、IPC、窗口失败面、日志挂点),用户可见行为的变化在最后这一桶;43 个是既有测试的断言更新;18 个是闸 / 生成物 / 文档 / 配置。其中 8 个文件不是本线的改动,是并入的上游 PR #16(Windows CI 基线修复):0.2.13 在 windows-latest 上本来就红 7 条,不并它,本 PR 的 Windows CI 看不出错误处理改动的真实结果。想读的话按这个顺序:packages/shared/src/contracts/errors/ → 任选一条边界(ipc.ts + desktopIpcHandler.ts)→ apps/web/src/utils/failureText.js。

提交只有一条。 开发期按 15 张票推进,留下 240 个提交(含 85 个工作票的合并气泡)。它们对审阅没有帮助,合进 main 还会把历史弄乱,所以分支压缩成一个提交。逐票历史留在 fork 的 archive/eh13-dev-history,想知道某处为什么这么改可以去那里查。合并方式不限:分支只剩一条提交,merge / squash / rebase 在 main 上都只多一个节点。

Evidence

闸:四条规则从基线收到绝对值。 同一脚本分别跑基线 a5bb881(696 个源文件)与 tip ab4e437(725 个):

                基线    现在
禁止丢码        38      0
契约违例 issues  0       0
空 catch        71      0
裸抛            1184    0

Windows 真机(并入 #16 后,C:\Users\15985\Desktop\code\ElecKoi-eh13,Node 24.20.0 / pnpm 10.32.1):unit 10 文件 / 84 用例 · ui 174 / 1256 · host 63 / 497 · tooling 9 / 43 · integration 关键 2 文件 27 用例——共 258 文件 / 1907 用例,0 失败;闸测试在真实 CRLF 检出下 8/8。GUI 组(desktop / browser 与应用窗口)在这台机器上起不来:Electron 44 启动即 APPCRASH 0x80000003(最小窗口脚本、交互式计划任务、--no-sandbox 都一样),与本次改动无关,故未纳入。

Windows CI(windows-latest,本 PR 的 run 39931028086):pnpm test 326 文件 / 2260 用例全过;pnpm build(含四条契约规则与全部生成物校验)、Electron SQLite ABI、DSH 握手三步也全过,整条 19m30s(fork 上同一 commit 另跑过一遍,17m36s,也是绿的)。这一轮 CI 抓出并修掉 3 个只在 Windows 出现的问题:闸测试的 CRLF + shebang、契约文档生成器读 details.ts 的正则、码表生成器把 D:\ 裸路径当 ESM 导入。

用户看到的是按码的句子,不是开发者诊断。

  • Before: 渲染层 103 处把 error?.message 当文案;主窗口写着 页面加载失败:{error?.message}
  • After: 全部走 resolveFailure(...).text。renderer-failure-text-wiring.test.jsx 喂 DesktopError(lookupNotFound, '开发者诊断:找不到 x', { what: '角色卡' }),断言界面显示 找不到角色卡。 且不含「开发者诊断」;裸 Error 仍用它的 message

应用级码真的走应用级通道。

  • Before: channel 只被当成 CSS class,storageCorrupt 在设置面板里就地显示成开发者诊断
  • After: failure-alert-escalation.test.jsx:storageCorrupt 升级到窗口级对话框,lookup/not-found 仍就地,无订阅者时退回就地兜底

IPC 不再跨进程抛异常。

  • Before: ipcMain.handle 的 handler 抛错,跨进程只剩 message
  • After: 主进程 9 处 handler 返回结构化失败,preload 11 处解包;ipc-failure.test.ts 断言码与详情原样过结构化克隆

全量门禁(bash eh13-final-gates.sh,在并入 #16 之前的集成分支 ab4e437 上跑;其后的 17 个文件(#16 的 8 个 + 四处收口)由上面的 Windows 真机与本 PR 的 CI 覆盖)

fast               EXIT=0     12 步快闸 + typecheck
test               EXIT=1     326 文件 / 2260 用例 → 2254 通过 · 4 skipped · 2 失败(均为环境红)
electron-dsh       EXIT=0     5 个探针全过
dsh-runtime        EXIT=0     16 bundle / 288 pinned
desktop-build      EXIT=0
dsh-renderer       EXIT=0
built-preload      EXIT=0
native-frame       EXIT=0

两个环境红:android-apk-resources(缺 Pillow,本机 python3 没有 pip)、updater-network(Windows autoUpdater 用例,Linux 平台不匹配——补上 X server 后仍红在同一断言)。

Merge Danger

Door: one-way(对约定)/ two-way(对代码)

这是这条设计第一次对上游表态。 合并即把「抛码 + 渲染层出文案」定成基线:规则 4 现在默认判失败,新写的裸抛当场红。代码可以整体 revert,但 revert 会连带撤掉闸、生成的契约文档与 ADR 0040,回退成本高于一般重构。并进来的 #16 是上游已开的那条 Windows CI 修复:本 PR 需要它,Windows CI 才能反映错误处理改动的真实结果;#16 合入后这些提交自动变成 no-op。

Blast Radius: repo-wide

  • 领域 / 宿主 / 运行时插件 / 客户端 bundle 的抛出形态都变了;依赖 error.message 或 instanceof 的外部代码要改读 .code(仓库内已扫干净)。
  • 失败过边界复用 isDSHRemoteError 标记:拿到 IPC 失败对象的 DSH 代码会把它当 RemoteError,这是有意为之;代价与退路写在 packages/shared/src/contracts/errors/ipc.ts 文件头。
  • 三个生成物进入「改源要重渲」的约束:客户端码表、docs/error-contract.md、插件 API 参考。
  • 已发布的迁移文件被改成带码抛出(行为等价),审阅时值得看一眼。
  • 并入 修复 Windows 桌面 CI 基线:Pillow、工具结果鉴权与三处测试环境 #16 的 8 个文件:改的是测试 / CI 配置与工具结果的 Windows 鉴权;与本线改动没有冲突,等 修复 Windows 桌面 CI 基线:Pillow、工具结果鉴权与三处测试环境 #16 上游合并后这些提交是 no-op。
  • 剩余未做:25 处功能内失败面仍是 <p role="alert"> 直显(关键窗口先接,组件与接线已就绪)。

请求维护者

这套写法只有在后续改动里被使用才算成立。合入后请按错误处理协作声明写新的失败,要点六条:

  1. 抛出带码:DesktopError / productFailure;message 写开发者诊断(日志用),不是给用户看的句子;
  2. 不拆失败信封:RemoteResult 走 unwrapOrFail 或 throw result.error,不写 new Error(result.error.message);
  3. 用户可见文案只在渲染层:resolveFailure(...).text;宿主、领域、运行时插件不拼用户句子;
  4. 空 catch 必须具名(failureAlreadyPublished / rejectionIsAwaitedElsewhere / bestEffort)或写非空处理体;
  5. 新码按 codes.ts → details.ts → 文案表 → 重生成 → 测试的顺序加;生成物不手改;
  6. 提交前跑 pnpm check:error-contract:四条规则都是绝对值,不建豁免账本。

哪条做不到或有异议,请在合并前说——合入后这几条由闸执行,不再是建议。

@KuramiWan

Copy link
Copy Markdown
Contributor Author

commit 有点多,review 合并的时候可以squash下

失败改成一套封闭的码表与几个明确的边界,不再靠裸 Error 和就地拼文案。

- 码表:eleckoi/<域>/<原因>,29 个领域码 + 兜底 eleckoi/internal;详情参数限定
  8 个(field/issues/path/operation/guard/what/expected/actual)。
- 边界:① 产生地 → ② 宿主出口 → ③ IPC(失败以对象过桥)→ ④ 插件运行时
  → ⑤ preload 解包 → ⑥ 渲染层唯一文案入口。形状判定,不依赖 instanceof。
- 呈现:行内 FailureAlert 与应用级 AppToast → AppErrorDialog 两条通道;严重度
  默认由码决定,调用点只能升级到应用级,没有降级参数。
- 闸:check:error-contract 的四条绝对规则(码不许丢、契约失败必须带 issues、
  空 catch 必须命名、不许裸抛);docs/error-contract.md 与客户端码表由生成器产出。
- 协作:docs/error-handling.md 写清加码与接边界的步骤;ADR 0040 记 15 条决策。

含 Windows CI 基线的修复(上游 eleckoi#16),否则 Windows 上八处既有失败挡住本改动。

373 个文件,+10215/−1862。开发期的逐票历史留在 fork 的 archive/eh13-dev-history 分支。
@KuramiWan
KuramiWan force-pushed the feat/error-handling-v0213 branch from 3ba241f to acecf81 Compare October 23, 2026 09:19

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant