diff --git a/.agents/notes/implemented/architecture/2026-09-22-fatal-diagnostics-and-crash-reports.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-22-fatal-diagnostics-and-crash-reports.i18n.yaml new file mode 100644 index 0000000000..aad257c7e9 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-22-fatal-diagnostics-and-crash-reports.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-22-fatal-diagnostics-and-crash-reports.md +2026-09-22-fatal-diagnostics-and-crash-reports.md: 64a1b8af221c62a650ce5da123b1341ac96ff46c +2026-09-22-fatal-diagnostics-and-crash-reports.zh.md: ea83cbbad62a60c134bcc576161672f47efc7e0e diff --git a/.agents/notes/implemented/architecture/2026-09-22-fatal-diagnostics-and-crash-reports.md b/.agents/notes/implemented/architecture/2026-09-22-fatal-diagnostics-and-crash-reports.md new file mode 100644 index 0000000000..64a1b8af22 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-22-fatal-diagnostics-and-crash-reports.md @@ -0,0 +1,57 @@ +# Agent Note: Fatal diagnostics and Desktop crash reports + +Status: implemented + +English | [中文](2026-09-22-fatal-diagnostics-and-crash-reports.zh.md) + +## Problem + +Two field failures of the Desktop application were diagnosed from screenshots of the fatal recovery dialog, which shows the last eight lines of the error and used to promise that the full diagnostic was in the Electron console. A packaged installation never surfaces that console. + +On Windows, `OutputCollector.spillAll` in `dsh-subprocess-local` opened its spill file inside the child's stdout `'data'` listener. The private per-process spill directory had been removed while empty, the `openSync` threw `ENOENT`, and because no process-level handler existed the Desktop Host died with Node's default uncaught-exception output. The `path:` property happened to land in the eight visible lines; nothing else about the failure was recoverable. + +On macOS, one application batch script of the Web client failed to load once. Every row in the batch and every row depending on one of them failed, and the boot audit listed each as `import failed (see console for the import error)` while the real import error stayed in the renderer console. The batch was requested exactly once, so a transient failure was final. + +Both cases shared three gaps: fatal diagnostics were not persisted, the Host had no `uncaughtException` policy, and a single fallible step was allowed to fail a whole feature. + +## Decision + +Four decisions ship together. + +**Uncaught exceptions are fatal, reported, and never resumed.** `installFailLoud` in `dsh-app-boot` registers the same handler for `'uncaughtException'` as for `'unhandledRejection'`: write one labelled `util.inspect` diagnostic to stderr, await the surface's release hook under the existing timeout, exit 1. The process does not continue after either, because only the throw site knows which state is intact; a `'data'` listener that threw mid-update has already dropped a chunk and half-set its own fields, and resuming would turn a visible crash into silently wrong output. `util.inspect` replaces `err.stack` because a `node:fs` error's `code`, `syscall`, and `path`, and any `cause` chain, are enumerable properties the stack line omits. The rejection label `fatal load failure` is unchanged because the Web profile expected-output e2e tests match it; exceptions use `fatal uncaught exception`. + +**Spill files are best-effort at every step.** `spillPath` was already optional and a failed final close already withheld it. `spillAll` now contains every filesystem failure while opening or appending: it discards the spill, reports once through the owner's logger (`SpillOptions.onFailure`, wired to `ctx.logger.error` by the runtime and the SSH helper), and leaves the in-memory tail collecting. The removed directory is not recreated: the security of the design rests on a random name created once by this process, and recreating a known name would give that up. A fresh random directory on `ENOENT` is deferred work. + +**A fatal Desktop failure writes a crash report before its dialog.** Every `reportFatal` caller names its source (`host`, `web-boot`, `renderer`, `main`). `DesktopFatalRecovery.report` awaits `writeCrashReport` for at most one second, then shows the dialog with the file path on its own line, independent of whether the error was shortened and in the listener-conflict variant too. The report holds the source, whether the backend had reached ready, application and runtime versions, the complete inspected error, and the primary window's recent error-level console output captured from `webContents` `'console-message'` (a 64 KiB tail). Reports live under `app.getPath('logs')`, are owner-only where the platform allows, and startup keeps the ten newest. Buttons and recovery actions are unchanged. + +**A failed batch script is a per-row problem, not a boot failure.** In `ClientModuleSystem.arrive`, a transport failure (the script `error` event; nothing executed) is retried once on the same URL, shared by every row waiting on that batch. A script that loaded without registering a row is never re-executed: the batch registers packages in sequence and `register()` rejects duplicates, so a replay would stop at the first package the original run did register. Either way each still-missing row then loads its own one-resource combo URL, which the Host serves for every package; a failed batch URL is remembered so later rows skip to their fallback, while a one-resource URL stays retryable. Dependency failures are wrapped with the consumer and dependency names. The module system records the last `import()` or `prefetch()` failure per row at the outer operation, so factory execution errors are captured as well as arrival errors, and `assertEntriesActive` reports that text per fiberless entry. + +## Relationship to the Desktop logging design + +The Desktop local logging design (a Worker-owned rolling JSONL log with a closed event vocabulary, redaction, and rate limits) is a continuous-log design and remains future work. It stated that Host stderr and renderer console output are never persisted and that errors reach the log only as fixed categories. This decision carves out one exception: the one-time crash report of a fatal failure keeps the raw error and the retained stderr and console tails, because that raw content is what both field diagnoses needed and the report is bounded, local, owner-only, and written only when the application is already showing its fatal dialog. The dialog names the file so a user knows what they would be sharing. The continuous-log privacy posture is unchanged for anything that is not a crash report. + +## Testing + +- `dsh-app-boot`: uncaught exceptions produce the labelled diagnostic with enumerable properties and the cause chain, share the exit latch and release timeout with rejections, and both handlers uninstall. +- `dsh-subprocess-local`: `ENOENT` on open (removed directory), `ENOTDIR` on open, and `ENOSPC` on append after the file exists each leave the tail byte-exact, withdraw the spill file, report once, and never throw. +- `dsh-client-modules`: a transport failure retries once and shares the retry; a persistent failure falls back per missing row without re-fetching the batch per row; a batch that registered nothing or only some rows is not re-executed; a one-resource URL stays retryable; the final error lists every attempt; import errors are recorded for transport, factory, and dependency failures and cleared by success and invalidation. +- `dsh-client-web`: the boot audit names the recorded import error when the module system is supplied. +- Desktop: `crash-report` unit tests cover rendering, owner-only writing, write failure, pruning that leaves other files alone, and the console tail; `fatal-recovery` tests pin the dialog text with and without a report path in both locales, the one-second bound, and a single write per fatal; the startup harness asserts the report input for a running-phase Host exit including the captured renderer console. + +## Consequences + +The Host now exits through one path for both failure kinds, up to the release timeout later than Node's immediate default exit, so a fatal dialog can appear up to two seconds after the failure. Spill failures are no longer visible to the user beyond a truncated result with no path; the plugin logger line is the only trace, which the Host-side continuous log design would persist. Crash reports persist raw stderr and console tails, accepted for the reasons above. A batch load failure now costs up to three requests per missing row and never fails registered rows; the shared-batch memory means a later import of a row from a failed batch goes straight to its one-resource URL for the life of the page. + +## Alternatives considered + +**Catch the uncaught exception and keep the Host running.** Rejected: the state after a listener throws is unknown to anything but the throw site. In the Windows case, continuing would have dropped the chunk, advertised a spill path to a file that did not exist, and thrown again on every following chunk. + +**Recreate the spill directory on `ENOENT`.** Rejected for this change: recreating a known name gives up the random-directory property the design exists for. A fresh `mkdtemp` on failure needs the collector to hold a directory provider rather than a string and is deferred. + +**Write the crash report synchronously so the dialog stays synchronous.** Rejected: a hung write on a slow or network-mounted profile directory would freeze the shell with no dialog at all. The asynchronous write with a one-second bound shows the dialog either way. + +**Persist only classified error categories in the crash report, as the continuous-log design prescribes.** Rejected for crash reports: the `path:` property and the renderer's `bundle script … failed to load` line were the load-bearing evidence in both diagnoses and would have been reduced to `UNCLASSIFIED`. + +**Retry the batch URL for every failure kind.** Rejected: re-executing a batch that already registered some packages throws `duplicate factory registration` inside the script, the `load` event still fires, and the caller sees the same "loaded without registering" it started with, unable to tell that the retry caused it. + +**Keep more generations of Host batch URLs so a recomposed graph does not 404 an in-flight boot.** Deferred rather than shipped: no production trigger for a post-ready graph change was found, `lazyBody` caches response buffers so retention has a real memory cost, and the generation and age limits are deployment tunables that need `Config` fields. diff --git a/.agents/notes/implemented/architecture/2026-09-22-fatal-diagnostics-and-crash-reports.zh.md b/.agents/notes/implemented/architecture/2026-09-22-fatal-diagnostics-and-crash-reports.zh.md new file mode 100644 index 0000000000..ea83cbbad6 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-22-fatal-diagnostics-and-crash-reports.zh.md @@ -0,0 +1,57 @@ +# Agent Note: 致命诊断与 Desktop 崩溃报告 + +Status: implemented + +[English](2026-09-22-fatal-diagnostics-and-crash-reports.md) | 中文 + +## Problem + +Desktop 应用的两起用户侧故障都是靠致命恢复对话框的截图定位的。该对话框只显示错误的最后八行,并且当时声称完整诊断已写入 Electron 控制台,而打包安装的应用从不暴露那个控制台。 + +Windows 上,`dsh-subprocess-local` 的 `OutputCollector.spillAll` 在子进程 stdout 的 `'data'` 监听器内打开 spill 文件。每进程私有 spill 目录在空置期间被删除,`openSync` 抛出 `ENOENT`,由于没有任何进程级 handler,Desktop Host 以 Node 默认的未捕获异常输出退出。`path:` 属性恰好落在可见的八行里;此外关于该故障的信息一无所获。 + +macOS 上,Web 客户端的一个 application batch 脚本加载失败了一次。该 batch 中的每一行以及依赖它们的每一行全部失败,启动审计把每一条都列为 `import failed (see console for the import error)`,而真正的 import 错误只留在渲染进程 console 中。该 batch 只请求一次,因此瞬态失败即为最终结果。 + +两起事故共享三个缺口:致命诊断没有持久化、Host 没有 `uncaughtException` 策略、一个可失败步骤被允许拖垮整个功能。 + +## Decision + +四条决定一并交付。 + +**未捕获异常是致命的,会被报告,且不恢复运行。** `dsh-app-boot` 的 `installFailLoud` 为 `'uncaughtException'` 注册与 `'unhandledRejection'` 相同的 handler:向 stderr 写一条带标签的 `util.inspect` 诊断,在既有超时内等待界面的 release 钩子,以 1 退出。两种情况下进程都不继续运行,因为只有抛出点知道哪些状态仍然完整;在更新过程中抛出的 `'data'` 监听器已经丢掉了一块数据并只设置了一半自己的字段,恢复运行会把可见的崩溃变成静默错误的输出。用 `util.inspect` 替代 `err.stack`,因为 `node:fs` 错误的 `code`、`syscall`、`path` 以及 `cause` 链都是堆栈行省略的可枚举属性。rejection 的标签 `fatal load failure` 保持不变,因为 Web profile 的 expected-output e2e 测试匹配它;异常使用 `fatal uncaught exception`。 + +**spill 文件在每一步都是尽力而为。** `spillPath` 本来就是可选的,最终关闭失败时已经会撤回它。`spillAll` 现在包住打开或追加过程中的每一种文件系统失败:丢弃该 spill,通过所有者的 logger 报告一次(`SpillOptions.onFailure`,由运行时和 SSH helper 接到 `ctx.logger.error`),并让内存尾部继续收集。被删除的目录不会重建:该设计的安全性依赖于由本进程一次性创建的随机名称,重建一个已知名称就放弃了这一点。在 `ENOENT` 时新建随机目录是待办工作。 + +**Desktop 致命失败在弹窗前写入崩溃报告。** 每个 `reportFatal` 调用方都标明来源(`host`、`web-boot`、`renderer`、`main`)。`DesktopFatalRecovery.report` 最多等待 `writeCrashReport` 一秒,然后弹出对话框并把文件路径单独占一行——与错误是否被截短无关,端口占用变体同样如此。报告包含来源、后端是否已就绪、应用与运行时版本、完整的 inspect 错误,以及从 `webContents` 的 `'console-message'` 捕获的主窗口最近 error 级 console 输出(64 KiB 尾部)。报告位于 `app.getPath('logs')` 下,平台允许时仅所有者可读,启动时保留最新十份。按钮与恢复操作不变。 + +**batch 脚本失败是按行的问题,不是启动失败。** 在 `ClientModuleSystem.arrive` 中,传输失败(脚本 `error` 事件;什么都没执行)在同一 URL 上重试一次,等待该 batch 的所有行共享这次重试。加载成功但没有注册某行的脚本绝不重新执行:batch 按顺序注册各包,`register()` 拒绝重复,重放会在原次运行已注册的第一个包处停止。两种情况下每条仍缺失的行随后加载自己的单资源 combo URL,Host 为每个包都提供该 URL;失败的 batch URL 会被记住,后续行直接走回退,而单资源 URL 保持可重试。依赖失败用消费者与依赖的名字包装。模块系统在最外层操作按行记录最后一次 `import()` 或 `prefetch()` 失败,因此 factory 执行错误与到达错误一样被捕获,`assertEntriesActive` 按无 fiber 的条目报告该文本。 + +## 与 Desktop 日志体系设计的关系 + +Desktop 本地日志设计(Worker 独占写入的滚动 JSONL 日志、封闭事件词汇、脱敏与限流)是持续日志设计,仍是未来工作。它规定 Host stderr 与渲染进程 console 输出永不持久化、错误只以固定分类进入日志。本决定划出一个例外:致命失败的一次性崩溃报告保留原始错误以及保留的 stderr 与 console 尾部,因为两次现场定位需要的正是这些原始内容,而报告有界、本地、仅所有者可读,且只在应用已经弹出致命对话框时写入。对话框标明文件名,用户知道自己会分享什么。任何非崩溃报告的内容仍遵循持续日志的隐私姿态。 + +## Testing + +- `dsh-app-boot`:未捕获异常产生带可枚举属性与 cause 链的带标签诊断,与 rejection 共享退出闩锁和 release 超时,两个 handler 都能卸载。 +- `dsh-subprocess-local`:打开时 `ENOENT`(目录被删)、打开时 `ENOTDIR`、文件已存在后追加时 `ENOSPC`,三者都保持尾部逐字节一致、撤回 spill 文件、报告一次且不抛出。 +- `dsh-client-modules`:传输失败重试一次并共享重试;持续失败按缺失行回退且不重复请求 batch;零注册或部分注册的 batch 不重新执行;单资源 URL 保持可重试;最终错误列出每次尝试;传输、factory 与依赖失败都被记录,成功和失效会清除记录。 +- `dsh-client-web`:提供模块系统时启动审计写出记录的 import 错误。 +- Desktop:`crash-report` 单测覆盖渲染、仅所有者可读的写入、写入失败、不触碰其他文件的清理以及 console 尾部;`fatal-recovery` 测试在两种语言下钉住带与不带报告路径的对话框文本、一秒上限以及每次致命失败只写一次;启动夹具断言运行阶段 Host 退出的报告输入,包括捕获的渲染进程 console。 + +## Consequences + +Host 现在对两种失败走同一条退出路径,比 Node 立即退出的默认行为最多晚 release 超时的时间,因此致命对话框可能在失败后最多两秒才出现。spill 失败对用户不再可见,只剩一个带截断标记且没有路径的结果;插件 logger 那一行是唯一痕迹,Host 侧持续日志设计将来会持久化它。崩溃报告持久化原始 stderr 与 console 尾部,理由如上。batch 加载失败现在对每条缺失行最多花费三次请求,且绝不让已注册的行失败;共享的 batch 记忆意味着页面生命周期内,失败 batch 中某行的后续 import 会直接走其单资源 URL。 + +## Alternatives considered + +**捕获未捕获异常并让 Host 继续运行。** 否决:监听器抛出后的状态除抛出点外无人知晓。在 Windows 案例中,继续运行会丢掉该块数据、向模型宣告一个不存在文件的 spill 路径,并在之后每一块上再次抛出。 + +**在 `ENOENT` 时重建 spill 目录。** 本次否决:重建已知名称放弃了该设计赖以存在的随机目录属性。失败时新建 `mkdtemp` 需要收集器持有目录提供函数而非字符串,作为待办。 + +**同步写崩溃报告以保持对话框同步。** 否决:在缓慢或网络挂载的 profile 目录上,一次挂起的写入会让壳冻结且完全没有对话框。带一秒上限的异步写入无论如何都会弹出对话框。 + +**按持续日志设计的规定,崩溃报告只持久化分类后的错误类别。** 对崩溃报告否决:`path:` 属性和渲染进程的 `bundle script … failed to load` 那一行是两次定位的关键证据,按该规则会被抹成 `UNCLASSIFIED`。 + +**对每种失败都重试 batch URL。** 否决:重新执行一个已注册部分包的 batch 会在脚本内抛出 `duplicate factory registration`,`load` 事件照常触发,调用方看到的还是最初那个「loaded without registering」,无法分辨是重试造成的。 + +**保留更多代 Host batch URL,使重组的 graph 不会让进行中的启动收到 404。** 推迟而非交付:没有找到 ready 之后改动 graph 的生产触发者,`lazyBody` 缓存响应 Buffer 使保留有实际内存成本,代数与时限是需要 `Config` 字段的部署可调项。