Fatal diagnostics and Desktop crash reports
致命诊断与 Desktop 崩溃报告
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 `op
English
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 openSync with 'wx' threw ENOENT, which for that flag means the private per-process spill directory was gone (it is created once and never recreated; what removed it was not observed), 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, the boot audit listed a set of Web client rows as import failed (see console for the import error) while the real import error stayed in the renderer console. The eight visible names match exactly the rows that depend on one application batch, so a single failed load of that batch script explains the screenshot; whether the failure was a transport error, a stale-URL 404, or a truncated response was not determinable. A batch was requested exactly once, so any 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 and reported; the failed operation is 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. Control never returns to the failed operation and the event loop runs only until the release settles or times out, 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 (the backend state carries the original failure object, and a Host startup failure carries the Host's own inspected error over the fatal IPC event, because the Host's stderr and that message race and the shell reports the first failure it sees), 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. A fatal failure during shutdown writes a report without a dialog. 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. Every executed batch URL is remembered, so this holds even when the first importer from the batch was a row it did register and a later row finds itself missing. 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 across imports (within one boot the Loader imports each entry once, so a missing row gets one fallback attempt). 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 earlier decisions
This partially supersedes the native Desktop fatal recovery note: the crash report file, not the Electron console, is now the record the dialog points to, and the dialog detail carries the report-path line; that note's dialog ownership, button set, and profile recovery remain active. The uncaughtException handling extends the release mechanism of the fail-loud release note to synchronous throws.
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 (the error section is cut at 256 KiB, the Host diagnostic at 64 KiB, the renderer console tail at 64 KiB), 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:ENOENTon open (removed directory),ENOTDIRon open, andENOSPCon 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-reportunit tests cover rendering, owner-only writing, write failure, pruning that leaves other files alone, and the console tail;fatal-recoverytests 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, and plugin stderr written during that release window can push the fail-loud line out of the dialog's eight-line excerpt (the report file keeps the 64 KiB tail). 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.
中文
Problem
Desktop 应用的两起用户侧故障都是靠致命恢复对话框的截图定位的。该对话框只显示错误的最后八行,并且当时声称完整诊断已写入 Electron 控制台,而打包安装的应用从不暴露那个控制台。
Windows 上,dsh-subprocess-local 的 OutputCollector.spillAll 在子进程 stdout 的 'data' 监听器内打开 spill 文件。带 'wx' 的 openSync 抛出 ENOENT,对该标志而言这意味着每进程私有 spill 目录已不存在(它只创建一次、从不重建;是什么删除了它没有被观测到),由于没有任何进程级 handler,Desktop Host 以 Node 默认的未捕获异常输出退出。path: 属性恰好落在可见的八行里;此外关于该故障的信息一无所获。
macOS 上,启动审计把一组 Web 客户端行列为 import failed (see console for the import error),而真正的 import 错误只留在渲染进程 console 中。可见的八个名字与依赖同一个 application batch 的行完全吻合,因此该 batch 脚本的一次加载失败可以解释截图;失败是传输错误、过期 URL 的 404 还是响应截断,无法判定。batch 只请求一次,因此任何瞬态失败都是最终结果。
两起事故共享三个缺口:致命诊断没有持久化、Host 没有 uncaughtException 策略、一个可失败步骤被允许拖垮整个功能。
Decision
四条决定一并交付。
未捕获异常是致命的并会被报告;失败的操作绝不恢复。 dsh-app-boot 的 installFailLoud 为 'uncaughtException' 注册与 'unhandledRejection' 相同的 handler:向 stderr 写一条带标签的 util.inspect 诊断,在既有超时内等待界面的 release 钩子,以 1 退出。控制流不会回到失败的操作,事件循环只运行到 release 结束或超时,因为只有抛出点知道哪些状态仍然完整;在更新过程中抛出的 '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 错误(backend 状态携带原始错误对象;Host 启动失败通过 fatal IPC 事件携带 Host 自己的 inspect 错误,因为 Host 的 stderr 与该消息存在竞争,而壳只报告它看到的第一个失败),以及从 webContents 的 'console-message' 捕获的主窗口最近 error 级 console 输出(64 KiB 尾部)。报告位于 app.getPath('logs') 下,平台允许时仅所有者可读,启动时保留最新十份。关闭过程中的致命失败只写报告、不弹窗。按钮与恢复操作不变。
batch 脚本失败是按行的问题,不是启动失败。 在 ClientModuleSystem.arrive 中,传输失败(脚本 error 事件;什么都没执行)在同一 URL 上重试一次,等待该 batch 的所有行共享这次重试。加载成功但没有注册某行的脚本绝不重新执行:batch 按顺序注册各包,register() 拒绝重复,重放会在原次运行已注册的第一个包处停止。每个已执行的 batch URL 都会被记住,因此即使首个从该 batch 导入的是它已注册的行、之后另一行发现自己缺失,这一点仍然成立。两种情况下每条仍缺失的行随后加载自己的单资源 combo URL,Host 为每个包都提供该 URL;失败的 batch URL 会被记住,后续行直接走回退,而单资源 URL 跨 import 保持可重试(一次启动内 Loader 对每个条目只 import 一次,因此缺失行只有一次回退尝试)。依赖失败用消费者与依赖的名字包装。模块系统在最外层操作按行记录最后一次 import() 或 prefetch() 失败,因此 factory 执行错误与到达错误一样被捕获,assertEntriesActive 按无 fiber 的条目报告该文本。
与既有决定的关系
本 note 部分取代 Desktop 原生致命恢复:对话框指向的记录现在是崩溃报告文件而非 Electron 控制台,对话框详情多出报告路径一行;该 note 对对话框归属、按钮集合与 profile 恢复的决定仍然有效。uncaughtException 的处理把 fail-loud release note 的 release 机制扩展到同步抛出。
与 Desktop 日志体系设计的关系
Desktop 本地日志设计(Worker 独占写入的滚动 JSONL 日志、封闭事件词汇、脱敏与限流)是持续日志设计,仍是未来工作。它规定 Host stderr 与渲染进程 console 输出永不持久化、错误只以固定分类进入日志。本决定划出一个例外:致命失败的一次性崩溃报告保留原始错误以及保留的 stderr 与 console 尾部,因为两次现场定位需要的正是这些原始内容,而报告有界(error 段截至 256 KiB、Host 诊断 64 KiB、渲染进程 console 尾部 64 KiB)、本地、仅所有者可读,且只在应用已经弹出致命对话框时写入。对话框标明文件名,用户知道自己会分享什么。任何非崩溃报告的内容仍遵循持续日志的隐私姿态。
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 超时的时间,因此致命对话框可能在失败后最多两秒才出现,且 release 窗口内插件写到 stderr 的内容可能把 fail-loud 那一行挤出对话框的八行摘录(报告文件保留 64 KiB 尾部)。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 字段的部署可调项。