fix(app-boot): keep the fail-loud exit fatal while the terminal is released

Review of the previous commit found two defects in the release path, both
reproduced against the implementation:

- The timeout guarding a never-settling release was unref'ed. An
  unhandledRejection listener suppresses Node's default fatal exit, so with
  nothing else referenced the process reached an empty event loop and exited
  0 on the very failure it was reporting. Keep the timer referenced and clear
  it once the race settles.

- The handler uninstalled itself before awaiting the release. A second
  concurrent rejection then became uncaught and killed the process
  mid-teardown, stranding exactly the terminal state this restores. Replace
  the uninstall with a latch: the first rejection is the reported one, and
  later rejections (teardown's own included) fall through to the pending exit.

Add the PTY regression the fake-process tests cannot express: boot the shipped
tree over a fixture whose llm-pi-ai providers value is list-shaped, expect exit
1, and assert the captured bytes carry both the diagnostic and ESC[?2004l.
Against the pre-fix source the stream ends at ESC[?2004h ESC[>7u ESC[?u ESC[c
with no reset and the case fails, so it pins the actual bug.

Split the two-shape formatting test into one install per case; a latched
handler reports once by design.
This commit is contained in:
Turtle
2026-07-31 20:19:58 +08:00
parent 70f37206d2
commit b35b06396d
10 changed files with 99 additions and 39 deletions

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 .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
2026-07-31-fail-loud-releases-the-terminal.md: ccac625171ef5523a4ed27843b543c838bf43ce8
2026-07-31-fail-loud-releases-the-terminal.zh.md: fe8a3271b26a94b9d986b8d17744893e5f448593

View File

@@ -25,9 +25,9 @@ The `/exit` path was never affected, because it disposes the tree and reaches th
`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.
- 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**: 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.
`dsh`'s TUI launcher passes a release that disposes the root context, which runs the TUI's existing `shutdown()` and hands the terminal back.
@@ -52,6 +52,8 @@ The guarantee belongs to whichever bin owns the terminal: a surface that grabs t
## 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.
`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`, and a burst of rejections reports only the first while the release still completes.
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.
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 diagnostic and `ESC[?2004l`. Against the pre-fix source the captured stream ends at `ESC[?2004h ESC[>7u ESC[?u ESC[c` with no reset, and the case fails on that assertion.
Testing policy requires a PTY case whenever terminal teardown changes, and this is it. The `/exit` path keeps its existing assertion that the same reset appears on a clean exit.

View File

@@ -25,9 +25,9 @@ Loader 并发挂载各个条目,因此条目失败的顺序并不等于启动
`installFailLoud` 新增可选的 `release` 拆卸回调,在诊断信息与退出之间被等待:
- 诊断信息在 release **之前**写出,因此即使 disposer 重绘或清屏,失败原因也不会丢失
- 处理函数在 release 之前先卸载自己。拆卸会执行插件 disposer其自身可能 rejection若处理函数被重入就会把清理失败报告成第二次致命加载失败从而掩盖真正的原因
- release 以 `FAIL_LOUD_RELEASE_TIMEOUT_MS`2 秒)为上限,且其 rejection 被吞掉。卡住或失败的 disposer 只会延迟致命退出,绝不会取消它。
- 诊断信息在 release **之前**写出,因此卡住或失败的 disposer 无法吞掉失败原因
- 使用闩锁latch而非卸载监听器来保证被报告的始终是第一个 rejection。若在拆卸期间移除监听器第二个并发 rejection 就会变成未捕获错误Node 会在拆卸中途杀死进程——恰好残留下本次要恢复的终端状态。后续 rejection包括 release 自身的)都会落入已挂起的退出流程
- release 以 `FAIL_LOUD_RELEASE_TIMEOUT_MS`2 秒)为上限,且其 rejection 被吞掉。卡住或失败的 disposer 只会延迟致命退出,绝不会取消它。该定时器保持 **referenced**:一旦 `unref()`Node 就会在事件循环清空后、恰恰在报告这次失败时以 0 退出,因为 `unhandledRejection` 监听器抑制了默认的致命退出。
- 不传 `release` 时行为与此前完全一致,因此 ACP、JSON-RPC 和各 demo bin 均无变化。
`dsh` 的 TUI 启动器传入的 release 会释放根上下文,从而执行 TUI 已有的 `shutdown()` 并把终端交还。
@@ -52,6 +52,8 @@ Loader 并发挂载各个条目,因此条目失败的顺序并不等于启动
## Testing
`packages/ui/app-boot/tests/app-boot.spec.ts` 覆盖 release 契约:退出提交前会等待该回调;回调 rejection 时仍退出 1在 fake timers 下,永不结算的回调会在 `FAIL_LOUD_RELEASE_TIMEOUT_MS` 后退出;以及处理函数在 release 之前已卸载,使拆卸无法重入它
`packages/ui/app-boot/tests/app-boot.spec.ts` 覆盖 release 契约:退出提交前会等待该回调;回调 rejection 时仍退出 1永不结算的回调会在 `FAIL_LOUD_RELEASE_TIMEOUT_MS` 后退出;以及一连串 rejection 只报告第一个,同时 release 仍能跑完
端到端症状是**进程退出之后**的终端状态——即 `dsh` 消失后 shell 所看到的东西——没有任何进程内断言能观测到它。该症状在 tmux 中针对 `providers` 为列表形状的配置手工验证:修复前下一条命令会被弄乱(`zsh: command not found: 4cecho`),修复后诊断信息完整、退出码为 1、下一条命令正常执行。同时复查了 `/exit` 路径,确认告别行与退出码 0 均未改变。
这些基于假进程的测试无法观测到最关键的两种失败形态——真实事件循环下的进程退出码,以及退出之后的终端状态——因此回归用例放在 `apps/cli/tests/tui-keyless-smoke.e2e.ts`。它在真实 PTY 中以 `fixtures/tui-invalid-provider.cordis.yml``providers` 为列表形状,正是用户真实会犯的错误)启动出厂配置树,期望退出码为 1并断言捕获到的字节流同时包含诊断信息与 `ESC[?2004l`。在修复前的源码上,捕获流止于 `ESC[?2004h ESC[>7u ESC[?u ESC[c` 而没有任何重置,该用例正是在这条断言上失败。
测试规范要求:只要改动终端拆卸,就必须有 PTY 用例——这就是它。`/exit` 路径保留其原有断言,确认正常退出时同样会出现该重置序列。