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 new file mode 100644 index 0000000000..4931fa907b --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.i18n.yaml @@ -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-07-31-fail-loud-releases-the-terminal.md +2026-07-31-fail-loud-releases-the-terminal.md: 2a6e7fcbbdd5d35bcf70dee09fdb9e5592486b78 +2026-07-31-fail-loud-releases-the-terminal.zh.md: f75c21cf79b241e6714c10ec7df9ac25f3d978b4 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 new file mode 100644 index 0000000000..2a6e7fcbbd --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.md @@ -0,0 +1,59 @@ +# Agent Note: fail-loud releases the terminal before exiting + +Status: implemented + +English | [中文](2026-07-31-fail-loud-releases-the-terminal.zh.md) + +## Problem + +A `dsh` launch whose config failed validation printed its diagnostic and returned the user to a broken shell. Typing was invisible, and the next command was mangled by stray text: + +``` +dsh: fatal load failure: ValidationError: invalid config: + - $.providers expected object but got [object Object] (at providers) +$ 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. 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. + +The `/exit` path was never affected, because it disposes the tree and reaches the TUI's own `shutdown()`, which calls `drainInput()` (absorbing the pending reply) and then `ui.stop()`. The defect was that a *failed boot* had no path to that same teardown. + +## Decision + +`installFailLoud` takes an optional `release` teardown, awaited between the diagnostic and the exit: + +- 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. + +The launcher captures the root context in `boot()`'s `prepare` hook rather than from its return value. The rejection arrives while `boot()` is still in flight, so `app.current` assigned after the `await` would still be `undefined` at exactly the moment the hook needs it. `prepare` runs after the Loader installs and before any config-tree entry mounts, which covers the whole window in which an entry can reject. + +## Alternatives considered + +**Reset the terminal from the fail-loud handler** (write `ESC [ ? 2004 l`, pop the keyboard protocol, clear raw mode). This duplicates pi-tui's teardown in a package that owns no terminal, and would drift as pi-tui's startup sequence changes. It also cannot absorb the in-flight Device Attributes reply, which is what corrupts the next prompt — only draining stdin while it is still raw does that. + +**Register a `process.on('exit')` terminal reset in the TUI.** Exit handlers are synchronous, so they cannot await `drainInput()`; the stray reply would still land. It also puts teardown on a global hook rather than the disposal path that already exists. + +**Have the TUI refuse to start until the tree settles.** This serializes a deliberately concurrent Loader and delays first paint for every healthy launch to fix a failure path. + +**Reorder config entries so `llm-pi-ai` mounts before `ui-tui`.** Ordering is not a guarantee the Loader makes, and any future entry could fail after the TUI mounts. + +## Consequences + +A failed boot now costs one tree disposal (bounded at 2s) before exit, and the exit code stays 1. In exchange, a misconfigured `dsh` returns a usable shell instead of one needing `stty sane` or `reset`. + +The guarantee belongs to whichever bin owns the terminal: a surface that grabs terminal state and does not pass `release` reintroduces this defect. `installFailLoud` cannot detect that on its own, since it has no view of what a mounted plugin did to the process. + +## 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`, 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 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. 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 new file mode 100644 index 0000000000..f75c21cf79 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.zh.md @@ -0,0 +1,59 @@ +# Agent Note:fail-loud 在退出前释放终端 + +Status: implemented + +[English](2026-07-31-fail-loud-releases-the-terminal.md) | 中文 + +## Problem + +配置校验失败的 `dsh` 启动会打印诊断信息,然后把用户丢回一个损坏的 shell:输入不可见,下一条命令还会被残留文本弄乱: + +``` +dsh: fatal load failure: ValidationError: invalid config: + - $.providers expected object but got [object Object] (at providers) +$ 1;2;4cecho hello +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)`。(事务化 Loader 现在让配置树失败经 `boot()` 结算,由它自行释放部分构建的上下文;release 回调仍然守护 `boot()` 看不到的 rejection——插件游离的异步工作在挂载期间或挂载之后失败。)没有任何环节释放这棵树,因此 `ProcessTerminal.stop()` 从未执行:raw 模式、bracketed paste 和键盘协议都残留在比进程活得更久的 shell 上。终端对 Device Attributes 查询的回应(`1;2;4c`)在进程退出之后才到达,被 shell 当作用户输入读入——也就是上面那段字面文本。 + +`/exit` 路径从不受影响,因为它会释放整棵树,从而进入 TUI 自身的 `shutdown()`:先 `drainInput()`(吸收尚未返回的响应),再 `ui.stop()`。缺陷在于**启动失败**没有通往这同一套拆卸流程的路径。 + +## Decision + +`installFailLoud` 新增可选的 `release` 拆卸回调,在诊断信息与退出之间被等待: + +- 诊断信息在 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()` 并把终端交还。 + +启动器在 `boot()` 的 `prepare` 回调中捕获根上下文,而不是取其返回值。rejection 到达时 `boot()` 尚未结算,因此在 `await` 之后赋值的 `app.current` 恰好在回调需要它的那一刻仍是 `undefined`。`prepare` 在 Loader 安装之后、任何配置树条目挂载之前运行,覆盖了条目可能 rejection 的整个窗口。 + +## Alternatives considered + +**在 fail-loud 处理函数里直接重置终端**(写 `ESC [ ? 2004 l`、弹出键盘协议、清除 raw 模式)。这会在一个并不拥有终端的包里重复 pi-tui 的拆卸逻辑,并随 pi-tui 启动序列的变化而漂移。它同样无法吸收尚未返回的 Device Attributes 响应——而这正是弄乱下一个提示符的原因,只有在 stdin 仍处于 raw 模式时排空它才能解决。 + +**在 TUI 中注册 `process.on('exit')` 终端重置。** exit 处理函数是同步的,无法等待 `drainInput()`,残留响应依旧会落到 shell;而且这把拆卸挂到全局钩子上,而非已经存在的释放路径。 + +**让 TUI 等整棵树结算后再启动。** 这会把刻意并发的 Loader 串行化,并为修复一条失败路径而拖慢每一次正常启动的首次绘制。 + +**调整配置顺序,让 `llm-pi-ai` 先于 `ui-tui` 挂载。** 顺序并不是 Loader 提供的保证,而且未来任何条目都可能在 TUI 挂载之后失败。 + +## Consequences + +启动失败现在会在退出前多付出一次树释放的代价(上限 2 秒),退出码仍为 1。作为交换,配置错误的 `dsh` 会交还一个可用的 shell,而不是需要 `stty sane` 或 `reset` 才能恢复的终端。 + +这项保证属于**拥有终端的那个 bin**:任何抢占终端状态却不传 `release` 的界面都会重新引入该缺陷。`installFailLoud` 自身无法察觉这一点,因为它看不到已挂载的插件对进程做了什么。 + +## Testing + +`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,并断言捕获到的字节流同时包含带标签的启动 rejection(`dsh: plugin tree failed to load:`)与 `ESC[?2004l`。同一用例端到端钉住了启动路径:正是它发现了以 13 静默退出、终端状态被残留的 [HMR 初始扫描启动死锁](2026-08-03-hmr-initial-scan-boot-deadlock.md)。 + +测试规范要求:只要改动终端拆卸,就必须有 PTY 用例——这就是它。`/exit` 路径保留其原有断言,确认正常退出时同样会出现该重置序列。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-03-hmr-initial-scan-boot-deadlock.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-03-hmr-initial-scan-boot-deadlock.i18n.yaml new file mode 100644 index 0000000000..170627ea76 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-03-hmr-initial-scan-boot-deadlock.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/bug-fix/2026-08-03-hmr-initial-scan-boot-deadlock.md b/.agents/notes/implemented/bug-fix/2026-08-03-hmr-initial-scan-boot-deadlock.md new file mode 100644 index 0000000000..4b3e259c21 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-03-hmr-initial-scan-boot-deadlock.md @@ -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`. diff --git a/.agents/notes/implemented/bug-fix/2026-08-03-hmr-initial-scan-boot-deadlock.zh.md b/.agents/notes/implemented/bug-fix/2026-08-03-hmr-initial-scan-boot-deadlock.zh.md new file mode 100644 index 0000000000..ce1bc8396a --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-03-hmr-initial-scan-boot-deadlock.zh.md @@ -0,0 +1,41 @@ +# Agent Note:HMR 初始扫描使失败的启动死锁为静默的 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 之后:回滚等 HMR,HMR 等 refresh,refresh 等 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` 覆盖。 diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts index 32b767cb2c..5af1a32cbb 100644 --- a/apps/cli/src/tui.ts +++ b/apps/cli/src/tui.ts @@ -115,7 +115,6 @@ export async function runTui( ) process.exit(1) } - installFailLoud(NAME) // The bin already loaded the invoking directory's .env, and that is the // whole environment: $DSH_HOME/.env is credentials-local's writable store, // and hoisting it would make every stored key read as a read-only ambient @@ -142,6 +141,19 @@ export async function runTui( const entry = process.argv[1] 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 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() + }) // Resume always enters the default surface because meta rejects // parent options, including `--resume`. The resumed session already persists // its cwd. @@ -219,6 +231,10 @@ export async function runTui( bootConfig, patches, (hostCtx) => { + // Runs after the Loader installs and before any config-tree entry mounts, + // so the fail-loud release hook can reach the tree for the whole window in + // which an entry may reject. + app.current = hostCtx // The launcher owns session identity and the exit line: a config-mounted // app bundle reads both from these slots, so no cordis.yml key can drop // resume. 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 f61d37360b..70c9c0fa65 100644 --- a/apps/cli/tests/tui-keyless-smoke.e2e.ts +++ b/apps/cli/tests/tui-keyless-smoke.e2e.ts @@ -26,6 +26,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' @@ -414,6 +417,28 @@ 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 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 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', + tempDirPrefix: 'dsh-tui-invalid-config-', + configPath: invalidProviderConfigPath, + expectedExitCode: 1, + }) + 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. + 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 65a471f69a..d565f6f11c 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: 2eb9e904d574df39b0884558fc0a53f9dc04cdc1 -README.zh.md: 78bd99943fcadebf42a5d772d49f3bfcf6a8790a +README.md: 7e0466c40583e6f5b22e0d5ef25d211d595c3216 +README.zh.md: abb796aaa9fd6f8e6ee0578423382ed7f23909ab diff --git a/packages/ui/app-boot/README.md b/packages/ui/app-boot/README.md index 2eb9e904d5..7e0466c405 100644 --- a/packages/ui/app-boot/README.md +++ b/packages/ui/app-boot/README.md @@ -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. diff --git a/packages/ui/app-boot/README.zh.md b/packages/ui/app-boot/README.zh.md index 78bd99943f..abb796aaa9 100644 --- a/packages/ui/app-boot/README.zh.md +++ b/packages/ui/app-boot/README.zh.md @@ -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 包解析。 diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index eb5003f72b..7f3579cda1 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -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 { + 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 + try { + await Promise.race([ + (async () => release())(), + new Promise((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 } /** diff --git a/packages/ui/app-boot/tests/app-boot.spec.ts b/packages/ui/app-boot/tests/app-boot.spec.ts index 610f93b3d5..96cad31ea3 100644 --- a/packages/ui/app-boot/tests/app-boot.spec.ts +++ b/packages/ui/app-boot/tests/app-boot.spec.ts @@ -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(() => {})) + 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', () => { diff --git a/vendor/README.md b/vendor/README.md index 3872faa753..c05a65e28f 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -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 diff --git a/vendor/hmr/src/index.ts b/vendor/hmr/src/index.ts index 65ce923dc3..2484d0152a 100644 --- a/vendor/hmr/src/index.ts +++ b/vendor/hmr/src/index.ts @@ -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. diff --git a/vendor/include/src/index.ts b/vendor/include/src/index.ts index a13d273bc2..4a9fd6be86 100644 --- a/vendor/include/src/index.ts +++ b/vendor/include/src/index.ts @@ -171,6 +171,7 @@ export class Include extends EntryTree { private content?: string private data?: EntryOptions[] private writeTask?: NodeJS.Timeout + private applyQueue: Promise = 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(task: () => Promise): Promise { + 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