fix(app-boot): release the terminal before a fatal load exit

A dsh launch whose config failed validation returned the user to a broken
shell: typing was invisible and the next command was mangled by a stray
Device Attributes reply (1;2;4cecho ...).

The Loader mounts entries concurrently, so ui-tui can already hold the
terminal (raw mode, bracketed paste, keyboard protocol, plus an in-flight
DA query) when a sibling entry rejects on its own config. installFailLoud
wrote its diagnostic and exited immediately, so nothing disposed the tree
and ProcessTerminal.stop() never ran.

Give installFailLoud an optional release teardown, awaited between the
diagnostic and the exit and bounded by FAIL_LOUD_RELEASE_TIMEOUT_MS. The
TUI launcher passes one that disposes the root context, reaching the same
shutdown() the /exit path already uses (drainInput() + ui.stop()). The
context is captured in boot()'s prepare hook because the rejection arrives
while boot() is still in flight.

Bins that pass no release keep the previous behavior exactly.
This commit is contained in:
Turtle
2026-07-31 19:57:45 +08:00
parent 5c4b701afe
commit 70f37206d2
9 changed files with 245 additions and 9 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -0,0 +1,57 @@
# Agent Notefail-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 均未改变。