Merge pull request #1093 from deepseek-harness/fix/tui-terminal-restore-on-boot-failure
fix(app-boot): restore the terminal when boot fails
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: 2eb9e904d574df39b0884558fc0a53f9dc04cdc1
|
||||
README.zh.md: 78bd99943fcadebf42a5d772d49f3bfcf6a8790a
|
||||
README.md: 7e0466c40583e6f5b22e0d5ef25d211d595c3216
|
||||
README.zh.md: abb796aaa9fd6f8e6ee0578423382ed7f23909ab
|
||||
|
||||
@@ -8,7 +8,8 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-c
|
||||
|---|---|
|
||||
| `resolveConfigPath(path, snapshotMode, cwd?)` | Absolute config path; `snapshotMode === 'replay'` swaps a `cordis.yml`/`.yaml` basename for its sibling `cordis.snapshot.yml` |
|
||||
| `loadEnv(binName, dir?, warn?)` | Load the gitignored `.env` (Node `process.loadEnvFile`); absent file is fine, an unloadable one warns a single labelled line (default: stderr) |
|
||||
| `installFailLoud(binName, proc?)` | Turn an unhandled boot or later Loader rejection into one labelled stderr line + `exit(1)`; returns the uninstaller (for tests) |
|
||||
| `installFailLoud(binName, proc?, release?)` | Turn an unhandled boot or later Loader rejection into one labelled stderr line + `exit(1)`; the optional `release` teardown is awaited between the two (bounded by `FAIL_LOUD_RELEASE_TIMEOUT_MS`) so a terminal-owning surface restores the terminal before exit; returns the uninstaller (for tests) |
|
||||
| `FAIL_LOUD_RELEASE_TIMEOUT_MS` | How long `installFailLoud` waits for its `release` hook; a wedged disposer delays the fatal exit, never cancels it |
|
||||
| `assertEntriesLoaded(ctx, binName)` | Throw when a settled tree holds an enabled entry with no fiber, reporting every unresolved plugin name as a Cordis startup failure |
|
||||
| `assertEntriesActivated(ctx, binName)` | Include the `assertEntriesLoaded` check, then await every enabled entry after the Loader settles; throw with each failed plugin's original stack or each pending plugin's unresolved services |
|
||||
| `loadPersonalPatches(binName, dir?)` | Parse the optional `config.yaml` in the Harness home (default [`resolveDshHome()`](../../util/paths/README.md): `$DSH_HOME`, else `~/.dsh`) — a top-level YAML array of include `PatchOptions` (id-targeted config overrides, `insert` lists, `!!js` allowed); absent file → `undefined`, an unreadable/unparsable/non-array file throws |
|
||||
@@ -22,6 +23,8 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-c
|
||||
|
||||
Loader settlement rejects import and lifecycle failures with the failing entry and stage; `boot()` disposes the partial context and wraps that failure with the bin name. Entries settlement leaves behind are audited separately: `assertEntriesLoaded` turns an enabled fiber-less entry into a rejection naming every unresolved plugin, and `assertEntriesActivated` awaits each failed fiber to include its original stack in the startup rejection and names each pending entry's unresolved services. 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 something else fails: exiting without the tree's own teardown 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 config-tree failure settles through `boot()`, whose disposal of the partial context runs the surface's own shutdown before the labelled rejection. For the rejections `boot()` cannot see — a plugin's detached async work rejecting during or after mounting — a terminal-owning bin passes `release` to dispose the tree before the exit commits; `dsh` captures the root context in `boot()`'s `prepare` hook rather than from its return value so the hook covers the whole mounting window. 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.
|
||||
|
||||
This package carries no loader hooks and no dev-mode surface. The [`dsh` app](../../../apps/cli/README.md) owns its Node source-launch hook and consumes these helpers for the boot sequence; built consumers continue to use plain Node package resolution.
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
|---|---|
|
||||
| `resolveConfigPath(path, snapshotMode, cwd?)` | 生成绝对配置路径;当 `snapshotMode === 'replay'` 时,把 basename 为 `cordis.yml`/`.yaml` 的文件替换为同级 `cordis.snapshot.yml` |
|
||||
| `loadEnv(binName, dir?, warn?)` | 加载已被 git 忽略的 `.env`(Node `process.loadEnvFile`);文件不存在不影响启动,文件无法加载时输出一行带标签的警告(默认写入 stderr) |
|
||||
| `installFailLoud(binName, proc?)` | 将启动期或后续未处理的 Loader rejection 转换为一行带标签的 stderr 消息并执行 `exit(1)`;返回卸载函数(供测试使用) |
|
||||
| `installFailLoud(binName, proc?, release?)` | 将启动期或后续未处理的 Loader rejection 转换为一行带标签的 stderr 消息并执行 `exit(1)`;两者之间会等待可选的 `release` 拆卸回调(以 `FAIL_LOUD_RELEASE_TIMEOUT_MS` 为上限),使持有终端的界面能在退出前恢复终端;返回卸载函数(供测试使用) |
|
||||
| `FAIL_LOUD_RELEASE_TIMEOUT_MS` | `installFailLoud` 等待其 `release` 回调的时长;卡住的 disposer 只会延迟致命退出,而不会取消它 |
|
||||
| `assertEntriesLoaded(ctx, binName)` | 树结算后,如果其中存在已启用但没有 fiber 的条目,则抛出异常,并以 Cordis 启动故障的形式报告每个未解析插件的名称 |
|
||||
| `assertEntriesActivated(ctx, binName)` | 先执行 `assertEntriesLoaded` 检查,再在 Loader 结算后等待每个已启用配置项;抛出的错误包含每个失败插件的原始错误堆栈,或每个等待中插件尚未解析的服务 |
|
||||
| `loadPersonalPatches(binName, dir?)` | 解析 Harness home 中可选的 `config.yaml`(默认使用 [`resolveDshHome()`](../../util/paths/README.md):先取 `$DSH_HOME`,否则取 `~/.dsh`):其顶层是一个 YAML 数组,内容为 include 的 `PatchOptions`(按 id 定位的配置覆盖、`insert` 列表,允许 `!!js`);文件不存在时返回 `undefined`,文件不可读、不可解析或内容不是数组时抛出异常 |
|
||||
@@ -22,6 +23,8 @@
|
||||
|
||||
Loader 结算会在导入或生命周期失败时 reject,并携带失败的配置项与阶段;`boot()` 会 dispose 部分构造的上下文,并用 bin 名称包装该失败。结算后遗留的配置项由独立审计处理:`assertEntriesLoaded` 将已启用却没有 fiber 的配置项转换为 rejection 并列出每个未解析插件;`assertEntriesActivated` 会显式等待每个失败的 fiber,把原始错误堆栈写入启动 rejection,并列出每个等待中配置项尚未解析的服务。抛出错误前,审计会通过一个进程级检查点标记这些 rejection 的确切原因,从而让 `installFailLoud` 将 Loader 的重复通知合并为一次,而所有无关的未处理 rejection 仍然致命。
|
||||
|
||||
Loader 并发挂载各个条目,因此当其他环节失败时,某个界面可能已经持有终端:此时不经过整棵树自身的拆卸就退出,会把 raw 模式、bracketed paste 和键盘协议残留在用户的 shell 上,而尚未返回的终端查询响应会在下一个提示符处显示为字面文本。配置树失败会经 `boot()` 结算:它先释放部分构建的上下文(从而执行该界面自身的 shutdown),再抛出带标签的 rejection。对于 `boot()` 看不到的 rejection(插件游离的异步工作在挂载期间或挂载完成后失败),持有终端的 bin 会传入 `release`,在提交退出前释放整棵树;`dsh` 在 `boot()` 的 `prepare` 回调中捕获根上下文,而不是取其返回值,使该回调覆盖整个挂载窗口。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()`。
|
||||
|
||||
此包不包含 loader 钩子,也不提供开发模式接口。[`dsh` 应用](../../../apps/cli/README.md)持有自己的 Node 源码启动钩子,并在启动序列中使用这些 helper;构建后的消费方仍使用普通 Node 包解析。
|
||||
|
||||
@@ -391,6 +391,11 @@ export interface FailLoudProcess {
|
||||
on(event: 'unhandledRejection', handler: (err: unknown) => void): unknown
|
||||
off(event: 'unhandledRejection', handler: (err: unknown) => void): unknown
|
||||
stderr: { write(chunk: string): unknown }
|
||||
/**
|
||||
* Terminate the process. Callers treat this as the end of the run, as
|
||||
* `process.exit` is; a fake that returns lets the caller continue, which only
|
||||
* a test observes.
|
||||
*/
|
||||
exit(code: number): void
|
||||
}
|
||||
|
||||
@@ -421,24 +426,81 @@ async function observeLoaderRejectionCheckpoint(reasons: readonly unknown[]): Pr
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How long {@link installFailLoud} waits for its `release` hook before exiting
|
||||
* anyway. A wedged disposer must delay the fatal exit, never cancel it.
|
||||
*/
|
||||
export const FAIL_LOUD_RELEASE_TIMEOUT_MS = 2_000
|
||||
|
||||
/**
|
||||
* Install before boot to turn a late unhandled plugin-init rejection into one
|
||||
* labelled stderr diagnostic and `exit(1)`. A rejection already included by
|
||||
* {@link assertEntriesActivated} is ignored during its process checkpoint;
|
||||
* every other rejection remains fatal. Stdout remains untouched for ACP; the
|
||||
* returned function removes the handler.
|
||||
*
|
||||
* The Loader mounts entries concurrently, so a surface that owns the terminal
|
||||
* can already hold it when a sibling entry rejects. Exiting straight from the
|
||||
* 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}, 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
|
||||
* terminal-owning surface to restore the terminal. Its own failure is
|
||||
* swallowed because the pending fatal exit already owns the outcome.
|
||||
* @returns the uninstaller that removes the rejection handler.
|
||||
*/
|
||||
export function installFailLoud(binName: string, proc: FailLoudProcess = process): () => void {
|
||||
export function installFailLoud(
|
||||
binName: string,
|
||||
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`)
|
||||
proc.exit(1)
|
||||
if (release === undefined) {
|
||||
proc.exit(1)
|
||||
return
|
||||
}
|
||||
void (async () => {
|
||||
// Definitely assigned: the timeout promise's executor runs synchronously
|
||||
// while the race is being constructed, before the first await.
|
||||
let timer!: ReturnType<typeof setTimeout>
|
||||
try {
|
||||
await Promise.race([
|
||||
(async () => release())(),
|
||||
new Promise<void>((resolve) => {
|
||||
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.
|
||||
}
|
||||
clearTimeout(timer)
|
||||
proc.exit(1)
|
||||
})()
|
||||
}
|
||||
const uninstall = (): void => void proc.off('unhandledRejection', handler)
|
||||
proc.on('unhandledRejection', handler)
|
||||
return () => void proc.off('unhandledRejection', handler)
|
||||
return uninstall
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,7 +5,8 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import {
|
||||
addHarnessSourceSection, assertEntriesActivated, assertEntriesLoaded, boot, HARNESS_SOURCE_SECTION,
|
||||
addHarnessSourceSection, assertEntriesActivated, assertEntriesLoaded, boot,
|
||||
FAIL_LOUD_RELEASE_TIMEOUT_MS, HARNESS_SOURCE_SECTION,
|
||||
installFailLoud, loadEnv, loadOverlayPatches, resolveConfigPath, type FailLoudProcess,
|
||||
} from '../src/index.ts'
|
||||
|
||||
@@ -109,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)', () => {
|
||||
@@ -162,6 +169,64 @@ describe('installFailLoud', () => {
|
||||
proc.handlers[0]!(error)
|
||||
expect(proc.exits).toEqual([1])
|
||||
})
|
||||
|
||||
// The Loader mounts entries concurrently, so a terminal-owning surface can
|
||||
// already hold raw mode when a sibling entry rejects. Exiting without running
|
||||
// its teardown strands the terminal on the user's shell.
|
||||
it('awaits the release hook before exiting so the terminal owner can restore it', async () => {
|
||||
const proc = fakeProc()
|
||||
const order: string[] = []
|
||||
installFailLoud(NAME, proc, async () => {
|
||||
await Promise.resolve()
|
||||
order.push('released')
|
||||
})
|
||||
proc.handlers[0]!(new Error('sibling entry rejected'))
|
||||
expect(proc.written[0]).toContain(`${NAME}: fatal load failure: `)
|
||||
// The release is in flight, so the exit has not committed yet.
|
||||
expect(proc.exits).toEqual([])
|
||||
await vi.waitFor(() => { expect(proc.exits).toEqual([1]) })
|
||||
expect(order).toEqual(['released'])
|
||||
})
|
||||
|
||||
it('still exits when the release hook rejects', async () => {
|
||||
const proc = fakeProc()
|
||||
installFailLoud(NAME, proc, () => Promise.reject(new Error('terminal stop failed')))
|
||||
proc.handlers[0]!(new Error('boom'))
|
||||
await vi.waitFor(() => { expect(proc.exits).toEqual([1]) })
|
||||
})
|
||||
|
||||
it('exits without waiting when a release hook never settles', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const proc = fakeProc()
|
||||
installFailLoud(NAME, proc, () => new Promise<void>(() => {}))
|
||||
proc.handlers[0]!(new Error('boom'))
|
||||
expect(proc.exits).toEqual([])
|
||||
await vi.advanceTimersByTimeAsync(FAIL_LOUD_RELEASE_TIMEOUT_MS)
|
||||
expect(proc.exits).toEqual([1])
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
// 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()
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
describe('assertEntriesLoaded', () => {
|
||||
|
||||
Reference in New Issue
Block a user