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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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()`。
|
||||
|
||||
|
||||
@@ -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,
|
||||
): () => 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<typeof setTimeout> | undefined
|
||||
try {
|
||||
await Promise.race([
|
||||
(async () => release())(),
|
||||
new Promise<void>((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)
|
||||
})()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user