From b35b06396def4a4a1e5770c1b389cc3f3f9cd4d1 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 31 Jul 2026 20:19:58 +0800 Subject: [PATCH] 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. --- ...-fail-loud-releases-the-terminal.i18n.yaml | 4 +- ...6-07-31-fail-loud-releases-the-terminal.md | 12 +++--- ...7-31-fail-loud-releases-the-terminal.zh.md | 12 +++--- .../fixtures/tui-invalid-provider.cordis.yml | 10 +++++ apps/cli/tests/tui-keyless-smoke.e2e.ts | 22 ++++++++++ packages/ui/app-boot/README.i18n.yaml | 4 +- packages/ui/app-boot/README.md | 2 +- packages/ui/app-boot/README.zh.md | 2 +- packages/ui/app-boot/src/index.ts | 28 +++++++++---- packages/ui/app-boot/tests/app-boot.spec.ts | 42 ++++++++++++------- 10 files changed, 99 insertions(+), 39 deletions(-) create mode 100644 apps/cli/tests/fixtures/tui-invalid-provider.cordis.yml 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 index 13949d3b73..97dc84403b 100644 --- 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 @@ -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 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 index 410e89a1f1..ccac625171 100644 --- 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 @@ -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. 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 index 678834d870..fe8a3271b2 100644 --- 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 @@ -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` 路径保留其原有断言,确认正常退出时同样会出现该重置序列。 diff --git a/apps/cli/tests/fixtures/tui-invalid-provider.cordis.yml b/apps/cli/tests/fixtures/tui-invalid-provider.cordis.yml new file mode 100644 index 0000000000..f03a58d5d7 --- /dev/null +++ b/apps/cli/tests/fixtures/tui-invalid-provider.cordis.yml @@ -0,0 +1,10 @@ +# An overlay whose `llm-pi-ai` config fails schema validation: `providers` is a +# dict keyed by provider name, and a list is the shape users reach for. The +# entry rejects while `ui-tui` — mounted concurrently by the Loader — already +# holds the terminal, which is the boot failure the fail-loud release hook +# exists for. +- id: llm-pi-ai + config: + providers: + - provider: openai + apiKey: keyless-invalid-shape diff --git a/apps/cli/tests/tui-keyless-smoke.e2e.ts b/apps/cli/tests/tui-keyless-smoke.e2e.ts index 7c7ad10141..8a917517a2 100644 --- a/apps/cli/tests/tui-keyless-smoke.e2e.ts +++ b/apps/cli/tests/tui-keyless-smoke.e2e.ts @@ -24,6 +24,9 @@ const dshBinScript = fileURLToPath(new URL('../src/bin.ts', import.meta.url)) // `--config` layers an overlay over the shared base, so the default surface // needs no config argument at all; these are the overlays under test. const scriptedConfigPath = fileURLToPath(new URL('./fixtures/tui-scripted.cordis.yml', import.meta.url)) +// An overlay whose `llm-pi-ai` config fails validation, so an entry rejects +// while the TUI already holds the terminal. +const invalidProviderConfigPath = fileURLToPath(new URL('./fixtures/tui-invalid-provider.cordis.yml', import.meta.url)) const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) const firstRunSnapshots = fileURLToPath(new URL('./tui-first-run-snapshots/', import.meta.url)) const synchronizedFrameEnd = '\x1b[?2026l' @@ -380,6 +383,25 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { expect(output).toContain('\u001B[?2004l') }, PTY_SMOKE_TEST_TIMEOUT_MS) + // The Loader mounts entries concurrently, so `ui-tui` can already own the + // terminal when a sibling entry rejects on its config. Exiting straight from + // the fail-loud handler left raw mode and bracketed paste set on the user's + // shell, and the pending Device Attributes reply landed there as literal + // text. The launcher's release hook must reach the TUI's own teardown. + it('restores the terminal when a sibling entry fails to validate during boot', async () => { + const output = await smoke({ + label: 'dsh invalid provider config', + tempDirPrefix: 'dsh-tui-invalid-config-', + configPath: invalidProviderConfigPath, + expectedExitCode: 1, + }) + expect(output).toContain('dsh: fatal load failure:') + expect(output).toContain('$.providers') + // Bracketed paste is disabled again, which only `ProcessTerminal.stop()` + // writes — proof the tree was disposed rather than exited out from under. + expect(output).toContain('\u001B[?2004l') + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('switches models, streams a response, answers a user-question dialog, and exits cleanly', async () => { const output = await smoke({ label: 'dsh conversation', diff --git a/packages/ui/app-boot/README.i18n.yaml b/packages/ui/app-boot/README.i18n.yaml index c0e13b0cf2..2e88921efc 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: ba5cf9a05b456e2d72abe1e2a65b64825ceef1a5 -README.zh.md: d2f2b2d2c93b1ecb9fb4fad085d4abd663440108 +README.md: 7107ea20e72a6117f957090e753c126106b26663 +README.zh.md: 1e5b0850d7c92cd365adf441c31ee3432f13f7fa diff --git a/packages/ui/app-boot/README.md b/packages/ui/app-boot/README.md index ba5cf9a05b..7107ea20e7 100644 --- a/packages/ui/app-boot/README.md +++ b/packages/ui/app-boot/README.md @@ -21,7 +21,7 @@ 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. +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. While a release is in flight the handler stays installed and latched: the first rejection is the reported one, and later rejections (teardown's own included) are swallowed rather than becoming uncaught and killing the process mid-teardown. 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. diff --git a/packages/ui/app-boot/README.zh.md b/packages/ui/app-boot/README.zh.md index d2f2b2d2c9..1e5b0850d7 100644 --- a/packages/ui/app-boot/README.zh.md +++ b/packages/ui/app-boot/README.zh.md @@ -21,7 +21,7 @@ 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()` 尚未结算。 +Loader 并发挂载各个条目,因此当某个同级条目 rejection 时,某个界面可能已经持有终端:此时直接从处理函数退出,会把 raw 模式、bracketed paste 和键盘协议残留在用户的 shell 上,而尚未返回的终端查询响应会在下一个提示符处显示为字面文本。因此,持有终端的 bin 会传入 `release` 来释放整棵树——执行该界面自身的 shutdown——然后才提交退出。`dsh` 在 `boot()` 的 `prepare` 回调中捕获根上下文,而不是取其返回值,因为 rejection 到达时 `boot()` 尚未结算。release 执行期间处理函数保持注册并加闩:被报告的始终是第一个 rejection,后续 rejection(包括拆卸自身的)会被吞掉,而不会变成未捕获错误、在拆卸中途杀死进程。 配置中的裸插件 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()`。 diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index ec8f717f45..da824851cc 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -343,10 +343,16 @@ export const FAIL_LOUD_RELEASE_TIMEOUT_MS = 2_000 * 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. + * hand it back; it is awaited under {@link FAIL_LOUD_RELEASE_TIMEOUT_MS}, whose + * timer stays referenced so a never-settling disposer cannot let Node reach an + * empty event loop and exit 0 instead of failing. + * + * The diagnostic is written before the release so a hanging or failing disposer + * cannot swallow the reason. The handler stays installed while the release runs + * — removing it would let a second concurrent rejection become uncaught and kill + * the process mid-teardown, stranding exactly the terminal state this restores — + * so a latch keeps the first rejection the reported one and lets later + * rejections (including the release's own) fall through to the pending exit. * @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 @@ -359,29 +365,33 @@ export function installFailLoud( proc: FailLoudProcess = process, release?: () => Promise | 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 + // (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`) 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 () => { + let timer: ReturnType | undefined try { await Promise.race([ (async () => release())(), new Promise((resolve) => { - setTimeout(resolve, FAIL_LOUD_RELEASE_TIMEOUT_MS).unref() + timer = setTimeout(resolve, FAIL_LOUD_RELEASE_TIMEOUT_MS) }), ]) } catch { // The terminal release failed; the fatal exit below is the outcome that // matters, and no reporter runs after it. } + if (timer !== undefined) clearTimeout(timer) proc.exit(1) })() } diff --git a/packages/ui/app-boot/tests/app-boot.spec.ts b/packages/ui/app-boot/tests/app-boot.spec.ts index c47b16cc78..f4b2e2a902 100644 --- a/packages/ui/app-boot/tests/app-boot.spec.ts +++ b/packages/ui/app-boot/tests/app-boot.spec.ts @@ -110,16 +110,22 @@ describe('installFailLoud', () => { expect(proc.exits).toEqual([1]) }) + // One rejection is reported per install: the first is the diagnosis, so each + // formatting case needs its own handler rather than reusing a latched one. it('stringifies a non-Error rejection and an Error without a stack falls back to its message', () => { - const proc = fakeProc() - installFailLoud(NAME, proc) - proc.handlers[0]!('plain failure') - expect(proc.written[0]).toContain('plain failure') + const plain = fakeProc() + installFailLoud(NAME, plain) + plain.handlers[0]!('plain failure') + expect(plain.written[0]).toContain('plain failure') + expect(plain.exits).toEqual([1]) + const stackless = new Error('no stack') delete (stackless as { stack?: string }).stack - proc.handlers[0]!(stackless) - expect(proc.written[1]).toContain('no stack') - expect(proc.exits).toEqual([1, 1]) + const bare = fakeProc() + installFailLoud(NAME, bare) + bare.handlers[0]!(stackless) + expect(bare.written[0]).toContain('no stack') + expect(bare.exits).toEqual([1]) }) it('returns an uninstaller that removes the handler (and defaults to the real process)', () => { @@ -203,15 +209,23 @@ describe('installFailLoud', () => { } }) - // 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 () => { + // Loader failures arrive in bursts, and teardown's own disposers may reject. + // Only the first rejection is the diagnosis; the handler must stay installed + // so a later one cannot become uncaught and kill the process mid-teardown. + it('reports only the first rejection and keeps handling later ones during the release', 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]) }) + let released = false + installFailLoud(NAME, proc, async () => { + await Promise.resolve() + released = true + }) + proc.handlers[0]!(new Error('first rejection')) + proc.handlers[0]!(new Error('second rejection')) + expect(proc.handlers).toHaveLength(1) expect(proc.written).toHaveLength(1) + expect(proc.written[0]).toContain('first rejection') + await vi.waitFor(() => { expect(proc.exits).toEqual([1]) }) + expect(released).toBe(true) }) })