merge: 同步最新 master

This commit is contained in:
gengruilin committed 2026-09-22 19:05:01 +08:00
commit 5729548cd0
54 files changed
+1672 -200

No files matched your search

@@ -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
@@ -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.
@@ -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 重置或应急恢复文档。后端致命故障必须通过原生恢复操作处理,不在当前进程中重试。
@@ -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
@@ -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.
@@ -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` 字段的部署可调项。
@@ -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
@@ -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.
@@ -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()` 并把终端交还。
+9 -1
View File
@@ -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<void> {
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()
+2 -2
View File
@@ -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
+4 -2
View File
@@ -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-<UTC time>-<source>.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-<timestamp>` (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.
+4 -2
View File
@@ -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-<UTC 时间>-<source>.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-<timestamp>`(重名时追加序号),无需解析;下次启动创建空 patch。已安装包和已有备份保留。home 级 patch 不变。Electron 控制台记录备份路径(或原文件不存在)以及 home 级 patch 未修改。profile 数据无效、重命名失败或写入失败会作为恢复操作错误报告;已完成的修改保留,Desktop 不会假装恢复成功后重启。Desktop 不提供 profile 重置操作或应急 HTML 文档。
+13 -4
View File
@@ -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<Host extends DesktopBackendHost> {
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<Host extends DesktopBackendHost> {
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')))
})
}
+184
View File
@@ -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-<ISO time with ':' and '.' as '-'>-<source>.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<string | undefined> {
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<void> {
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)
}
}
}
+52 -8
View File
@@ -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<void>
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<string | undefined>
}
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<void> {
async report(error: unknown, source: CrashReportSource): Promise<void> {
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<string | undefined> {
let timer: ReturnType<typeof setTimeout> | undefined
try {
return await Promise.race([
this.operations.writeReport(error, source),
new Promise<undefined>((resolve) => { timer = setTimeout(() => { resolve(undefined) }, CRASH_REPORT_WAIT_MS) }),
])
} catch (failure) {
console.error('dsh desktop: crash report failed', failure)
return undefined
} finally {
clearTimeout(timer)
}
}
}
+26 -2
View File
@@ -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)
+4 -2
View File
@@ -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: '重启',
+53 -14
View File
@@ -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<void> => {}
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/<name>, otherwise under userData);
// set before ready so the first fatal report already resolves under it.
app.setAppLogsPath()
function currentDesktopLocale(): ReturnType<typeof resolveDesktopLocale> {
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<string | undefined> {
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> {
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<void> {
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<DesktopUpdateState, Promise<void>>()
@@ -422,7 +457,7 @@ async function main(): Promise<void> {
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<void> {
// 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<void> {
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<void> {
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<void> {
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)
+21 -2
View File
@@ -49,12 +49,30 @@ export async function authenticateWebHost(url: string): Promise<string> {
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<Response> {
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 })
}
+13 -8
View File
@@ -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()
})
+117
View File
@@ -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<string> {
const root = await mkdtemp(join(tmpdir(), 'desktop-crash-'))
roots.push(root)
return root
}
const input = (overrides: Partial<CrashReportInput> = {}): 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)])
})
@@ -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
@@ -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 并重启
+120 -12
View File
@@ -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<string | undefined> = async () => undefined,
) {
const choice = Promise.withResolvers<{ response: number; checkboxChecked: boolean }>()
const stopped = Promise.withResolvers<undefined>()
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<typeof vi.fn> }): Promise<void> {
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<undefined>()
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<string>(() => {}))
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)
})
+15 -1
View File
@@ -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 () => {
+70 -1
View File
@@ -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<typeof import('../src/crash-report.ts')>(),
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')
+25
View File
@@ -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)
@@ -34,6 +34,11 @@ const state = vi.hoisted(() => ({
nativeTheme: { themeSource: 'system', shouldUseDarkColors: false },
}))
vi.mock('../src/crash-report.ts', async importOriginal => ({
...await importOriginal<typeof import('../src/crash-report.ts')>(),
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,
+2 -2
View File
@@ -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
+2 -1
View File
@@ -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`.
<a id="profiles"></a>
### 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.
+2 -1
View File
@@ -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`。
<a id="profiles"></a>
### Profile
@@ -97,6 +97,7 @@ Loader 结算后,app-boot 在仅 optional 条目未激活时输出警告。如
| 注入的服务不可用 | 警告;继续,条目等待依赖 | 终止启动 | 条目继续等待;补上缺失的提供方后可以激活 |
| HTTP 端口绑定失败 | 警告;继续,但该端点不可用 | 终止启动 | 进程继续运行,但失败的端点不可用;修正配置后可以恢复 |
| 脱离 `apply()` 返回 Promise 的异步任务产生未处理 rejection | 致命错误:释放应用并以非零码退出 | 致命错误:释放应用并以非零码退出 | 致命错误:释放应用并以非零码退出,与条目 id 无关 |
| 同步回调(流的 `'data'` 监听器、定时器)在进程生命周期任一时刻抛出未捕获异常 | 致命错误:释放应用并以非零码退出;失败的操作不会被恢复 | 致命错误:释放应用并以非零码退出;失败的操作不会被恢复 | 致命错误:释放应用并以非零码退出,与条目 id 无关 |
| 条目缺失或被显式禁用 | 忽略 | 忽略 | 不激活该条目;不执行 required 启动审计 |
上面的 required 列表包含 `modules` 与 `connection`;只要其中一个已启用条目失败,Web 就无法成功启动。Optional 提供方失败也可能使 required 消费方无法激活。现有条目的新配置在更新前被 schema 校验拒绝,并不等于对兄弟插件的变更做事务回滚。
+38 -15
View File
@@ -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,
): () => 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
}
+70 -20
View File
@@ -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<FailLoudEvent, Handler>()
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<void>(() => {}))
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', () => {
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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.<name>.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. `<id>/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.<name>.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 `<script>` fails to load is requested once more; a combo that loads without registering a row is never re-executed, because the batch script registers its packages in sequence and a replay would stop at the first duplicate registration. In both cases each still-missing row then loads its own one-resource combo URL, so one failed batch costs at most three requests per missing row and leaves the rows it did register untouched. The module system records the last import failure per row (transport, registration, dependency cascade, or factory execution); the Web boot audit reports that text per entry. HMR switches one changed row to its revisioned one-resource combo URL. `<id>/client` and the bare id resolve to the same exports, because a plugin bundle is its package's client half.
### Live plugin composition
+1 -1
View File
@@ -35,7 +35,7 @@ kind: "package-reference"
### 浏览器加载什么
application combo 脚本只携带每个插件的 `client.js` 入口,并在启动时仅注册一次这些 factory;模块主体仍保持惰性,只在首次 import 或物化时运行。经 tsdown 拆分的源码 `import()` 会编译为 `require.async("./client.<name>.js")`;只有执行该表达式时,对应的带版本同级脚本才会到达。共享 combo URL 的 row 共用一个进行中的脚本任务。HMR(热模块替换)会让一条发生变化的 row 改用带 revision 的单资源 combo URL。`<id>/client` 与裸 id 解析到同一组导出,因为插件 bundle 就是其包的客户端半侧。
application combo 脚本只携带每个插件的 `client.js` 入口,并在启动时仅注册一次这些 factory;模块主体仍保持惰性,只在首次 import 或物化时运行。经 tsdown 拆分的源码 `import()` 会编译为 `require.async("./client.<name>.js")`;只有执行该表达式时,对应的带版本同级脚本才会到达。共享 combo URL 的 row 共用一个进行中的脚本任务。`<script>` 加载失败的 combo 会再请求一次;加载成功但没有注册某条 row 的 combo 绝不会重新执行,因为批量脚本按顺序注册各个包,重放会在第一个重复注册处停止。两种情况下,每条仍缺失的 row 随后加载自己的单资源 combo URL,因此一个失败的 batch 对每条缺失 row 最多花费三次请求,且不影响它已经注册的 row。模块系统按 row 记录最后一次 import 失败(传输、注册、依赖级联或 factory 执行);Web 启动审计按条目报告该文本。HMR(热模块替换)会让一条发生变化的 row 改用带 revision 的单资源 combo URL。`<id>/client` 与裸 id 解析到同一组导出,因为插件 bundle 就是其包的客户端半侧。
### 插件动态组合
+10 -1
View File
@@ -100,7 +100,7 @@ export interface WebBootGraph {
export interface BootModuleRow {
/** Entry name == package name (module-table key). */
id: string
/** Revisioned single-resource combo reference used after HMR invalidation. */
/** Revisioned single-resource combo reference: the fallback when the row's batch fails and the reload target after HMR invalidation. */
url: string
/** Content-addressed combo reference used before the first HMR invalidation. */
initialUrl: string
@@ -412,6 +412,15 @@ export interface ClientModuleLoader {
* @param id - graph entry name.
*/
prefetch(id: string): Promise<void>
/**
* The last failure of {@link import} or {@link prefetch} for one graph row:
* transport, registration, dependency cascade, or factory execution. Cleared
* by a later success and by {@link invalidate}. The boot audit reads it to
* report why a Loader entry has no fiber.
* @param id - graph entry name.
* @returns the recorded failure, or `undefined` when the row never failed or succeeded since.
*/
importError(id: string): Error | undefined
/**
* Full reset of one non-bootstrap package: drop its entry and chunk factories
* and materialized records so the next prefetch/import loads its one-resource
+123 -21
View File
@@ -38,6 +38,11 @@ function atRevision(url: string, rev: string): string {
const CLIENT_CHUNK = /^client\.[A-Za-z0-9][A-Za-z0-9._-]*\.js$/
/** The message of a thrown value: an Error's message, anything else stringified. */
function describeError(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
/** Internal module-table key for one package-local chunk. */
function chunkId(ownerId: string, fileName: string): string {
return `${ownerId}/${fileName}`
@@ -101,6 +106,12 @@ export class ClientModuleSystem implements ClientModuleLoader {
private readonly materializing = new Set<string>()
private readonly graphRows = new Map<string, BootModuleRow>()
private readonly loadBundle: (url: string) => Promise<void>
/** Last import or prefetch failure per graph row, cleared by a later success or invalidation. */
private readonly importErrors = new Map<string, Error>()
/** Batch URLs whose transport or execution already failed; rows still missing from them go straight to their one-resource URL. */
private readonly failedBundleUrls = new Set<string>()
/** Every URL whose script has executed once; a batch among them is never requested again. */
private readonly executedBundleUrls = new Set<string>()
/**
* Build the module system over the parsed boot rows.
@@ -161,25 +172,75 @@ export class ClientModuleSystem implements ClientModuleLoader {
})
}
/** Load one graph row so its factory is registered (idempotent per in-flight arrival). */
private arrive(row: BootModuleRow): Promise<void> {
const { id } = row
if (this.loadCache.has(id) || this.factories.has(id)) return Promise.resolve()
const reload = this.reloadTargets.get(id)
const url = reload?.url ?? row.initialUrl
/** Run one bundle transport per URL; every row waiting on the same URL shares the in-flight request. */
private loadShared(url: string): Promise<void> {
let transport = this.pendingArrival.get(url)
if (transport === undefined) {
transport = this.loadBundle(url).finally(() => { this.pendingArrival.delete(url) })
transport = this.loadBundle(url)
.then(() => { this.executedBundleUrls.add(url) })
.finally(() => { this.pendingArrival.delete(url) })
this.pendingArrival.set(url, transport)
}
return transport.then(() => {
if (!this.factories.has(id)) {
throw new Error(`client-modules: bundle ${url} loaded without registering "${id}" via __ModuleLoader__.load`)
return transport
}
/**
* Load one graph row so its factory is registered (idempotent per in-flight
* arrival). A batch script is one classic script that registers every
* package in sequence, and {@link register} rejects a second registration,
* so the two failure kinds differ: a transport failure (`error` event, nothing
* executed) is retried once on the same URL; a script that loaded without
* registering this row (a parse error registered nothing, or a runtime throw
* stopped it after registering others) is never re-executed, because a replay
* would stop again at the first duplicate registration; that holds even when
* the row that first imports from the batch is one it did register, because
* every executed batch URL is remembered. Either way the row then falls back
* to its own one-resource URL, which the Host serves for every package, so one
* failed batch costs at most three requests per missing row and never fails
* the rows that were registered.
*/
private async arrive(row: BootModuleRow): Promise<void> {
const { id } = row
if (this.loadCache.has(id) || this.factories.has(id)) return
const reload = this.reloadTargets.get(id)
const preferred = reload?.url ?? row.initialUrl
const fallback = reload === undefined && row.url !== preferred ? row.url : undefined
const failures: string[] = []
const attempt = async (url: string): Promise<'registered' | 'transport-failed' | 'not-registered'> => {
try {
await this.loadShared(url)
} catch (error) {
failures.push(`${url}: ${describeError(error)}`)
return 'transport-failed'
}
if (reload !== undefined && this.reloadTargets.get(id) === reload) {
this.reloadTargets.delete(id)
}
})
if (this.factories.has(id)) return 'registered'
failures.push(`${url}: loaded without registering "${id}" via __ModuleLoader__.load`)
return 'not-registered'
}
let outcome: Awaited<ReturnType<typeof attempt>> = 'transport-failed'
if (this.failedBundleUrls.has(preferred)) {
failures.push(`${preferred}: skipped after an earlier failure of this bundle`)
} else if (fallback !== undefined && this.executedBundleUrls.has(preferred)) {
// The batch already ran (an earlier importer was a row it did register)
// and this row is still missing: a replay would stop at the first
// duplicate registration.
failures.push(`${preferred}: already executed without registering "${id}"`)
outcome = 'not-registered'
this.failedBundleUrls.add(preferred)
} else {
outcome = await attempt(preferred)
if (outcome === 'transport-failed') outcome = await attempt(preferred)
// Only a batch URL is remembered: a one-resource URL has no fallback and
// stays retryable on the next import, as before.
if (outcome !== 'registered' && fallback !== undefined) this.failedBundleUrls.add(preferred)
}
if (outcome !== 'registered' && fallback !== undefined) outcome = await attempt(fallback)
if (outcome !== 'registered') {
throw new Error(`client-modules: could not load "${id}": ${failures.join('; ')}`)
}
if (reload !== undefined && this.reloadTargets.get(id) === reload) {
this.reloadTargets.delete(id)
}
}
/** Register each injected package and unresolved dynamic request before its consumer. */
@@ -202,15 +263,32 @@ export class ClientModuleSystem implements ClientModuleLoader {
const id = stripClientSuffix(request)
if (this.seed.has(request) || this.loadCache.has(id)) continue
const dependency = this.graphRows.get(id)
if (dependency !== undefined) await this.arriveGraphRow(dependency, next, visited)
if (dependency !== undefined) await this.arriveDependency(row.id, dependency, next, visited)
}
for (const packageName of row.inject) {
const dependency = this.graphRows.get(packageName)
if (dependency !== undefined) await this.arriveGraphRow(dependency, [], visited)
if (dependency !== undefined) await this.arriveDependency(row.id, dependency, [], visited)
}
await this.arrive(row)
}
/** Arrive one dependency, naming the consumer it failed for so a cascade reads as a chain, not as 44 unrelated failures. */
private async arriveDependency(
consumerId: string,
dependency: BootModuleRow,
open: readonly string[],
visited: Set<string>,
): Promise<void> {
try {
await this.arriveGraphRow(dependency, open, visited)
} catch (error) {
throw new Error(
`client-modules: "${consumerId}" not loaded because dependency "${dependency.id}" failed: ${describeError(error)}`,
{ cause: error },
)
}
}
/** Materialize a registered factory (synchronous; memoized in loadCache). */
private materialize(id: string, ownerId = id): ClientModuleRecord {
const existing = this.loadCache.get(id)
@@ -298,15 +376,17 @@ export class ClientModuleSystem implements ClientModuleLoader {
const existing = this.loadCache.get(id)
if (existing !== undefined) return existing.exports
const row = this.graphRows.get(id)
if (row !== undefined) {
await this.arriveGraphRow(row)
} else if (!this.factories.has(id)) {
if (row === undefined) {
if (this.factories.has(id)) return this.materialize(id).exports
throw new Error(
`client-modules: cannot resolve "${specifier}" — not a seed word, not a materialized module, `
+ 'and not a row in the boot graph (the runtime mirror of the bundle purity gate)',
)
}
return this.materialize(id).exports
return this.recordingImportError(id, async () => {
await this.arriveGraphRow(row)
return this.materialize(id).exports
})
}
async prefetch(id: string): Promise<void> {
@@ -314,7 +394,28 @@ export class ClientModuleSystem implements ClientModuleLoader {
if (this.loadCache.has(normalized)) return
const row = this.graphRows.get(normalized)
if (row === undefined) throw new Error(`client-modules: prefetch("${id}") — not a graph entry`)
await this.arriveGraphRow(row)
await this.recordingImportError(normalized, () => this.arriveGraphRow(row))
}
importError(id: string): Error | undefined {
return this.importErrors.get(stripClientSuffix(id))
}
/**
* Run one graph-row operation, recording its failure for the boot audit and
* clearing the record on success. Arrival and materialization both run in
* here, so a factory that throws is recorded as well as a bundle that never
* arrived; the Loader only sees a missing fiber either way.
*/
private async recordingImportError<T>(id: string, operation: () => Promise<T>): Promise<T> {
try {
const result = await operation()
this.importErrors.delete(id)
return result
} catch (error) {
this.importErrors.set(id, error instanceof Error ? error : new Error(describeError(error)))
throw error
}
}
/** Refresh descriptors and unowned factory revisions before any entry imports its dependencies. */
@@ -361,6 +462,7 @@ export class ClientModuleSystem implements ClientModuleLoader {
invalidate(id: string, rev?: string): void {
const normalized = stripClientSuffix(id)
if (this.bootstrapIds.has(normalized)) return
this.importErrors.delete(normalized)
this.generations.set(normalized, (this.generations.get(normalized) ?? 0) + 1)
const row = this.graphRows.get(normalized)
if (row !== undefined) {
@@ -483,7 +483,8 @@ it('discards a failed arrival target when an uncreated entry receives a newer gr
expect(b.modules.entries.state.getSnapshot().failures[0]?.message).toContain('offline r1')
b.arrival(async () => {})
await b.modules.entries.sync(graph(row('a', 'r2')))
expect(b.fetched).toEqual([row('a').url, row('a', 'r1').url, row('a', 'r2').url])
// A transport failure is retried once on the same URL before the reload is reported failed.
expect(b.fetched).toEqual([row('a').url, row('a', 'r1').url, row('a', 'r1').url, row('a', 'r2').url])
})
it('uses the latest desired revision when a rebuild queues before entry creation', async () => {
@@ -76,17 +76,26 @@ function bench(
pending?: ClientBundleRegistration[]
defaultTransport?: boolean
chunks?: Record<string, Factory | null>
/** Remaining transport rejections per URL (the `<script>` error event). */
transportFailures?: Record<string, number>
/** URLs whose script loads but registers only the listed ids (a runtime throw after those registrations). */
registerOnly?: Record<string, string[]>
} = {},
): Bench {
const fetched: string[] = []
const gates = new Map<string, () => void>()
const target = registrationTarget(opts.pending)
win.__ModuleLoader__ = target
const transportFailures = { ...opts.transportFailures }
const loadBundle = async (url: string): Promise<void> => {
fetched.push(url)
if (opts.gated?.includes(url) === true) {
await new Promise<void>((resolve) => { gates.set(url, resolve) })
}
if ((transportFailures[url] ?? 0) > 0) {
transportFailures[url] = (transportFailures[url] as number) - 1
throw new Error(`client-modules: bundle script ${url} failed to load`)
}
const sibling = /^plugins\/(.+)\/(client\.[A-Za-z0-9][A-Za-z0-9._-]*\.js)\?rev=[^&]+$/.exec(url)
if (sibling !== null) {
const id = sibling[1] as string
@@ -105,7 +114,9 @@ function bench(
const singleId = combo?.split(',').length === 1 && combo.endsWith('/client.js')
? combo.slice(0, -'/client.js'.length)
: undefined
const only = opts.registerOnly?.[url]
for (const id of batchIds ?? (singleId === undefined ? [] : [singleId])) {
if (only !== undefined && !only.includes(id)) continue
const factory = bundles[id]
if (factory != null) win.__ModuleLoader__?.load({ id, factory })
}
@@ -343,6 +354,134 @@ describe('lazy CJS arrival', () => {
})
})
describe('bundle arrival recovery', () => {
const single = (id: string): string => comboReference([id], '0')
it('retries a batch once after a transport failure and shares the retry across its rows', async () => {
const b = bench([row('a'), row('b')], { a: () => ({ a: 1 }), b: () => ({ b: 2 }) }, {
transportFailures: { [APPLICATION_URL]: 1 },
})
const [a, c] = await Promise.all([b.loader.import('a', '', {}), b.loader.import('b', '', {})])
expect(a).toEqual({ a: 1 })
expect(c).toEqual({ b: 2 })
expect(b.fetched).toEqual([APPLICATION_URL, APPLICATION_URL])
})
it('falls back to each missing row\'s one-resource URL when the batch keeps failing, without re-fetching the batch per row', async () => {
const b = bench([row('a'), row('b')], { a: () => ({ a: 1 }), b: () => ({ b: 2 }) }, {
transportFailures: { [APPLICATION_URL]: 5 },
})
const [a, c] = await Promise.all([b.loader.import('a', '', {}), b.loader.import('b', '', {})])
expect(a).toEqual({ a: 1 })
expect(c).toEqual({ b: 2 })
expect(b.fetched.filter(url => url === APPLICATION_URL)).toHaveLength(2)
expect(b.fetched.slice(2).sort()).toEqual([single('a'), single('b')].sort())
expect(b.loader.importError('a')).toBeUndefined()
})
it('does not re-execute a batch that loaded without registering; the missing row loads alone', async () => {
const b = bench([row('a')], { a: () => ({ a: 1 }) }, { registerOnly: { [APPLICATION_URL]: [] } })
expect(await b.loader.import('a', '', {})).toEqual({ a: 1 })
expect(b.fetched).toEqual([APPLICATION_URL, single('a')])
})
it('after a partially registering batch, loads only the missing row and never duplicates the registered one', async () => {
const b = bench([row('a'), row('b')], { a: () => ({ a: 1 }), b: () => ({ b: 2 }) }, {
registerOnly: { [APPLICATION_URL]: ['a'] },
})
expect(await b.loader.import('b', '', {})).toEqual({ b: 2 })
expect(b.fetched).toEqual([APPLICATION_URL, single('b')])
expect(await b.loader.import('a', '', {})).toEqual({ a: 1 })
expect(b.fetched).toHaveLength(2)
})
it('never re-executes a batch that already ran, even when its first importer was one of the rows it did register', async () => {
const b = bench([row('a'), row('b')], { a: () => ({ a: 1 }), b: () => ({ b: 2 }) }, {
registerOnly: { [APPLICATION_URL]: ['a'] },
})
expect(await b.loader.import('a', '', {})).toEqual({ a: 1 })
expect(b.fetched).toEqual([APPLICATION_URL])
expect(await b.loader.import('b', '', {})).toEqual({ b: 2 })
expect(b.fetched).toEqual([APPLICATION_URL, single('b')])
})
it('reports every attempt when the one-resource fallback fails too', async () => {
const b = bench([row('a')], { a: () => ({ a: 1 }) }, {
transportFailures: { [APPLICATION_URL]: 2, [single('a')]: 1 },
})
const failure: unknown = await b.loader.import('a', '', {}).then(() => undefined, (error: unknown) => error)
if (!(failure instanceof Error)) throw new Error('import resolved')
expect(failure.message).toContain('could not load "a"')
const batchAttempts = failure.message.split(`${APPLICATION_URL}: client-modules: bundle script`).length - 1
expect(batchAttempts).toBe(2)
expect(failure.message).toContain(`${single('a')}: client-modules: bundle script`)
expect(b.fetched).toEqual([APPLICATION_URL, APPLICATION_URL, single('a')])
})
it('a one-resource URL that fails stays retryable on the next import instead of being remembered as a failed batch', async () => {
const reloadUrl = comboReference(['a'], '1')
const b = bench([row('a')], { a: () => ({ a: 1 }) }, { transportFailures: { [reloadUrl]: 2 } })
expect(await b.loader.import('a', '', {})).toEqual({ a: 1 })
b.loader.invalidate('a', '1')
await expect(b.loader.import('a', '', {})).rejects.toThrow('could not load "a"')
expect(await b.loader.import('a', '', {})).toEqual({ a: 1 })
expect(b.fetched).toEqual([APPLICATION_URL, reloadUrl, reloadUrl, reloadUrl])
})
})
describe('import error record', () => {
it('records the transport failure of a row and clears it once the row imports', async () => {
const b = bench([row('a')], { a: () => ({ a: 1 }) }, {
transportFailures: { [APPLICATION_URL]: 2, [comboReference(['a'], '0')]: 1 },
})
await expect(b.loader.import('a', '', {})).rejects.toThrow()
expect(b.loader.importError('a')?.message).toContain('could not load "a"')
expect(b.loader.importError('a/client')).toBe(b.loader.importError('a'))
expect(await b.loader.import('a', '', {})).toEqual({ a: 1 })
expect(b.loader.importError('a')).toBeUndefined()
})
it('records a factory that throws during materialization, which no arrival record could see', async () => {
const b = bench([row('a')], { a: () => { throw new Error('factory exploded') } })
await expect(b.loader.import('a', '', {})).rejects.toThrow('factory exploded')
expect(b.loader.importError('a')?.message).toBe('factory exploded')
})
it('names the failed dependency in the consumer\'s record so a cascade reads as a chain', async () => {
const b = bench([row('dep'), row('consumer', { inject: ['dep'] })], { dep: () => ({}), consumer: () => ({}) }, {
registerOnly: { [APPLICATION_URL]: ['consumer'] },
transportFailures: { [comboReference(['dep'], '0')]: 1 },
})
await expect(b.loader.import('consumer', '', {})).rejects.toThrow(
'"consumer" not loaded because dependency "dep" failed: client-modules: could not load "dep"',
)
expect(b.loader.importError('consumer')?.message).toContain('dependency "dep" failed')
expect(b.loader.importError('dep')).toBeUndefined()
expect(b.loader.importError('consumer')?.cause).toBeInstanceOf(Error)
})
it('records a non-Error thrown by a factory as an Error carrying its text', async () => {
const b = bench([row('a')], { a: () => { throw 'factory rejected a string' } })
await expect(b.loader.import('a', '', {})).rejects.toBe('factory rejected a string')
expect(b.loader.importError('a')).toBeInstanceOf(Error)
expect(b.loader.importError('a')?.message).toBe('factory rejected a string')
})
it('records a prefetch failure and invalidate clears the record', async () => {
const b = bench([row('a')], { a: null })
await expect(b.loader.prefetch('a')).rejects.toThrow('without registering "a"')
expect(b.loader.importError('a')).toBeDefined()
b.loader.invalidate('a')
expect(b.loader.importError('a')).toBeUndefined()
})
it('has no record for a row that never failed or is unknown', () => {
const b = bench([row('a')], { a: () => ({}) })
expect(b.loader.importError('a')).toBeUndefined()
expect(b.loader.importError('nope')).toBeUndefined()
})
})
describe('require resolution', () => {
it('a factory requiring a registered-but-unmaterialized module materializes it recursively', async () => {
const order: string[] = []
+10 -4
View File
@@ -28,7 +28,8 @@ export interface ClientBootOptions {
* Compose the client: `ctx.plugin(Loader)`, `loader.internal = modules`, one
* `loader.create({ name })` per manifest row, `loader.await()`, then
* {@link assertEntriesActive}. A row whose module cannot be imported is marked
* failed; the Loader logs its import error and the audit rejects startup.
* failed; the Loader logs its import error, the module system records it, and
* the audit rejects startup with that error text per entry.
* @param options - context, module system, manifest, optional progress sink.
* @returns resolves after every entry is active; rejects with the audit report otherwise.
*/
@@ -52,20 +53,25 @@ export async function bootClient(options: ClientBootOptions): Promise<void> {
}
await loader.await()
assertEntriesActive(ctx)
assertEntriesActive(ctx, options.modules)
}
/**
* Reject entries that failed import/apply or still wait on missing services.
* @param ctx - root Context carrying the Loader.
* @param modules - the module system whose recorded import failures name why an entry
* has no fiber; a row with no record points at the console.
* @throws {Error} listing every non-active entry with its reason.
*/
export function assertEntriesActive(ctx: Context): void {
export function assertEntriesActive(ctx: Context, modules: Pick<ClientModuleLoader, 'importError'>): void {
const failures: string[] = []
for (const entry of ctx.loader.entries()) {
const name = entry.options.name
if (entry.fiber === undefined) {
failures.push(`${name}: import failed (see console for the import error)`)
const importError = modules.importError(name)
failures.push(importError === undefined
? `${name}: import failed (see console for the import error)`
: `${name}: import failed: ${importError.message}`)
continue
}
const state = STATE_LABELS[entry.fiber.state]
@@ -110,8 +110,10 @@ describe('assertEntriesActive', () => {
} as unknown as Context
}
const silent = { importError: () => undefined }
it('passes when every entry is active', () => {
expect(() => { assertEntriesActive(auditCtx([{ name: 'a', fiber: { state: FIBER_STATE.ACTIVE, inject: {} } }])) }).not.toThrow()
expect(() => { assertEntriesActive(auditCtx([{ name: 'a', fiber: { state: FIBER_STATE.ACTIVE, inject: {} } }]), silent) }).not.toThrow()
})
it('names import failures, missing services, and other non-active states', () => {
@@ -122,7 +124,7 @@ describe('assertEntriesActive', () => {
{ name: 'broken', fiber: { state: FIBER_STATE.FAILED, inject: {} } },
], { present: {} })
expect(() => { assertEntriesActive(ctx) }).toThrow([
expect(() => { assertEntriesActive(ctx, silent) }).toThrow([
'web boot: 4 entries did not activate',
'lost: import failed (see console for the import error)',
'waiting: pending (waiting for services: a, b)',
@@ -132,6 +134,16 @@ describe('assertEntriesActive', () => {
})
it('uses the singular form for one failing entry', () => {
expect(() => { assertEntriesActive(auditCtx([{ name: 'lost' }])) }).toThrow('web boot: 1 entry did not activate\n')
expect(() => { assertEntriesActive(auditCtx([{ name: 'lost' }]), silent) }).toThrow('web boot: 1 entry did not activate\n')
})
it('names the recorded import error of a fiberless entry when the module system is supplied', () => {
const recorded = new Map([['lost', new Error('client-modules: could not load "lost": plugins/??lost/client.js&rev=0: bundle script failed to load')]])
const modules = { importError: (id: string) => recorded.get(id) }
expect(() => { assertEntriesActive(auditCtx([{ name: 'lost' }, { name: 'quiet' }]), modules) }).toThrow([
'web boot: 2 entries did not activate',
'lost: import failed: client-modules: could not load "lost": plugins/??lost/client.js&rev=0: bundle script failed to load',
'quiet: import failed (see console for the import error)',
].join('\n'))
})
})
+11 -3
View File
@@ -8,7 +8,7 @@ import type { Duplex, Readable, Writable } from 'node:stream'
import { finished, pipeline } from 'node:stream/promises'
import type { Context } from '@deepseek-ai/cordis'
import type { SubprocessHandle, SubprocessSpawnSpec, SubprocessTerminalHandle } from '@deepseek-ai/dsh-subprocess'
import { OutputCollector, prepareManagedProcessBinding } from '@deepseek-ai/dsh-subprocess-local/output'
import { logSpillFailure, OutputCollector, prepareManagedProcessBinding, type SpillFailureReporter } from '@deepseek-ai/dsh-subprocess-local/output'
import { doneSchema, outputSnapshotFrameLimit, spawnSchema, type SshProcessId, type SshStreamEndpoint } from './schemas.ts'
import { z } from 'zod'
import { SSH_STREAM_TLS_OPTIONS } from './stream-security.ts'
@@ -94,10 +94,15 @@ export class RemoteProcesses {
private readonly cleanups = new Set<Promise<void>>()
private closing = false
/** Spill failures reach the helper's logger; the remote caller then receives the tail without a spill path. */
private readonly reportSpillFailure: SpillFailureReporter
constructor(
private readonly ctx: Context, private readonly root: string,
private readonly limit: number, private readonly preparationMs: number,
) {}
) {
this.reportSpillFailure = logSpillFailure(ctx.logger, 'ssh helper')
}
/**
* Allocate private stream listeners; no target executes until start().
@@ -217,7 +222,10 @@ export class RemoteProcesses {
const mode = stdio[name]
const socket = await (record.endpoints[name] as Endpoint).connected
if (typeof mode === 'object') {
const collector = new OutputCollector(mode.maxBytes, mode.spill?.maxBytes, name, prepareManagedProcessBinding().spillDir)
const binding = prepareManagedProcessBinding({ onSpillFailure: this.reportSpillFailure })
const collector = new OutputCollector(mode.maxBytes, name, mode.spill === undefined ? undefined : {
maxBytes: mode.spill.maxBytes, dir: binding.spillDir, onFailure: binding.onSpillFailure,
})
collectors[name] = collector
const forwarder = new CollectedOutputForwarder(socket, collector, mode.maxBytes)
forwarders.push(forwarder)
+2 -2
View File
@@ -52,7 +52,7 @@ class RemoteProcess implements SubprocessHandle {
const collect = (name: 'stdout' | 'stderr', stream: PassThrough, mode: SubprocessOutputMode) => {
if (mode === 'pipe') return undefined
if (mode === 'inherit') { stream.pipe(name === 'stdout' ? process.stdout : process.stderr, { end: false }); return undefined }
let collector = new OutputCollector(mode.maxBytes, undefined, name, '')
let collector = new OutputCollector(mode.maxBytes, name, undefined)
let base = 0
let total = 0
let finalized = false
@@ -62,7 +62,7 @@ class RemoteProcess implements SubprocessHandle {
if (finalized) return
if (snapshot.totalBytes < total) throw new Error('SSH helper rewound collected output')
finalized = final
collector = new OutputCollector(mode.maxBytes, undefined, name, '')
collector = new OutputCollector(mode.maxBytes, name, undefined)
collector.push(bytes)
base = snapshot.totalBytes - bytes.length
total = snapshot.totalBytes
@@ -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/subprocess/subprocess-local/README.md
README.md: 24480565de2f324a451dfdb40101eb50703b30b6
README.zh.md: c86a7eb930834d960fa25dd7b5d175af63d0b31a
README.md: 0e5423aecd46a18cde79437ca5707be5ee58d59b
README.zh.md: 32a1b937e5768ad87d72914fb6c8a90a33c5899f
@@ -44,7 +44,7 @@ Windows ordinary subprocesses start the private Job runner with `windowsHide` an
### Collecting output
Collect mode keeps the last `maxBytes` of a stream in memory — errors and final results cluster at the end — and, when a `spill` cap is configured, appends the complete stream to a private file under a per-process directory in the OS temp dir (a `0700` directory, `0600` random-named files). A stream larger than the spill cap discards its incomplete spill and returns only the marked truncated tail. Reads are offset-based and non-consuming, so background and batch readers coexist before and after exit.
Collect mode keeps the last `maxBytes` of a stream in memory — errors and final results cluster at the end — and, when a `spill` cap is configured, appends the complete stream to a private file under a per-process directory in the OS temp dir (a `0700` directory, `0600` random-named files). A stream larger than the spill cap discards its incomplete spill and returns only the marked truncated tail. Spilling is best-effort: when the spill file cannot be opened or appended (the per-process directory removed by a temporary-file cleaner, `EACCES`, `EMFILE`, `ENOSPC`), the collector discards the spill, logs one `error` through the plugin logger, and keeps collecting the in-memory tail, so the result is truncated with no spill path. Reads are offset-based and non-consuming, so background and batch readers coexist before and after exit.
The `./output` export shares this collector and retained-spill storage with process adapters. `snapshot()` returns the retained raw bytes and total byte count, allowing remote adapters to preserve offsets without forwarding the complete stream.
@@ -109,7 +109,7 @@ A spawn synchronously validates the final argv, cwd, and environment, selects co
### Safety invariants
Spill files are opened `0600` with `O_EXCL` and random names under a `0700` per-process directory, defeating symlink planting in shared temp dirs; a failed final close withholds the spill path. Fallback process identities carry start times, so cleanup never follows PID reuse. A selected native failure is reported instead of replaying argv through fallback, and a range is removed from the live set only after cleanup completes or the failure remains observable. Host-exit finalization creates no promises or timers, preserves the host exit code and diagnostic, contains each target's failure, and does not claim quiescence.
Spill files are opened `0600` with `O_EXCL` and random names under a `0700` per-process directory, defeating symlink planting in shared temp dirs; a failed open, append, or final close withholds the spill path and never interrupts collection, because collection runs inside the stream's `'data'` listener where a thrown error would kill the host process. Fallback process identities carry start times, so cleanup never follows PID reuse. A selected native failure is reported instead of replaying argv through fallback, and a range is removed from the live set only after cleanup completes or the failure remains observable. Host-exit finalization creates no promises or timers, preserves the host exit code and diagnostic, contains each target's failure, and does not claim quiescence.
</details>
@@ -155,6 +155,7 @@ These limits define when the provider is a poor fit or needs special operational
- **In-process cleanup requires a JavaScript-observable exit** — direct `process.exit()`, default uncaught exceptions, and default unhandled rejections emit Node's synchronous `exit` event. The default OS disposition for an unhandled `SIGTERM`, `SIGINT`, or `SIGHUP` bypasses that event; an application covers those signals only by installing a handler that performs normal disposal or calls `process.exit()`. `SIGKILL`, fatal OOM, `process.abort()`, native crashes, power loss, and any failure that cannot run JavaScript require an external supervisor, container init, or equivalent OS owner.
- **The credential scrub is a name heuristic** — `*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*` only; differently named secrets (for example `*PASSPHRASE*`) pass through, and a whitelist for over-scrubbed variables is noted future work.
- **Completed spill files are not deleted** — bounded full-output recovery files accumulate under the OS tmpdir until something external cleans them; the private per-process spill directory is removed at a JavaScript-observable exit only when it holds no completed spill file.
- **A removed spill directory is not recreated** — the private per-process directory is created once; after an external cleaner removes it, every later spill in that process degrades to the in-memory tail with an `error` log until the host restarts. Recreating a fresh random directory on `ENOENT` is deferred work.
<a id="dev-note"></a>
### Dev Note
@@ -44,7 +44,7 @@ Windows 普通子进程通过 `windowsHide` 启动私有 Job runner,并为原
### 收集输出
收集模式在内存中保留一条流的最后 `maxBytes`——错误与最终结果通常聚集在末尾——并在配置了 `spill` 上限时把完整流追加到 OS 临时目录下每进程目录中的私有文件(`0700` 目录、`0600` 随机命名文件)。某条流大于 spill 上限时,会丢弃不完整的 spill,只返回带截断标记的尾部。读取基于偏移量且从不消费,因此后台读取与批量读取在退出前后都可以共存。
收集模式在内存中保留一条流的最后 `maxBytes`——错误与最终结果通常聚集在末尾——并在配置了 `spill` 上限时把完整流追加到 OS 临时目录下每进程目录中的私有文件(`0700` 目录、`0600` 随机命名文件)。某条流大于 spill 上限时,会丢弃不完整的 spill,只返回带截断标记的尾部。spill 是尽力而为:当 spill 文件无法打开或追加(每进程目录被临时文件清理工具删除、`EACCES`、`EMFILE`、`ENOSPC`)时,收集器丢弃该 spill,通过插件 logger 记录一条 `error`,并继续收集内存尾部,因此结果带截断标记且没有 spill 路径。读取基于偏移量且从不消费,因此后台读取与批量读取在退出前后都可以共存。
`./output` 导出向进程适配器共享该收集器与保留 spill 的存储。`snapshot()` 返回保留的原始字节及总字节数,使远程适配器能够保留偏移量,而无需转发完整的流。
@@ -109,7 +109,7 @@ Linux 普通进程和终端进程即使在 bootstrap 消费启动请求前被取
### 安全不变式
spill 文件以 `0600` 权限、`O_EXCL` 与随机名称在 `0700` 每进程目录下创建,可抵御共享临时目录中的符号链接植入;最终关闭失败时不公布 spill 路径。fallback 进程身份携带启动时间,因此清理绝不会跟随 PID 复用。选定的 native 路径失败时会报告错误,而不会通过 fallback 重放 argv;受管范围只有在清理完成后才从存活集合移除,否则失败仍保持可观察。宿主退出最终清理不创建 Promise 或定时器,保留宿主退出码与诊断,分别包含每个目标的失败,也不会声称已经完全停稳。
spill 文件以 `0600` 权限、`O_EXCL` 与随机名称在 `0700` 每进程目录下创建,可抵御共享临时目录中的符号链接植入;打开、追加或最终关闭失败时不公布 spill 路径,且绝不中断收集,因为收集运行在流的 `'data'` 监听器内,抛出的错误会杀死宿主进程。fallback 进程身份携带启动时间,因此清理绝不会跟随 PID 复用。选定的 native 路径失败时会报告错误,而不会通过 fallback 重放 argv;受管范围只有在清理完成后才从存活集合移除,否则失败仍保持可观察。宿主退出最终清理不创建 Promise 或定时器,保留宿主退出码与诊断,分别包含每个目标的失败,也不会声称已经完全停稳。
</details>
@@ -155,6 +155,7 @@ spill 文件以 `0600` 权限、`O_EXCL` 与随机名称在 `0700` 每进程目
- **进程内清理要求退出阶段仍能执行 JavaScript**——直接 `process.exit()`、默认未捕获异常和默认未处理 rejection 会发出 Node 同步 `exit` 事件。未安装 handler 时,`SIGTERM`、`SIGINT` 或 `SIGHUP` 的默认 OS 处置不会发出该事件;应用只有安装执行正常 dispose 或调用 `process.exit()` 的 handler 才能覆盖这些信号。`SIGKILL`、fatal OOM、`process.abort()`、native crash、断电,以及任何无法运行 JavaScript 的故障,都需要外部 supervisor、容器 init 或等价的 OS owner 负责。
- **凭据清除依赖名称启发式规则**——只匹配 `*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`;名称不同的 secret(例如 `*PASSPHRASE*`)会继续传递,对误删变量引入白名单属于已记录的后续工作。
- **不会删除已完成的 spill 文件**——有界的完整输出恢复文件会在 OS tmpdir 下累积,直到外部机制进行清理;每进程私有 spill 目录仅在未持有任何已完成 spill 文件时于 JavaScript 可观察的退出阶段删除。
- **被删除的 spill 目录不会重建**——每进程私有目录只创建一次;被外部清理工具删除后,该进程内之后的每次 spill 都降级为内存尾部并记录一条 `error`,直到宿主重启。在 `ENOENT` 时重新创建一个新的随机目录是待办工作。
<a id="dev-note"></a>
### 开发备注
@@ -31,7 +31,7 @@ import {
spawnSubprocess,
validateSubprocessSpec,
} from './spawn.ts'
import { prepareManagedProcessBinding } from './output.ts'
import { logSpillFailure, prepareManagedProcessBinding } from './output.ts'
import type { LocalSubprocessHandle, SpawnInternals } from './spawn.ts'
import {
launchLinuxScope,
@@ -84,6 +84,9 @@ export class LocalSubprocessRuntime extends SubprocessRuntime {
}, 'local subprocess teardown')
}
/** Spill failures reach the plugin logger; the log line is the only trace of why a result has no spill path. */
private readonly reportSpillFailure = logSpillFailure(this.ctx.logger, 'subprocess-local')
private terminateForHostExit(): void {
for (const handle of this.live) {
try {
@@ -181,10 +184,11 @@ export class LocalSubprocessRuntime extends SubprocessRuntime {
const env = targetEnvironment(spec)
const containmentMode = this.selectContainmentMode('ordinary')
let handle: LocalSubprocessHandle
const internals: SpawnInternals = { ...this.internals, onSpillFailure: this.reportSpillFailure }
if (containmentMode === 'fallback') {
handle = spawnSubprocess(spec, this.internals)
handle = spawnSubprocess(spec, internals)
} else {
const binding = prepareManagedProcessBinding(this.internals)
const binding = prepareManagedProcessBinding(internals)
const launch = containmentMode === 'linux-scope'
? launchLinuxScope(spec, env)
: launchWindowsJob(spec, env)
@@ -5,16 +5,39 @@ import { join } from 'node:path'
import { tmpdir } from 'node:os'
import type { CollectedOutput } from '@deepseek-ai/dsh-subprocess'
/**
* Receives one spill failure so the owner can log it through its own logger.
* Called at most once per collector, after the spill has been discarded and
* the in-memory tail has kept collecting. A reporter that throws is contained
* and its failure written to stderr.
* @param error - the `node:fs` failure from opening or appending the spill file.
* @param label - the stream label of the collector that failed.
*/
export type SpillFailureReporter = (error: unknown, label: string) => void
/** Spill storage for one collected stream; `undefined` means tail-only collection. */
export interface SpillOptions {
/** Whole-stream byte cap beyond which an incomplete spill is discarded. */
maxBytes: number
/** Private directory receiving the spill file. */
dir: string
/** Owner-side report of a spill open or write failure. */
onFailure: SpillFailureReporter
}
let spillCounter = 0
let defaultSpillDir: string | undefined
/**
* The default spill location: a private (0700) per-process directory under
* the OS tmpdir, created lazily. Predictable world-readable paths would let
* other local users read command output or pre-create symlinks. At a
* JavaScript-observable process exit the directory is removed only when it
* holds no completed spill file (spill files are retained as full-output
* recovery artifacts until an external cleanup).
* other local users read command output or pre-create symlinks. The directory
* is created once per process and never recreated: when an external
* temporary-file cleaner removes it while empty, the next spill open fails and
* the collector degrades to its in-memory tail. At a JavaScript-observable
* process exit the directory is removed only when it holds no completed spill
* file (spill files are retained as full-output recovery artifacts until an
* external cleanup).
*/
function privateSpillDir(): string {
defaultSpillDir ??= mkdtempSync(join(tmpdir(), 'dsh-subprocess-'))
@@ -32,25 +55,74 @@ process.once('exit', () => {
try { rmdirSync(defaultSpillDir) } catch { /* best-effort: ENOENT/ENOTEMPTY/EBUSY/EPERM must not change the exit code. */ }
})
/**
* The stderr reporter used when no owner supplies one: a bare
* {@link prepareManagedProcessBinding} caller has no plugin logger, and the
* failure must still reach the process diagnostics.
* @param error - the spill failure.
* @param label - the failed stream label.
*/
function reportSpillFailureToStderr(error: unknown, label: string): void {
process.stderr.write(`dsh-subprocess-local: ${label} spill failed; only the in-memory tail is retained: ${String(error)}\n`)
}
/**
* Build the reporter an owner passes as {@link SpillOptions.onFailure}: one
* error-level log line naming the owner and stream, with the failure appended
* so its `code`, `syscall`, and `path` reach the log.
* @param logger - the owner's plugin logger.
* @param owner - the component named in the line.
* @returns the reporter.
*/
export function logSpillFailure(logger: { error(message: string, ...detail: unknown[]): void }, owner: string): SpillFailureReporter {
return (error, label) => {
const removedDirectory = (error as NodeJS.ErrnoException).code === 'ENOENT'
logger.error(
`${owner} could not write the complete ${label} stream to its spill file; the result keeps only the in-memory tail and reports no full-output path.`
+ (removedDirectory ? ' The spill directory no longer exists; a temporary-file cleaner removing it while empty is the usual cause.' : ''),
error,
)
}
}
/** Inputs a managed native process needs before its output streams are bound. */
export interface ManagedProcessBinding {
/** Directory receiving spill files. */
spillDir: string
/** Receives a spill open or write failure. */
onSpillFailure: SpillFailureReporter
}
/**
* Prepare fallible output storage before starting a managed native process.
* @param internals - optional caller-owned spill directory.
* This is the explicit resolve step for spill inputs: the spill directory
* defaults to the private per-process directory, and the failure reporter
* defaults to a stderr line when the caller has no logger.
* @param internals - optional caller-owned spill directory and failure reporter.
* @returns binding inputs whose spill directory is ready for use.
*/
export function prepareManagedProcessBinding(
internals: { spillDir?: string } = {},
): { spillDir: string } {
return { spillDir: internals.spillDir ?? privateSpillDir() }
internals: { spillDir?: string; onSpillFailure?: SpillFailureReporter } = {},
): ManagedProcessBinding {
return {
spillDir: internals.spillDir ?? privateSpillDir(),
onSpillFailure: internals.onSpillFailure ?? reportSpillFailureToStderr,
}
}
/**
* Collects one stream with a bounded in-memory tail. With a spill cap, on
* Collects one stream with a bounded in-memory tail. With spill options, on
* first overflow a spill file is created and every chunk (including those
* already collected) is appended there while the full stream remains within
* the cap; without one, only the in-memory tail is ever retained (the
* the cap; without them, only the in-memory tail is ever retained (the
* diagnostic-tail shape — a language server's stderr).
*
* Spilling is best-effort: a spill open or write failure discards the spill,
* reports once through {@link SpillOptions.onFailure}, and never interrupts
* in-memory collection, because `push()` runs inside the stream's `'data'`
* listener where a thrown error would become an uncaught exception.
*
* Tail-keep rationale (pi/OpenCode): errors and final results cluster at the
* end of command output; the spill file covers the head.
*/
@@ -64,13 +136,17 @@ export class OutputCollector {
/** Total bytes ever pushed (not just retained). */
private total = 0
/**
* @param maxBytes - in-memory tail cap in bytes.
* @param label - stream label used in spill file names and failure reports.
* @param spill - spill storage; omit for tail-only collection.
*/
constructor(
private readonly maxBytes: number,
private readonly maxSpillBytes: number | undefined,
private readonly label: string,
private readonly spillDir: string,
private readonly spill: SpillOptions | undefined,
) {
this.spillDisabled = maxSpillBytes === undefined
this.spillDisabled = spill === undefined
}
/**
@@ -84,7 +160,8 @@ export class OutputCollector {
push(chunk: Buffer): void {
this.total += chunk.length
const overflows = this.bytes + chunk.length > this.maxBytes
if (!this.spillDisabled && (overflows || this.spillFd !== undefined)) this.spillAll(chunk)
const spill = this.spill
if (spill !== undefined && !this.spillDisabled && (overflows || this.spillFd !== undefined)) this.spillAll(spill, chunk)
this.chunks.push(chunk)
this.bytes += chunk.length
while (this.bytes > this.maxBytes) {
@@ -105,24 +182,48 @@ export class OutputCollector {
}
}
/** Open the spill file lazily and append `chunk` (and any prior chunks once). */
private spillAll(chunk: Buffer): void {
if (this.maxSpillBytes !== undefined && this.total > this.maxSpillBytes) {
/**
* Open the spill file lazily and append `chunk` (and any prior chunks once).
* Runs inside the stream's `'data'` listener, so every filesystem failure is
* contained here: the spill is discarded, reported once, and collection
* continues with the in-memory tail alone.
*/
private spillAll(spill: SpillOptions, chunk: Buffer): void {
if (this.total > spill.maxBytes) {
this.discardSpill()
return
}
if (this.spillFd === undefined) {
// Random suffix + O_EXCL + no-follow-equivalent ('wx' fails on any
// existing path, symlink or not) + owner-only mode: defeats spill-path
// prediction and symlink planting in shared tmp dirs.
this.spillFile = join(
this.spillDir,
`dsh-subprocess-${process.pid}-${++spillCounter}-${randomBytes(6).toString('hex')}-${this.label}.log`,
)
this.spillFd = openSync(this.spillFile, 'wx', 0o600)
for (const prior of this.chunks) writeSync(this.spillFd, prior)
try {
if (this.spillFd === undefined) {
// Random suffix + O_EXCL + no-follow-equivalent ('wx' fails on any
// existing path, symlink or not) + owner-only mode: defeats spill-path
// prediction and symlink planting in shared tmp dirs. The path is
// published only once the open succeeded, so a failed open (EEXIST on a
// planted entry included) never lets discardSpill unlink a path this
// process did not create.
const file = join(
spill.dir,
`dsh-subprocess-${process.pid}-${++spillCounter}-${randomBytes(6).toString('hex')}-${this.label}.log`,
)
const fd = openSync(file, 'wx', 0o600)
this.spillFile = file
this.spillFd = fd
for (const prior of this.chunks) writeSync(fd, prior)
}
writeSync(this.spillFd, chunk)
} catch (error) {
// ENOENT (spill directory removed by a temp cleaner), EACCES/EPERM,
// EMFILE, or ENOSPC: the spill file is a recovery aid, not a
// precondition of collection.
this.discardSpill()
try {
spill.onFailure(error, this.label)
} catch (reporterFailure) {
// The reporter runs inside the stream listener too; a failing logger
// must not become the uncaught exception this path exists to prevent.
process.stderr.write(`dsh-subprocess-local: spill failure reporter threw: ${String(reporterFailure)}\n`)
}
}
writeSync(this.spillFd, chunk)
}
/** Stop spilling and remove the file once it can no longer hold the complete stream. */
@@ -25,7 +25,7 @@ import { waitWithAbort } from './managed-owner.ts'
import { controlEnvironment, controlPipe } from './control-spawn.ts'
import { SUBPROCESS_CONTROL_FD } from '@deepseek-ai/dsh-subprocess/control'
import { linuxProcessGroupHasLiveMembers } from './process-inspector.ts'
import { OutputCollector, prepareManagedProcessBinding } from './output.ts'
import { OutputCollector, prepareManagedProcessBinding, type SpillFailureReporter } from './output.ts'
type SpawnProcess = (
program: string,
@@ -59,6 +59,8 @@ export interface SpawnInternals {
spawn?: SpawnProcess
/** Directory for spill files (defaults to the OS temp dir). */
spillDir?: string
/** Receives spill open/write failures; the runtime supplies its plugin logger, bare callers get a stderr line. */
onSpillFailure?: SpillFailureReporter
/** Windows tree-termination runner (defaults to `taskkill /PID <pid> /T /F`). */
taskkill?: (pid: number) => void
/** Host platform override for signalling decisions. */
@@ -259,15 +261,15 @@ function fallbackOwner(
* Bind platform launch facts to the existing stdio, outcome, abort, and termination lifecycle.
* @param spec - fully resolved argv, cwd, stdio, grace, cancellation, environment.
* @param launch - platform streams, direct outcome, and managed-range owner.
* @param internals - test-only spill-directory override.
* @param internals - spill-directory override and spill failure reporter.
* @returns live subprocess handle.
*/
export function bindManagedProcess(
spec: SubprocessSpawnSpec,
launch: ManagedProcessLaunch,
internals: Pick<SpawnInternals, 'spillDir'> = {},
internals: Pick<SpawnInternals, 'spillDir' | 'onSpillFailure'> = {},
): LocalSubprocessHandle {
const { spillDir } = prepareManagedProcessBinding(internals)
const { spillDir, onSpillFailure } = prepareManagedProcessBinding(internals)
const { stdin, stdout, stderr } = launch
const isCollect = (mode: SubprocessOutputMode): mode is SubprocessCollect =>
@@ -278,7 +280,11 @@ export function bindManagedProcess(
const collectStream = (mode: SubprocessOutputMode, stream: Readable | null, label: string): OutputCollector | undefined => {
if (!isCollect(mode) || stream === null) return undefined
const collector = new OutputCollector(mode.maxBytes, mode.spill?.maxBytes, label, spillDir)
const collector = new OutputCollector(
mode.maxBytes,
label,
mode.spill === undefined ? undefined : { maxBytes: mode.spill.maxBytes, dir: spillDir, onFailure: onSpillFailure },
)
stream.on('data', (chunk: Buffer) => { collector.push(chunk) })
return collector
}
@@ -2,7 +2,9 @@ import { PassThrough } from 'node:stream'
import os from 'node:os'
import { syncBuiltinESMExports } from 'node:module'
import { describe, expect, it, vi } from 'vitest'
import { basename, dirname, relative, resolve } from 'node:path'
import { basename, dirname, join, relative, resolve } from 'node:path'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { Context } from '@deepseek-ai/cordis'
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
import type { SubprocessSpawnSpec, SubprocessTerminalHandle, SubprocessTerminalSpawnSpec } from '@deepseek-ai/dsh-subprocess'
@@ -744,6 +746,35 @@ describe('LocalSubprocessRuntime', () => {
await fiber.dispose()
})
it('logs one error through the plugin logger when a spill cannot be written and keeps the tail', async () => {
const removedDir = mkdtempSync(join(tmpdir(), 'dsh-subprocess-removed-'))
rmSync(removedDir, { recursive: true, force: true })
const ctx = new Context()
const logged = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {})
const fiber = await ctx.plugin(LocalSubprocessRuntime)
const runtime = ctx.subprocess as LocalSubprocessRuntime
runtime.internals = { spillDir: removedDir }
try {
const handle = runtime.spawn(spec('', {
argv: [process.execPath, '-e', 'process.stdout.write("x".repeat(4096))'],
stdio: { stdin: 'ignore', stdout: { maxBytes: 16, spill: { maxBytes: 1_000_000 } }, stderr: 'pipe' },
}))
const result = await handle.done
expect(result.exitCode).toBe(0)
const stdout = handle.collected.stdout!.readFrom(0)
expect(stdout.text).toBe('x'.repeat(16))
expect(stdout.lossy).toBe(true)
expect(stdout.spillPath).toBeUndefined()
expect(logged).toHaveBeenCalledOnce()
const [message, error] = logged.mock.calls[0] as [string, NodeJS.ErrnoException]
expect(message).toContain('could not write the complete stdout stream')
expect(message).toContain('temporary-file cleaner')
expect(error.code).toBe('ENOENT')
} finally {
await fiber.dispose()
}
})
it('warns once when ordinary spawns use the weaker macOS fallback', async () => {
const ctx = new Context()
const warning = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
@@ -1,6 +1,6 @@
import { OutputCollector } from '../src/output.ts'
import { logSpillFailure, OutputCollector, type SpillOptions } from '../src/output.ts'
import { spawn as nodeSpawn, spawnSync as nodeSpawnSync } from 'node:child_process'
import { mkdtempSync, readFileSync, rmSync, statSync, unlinkSync } from 'node:fs'
import { mkdtempSync, readFileSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { afterAll, describe, expect, it, vi } from 'vitest'
@@ -64,9 +64,12 @@ function shellArgv(command: string): string[] {
}
}
const { failNextClose, failNextUnlink } = vi.hoisted(() => ({
const { failNextClose, failNextUnlink, failNextWrite, failNextOpen, unlinked } = vi.hoisted(() => ({
failNextClose: { value: false },
failNextUnlink: { value: false },
failNextWrite: { value: false },
failNextOpen: { value: false },
unlinked: [] as string[],
}))
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>()
@@ -80,17 +83,38 @@ vi.mock('node:fs', async (importOriginal) => {
actual.closeSync(fd)
},
unlinkSync(path: Parameters<typeof actual.unlinkSync>[0]): void {
unlinked.push(String(path))
if (failNextUnlink.value) {
failNextUnlink.value = false
throw Object.assign(new Error('simulated EIO on unlink'), { code: 'EIO' })
}
actual.unlinkSync(path)
},
openSync(...args: Parameters<typeof actual.openSync>): number {
if (failNextOpen.value) {
failNextOpen.value = false
throw Object.assign(new Error('simulated EEXIST on open'), { code: 'EEXIST' })
}
return actual.openSync(...args)
},
writeSync(...args: Parameters<typeof actual.writeSync>): number {
if (failNextWrite.value) {
failNextWrite.value = false
throw Object.assign(new Error('simulated ENOSPC on write'), { code: 'ENOSPC' })
}
return actual.writeSync(...args)
},
}
})
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-subprocess-spec-'))
/** Spill options for one collector test; failures are collected for assertions. */
function spillOptions(maxBytes: number, dir = spillDir): { options: SpillOptions; failures: { error: unknown; label: string }[] } {
const failures: { error: unknown; label: string }[] = []
return { options: { maxBytes, dir, onFailure: (error, label) => { failures.push({ error, label }) } }, failures }
}
/** The per-process default spill dir captured by the default-spill test. */
let defaultSpillDir: string | undefined
@@ -516,7 +540,7 @@ describe('output truncation and spill', () => {
describe('OutputCollector', () => {
it('keeps the tail of a single oversized chunk', () => {
const collector = new OutputCollector(10, 100, 'test', spillDir)
const collector = new OutputCollector(10, 'test', spillOptions(100).options)
collector.push(Buffer.from('0123456789abcdef'))
const out = collector.finalize()
expect(out.text).toBe('6789abcdef')
@@ -527,7 +551,7 @@ describe('OutputCollector', () => {
it('retains a byte-exact tail across uneven chunk boundaries', () => {
// A diagnostic tail must be exactly the LAST maxBytes regardless of
// chunking; dropping only whole chunks would under-retain.
const collector = new OutputCollector(10, undefined, 'exact-tail', spillDir)
const collector = new OutputCollector(10, 'exact-tail', undefined)
collector.push(Buffer.from('aaaa'))
collector.push(Buffer.from('bbbbbb'))
collector.push(Buffer.from('cc'))
@@ -538,7 +562,7 @@ describe('OutputCollector', () => {
})
it('readFrom returns increments and flags lossy reads', () => {
const collector = new OutputCollector(10, 100, 'test', spillDir)
const collector = new OutputCollector(10, 'test', spillOptions(100).options)
collector.push(Buffer.from('aaaaa'))
const first = collector.readFrom(0)
expect(first.text).toBe('aaaaa')
@@ -559,7 +583,7 @@ describe('OutputCollector', () => {
})
it('contains close failures and drops the spill path', () => {
const collector = new OutputCollector(4, 100, 'closefail', spillDir)
const collector = new OutputCollector(4, 'closefail', spillOptions(100).options)
collector.push(Buffer.from('aaaa'))
collector.push(Buffer.from('bbbb'))
expect(collector.readFrom(0).spillPath).toBeDefined()
@@ -575,7 +599,7 @@ describe('OutputCollector', () => {
})
it('discards a spill that exceeds its configured cap', () => {
const collector = new OutputCollector(4, 8, 'bounded', spillDir)
const collector = new OutputCollector(4, 'bounded', spillOptions(8).options)
collector.push(Buffer.from('aaaa'))
collector.push(Buffer.from('bbbb'))
const spillPath = collector.readFrom(0).spillPath!
@@ -591,7 +615,7 @@ describe('OutputCollector', () => {
})
it('does not create a spill when the first overflowing chunk exceeds the cap', () => {
const collector = new OutputCollector(4, 4, 'no-spill', spillDir)
const collector = new OutputCollector(4, 'no-spill', spillOptions(4).options)
collector.push(Buffer.from('abcdefgh'))
const out = collector.finalize()
expect(out.text).toBe('efgh')
@@ -600,7 +624,7 @@ describe('OutputCollector', () => {
})
it('contains cleanup failures while disabling an oversize spill', () => {
const collector = new OutputCollector(4, 8, 'cleanup-fail', spillDir)
const collector = new OutputCollector(4, 'cleanup-fail', spillOptions(8).options)
collector.push(Buffer.from('aaaa'))
collector.push(Buffer.from('bbbb'))
const spillPath = collector.readFrom(0).spillPath!
@@ -613,6 +637,126 @@ describe('OutputCollector', () => {
expect(collector.finalize().spillPath).toBeUndefined()
unlinkSync(spillPath)
})
it('keeps collecting when the spill directory has been removed (ENOENT on open)', () => {
const removedDir = mkdtempSync(join(tmpdir(), 'dsh-subprocess-removed-'))
rmSync(removedDir, { recursive: true, force: true })
const { options, failures } = spillOptions(100, removedDir)
const collector = new OutputCollector(4, 'enoent', options)
collector.push(Buffer.from('aaaa'))
expect(() => { collector.push(Buffer.from('bbbb')) }).not.toThrow()
collector.push(Buffer.from('cc'))
expect(failures).toHaveLength(1)
expect((failures[0]!.error as NodeJS.ErrnoException).code).toBe('ENOENT')
expect(failures[0]!.label).toBe('enoent')
const out = collector.finalize()
expect(out.text).toBe('bbcc')
expect(out.truncated).toBe(true)
expect(out.spillPath).toBeUndefined()
})
it('keeps collecting when the spill directory is a file (ENOTDIR on open)', () => {
const fileAsDir = join(spillDir, `not-a-dir-${Date.now()}`)
writeFileSync(fileAsDir, '')
const { options, failures } = spillOptions(100, fileAsDir)
const collector = new OutputCollector(4, 'enotdir', options)
collector.push(Buffer.from('aaaa'))
expect(() => { collector.push(Buffer.from('bbbb')) }).not.toThrow()
expect(failures).toHaveLength(1)
expect(['ENOTDIR', 'ENOENT']).toContain((failures[0]!.error as NodeJS.ErrnoException).code)
expect(collector.finalize()).toEqual({ text: 'bbbb', truncated: true })
})
it('withdraws a spill whose append fails after the file exists (ENOSPC on write)', () => {
const { options, failures } = spillOptions(100)
const collector = new OutputCollector(4, 'enospc', options)
collector.push(Buffer.from('aaaa'))
collector.push(Buffer.from('bbbb'))
const spillPath = collector.readFrom(0).spillPath!
expect(readFileSync(spillPath, 'utf8')).toBe('aaaabbbb')
failNextWrite.value = true
expect(() => { collector.push(Buffer.from('cccc')) }).not.toThrow()
expect(failNextWrite.value).toBe(false)
expect(failures).toHaveLength(1)
expect((failures[0]!.error as NodeJS.ErrnoException).code).toBe('ENOSPC')
expect(() => readFileSync(spillPath)).toThrow()
collector.push(Buffer.from('dd'))
expect(failures).toHaveLength(1)
const out = collector.finalize()
expect(out.text).toBe('ccdd')
expect(out.truncated).toBe(true)
expect(out.spillPath).toBeUndefined()
})
it('does not unlink a path it never created when the exclusive open fails (EEXIST)', () => {
const { options, failures } = spillOptions(100)
const collector = new OutputCollector(4, 'eexist', options)
collector.push(Buffer.from('aaaa'))
unlinked.length = 0
failNextOpen.value = true
expect(() => { collector.push(Buffer.from('bbbb')) }).not.toThrow()
expect(failNextOpen.value).toBe(false)
expect((failures[0]!.error as NodeJS.ErrnoException).code).toBe('EEXIST')
expect(unlinked).toEqual([])
expect(collector.finalize()).toEqual({ text: 'bbbb', truncated: true })
})
it('contains a reporter that throws and keeps collecting', () => {
const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
try {
const collector = new OutputCollector(4, 'loud-reporter', {
maxBytes: 100, dir: join(spillDir, `absent-${Date.now()}`), onFailure: () => { throw new Error('logger down') },
})
collector.push(Buffer.from('aaaa'))
expect(() => { collector.push(Buffer.from('bbbb')) }).not.toThrow()
collector.push(Buffer.from('cc'))
expect(stderr).toHaveBeenCalledOnce()
expect(String(stderr.mock.calls[0]![0])).toContain('spill failure reporter threw: Error: logger down')
expect(collector.finalize()).toEqual({ text: 'bbcc', truncated: true })
} finally {
stderr.mockRestore()
}
})
})
describe('logSpillFailure', () => {
it('names the removed directory only for ENOENT and appends the error for every code', () => {
const lines: unknown[][] = []
const report = logSpillFailure({ error: (...detail: unknown[]) => { lines.push(detail) } }, 'test owner')
report(Object.assign(new Error('gone'), { code: 'ENOENT' }), 'stdout')
report(Object.assign(new Error('full'), { code: 'ENOSPC' }), 'stderr')
expect(lines[0]![0]).toContain('test owner could not write the complete stdout stream')
expect(lines[0]![0]).toContain('temporary-file cleaner')
expect(lines[1]![0]).toContain('complete stderr stream')
expect(lines[1]![0]).not.toContain('temporary-file cleaner')
expect((lines[1]![1] as NodeJS.ErrnoException).code).toBe('ENOSPC')
})
})
describe('spill failure reporting without an owner logger', () => {
it('writes one stderr line for a bare spawn whose spill directory is gone', async () => {
const removedDir = mkdtempSync(join(tmpdir(), 'dsh-subprocess-removed-'))
rmSync(removedDir, { recursive: true, force: true })
const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
try {
const result = await finish(spawnSubprocess(
spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }),
{ spillDir: removedDir },
))
expect(result.exitCode).toBe(0)
expect(result.stdout.truncated).toBe(true)
expect(result.stdout.text).toContain('line-0200')
expect(result.stdout.spillPath).toBeUndefined()
const lines = stderr.mock.calls.map(call => String(call[0])).filter(line => line.includes('dsh-subprocess-local:'))
expect(lines).toHaveLength(1)
expect(lines[0]).toContain('stdout spill failed; only the in-memory tail is retained')
expect(lines[0]).toContain('ENOENT')
} finally {
stderr.mockRestore()
}
})
})
describe('killGroup', () => {
@@ -77,8 +77,12 @@ describe('createInProcessModules', () => {
it('rejects loudly instead of fetching when a graph row has no loaded module', async () => {
const system = createInProcessModules(graph(['ghost']), new Map())
await expect(system.prefetch('ghost')).rejects.toThrow('in-process modules never load bundles (plugins/all.js)')
await expect(system.import('ghost', '', {})).rejects.toThrow('in-process modules never load bundles (plugins/all.js)')
// The module system retries the batch once and then tries the row's one-resource URL; every attempt is refused.
const first: unknown = await system.prefetch('ghost').then(() => undefined, (error: unknown) => error)
if (!(first instanceof Error)) throw new Error('prefetch resolved')
expect(first.message).toContain('in-process modules never load bundles (plugins/all.js)')
expect(first.message).toContain('in-process modules never load bundles (plugins/ghost/client.js)')
await expect(system.import('ghost', '', {})).rejects.toThrow('in-process modules never load bundles (plugins/ghost/client.js)')
})
it('exposes the graph parsed by the production validator as its manifest', () => {