diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.i18n.yaml new file mode 100644 index 0000000000..13949d3b73 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.md +2026-07-31-fail-loud-releases-the-terminal.md: 410e89a1f172f2c7a37016aa6ac023e9cb80d153 +2026-07-31-fail-loud-releases-the-terminal.zh.md: 678834d8705eb6ce7ad52560a0ec255b4ea518a1 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.md b/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.md new file mode 100644 index 0000000000..410e89a1f1 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.md @@ -0,0 +1,57 @@ +# Agent Note: fail-loud releases the terminal before exiting + +Status: implemented + +English | [中文](2026-07-31-fail-loud-releases-the-terminal.zh.md) + +## Problem + +A `dsh` launch whose config failed validation printed its diagnostic and returned the user to a broken shell. Typing was invisible, and the next command was mangled by stray text: + +``` +dsh: fatal load failure: ValidationError: invalid config: + - $.providers expected object but got [object Object] (at providers) +$ 1;2;4cecho hello +zsh: command not found: 4cecho +``` + +The Loader mounts entries concurrently, so entry failure order is not startup order. `ui-tui` activates and calls pi-tui's `ProcessTerminal.start()`, which puts stdin in raw mode, enables bracketed paste, and writes the Kitty keyboard-protocol probe — a sequence ending in a Device Attributes query (`ESC [ c`). A sibling entry (here `llm-pi-ai`) then rejects on its own config. That rejection surfaces as an unhandled rejection, and `installFailLoud` wrote one stderr line and called `process.exit(1)` immediately. + +Nothing disposed the tree, so `ProcessTerminal.stop()` never ran: raw mode, bracketed paste, and the keyboard protocol stayed set on the shell that outlived the process. The terminal's answer to the Device Attributes query (`1;2;4c`) arrived after exit and was read by the shell as typed input — the literal text above. + +The `/exit` path was never affected, because it disposes the tree and reaches the TUI's own `shutdown()`, which calls `drainInput()` (absorbing the pending reply) and then `ui.stop()`. The defect was that a *failed boot* had no path to that same teardown. + +## Decision + +`installFailLoud` takes an optional `release` teardown, awaited between the diagnostic and the exit: + +- The diagnostic is written **before** the release, so the reason survives a disposer that repaints or clears the screen. +- The handler uninstalls itself before releasing. Teardown runs plugin disposers that may themselves reject, and a re-entered handler would report a cleanup failure as a second fatal load failure, burying the real one. +- 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. +- Omitting `release` keeps the previous behavior exactly, so the ACP, JSON-RPC, and demo bins are unchanged. + +`dsh`'s TUI launcher passes a release that disposes the root context, which runs the TUI's existing `shutdown()` and hands the terminal back. + +The launcher captures the root context in `boot()`'s `prepare` hook rather than from its return value. The rejection arrives while `boot()` is still in flight, so `app.current` assigned after the `await` would still be `undefined` at exactly the moment the hook needs it. `prepare` runs after the Loader installs and before any config-tree entry mounts, which covers the whole window in which an entry can reject. + +## Alternatives considered + +**Reset the terminal from the fail-loud handler** (write `ESC [ ? 2004 l`, pop the keyboard protocol, clear raw mode). This duplicates pi-tui's teardown in a package that owns no terminal, and would drift as pi-tui's startup sequence changes. It also cannot absorb the in-flight Device Attributes reply, which is what corrupts the next prompt — only draining stdin while it is still raw does that. + +**Register a `process.on('exit')` terminal reset in the TUI.** Exit handlers are synchronous, so they cannot await `drainInput()`; the stray reply would still land. It also puts teardown on a global hook rather than the disposal path that already exists. + +**Have the TUI refuse to start until the tree settles.** This serializes a deliberately concurrent Loader and delays first paint for every healthy launch to fix a failure path. + +**Reorder config entries so `llm-pi-ai` mounts before `ui-tui`.** Ordering is not a guarantee the Loader makes, and any future entry could fail after the TUI mounts. + +## Consequences + +A failed boot now costs one tree disposal (bounded at 2s) before exit, and the exit code stays 1. In exchange, a misconfigured `dsh` returns a usable shell instead of one needing `stty sane` or `reset`. + +The guarantee belongs to whichever bin owns the terminal: a surface that grabs terminal state and does not pass `release` reintroduces this defect. `installFailLoud` cannot detect that on its own, since it has no view of what a mounted plugin did to the process. + +## Testing + +`packages/ui/app-boot/tests/app-boot.spec.ts` covers the release contract: the hook is awaited before the exit commits, a rejecting hook still exits 1, a never-settling hook exits after `FAIL_LOUD_RELEASE_TIMEOUT_MS` under fake timers, and the handler is uninstalled before releasing so teardown cannot re-enter it. + +The end-to-end symptom is terminal state after process exit — what the *shell* sees once `dsh` is gone — which no in-process assertion observes. It was verified manually in tmux against a config with a list-shaped `providers` value: before the change the next command was mangled (`zsh: command not found: 4cecho`); after it, the diagnostic is intact, the exit code is 1, and the next command runs normally. The `/exit` path was re-checked to confirm the goodbye line and exit code 0 are unchanged. diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.zh.md b/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.zh.md new file mode 100644 index 0000000000..678834d870 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.zh.md @@ -0,0 +1,57 @@ +# Agent Note:fail-loud 在退出前释放终端 + +Status: implemented + +[English](2026-07-31-fail-loud-releases-the-terminal.md) | 中文 + +## Problem + +配置校验失败的 `dsh` 启动会打印诊断信息,然后把用户丢回一个损坏的 shell:输入不可见,下一条命令还会被残留文本弄乱: + +``` +dsh: fatal load failure: ValidationError: invalid config: + - $.providers expected object but got [object Object] (at providers) +$ 1;2;4cecho hello +zsh: command not found: 4cecho +``` + +Loader 并发挂载各个条目,因此条目失败的顺序并不等于启动顺序。`ui-tui` 会先激活并调用 pi-tui 的 `ProcessTerminal.start()`,它把 stdin 置为 raw 模式、启用 bracketed paste,并写出 Kitty 键盘协议探测序列——该序列以一个 Device Attributes 查询(`ESC [ c`)结尾。随后某个同级条目(这里是 `llm-pi-ai`)因自身配置而 rejection。 + +该 rejection 以未处理 rejection 的形式浮现,而 `installFailLoud` 只写一行 stderr 就立即调用 `process.exit(1)`。没有任何环节释放这棵树,因此 `ProcessTerminal.stop()` 从未执行:raw 模式、bracketed paste 和键盘协议都残留在比进程活得更久的 shell 上。终端对 Device Attributes 查询的回应(`1;2;4c`)在进程退出之后才到达,被 shell 当作用户输入读入——也就是上面那段字面文本。 + +`/exit` 路径从不受影响,因为它会释放整棵树,从而进入 TUI 自身的 `shutdown()`:先 `drainInput()`(吸收尚未返回的响应),再 `ui.stop()`。缺陷在于**启动失败**没有通往这同一套拆卸流程的路径。 + +## Decision + +`installFailLoud` 新增可选的 `release` 拆卸回调,在诊断信息与退出之间被等待: + +- 诊断信息在 release **之前**写出,因此即使 disposer 重绘或清屏,失败原因也不会丢失。 +- 处理函数在 release 之前先卸载自己。拆卸会执行插件 disposer,其自身可能 rejection;若处理函数被重入,就会把清理失败报告成第二次致命加载失败,从而掩盖真正的原因。 +- release 以 `FAIL_LOUD_RELEASE_TIMEOUT_MS`(2 秒)为上限,且其 rejection 被吞掉。卡住或失败的 disposer 只会延迟致命退出,绝不会取消它。 +- 不传 `release` 时行为与此前完全一致,因此 ACP、JSON-RPC 和各 demo bin 均无变化。 + +`dsh` 的 TUI 启动器传入的 release 会释放根上下文,从而执行 TUI 已有的 `shutdown()` 并把终端交还。 + +启动器在 `boot()` 的 `prepare` 回调中捕获根上下文,而不是取其返回值。rejection 到达时 `boot()` 尚未结算,因此在 `await` 之后赋值的 `app.current` 恰好在回调需要它的那一刻仍是 `undefined`。`prepare` 在 Loader 安装之后、任何配置树条目挂载之前运行,覆盖了条目可能 rejection 的整个窗口。 + +## Alternatives considered + +**在 fail-loud 处理函数里直接重置终端**(写 `ESC [ ? 2004 l`、弹出键盘协议、清除 raw 模式)。这会在一个并不拥有终端的包里重复 pi-tui 的拆卸逻辑,并随 pi-tui 启动序列的变化而漂移。它同样无法吸收尚未返回的 Device Attributes 响应——而这正是弄乱下一个提示符的原因,只有在 stdin 仍处于 raw 模式时排空它才能解决。 + +**在 TUI 中注册 `process.on('exit')` 终端重置。** exit 处理函数是同步的,无法等待 `drainInput()`,残留响应依旧会落到 shell;而且这把拆卸挂到全局钩子上,而非已经存在的释放路径。 + +**让 TUI 等整棵树结算后再启动。** 这会把刻意并发的 Loader 串行化,并为修复一条失败路径而拖慢每一次正常启动的首次绘制。 + +**调整配置顺序,让 `llm-pi-ai` 先于 `ui-tui` 挂载。** 顺序并不是 Loader 提供的保证,而且未来任何条目都可能在 TUI 挂载之后失败。 + +## Consequences + +启动失败现在会在退出前多付出一次树释放的代价(上限 2 秒),退出码仍为 1。作为交换,配置错误的 `dsh` 会交还一个可用的 shell,而不是需要 `stty sane` 或 `reset` 才能恢复的终端。 + +这项保证属于**拥有终端的那个 bin**:任何抢占终端状态却不传 `release` 的界面都会重新引入该缺陷。`installFailLoud` 自身无法察觉这一点,因为它看不到已挂载的插件对进程做了什么。 + +## Testing + +`packages/ui/app-boot/tests/app-boot.spec.ts` 覆盖 release 契约:退出提交前会等待该回调;回调 rejection 时仍退出 1;在 fake timers 下,永不结算的回调会在 `FAIL_LOUD_RELEASE_TIMEOUT_MS` 后退出;以及处理函数在 release 之前已卸载,使拆卸无法重入它。 + +端到端症状是**进程退出之后**的终端状态——即 `dsh` 消失后 shell 所看到的东西——没有任何进程内断言能观测到它。该症状在 tmux 中针对 `providers` 为列表形状的配置手工验证:修复前下一条命令会被弄乱(`zsh: command not found: 4cecho`),修复后诊断信息完整、退出码为 1、下一条命令正常执行。同时复查了 `/exit` 路径,确认告别行与退出码 0 均未改变。 diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts index 15e7d6f77b..5e19dc9c6d 100644 --- a/apps/cli/src/tui.ts +++ b/apps/cli/src/tui.ts @@ -113,7 +113,6 @@ export async function runTui( ) process.exit(1) } - installFailLoud(NAME) // The bin already loaded the invoking directory's .env, and that is the // whole environment: $DSH_HOME/.env is credentials-local's writable store, // and hoisting it would make every stored key read as a read-only ambient @@ -140,6 +139,17 @@ export async function runTui( const entry = process.argv[1] const execve = process.execve?.bind(process) const app: { current?: Context } = {} + // The Loader mounts entries concurrently, so `ui-tui` can already hold the + // terminal (raw mode, bracketed paste, keyboard protocol) when a sibling + // entry rejects — and that rejection arrives while `boot` is still in + // flight. Disposing the tree runs the TUI's own shutdown, which stops the + // terminal and hands the shell back; without it a failed boot returns to a + // corrupted prompt. `app.current` is captured from boot's `prepare` hook, so + // it holds the root context for the whole mounting window rather than only + // after boot resolves. + installFailLoud(NAME, process, async () => { + await app.current?.fiber.dispose() + }) // Resume always enters the default surface because experimental-meta rejects // parent options, including `--resume`. The resumed session already persists // its cwd. @@ -216,6 +226,10 @@ export async function runTui( bootConfig, patches, (hostCtx) => { + // Runs after the Loader installs and before any config-tree entry mounts, + // so the fail-loud release hook can reach the tree for the whole window in + // which an entry may reject. + app.current = hostCtx // The launcher owns session identity and the exit line: a config-mounted // app bundle reads both from these slots, so no cordis.yml key can drop // resume. diff --git a/packages/ui/app-boot/README.i18n.yaml b/packages/ui/app-boot/README.i18n.yaml index 75b1ec16b2..c0e13b0cf2 100644 --- a/packages/ui/app-boot/README.i18n.yaml +++ b/packages/ui/app-boot/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/ui/app-boot/README.md -README.md: ebd8e0842b934f6887e3c122e781c1d0f13bb5d3 -README.zh.md: ccd897d48178482aa74d0eb73505e26ec3a08d6c +README.md: ba5cf9a05b456e2d72abe1e2a65b64825ceef1a5 +README.zh.md: d2f2b2d2c93b1ecb9fb4fad085d4abd663440108 diff --git a/packages/ui/app-boot/README.md b/packages/ui/app-boot/README.md index ebd8e0842b..ba5cf9a05b 100644 --- a/packages/ui/app-boot/README.md +++ b/packages/ui/app-boot/README.md @@ -8,7 +8,8 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-c |---|---| | `resolveConfigPath(path, snapshotMode, cwd?)` | Absolute config path; `snapshotMode === 'replay'` swaps a `cordis.yml`/`.yaml` basename for its sibling `cordis.snapshot.yml` | | `loadEnv(binName, dir?, warn?)` | Load the gitignored `.env` (Node `process.loadEnvFile`); absent file is fine, an unloadable one warns a single labelled line (default: stderr) | -| `installFailLoud(binName, proc?)` | Turn a post-`boot()` unhandled Loader rejection into one labelled stderr line + `exit(1)`; returns the uninstaller (for tests) | +| `installFailLoud(binName, proc?, release?)` | Turn a post-`boot()` unhandled Loader rejection into one labelled stderr line + `exit(1)`; the optional `release` teardown is awaited between the two (bounded by `FAIL_LOUD_RELEASE_TIMEOUT_MS`) so a terminal-owning surface restores the terminal before exit; returns the uninstaller (for tests) | +| `FAIL_LOUD_RELEASE_TIMEOUT_MS` | How long `installFailLoud` waits for its `release` hook; a wedged disposer delays the fatal exit, never cancels it | | `assertEntriesLoaded(ctx, binName)` | Throw when a settled tree holds an enabled entry with no fiber, reporting every unresolved plugin name as a Cordis startup failure | | `assertEntriesActivated(ctx, binName)` | Include the `assertEntriesLoaded` check, then await every enabled entry after the Loader settles; throw with each failed plugin's original stack or each pending plugin's unresolved services | | `loadPersonalPatches(binName, dir?)` | Parse the optional `config.yaml` in the Harness home (default [`resolveDshHome()`](../../util/paths/README.md): `$DSH_HOME`, else `~/.dsh`) — a top-level YAML array of include `PatchOptions` (id-targeted config overrides, `insert` lists, `!!js` allowed); absent file → `undefined`, an unreadable/unparsable/non-array file throws | @@ -20,6 +21,8 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-c Two Loader failure classes require separate guards because tree settlement propagates neither to its caller. A failed plugin import leaves a fiber-less entry that `assertEntriesLoaded` turns into a `boot()` rejection naming every unresolved plugin. A plugin callback or config failure leaves a failed fiber because `loader.await()` settles lifecycle tasks without propagating that error; `assertEntriesActivated` awaits the fiber explicitly and includes its original stack in the startup rejection. Before throwing, the audit marks those exact rejection reasons through one process checkpoint so `installFailLoud` coalesces Loader's duplicate notification while every unrelated unhandled rejection remains fatal. +The Loader mounts entries concurrently, so a surface can already own the terminal when a sibling entry rejects: exiting straight from the handler would leave raw mode, bracketed paste, and the keyboard protocol set on the user's shell, and an in-flight terminal query's reply would land as literal text at the next prompt. A terminal-owning bin therefore passes `release` to dispose the tree — running that surface's own shutdown — before the exit commits. `dsh` captures the root context in `boot()`'s `prepare` hook rather than from its return value, because the rejection arrives while `boot()` is still in flight. + Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve through the Cordis Loader's internal module loader. Repository bins install Loader's optional `node-addon-require-builtin` peer; external callers must supply it or install plugins where plain Node import resolution can find them. Relative specifiers resolve against the config directory without the native helper. The built `dsh-app-boot` artifact embeds the statically mounted Include implementation while leaving Loader external, so the include tree and host bind to one Loader peer. The `dsh` source launcher additionally maps manifest-declared workspace packages to their TypeScript source; its configuration gate requires every TUI/Web bare plugin to appear in the resolver manifest's `dependencies`. The bins' subprocess smokes exercise the internal-loader path, while this package's unit suite drives `boot()` in-process against configs with relative specifiers. This package carries no loader hooks and no dev-mode surface. The [`dsh` app](../../../apps/cli/README.md) owns its Node source-launch hook and consumes these helpers for the boot sequence; built consumers continue to use plain Node package resolution. diff --git a/packages/ui/app-boot/README.zh.md b/packages/ui/app-boot/README.zh.md index ccd897d481..d2f2b2d2c9 100644 --- a/packages/ui/app-boot/README.zh.md +++ b/packages/ui/app-boot/README.zh.md @@ -8,7 +8,8 @@ |---|---| | `resolveConfigPath(path, snapshotMode, cwd?)` | 生成绝对配置路径;当 `snapshotMode === 'replay'` 时,把 basename 为 `cordis.yml`/`.yaml` 的文件替换为同级 `cordis.snapshot.yml` | | `loadEnv(binName, dir?, warn?)` | 加载已被 git 忽略的 `.env`(Node `process.loadEnvFile`);文件不存在不影响启动,文件无法加载时输出一行带标签的警告(默认写入 stderr) | -| `installFailLoud(binName, proc?)` | 将 `boot()` 之后未处理的 Loader rejection 转换为一行带标签的 stderr 消息并执行 `exit(1)`;返回卸载函数(供测试使用) | +| `installFailLoud(binName, proc?, release?)` | 将 `boot()` 之后未处理的 Loader rejection 转换为一行带标签的 stderr 消息并执行 `exit(1)`;两者之间会等待可选的 `release` 拆卸回调(以 `FAIL_LOUD_RELEASE_TIMEOUT_MS` 为上限),使持有终端的界面能在退出前恢复终端;返回卸载函数(供测试使用) | +| `FAIL_LOUD_RELEASE_TIMEOUT_MS` | `installFailLoud` 等待其 `release` 回调的时长;卡住的 disposer 只会延迟致命退出,而不会取消它 | | `assertEntriesLoaded(ctx, binName)` | 树结算后,如果其中存在已启用但没有 fiber 的条目,则抛出异常,并以 Cordis 启动故障的形式报告每个未解析插件的名称 | | `assertEntriesActivated(ctx, binName)` | 先执行 `assertEntriesLoaded` 检查,再在 Loader 结算后等待每个已启用配置项;抛出的错误包含每个失败插件的原始错误堆栈,或每个等待中插件尚未解析的服务 | | `loadPersonalPatches(binName, dir?)` | 解析 Harness home 中可选的 `config.yaml`(默认使用 [`resolveDshHome()`](../../util/paths/README.md):先取 `$DSH_HOME`,否则取 `~/.dsh`):其顶层是一个 YAML 数组,内容为 include 的 `PatchOptions`(按 id 定位的配置覆盖、`insert` 列表,允许 `!!js`);文件不存在时返回 `undefined`,文件不可读、不可解析或内容不是数组时抛出异常 | @@ -20,6 +21,8 @@ Loader 树结算不会向调用方传播两类故障,因此需要分别保护。插件导入失败会留下没有 fiber 的配置项,`assertEntriesLoaded` 将其转换为 `boot()` rejection,并列出每个未解析插件。插件回调或配置失败则会留下失败的 fiber,因为 `loader.await()` 只结算生命周期任务,不传播该错误;`assertEntriesActivated` 会显式等待该 fiber,并把原始错误堆栈写入启动 rejection。抛出错误前,审计会通过一个进程级检查点标记这些 rejection 的确切原因,从而让 `installFailLoud` 将 Loader 的重复通知合并为一次,而所有无关的未处理 rejection 仍然致命。 +Loader 并发挂载各个条目,因此当某个同级条目 rejection 时,某个界面可能已经持有终端:此时直接从处理函数退出,会把 raw 模式、bracketed paste 和键盘协议残留在用户的 shell 上,而尚未返回的终端查询响应会在下一个提示符处显示为字面文本。因此,持有终端的 bin 会传入 `release` 来释放整棵树——执行该界面自身的 shutdown——然后才提交退出。`dsh` 在 `boot()` 的 `prepare` 回调中捕获根上下文,而不是取其返回值,因为 rejection 到达时 `boot()` 尚未结算。 + 配置中的裸插件 specifier(`@deepseek-ai/dsh-*`、npm 包(package))通过 Cordis Loader 的内部模块 loader 解析。仓库 bin 会安装 Loader 的可选 peer `node-addon-require-builtin`;外部调用方必须提供该组件,或者把插件安装到普通 Node import 解析可以找到的位置。相对 specifier 无需原生 helper,并以配置目录为基准解析。构建后的 `dsh-app-boot` 产物内嵌静态挂载的 Include 实现,但仍将 Loader 保持为外部依赖,因此 include 树与 host 会绑定到同一个 Loader peer。`dsh` 源码启动器还会将 manifest(元数据清单)声明的 workspace 包映射到其 TypeScript 源码;其配置门禁要求每个 TUI/Web 裸插件都出现在解析所用 manifest 的 `dependencies` 中。bin 的子进程冒烟测试覆盖内部 loader 路径,而本包的单元测试套件会在进程内使用相对 specifier 配置驱动 `boot()`。 此包不包含 loader 钩子,也不提供开发模式接口。[`dsh` 应用](../../../apps/cli/README.md)持有自己的 Node 源码启动钩子,并在启动序列中使用这些 helper;构建后的消费方仍使用普通 Node 包解析。 diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index 982bcc59ed..ec8f717f45 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -325,24 +325,69 @@ async function observeLoaderRejectionCheckpoint(reasons: readonly unknown[]): Pr } } +/** + * How long {@link installFailLoud} waits for its `release` hook before exiting + * anyway. A wedged disposer must delay the fatal exit, never cancel it. + */ +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 assertEntriesActivated} is ignored during its process checkpoint; * every other rejection remains fatal. Stdout remains untouched for ACP; the * returned function removes the handler. + * + * 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 + * handler would strand raw mode, bracketed paste, and the keyboard protocol on + * the user's shell, and leave an in-flight terminal query's reply to land as + * literal text at the next prompt. `release` is the terminal owner's chance to + * hand it back; it is awaited under {@link FAIL_LOUD_RELEASE_TIMEOUT_MS}. The + * diagnostic is written before the release so the reason survives a disposer + * that repaints or clears the screen, and the handler uninstalls itself before + * releasing so a rejection from teardown cannot re-enter it. * @param binName - the diagnostic prefix on the fatal-failure line. * @param proc - the process slice to register on; tests inject a fake. + * @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. */ -export function installFailLoud(binName: string, proc: FailLoudProcess = process): () => void { +export function installFailLoud( + binName: string, + proc: FailLoudProcess = process, + release?: () => Promise | void, +): () => void { const handler = (err: unknown): void => { if (assembledActivationRejections.has(err)) return proc.stderr.write(`${binName}: fatal load failure: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`) - proc.exit(1) + if (release === undefined) { + proc.exit(1) + return + } + // The release runs plugin disposers, which may themselves reject. Without + // this the handler would re-enter and report a teardown failure as a second + // fatal load failure, hiding the real one. + uninstall() + void (async () => { + try { + await Promise.race([ + (async () => release())(), + new Promise((resolve) => { + setTimeout(resolve, FAIL_LOUD_RELEASE_TIMEOUT_MS).unref() + }), + ]) + } catch { + // The terminal release failed; the fatal exit below is the outcome that + // matters, and no reporter runs after it. + } + proc.exit(1) + })() } + const uninstall = (): void => void proc.off('unhandledRejection', handler) proc.on('unhandledRejection', handler) - return () => void proc.off('unhandledRejection', handler) + return uninstall } /** diff --git a/packages/ui/app-boot/tests/app-boot.spec.ts b/packages/ui/app-boot/tests/app-boot.spec.ts index 7f06016267..c47b16cc78 100644 --- a/packages/ui/app-boot/tests/app-boot.spec.ts +++ b/packages/ui/app-boot/tests/app-boot.spec.ts @@ -5,7 +5,8 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import { - addHarnessSourceSection, assertEntriesActivated, assertEntriesLoaded, boot, HARNESS_SOURCE_SECTION, + addHarnessSourceSection, assertEntriesActivated, assertEntriesLoaded, boot, + FAIL_LOUD_RELEASE_TIMEOUT_MS, HARNESS_SOURCE_SECTION, installFailLoud, loadEnv, loadOverlayPatches, resolveConfigPath, type FailLoudProcess, } from '../src/index.ts' @@ -162,6 +163,56 @@ describe('installFailLoud', () => { proc.handlers[0]!(error) expect(proc.exits).toEqual([1]) }) + + // The Loader mounts entries concurrently, so a terminal-owning surface can + // already hold raw mode when a sibling entry rejects. Exiting without running + // its teardown strands the terminal on the user's shell. + it('awaits the release hook before exiting so the terminal owner can restore it', async () => { + const proc = fakeProc() + const order: string[] = [] + installFailLoud(NAME, proc, async () => { + await Promise.resolve() + order.push('released') + }) + proc.handlers[0]!(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([]) + await vi.waitFor(() => { expect(proc.exits).toEqual([1]) }) + expect(order).toEqual(['released']) + }) + + 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')) + await vi.waitFor(() => { expect(proc.exits).toEqual([1]) }) + }) + + it('exits without waiting when a release hook never settles', async () => { + vi.useFakeTimers() + try { + const proc = fakeProc() + installFailLoud(NAME, proc, () => new Promise(() => {})) + proc.handlers[0]!(new Error('boom')) + expect(proc.exits).toEqual([]) + await vi.advanceTimersByTimeAsync(FAIL_LOUD_RELEASE_TIMEOUT_MS) + expect(proc.exits).toEqual([1]) + } finally { + vi.useRealTimers() + } + }) + + // Teardown runs plugin disposers, whose own rejection must not be reported as + // a second fatal load failure over the real one. + it('uninstalls the handler before releasing, so teardown cannot re-enter it', async () => { + const proc = fakeProc() + installFailLoud(NAME, proc, () => {}) + proc.handlers[0]!(new Error('boom')) + expect(proc.handlers).toHaveLength(0) + await vi.waitFor(() => { expect(proc.exits).toEqual([1]) }) + expect(proc.written).toHaveLength(1) + }) }) describe('assertEntriesLoaded', () => {