fix(hmr,include): settle a failing boot instead of a silent exit 13

Master's transactional loader made the invalid-provider PTY case regress:
the HMR main watcher's initial scan refreshed the include mid-initial-apply,
the concurrent group updates stranded the include fiber, and once serialized
the failing apply's rollback deadlocked on HMR's refresh drain — dsh exited
13 with no diagnostic and the terminal stranded, the exact symptom this
branch fixes. Serialize every include child-tree mutation through one queue
and pass ignoreInitial to the HMR main watcher; the failing boot now settles
through boot()'s labelled rejection with the tree disposed and exit 1. The
PTY case asserts the settled diagnostic; the fail-loud release remains the
guard for rejections boot cannot see.
This commit is contained in:
Turtle
2026-08-03 14:14:20 +08:00
parent b37c22bd0a
commit ad4aeacd19
14 changed files with 156 additions and 28 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: 8659c8a72dbb25cceaccbb0fb99b8b0251e1d506
2026-07-31-fail-loud-releases-the-terminal.zh.md: 19ced1f685c8719a652ebabd27ac199519b09369
2026-07-31-fail-loud-releases-the-terminal.md: 2a6e7fcbbdd5d35bcf70dee09fdb9e5592486b78
2026-07-31-fail-loud-releases-the-terminal.zh.md: f75c21cf79b241e6714c10ec7df9ac25f3d978b4

View File

