diff --git a/.agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.i18n.yaml index 3c89776191..a57f31af0a 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.i18n.yaml @@ -2,5 +2,5 @@ # 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-15-desktop-native-fatal-recovery.md -2026-09-15-desktop-native-fatal-recovery.md: 94ead425f5483ba3e6ed5d9d595edb4483254c27 -2026-09-15-desktop-native-fatal-recovery.zh.md: 2138fb11405d00d51775350bf60d2702621aaa19 +2026-09-15-desktop-native-fatal-recovery.md: fc1564b02e2f7b707d2b6f6d8e0947b1a78f142d +2026-09-15-desktop-native-fatal-recovery.zh.md: ceda54b4141c5fe58e6b385c86e6c1e4958dd261 diff --git a/.agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.md b/.agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.md index 94ead425f5..fc1564b02e 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.md +++ b/.agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.md @@ -12,7 +12,7 @@ A recovery document depends on the renderer and preload whose failure can preven Electron owns one native fatal dialog per application process. Explicit main-window creation, document-load, preload, renderer, Web initialization, and backend failures enter this path. Ordinary requests retain local error handling, and the shared Web plugin manager owns package-operation failures. Expected cancellation and shutdown do not enter recovery. No elapsed-time heuristic classifies a slow startup as fatal. -The first report claims presentation before awaiting the dialog. Later reports remain in logs. The Electron console retains the complete reported diagnostic. The dialog bounds the first diagnostic to its final eight lines and limits the complete detail to 1,200 UTF-16 code units, including truncation notice and reinstall advice, because native dialogs cannot scroll. The dialog offers exit, restart, or disabling third-party bundles followed by a whole-application restart. Recovery calls the shared app-boot `sanitizeProfile` function under the existing profile lock after Host shutdown. The function restores caller-supplied bundles and renames the profile patch to a unique backup without parsing it, requiring runtime initialization, or deleting installed files. Callers own profile shutdown and write exclusion; Desktop is the current production caller. The home-level patch remains unchanged. An explicit recovery-operation failure is presented separately and does not count as another automatic fatal report. +The first report claims presentation before awaiting the dialog. Later reports remain in logs. A crash report file under the application logs directory retains the complete reported diagnostic, and the dialog names it on its own line ([fatal diagnostics and crash reports](2026-09-22-fatal-diagnostics-and-crash-reports.md)). The dialog bounds the first diagnostic to its final eight lines and limits the complete detail to 1,200 UTF-16 code units, including truncation notice, report path, and reinstall advice, because native dialogs cannot scroll. The dialog offers exit, restart, or disabling third-party bundles followed by a whole-application restart. Recovery calls the shared app-boot `sanitizeProfile` function under the existing profile lock after Host shutdown. The function restores caller-supplied bundles and renames the profile patch to a unique backup without parsing it, requiring runtime initialization, or deleting installed files. Callers own profile shutdown and write exclusion; Desktop is the current production caller. The home-level patch remains unchanged. An explicit recovery-operation failure is presented separately and does not count as another automatic fatal report. The Web document stays in place. A carrier callback owns startup failure presentation while the shared boot page retains its spinner; ordinary browser boot still renders its own failure report. Only the primary application frame may report a Web boot failure. Backend state remains in the main process, and native recovery directly owns disabling all third-party bundles. Desktop has no profile reset or emergency recovery document. A fatal backend failure requires one of the native recovery actions rather than an in-process retry. diff --git a/.agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.zh.md b/.agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.zh.md index 2138fb1140..ceda54b414 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.zh.md @@ -12,7 +12,7 @@ Status: implemented Electron 在每个应用进程中提供一次原生致命错误对话框。明确的主窗口创建、文档加载、preload、渲染器、Web 初始化和后端失败进入此路径。普通请求保留局部错误处理,共享 Web 插件管理器负责包操作失败。预期取消和关闭不进入恢复。不通过耗时推断慢启动为致命故障。 -首次报告在等待对话框前取得展示权。后续报告保留在日志中。Electron 控制台保留完整的已报告诊断。原生对话框无法滚动,因此仅显示首次诊断末尾八行,并将包含截断提示和重装建议的完整详情限制为 1,200 个 UTF-16 代码单元。对话框提供退出、重启或禁用第三方 bundle 后重启整个应用。恢复操作等待 Host 关闭后,在已有 profile 锁内调用共享 app-boot `sanitizeProfile` 函数。该函数恢复调用方指定的 bundle,并将 profile patch 重命名为唯一备份,无需解析 patch、初始化运行时或删除安装文件。调用方负责 profile 关闭并排除并发写入;当前生产调用方是 Desktop。home 级 patch 保持不变。显式恢复操作失败会单独展示,不计为另一次自动致命报告。 +首次报告在等待对话框前取得展示权。后续报告保留在日志中。应用日志目录下的崩溃报告文件保留完整的已报告诊断,对话框单独一行标明该文件([致命诊断与崩溃报告](2026-09-22-fatal-diagnostics-and-crash-reports.zh.md))。原生对话框无法滚动,因此仅显示首次诊断末尾八行,并将包含截断提示、报告路径和重装建议的完整详情限制为 1,200 个 UTF-16 代码单元。对话框提供退出、重启或禁用第三方 bundle 后重启整个应用。恢复操作等待 Host 关闭后,在已有 profile 锁内调用共享 app-boot `sanitizeProfile` 函数。该函数恢复调用方指定的 bundle,并将 profile patch 重命名为唯一备份,无需解析 patch、初始化运行时或删除安装文件。调用方负责 profile 关闭并排除并发写入;当前生产调用方是 Desktop。home 级 patch 保持不变。显式恢复操作失败会单独展示,不计为另一次自动致命报告。 Web 文档保留在原位。宿主回调负责启动失败展示,共享启动页保留加载动画;普通浏览器启动仍显示自身的失败报告。只有主应用框架可以上报 Web 启动失败。后端状态保留在主进程中,原生恢复直接负责禁用全部第三方 bundle。Desktop 不提供 profile 重置或应急恢复文档。后端致命故障必须通过原生恢复操作处理,不在当前进程中重试。 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..89a855ae78 --- /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: 01cbb24fc84b19465a15e9f82c03ae87621b5fe9 +2026-09-22-fatal-diagnostics-and-crash-reports.zh.md: 38f6a6802b61b128ab6b8ffda0465691c5244523 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..01cbb24fc8 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-22-fatal-diagnostics-and-crash-reports.md @@ -0,0 +1,61 @@ +# 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 `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](2026-09-15-desktop-native-fatal-recovery.md) 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](../bug-fix/2026-07-31-fail-loud-releases-the-terminal.md) 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`: `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, 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. 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..38f6a6802b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-22-fatal-diagnostics-and-crash-reports.zh.md @@ -0,0 +1,61 @@ +# 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 文件。带 `'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 原生致命恢复](2026-09-15-desktop-native-fatal-recovery.zh.md):对话框指向的记录现在是崩溃报告文件而非 Electron 控制台,对话框详情多出报告路径一行;该 note 对对话框归属、按钮集合与 profile 恢复的决定仍然有效。`uncaughtException` 的处理把 [fail-loud release](../bug-fix/2026-07-31-fail-loud-releases-the-terminal.zh.md) 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` 字段的部署可调项。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.i18n.yaml index 89a74eaa00..438bf04d9c 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.i18n.yaml @@ -2,5 +2,5 @@ # 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/bug-fix/2026-07-31-fail-loud-releases-the-terminal.md -2026-07-31-fail-loud-releases-the-terminal.md: a6b8ccc5a0ad885a349b32707face7bfce9d7bed -2026-07-31-fail-loud-releases-the-terminal.zh.md: 728fca424d2622af6222d51a3240eafd0e117986 +2026-07-31-fail-loud-releases-the-terminal.md: 9c8aa41e960764c6d9311c49a8757a25b3996b78 +2026-07-31-fail-loud-releases-the-terminal.zh.md: e44d4672dcf768c33d94b801809cf7aa653aa62f diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.md b/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.md index a6b8ccc5a0..9c8aa41e96 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.md @@ -29,6 +29,7 @@ The `/exit` path was never affected, because it disposes the tree and reaches th - A latch, not an uninstall, keeps the first rejection the reported one. Removing the listener during teardown would let a second concurrent rejection become uncaught, and Node would kill the process mid-teardown — stranding exactly the terminal state this restores. Later rejections, including the release's own, fall through to the pending exit. - The release is bounded by `FAIL_LOUD_RELEASE_TIMEOUT_MS` (2s) and its rejection is swallowed. A wedged or failing disposer delays the fatal exit; it never cancels it. That timer stays **referenced**: an `unref()`ed one lets Node reach an empty event loop and exit 0 on the very failure being reported, because an `unhandledRejection` listener suppresses the default fatal exit. - Omitting `release` keeps the previous behavior exactly, so the ACP, JSON-RPC, and demo bins are unchanged. +- The same handler, latch, and release run for `uncaughtException` since the [fatal diagnostics and crash reports](../architecture/2026-09-22-fatal-diagnostics-and-crash-reports.md) decision, which also switched the diagnostic to `util.inspect`. `dsh`'s TUI launcher passes a release that disposes the root context, which runs the TUI's existing `shutdown()` and hands the terminal back. diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.zh.md b/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.zh.md index 728fca424d..e44d4672dc 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.zh.md @@ -29,6 +29,7 @@ Loader 并发挂载各个条目,因此条目失败的顺序并不等于启动 - 使用闩锁(latch)而非卸载监听器,来保证被报告的始终是第一个 rejection。若在拆卸期间移除监听器,第二个并发 rejection 就会变成未捕获错误,Node 会在拆卸中途杀死进程——恰好残留下本次要恢复的终端状态。后续 rejection(包括 release 自身的)都会落入已挂起的退出流程。 - release 以 `FAIL_LOUD_RELEASE_TIMEOUT_MS`(2 秒)为上限,且其 rejection 被吞掉。卡住或失败的 disposer 只会延迟致命退出,绝不会取消它。该定时器保持 **referenced**:一旦 `unref()`,Node 就会在事件循环清空后、恰恰在报告这次失败时以 0 退出,因为 `unhandledRejection` 监听器抑制了默认的致命退出。 - 不传 `release` 时行为与此前完全一致,因此 ACP(Agent Client Protocol)、JSON-RPC 和各 demo bin 均无变化。 +- 自[致命诊断与崩溃报告](../architecture/2026-09-22-fatal-diagnostics-and-crash-reports.zh.md)决定起,同一 handler、闩锁与 release 也对 `uncaughtException` 生效,诊断同时改为 `util.inspect`。 `dsh` 的 TUI 启动器传入的 release 会释放根上下文,从而执行 TUI 已有的 `shutdown()` 并把终端交还。 diff --git a/apps/desktop-host/src/index.ts b/apps/desktop-host/src/index.ts index 5a40ec2afe..209882013e 100644 --- a/apps/desktop-host/src/index.ts +++ b/apps/desktop-host/src/index.ts @@ -1,6 +1,7 @@ /** Launch the Desktop profile through the Web application and report its URL to Electron. */ import { delimiter, join } from 'node:path' +import { inspect } from 'node:util' import { loadLayeredEnv, loadProfileDirectory } from '@deepseek-ai/dsh-app-boot' import { runProfile } from '@deepseek-ai/dsh/profile-boot' import type {} from '@deepseek-ai/dsh-client-connection' @@ -80,10 +81,17 @@ async function main(): Promise { if (process.connected) process.send?.({ type: 'ready', url, injections: ctx.webServer.collectIndexInjections() }, (error) => { if (error !== null) console.error(error) }) } +/** Upper bound of the startup diagnostic carried over IPC; the head holds the message and stack. */ +const MAX_FATAL_DIAGNOSTIC_CHARS = 64 * 1024 + if (import.meta.main) { main().catch((error: unknown) => { const message = error instanceof Error ? error.message : String(error) - if (process.connected) process.send?.({ type: 'fatal', message }, (error) => { if (error !== null) console.error(error) }) + // The shell receives the complete inspected error here, not through stderr: + // stderr bytes and this IPC message race, and the shell reports the first + // failure it sees. + const diagnostic = inspect(error, { depth: 4, maxArrayLength: 50 }).slice(0, MAX_FATAL_DIAGNOSTIC_CHARS) + if (process.connected) process.send?.({ type: 'fatal', message, diagnostic }, (error) => { if (error !== null) console.error(error) }) console.error(error) process.exitCode = 1 if (process.connected) process.disconnect() diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 1baba15db0..77500865f6 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -2,5 +2,5 @@ # 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 apps/desktop/README.md -README.md: df95691f20d434baaeed7f0062989bd28df2b35e -README.zh.md: ceefff9cc7d0aaac68f1dd86dcaadad99cfcbdcf +README.md: 2b07b2b9d95c3d16a3aaa58b6d52f55cb1242e2e +README.zh.md: 4ebae34332b1d622a35d0bf857f1f3252736512c diff --git a/apps/desktop/README.md b/apps/desktop/README.md index df95691f20..2b07b2b9d9 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The desktop application is an Electron shell around the complete dsh Web application. An Electron RunAsNode child starts the shared profile runner, and Electron immediately loads the packaged Web entry at `dsh-app://app/`. Its shared loading page waits for Host boot injections, then starts the client without navigating to another document. Electron forwards application HTTP requests to the authenticated Web Host; WebSocket streams connect to that Host with credentials attached only for the owned application window. Node IPC carries boot injections, readiness, and shutdown. Desktop defaults to port `19387`, separate from Web’s `3080`; a `webserver.config.port` patch can override it. +The desktop application is an Electron shell around the complete dsh Web application. An Electron RunAsNode child starts the shared profile runner, and Electron immediately loads the packaged Web entry at `dsh-app://app/`. Its shared loading page waits for Host boot injections, then starts the client without navigating to another document. Electron forwards application HTTP requests to the authenticated Web Host, dropping connection-level response headers (`transfer-encoding`, `connection`, `keep-alive`) that describe the Node fetch rather than the resource, and marking plugin bundle responses `no-store` because their per-launch revisions would only accumulate in Chromium's disk cache; WebSocket streams connect to that Host with credentials attached only for the owned application window. Node IPC carries boot injections, readiness, and shutdown. Desktop defaults to port `19387`, separate from Web’s `3080`; a `webserver.config.port` patch can override it. The first application-menu command, **About DeepSeek Harness**, opens Electron's native About panel with the application icon, product name, and installed release version. The menu follows the Desktop shell locale. On macOS, Hide, Hide Others, Show All, and Quit use localized labels; Hide and Quit include the DeepSeek Harness product name. These commands retain their native actions and shortcuts. macOS reads the icon from its application bundle, so an unpackaged development launch displays Electron's icon; Windows receives the packaged PNG. @@ -82,7 +82,9 @@ The [Web plugin UI](../../packages/client/ui-plugin-manager/README.md) owns the Fatal main-window creation, main-document loading, preload, renderer, Web initialization, or backend failures open one native recovery dialog per application process. It shows a bounded tail of the first error, notes any truncation, and offers Exit, Restart, and Disable third-party plugins, back up profile patch, and restart. Startup failures retain the Web loading page and spinner; runtime failures retain the current page. Expected shutdowns, cancelled navigation, and ordinary requests do not trigger recovery. The shared Web plugin manager reports package-operation errors; Host startup failures after plugin changes enter native recovery. There is no startup timeout heuristic. A listener failure containing `listen EADDRINUSE` replaces the diagnostics and reinstall advice with guidance to quit other running DSH instances, and offers only Exit and Restart. -Native dialog details include at most 1,200 UTF-16 code units and eight diagnostic lines; the complete reported error is written to the Electron console. Host error diagnostics retain only the last 64 Ki characters written to stderr. Earlier output is discarded so a long-running Host does not grow the shell’s diagnostic buffer indefinitely. +Native dialog details include at most 1,200 UTF-16 code units and eight diagnostic lines, plus the path of the crash report described below when one was written. Host error diagnostics retain only the last 64 Ki characters written to stderr. Earlier output is discarded so a long-running Host does not grow the shell’s diagnostic buffer indefinitely. + +Before the first fatal dialog opens, Electron writes a crash report to the platform logs directory (`app.getPath('logs')`: `~/Library/Logs/DeepSeek Harness` on macOS, `logs` under the application’s `userData` directory on Windows and Linux), waiting at most one second for the write; a slow or failed write shows the dialog without a path. The file `crash--.log` records the source (`host` for a Host exit, `web-boot` for a renderer boot failure, `renderer` for a renderer or document failure, `main` for a shell error), whether the backend had reached ready, application and runtime versions, the error with its enumerable properties and cause chain (cut at 256 KiB), the Host’s own inspected error (at most 64 KiB) when the Host reported a startup failure over IPC before exiting, and the primary window’s recent error-level console output (at most 64 KiB). A Host exit report therefore contains the retained stderr tail, which may include plugin output. A fatal failure during shutdown writes a report without opening a dialog. Files are owner-only where the platform supports it; startup keeps the ten newest reports and removes older ones without touching other files in the directory. Recovery waits for Host shutdown before changing plugin activation. The native recovery action calls the shared app-boot recovery function under the profile transaction lock. It disables third-party bundles and renames the profile’s `cordis.patch.yml` to `cordis.patch.yml.bak-` (with an ordinal on collisions) without parsing it; the next startup creates an empty patch. Installed packages and earlier backups remain. The home-level patch is unchanged. The Electron console records the backup path (or its absence) and the unchanged home-level patch. Invalid profile data, rename failures, or write failures are reported as recovery-operation errors; completed changes remain, and Desktop does not restart as though recovery succeeded. Desktop has no profile-reset action or emergency HTML document. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index ceefff9cc7..4ebae34332 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -桌面应用是完整 dsh Web 应用外的一层 Electron 壳。Electron RunAsNode 子进程启动共享 profile runner,Electron 立即从 `dsh-app://app/` 加载打包内的 Web 入口。共享加载页等待 Host 启动注入,然后在同一文档中启动客户端。Electron 将应用 HTTP 请求转发给已认证的 Web Host;WebSocket 流连接到该 Host,仅为归属的应用窗口附加凭据。Node IPC 承载启动注入、就绪与关闭。Desktop 默认使用端口 `19387`,与 Web 的 `3080` 分开;可通过 `webserver.config.port` patch 覆盖。 +桌面应用是完整 dsh Web 应用外的一层 Electron 壳。Electron RunAsNode 子进程启动共享 profile runner,Electron 立即从 `dsh-app://app/` 加载打包内的 Web 入口。共享加载页等待 Host 启动注入,然后在同一文档中启动客户端。Electron 将应用 HTTP 请求转发给已认证的 Web Host,转发时丢弃描述 Node fetch 连接而非资源本身的响应头(`transfer-encoding`、`connection`、`keep-alive`),并把插件 bundle 响应标记为 `no-store`,因为其每次启动都变化的 revision 只会在 Chromium 磁盘缓存中累积;WebSocket 流连接到该 Host,仅为归属的应用窗口附加凭据。Node IPC 承载启动注入、就绪与关闭。Desktop 默认使用端口 `19387`,与 Web 的 `3080` 分开;可通过 `webserver.config.port` patch 覆盖。 应用菜单第一项“**关于 DeepSeek Harness**”打开 Electron 原生关于面板,展示应用图标、产品名称和当前安装的发布版本。菜单文案跟随桌面壳的语言。macOS 的隐藏、隐藏其他、显示全部和退出条目使用本地化文案,隐藏和退出条目包含 DeepSeek Harness 产品名称。这些条目保留原生动作和快捷键。macOS 从应用包读取图标,因此未打包的开发启动会显示 Electron 图标;Windows 使用随包分发的 PNG。 @@ -82,7 +82,9 @@ macOS 上自定义应用菜单还会声明标准的 File、Window 和应用菜 主窗口创建、主文档加载、preload、渲染器、Web 初始化或后端的致命失败,会在每个应用进程中打开一次原生恢复对话框。对话框显示首次错误末尾的限长摘要,标明截断情况,并提供退出、重启、禁用第三方插件、备份 profile patch 并重启。启动失败保留 Web 加载页和动画;运行中失败保留当前页面。预期关闭、取消导航和普通请求错误不会触发恢复。共享 Web 插件管理器报告包操作错误;插件变更后的 Host 启动失败会进入原生恢复。不通过启动超时推断故障。 包含 `listen EADDRINUSE` 的监听失败以退出其他正在运行的 DSH 实例的提示替代诊断和重装建议,仅提供退出和重启。 -原生弹窗详情最多包含 1,200 个 UTF-16 代码单元和八行诊断;完整的已报告错误写入 Electron 控制台。Host 错误诊断仅保留 stderr 输出的最后 64 Ki 个字符。更早的输出会被丢弃,避免长期运行的 Host 使壳的诊断缓冲区无限增长。 +原生弹窗详情最多包含 1,200 个 UTF-16 代码单元和八行诊断,若已写入下述崩溃报告则附上其路径。Host 错误诊断仅保留 stderr 输出的最后 64 Ki 个字符。更早的输出会被丢弃,避免长期运行的 Host 使壳的诊断缓冲区无限增长。 + +首个致命弹窗打开前,Electron 会向平台日志目录(`app.getPath('logs')`:macOS 为 `~/Library/Logs/DeepSeek Harness`,Windows 与 Linux 为应用 `userData` 目录下的 `logs`)写入一份崩溃报告,最多等待写入一秒;写入缓慢或失败时弹窗不带路径。文件 `crash--.log` 记录来源(`host` 为 Host 退出、`web-boot` 为渲染进程启动失败、`renderer` 为渲染进程或文档失败、`main` 为壳自身错误)、后端是否已就绪、应用与运行时版本、包含可枚举属性与 cause 链的错误(截至 256 KiB)、Host 在退出前通过 IPC 报告启动失败时自己的 inspect 错误(最多 64 KiB),以及主窗口最近的 error 级 console 输出(最多 64 KiB)。因此 Host 退出报告包含保留的 stderr 尾部,其中可能含有插件输出。关闭过程中的致命失败只写报告、不弹窗。平台支持时文件仅所有者可读;启动时保留最新十份报告并删除更早的,不触碰目录中的其他文件。 恢复操作等待 Host 关闭后才修改插件启用状态。原生恢复操作在 profile 事务锁内调用共享 app-boot 恢复函数。它禁用第三方 bundle,并将 profile 的 `cordis.patch.yml` 重命名为 `cordis.patch.yml.bak-`(重名时追加序号),无需解析;下次启动创建空 patch。已安装包和已有备份保留。home 级 patch 不变。Electron 控制台记录备份路径(或原文件不存在)以及 home 级 patch 未修改。profile 数据无效、重命名失败或写入失败会作为恢复操作错误报告;已完成的修改保留,Desktop 不会假装恢复成功后重启。Desktop 不提供 profile 重置操作或应急 HTML 文档。 diff --git a/apps/desktop/src/backend-controller.ts b/apps/desktop/src/backend-controller.ts index f78a081c51..3e85b868ea 100644 --- a/apps/desktop/src/backend-controller.ts +++ b/apps/desktop/src/backend-controller.ts @@ -3,7 +3,16 @@ import { desktopErrorState } from './startup-error.ts' /** Backend availability presented by the desktop window. */ -export type DesktopBackendState = { readonly phase: 'starting' } | { readonly phase: 'ready' } | { readonly phase: 'error'; readonly message: string } +export type DesktopBackendState = + | { readonly phase: 'starting' } + | { readonly phase: 'ready' } + /** `message` is the rendered text; `failure` is the original error so a crash report keeps its stack, properties, and cause. */ + | { readonly phase: 'error'; readonly message: string; readonly failure: unknown } + +/** The error state for one failure: rendered message plus the failure itself. */ +function errorState(failure: unknown): DesktopBackendState { + return { ...desktopErrorState(failure), failure } +} /** Child lifecycle owned by the desktop backend controller. */ export interface DesktopBackendHost { @@ -80,7 +89,7 @@ export class DesktopBackendController { try { await this.cleanup(attempt) } catch (cleanupError) { if (cleanupError !== error) failure = new AggregateError([error, cleanupError], 'desktop backend startup and cleanup failed') } - if (!cancelled) this.update(desktopErrorState(failure)) + if (!cancelled) this.update(errorState(failure)) throw failure } }).finally(() => { if (this.pending === pending) this.pending = undefined }) @@ -132,9 +141,9 @@ export class DesktopBackendController { if (this.current.phase !== 'ready') return attempt.cancelled = true const cleanup = this.cleanup(attempt) - this.update(desktopErrorState(error)) + this.update(errorState(error)) void cleanup.catch((cleanupError: unknown) => { - if (this.attempt === attempt) this.update(desktopErrorState(new AggregateError([error, cleanupError], 'Desktop backend failed and could not stop'))) + if (this.attempt === attempt) this.update(errorState(new AggregateError([error, cleanupError], 'Desktop backend failed and could not stop'))) }) } diff --git a/apps/desktop/src/crash-report.ts b/apps/desktop/src/crash-report.ts new file mode 100644 index 0000000000..9c6d9ed793 --- /dev/null +++ b/apps/desktop/src/crash-report.ts @@ -0,0 +1,184 @@ +/** + * Crash report files: the complete diagnostic of one fatal Desktop failure, + * written before the recovery dialog so the dialog can name the file. The + * dialog itself shows only the last lines of the error; the file holds the + * whole error, its enumerable properties and cause chain, the process facts, + * and the renderer's recent error-level console output. + */ + +import { mkdir, readdir, unlink, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { inspect } from 'node:util' + +/** Where a fatal failure surfaced. */ +export type CrashReportSource = 'host' | 'web-boot' | 'renderer' | 'main' + +/** Whether the backend had reached ready when the failure surfaced. */ +export type CrashReportPhase = 'startup' | 'running' + +/** Facts about the running application recorded in every report header. */ +export interface CrashReportApp { + readonly name: string + readonly version: string + readonly platform: string + readonly arch: string + readonly electron: string + readonly node: string + readonly locale: string +} + +/** Inputs of {@link renderCrashReport} and {@link writeCrashReport}. */ +export interface CrashReportInput { + readonly source: CrashReportSource + readonly phase: CrashReportPhase + readonly error: unknown + /** The Host's own inspected error when it reported the failure over IPC before exiting. */ + readonly hostDiagnostic?: string + /** Recent renderer console lines at error level, oldest first. */ + readonly rendererConsole: readonly string[] + readonly app: CrashReportApp + readonly time: Date +} + +/** File name prefix every report shares. */ +export const CRASH_REPORT_PREFIX = 'crash-' + +/** The exact generated file name syntax; pruning touches only files that match it. */ +const CRASH_REPORT_NAME = /^crash-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z-(?:host|web-boot|renderer|main)\.log$/u + +/** Reports kept after pruning, newest first by file name. */ +export const CRASH_REPORTS_RETAINED = 10 + +/** Retained bytes of renderer error-level console output. */ +export const RENDERER_CONSOLE_MAX_BYTES = 64 * 1024 + +/** Upper bound of the rendered error section; a Host exit error already carries a 64 KiB stderr tail in its message. */ +export const ERROR_SECTION_MAX_CHARS = 256 * 1024 + +/** + * Bounded tail of renderer error-level console lines. Lines are dropped from + * the head once the retained byte total exceeds the cap; one oversized line is + * kept whole so a long stack is never cut mid-line. + */ +export class RendererConsoleTail { + private readonly lines: string[] = [] + private bytes = 0 + + /** @param maxBytes - retained byte cap across all lines. */ + constructor(private readonly maxBytes = RENDERER_CONSOLE_MAX_BYTES) {} + + /** + * Append one console line. + * @param line - the formatted console message. + */ + push(line: string): void { + this.lines.push(line) + this.bytes += Buffer.byteLength(line) + while (this.lines.length > 1 && this.bytes > this.maxBytes) { + this.bytes -= Buffer.byteLength(this.lines.shift() as string) + } + } + + /** @returns the retained lines, oldest first. */ + snapshot(): readonly string[] { + return [...this.lines] + } +} + +/** + * The report file name: sortable by time, then the source. + * @param time - failure time. + * @param source - where the failure surfaced. + * @returns `crash--.log`. + */ +export function crashReportFileName(time: Date, source: CrashReportSource): string { + return `${CRASH_REPORT_PREFIX}${time.toISOString().replaceAll(/[:.]/gu, '-')}-${source}.log` +} + +/** + * Render one report as plain text: a header of facts, the inspected error, + * the Host's own diagnostic when it reported one, and the renderer console tail. + * @param input - the failure and its context. + * @returns the complete file content. + */ +export function renderCrashReport(input: CrashReportInput): string { + const header = [ + `time: ${input.time.toISOString()}`, + `source: ${input.source}`, + `phase: ${input.phase}`, + `app: ${input.app.name} ${input.app.version}`, + `platform: ${input.app.platform} ${input.app.arch}`, + `electron: ${input.app.electron}`, + `node: ${input.app.node}`, + `locale: ${input.app.locale}`, + `shell pid: ${String(process.pid)}`, + ] + const consoleSection = input.rendererConsole.length === 0 + ? '(no error-level renderer console output was captured)' + : input.rendererConsole.join('\n') + return [ + header.join('\n'), + '', + '--- error ---', + boundedErrorSection(input.error), + '', + ...(input.hostDiagnostic === undefined ? [] : ['--- host diagnostic (as reported by the Host process) ---', input.hostDiagnostic, '']), + '--- renderer console (error level, oldest first) ---', + consoleSection, + '', + ].join('\n') +} + +function boundedErrorSection(error: unknown): string { + const rendered = inspect(error, { depth: 6, maxStringLength: 64 * 1024, maxArrayLength: 100, breakLength: 120 }) + return rendered.length <= ERROR_SECTION_MAX_CHARS + ? rendered + : `${rendered.slice(0, ERROR_SECTION_MAX_CHARS)}\n… (error section cut at ${String(ERROR_SECTION_MAX_CHARS)} characters)` +} + +/** + * Write one report into `directory`, creating it when absent. Failure is + * reported to `console.error` and yields `undefined`: the recovery dialog + * proceeds without a file rather than failing over a diagnostic aid. + * @param directory - the application logs directory. + * @param input - the failure and its context. + * @returns the written file path, or `undefined` when writing failed. + */ +export async function writeCrashReport(directory: string, input: CrashReportInput): Promise { + const path = join(directory, crashReportFileName(input.time, input.source)) + try { + await mkdir(directory, { recursive: true, mode: 0o700 }) + await writeFile(path, renderCrashReport(input), { mode: 0o600, flag: 'wx' }) + return path + } catch (error) { + console.error('dsh desktop: crash report could not be written', path, error) + return undefined + } +} + +/** + * Delete the oldest reports beyond `retained`, judged by file name order, and + * leave every other file in the directory alone. Failure is reported to + * `console.error`; a missing directory is not a failure. + * @param directory - the application logs directory. + * @param retained - reports to keep. + */ +export async function pruneCrashReports(directory: string, retained = CRASH_REPORTS_RETAINED): Promise { + let names: string[] + try { + names = await readdir(directory) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return + console.error('dsh desktop: crash report directory could not be listed', directory, error) + return + } + const reports = names.filter(name => CRASH_REPORT_NAME.test(name)).sort() + const excess = reports.slice(0, Math.max(0, reports.length - retained)) + for (const name of excess) { + try { + await unlink(join(directory, name)) + } catch (error) { + console.error('dsh desktop: stale crash report could not be removed', join(directory, name), error) + } + } +} diff --git a/apps/desktop/src/fatal-recovery.ts b/apps/desktop/src/fatal-recovery.ts index d2b9dc452b..5a739b1288 100644 --- a/apps/desktop/src/fatal-recovery.ts +++ b/apps/desktop/src/fatal-recovery.ts @@ -1,7 +1,8 @@ /** Native recovery for the first fatal failure in one Desktop process. */ import type { MessageBoxOptions, MessageBoxReturnValue } from 'electron' -import type { DesktopMessages } from './locale.ts' +import type { CrashReportSource } from './crash-report.ts' +import { formatDesktopMessage, type DesktopMessages } from './locale.ts' import { desktopErrorState } from './startup-error.ts' interface RecoveryOperations { @@ -11,21 +12,43 @@ interface RecoveryOperations { disablePlugins(): Promise exit(): void restart(): void + /** Persist the complete diagnostic; resolves with the file path, or `undefined` when nothing was written. */ + writeReport(error: unknown, source: CrashReportSource): Promise } -function dialogDetail(error: string, messages: DesktopMessages): string { +/** Upper bound on waiting for the crash report before the dialog opens. */ +export const CRASH_REPORT_WAIT_MS = 1_000 + +/** The dialog's `detail` budget in characters, within which the native message box stays readable. */ +const DETAIL_BUDGET = 1200 + +/** Tail lines of the error kept in the dialog; the report file holds the rest. */ +const DETAIL_TAIL_LINES = 8 + +/** + * Compose the dialog detail: the error's last lines (prefixed by the + * shortening notice when they are not the whole error), then the report path + * when one was written, then the reinstall advice. The report line does not + * depend on shortening: a short error that was persisted names its file too. + */ +function dialogDetail(error: string, messages: DesktopMessages, reportPath: string | undefined): string { const advice = `\n\n${messages.startupReinstallAdvice}` - const tail = error.split(/\r\n|[\n\r\u2028\u2029]/u).slice(-8).join('\n') - const budget = 1200 - advice.length - messages.diagnosticTruncated.length - 1 + const report = reportLine(messages, reportPath) + const tail = error.split(/\r\n|[\n\r\u2028\u2029]/u).slice(-DETAIL_TAIL_LINES).join('\n') + const budget = DETAIL_BUDGET - advice.length - report.length - messages.diagnosticTruncated.length - 1 const shortened = tail.slice(-budget).replace(/^[\uDC00-\uDFFF]/u, '') - return `${shortened === error ? error : `${messages.diagnosticTruncated}\n${shortened}`}${advice}` + return `${shortened === error ? error : `${messages.diagnosticTruncated}\n${shortened}`}${report}${advice}` +} + +function reportLine(messages: DesktopMessages, reportPath: string | undefined): string { + return reportPath === undefined ? '' : `\n${formatDesktopMessage(messages.reportWrittenTo, { path: reportPath })}` } /** Deduplicates fatal reports while keeping explicit recovery-operation failures actionable. */ export class DesktopFatalRecovery { private reported = false - /** @param operations - Native presentation and application-owned shutdown operations. */ + /** @param operations - Native presentation, report persistence, and application-owned shutdown operations. */ constructor(private readonly operations: RecoveryOperations) {} /** Whether this process requires a native recovery action before further plugin changes. */ @@ -33,13 +56,17 @@ export class DesktopFatalRecovery { /** * Show the first fatal error; later reports cannot replace it or open another dialog. + * The crash report is written first, bounded by {@link CRASH_REPORT_WAIT_MS}, so the dialog can name it; + * a slow or failed write shows the dialog without a path. * @param error - Fatal failure, including nested diagnostic causes. + * @param source - Where the failure surfaced, recorded in the report. * @returns Completion of the user's recovery action; duplicate reports resolve immediately. */ - async report(error: unknown): Promise { + async report(error: unknown, source: CrashReportSource): Promise { if (this.reported) return this.reported = true const messages = this.operations.messages() + const reportPath = await this.persist(error, source) let detail = desktopErrorState(error).message let message = messages.fatalSummary for (;;) { @@ -48,7 +75,9 @@ export class DesktopFatalRecovery { type: 'error', title: messages.startupFailed, message, - detail: addressInUse ? messages.startupAddressInUse : dialogDetail(detail, messages), + detail: addressInUse + ? `${messages.startupAddressInUse}${reportLine(messages, reportPath)}` + : dialogDetail(detail, messages, reportPath), buttons: addressInUse ? [messages.exitApplication, messages.restartApplication] : [messages.exitApplication, messages.restartApplication, messages.disableThirdPartyPlugins], @@ -73,4 +102,19 @@ export class DesktopFatalRecovery { } } } + + private async persist(error: unknown, source: CrashReportSource): Promise { + let timer: ReturnType | undefined + try { + return await Promise.race([ + this.operations.writeReport(error, source), + new Promise((resolve) => { timer = setTimeout(() => { resolve(undefined) }, CRASH_REPORT_WAIT_MS) }), + ]) + } catch (failure) { + console.error('dsh desktop: crash report failed', failure) + return undefined + } finally { + clearTimeout(timer) + } + } } diff --git a/apps/desktop/src/host-process.ts b/apps/desktop/src/host-process.ts index 834db0450e..37d8c31e4b 100644 --- a/apps/desktop/src/host-process.ts +++ b/apps/desktop/src/host-process.ts @@ -14,6 +14,8 @@ interface ReadyEvent { interface FatalEvent { readonly type: 'fatal' readonly message: string + /** The Host's complete inspected error: stack, enumerable properties, cause chain. */ + readonly diagnostic?: string } interface PlatformSessionEvent { @@ -56,7 +58,7 @@ function isDesktopHostEvent(message: unknown): message is DesktopHostEvent { } catch { return false } } case 'fatal': - return typeof candidate.message === 'string' + return typeof candidate.message === 'string' && (candidate.diagnostic === undefined || typeof candidate.diagnostic === 'string') case 'update-tasks': return Number.isSafeInteger(candidate.requestId) && typeof candidate.active === 'boolean' && (candidate.error === undefined || typeof candidate.error === 'string') @@ -87,6 +89,28 @@ export interface DesktopHostReady { /** The child has exited, but task teardown did not finish successfully. */ export class DesktopHostUncleanExitError extends Error {} +/** + * A Host failure reported over IPC before the process exited. `message` is what + * the Host chose to show; `diagnostic` is its complete inspected error, kept + * separately so a crash report can print it verbatim instead of a string escaped + * inside another error's properties. + */ +export class DesktopHostFatalError extends Error { + readonly #diagnostic: string | undefined + + /** + * @param message - The Host's failure message. + * @param diagnostic - The Host's inspected error, when the Host supplied one. + */ + constructor(message: string, diagnostic: string | undefined) { + super(message) + this.#diagnostic = diagnostic + } + + /** The Host's inspected error; a getter so `util.inspect` of this error does not repeat it as an escaped property. */ + get diagnostic(): string | undefined { return this.#diagnostic } +} + /** One Web backend running under the Electron executable in Node mode. */ export class DesktopHostProcess { private child: ChildProcess | undefined @@ -165,7 +189,7 @@ export class DesktopHostProcess { if (this.stopping) this.shutdownCompleted = true else this.fail(new Error('dsh desktop host acknowledged an unrequested shutdown')) } - else if (message.type === 'fatal') this.fail(new Error(message.message)) + else if (message.type === 'fatal') this.fail(new DesktopHostFatalError(message.message, message.diagnostic)) else { const query = this.taskQueries.get(message.requestId) if (message.error === undefined) query?.resolve(message.active) diff --git a/apps/desktop/src/locale.ts b/apps/desktop/src/locale.ts index 45c2d5b25e..7a1c935a8d 100644 --- a/apps/desktop/src/locale.ts +++ b/apps/desktop/src/locale.ts @@ -21,7 +21,8 @@ export const en = { startupFailed: 'DeepSeek Harness is unavailable', fatalSummary: 'The application could not start or stopped unexpectedly.', startupAddressInUse: 'Another DSH instance (such as dsh web or the desktop app) is running. They cannot start at the same time. Quit the other running DSH instance, then restart.', - diagnosticTruncated: '… Error details shortened. The full diagnostic was written to the Electron console.', + diagnosticTruncated: '… Error details shortened.', + reportWrittenTo: 'Diagnostic report: {path}', startupReinstallAdvice: 'If application files are missing or damaged, close the application and reinstall it. Your tasks are stored separately.', exitApplication: 'Exit', restartApplication: 'Restart', @@ -150,7 +151,8 @@ export const zh = { startupFailed: 'DeepSeek Harness 无法使用', fatalSummary: '应用无法启动或已意外停止。', startupAddressInUse: '有其他正在运行的 DSH(如其他 dsh web、桌面端),无法同时启动,请退出其他正在运行的 DSH 后重启。', - diagnosticTruncated: '… 错误详情已截短,完整诊断已写入 Electron 控制台。', + diagnosticTruncated: '… 错误详情已截短。', + reportWrittenTo: '诊断报告:{path}', startupReinstallAdvice: '如果应用文件缺失或损坏,请关闭应用并重新安装。任务数据存储在独立位置。', exitApplication: '退出', restartApplication: '重启', diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index d9c55f44ae..9f8ad5c22f 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -22,7 +22,7 @@ import { } from 'electron' import { resolveDesktopPaths } from './paths.ts' import { DesktopProjectManager } from './project-manager.ts' -import { DesktopHostProcess, DesktopHostUncleanExitError } from './host-process.ts' +import { DesktopHostFatalError, DesktopHostProcess, DesktopHostUncleanExitError } from './host-process.ts' import { DesktopPlatformView, PLATFORM_IPC, platformBounds } from './platform-view.ts' import { installDesktopDirectoryPicker } from './directory-picker.ts' import { installMicrophonePermissions } from './microphone-permissions.ts' @@ -33,6 +33,7 @@ import { claimDesktopSingleInstance } from './single-instance.ts' import { DesktopUpdateCoordinator } from './update-coordinator.ts' import { serveWebDocument, authenticateWebHost, forwardWebRequest } from './web-document.ts' import { DesktopFatalRecovery } from './fatal-recovery.ts' +import { pruneCrashReports, RendererConsoleTail, writeCrashReport, type CrashReportSource } from './crash-report.ts' import { openWelcomeWindow } from './welcome-window.ts' import { WELCOME_IPC, needsWelcome } from './welcome-api.ts' import { connectDesktopWelcome, type DesktopWelcomeBackend } from './welcome-backend.ts' @@ -52,6 +53,18 @@ let focusPrimaryWindow = (): void => {} let stopForRecovery = async (): Promise => {} let shuttingDown = false let windowsLanguage: string | undefined +/** + * Whether the backend has reached ready: false until the first ready, back to + * false when a restart returns it to starting, frozen during shutdown so a + * failure while tearing down a ready backend still reads as `running`. + */ +let backendReady = false +/** Error-level console output of the primary window, attached to crash reports. */ +const rendererConsole = new RendererConsoleTail() + +// Platform-conventional logs directory (macOS ~/Library/Logs/, otherwise under userData); +// set before ready so the first fatal report already resolves under it. +app.setAppLogsPath() function currentDesktopLocale(): ReturnType { return resolveDesktopLocale(windowsLanguage ?? app.getLocale()) @@ -67,12 +80,32 @@ const recovery = new DesktopFatalRecovery({ }, exit: () => { app.quit() }, restart: () => { app.relaunch(); app.quit() }, + writeReport: (error, source) => persistCrashReport(error, source), }) -function reportFatal(error: unknown): void { +function persistCrashReport(error: unknown, source: CrashReportSource): Promise { + return writeCrashReport(app.getPath('logs'), { + source, + phase: backendReady ? 'running' : 'startup', + error, + ...(error instanceof DesktopHostFatalError && error.diagnostic !== undefined ? { hostDiagnostic: error.diagnostic } : {}), + rendererConsole: rendererConsole.snapshot(), + app: { + name: app.name, version: app.getVersion(), platform: process.platform, arch: process.arch, + electron: process.versions.electron, node: process.versions.node, locale: currentDesktopLocale().id, + }, + time: new Date(), + }) +} + +function reportFatal(error: unknown, source: CrashReportSource): void { console.error(error) - if (shuttingDown) return - void recovery.report(error).catch((failure: unknown) => { console.error(failure); app.exit(1) }) + if (shuttingDown) { + // No dialog during shutdown, but the report still records what failed on the way out. + void persistCrashReport(error, source) + return + } + void recovery.report(error, source).catch((failure: unknown) => { console.error(failure); app.exit(1) }) } protocol.registerSchemesAsPrivileged([{ @@ -250,6 +283,7 @@ function createWindow(preload: string, show = false, primary = false): BrowserWi } async function main(): Promise { + void pruneCrashReports(app.getPath('logs')) const journalDirectory = process.env.DSH_DESKTOP_UPDATE_JOURNAL_DIR const updateJournal = journalDirectory === undefined ? undefined : new DesktopUpdateJournal(journalDirectory, app.getVersion()) const resources = runtimeResources() @@ -383,7 +417,8 @@ async function main(): Promise { updateTasks: (action: 'inspect' | 'lock' | 'unlock') => host.updateTasks(action), } }, (state) => { - if (state.phase === 'error') reportFatal(new Error(state.message)) + if (state.phase === 'error') reportFatal(state.failure, 'host') + else if (!shuttingDown) backendReady = state.phase === 'ready' }) const updateErrors = new WeakMap>() @@ -422,7 +457,7 @@ async function main(): Promise { if (backend.host !== undefined) updateJournal?.action('workspace-ready') }) workspaceRecovery = recovery - void recovery.catch(reportFatal).finally(() => { + void recovery.catch((error: unknown) => { reportFatal(error, 'main') }).finally(() => { if (startup === hostReady) startup = undefined if (workspaceRecovery === recovery) workspaceRecovery = undefined }) @@ -449,7 +484,7 @@ async function main(): Promise { // The existing Web document resumes through the boot IPC response. })().catch((error: unknown) => { updateJournal?.action('workspace-failed') - reportFatal(error) + reportFatal(error, 'main') throw error }).finally(() => { startup = undefined }) return startup @@ -546,7 +581,7 @@ async function main(): Promise { throw new Error('dsh desktop: rejected startup failure from a non-primary frame') } if (typeof message !== 'string') throw new Error('dsh desktop: startup failure must be text') - reportFatal(new Error(message)) + reportFatal(new Error(message), 'web-boot') }) ipcMain.handle(DESKTOP_IPC.browserAcquire, (event, workspace: unknown) => { @@ -863,18 +898,22 @@ async function main(): Promise { browserGuests.bind(window) window.on('focus', automaticCheck) window.on('closed', () => { if (mainWindow === window) mainWindow = undefined }) + window.webContents.on('console-message', (details) => { + if (details.level !== 'error') return + rendererConsole.push(`${details.sourceId}:${String(details.lineNumber)} ${details.message}`) + }) window.webContents.on('did-fail-load', (_event, code, description, url, isMainFrame) => { if (isMainFrame && code !== -3 && !quitting && !window.isDestroyed()) { - reportFatal(new Error(`Desktop page failed to load: ${url} (${String(code)}: ${description})`)) + reportFatal(new Error(`Desktop page failed to load: ${url} (${String(code)}: ${description})`), 'renderer') } }) window.webContents.on('preload-error', (_event, _path, error) => { - if (!quitting && !window.isDestroyed()) reportFatal(error) + if (!quitting && !window.isDestroyed()) reportFatal(error, 'renderer') }) window.webContents.on('render-process-gone', (_event, details) => { navigation = undefined if (!quitting && !window.isDestroyed() && details.reason !== 'clean-exit') { - reportFatal(new Error(`Desktop renderer exited: ${details.reason}`)) + reportFatal(new Error(`Desktop renderer exited: ${details.reason}`), 'renderer') } }) return window @@ -963,8 +1002,8 @@ async function main(): Promise { if (isMandatory()) { mandatoryUI?.focus(); return } const window = welcomeWindow ?? mainWindow if (window === undefined || window.isDestroyed()) { - try { createMainWindow() } catch (error) { reportFatal(error); return } - void (backend.state.phase === 'ready' ? openInitialWindow() : navigateMain(applicationUrl)).catch(reportFatal) + try { createMainWindow() } catch (error) { reportFatal(error, 'main'); return } + void (backend.state.phase === 'ready' ? openInitialWindow() : navigateMain(applicationUrl)).catch((error: unknown) => { reportFatal(error, 'main') }) return } // Startup and sign-out select the visible window before activation may reveal the workspace. @@ -1073,7 +1112,7 @@ if (ownsDesktopInstance) void app.whenReady().then(main).catch(async (error: unk if (diagnosticFile !== undefined) { await writeFile(diagnosticFile, `${error instanceof Error ? error.stack ?? message : message}\n`).catch(() => undefined) } - reportFatal(error) + reportFatal(error, 'main') }).catch((error: unknown) => { console.error(error) app.exit(1) diff --git a/apps/desktop/src/web-document.ts b/apps/desktop/src/web-document.ts index d7fe1d7fca..a74ea7c7ef 100644 --- a/apps/desktop/src/web-document.ts +++ b/apps/desktop/src/web-document.ts @@ -49,12 +49,30 @@ export async function authenticateWebHost(url: string): Promise { return end < 0 ? cookie : cookie.slice(0, end) } +/** + * Response headers not relayed to the renderer. `set-cookie` would hand the + * Host's authentication cookie to the page's cookie jar, which the shell owns + * instead; the rest describe the Node `fetch` connection (its encoding, length, + * and hop-by-hop transport), which Chromium never sees. + */ +const WITHHELD_RESPONSE_HEADERS = [ + 'set-cookie', + 'content-encoding', 'content-length', + 'transfer-encoding', 'connection', 'keep-alive', 'te', 'trailer', 'upgrade', 'proxy-authenticate', 'proxy-authorization', +] + +/** Host routes whose responses carry immutable cache headers keyed by a per-process revision. */ +const PLUGIN_BUNDLE_PATH = /^\/plugins\//u + /** * Forward local application requests to its authenticated Host, preserving streaming and cancellation. + * Plugin bundle responses lose their `cache-control` for `no-store`: the Host marks them immutable + * under a revision that changes every launch, so Chromium's disk cache would only accumulate bundles + * no later launch can reuse. * @param request - Request from the application origin. * @param host - Owned Host URL. * @param cookie - Host-issued authentication cookie. - * @returns Host response without network-only encoding headers. + * @returns Host response without connection-level headers. */ export async function forwardWebRequest(request: Request, host: string, cookie: string): Promise { const source = new URL(request.url) @@ -69,6 +87,7 @@ export async function forwardWebRequest(request: Request, host: string, cookie: const init = { method: request.method, headers, body: request.body, signal: request.signal, duplex: 'half', redirect: 'manual' as const } const response = await fetch(target, init) const outgoing = new Headers(response.headers) - for (const name of ['content-encoding', 'content-length', 'set-cookie']) outgoing.delete(name) + for (const name of WITHHELD_RESPONSE_HEADERS) outgoing.delete(name) + if (PLUGIN_BUNDLE_PATH.test(source.pathname)) outgoing.set('cache-control', 'no-store') return new Response(response.body, { status: response.status, headers: outgoing }) } diff --git a/apps/desktop/tests/backend-controller.spec.ts b/apps/desktop/tests/backend-controller.spec.ts index abdc78a9e7..37a0989c5f 100644 --- a/apps/desktop/tests/backend-controller.spec.ts +++ b/apps/desktop/tests/backend-controller.spec.ts @@ -82,8 +82,9 @@ describe('desktop backend controller', () => { it('publishes preparation errors and permits retry', async () => { const f = fixture() - await expect(f.controller.start(async () => { throw new Error('invalid profile') })).rejects.toThrow('invalid profile') - expect(f.controller.state).toEqual({ phase: 'error', message: 'invalid profile' }) + const invalid = new Error('invalid profile') + await expect(f.controller.start(async () => { throw invalid })).rejects.toThrow('invalid profile') + expect(f.controller.state).toEqual({ phase: 'error', message: 'invalid profile', failure: invalid }) expect(f.create).not.toHaveBeenCalled() f.ready.resolve() await f.controller.start(async () => {}) @@ -97,11 +98,13 @@ describe('desktop backend controller', () => { const start = f.controller.start(async () => {}) const rejected = expect(start).rejects.toThrow('plugin failed') await f.started.promise - f.ready.reject(new Error('plugin failed')) + const pluginFailure = new Error('plugin failed') + f.ready.reject(pluginFailure) await f.stopping.promise f.exited.resolve() await rejected - expect(f.controller.state).toEqual({ phase: 'error', message: 'plugin failed' }) + // The original error object travels with the state so a crash report keeps its stack and properties. + expect(f.controller.state).toEqual({ phase: 'error', message: 'plugin failed', failure: pluginFailure }) expect(f.host.stop).toHaveBeenCalledTimes(1) await f.controller.close() }) @@ -110,8 +113,9 @@ describe('desktop backend controller', () => { const f = fixture() f.ready.resolve() await f.controller.start(async () => {}) - f.fail(new Error('transport failed')) - expect(f.controller.state).toEqual({ phase: 'error', message: 'transport failed' }) + const transportFailure = new Error('transport failed') + f.fail(transportFailure) + expect(f.controller.state).toEqual({ phase: 'error', message: 'transport failed', failure: transportFailure }) expect(f.controller.host).toBeUndefined() await f.stopping.promise const prepare = vi.fn(async () => {}) @@ -131,11 +135,12 @@ describe('desktop backend controller', () => { const rejected = expect(start).rejects.toThrow('immediate failure') await f.started.promise f.ready.resolve() - f.fail(new Error('immediate failure')) + const immediate = new Error('immediate failure') + f.fail(immediate) await f.stopping.promise f.exited.resolve() await rejected - expect(f.states).toEqual([{ phase: 'starting' }, { phase: 'error', message: 'immediate failure' }]) + expect(f.states).toEqual([{ phase: 'starting' }, { phase: 'error', message: 'immediate failure', failure: immediate }]) await f.controller.close() }) diff --git a/apps/desktop/tests/crash-report.spec.ts b/apps/desktop/tests/crash-report.spec.ts new file mode 100644 index 0000000000..ec2f6e755a --- /dev/null +++ b/apps/desktop/tests/crash-report.spec.ts @@ -0,0 +1,117 @@ +import { mkdtemp, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { + CRASH_REPORTS_RETAINED, crashReportFileName, ERROR_SECTION_MAX_CHARS, pruneCrashReports, renderCrashReport, RendererConsoleTail, + writeCrashReport, + type CrashReportInput, +} from '../src/crash-report.ts' + +const roots: string[] = [] +afterEach(async () => { + vi.restoreAllMocks() + await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) +}) + +async function scratch(): Promise { + const root = await mkdtemp(join(tmpdir(), 'desktop-crash-')) + roots.push(root) + return root +} + +const input = (overrides: Partial = {}): CrashReportInput => ({ + source: 'host', + phase: 'running', + error: Object.assign(new Error("ENOENT: no such file or directory, open 'C:\\\\Temp\\\\dsh-subprocess-x\\\\out.log'"), { + errno: -4058, code: 'ENOENT', syscall: 'open', path: 'C:\\Temp\\dsh-subprocess-x\\out.log', + }), + rendererConsole: [], + app: { name: 'DeepSeek Harness', version: '0.1.7', platform: 'win32', arch: 'x64', electron: '44.0.0', node: '24.18.1', locale: 'zh_CN' }, + time: new Date('2026-09-22T10:30:00.123Z'), + ...overrides, +}) + +it('names files by sortable time and source', () => { + expect(crashReportFileName(new Date('2026-09-22T10:30:00.123Z'), 'web-boot')).toBe('crash-2026-09-22T10-30-00-123Z-web-boot.log') +}) + +it('renders the facts header, the inspected error with its properties and cause, and the renderer console', () => { + const text = renderCrashReport(input({ + error: new Error('wrapper', { cause: input().error }), + rendererConsole: ['dsh-app://app/assets/entry.js:12 client-modules: bundle script plugins/??a/client.js&rev=1 failed to load'], + })) + expect(text).toContain('time: 2026-09-22T10:30:00.123Z\nsource: host\nphase: running\napp: DeepSeek Harness 0.1.7\nplatform: win32 x64\nelectron: 44.0.0\nnode: 24.18.1\nlocale: zh_CN\n') + expect(text).toContain('--- error ---\nError: wrapper') + expect(text).toContain("syscall: 'open'") + expect(text).toContain("path: 'C:\\\\Temp\\\\dsh-subprocess-x\\\\out.log'") + expect(text).toContain('[cause]: Error: ENOENT') + expect(text).toContain('--- renderer console (error level, oldest first) ---\ndsh-app://app/assets/entry.js:12 client-modules: bundle script') + expect(text.endsWith('\n')).toBe(true) +}) + +it('prints the Host diagnostic verbatim in its own section when the Host reported one', () => { + const diagnostic = "Error: profile has no cordis.yml\n at load (index.js:1:1) {\n code: 'ENOENT'\n}" + const text = renderCrashReport(input({ error: new Error('profile has no cordis.yml'), hostDiagnostic: diagnostic })) + expect(text).toContain(`--- host diagnostic (as reported by the Host process) ---\n${diagnostic}\n\n--- renderer console`) + expect(renderCrashReport(input())).not.toContain('--- host diagnostic') +}) + +it('says so when no renderer console output was captured and keeps a long error message whole', () => { + const text = renderCrashReport(input({ error: new Error('x'.repeat(20_000)) })) + expect(text).toContain('(no error-level renderer console output was captured)') + expect(text).toContain('x'.repeat(20_000)) +}) + +it('writes an owner-only report into a directory it creates and returns its path', async () => { + const root = await scratch() + const directory = join(root, 'logs') + const path = await writeCrashReport(directory, input()) + expect(path).toBe(join(directory, 'crash-2026-09-22T10-30-00-123Z-host.log')) + expect(await readFile(path!, 'utf8')).toContain('source: host') + if (process.platform !== 'win32') expect((await stat(path!)).mode & 0o777).toBe(0o600) +}) + +it('returns undefined and logs when the report cannot be written', async () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + const root = await scratch() + const blocked = join(root, 'not-a-directory') + await writeFile(blocked, '') + expect(await writeCrashReport(blocked, input())).toBeUndefined() + expect(error).toHaveBeenCalledWith('dsh desktop: crash report could not be written', expect.stringContaining('crash-'), expect.anything()) +}) + +it('prunes only the oldest crash reports and leaves other files alone', async () => { + const root = await scratch() + const names = Array.from({ length: CRASH_REPORTS_RETAINED + 3 }, (_, index) => + crashReportFileName(new Date(Date.UTC(2026, 8, 1 + index)), 'main')) + for (const name of names) await writeFile(join(root, name), '') + const foreign = ['crash-notes.txt', 'crash-notes.log', 'crash-2026-09-01T00-00-00-000Z-unknown.log', 'update-journal.jsonl'] + for (const name of foreign) await writeFile(join(root, name), '') + await pruneCrashReports(root) + const remaining = (await readdir(root)).sort() + expect(remaining).toEqual([...names.slice(3), ...foreign].sort()) +}) + +it('cuts an oversized error section and says so', () => { + const text = renderCrashReport(input({ error: new Error('y'.repeat(300 * 1024)) })) + const section = text.slice(text.indexOf('--- error ---'), text.indexOf('--- renderer console')) + expect(section.length).toBeLessThan(ERROR_SECTION_MAX_CHARS + 200) + expect(section).toContain(`… (error section cut at ${String(ERROR_SECTION_MAX_CHARS)} characters)`) +}) + +it('treats a missing logs directory as nothing to prune', async () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + await pruneCrashReports(join(await scratch(), 'absent')) + expect(error).not.toHaveBeenCalled() +}) + +it('keeps the newest renderer console lines within the byte cap, never cutting a line', () => { + const tail = new RendererConsoleTail(20) + tail.push('a'.repeat(8)) + tail.push('b'.repeat(8)) + tail.push('c'.repeat(8)) + expect(tail.snapshot()).toEqual(['b'.repeat(8), 'c'.repeat(8)]) + tail.push('d'.repeat(40)) + expect(tail.snapshot()).toEqual(['d'.repeat(40)]) +}) diff --git a/apps/desktop/tests/expected/fatal-dialog-with-report-en.txt b/apps/desktop/tests/expected/fatal-dialog-with-report-en.txt new file mode 100644 index 0000000000..aaa637ca1a --- /dev/null +++ b/apps/desktop/tests/expected/fatal-dialog-with-report-en.txt @@ -0,0 +1,10 @@ +DeepSeek Harness is unavailable +The application could not start or stopped unexpectedly. +Desktop Host failed +Plugin initialization failed +Diagnostic report: C:\Users\someone\AppData\Roaming\DeepSeek Harness\logs\crash-2026-09-22T10-30-00-000Z-host.log + +If application files are missing or damaged, close the application and reinstall it. Your tasks are stored separately. +Exit +Restart +Disable third-party plugins, back up profile patch, and restart diff --git a/apps/desktop/tests/expected/fatal-dialog-with-report-zh-CN.txt b/apps/desktop/tests/expected/fatal-dialog-with-report-zh-CN.txt new file mode 100644 index 0000000000..353b59f4a4 --- /dev/null +++ b/apps/desktop/tests/expected/fatal-dialog-with-report-zh-CN.txt @@ -0,0 +1,10 @@ +DeepSeek Harness 无法使用 +应用无法启动或已意外停止。 +Desktop Host failed +Plugin initialization failed +诊断报告:C:\Users\someone\AppData\Roaming\DeepSeek Harness\logs\crash-2026-09-22T10-30-00-000Z-host.log + +如果应用文件缺失或损坏,请关闭应用并重新安装。任务数据存储在独立位置。 +退出 +重启 +禁用第三方插件、备份 profile patch 并重启 diff --git a/apps/desktop/tests/fatal-recovery.spec.ts b/apps/desktop/tests/fatal-recovery.spec.ts index 05fb893c0d..7ab4efd68a 100644 --- a/apps/desktop/tests/fatal-recovery.spec.ts +++ b/apps/desktop/tests/fatal-recovery.spec.ts @@ -1,9 +1,13 @@ import { afterEach, expect, it, vi } from 'vitest' import type { MessageBoxOptions } from 'electron' -import { DesktopFatalRecovery } from '../src/fatal-recovery.ts' +import { CRASH_REPORT_WAIT_MS, DesktopFatalRecovery } from '../src/fatal-recovery.ts' +import type { CrashReportSource } from '../src/crash-report.ts' import { resolveDesktopLocale } from '../src/locale.ts' -function fixture(locale = 'en') { +function fixture( + locale = 'en', + writeReport: (error: unknown, source: CrashReportSource) => Promise = async () => undefined, +) { const choice = Promise.withResolvers<{ response: number; checkboxChecked: boolean }>() const stopped = Promise.withResolvers() const operations = { @@ -13,17 +17,26 @@ function fixture(locale = 'en') { disablePlugins: vi.fn(async () => {}), exit: vi.fn(), restart: vi.fn(), + writeReport: vi.fn(writeReport), } return { operations, choice, stopped, recovery: new DesktopFatalRecovery(operations) } } +/** The dialog opens only after the report write settles, so tests wait for the first show() call. */ +async function shown(operations: { show: ReturnType }): Promise { + await vi.waitFor(() => { expect(operations.show).toHaveBeenCalled() }) +} + +const REPORT_PATH = 'C:\\Users\\someone\\AppData\\Roaming\\DeepSeek Harness\\logs\\crash-2026-09-22T10-30-00-000Z-host.log' + afterEach(() => { vi.restoreAllMocks() }) it.each(['en', 'zh-CN'])('offers only exit and restart for a listener conflict in %s', async (locale) => { const { operations, choice, stopped, recovery } = fixture(locale) const pending = recovery.report(new AggregateError([ new Error('webserver (@deepseek-ai/dsh-host-webserver): Error: listen EADDRINUSE: address already in use 127.0.0.1:19387'), - ], 'required startup failure')) + ], 'required startup failure'), 'host') + await shown(operations) const options = operations.show.mock.calls[0]![0] expect(options.buttons).toEqual([operations.messages().exitApplication, operations.messages().restartApplication]) await expect([options.title, options.message, options.detail, ...options.buttons!].join('\n') + '\n') @@ -38,7 +51,8 @@ it.each(['en', 'zh-CN'])('offers only exit and restart for a listener conflict i it.each(['win32', 'darwin', 'linux'] as const)('offers the same listener conflict recovery on %s', async (platform) => { vi.spyOn(process, 'platform', 'get').mockReturnValue(platform) const { operations, choice, stopped, recovery } = fixture() - const pending = recovery.report(new Error('listen EADDRINUSE: address already in use')) + const pending = recovery.report(new Error('listen EADDRINUSE: address already in use'), 'main') + await shown(operations) expect(operations.show.mock.calls[0]![0].buttons).toEqual([ operations.messages().exitApplication, operations.messages().restartApplication, ]) @@ -50,7 +64,8 @@ it.each(['win32', 'darwin', 'linux'] as const)('offers the same listener conflic it.each(['en', 'zh-CN'])('records the %s native recovery dialog', async (locale) => { const { operations, choice, stopped, recovery } = fixture(locale) - const pending = recovery.report(new AggregateError([new Error('Plugin initialization failed')], 'Desktop Host failed')) + const pending = recovery.report(new AggregateError([new Error('Plugin initialization failed')], 'Desktop Host failed'), 'main') + await shown(operations) const options = operations.show.mock.calls[0]![0] await expect([options.title, options.message, options.detail, ...options.buttons!].join('\n') + '\n') .toMatchFileSnapshot(`expected/fatal-dialog-${locale}.txt`) @@ -61,14 +76,15 @@ it.each(['en', 'zh-CN'])('records the %s native recovery dialog', async (locale) it('locks the first report before the dialog settles and never resets after an action', async () => { const { operations, choice, stopped, recovery } = fixture() - const pending = recovery.report(new Error('first failure')) - await recovery.report(new Error('second failure')) + const pending = recovery.report(new Error('first failure'), 'main') + await shown(operations) + await recovery.report(new Error('second failure'), 'main') expect(operations.show).toHaveBeenCalledOnce() expect(operations.show.mock.calls[0]![0].detail).toContain('first failure') choice.resolve({ response: 1, checkboxChecked: false }) stopped.resolve(undefined) await pending - await recovery.report(new Error('third failure')) + await recovery.report(new Error('third failure'), 'main') expect(operations.show).toHaveBeenCalledOnce() }) @@ -76,7 +92,7 @@ it.each([0, 1, 2])('waits for shutdown before executing choice %s', async (respo const { operations, choice, stopped, recovery } = fixture() const stopping = Promise.withResolvers() operations.stop.mockImplementation(() => { stopping.resolve(undefined); return stopped.promise }) - const pending = recovery.report(new Error('fatal')) + const pending = recovery.report(new Error('fatal'), 'main') choice.resolve({ response, checkboxChecked: false }) await stopping.promise expect(operations.exit).not.toHaveBeenCalled() @@ -96,7 +112,7 @@ it('reports a user-requested disable failure and allows exit without restarting' .mockResolvedValueOnce({ response: 0, checkboxChecked: false }) operations.disablePlugins.mockRejectedValueOnce(new Error('profile is read-only')) stopped.resolve(undefined) - await recovery.report(new Error('fatal')) + await recovery.report(new Error('fatal'), 'main') expect(operations.show).toHaveBeenCalledTimes(2) expect(operations.show.mock.calls[1]![0].detail).toContain('profile is read-only') expect(operations.restart).not.toHaveBeenCalled() @@ -106,7 +122,7 @@ it('reports a user-requested disable failure and allows exit without restarting' it('allows exit after shutdown cleanup fails', async () => { vi.spyOn(console, 'error').mockImplementation(() => {}) const { operations, choice, stopped, recovery } = fixture() - const pending = recovery.report(new Error('fatal')) + const pending = recovery.report(new Error('fatal'), 'main') choice.resolve({ response: 0, checkboxChecked: false }) stopped.reject(new Error('cleanup failed')) await pending @@ -120,7 +136,7 @@ it.each(['en', 'zh-CN'])('bounds long diagnostics and recovery-operation errors .mockResolvedValueOnce({ response: 0, checkboxChecked: false }) operations.disablePlugins.mockRejectedValueOnce(new Error('read-only\n'.repeat(5000) + 'final write failure')) stopped.resolve(undefined) - await recovery.report(new Error('😀'.repeat(32768) + '\nfinal backend failure')) + await recovery.report(new Error('😀'.repeat(32768) + '\nfinal backend failure'), 'main') for (const [options] of operations.show.mock.calls) { expect(options.detail!.length).toBeLessThanOrEqual(1200) expect(options.detail!.split('\n').length).toBeLessThanOrEqual(12) @@ -131,3 +147,95 @@ it.each(['en', 'zh-CN'])('bounds long diagnostics and recovery-operation errors expect(operations.show.mock.calls[0]![0].detail).toContain('final backend failure') expect(operations.show.mock.calls[1]![0].detail).toContain('final write failure') }) + +it.each(['en', 'zh-CN'])('names the report file even when the error is short enough to show whole, in %s', async (locale) => { + const { operations, choice, stopped, recovery } = fixture(locale, async () => REPORT_PATH) + const pending = recovery.report(new Error('Desktop Host failed\nPlugin initialization failed'), 'host') + await shown(operations) + await vi.waitFor(() => { expect(operations.show).toHaveBeenCalledOnce() }) + expect(operations.writeReport).toHaveBeenCalledWith(expect.any(Error), 'host') + const options = operations.show.mock.calls[0]![0] + expect(options.detail).not.toContain(operations.messages().diagnosticTruncated) + expect(options.detail).toContain(REPORT_PATH) + await expect([options.title, options.message, options.detail, ...options.buttons!].join('\n') + '\n') + .toMatchFileSnapshot(`expected/fatal-dialog-with-report-${locale}.txt`) + choice.resolve({ response: 0, checkboxChecked: false }) + stopped.resolve(undefined) + await pending +}) + +it('keeps the report line inside the detail budget when the error is long', async () => { + const { operations, choice, stopped, recovery } = fixture('en', async () => REPORT_PATH) + const pending = recovery.report(new Error('😀'.repeat(32768) + '\nfinal backend failure'), 'web-boot') + await shown(operations) + await vi.waitFor(() => { expect(operations.show).toHaveBeenCalledOnce() }) + const detail = operations.show.mock.calls[0]![0].detail! + expect(detail.length).toBeLessThanOrEqual(1200) + expect(detail.split('\n').length).toBeLessThanOrEqual(12) + expect(detail.isWellFormed()).toBe(true) + expect(detail).toContain(operations.messages().diagnosticTruncated) + expect(detail).toContain('final backend failure') + expect(detail).toContain(REPORT_PATH) + expect(detail.indexOf(REPORT_PATH)).toBeLessThan(detail.indexOf(operations.messages().startupReinstallAdvice)) + choice.resolve({ response: 0, checkboxChecked: false }) + stopped.resolve(undefined) + await pending +}) + +it('names the report file in the listener-conflict dialog too', async () => { + const { operations, choice, stopped, recovery } = fixture('en', async () => REPORT_PATH) + const pending = recovery.report(new Error('listen EADDRINUSE: address already in use 127.0.0.1:19387'), 'host') + await shown(operations) + await vi.waitFor(() => { expect(operations.show).toHaveBeenCalledOnce() }) + const options = operations.show.mock.calls[0]![0] + expect(options.buttons).toHaveLength(2) + expect(options.detail).toBe(`${operations.messages().startupAddressInUse}\n${operations.messages().reportWrittenTo.replace('{path}', REPORT_PATH)}`) + choice.resolve({ response: 0, checkboxChecked: false }) + stopped.resolve(undefined) + await pending +}) + +it('shows the dialog without a path when the report write fails or returns nothing', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + for (const writeReport of [async () => undefined, async () => { throw new Error('disk full') }]) { + const { operations, choice, stopped, recovery } = fixture('en', writeReport) + const pending = recovery.report(new Error('fatal'), 'main') + await shown(operations) + await vi.waitFor(() => { expect(operations.show).toHaveBeenCalledOnce() }) + expect(operations.show.mock.calls[0]![0].detail).toBe(`fatal\n\n${operations.messages().startupReinstallAdvice}`) + choice.resolve({ response: 0, checkboxChecked: false }) + stopped.resolve(undefined) + await pending + } +}) + +it('does not wait longer than the report deadline before showing the dialog', async () => { + vi.useFakeTimers() + try { + const { operations, choice, stopped, recovery } = fixture('en', () => new Promise(() => {})) + const pending = recovery.report(new Error('fatal'), 'main') + await vi.advanceTimersByTimeAsync(CRASH_REPORT_WAIT_MS - 1) + expect(operations.show).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(1) + expect(operations.show).toHaveBeenCalledOnce() + expect(operations.show.mock.calls[0]![0].detail).not.toContain('crash-') + choice.resolve({ response: 0, checkboxChecked: false }) + stopped.resolve(undefined) + await pending + } finally { + vi.useRealTimers() + } +}) + +it('writes the report once for the first fatal failure and not for the recovery-failure redisplay', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const { operations, stopped, recovery } = fixture('en', async () => REPORT_PATH) + operations.show.mockResolvedValueOnce({ response: 2, checkboxChecked: false }) + .mockResolvedValueOnce({ response: 0, checkboxChecked: false }) + operations.disablePlugins.mockRejectedValueOnce(new Error('profile is read-only')) + stopped.resolve(undefined) + await recovery.report(new Error('fatal'), 'host') + expect(operations.show).toHaveBeenCalledTimes(2) + expect(operations.writeReport).toHaveBeenCalledOnce() + expect(operations.show.mock.calls[1]![0].detail).toContain(REPORT_PATH) +}) diff --git a/apps/desktop/tests/host-process.spec.ts b/apps/desktop/tests/host-process.spec.ts index 78f4dd2082..cb092742a7 100644 --- a/apps/desktop/tests/host-process.spec.ts +++ b/apps/desktop/tests/host-process.spec.ts @@ -2,7 +2,7 @@ import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -import { DesktopHostProcess, DesktopHostUncleanExitError } from '../src/host-process.ts' +import { DesktopHostFatalError, DesktopHostProcess, DesktopHostUncleanExitError } from '../src/host-process.ts' const roots: string[] = [] const hosts: DesktopHostProcess[] = [] @@ -176,11 +176,25 @@ describe('desktop host process', () => { it.each([ ["process.send({ type: 'fatal', message: 'startup failed' }); process.disconnect()", 'startup failed'], ["process.send({ type: 'ready', url: 4 })", 'invalid IPC event'], + ["process.send({ type: 'fatal', message: 'startup failed', diagnostic: 42 })", 'invalid IPC event'], ['process.exit(0)', 'host stopped'], ])('rejects startup when the child fails before readiness: %s', async (source, message) => { const host = hostProcess(projectWithHost(source)) await expect(host.start()).rejects.toThrow(message) }) + + it('keeps the Host\'s inspected error separate from the message it reports', async () => { + const diagnostic = "Error: startup failed\\n at boot (lib/index.js:3:9) {\\n code: 'ENOENT',\\n path: '/profile/cordis.yml'\\n}" + const failures: Error[] = [] + const host = hostProcess(projectWithHost( + `process.send({ type: 'fatal', message: 'startup failed', diagnostic: ${JSON.stringify(diagnostic)} }); process.disconnect()`, + ), undefined, (error) => { failures.push(error) }) + await expect(host.start()).rejects.toThrow('startup failed') + const [failure] = failures + expect(failure).toBeInstanceOf(DesktopHostFatalError) + expect((failure as DesktopHostFatalError).diagnostic).toBe(diagnostic) + expect(Object.keys(failure!)).not.toContain('diagnostic') + }) }) it('carries Platform credentials over private IPC and clears them on shutdown', async () => { diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index 94409491f6..0242d214a3 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -8,14 +8,22 @@ import { tmpdir } from 'node:os' import type { MenuItemConstructorOptions, MessageBoxOptions } from 'electron' import { DESKTOP_IPC, type DesktopUpdateState } from '../src/ipc.ts' import { MANDATORY_IPC } from '../src/mandatory-update-ipc.ts' -import { DesktopHostUncleanExitError } from '../src/host-process.ts' +import { DesktopHostFatalError, DesktopHostUncleanExitError } from '../src/host-process.ts' import { en } from '../src/locale.ts' import { DesktopUpdatePreparationError } from '../src/update-error.ts' +import { writeCrashReport } from '../src/crash-report.ts' type InvokeEvent = { sender?: unknown; senderFrame: { url: string } } type InvokeHandler = (event: InvokeEvent, ...args: unknown[]) => unknown vi.mock('../src/web-document.ts', () => ({ authenticateWebHost: async () => 'test-cookie', serveWebDocument: vi.fn(), forwardWebRequest: vi.fn() })) +// Report persistence has its own unit tests; here it resolves within microtasks so the fatal +// dialog never outlives the test that triggered it. +vi.mock('../src/crash-report.ts', async importOriginal => ({ + ...await importOriginal(), + writeCrashReport: vi.fn(async () => 'desktop-test-logs/crash-test.log'), + pruneCrashReports: vi.fn(async () => {}), +})) const harness = await vi.hoisted(async () => { const { EventEmitter } = await import('node:events') @@ -131,6 +139,8 @@ const harness = await vi.hoisted(async () => { getPreferredSystemLanguages: () => ['en-US'], getVersion: () => '1.0.0', getAppPath: (): string => 'desktop-test-app', + setAppLogsPath: vi.fn(), + getPath: (name: string): string => `desktop-test-${name}`, setAboutPanelOptions: vi.fn<(options: Electron.AboutPanelOptionsOptions) => void>(), requestSingleInstanceLock: () => true, setAsDefaultProtocolClient: vi.fn(), @@ -1426,7 +1436,10 @@ describe('desktop main startup', () => { expect(() => { handler(event, {}) }).toThrow('must be text') expect(harness.dialog.showMessageBox).not.toHaveBeenCalled() handler(event, 'client mount failed') + await harness.dialogShown.promise expect((harness.dialog.showMessageBox.mock.calls[0]![0] as MessageBoxOptions).detail).toContain('client mount failed') + expect((harness.dialog.showMessageBox.mock.calls[0]![0] as MessageBoxOptions).detail).toContain('desktop-test-logs/crash-test.log') + expect(writeCrashReport).toHaveBeenCalledWith('desktop-test-logs', expect.objectContaining({ source: 'web-boot', phase: 'startup' })) expect(window.urls).toEqual(['dsh-app://app/']) }) @@ -1438,7 +1451,9 @@ describe('desktop main startup', () => { window.webContents.emit('did-fail-load', {}, -3, 'aborted', 'dsh-app://app/', true) expect(harness.dialog.showMessageBox).not.toHaveBeenCalled() window.webContents.emit('did-fail-load', {}, -2, 'failed', 'dsh-app://app/', true) + await harness.dialogShown.promise expect((harness.dialog.showMessageBox.mock.calls[0]![0] as MessageBoxOptions).detail).toContain('Desktop page failed to load') + expect(writeCrashReport).toHaveBeenCalledWith('desktop-test-logs', expect.objectContaining({ source: 'renderer' })) }) it('offers all recovery choices when resources fail before the Host starts', async () => { @@ -1486,6 +1501,60 @@ describe('desktop main startup', () => { expect(harness.hosts).toHaveLength(0) }) + it('writes the Host\'s own diagnostic into a startup-phase crash report when the Host reports a fatal error before ready', async () => { + await import('../src/main.ts') + await harness.preparing.promise + harness.prepared.resolve() + await harness.hostStarted.promise + const host = harness.hosts[0]! + const diagnostic = "Error: profile has no cordis.yml\n at loadProfileDirectory (app-boot/lib/index.js:12:3) {\n code: 'ENOENT',\n path: '/profiles/desktop/cordis.yml'\n}" + // DesktopHostProcess.fail() rejects start() and calls onFailure with the same error object. + const failure = new DesktopHostFatalError('profile has no cordis.yml', diagnostic) + host.onFailure!(failure) + host.ready.reject(failure) + await host.stopping.promise + host.exited.resolve() + await harness.dialogShown.promise + const [directory, report] = vi.mocked(writeCrashReport).mock.calls[0]! + expect(directory).toBe('desktop-test-logs') + expect(report.source).toBe('host') + expect(report.phase).toBe('startup') + expect(report.error).toBeInstanceOf(DesktopHostFatalError) + expect(report.hostDiagnostic).toBe(diagnostic) + expect((harness.dialog.showMessageBox.mock.calls[0]![0] as MessageBoxOptions).detail).toContain('profile has no cordis.yml') + }) + + it('attaches the primary window\'s error-level console output to a running-phase Host crash report', async () => { + harness.dialog.showMessageBox.mockResolvedValue({ response: 0, checkboxChecked: false }) + await import('../src/main.ts') + await harness.preparing.promise + harness.prepared.resolve() + await harness.hostStarted.promise + const host = harness.hosts[0]! + host.ready.resolve() + await Promise.resolve(invoke(DESKTOP_IPC.boot)) + const window = harness.windows[0]! + window.webContents.emit('console-message', { level: 'info', message: 'boot ok', sourceId: 'dsh-app://app/assets/entry.js', lineNumber: 1 }) + window.webContents.emit('console-message', { + level: 'error', message: 'client-modules: bundle script plugins/??a/client.js&rev=1 failed to load', + sourceId: 'dsh-app://app/assets/entry.js', lineNumber: 12, + }) + host.onFailure!(new Error('dsh desktop host exited with 1: fatal uncaught exception: ENOENT')) + await host.stopping.promise + expect(writeCrashReport).toHaveBeenCalledOnce() + const [directory, report] = vi.mocked(writeCrashReport).mock.calls[0]! + expect(directory).toBe('desktop-test-logs') + expect(report.source).toBe('host') + expect(report.phase).toBe('running') + expect(report.error).toBeInstanceOf(Error) + expect((report.error as Error).message).toContain('fatal uncaught exception: ENOENT') + expect(report.rendererConsole).toEqual(['dsh-app://app/assets/entry.js:12 client-modules: bundle script plugins/??a/client.js&rev=1 failed to load']) + expect(report.app).toMatchObject({ name: 'Desktop test', version: '1.0.0', platform: process.platform, locale: 'en' }) + expect(report.time).toBeInstanceOf(Date) + host.exited.resolve() + await harness.quitCompleted.promise + }) + it.each([0, 1, 2])('waits for Host exit before recovery action %s', async (response) => { harness.dialog.showMessageBox.mockResolvedValue({ response, checkboxChecked: false }) await import('../src/main.ts') diff --git a/apps/desktop/tests/web-document.spec.ts b/apps/desktop/tests/web-document.spec.ts index ec07655a76..0fd9b591b1 100644 --- a/apps/desktop/tests/web-document.spec.ts +++ b/apps/desktop/tests/web-document.spec.ts @@ -54,6 +54,31 @@ it('forwards upload bytes and cancellation with Host credentials while keeping t expect(await response.text()).toBe('stream') }) +it('drops connection-level headers the Host wrote for its own transport', async () => { + const fetch = vi.fn().mockResolvedValue(new Response('body', { headers: { + 'transfer-encoding': 'chunked', connection: 'keep-alive', 'keep-alive': 'timeout=5', trailer: 'x', te: 'trailers', + 'content-type': 'text/plain', 'cache-control': 'no-cache', etag: '"1"', + } })) + vi.stubGlobal('fetch', fetch) + const response = await forwardWebRequest(new Request('dsh-app://app/api/read'), 'http://127.0.0.1:1234/', 'session=owned') + for (const name of ['transfer-encoding', 'connection', 'keep-alive', 'trailer', 'te']) expect(response.headers.get(name)).toBeNull() + expect(response.headers.get('content-type')).toBe('text/plain') + expect(response.headers.get('cache-control')).toBe('no-cache') + expect(response.headers.get('etag')).toBe('"1"') +}) + +it('replaces the immutable cache header of plugin bundles with no-store and leaves other routes alone', async () => { + const immutable = 'public, max-age=31536000, immutable' + const fetch = vi.fn().mockImplementation(async () => new Response('js', { headers: { 'cache-control': immutable } })) + vi.stubGlobal('fetch', fetch) + const bundle = await forwardWebRequest(new Request('dsh-app://app/plugins/??a/client.js&rev=1'), 'http://127.0.0.1:1234/', 'c') + expect(bundle.headers.get('cache-control')).toBe('no-store') + const chunk = await forwardWebRequest(new Request('dsh-app://app/plugins/a/client.x.js?rev=1'), 'http://127.0.0.1:1234/', 'c') + expect(chunk.headers.get('cache-control')).toBe('no-store') + const asset = await forwardWebRequest(new Request('dsh-app://app/api/plugins/list'), 'http://127.0.0.1:1234/', 'c') + expect(asset.headers.get('cache-control')).toBe(immutable) +}) + it('refuses another page origin without forwarding its request', async () => { const fetch = vi.fn() vi.stubGlobal('fetch', fetch) diff --git a/apps/desktop/tests/welcome-startup.spec.ts b/apps/desktop/tests/welcome-startup.spec.ts index 6dd4080692..4a823afdf9 100644 --- a/apps/desktop/tests/welcome-startup.spec.ts +++ b/apps/desktop/tests/welcome-startup.spec.ts @@ -34,6 +34,11 @@ const state = vi.hoisted(() => ({ nativeTheme: { themeSource: 'system', shouldUseDarkColors: false }, })) +vi.mock('../src/crash-report.ts', async importOriginal => ({ + ...await importOriginal(), + writeCrashReport: vi.fn(async () => undefined), + pruneCrashReports: vi.fn(async () => {}), +})) vi.mock('electron', () => ({ clipboard: { writeText: state.copy }, app: { @@ -46,6 +51,8 @@ vi.mock('electron', () => ({ getVersion: () => '1.0.0', setAboutPanelOptions: vi.fn(), getAppPath: () => '/development-app', + getPath: (name: string) => `/development-${name}`, + setAppLogsPath: vi.fn(), getPreferredSystemLanguages: () => ['en-US'], on: (name: string, callback: (...args: unknown[]) => void) => { state.appListeners.set(name, callback) }, quit: state.quit, diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index f4eaa088e5..ff61bc8512 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/app-boot/README.i18n.yaml @@ -2,5 +2,5 @@ # 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 packages/boot/app-boot/README.md -README.md: 5bbe75d641eeeaf3b87c31221b3491ad1408934e -README.zh.md: b3ac89c1cb082880622175b494d540888b19e804 +README.md: 70a8e235ac5846061ea47e79e7dc575881110a50 +README.zh.md: 9849b033e01827a4c50a22b72ea20deebb0fdd85 diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index 5bbe75d641..70a8e235ac 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -40,7 +40,7 @@ installFailLoud('dsh') const ctx = await boot('dsh', resolveConfigPath(argv[2], process.env.DSH_SNAPSHOT)) ``` -With that entry point, startup keeps every plugin that can activate. An enabled failed plugin produces a labelled warning. A failed required entry makes startup dispose the whole app and exit nonzero; required ids absent from a profile and disabled required entries do not affect startup. The global required list covers shared Agent execution, application endpoints, and Web bootstrap/transport: `agent-loop`, `webserver`, `modules`, `connection`, `headless-runner`, `acp`, and `sdk-jsonrpc-server`. +`installFailLoud` writes one labelled `util.inspect` diagnostic to stderr for an unhandled rejection or an uncaught exception, awaits the surface's release hook under a fixed timeout, and exits 1; control never returns to the failed operation, because only the throw site knows which state is intact, and the event loop runs only until the release settles or times out. With that entry point, startup keeps every plugin that can activate. An enabled failed plugin produces a labelled warning. A failed required entry makes startup dispose the whole app and exit nonzero; required ids absent from a profile and disabled required entries do not affect startup. The global required list covers shared Agent execution, application endpoints, and Web bootstrap/transport: `agent-loop`, `webserver`, `modules`, `connection`, `headless-runner`, `acp`, and `sdk-jsonrpc-server`. ### Profiles @@ -97,6 +97,7 @@ After the Loader settles, app-boot warns when only optional entries are inactive | An injected service is unavailable | Warn; continue while the entry waits for its dependencies | Stop startup | Keep the entry waiting; adding the missing provider can activate it | | HTTP port binding fails | Warn; continue without that endpoint | Stop startup | Keep the process running without the failed endpoint; corrected config can restore it | | Detached asynchronous work outside the `apply()` return Promise produces an unhandled rejection | Fatal: dispose the app and exit nonzero | Fatal: dispose the app and exit nonzero | Fatal: dispose the app and exit nonzero, regardless of entry id | +| A synchronous callback (a stream `'data'` listener, a timer) throws an uncaught exception at any point in the process lifetime | Fatal: dispose the app and exit nonzero; the failed operation is not resumed | Fatal: dispose the app and exit nonzero; the failed operation is not resumed | Fatal: dispose the app and exit nonzero, regardless of entry id | | Entry is absent or explicitly disabled | Ignore it | Ignore it | Do not activate it; no required-startup audit | The required list above includes `modules` and `connection`; Web startup cannot succeed when either enabled entry fails. Failure of an optional provider can also prevent a required consumer from activating. Schema rejection before an existing entry updates is not a transactional rollback of sibling changes. diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index b3ac89c1cb..9849b033e0 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -40,7 +40,7 @@ installFailLoud('dsh') const ctx = await boot('dsh', resolveConfigPath(argv[2], process.env.DSH_SNAPSHOT)) ``` -有了这个入口,启动会保留所有能够激活的插件。启用但失败的插件会产生带标签的警告。required entry 失败时,启动会拆卸整个应用并以非零码退出;profile 中不存在的 required id 和已禁用的 required entry 不影响启动。全局 required list 覆盖共享 Agent 执行、应用 endpoint,以及 Web 启动与传输:`agent-loop`、`webserver`、`modules`、`connection`、`headless-runner`、`acp` 和 `sdk-jsonrpc-server`。 +`installFailLoud` 会为未处理 rejection 或未捕获异常向 stderr 写一条带标签的 `util.inspect` 诊断,在固定超时内等待界面的 release 钩子,然后以 1 退出;控制流不会回到失败的操作,因为只有抛出点知道哪些状态仍然完整,事件循环只运行到 release 结束或超时。有了这个入口,启动会保留所有能够激活的插件。启用但失败的插件会产生带标签的警告。required entry 失败时,启动会拆卸整个应用并以非零码退出;profile 中不存在的 required id 和已禁用的 required entry 不影响启动。全局 required list 覆盖共享 Agent 执行、应用 endpoint,以及 Web 启动与传输:`agent-loop`、`webserver`、`modules`、`connection`、`headless-runner`、`acp` 和 `sdk-jsonrpc-server`。 ### Profile @@ -97,6 +97,7 @@ Loader 结算后,app-boot 在仅 optional 条目未激活时输出警告。如 | 注入的服务不可用 | 警告;继续,条目等待依赖 | 终止启动 | 条目继续等待;补上缺失的提供方后可以激活 | | HTTP 端口绑定失败 | 警告;继续,但该端点不可用 | 终止启动 | 进程继续运行,但失败的端点不可用;修正配置后可以恢复 | | 脱离 `apply()` 返回 Promise 的异步任务产生未处理 rejection | 致命错误:释放应用并以非零码退出 | 致命错误:释放应用并以非零码退出 | 致命错误:释放应用并以非零码退出,与条目 id 无关 | +| 同步回调(流的 `'data'` 监听器、定时器)在进程生命周期任一时刻抛出未捕获异常 | 致命错误:释放应用并以非零码退出;失败的操作不会被恢复 | 致命错误:释放应用并以非零码退出;失败的操作不会被恢复 | 致命错误:释放应用并以非零码退出,与条目 id 无关 | | 条目缺失或被显式禁用 | 忽略 | 忽略 | 不激活该条目;不执行 required 启动审计 | 上面的 required 列表包含 `modules` 与 `connection`;只要其中一个已启用条目失败,Web 就无法成功启动。Optional 提供方失败也可能使 required 消费方无法激活。现有条目的新配置在更新前被 schema 校验拒绝,并不等于对兄弟插件的变更做事务回滚。 diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts index 39dd644846..0881307284 100644 --- a/packages/boot/app-boot/src/index.ts +++ b/packages/boot/app-boot/src/index.ts @@ -8,7 +8,7 @@ import { pathToFileURL } from 'node:url' import { readFileSync } from 'node:fs' -import { parseEnv } from 'node:util' +import { inspect, parseEnv } from 'node:util' import { basename, dirname, isAbsolute, resolve } from 'node:path' import * as yaml from 'js-yaml' import { Context, type FiberState } from '@deepseek-ai/cordis' @@ -566,13 +566,16 @@ export async function mountRootInclude( return entry } +/** The two process events {@link installFailLoud} turns into a fatal exit. */ +export type FailLoudEvent = 'unhandledRejection' | 'uncaughtException' + /** * The slice of `process` {@link installFailLoud} needs — injectable so tests * exercise the handler without registering on (or exiting) the real process. */ export interface FailLoudProcess { - on(event: 'unhandledRejection', handler: (err: unknown) => void): unknown - off(event: 'unhandledRejection', handler: (err: unknown) => void): unknown + on(event: FailLoudEvent, handler: (err: unknown) => void): unknown + off(event: FailLoudEvent, handler: (err: unknown) => void): unknown stderr: { write(chunk: string): unknown } /** * Terminate the process. Callers treat this as the end of the run, as @@ -616,11 +619,22 @@ async function observeLoaderRejectionCheckpoint(reasons: readonly unknown[]): Pr export const FAIL_LOUD_RELEASE_TIMEOUT_MS = 2_000 /** - * Install before boot to turn a late unhandled plugin-init rejection into one - * labelled stderr diagnostic and `exit(1)`. A rejection already included by - * {@link auditStartupEntries} is ignored during its process checkpoint; - * every other rejection remains fatal. Stdout remains untouched for ACP; the - * returned function removes the handler. + * Install before boot to turn an unhandled rejection or an uncaught exception, + * at any point in the process lifetime, into one labelled stderr diagnostic and + * `exit(1)`. A rejection already included by {@link auditStartupEntries} is + * ignored during its process checkpoint; every other rejection and every + * uncaught exception remains fatal. Control never returns to the failed + * operation after either: only the throw site knows which state is intact, and + * a listener that threw mid-update (a stream `'data'` handler, a half-applied + * registry write) leaves silently wrong results behind if it were resumed. The + * event loop keeps running only until the release hook settles or times out. + * Stdout remains untouched for ACP; the returned function removes both handlers. + * + * The diagnostic is `util.inspect(err)`, not `err.stack`: a `node:fs` error's + * `code`, `syscall`, and `path` and any `cause` chain are enumerable properties + * that the stack line omits, and they are what a crash report needs. Once a + * handler is installed Node prints nothing of its own, so this line is the + * only record of the failure. * * The Loader mounts entries concurrently, so a surface that owns the terminal * can already hold it when a sibling entry rejects. Exiting straight from the @@ -642,7 +656,7 @@ export const FAIL_LOUD_RELEASE_TIMEOUT_MS = 2_000 * @param release - optional teardown awaited before exit, used by a * terminal-owning surface to restore the terminal. Its own failure is * swallowed because the pending fatal exit already owns the outcome. - * @returns the uninstaller that removes the rejection handler. + * @returns the uninstaller that removes both handlers. */ export function installFailLoud( binName: string, @@ -650,14 +664,13 @@ export function installFailLoud( release?: () => Promise | void, ): () => void { let exiting = false - const handler = (err: unknown): void => { - if (assembledActivationRejections.has(err)) return - // A release in flight already owns the exit. Swallow later rejections + const report = (err: unknown, label: string): void => { + // A release in flight already owns the exit. Swallow later failures // (teardown's own included) rather than reporting a second failure over the // real one or letting Node kill the process before the terminal is back. if (exiting) return exiting = true - proc.stderr.write(`${binName}: fatal load failure: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`) + proc.stderr.write(`${binName}: ${label}: ${inspect(err, { depth: 4, maxArrayLength: 50 })}\n`) if (release === undefined) { proc.exit(1) return @@ -681,8 +694,18 @@ export function installFailLoud( proc.exit(1) })() } - const uninstall = (): void => void proc.off('unhandledRejection', handler) - proc.on('unhandledRejection', handler) + const onRejection = (err: unknown): void => { + if (assembledActivationRejections.has(err)) return + // Label kept stable: the Web profile expected-output e2e tests match it. + report(err, 'fatal load failure') + } + const onException = (err: unknown): void => { report(err, 'fatal uncaught exception') } + const uninstall = (): void => { + proc.off('unhandledRejection', onRejection) + proc.off('uncaughtException', onException) + } + proc.on('unhandledRejection', onRejection) + proc.on('uncaughtException', onException) return uninstall } diff --git a/packages/boot/app-boot/tests/app-boot.spec.ts b/packages/boot/app-boot/tests/app-boot.spec.ts index ba841a95f1..f291e45cc6 100644 --- a/packages/boot/app-boot/tests/app-boot.spec.ts +++ b/packages/boot/app-boot/tests/app-boot.spec.ts @@ -9,7 +9,7 @@ import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import { addHarnessSourceSection, auditStartupEntries, boot, StartupError, FAIL_LOUD_RELEASE_TIMEOUT_MS, HARNESS_SOURCE_SECTION, - installFailLoud, loadEnv, loadLayeredEnv, loadOverlayPatches, resolveConfigPath, type FailLoudProcess, + installFailLoud, loadEnv, loadLayeredEnv, loadOverlayPatches, resolveConfigPath, type FailLoudEvent, type FailLoudProcess, } from '../src/index.ts' const NAME = 'dsh-test-bin' @@ -360,14 +360,24 @@ describe('loadLayeredEnv', () => { }) describe('installFailLoud', () => { - function fakeProc(): FailLoudProcess & { handlers: Array<(err: unknown) => void>; written: string[]; exits: number[] } { - const handlers: Array<(err: unknown) => void> = [] + type Handler = (err: unknown) => void + function fakeProc(): FailLoudProcess & { + handlers: Handler[] + rejection: Handler + exception: Handler + written: string[] + exits: number[] + } { + const handlers: Handler[] = [] + const registered = new Map() const written: string[] = [] const exits: number[] = [] return { handlers, written, exits, - on: (_event, handler) => { handlers.push(handler) }, - off: (_event, handler) => { handlers.splice(handlers.indexOf(handler), 1) }, + get rejection() { return registered.get('unhandledRejection')! }, + get exception() { return registered.get('uncaughtException')! }, + on: (event, handler) => { handlers.push(handler); registered.set(event, handler) }, + off: (event, handler) => { handlers.splice(handlers.indexOf(handler), 1); registered.delete(event) }, stderr: { write: (chunk: string) => { written.push(chunk) } }, exit: (code: number) => { exits.push(code) }, } @@ -377,7 +387,7 @@ describe('installFailLoud', () => { const proc = fakeProc() installFailLoud(NAME, proc) const error = new Error('boom') - proc.handlers[0]!(error) + proc.rejection(error) expect(proc.written[0]).toContain(`${NAME}: fatal load failure: `) expect(proc.written[0]).toContain(error.stack) expect(proc.exits).toEqual([1]) @@ -388,7 +398,7 @@ describe('installFailLoud', () => { it('stringifies a non-Error rejection and an Error without a stack falls back to its message', () => { const plain = fakeProc() installFailLoud(NAME, plain) - plain.handlers[0]!('plain failure') + plain.rejection('plain failure') expect(plain.written[0]).toContain('plain failure') expect(plain.exits).toEqual([1]) @@ -396,7 +406,7 @@ describe('installFailLoud', () => { delete (stackless as { stack?: string }).stack const bare = fakeProc() installFailLoud(NAME, bare) - bare.handlers[0]!(stackless) + bare.rejection(stackless) expect(bare.written[0]).toContain('no stack') expect(bare.exits).toEqual([1]) }) @@ -404,16 +414,18 @@ describe('installFailLoud', () => { it('returns an uninstaller that removes the handler (and defaults to the real process)', () => { const proc = fakeProc() const uninstall = installFailLoud(NAME, proc) - expect(proc.handlers).toHaveLength(1) + expect(proc.handlers).toHaveLength(2) uninstall() expect(proc.handlers).toHaveLength(0) // Default-proc arm: install on the real process, then immediately uninstall // so the suite leaks no handler and can never exit the runner. - const before = process.listenerCount('unhandledRejection') + const before = [process.listenerCount('unhandledRejection'), process.listenerCount('uncaughtException')] const uninstallReal = installFailLoud(NAME) - expect(process.listenerCount('unhandledRejection')).toBe(before + 1) + expect(process.listenerCount('unhandledRejection')).toBe(before[0]! + 1) + expect(process.listenerCount('uncaughtException')).toBe(before[1]! + 1) uninstallReal() - expect(process.listenerCount('unhandledRejection')).toBe(before) + expect(process.listenerCount('unhandledRejection')).toBe(before[0]) + expect(process.listenerCount('uncaughtException')).toBe(before[1]) }) it('does not report an activation rejection shared by entries in the boot audit', async () => { @@ -436,12 +448,12 @@ describe('installFailLoud', () => { } as unknown as Context, NAME, warn) await Promise.resolve() await Promise.resolve() - proc.handlers[0]!(error) + proc.rejection(error) expect(proc.written).toEqual([]) expect(proc.exits).toEqual([]) await audit expect(warn).toHaveBeenCalledWith(expect.stringContaining('assembled activation failure')) - proc.handlers[0]!(error) + proc.rejection(error) expect(proc.exits).toEqual([1]) }) @@ -455,7 +467,7 @@ describe('installFailLoud', () => { await Promise.resolve() order.push('released') }) - proc.handlers[0]!(new Error('sibling entry rejected')) + proc.rejection(new Error('sibling entry rejected')) expect(proc.written[0]).toContain(`${NAME}: fatal load failure: `) // The release is in flight, so the exit has not committed yet. expect(proc.exits).toEqual([]) @@ -466,7 +478,7 @@ describe('installFailLoud', () => { it('still exits when the release hook rejects', async () => { const proc = fakeProc() installFailLoud(NAME, proc, () => Promise.reject(new Error('terminal stop failed'))) - proc.handlers[0]!(new Error('boom')) + proc.rejection(new Error('boom')) await vi.waitFor(() => { expect(proc.exits).toEqual([1]) }) }) @@ -475,7 +487,7 @@ describe('installFailLoud', () => { try { const proc = fakeProc() installFailLoud(NAME, proc, () => new Promise(() => {})) - proc.handlers[0]!(new Error('boom')) + proc.rejection(new Error('boom')) expect(proc.exits).toEqual([]) await vi.advanceTimersByTimeAsync(FAIL_LOUD_RELEASE_TIMEOUT_MS) expect(proc.exits).toEqual([1]) @@ -494,14 +506,52 @@ describe('installFailLoud', () => { await Promise.resolve() released = true }) - proc.handlers[0]!(new Error('first rejection')) - proc.handlers[0]!(new Error('second rejection')) - expect(proc.handlers).toHaveLength(1) + proc.rejection(new Error('first rejection')) + proc.rejection(new Error('second rejection')) + proc.exception(new Error('exception during teardown')) + expect(proc.handlers).toHaveLength(2) expect(proc.written).toHaveLength(1) expect(proc.written[0]).toContain('first rejection') await vi.waitFor(() => { expect(proc.exits).toEqual([1]) }) expect(released).toBe(true) }) + + it('turns an uncaught exception into a labelled diagnostic with its enumerable properties and exits 1', () => { + const proc = fakeProc() + installFailLoud(NAME, proc) + const error = Object.assign(new Error("ENOENT: no such file or directory, open '/tmp/dsh-subprocess-x/out.log'"), { + errno: -2, code: 'ENOENT', syscall: 'open', path: '/tmp/dsh-subprocess-x/out.log', + }) + proc.exception(error) + expect(proc.written).toHaveLength(1) + expect(proc.written[0]).toContain(`${NAME}: fatal uncaught exception: `) + expect(proc.written[0]).toContain(error.stack) + expect(proc.written[0]).toContain("syscall: 'open'") + expect(proc.written[0]).toContain("path: '/tmp/dsh-subprocess-x/out.log'") + expect(proc.exits).toEqual([1]) + }) + + it('includes the cause chain in the diagnostic', () => { + const proc = fakeProc() + installFailLoud(NAME, proc) + proc.exception(new Error('wrapper', { cause: new Error('root cause') })) + expect(proc.written[0]).toContain('[cause]: Error: root cause') + expect(proc.exits).toEqual([1]) + }) + + it('awaits the release hook for an uncaught exception as it does for a rejection', async () => { + const proc = fakeProc() + let released = false + installFailLoud(NAME, proc, async () => { + await Promise.resolve() + released = true + }) + proc.exception(new Error('listener threw')) + expect(proc.written[0]).toContain('fatal uncaught exception') + expect(proc.exits).toEqual([]) + await vi.waitFor(() => { expect(proc.exits).toEqual([1]) }) + expect(released).toBe(true) + }) }) describe('auditStartupEntries', () => { diff --git a/packages/client/modules/README.i18n.yaml b/packages/client/modules/README.i18n.yaml index a6df4e490c..d44443a96f 100644 --- a/packages/client/modules/README.i18n.yaml +++ b/packages/client/modules/README.i18n.yaml @@ -2,5 +2,5 @@ # 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 packages/client/modules/README.md -README.md: c99293b1f7a26a86279c4d71d9d6f41c6452b13e -README.zh.md: f6057204e1a039cecb824a3c355f4d7b906abac9 +README.md: 3388eb2ae421b303df6709f0bfc656fe67e3a866 +README.zh.md: 3cbc600ee5ed211640070773efb72f58b91d4669 diff --git a/packages/client/modules/README.md b/packages/client/modules/README.md index c99293b1f7..3388eb2ae4 100644 --- a/packages/client/modules/README.md +++ b/packages/client/modules/README.md @@ -35,7 +35,7 @@ A browser plugin package declares `dsh.client` in its `package.json` with `platf ### What the browser loads -The application combo scripts carry only each plugin's `client.js` entry and register those factories once during boot; module bodies remain lazy and run only at first import or materialization. A source `import()` split by tsdown compiles to `require.async("./client..js")`; its versioned sibling script arrives only when that expression runs. Rows that share a combo URL share one in-flight script task. HMR switches one changed row to its revisioned one-resource combo URL. `/client` and the bare id resolve to the same exports, because a plugin bundle is its package's client half. +The application combo scripts carry only each plugin's `client.js` entry and register those factories once during boot; module bodies remain lazy and run only at first import or materialization. A source `import()` split by tsdown compiles to `require.async("./client..js")`; its versioned sibling script arrives only when that expression runs. Rows that share a combo URL share one in-flight script task. A combo whose `