fail-loud releases the terminal before exiting
fail-loud 在退出前释放终端
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 failur
English
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. At the time, that rejection surfaced as an unhandled rejection, and installFailLoud wrote one stderr line and called process.exit(1) immediately. (The transactional Loader now settles config-tree failures through boot(), which disposes the partial context itself; the release hook remains the guard for rejections boot() cannot see — a plugin's detached async work rejecting during or after mounting.)
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 a hanging or failing disposer cannot swallow the reason.
- 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: anunref()ed one lets Node reach an empty event loop and exit 0 on the very failure being reported, because anunhandledRejectionlistener suppresses the default fatal exit. - Omitting
releasekeeps 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/boot/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, and a burst of rejections reports only the first while the release still completes.
Those fake-process tests cannot observe the two failure modes that matter most — process exit code with a real event loop, and terminal state after exit — so the regression lives in apps/cli/tests/tui-keyless-smoke.e2e.ts. It boots the shipped tree in a real PTY over fixtures/tui-invalid-provider.cordis.yml (a list-shaped providers, the mistake users actually make), expects exit 1, and asserts the captured bytes contain both the labelled boot rejection (dsh: plugin tree failed to load:) and ESC[?2004l. The same case pins the boot path end to end: it caught the HMR initial-scan boot deadlock that silently exited 13 with the terminal stranded.
The /exit path keeps its existing assertion that the same reset appears on a clean exit.
中文
问题
配置校验失败的 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)。(事务化 Loader 现在让配置树失败经 boot() 结算,由它自行 dispose(资源释放)部分构建的上下文;release 钩子仍然守护 boot() 看不到的 rejection——插件游离的异步工作在挂载期间或挂载之后失败。)没有任何环节 dispose 这棵树,因此 ProcessTerminal.stop() 从未执行:raw 模式、bracketed paste 和键盘协议都残留在比进程活得更久的 shell 上。终端对 Device Attributes 查询的回应(1;2;4c)在进程退出之后才到达,被 shell 当作用户输入读入——也就是上面那段字面文本。
/exit 路径从不受影响,因为它会 dispose 整棵树,从而进入 TUI 自身的 shutdown():先 drainInput()(吸收尚未返回的响应),再 ui.stop()。缺陷在于启动失败没有通往这同一套拆卸流程的路径。
决策
installFailLoud 新增可选的 release 拆卸回调,在诊断信息与退出之间被等待:
- 诊断信息在 release 之前写出,因此卡住或失败的 disposer 无法吞掉失败原因。
- 使用闩锁(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 均无变化。
dsh 的 TUI 启动器传入的 release 会释放根上下文,从而执行 TUI 已有的 shutdown() 并把终端交还。
启动器在 boot() 的 prepare 钩子中捕获根上下文,而不是取其返回值。rejection 到达时 boot() 尚未结算,因此在 await 之后赋值的 app.current 恰好在回调需要它的那一刻仍是 undefined。prepare 在 Loader 安装之后、任何配置树条目挂载之前运行,覆盖了条目可能 rejection 的整个窗口。
考虑过的替代方案
在响亮失败处理函数里直接重置终端(写 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 挂载之后失败。
后果
启动失败现在会在退出前多付出一次树释放的代价(上限 2 秒),退出码仍为 1。作为交换,配置错误的 dsh 会交还一个可用的 shell,而不是需要 stty sane 或 reset 才能恢复的终端。
这项保证属于拥有终端的那个 bin:任何抢占终端状态却不传 release 的界面都会重新引入该缺陷。installFailLoud 自身无法察觉这一点,因为它看不到已挂载的插件对进程做了什么。
测试
packages/boot/app-boot/tests/app-boot.spec.ts 覆盖 release 约定:退出提交前会等待该钩子;钩子 rejection 时仍退出 1;永不结算的钩子会在 FAIL_LOUD_RELEASE_TIMEOUT_MS 后退出;以及一连串 rejection 只报告第一个,同时 release 仍能跑完。
这些基于假进程的测试无法观测到最关键的两种失败形态——真实事件循环下的进程退出码,以及退出之后的终端状态——因此回归用例放在 apps/cli/tests/tui-keyless-smoke.e2e.ts。它在真实 PTY 中以 fixtures/tui-invalid-provider.cordis.yml(providers 为列表形状,正是用户真实会犯的错误)启动出厂配置树,期望退出码为 1,并断言捕获到的字节流同时包含带标签的启动 rejection(dsh: plugin tree failed to load:)与 ESC[?2004l。同一用例端到端钉住了启动路径:正是它发现了以 13 静默退出、终端状态未被恢复的 HMR(热模块替换)初始扫描启动死锁。
/exit 路径保留其原有断言,确认正常退出时同样会出现该重置序列。