@@ -15,7 +15,7 @@ $ 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. That rejection surfaces as an unhandled rejection, and `installFailLoud` wrote one stderr line and called `process.exit(1)` immediately.
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.
@@ -54,6 +54,6 @@ The guarantee belongs to whichever bin owns the terminal: a surface that grabs t
`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.
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 capture still shows the terminal being taken (`ESC[?2004h ESC[>7u ESC[?u ESC[c`) and the diagnostic printed, but no reset ever follows, and the case fails on the `ESC[?2004l` assertion alone.
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](2026-08-03-hmr-initial-scan-boot-deadlock.md) that silently exited 13 with the terminal stranded.
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

@@ -17,7 +17,7 @@ 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)`。没有任何环节释放这棵树,因此 `ProcessTerminal.stop()` 从未执行raw 模式、bracketed paste 和键盘协议都残留在比进程活得更久的 shell 上。终端对 Device Attributes 查询的回应(`1;2;4c`)在进程退出之后才到达,被 shell 当作用户输入读入——也就是上面那段字面文本。
在当时,该 rejection 以未处理 rejection 的形式浮现,而 `installFailLoud` 只写一行 stderr 就立即调用 `process.exit(1)`(事务化 Loader 现在让配置树失败经 `boot()` 结算由它自行释放部分构建的上下文release 回调仍然守护 `boot()` 看不到的 rejection——插件游离的异步工作在挂载期间或挂载之后失败。没有任何环节释放这棵树,因此 `ProcessTerminal.stop()` 从未执行raw 模式、bracketed paste 和键盘协议都残留在比进程活得更久的 shell 上。终端对 Device Attributes 查询的回应(`1;2;4c`)在进程退出之后才到达,被 shell 当作用户输入读入——也就是上面那段字面文本。
`/exit` 路径从不受影响,因为它会释放整棵树,从而进入 TUI 自身的 `shutdown()`:先 `drainInput()`(吸收尚未返回的响应),再 `ui.stop()`。缺陷在于**启动失败**没有通往这同一套拆卸流程的路径。
@@ -54,6 +54,6 @@ Loader 并发挂载各个条目,因此条目失败的顺序并不等于启动
`packages/ui/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并断言捕获到的字节流同时包含诊断信息与 `ESC[?2004l`。在修复前的源码上,捕获内容仍能看到终端被接管(`ESC[?2004h ESC[>7u ESC[?u ESC[c`)以及诊断信息被打印,但其后始终没有任何重置序列,该用例仅在 `ESC[?2004l` 这条断言上失败
这些基于假进程的测试无法观测到最关键的两种失败形态——真实事件循环下的进程退出码,以及退出之后的终端状态——因此回归用例放在 `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 初始扫描启动死锁](2026-08-03-hmr-initial-scan-boot-deadlock.md)
测试规范要求:只要改动终端拆卸,就必须有 PTY 用例——这就是它。`/exit` 路径保留其原有断言,确认正常退出时同样会出现该重置序列。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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-08-03-hmr-initial-scan-boot-deadlock.md
2026-08-03-hmr-initial-scan-boot-deadlock.md: 4b3e259c216d258c321ab06c41225b33ed240d19
2026-08-03-hmr-initial-scan-boot-deadlock.zh.md: ce1bc8396ac6e7fb6ecb1647fe2b29cdc788c7e1

View File

@@ -0,0 +1,41 @@
# Agent Note: HMR's initial scan deadlocked a failing boot into a silent exit 13
Status: implemented
English | [中文](2026-08-03-hmr-initial-scan-boot-deadlock.zh.md)
## Problem
A `dsh` launch whose config-tree failed validation exited 13 (unsettled top-level await) with no diagnostic at all, and left the TUI's terminal state stranded on the shell — the exact symptom the [fail-loud release](2026-07-31-fail-loud-releases-the-terminal.md) fixed, reintroduced through a different mechanism after the [transactional config reload](2026-07-20-config-hot-reload-resilience.md).
Two defects compounded:
1. **Concurrent Include applies corrupt the transactional group update.** The HMR main watcher's chokidar initial scan re-announces every existing file as `add`. Its `add` for the config file triggered `Include.refresh()` while the Include's initial apply was still in flight (`this.content`, the changed-content dedup key, commits only after apply). Two concurrent `EntryGroup.update` calls on one group interleave create and rollback on the same entries, and the Include fiber never settles — `loader.create` hangs, `boot()` neither resolves nor rejects, and Node exits 13 once the loop drains.
2. **Serialized applies alone deadlock the failure rollback.** With Include mutations queued, a failing initial apply rolls back by disposing every mounted entry — including `hmr`, whose teardown drains its refresh tasks. The scan-triggered refresh task sits in the Include queue behind the very apply whose rollback is disposing HMR: rollback waits on HMR, HMR waits on the refresh, the refresh waits on the apply.
## Decision
Both halves are fixed in the vendored packages (logged in `vendor/README.md`):
- `include/src/index.ts` funnels every child-tree mutation — initial apply, refresh, and `internal/update` patch re-application — through one per-Include promise queue. The group's transactional `update` is not reentrant, so serialization is a correctness requirement, not a throughput choice. `refresh()` also reads inside the queue so its changed-content check compares against the predecessor's committed state.
- `hmr/src/index.ts` passes `ignoreInitial: true` to the main watcher. The initial scan only re-announces files boot has just consumed; suppressing it removes both the boot-time refresh and the spurious `add` events for already-loaded modules. `registerConfig()` keeps its own `ignoreInitial: false` watcher because a personal config present at registration must apply exactly once.
With both in place a failing boot follows the intended path: the single apply fails, the rollback disposes the tree (running the TUI's own shutdown, restoring the terminal), `loader.create` rejects, and `boot()` rethrows the labelled diagnostic with exit 1.
## Alternatives considered
**Only `ignoreInitial: true`.** Removes the trigger but leaves the corruption: any genuinely concurrent refresh (a config edit racing a slow apply) still interleaves two group updates and strands the fiber.
**Only serialization.** Converts the corruption into the rollback deadlock described above; the process still exits 13 silently.
**Cancel queued refreshes on HMR teardown.** Requires cancellation plumbing through `refreshConfig`'s task loop and the Include queue for a case `ignoreInitial` already removes from every boot; not worth the machinery until a real trigger remains.
## Consequences
A config file edit landing inside the watcher's startup scan window is now picked up by the next `change` event rather than the scan itself; steady-state reload behavior is unchanged.
One latent gap remains: a config edit made during a *failing* initial apply can still queue a refresh that the rollback's HMR teardown waits on — the same deadlock shape with a human-scale trigger window of one failing boot. If that ever bites, the fix is refresh-task cancellation at HMR teardown.
## Testing
The `dsh` invalid-provider PTY case in `apps/cli/tests/tui-keyless-smoke.e2e.ts` pins the end-to-end contract: exit 1, the labelled `dsh: plugin tree failed to load:` diagnostic naming `$.providers`, and the bracketed-paste reset proving the tree was disposed. Before this fix the same case observed exit 13 with no diagnostic. Reload behavior stays covered by `packages/ui/app-boot/tests/config-reload.spec.ts` and `packages/ui/app-boot/tests/hmr-config.spec.ts`.

View File

@@ -0,0 +1,41 @@
# Agent NoteHMR 初始扫描使失败的启动死锁为静默的 exit 13
状态:已实现
[English](2026-08-03-hmr-initial-scan-boot-deadlock.md) | 中文
## 问题
`dsh` 启动时配置树校验失败,进程以 13 退出(未结算的顶层 await不输出任何诊断并把 TUI 的终端状态残留在 shell 上——这正是 [fail-loud release](2026-07-31-fail-loud-releases-the-terminal.md) 修复过的症状,在[事务化配置重载](2026-07-20-config-hot-reload-resilience.md)之后经由另一条机制重新出现。
两个缺陷叠加:
1. **并发的 Include apply 破坏事务化的 group update。** HMR 主 watcher 的 chokidar 初始扫描会把每个已存在的文件重新宣告为 `add`。其中配置文件的 `add` 在 Include 的首次 apply 尚未结束时触发了 `Include.refresh()`(内容去重键 `this.content` 只在 apply 完成后才提交)。同一 group 上两个并发的 `EntryGroup.update` 会在相同条目上交错执行 create 与回滚,导致 Include fiber 永远无法结算:`loader.create` 挂起,`boot()` 既不 resolve 也不 reject事件循环排空后 Node 以 13 退出。
2. **仅序列化 apply 会让失败回滚死锁。** 将 Include 的变更排入队列后,首次 apply 失败时的回滚会释放每个已挂载条目——包括 `hmr`,而它的拆卸会等待自身的 refresh 任务排空。扫描触发的 refresh 任务正排在 Include 队列中、位于正在回滚的那次 apply 之后:回滚等 HMRHMR 等 refreshrefresh 等 apply。
## 决定
两处修复都落在 vendored 包中(记录于 `vendor/README.md`
- `include/src/index.ts` 将每次子树变更——首次 apply、refresh、`internal/update` 补丁重应用——汇入每个 Include 一条的 promise 队列。group 的事务化 `update` 不可重入,因此序列化是正确性要求,而不是吞吐取舍。`refresh()` 也在队列内读取文件,使其内容变更判断与前一任务提交后的状态比较。
- `hmr/src/index.ts` 给主 watcher 传入 `ignoreInitial: true`。初始扫描只会重新宣告启动刚刚消费过的文件;抑制它同时消除了启动期 refresh 和对已加载模块的多余 `add` 事件。`registerConfig()` 保留自己 `ignoreInitial: false` 的 watcher因为注册时已存在的个人配置必须恰好应用一次。
两者齐备后,失败的启动走上预期路径:唯一一次 apply 失败,回滚释放整棵树(执行 TUI 自身的 shutdown、恢复终端`loader.create` reject`boot()` 重新抛出带标签的诊断并以 1 退出。
## 曾考虑的替代方案
**只加 `ignoreInitial: true`。** 消除了触发条件,但保留了破坏本身:任何真正并发的 refresh配置编辑与缓慢的 apply 竞争)仍会交错两次 group update 并使 fiber 悬置。
**只做序列化。** 把破坏转化为上述回滚死锁;进程仍然静默地以 13 退出。
**在 HMR 拆卸时取消排队中的 refresh。** 需要在 `refreshConfig` 的任务循环和 Include 队列中铺设取消机制,而 `ignoreInitial` 已把该场景从每次启动中移除;在真实触发条件出现之前不值得引入这套机构。
## 后果
落在 watcher 启动扫描窗口内的配置文件编辑,现在由下一个 `change` 事件而非扫描本身拾取;稳态的重载行为不变。
仍留有一个潜在缺口:在一次*失败的*首次 apply 期间进行的配置编辑,仍可能排入一个被回滚的 HMR 拆卸所等待的 refresh——同样的死锁形态但触发窗口缩小到一次失败启动的人力尺度。若它真的发生修复方向是在 HMR 拆卸时取消 refresh 任务。
## 测试
`apps/cli/tests/tui-keyless-smoke.e2e.ts``dsh` 无效 provider 的 PTY 用例钉住了端到端契约:以 1 退出、带标签的 `dsh: plugin tree failed to load:` 诊断指明 `$.providers`、以及证明整棵树已被释放的 bracketed-paste 复位序列。此修复之前,同一用例观察到的是无诊断的 exit 13。重载行为仍由 `packages/ui/app-boot/tests/config-reload.spec.ts``packages/ui/app-boot/tests/hmr-config.spec.ts` 覆盖。

View File

@@ -142,13 +142,15 @@ export async function runTui(
const execve = process.execve?.bind(process)
const app: { current?: Context } = {}
// The Loader mounts entries concurrently, so `ui-tui` can already hold the
// terminal (raw mode, bracketed paste, keyboard protocol) when a sibling
// entry rejects — and that rejection arrives while `boot` is still in
// flight. Disposing the tree runs the TUI's own shutdown, which stops the
// terminal and hands the shell back; without it a failed boot returns to a
// corrupted prompt. `app.current` is captured from boot's `prepare` hook, so
// it holds the root context for the whole mounting window rather than only
// after boot resolves.
// terminal (raw mode, bracketed paste, keyboard protocol) when something
// else fails. A config-tree failure settles through `boot`, which disposes
// the tree itself; this release covers the rejections `boot` cannot see — a
// plugin's detached async work rejecting while mounting is still in flight
// or after the tree settled. Disposing the tree runs the TUI's own shutdown,
// which stops the terminal and hands the shell back; without it such a
// failure returns to a corrupted prompt. `app.current` is captured from
// boot's `prepare` hook, so it holds the root context for the whole mounting
// window rather than only after boot resolves.
installFailLoud(NAME, process, async () => {
await app.current?.fiber.dispose()
})

View File

@@ -418,10 +418,13 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => {
}, 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
// terminal when a sibling entry rejects on its config. Exiting without the
// tree's own teardown 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.
// text. The transactional mount must settle (an HMR initial-scan refresh
// once deadlocked its rollback into a silent exit 13) so `boot` disposes
// the tree — reaching the TUI's own shutdown — and rejects with the
// labelled diagnostic.
it('restores the terminal when a sibling entry fails to validate during boot', async () => {
const output = await smoke({
label: 'dsh invalid provider config',
@@ -429,7 +432,7 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => {
configPath: invalidProviderConfigPath,
expectedExitCode: 1,
})
expect(output).toContain('dsh: fatal load failure:')
expect(output).toContain('dsh: plugin tree failed to load:')
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.

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 packages/ui/app-boot/README.md
README.md: 942a09fcfd76b2a8d4735598bea0f42f47f98587
README.zh.md: 662e43bf441db82ef077ee20a42616c190d766b0
README.md: 7e0466c40583e6f5b22e0d5ef25d211d595c3216
README.zh.md: abb796aaa9fd6f8e6ee0578423382ed7f23909ab

View File

@@ -23,7 +23,7 @@ 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 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.
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.

View File

@@ -23,7 +23,7 @@
Loader 结算会在导入或生命周期失败时 reject并携带失败的配置项与阶段`boot()` 会 dispose 部分构造的上下文,并用 bin 名称包装该失败。结算后遗留的配置项由独立审计处理:`assertEntriesLoaded` 将已启用却没有 fiber 的配置项转换为 rejection 并列出每个未解析插件;`assertEntriesActivated` 会显式等待每个失败的 fiber把原始错误堆栈写入启动 rejection并列出每个等待中配置项尚未解析的服务。抛出错误前审计会通过一个进程级检查点标记这些 rejection 的确切原因,从而让 `installFailLoud` 将 Loader 的重复通知合并为一次,而所有无关的未处理 rejection 仍然致命。
Loader 并发挂载各个条目,因此当某个同级条目 rejection 时,某个界面可能已经持有终端:此时直接从处理函数退出,会把 raw 模式、bracketed paste 和键盘协议残留在用户的 shell 上,而尚未返回的终端查询响应会在下一个提示符处显示为字面文本。因此,持有终端的 bin 会传入 `release` 来释放整棵树——执行该界面自身的 shutdown——然后才提交退出。`dsh``boot()``prepare` 回调中捕获根上下文,而不是取其返回值,因为 rejection 到达时 `boot()` 尚未结算。release 执行期间处理函数保持注册并加闩:被报告的始终是第一个 rejection后续 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 源码;其配置门禁要求每个 TUIWeb 裸插件都出现在解析所用 manifest 的 `dependencies` 中。bin 的子进程冒烟测试覆盖内部 loader 路径,而本包的单元测试套件会在进程内使用相对 specifier 配置驱动 `boot()`

1
vendor/README.md vendored
View File

@@ -42,6 +42,7 @@ Keep this log exhaustive — every divergence from upstream must be listed.
10. **`loader/src/repository.ts`, `loader/tsdown.config.ts`, and the `@cordisjs/plugin-loader/repository` export**: the Node-only `RepositoryCache` installs one exact dependency specifier through the bundled `pnpm@11.7.0`, single-flights callers, and atomically publishes only a prepared package plus marker under the specifier hash. The subpath stays out of the browser-reachable Loader entry. Identical specifiers permanently reuse that entry; callers change the ref/specifier for another generation. The isolated workspace permits dependency build scripts because a configured repository is executable code, while the child drops ambient credential-shaped variables. Covered by `packages/ui/app-boot/tests/repository-cache.spec.ts`, including a keyless local-Git prepare run through the bundled pnpm.
11. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`; its built ESM/CJS entries retain explicit `.mjs`/`.cjs` extensions.
12. **`include/src/index.ts` patch-semantics export**: extracted the private `applyPatches` body into the exported pure function `applyEntryPatches(data, patches, warn)` (the method delegates to it) and exported the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes and prints exactly what the include would mount without booting a tree. Behavior-preserving for mounting; the extraction exists because config tooling must never reimplement (and drift from) the patch algorithm. `applyEntryPatches` also indexes each `insert`ed entry as it is added, so a later patch in the same list can configure or disable a row an earlier patch inserted; upstream built the id index once before the patch loop, leaving inserted rows silently unpatchable. That matters because `dsh` composes one shared base (`apps/cli/config/base.cordis.yml`) with a surface overlay, an optional `--config` overlay, and the personal `~/.dsh/config.yaml` as sibling patch lists at one include level — patches never cross an include boundary, so surface-only rows would otherwise be unreachable from user config. Covered by `packages/ui/app-boot/tests/config-reload.spec.ts`.
13. **`include/src/index.ts` serialized child-tree mutation and `hmr/src/index.ts` main-watcher initial-scan suppression**: every Include child-tree mutation (initial apply, refresh, `internal/update` patch re-application) runs through one per-Include queue, because the group's transactional `update` is not reentrant — two concurrent applies interleave create and rollback on the same entries and strand the Include fiber without ever settling. The HMR main watcher passes `ignoreInitial: true`: the initial scan re-announced files boot had just consumed, and its `add` for a config file refreshed an Include mid-initial-apply; once serialized, a failing initial apply's rollback disposed HMR, whose teardown drain waited on the queued refresh sitting behind that same apply — a deadlock that exited 13 with no diagnostic and the TUI's terminal state stranded. `registerConfig()` keeps its own `ignoreInitial: false` watcher because a personal config present at registration must apply once. Covered by the `dsh` invalid-provider PTY case in `apps/cli/tests/tui-keyless-smoke.e2e.ts`.
## Sync procedure

View File

@@ -209,6 +209,14 @@ class Hmr extends Service {
...this.config,
cwd: this.baseDir,
ignored: path => match(relative(this.baseDir, path)),
// The initial scan re-announces files the boot just consumed: an `add`
// for a config file refreshes an include whose initial apply may still
// be in flight, and a failing apply then rolls this plugin back while
// the scan-triggered refresh waits on that apply — a teardown deadlock
// that strands boot without a diagnostic. Only events after the scan
// matter here; `registerConfig` keeps its own initial scan because a
// personal config present at registration must apply once.
ignoreInitial: true,
})
// Collect externals: framework modules reachable from the main entry.

View File

@@ -171,6 +171,7 @@ export class Include extends EntryTree {
private content?: string
private data?: EntryOptions[]
private writeTask?: NodeJS.Timeout
private applyQueue: Promise<unknown> = Promise.resolve()
constructor(ctx: Context, public config: Include.Config) {
super(ctx)
@@ -186,12 +187,29 @@ export class Include extends EntryTree {
ctx.on('internal/update', async (config, _, next) => {
if (config.path !== this.config.path) return next()
const data = this.applyPatches(this.data!, config.patches)
await this.root.update(data)
this.config = config
await this.enqueue(async () => {
const data = this.applyPatches(this.data!, config.patches)
await this.root.update(data)
this.config = config
})
})
}
/**
* Serialize one child-tree mutation behind every earlier one. The group's
* transactional `update` is not reentrant: two concurrent applies (the init
* apply racing an HMR-triggered refresh from the watcher's initial scan)
* interleave create and rollback on the same entries and strand the include
* fiber without settling, so every apply path funnels through this queue.
* A predecessor's failure is its own caller's outcome and never gates the
* next task.
*/
private enqueue<T>(task: () => Promise<T>): Promise<T> {
const run = this.applyQueue.then(task, task)
this.applyQueue = run.then(() => {}, () => {})
return run
}
private async checkAccess() {
if (!this.type) return
try {
@@ -262,12 +280,20 @@ export class Include extends EntryTree {
* @throws when reading, parsing, validation, application, or rollback fails; the last good tree remains active when rollback succeeds.
*/
async refresh() {
const candidate = await this.read()
if (!candidate) return
await this.apply(candidate)
// Read inside the queue so the changed-content check compares against the
// predecessor's committed state, not a mid-apply snapshot.
await this.enqueue(async () => {
const candidate = await this.read()
if (!candidate) return
await this._apply(candidate)
})
}
private async apply(candidate: ReadCandidate) {
private apply(candidate: ReadCandidate) {
return this.enqueue(() => this._apply(candidate))
}
private async _apply(candidate: ReadCandidate) {
const data = this.applyPatches(candidate.data, this.config.patches)
await this.root.update(data)
this.content = candidate.content