From 195f7fa9af0ff33e75f44e767a23b8e1e8aaa200 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 30 Jul 2026 03:11:16 +0800 Subject: [PATCH] fix(cordis): make config reload transactional --- ...-20-config-hot-reload-resilience.i18n.yaml | 6 +- ...2026-07-20-config-hot-reload-resilience.md | 31 +-- ...6-07-20-config-hot-reload-resilience.zh.md | 31 +-- docs/cordis-catalog/core/fiber.md | 6 +- docs/cordis-catalog/events.md | 3 +- .../stderr.expected.txt | 4 +- .../host/directory-picker-auto/src/index.ts | 8 +- .../tests/loader-composition.spec.ts | 2 +- packages/host/webserver/README.i18n.yaml | 4 +- packages/host/webserver/README.md | 2 +- packages/host/webserver/README.zh.md | 2 +- .../host/webserver/tests/webserver.spec.ts | 24 +- packages/typert/loader/tests/loader.spec.ts | 4 +- packages/ui/app-boot/README.i18n.yaml | 4 +- packages/ui/app-boot/README.md | 6 +- packages/ui/app-boot/README.zh.md | 6 +- packages/ui/app-boot/package.json | 2 + packages/ui/app-boot/src/index.ts | 81 +++--- packages/ui/app-boot/tests/app-boot.spec.ts | 48 +++- .../ui/app-boot/tests/config-reload.spec.ts | 236 +++++++++++++++++- packages/ui/app-boot/tests/hmr-config.spec.ts | 142 +++++++++++ pnpm-lock.yaml | 6 + scripts/gen-cordis-catalog.ts | 3 +- vendor/README.md | 9 +- vendor/cordis/src/events.ts | 2 +- vendor/cordis/src/fiber.ts | 6 +- vendor/hmr/src/index.ts | 164 ++++++++++-- vendor/include/src/index.ts | 114 +++++---- vendor/loader/src/config/entry.ts | 192 +++++++++++--- vendor/loader/src/config/group.ts | 72 ++++-- vendor/loader/src/config/isolate.ts | 4 +- vendor/loader/src/config/tree.ts | 53 +++- vendor/loader/src/index.ts | 11 +- 33 files changed, 1020 insertions(+), 268 deletions(-) create mode 100644 packages/ui/app-boot/tests/hmr-config.spec.ts diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.i18n.yaml index b16ef70d7c..f6a6429e64 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.i18n.yaml @@ -1,6 +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 -2026-07-20-config-hot-reload-resilience.md: 1a8e29c603ede50b60199e9151fca58dadcc3d40 -2026-07-20-config-hot-reload-resilience.zh.md: 6c7a421bfa84504a36d5329e13a485bf72cc6b6c +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.md +2026-07-20-config-hot-reload-resilience.md: f3c36f8055179870c19c9d1ce99c3533fe602aa6 +2026-07-20-config-hot-reload-resilience.zh.md: 72ef2ebfa582dcc614198ed094c9b58ea1713460 diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.md b/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.md index 1a8e29c603..f3c36f8055 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.md +++ b/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.md @@ -6,33 +6,36 @@ English | [中文](2026-07-20-config-hot-reload-resilience.zh.md) ## Problem -The demo apps mount `@cordisjs/plugin-hmr` as a leaf so a running agent picks up `cordis.yml` edits. One bad edit killed the process: `Include.refresh()` rethrew the YAML parse error, the HMR watcher awaits `refresh()` inside an async chokidar callback nobody catches, and the resulting unhandled rejection tripped `dsh-app-boot`'s fail-loud handler — `exit(1)` mid-session, losing the live TUI. Two adjacent defects made even *valid* reloads wrong: a file that parses to `undefined` (empty or mid-write truncated — editors and `sed -i` routinely produce these states) crashed the entry walk instead of reading as invalid, and a re-read never re-applied the include's `config.patches`, so any hot-reload of an overlay-based tree (Code Mode, personal overlays) silently reverted patched entries and removed inserted ones. +An invalid `cordis.yml` edit must not kill a running agent, but preserving the process is insufficient when a valid-looking update partially replaces the Loader tree before a later entry fails. Callers also need to observe a rejected live update without treating the same error as an unhandled boot failure. Personal configuration adds a second requirement: HMR must observe one exact file outside its module roots, including a file or parent directory created after startup. ## Decision -Harden the vendored `@cordisjs/plugin-include` (logged as local modification 8 in [vendor/README.md](../../../../vendor/README.md)) rather than the callers: +The vendored Cordis lifecycle and Loader plugins provide an awaited, compensating config transaction, logged as local modifications 6, 8, and 9 in [vendor/README.md](../../../../vendor/README.md). -- `refresh()` awaits the whole read-and-update and catches failures, logs a warning, and keeps the last good entry tree. A hot-reload is advisory; the invariant is that no file state reachable by an editor may take the process down. -- `read()` rejects a non-array parse result with a `TypeError`, folding the `undefined`-parse case into the same "invalid file" signal, and commits `content`/`data` only after a successful parse — so reverting an edit to the exact last good content correctly reads as "unchanged". -- `refresh()` and the `internal/update` listener apply `this.applyPatches(...)` before `root.update()`, restoring parity with `[Service.init]`. `applyPatches` deep-copies the cached parse (`structuredClone`) instead of mutating it, so repeated application converges and removing a patch reverts to the file's own values. The listener uses the incoming config's `patches` and persists that config itself: it vetoes the fiber restart (children update in place), and `Fiber.update` only assigns `this.config` behind `next()`, so without the explicit assignment the next re-read would re-apply the old overlay. +`Fiber.update()` returns its `internal/update` waterfall result. Config validation remains synchronous, while the default continuation returns the restart promise. Loader entry updates can therefore distinguish validation, import, application, and rollback failure from successful lifecycle settlement. `EntryTree.await()` rechecks service-gated fibers after Loader tasks drain and rejects settled failures; a fiber waiting on an absent service remains a valid pending entry rather than making settlement hang. -Boot-time behavior stays fail-loud and gets a sharper diagnostic: `[Service.init]` falls back to `initial` (or "config file not found") only on `ENOENT`; an existing-but-invalid file now fails with its real parse error instead of being mislabelled as absent or silently overwritten by `initial`. +Loader imports a changed module name before disposing the active fiber. Candidate application is awaited; a failure disposes candidate effects and restores the prior plugin or config. Group reconciliation is sequential and restores earlier changed entries, additions, removals, and moves before rejecting. Persistence occurs only after successful programmatic mutation. This is a compensating transaction: lifecycle effects may be briefly visible, and a failed rollback is reported as an `AggregateError` rather than misrepresented as a retained tree. + +Include reads and validates detached candidate content, applies patches to a clone, reconciles the Loader tree, and only then commits cached content and parsed data. `refresh()` rejects to its caller after a parse, validation, application, or rollback failure. Initial load remains fail-loud; only an absent file may use `initial`. A non-array YAML/JSON result is invalid, and both file refresh and Include-config update re-apply patches without mutating the cached parse. + +HMR contains live refresh rejection. Its `registerConfig(filename, refresh)` method watches one exact path from the nearest existing ancestor, serializes and coalesces refreshes, and returns an async disposer that closes the watcher and drains active work. Both exact-path and ordinary config-file refreshes use that queue. A failure is normalized to `Error`, logged, and broadcast through the parallel `hmr/config-update-failed(filename, error)` event; rejecting observers are logged without stopping later refreshes. Creation, change, and removal are observed. ## Alternatives considered -**Catch in the HMR watcher callback instead of `refresh()`.** Rejected: it would leave `refresh()` a trap for every other caller (the `internal/update` path shares the same tree-update logic), and it cannot fix the `undefined`-parse or patch-loss defects, which live inside the include. +**Contain failures inside `Include.refresh()`.** Rejected because it prevents an HMR host from broadcasting the failure and still permits Loader reconciliation to hide partial application. Include owns candidate parsing and commit; HMR owns containment and observation. -**Filter config-file rejections in `installFailLoud`.** Rejected: the fail-loud handler exists to make late load failures visible; teaching it to classify exceptions by origin would silently swallow genuine boot failures and leave the stale-`data` crash in place. +**Restart the process for every config edit.** Rejected because Cordis effects already provide reversible plugin lifecycle, and a syntax error or failed optional plugin must not discard live sessions merely to recover the prior composition. -**A PTY e2e proving the TUI survives a bad edit.** Rejected as the primary gate: the PTY smoke reads the repo's committed `cordis.yml`, so corrupting it in-place is not test-safe, and a temp copy cannot resolve the tree's bare package specifiers. The unit spec drives the exact `refresh()` entry point the watcher calls; the fix was additionally verified manually against the live TUI (bad YAML, empty file, restored file). +**Promise invisible atomic replacement.** Rejected because arbitrary plugin effects cannot be snapshotted. Sequential application plus explicit compensation provides a stable final result without claiming that observers cannot see intermediate lifecycle transitions. ## Consequences -- A bad `cordis.yml` edit now logs `ignoring config reload at ` and the agent keeps running on the last good tree; the next valid edit applies normally. With no logger exporter mounted in the TUI demos the warning is currently invisible on screen — surfacing loader warnings in the TUI is deferred. -- Overlay trees survive base-file reloads with patches intact instead of silently reverting to the unpatched base. -- The vendored include diverges further from upstream; the divergence is logged in the vendor manifest and re-applies on the next sync. -- Known gap, out of scope here: the HMR watcher only handles chokidar `change` events, so editors that replace the file by rename (BSD `sed -i`, `git checkout`) do not trigger a config reload at all; and a reloaded app-entry config does not visibly restart the running TUI (pre-existing on the unmodified tree). +- A failed live refresh rejects internally, retains or restores the last-good tree when compensation succeeds, and broadcasts one typed failure without becoming an unhandled rejection. +- A rollback failure is visible and may leave an entry unavailable; the event and log do not claim otherwise. +- Fibers waiting on declared dependencies remain valid pending entries: lifecycle settlement means no current work failed, not that every dependency exists. +- Exact config watchers add filesystem resources only for registered paths and release them with their owning HMR fiber. +- The vendored Loader, Include, HMR, and core event typing diverge further from upstream; the complete divergence is maintained in the vendor manifest. ## Testing -`packages/ui/app-boot/tests/config-reload.spec.ts` boots real Loader trees against temp configs and pins: an invalid-YAML edit and an empty-file edit both resolve `refresh()` without rejection and keep the previous entry config; a subsequent valid edit applies; an overlay tree re-applies both entry patches and inserted entries on re-read; a hot-update of the include entry's own `patches` applies immediately, survives the next file re-read, and reverts cleanly when the patches are removed. The assertions fail on the unpatched vendored include. +`packages/ui/app-boot/tests/config-reload.spec.ts` boots real temporary Loader/Include trees and covers parse and shape rejection, import-before-dispose, plugin/config restoration, multi-entry rollback, ancestor disablement, overlay convergence, option identity, failed direct-update persistence, and failed programmatic moves. `packages/ui/app-boot/tests/hmr-config.spec.ts` covers existing and missing exact paths, add/change/removal, serialized coalescing, disposal drainage, non-`Error` normalization, failure broadcast, and rejecting-observer containment. `packages/host/webserver/tests/webserver.spec.ts` proves a service-gated startup failure rejects Loader composition with its bind diagnostic, and `packages/typert/loader/tests/loader.spec.ts` exercises awaited programmatic removal through a real Loader consumer. diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.zh.md b/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.zh.md index 6c7a421bfa..72ef2ebfa5 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.zh.md @@ -6,33 +6,36 @@ Status: implemented ## Problem -各示例应用把 `@cordisjs/plugin-hmr` 挂载为叶子配置项,让运行中的 agent 能感知 `cordis.yml` 的编辑。一次错误的编辑就会杀死进程:`Include.refresh()` 把 YAML 解析错误原样抛出,HMR 的文件监听器在一个无人捕获的异步 chokidar 回调里 await `refresh()`,产生的未处理 rejection 触发 `dsh-app-boot` 的快速失败处理器——会话中途 `exit(1)`,正在运行的 TUI 就此丢失。另有两个相邻缺陷让*合法*的重载也出错:解析结果为 `undefined` 的文件(空文件或写入中途被截断的文件——编辑器和 `sed -i` 常态性地产生这类中间状态)会让配置项遍历直接崩溃,而不是被判定为无效文件;并且重新读取时从不重新应用 include 的 `config.patches`,因此对基于 overlay 的配置树(Code Mode、个人 overlay)做任何热重载,都会悄悄把打过补丁的配置项回退、并把插入的配置项移除。 +无效的 `cordis.yml` 编辑不得杀死运行中的 agent(智能体);但若一次看似有效的更新先部分替换 Loader 树,后续配置项才失败,仅仅保住进程仍不够。调用方还需要能观察到被拒绝的实时更新,同时不能让同一个错误被当作未处理的启动失败。个人配置还带来第二项要求:HMR(热模块替换)必须观察其模块根目录之外的一个确切文件,包括启动后才创建的文件或父目录。 ## Decision -加固 vendor 的 `@cordisjs/plugin-include`(在 [vendor/README.md](../../../../vendor/README.md) 中记录为本地修改第 8 条),而不是修改调用方: +vendor 中的 Cordis 生命周期和 Loader 插件提供可等待、带补偿的配置事务,并在 [vendor/README.md](../../../../vendor/README.md) 中记录为本地修改第 6、8、9 条。 -- `refresh()` await 整个「读取并更新」过程并捕获失败,记录一条警告,并保留上一份完好的配置树。热重载是尽力而为的;不变式是编辑器可能产生的任何文件状态都不得导致进程退出。 -- `read()` 对非数组的解析结果抛出 `TypeError`,把 `undefined` 解析结果并入同一个「无效文件」信号,并且只在解析成功后才提交 `content`/`data`——因此把编辑撤销回与上一份完好内容完全一致时,会正确地判定为「无变化」。 -- `refresh()` 与 `internal/update` 监听器在 `root.update()` 之前调用 `this.applyPatches(...)`,与 `[Service.init]` 保持一致。`applyPatches` 对缓存的解析结果做深拷贝(`structuredClone`)而不是就地修改,因此重复应用会收敛,移除补丁会回退到文件自身的值。监听器使用传入配置中的 `patches` 并自行持久化该配置:它否决 fiber 重启(子配置项就地更新),而 `Fiber.update` 只在 `next()` 之后才赋值 `this.config`,若不显式赋值,下一次重新读取会重新应用旧的 overlay。 +`Fiber.update()` 返回其 `internal/update` waterfall(瀑布式事件)的结果。配置校验保持同步,而默认 continuation 返回重启 promise。因此,Loader 配置项更新可以区分校验、导入、应用和回滚失败,以及生命周期成功完成。`EntryTree.await()` 会在 Loader 任务排空后重新检查受服务门控的 fiber,并在 fiber 已结算为失败时 reject;等待缺失服务的 fiber 仍是有效的 pending 配置项,不会让结算挂起。 -启动期行为保持快速失败并获得更准确的诊断:`[Service.init]` 只在 `ENOENT` 时回退到 `initial`(或「config file not found」);存在但无效的文件现在会以真实的解析错误失败,而不是被误标为文件缺失、或被 `initial` 静默覆盖。 +Loader 会先导入变化后的模块名,再 dispose(资源释放)活动 fiber。它会 await 候选项的应用;若失败,则 dispose 候选项的 effect,并恢复先前的插件或配置。组内对账按顺序进行,并会在拒绝前恢复此前已变更的配置项、添加项、移除项和移动项。只有程序化变更成功后才会持久化。这是一种补偿事务:生命周期 effect 可能短暂可见;回滚失败会报告为 `AggregateError`,而不会被误称为树已保留。 + +Include 读取并校验尚未提交的候选内容,把补丁应用到其副本,对账 Loader 树,然后才提交缓存内容和解析数据。解析、校验、应用或回滚失败后,`refresh()` 会向调用方 reject。初始加载继续快速失败;只有文件不存在时才可以使用 `initial`。YAML/JSON 结果若不是数组即为无效;文件刷新和 Include 配置更新都会重新应用补丁,且不修改缓存的解析结果。 + +HMR 收容实时刷新 rejection。其 `registerConfig(filename, refresh)` 方法从最近的现有祖先目录开始监听一个确切路径,串行化并合并刷新,并返回一个异步 disposer;该 disposer 会关闭 watcher 并排空活跃工作。确切路径和普通配置文件的刷新都使用此队列。失败会被规范化为 `Error`、记入日志,并通过并行事件 `hmr/config-update-failed(filename, error)` 广播;发生 rejection 的观察者会被记录,但不会阻止后续刷新。创建、变更和移除均会被观察。 ## Alternatives considered -**在 HMR 监听回调里捕获,而不是在 `refresh()` 里。** 否决:这会让 `refresh()` 继续成为其他所有调用方的陷阱(`internal/update` 路径共享同一套树更新逻辑),而且无法修复 `undefined` 解析结果与补丁丢失这两个位于 include 内部的缺陷。 +**在 `Include.refresh()` 内收容失败。** 已否决,因为这会使 HMR 宿主无法广播失败,却仍允许 Loader 对账掩盖部分应用。Include 负责候选内容的解析与提交;HMR 负责收容和观察。 -**在 `installFailLoud` 里过滤配置文件相关的 rejection。** 否决:快速失败处理器的存在意义就是让延迟出现的加载失败可见;教它按来源给异常分类会悄悄吞掉真正的启动失败,并且原样保留陈旧 `data` 导致的崩溃。 +**每次编辑配置都重启进程。** 已否决,因为 Cordis effect 已经提供可逆的插件生命周期,而语法错误或可选插件失败不应只为恢复先前的组合就丢弃正在进行的会话。 -**用 PTY e2e 证明 TUI 能在错误编辑后存活。** 否决其作为主要门禁:PTY 冒烟测试读取仓库中已提交的 `cordis.yml`,就地破坏它对测试不安全,而临时副本无法解析该配置树的裸包说明符。单元测试直接驱动监听器所调用的 `refresh()` 入口;此外还对运行中的 TUI 做了人工验证(错误 YAML、空文件、恢复文件)。 +**承诺不可见的原子替换。** 已否决,因为任意插件 effect 无法制作快照。按顺序应用并显式补偿可以得到稳定的最终结果,同时不会声称观察者看不到中间生命周期转换。 ## Consequences -- 现在错误的 `cordis.yml` 编辑会记录 `ignoring config reload at `,agent 继续运行在上一份完好的配置树上;下一次合法编辑正常生效。TUI 示例没有挂载任何日志导出器,这条警告目前不会显示在屏幕上——在 TUI 中呈现 loader 警告的工作暂缓。 -- overlay 配置树在基础文件重载后补丁保持完整,不再悄悄回退到未打补丁的基础配置。 -- vendor 的 include 与上游进一步分叉;该分叉已记录在 vendor 的 manifest 里,下次同步时重新应用。 -- 已知缺口,不在本次范围内:HMR 监听器只处理 chokidar 的 `change` 事件,因此通过重命名替换文件的编辑方式(BSD `sed -i`、`git checkout`)完全不会触发配置重载;应用配置项重载后也不会可见地重启运行中的 TUI(未修改的代码树上即已如此)。 +- 实时刷新失败会在内部 reject;补偿成功时会保留或恢复上一份完好的树,并广播一次类型化失败,而不会成为未处理的 rejection。 +- 回滚失败可见,并可能使一个配置项不可用;事件和日志不会误称其已恢复。 +- 等待已声明依赖的 fiber 仍是有效的 pending 配置项:生命周期完成只表示当前工作均未失败,而不表示每项依赖都存在。 +- 确切配置 watcher 只为已注册路径增加文件系统资源,并随其所属 HMR fiber 一起释放。 +- vendor 中的 Loader、Include、HMR 与核心事件类型定义进一步偏离上游;全部分叉均维护在 vendor manifest(元数据清单)中。 ## Testing -`packages/ui/app-boot/tests/config-reload.spec.ts` 用真实 Loader 树加载临时配置并固定以下行为:无效 YAML 编辑和空文件编辑都让 `refresh()` 正常 resolve 而不产生 rejection,并保留之前的配置项配置;随后的合法编辑正常生效;overlay 配置树在重新读取时重新应用配置项补丁和插入的配置项;对 include 配置项自身 `patches` 的热更新立即生效、在下一次文件重读后依然保持、并在补丁移除后干净地回退。这些断言在未打补丁的 vendor include 上会失败。 +`packages/ui/app-boot/tests/config-reload.spec.ts` 启动真实的临时 Loader/Include 树,并覆盖对解析和形状错误的拒绝、先导入再 dispose、插件/配置恢复、多配置项回滚、祖先禁用、overlay 收敛、option 对象身份、失败的直接更新不持久化以及失败的程序化移动。`packages/ui/app-boot/tests/hmr-config.spec.ts` 覆盖现有和缺失的确切路径、添加/变更/移除、串行化合并、dispose 排空、非 `Error` 值的规范化、失败广播以及对发生 rejection 的观察者的收容。`packages/host/webserver/tests/webserver.spec.ts` 证明受服务门控的启动失败会让 Loader 组合以其 bind 诊断 reject;`packages/typert/loader/tests/loader.spec.ts` 则通过真实 Loader 消费方演练可等待的程序化移除。 diff --git a/docs/cordis-catalog/core/fiber.md b/docs/cordis-catalog/core/fiber.md index 3cca4e8b86..35a991f789 100644 --- a/docs/cordis-catalog/core/fiber.md +++ b/docs/cordis-catalog/core/fiber.md @@ -256,8 +256,8 @@ Dispose and immediately reload this plugin with its current config. * * @param config — the new raw config; validated before anything restarts. * @param noSave — hint for persistence hooks not to write the change back. - * @returns nothing; the restart runs behind the `internal/update` waterfall. - * @throws {ValidationError} when the new config fails validation. + * @returns the update waterfall result; the default restart returns a promise. + * @throws when validation, an update listener, or the restarted plugin fails. */ update(config: any, noSave = false) ``` @@ -269,7 +269,7 @@ Runs the `internal/update` waterfall first, so update hooks (and HMR) can veto o - `config` — the new raw config; validated before anything restarts. - `noSave` — hint for persistence hooks not to write the change back. -**Returns** nothing; the restart runs behind the `internal/update` waterfall. +**Returns** the update waterfall result; the default restart returns a promise. [Source](../../../vendor/cordis/src/fiber.ts#L734) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index a7ff21e2f4..1f9386fe39 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -1183,7 +1183,8 @@ The framework events every plugin also sees, beyond the harness vocabulary above - `internal/listener` — A listener was registered. ([`vendor/cordis/src/events.ts:340`](../../vendor/cordis/src/events.ts)) - `internal/dispatch` — An event is being dispatched to listeners. ([`vendor/cordis/src/events.ts:342`](../../vendor/cordis/src/events.ts)) - `hmr/change` — A watched source file changed on disk. ([`vendor/hmr/src/index.ts:20`](../../vendor/hmr/src/index.ts)) -- `hmr/reload` — Plugins are being reloaded after a change. ([`vendor/hmr/src/index.ts:21`](../../vendor/hmr/src/index.ts)) +- `hmr/reload` — Plugins are being reloaded after a change. ([`vendor/hmr/src/index.ts:22`](../../vendor/hmr/src/index.ts)) +- `hmr/config-update-failed` — A watched config-file refresh failed. ([`vendor/hmr/src/index.ts:29`](../../vendor/hmr/src/index.ts)) - `exit` — The process is exiting on a signal. ([`vendor/loader/src/index.ts:23`](../../vendor/loader/src/index.ts)) - `loader/config-update` — The loader config tree changed. ([`vendor/loader/src/index.ts:24`](../../vendor/loader/src/index.ts)) - `loader/entry-init` — A config entry is being initialized. ([`vendor/loader/src/index.ts:25`](../../vendor/loader/src/index.ts)) diff --git a/examples/headless-agent/tests/snapshots/startup-activation-error/stderr.expected.txt b/examples/headless-agent/tests/snapshots/startup-activation-error/stderr.expected.txt index 5896d03464..cd688cd471 100644 --- a/examples/headless-agent/tests/snapshots/startup-activation-error/stderr.expected.txt +++ b/examples/headless-agent/tests/snapshots/startup-activation-error/stderr.expected.txt @@ -1,3 +1,3 @@ -dsh-cli-demo: dsh-cli-demo: 1 entry did not activate -./activation-error.mjs: Error: startup activation snapshot failure +dsh-cli-demo: dsh-cli-demo: plugin tree failed to load: failed to apply loader entry include (cordis:include): failed to apply loader entry activation-error (./activation-error.mjs): startup activation snapshot failure +Error: startup activation snapshot failure at activation-error-fixture diff --git a/packages/host/directory-picker-auto/src/index.ts b/packages/host/directory-picker-auto/src/index.ts index 5766e36b98..3cf75b20dc 100644 --- a/packages/host/directory-picker-auto/src/index.ts +++ b/packages/host/directory-picker-auto/src/index.ts @@ -61,11 +61,9 @@ export async function apply(ctx: Context): Promise { // nothing is left to unmount or await then. const entry = ctx.loader.store[id] if (entry === undefined) return - const fiber = entry.fiber - ctx.loader.remove(id) - // remove() only starts the fiber's dispose; join it so the chooser's - // unload signals completion only after the backend quiesced. - await fiber?.dispose() + // remove() disposes the entry transactionally, so the chooser's unload + // signals completion only after the backend quiesced. + await ctx.loader.remove(id) } }, 'directory-picker-auto: backend entry') } diff --git a/packages/host/directory-picker-auto/tests/loader-composition.spec.ts b/packages/host/directory-picker-auto/tests/loader-composition.spec.ts index 59ca1c1992..9d0b8c7de8 100644 --- a/packages/host/directory-picker-auto/tests/loader-composition.spec.ts +++ b/packages/host/directory-picker-auto/tests/loader-composition.spec.ts @@ -167,7 +167,7 @@ describe('real Loader composition', () => { const { ctx, configPath } = await loadComposition('127.0.0.1') const backendEntry = [...ctx.loader.entries()].find(entry => entry.options.name === NATIVE)! - ctx.loader.remove(backendEntry.id) + await ctx.loader.remove(backendEntry.id) const autoEntry = [...ctx.loader.entries()].find(entry => entry.options.name === AUTO)! await expect(autoEntry.fiber!.dispose()).resolves.not.toThrow() expect(entryNames(ctx)).not.toContain(NATIVE) diff --git a/packages/host/webserver/README.i18n.yaml b/packages/host/webserver/README.i18n.yaml index 0160db9f01..a79958e9d2 100644 --- a/packages/host/webserver/README.i18n.yaml +++ b/packages/host/webserver/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/host/webserver/README.md -README.md: ace8c09e43dd8544a28d300f97b04610be78bc69 -README.zh.md: b9948e3d387a5da393ff62b9eeacfe310516f46a +README.md: c3c7b222683bc7731a6c21f2fffd325225099bab +README.zh.md: 99c0560eb74dc8076772ba1deef3034000f5f0db diff --git a/packages/host/webserver/README.md b/packages/host/webserver/README.md index ace8c09e43..c3c7b22268 100644 --- a/packages/host/webserver/README.md +++ b/packages/host/webserver/README.md @@ -6,7 +6,7 @@ Plain HTTP route-registration plugin (default-exported `HttpServerService`, conf The package knows no harness concepts: the `/api` bridge is the connection plugin's route, plugin bundles and the HMR event stream are the modules/hmr plugins' routes. `host` accepts only `127.0.0.1` (default posture) and `0.0.0.0` (deliberate network exposure); `distIndex` is an assembly fact the composing app resolves and injects, never self-resolved (dist location is workspace knowledge of the app). Web (browser) shape only — Electron loads dist over `file://` and carries fetch over an IPC bridge, not this server. This package never prints; the URL line belongs to the shell. -A listen failure (EADDRINUSE…) throws out of activation — a FAILED fiber the boot's fail-loud sweep reports. A request whose handling throws (a malformed %-escape hitting `decodeURIComponent`, a client dropping mid-body) is answered 400 — or the socket destroyed when headers are already out — and logged as a warning; it never exits the process. Disposal pairs `close()` with `closeAllConnections()` because held-open responses (SSE) never end on their own. +A listen failure (EADDRINUSE…) throws out of activation and rejects Loader composition with the bind diagnostic; the failed candidate fiber is disposed. A request whose handling throws (a malformed %-escape hitting `decodeURIComponent`, a client dropping mid-body) is answered 400 — or the socket destroyed when headers are already out — and logged as a warning; it never exits the process. Disposal pairs `close()` with `closeAllConnections()` because held-open responses (SSE) never end on their own. In development, the client-plugin registry synchronously captures each built bundle's stat baseline before it returns, then polls those baselines and re-hashes changed content. Each rescan stages its candidate table, graph, and watch map before publishing them, so a baseline failure preserves the prior graph. An immediate rebuild therefore cannot disappear into an asynchronously established watch baseline; a rename window marks the path dirty, retains the last successful baseline, and forces a re-hash when the bundle reappears even with identical metadata. diff --git a/packages/host/webserver/README.zh.md b/packages/host/webserver/README.zh.md index b9948e3d38..99c0560eb7 100644 --- a/packages/host/webserver/README.zh.md +++ b/packages/host/webserver/README.zh.md @@ -6,7 +6,7 @@ 该包不了解任何 harness 概念:`/api` 桥接是 connection 插件的路由,插件 bundle 与 HMR(热模块替换)事件流则是 modules/hmr 插件的路由。`host` 只接受 `127.0.0.1`(默认姿态)和 `0.0.0.0`(有意向网络开放);`distIndex` 是由组合应用解析并注入的组装事实,绝不会自行解析,因为 dist 位置属于应用的工作区知识。该服务器只服务 Web(浏览器)形态;Electron 通过 `file://` 加载 dist,并经 IPC 桥接承载 fetch,而不使用本服务器。该包从不打印内容;URL 行属于 shell。 -监听失败(EADDRINUSE……)会从激活过程抛出,使 fiber 进入 FAILED 状态并由启动流程的快速失败扫描报告。处理请求时抛错(例如格式错误的百分号转义传入 `decodeURIComponent`,或客户端在请求体传输中途断开)时,服务器会响应 400;若响应头已经发出,则销毁 socket,并记录 warning,但绝不会退出进程。资源释放会把 `close()` 与 `closeAllConnections()` 配对,因为一直保持打开的响应(SSE)不会自行结束。 +监听失败(EADDRINUSE……)会从激活过程抛出,以 bind 诊断使 Loader 组合 reject;失败的候选 fiber 会被 dispose(资源释放)。处理请求时抛错(例如格式错误的百分号转义传入 `decodeURIComponent`,或客户端在请求体传输中途断开)时,服务器会响应 400;若响应头已经发出,则销毁 socket,并记录 warning,但绝不会退出进程。资源释放会把 `close()` 与 `closeAllConnections()` 配对,因为一直保持打开的 SSE(Server-Sent Events)响应不会自行结束。 在开发环境中,客户端插件注册表会在返回前同步捕获每个已构建 bundle 的 stat 基线,随后轮询这些基线,并在内容变化后重新计算哈希。每次重新扫描都会先暂存候选表、图和监听 map,再统一发布,因此基线失败会保留先前的图。这样,即时重建不会消失在异步建立的监听基线中;重命名窗口会把路径标记为脏,保留最近一次成功基线,并在 bundle 重新出现时强制重新计算哈希,即使其元数据完全相同也不例外。 diff --git a/packages/host/webserver/tests/webserver.spec.ts b/packages/host/webserver/tests/webserver.spec.ts index 017fedba1a..c64208eb8e 100644 --- a/packages/host/webserver/tests/webserver.spec.ts +++ b/packages/host/webserver/tests/webserver.spec.ts @@ -11,7 +11,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' import { afterEach, describe, expect, it } from 'vitest' -import { Context, FiberState } from 'cordis' +import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import Include from '@cordisjs/plugin-include' import HttpServer from '../src/index.ts' @@ -142,25 +142,17 @@ describe('real Loader composition', () => { const firstRoot = root root = undefined // keep the first composition's files until the end - // loader.await() never rejects (allSettled); the bind failure surfaces as - // a FAILED fiber whose error escapes as a late rejection — the shape the - // boot's installFailLoud is contracted to catch. Capture it here the same - // way, and assert it really is the bind error. - const rejections: unknown[] = [] - const onUnhandled = (err: unknown): void => { rejections.push(err) } - process.on('unhandledRejection', onUnhandled) let second: Context | undefined try { - second = await loadComposition(takenPort) - const entry = [...second.loader.entries()].find(e => e.options.name === '@deepseek-ai/dsh-host-webserver') - expect(entry?.fiber?.state).toBe(FiberState.FAILED) - // The rejection escapes a tick after loader.await() settles; bounded poll. - for (let i = 0; i < 100 && rejections.length === 0; i++) { - await new Promise(resolve => setTimeout(resolve, 10)) + let failure: unknown + try { + await loadComposition(takenPort) + } catch (error) { + failure = error } - expect(rejections.map(String).join('\n')).toContain('EADDRINUSE') + second = context + expect(String(failure)).toMatch(/failed to apply loader entry.*EADDRINUSE/) } finally { - process.off('unhandledRejection', onUnhandled) await second?.fiber.dispose() context = first if (root !== undefined) await rm(root, { recursive: true, force: true }) diff --git a/packages/typert/loader/tests/loader.spec.ts b/packages/typert/loader/tests/loader.spec.ts index c61db1ae9c..3b126f1e76 100644 --- a/packages/typert/loader/tests/loader.spec.ts +++ b/packages/typert/loader/tests/loader.spec.ts @@ -147,12 +147,12 @@ describe('typert loader', () => { await new Promise(resolve => setTimeout(resolve, 20)) expect(ctx.typert.list()).toHaveLength(1) - ctx.loader.remove(id) + await ctx.loader.remove(id) await ctx.loader.await() // The unmount reconciliation rides a queued microtask flush. await new Promise(resolve => setTimeout(resolve, 20)) expect(ctx.typert.get('@fixture/with-typert#Thing')).toBeUndefined() - ctx.loader.remove(plainId) + await ctx.loader.remove(plainId) await ctx.loader.await() await new Promise(resolve => setTimeout(resolve, 20)) diff --git a/packages/ui/app-boot/README.i18n.yaml b/packages/ui/app-boot/README.i18n.yaml index 0ee7591bec..6d7aa6f0d3 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: efc8c42e192a02ecf37f8ea1356aa22261c70d0e -README.zh.md: 927d6d1fb493c404fcdbe14f1c668b1743412ea5 +README.md: e82d378f9cabd24d0f8b3069237f142c1885191f +README.zh.md: 5d749531e48a502291491e6d047b45cd8a505544 diff --git a/packages/ui/app-boot/README.md b/packages/ui/app-boot/README.md index efc8c42e19..e82d378f9c 100644 --- a/packages/ui/app-boot/README.md +++ b/packages/ui/app-boot/README.md @@ -8,17 +8,17 @@ 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 a post-`boot()` unhandled Loader rejection into one labelled stderr line + `exit(1)`; returns the uninstaller (for tests) | +| `installFailLoud(binName, proc?)` | Turn an unhandled boot or later Loader rejection into one labelled stderr line + `exit(1)`; returns the uninstaller (for tests) | | `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 | | `loadOverlayPatches(binName, file)` | Parse a required patch-list file with the same shape as personal config; read or parse failures throw a labelled error | -| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, expose `dshHomePath(...segments)` to Loader `!!js` config expressions, install Loader, run optional host preparation before config-tree entries mount (`prepare` may use Loader and provide launcher-owned context slots such as [`MAIN_SESSION_ID_KEY`](../tui/README.md)), then mount and await the include tree, assert entries loaded and activated, and return the root context | +| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, expose `dshHomePath(...segments)` to Loader `!!js` config expressions, install Loader, run optional host preparation before config-tree entries mount (`prepare` may use Loader and provide launcher-owned context slots such as [`MAIN_SESSION_ID_KEY`](../tui/README.md)), then mount and await the include tree, assert entries loaded and activated, and return the root context — or dispose the partial context and reject a labelled error | | `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | Compose the base config and labeled overlay layers offline — the include's own parser and patch algorithm (`entryListSchema`/`applyEntryPatches`), so the result equals what `boot()` mounts — and render YAML with `!!js` expressions verbatim; each run of same-provenance rows is preceded by a `# ==` comment naming the contributing file and the layers that patched it, keeping the output one loadable document; a patch matching no row goes to `warn` with its layer label (default: one stderr line), read/parse/shape failures throw | | `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to the DSH implementation checkout while warning it not to infer the current working directory from that path and to use `pwd` instead; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot | | `HARNESS_SOURCE_SECTION` | The `'harness:source'` section name `addHarnessSourceSection` registers under | -Two Loader failure classes require separate guards because tree settlement propagates neither to its caller. A failed plugin import leaves a fiber-less entry that `assertEntriesLoaded` turns into a `boot()` rejection naming every unresolved plugin. A plugin callback or config failure leaves a failed fiber because `loader.await()` settles lifecycle tasks without propagating that error; `assertEntriesActivated` awaits the fiber explicitly and includes its original stack in the startup rejection. Before throwing, the audit marks those exact rejection reasons through one process checkpoint so `installFailLoud` coalesces Loader's duplicate notification while every unrelated unhandled rejection remains fatal. +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. Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve through the Cordis Loader's internal module loader. Repository bins install Loader's optional `node-addon-require-builtin` peer; external callers must supply it or install plugins where plain Node import resolution can find them. Relative specifiers resolve against the config directory without the native helper. The built `dsh-app-boot` artifact embeds the statically mounted Include implementation while leaving Loader external, so the include tree and host bind to one Loader peer. The `dsh` source launcher additionally maps manifest-declared workspace packages to their TypeScript source; its configuration gate requires every TUI/Web bare plugin to appear in the resolver manifest's `dependencies`. The bins' subprocess smokes exercise the internal-loader path, while this package's unit suite drives `boot()` in-process against configs with relative specifiers. diff --git a/packages/ui/app-boot/README.zh.md b/packages/ui/app-boot/README.zh.md index 927d6d1fb4..5d749531e4 100644 --- a/packages/ui/app-boot/README.zh.md +++ b/packages/ui/app-boot/README.zh.md @@ -8,17 +8,17 @@ |---|---| | `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?)` | 将 `boot()` 之后未处理的 Loader rejection 转换为一行带标签的 stderr 消息并执行 `exit(1)`;返回卸载函数(供测试使用) | +| `installFailLoud(binName, proc?)` | 将启动期或后续未处理的 Loader rejection 转换为一行带标签的 stderr 消息并执行 `exit(1)`;返回卸载函数(供测试使用) | | `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`,文件不可读、不可解析或内容不是数组时抛出异常 | | `loadOverlayPatches(binName, file)` | 解析一份必需的 patch 列表文件,其形状与个人配置相同;读取或解析失败时抛出带标签的错误 | -| `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文,向 Loader `!!js` 配置表达式暴露 `dshHomePath(...segments)` 并安装 Loader,在配置树条目挂载前执行可选的宿主准备操作(`prepare` 可以使用 Loader,也可以提供由启动器拥有的上下文插槽,例如 [`MAIN_SESSION_ID_KEY`](../tui/README.md)),再挂载并等待 include 树结算,断言所有条目均已加载并激活,最后返回根上下文 | +| `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文,向 Loader `!!js` 配置表达式暴露 `dshHomePath(...segments)` 并安装 Loader,在配置树条目挂载前执行可选的宿主准备操作(`prepare` 可以使用 Loader,也可以提供由启动器拥有的上下文插槽,例如 [`MAIN_SESSION_ID_KEY`](../tui/README.md)),再挂载并等待 include 树结算,断言所有条目均已加载并激活,最后返回根上下文——失败时 dispose(资源释放)部分构造的上下文,并以带标签的错误 reject | | `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | 离线合成基础配置与带标签的覆盖层——使用 include 自己的解析器和补丁算法(`entryListSchema`/`applyEntryPatches`),因此结果与 `boot()` 挂载的内容一致——并渲染为 YAML,`!!js` 表达式原样保留;每段来源相同的连续行之前都有一条 `# ==` 注释,标明贡献该段的文件以及修补过它的层,输出仍是一份可加载的文档;未匹配到行的补丁连同其层标签交给 `warn`(默认:一行 stderr),读取/解析/形状失败则抛出 | | `addHarnessSourceSection(ctx, sourceRoot)` | 添加全局 `harness:source` 提示词段落(顺序紧随 harness 身份、位于 persona 之前),告知 agent(智能体)DSH 实现代码 checkout 的磁盘路径,同时提醒它不得据此推断当前工作目录,而应使用 `pwd`;如果已启动树没有此项服务,则不执行操作并返回 `undefined`。这里的服务是 `systemPrompt`;该段落注册到它的 fiber,因此开发环境 HMR(热模块替换)重新加载系统提示词后,它会消失直至下次启动 | | `HARNESS_SOURCE_SECTION` | `'harness:source'` 段落名称,供 `addHarnessSourceSection` 注册使用 | -Loader 树结算不会向调用方传播两类故障,因此需要分别保护。插件导入失败会留下没有 fiber 的配置项,`assertEntriesLoaded` 将其转换为 `boot()` rejection,并列出每个未解析插件。插件回调或配置失败则会留下失败的 fiber,因为 `loader.await()` 只结算生命周期任务,不传播该错误;`assertEntriesActivated` 会显式等待该 fiber,并把原始错误堆栈写入启动 rejection。抛出错误前,审计会通过一个进程级检查点标记这些 rejection 的确切原因,从而让 `installFailLoud` 将 Loader 的重复通知合并为一次,而所有无关的未处理 rejection 仍然致命。 +Loader 结算会在导入或生命周期失败时 reject,并携带失败的配置项与阶段;`boot()` 会 dispose 部分构造的上下文,并用 bin 名称包装该失败。结算后遗留的配置项由独立审计处理:`assertEntriesLoaded` 将已启用却没有 fiber 的配置项转换为 rejection 并列出每个未解析插件;`assertEntriesActivated` 会显式等待每个失败的 fiber,把原始错误堆栈写入启动 rejection,并列出每个等待中配置项尚未解析的服务。抛出错误前,审计会通过一个进程级检查点标记这些 rejection 的确切原因,从而让 `installFailLoud` 将 Loader 的重复通知合并为一次,而所有无关的未处理 rejection 仍然致命。 配置中的裸插件 specifier(`@deepseek-ai/dsh-*`、npm 包(package))通过 Cordis Loader 的内部模块 loader 解析。仓库 bin 会安装 Loader 的可选 peer `node-addon-require-builtin`;外部调用方必须提供该组件,或者把插件安装到普通 Node import 解析可以找到的位置。相对 specifier 无需原生 helper,并以配置目录为基准解析。构建后的 `dsh-app-boot` 产物内嵌静态挂载的 Include 实现,但仍将 Loader 保持为外部依赖,因此 include 树与 host 会绑定到同一个 Loader peer。`dsh` 源码启动器还会将 manifest(元数据清单)声明的 workspace 包映射到其 TypeScript 源码;其配置门禁要求每个 TUI/Web 裸插件都出现在解析所用 manifest 的 `dependencies` 中。bin 的子进程冒烟测试覆盖内部 loader 路径,而本包的单元测试套件会在进程内使用相对 specifier 配置驱动 `boot()`。 diff --git a/packages/ui/app-boot/package.json b/packages/ui/app-boot/package.json index ef267e8588..68062cb480 100644 --- a/packages/ui/app-boot/package.json +++ b/packages/ui/app-boot/package.json @@ -38,8 +38,10 @@ "cordis": "^4.0.0-rc.7" }, "devDependencies": { + "@cordisjs/plugin-hmr": "workspace:^", "@cordisjs/plugin-include": "workspace:^", "@cordisjs/plugin-loader": "workspace:^", + "@cordisjs/plugin-timer": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index febd724097..917eb777e8 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -11,7 +11,7 @@ import { readFileSync } from 'node:fs' import { basename, dirname, join, resolve } from 'node:path' import * as yaml from 'js-yaml' import { Context, type FiberState } from 'cordis' -import Loader from '@cordisjs/plugin-loader' +import Loader, { type EntryOptions } from '@cordisjs/plugin-loader' import Include, { applyEntryPatches, entryListSchema, type PatchOptions } from '@cordisjs/plugin-include' import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-paths' // Side-effect type import: resolves `ctx.get('systemPrompt')` to the service. @@ -430,12 +430,13 @@ export async function assertEntriesActivated(ctx: Context, binName: string): Pro * `cordis:include` builtin, loading through the ambient module pipeline * (vite/tsx/plain ESM) while the included tree's own specifiers stay * config-relative. The package build embeds Include while leaving Loader - * external, so the built include tree and host share one Loader peer. A - * missing fiber rejects here; a later init rejection is rethrown with its - * original stack by {@link assertEntriesActivated}; later unhandled - * rejections remain covered by {@link installFailLoud}. Built bins need the - * Loader's native helper for bare plugin specifiers; relative specifiers do - * not. + * external, so the built include tree and host share one Loader peer. Loader + * settlement rejects startup failures, which `boot` wraps after disposing the + * partial context; a missing fiber or never-activating entry is rejected by + * the final audit, {@link assertEntriesActivated}, which rethrows a plugin's + * init rejection with its original stack; later unhandled rejections remain + * covered by {@link installFailLoud}. Built bins need the Loader's native + * helper for bare plugin specifiers; relative specifiers do not. * @param binName - the diagnostic prefix for load-failure errors. * @param absoluteConfigPath - the config to include; must already be absolute * (see {@link resolveConfigPath}). @@ -444,6 +445,7 @@ export async function assertEntriesActivated(ctx: Context, binName: string): Pro * @param prepare - optional host setup run after Loader installation and before any config-tree entry mounts. * @returns the root context once every entry has started, or as soon as a * surface disposed the tree while startup was still in flight. + * @throws a labelled load error after disposing the partial context. */ export async function boot( binName: string, @@ -452,28 +454,49 @@ export async function boot( prepare?: (ctx: Context) => Promise | void, ): Promise { const ctx = new Context() - ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/' - ctx.provide('dshHomePath', dshHomePath) - await ctx.plugin(Loader) - ctx.loader.builtins.include = Include - await prepare?.(ctx) - await ctx.loader.create({ - name: 'cordis:include', - config: { - path: pathToFileURL(absoluteConfigPath).href, - ...patches !== undefined && patches.length > 0 ? { patches } : {}, - }, - }) - await ctx.loader.await() - // A surface can finish and dispose the whole tree while that await is still - // pending: the TUI renders as soon as its own fiber starts, so an `/exit` - // typed before the last entry settles tears the context down under us. The - // Loader service goes with it, and the activation audit describes a live - // tree — reading `ctx.loader` here would throw a TypeError over an app that - // exited exactly as asked. - if (ctx.get('loader') === undefined) return ctx - await assertEntriesActivated(ctx, binName) - return ctx + try { + ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/' + ctx.provide('dshHomePath', dshHomePath) + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + await prepare?.(ctx) + // Pinned id: the bootstrap include is app glue, not a config row, and its + // id appears in Loader failure chains — a random id would make startup + // diagnostics unstable across runs (and snapshot fixtures). + const rootInclude: EntryOptions = { + id: 'include', + name: 'cordis:include', + config: { + path: pathToFileURL(absoluteConfigPath).href, + ...patches !== undefined && patches.length > 0 ? { patches } : {}, + }, + } + await ctx.loader.create(rootInclude) + // A surface can finish and dispose the whole tree while startup is still + // in flight: the TUI renders as soon as its own fiber starts, so an `/exit` + // typed before the last entry settles tears the context down under us. The + // Loader service goes with it, and the activation audit describes a live + // tree — reading `ctx.loader` past this point would throw a TypeError over + // an app that exited exactly as asked. Transactional group updates settle + // lifecycle inside the mount, so the teardown can land before it returns; + // re-check after every await. + await ctx.get('loader')?.await() + if (ctx.get('loader') === undefined) return ctx + await assertEntriesActivated(ctx, binName) + return ctx + } catch (cause) { + await ctx.fiber.dispose() + const detail = cause instanceof Error ? cause.message : String(cause) + // The transactional Loader wraps a failing entry apply in one message per + // tree layer; every layer's message is folded into `detail` above, and the + // deepest cause is the plugin's own thrown error, whose stack names the + // real failure site — append it so the startup diagnostic preserves the + // original activation error instead of only the wrap chain. + let deepest: unknown = cause + while (deepest instanceof Error && deepest.cause !== undefined) deepest = deepest.cause + const stack = deepest instanceof Error && deepest !== cause ? `\n${deepest.stack ?? deepest.message}` : '' + throw new Error(`${binName}: plugin tree failed to load: ${detail}${stack}`, { cause }) + } } /** Prompt-section name for the harness-source location line an app bin adds after boot. */ diff --git a/packages/ui/app-boot/tests/app-boot.spec.ts b/packages/ui/app-boot/tests/app-boot.spec.ts index 4f64365a64..03dd93a365 100644 --- a/packages/ui/app-boot/tests/app-boot.spec.ts +++ b/packages/ui/app-boot/tests/app-boot.spec.ts @@ -325,6 +325,22 @@ describe('boot', () => { } }) + it('disposes partial host setup and labels non-Error preparation failures', async () => { + const dir = tmp() + const failure = 42 + let disposed = false + const task = boot(NAME, join(dir, 'cordis.yml'), undefined, (ctx) => { + ctx.effect(() => () => { disposed = true }) + throw failure + }) + + await expect(task).rejects.toMatchObject({ + message: `${NAME}: plugin tree failed to load: ${failure}`, + cause: failure, + }) + expect(disposed).toBe(true) + }) + it('exposes dshHomePath to Loader config expressions', async () => { const dir = tmp() const dshHome = join(dir, 'home') @@ -375,7 +391,37 @@ describe('boot', () => { it('rejects (never exits 0 half-empty) when a config names a plugin that cannot be imported', async () => { const dir = tmp() writeFileSync(join(dir, 'cordis.yml'), '- id: ghost\n name: ./missing.mjs\n') - await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow(`${NAME}: plugin(s) failed to load: ./missing.mjs`) + await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow( + `${NAME}: plugin tree failed to load: failed to apply loader entry`, + ) + }) + + it('appends the deepest cause with its original stack to the load failure', async () => { + const dir = tmp() + writeFileSync(join(dir, 'failing.mjs'), [ + 'export function apply() {', + " const failure = new Error('pinned activation failure')", + " failure.stack = 'Error: pinned activation failure\\n at failing-fixture'", + ' throw failure', + '}', + '', + ].join('\n')) + writeFileSync(join(dir, 'cordis.yml'), '- id: failing\n name: ./failing.mjs\n') + await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow(new RegExp([ + String.raw`failed to apply loader entry failing \(\./failing\.mjs\): pinned activation failure\n`, + String.raw`Error: pinned activation failure\n {4}at failing-fixture$`, + ].join(''))) + }) + + it('falls back to the deepest cause message when its stack was erased', async () => { + const dir = tmp() + const deepest = new Error('stackless deep failure') + delete (deepest as { stack?: string }).stack + await expect(boot(NAME, join(dir, 'cordis.yml'), undefined, () => { + throw new Error('host preparation failed', { cause: deepest }) + })).rejects.toThrow( + `${NAME}: plugin tree failed to load: host preparation failed\nstackless deep failure`, + ) }) it('reports a pending real Loader fiber and the service unresolved in its own context', async () => { diff --git a/packages/ui/app-boot/tests/config-reload.spec.ts b/packages/ui/app-boot/tests/config-reload.spec.ts index c4f6c48d7f..1a236e906b 100644 --- a/packages/ui/app-boot/tests/config-reload.spec.ts +++ b/packages/ui/app-boot/tests/config-reload.spec.ts @@ -1,12 +1,7 @@ /** - * Config hot-reload resilience of the booted include tree. `dsh-app-boot` - * installs a fail-loud unhandled-rejection handler, so a `refresh()` that - * rethrows a config-file parse error would kill a live app on one bad - * `cordis.yml` edit (the HMR watcher awaits `refresh()` in an async event - * callback nobody else catches). These tests pin the vendored - * `@cordisjs/plugin-include` contract that boot relies on: an invalid file - * keeps the last good tree, and a valid re-read re-applies overlay patches - * exactly like the initial load. + * Transactional config replacement through the booted Include and Loader tree. + * HMR contains rejected refreshes; direct callers receive the error after the + * previous generation has been retained or restored. */ import { mkdtempSync, writeFileSync } from 'node:fs' @@ -15,6 +10,7 @@ import { join } from 'node:path' import { describe, expect, it } from 'vitest' import type { Context } from 'cordis' import type { Include } from '@cordisjs/plugin-include' +import { Group } from '@cordisjs/plugin-loader' import { boot } from '../src/index.ts' const NAME = 'dsh-test-bin' @@ -27,9 +23,10 @@ interface TreeFixture { include: Include } -async function bootTree(configBody: string): Promise { +async function bootTree(configBody: string, files: Record = {}): Promise { const dir = mkdtempSync(join(tmpdir(), 'dsh-config-reload-')) writeFileSync(join(dir, 'noop.mjs'), NOOP_PLUGIN) + for (const [name, content] of Object.entries(files)) writeFileSync(join(dir, name), content) writeFileSync(join(dir, 'cordis.yml'), configBody) const ctx = await boot(NAME, join(dir, 'cordis.yml')) const entry = [...ctx.loader.entries()].find(candidate => candidate.subtree !== undefined) @@ -41,20 +38,41 @@ function entryConfig(ctx: Context, id: string): unknown { return [...ctx.loader.entries()].find(entry => entry.options.id === id)?.options.config } +function entryById(ctx: Context, id: string) { + const entry = [...ctx.loader.entries()].find(entry => entry.options.id === id) + if (!entry) throw new Error(`missing loader entry ${id}`) + return entry +} + +function plugin(name: string, body = ''): string { + return `export default function ${name}(_ctx, config = {}) { ${body} }\n` +} + +async function expectUpdateFailure(task: Promise, stage: string): Promise { + try { + await task + } catch (error) { + expect(error).toBeInstanceOf(Error) + expect((error as Error).message).toContain(`failed to ${stage} loader entry`) + return + } + throw new Error(`expected loader update to fail during ${stage}`) +} + describe('include refresh with an invalid file', () => { - it('keeps the last good tree instead of throwing, then applies the next valid edit', async () => { + it('rejects while keeping the last good tree, then applies the next valid edit', async () => { const { ctx, dir, include } = await bootTree('- id: noop\n name: ./noop.mjs\n config:\n value: 1\n') try { expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 }) writeFileSync(join(dir, 'cordis.yml'), 'invalid: [unclosed\n') - await expect(include.refresh()).resolves.toBeUndefined() + await expect(include.refresh()).rejects.toThrow('failed to parse config file') expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 }) // An empty file parses to `undefined` without a YAML error; it must be // treated exactly like a parse failure, not crash the entry walk. writeFileSync(join(dir, 'cordis.yml'), '') - await expect(include.refresh()).resolves.toBeUndefined() + await expect(include.refresh()).rejects.toThrow('failed to validate config file') expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 }) writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: 2\n') @@ -67,6 +85,200 @@ describe('include refresh with an invalid file', () => { }) }) +describe('loader entry replacement', () => { + it('imports a changed name before replacing the running plugin', async () => { + const { ctx } = await bootTree('- id: target\n name: ./old.mjs\n', { + 'old.mjs': plugin('oldPlugin'), + 'new.mjs': plugin('newPlugin'), + }) + try { + const entry = entryById(ctx, 'target') + await entry.update({ name: './new.mjs' }) + expect(entry.options.name).toBe('./new.mjs') + expect(entry.parent.data.find(options => options.id === 'target')).toBe(entry.options) + expect(entry.fiber?.runtime?.callback.name).toBe('newPlugin') + expect(entry.options.disabled).toBeUndefined() + await entry.fiber?.await() + } finally { + await ctx.fiber.dispose() + } + }) + + it('retains the running plugin when the replacement cannot be imported', async () => { + const { ctx } = await bootTree('- id: target\n name: ./old.mjs\n', { + 'old.mjs': plugin('oldPlugin'), + }) + try { + const entry = entryById(ctx, 'target') + const fiber = entry.fiber + await expectUpdateFailure(entry.update({ name: './missing.mjs' }), 'import') + expect(entry.options.name).toBe('./old.mjs') + expect(entry.fiber === fiber).toBe(true) + await fiber?.await() + } finally { + await ctx.fiber.dispose() + } + }) + + it('restores the previous plugin after replacement application fails', async () => { + const { ctx } = await bootTree('- id: target\n name: ./old.mjs\n', { + 'old.mjs': plugin('oldPlugin'), + 'bad.mjs': plugin('badPlugin', 'throw new Error("candidate apply failed")'), + }) + try { + const entry = entryById(ctx, 'target') + const previous = entry.fiber + await expectUpdateFailure(entry.update({ name: './bad.mjs' }), 'apply') + expect(entry.options.name).toBe('./old.mjs') + expect(entry.fiber === previous).toBe(false) + expect(entry.fiber?.runtime?.callback.name).toBe('oldPlugin') + expect(entry.options.disabled).toBeUndefined() + await entry.fiber?.await() + } finally { + await ctx.fiber.dispose() + } + }) + + it('restores the previous config when an in-place restart fails', async () => { + const { ctx } = await bootTree('- id: target\n name: ./configurable.mjs\n config:\n fail: false\n', { + 'configurable.mjs': plugin('configurablePlugin', 'if (config.fail) throw new Error("candidate config failed")'), + }) + try { + const entry = entryById(ctx, 'target') + const fiber = entry.fiber + await expectUpdateFailure(entry.update({ config: { fail: true } }), 'apply') + expect(entry.options.config).toEqual({ fail: false }) + expect(entry.fiber === fiber).toBe(true) + await fiber?.await() + } finally { + await ctx.fiber.dispose() + } + }) + + it('does not persist a failed direct fiber update', async () => { + const { ctx } = await bootTree('- id: target\n name: ./configurable.mjs\n config:\n fail: false\n', { + 'configurable.mjs': plugin('configurablePlugin', 'if (config.fail) throw new Error("candidate config failed")'), + }) + try { + const entry = entryById(ctx, 'target') + const fiber = entry.fiber + if (!fiber) throw new Error('target entry has no fiber') + await expect(fiber.update({ fail: true })).rejects.toThrow('candidate config failed') + expect(entry.options.config).toEqual({ fail: false }) + expect(entry.parent.data.find(options => options.id === 'target')).toBe(entry.options) + } finally { + await ctx.fiber.dispose() + } + }) +}) + +describe('loader tree replacement', () => { + it('rolls back earlier updates and additions when a later entry fails', async () => { + const { ctx, dir, include } = await bootTree([ + '- id: existing', + ' name: ./configurable.mjs', + ' config:', + ' value: old', + '', + ].join('\n'), { + 'configurable.mjs': plugin('configurablePlugin'), + 'bad.mjs': plugin('badPlugin', 'throw new Error("candidate apply failed")'), + }) + try { + writeFileSync(join(dir, 'cordis.yml'), [ + '- id: existing', + ' name: ./configurable.mjs', + ' config:', + ' value: candidate', + '- id: added', + ' name: ./noop.mjs', + '- id: bad', + ' name: ./bad.mjs', + '', + ].join('\n')) + await expect(include.refresh()).rejects.toThrow('failed to apply loader entry bad') + expect(entryConfig(ctx, 'existing')).toEqual({ value: 'old' }) + expect([...ctx.loader.entries()].some(entry => entry.options.id === 'added')).toBe(false) + expect([...ctx.loader.entries()].some(entry => entry.options.id === 'bad')).toBe(false) + + writeFileSync(join(dir, 'cordis.yml'), [ + '- id: existing', + ' name: ./configurable.mjs', + ' config:', + ' value: committed', + '- id: added', + ' name: ./noop.mjs', + '', + ].join('\n')) + await include.refresh() + expect(entryConfig(ctx, 'existing')).toEqual({ value: 'committed' }) + expect(entryById(ctx, 'added').fiber).toBeDefined() + } finally { + await ctx.fiber.dispose() + } + }) + + it('stops and restores descendants when an ancestor group is disabled and re-enabled', async () => { + const { ctx, dir, include } = await bootTree('- id: noop\n name: ./noop.mjs\n') + ctx.loader.builtins.group = Group + try { + const config = (disabled: boolean) => [ + '- id: parent', + ' name: cordis:group', + ' group: true', + ` disabled: ${disabled}`, + ' config:', + ' - id: child', + ' name: ./noop.mjs', + '', + ].join('\n') + + writeFileSync(join(dir, 'cordis.yml'), config(false)) + await include.refresh() + expect(entryById(ctx, 'child').fiber).toBeDefined() + + writeFileSync(join(dir, 'cordis.yml'), config(true)) + await include.refresh() + expect(entryById(ctx, 'child').fiber).toBeUndefined() + + writeFileSync(join(dir, 'cordis.yml'), config(false)) + await include.refresh() + expect(entryById(ctx, 'child').fiber).toBeDefined() + } finally { + await ctx.fiber.dispose() + } + }) + + it('restores a programmatic entry move when its update fails', async () => { + const { ctx } = await bootTree('- id: noop\n name: ./noop.mjs\n', { + 'movable.mjs': plugin('movablePlugin', 'if (config.fail) throw new Error("candidate config failed")'), + }) + ctx.loader.builtins.group = Group + try { + const groupId = await ctx.loader.create({ name: 'cordis:group', group: true, config: [] }) + const targetId = await ctx.loader.create({ name: './movable.mjs', config: { fail: false } }) + const target = entryById(ctx, targetId) + const source = target.parent + const sourceIndex = source.data.indexOf(target.options) + const destination = entryById(ctx, groupId).subgroup + if (!destination) throw new Error('created loader group has no subgroup') + + await expectUpdateFailure( + ctx.loader.update(targetId, { config: { fail: true } }, groupId), + 'apply', + ) + + expect(target.parent).toBe(source) + expect(Object.getPrototypeOf(target.ctx)).toBe(source.ctx) + expect(source.data.indexOf(target.options)).toBe(sourceIndex) + expect(destination.data).not.toContain(target.options) + expect(target.options.config).toEqual({ fail: false }) + } finally { + await ctx.fiber.dispose() + } + }) +}) + describe('include refresh with overlay patches', () => { it('re-applies entry patches and inserted entries on every re-read (parity with initial load)', async () => { const dir = mkdtempSync(join(tmpdir(), 'dsh-config-reload-overlay-')) diff --git a/packages/ui/app-boot/tests/hmr-config.spec.ts b/packages/ui/app-boot/tests/hmr-config.spec.ts new file mode 100644 index 0000000000..1892a6e73a --- /dev/null +++ b/packages/ui/app-boot/tests/hmr-config.spec.ts @@ -0,0 +1,142 @@ +import { mkdirSync, mkdtempSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { Context } from 'cordis' +import Hmr from '@cordisjs/plugin-hmr' +import Loader from '@cordisjs/plugin-loader' +import Timer from '@cordisjs/plugin-timer' +import { describe, expect, it } from 'vitest' + +async function bootHmr(dir: string): Promise { + const ctx = new Context() + ctx.baseUrl = pathToFileURL(dir).href + '/' + await ctx.plugin(Loader) + await ctx.plugin(Timer) + await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 }) + return ctx +} + +async function eventually(test: () => boolean, message: string): Promise { + const deadline = Date.now() + 10_000 + while (!test()) { + if (Date.now() >= deadline) throw new Error(message) + await new Promise(resolve => setTimeout(resolve, 10)) + } +} + +describe('HMR exact config paths', () => { + it('observes add, change, and unlink outside its module roots', { timeout: 20_000 }, async () => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-')) + const filename = join(dir, 'plugins.yml') + const ctx = await bootHmr(dir) + const observed: string[] = [] + try { + await ctx.hmr.registerConfig(filename, () => { + try { + observed.push(readFileSync(filename, 'utf8')) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + observed.push('missing') + } + }) + + writeFileSync(filename, 'one', { flag: 'wx' }) + await eventually(() => observed.includes('one'), 'HMR did not observe config creation') + writeFileSync(filename, 'two') + await eventually(() => observed.includes('two'), 'HMR did not observe config change') + unlinkSync(filename) + await eventually(() => observed.includes('missing'), 'HMR did not observe config removal') + } finally { + await ctx.fiber.dispose() + } + }) + + it('observes creation when the config parent did not exist at registration', { timeout: 20_000 }, async () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-')) + const dir = join(root, 'later') + const filename = join(dir, 'plugins.yml') + const ctx = await bootHmr(root) + const observed: string[] = [] + try { + await ctx.hmr.registerConfig(filename, () => { + observed.push(readFileSync(filename, 'utf8')) + }) + mkdirSync(dir) + writeFileSync(filename, 'created') + await eventually(() => observed.includes('created'), 'HMR did not observe config creation under a new parent') + } finally { + await ctx.fiber.dispose() + } + }) + + it('serializes refreshes and waits for them during disposal', { timeout: 20_000 }, async () => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-')) + const filename = join(dir, 'plugins.yml') + writeFileSync(filename, 'one') + const ctx = await bootHmr(dir) + const started = Promise.withResolvers() + const release = Promise.withResolvers() + const observed: string[] = [] + let active = 0 + let maxActive = 0 + try { + const dispose = await ctx.hmr.registerConfig(filename, async () => { + active += 1 + maxActive = Math.max(maxActive, active) + observed.push(readFileSync(filename, 'utf8')) + if (observed.length === 1) { + started.resolve(undefined) + await release.promise + } + active -= 1 + }) + await started.promise + writeFileSync(filename, 'two') + // Chokidar coalesces atomic writes for 100 ms by default. Wait beyond + // that window so this edit is queued before registration disposal. + await new Promise(resolve => setTimeout(resolve, 250)) + + let disposed = false + const disposal = dispose().then(() => { disposed = true }) + await Promise.resolve() + expect(disposed).toBe(false) + release.resolve(undefined) + await disposal + expect(maxActive).toBe(1) + expect(observed).toEqual(['one', 'two']) + } finally { + release.resolve(undefined) + await ctx.fiber.dispose() + } + }) + + it('normalizes refresh failures and broadcasts them without escaping the watcher', { timeout: 20_000 }, async () => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-')) + const filename = join(dir, 'plugins.yml') + const ctx = await bootHmr(dir) + const failure = Promise.withResolvers<{ filename: string; error: Error }>() + let failureCount = 0 + try { + ctx.on('hmr/config-update-failed', () => { + throw new Error('observer failed') + }) + ctx.on('hmr/config-update-failed', (failedFilename, error) => { + failureCount += 1 + failure.resolve({ filename: failedFilename, error }) + }) + await ctx.hmr.registerConfig(filename, () => { throw 42 }) + writeFileSync(filename, 'invalid') + + const observed = await failure.promise + expect(observed.filename).toBe(filename) + expect(observed.error).toBeInstanceOf(Error) + expect(observed.error.message).toBe('42') + + writeFileSync(filename, 'invalid again') + await eventually(() => failureCount === 2, 'HMR stopped broadcasting after an observer rejected') + } finally { + await ctx.fiber.dispose() + } + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 34eb7b8f87..dff92c6511 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5484,12 +5484,18 @@ importers: specifier: ^4.2.0 version: 4.2.0 devDependencies: + '@cordisjs/plugin-hmr': + specifier: workspace:^ + version: link:../../../vendor/hmr '@cordisjs/plugin-include': specifier: workspace:^ version: link:../../../vendor/include '@cordisjs/plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader + '@cordisjs/plugin-timer': + specifier: workspace:^ + version: link:../../../vendor/timer '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index dc5c392549..97685a9086 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -303,7 +303,8 @@ export const CORDIS_CATALOG_POLICY: CordisCatalogPolicy = { { name: 'internal/listener', summary: 'A listener was registered.', source: 'vendor/cordis/src/events.ts:340' }, { name: 'internal/dispatch', summary: 'An event is being dispatched to listeners.', source: 'vendor/cordis/src/events.ts:342' }, { name: 'hmr/change', summary: 'A watched source file changed on disk.', source: 'vendor/hmr/src/index.ts:20' }, - { name: 'hmr/reload', summary: 'Plugins are being reloaded after a change.', source: 'vendor/hmr/src/index.ts:21' }, + { name: 'hmr/reload', summary: 'Plugins are being reloaded after a change.', source: 'vendor/hmr/src/index.ts:22' }, + { name: 'hmr/config-update-failed', summary: 'A watched config-file refresh failed.', source: 'vendor/hmr/src/index.ts:29' }, { name: 'exit', summary: 'The process is exiting on a signal.', source: 'vendor/loader/src/index.ts:23' }, { name: 'loader/config-update', summary: 'The loader config tree changed.', source: 'vendor/loader/src/index.ts:24' }, { name: 'loader/entry-init', summary: 'A config entry is being initialized.', source: 'vendor/loader/src/index.ts:25' }, diff --git a/vendor/README.md b/vendor/README.md index 2d3e1b6b05..1ad2b94e41 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -35,11 +35,12 @@ Keep this log exhaustive — every divergence from upstream must be listed. 3. **All `tsconfig.json` files**: regenerated to extend the repo-root `tsconfig.base.json`, emit TypeScript intermediates to `lib/types`, and declare project references. 4. **Vendored TypeScript source internal specifiers**: changed local relative imports/exports from upstream's specifier shape to explicit `.ts` specifiers so TypeScript rewrites emitted JS to `.js` while declarations keep explicit, NodeNext-safe `.ts` specifiers. This includes `loader/src/config/isolate.ts` using `declare module './entry.ts'`. 5. **`schemastery/tsdown.config.ts` and `logger-console/tsdown.config.ts`**: ours, not upstream files — per-package build-shape overrides (dual ESM+CJS output; separate node/browser entries) for the repo-root tsdown build. They read the JS emitted under `lib/types` and then write the publish runtime entries under `lib/`. Like the regenerated tsconfigs, they are not part of the upstream sync surface. -6. **`cordis/src/fiber.ts` lifecycle hardening**: locally closes three reentrant disposal gaps. An effect's owner-list wrapper is registered before its setup body runs, so an unload begun from inside setup awaits setup and every collected cleanup; synchronous setup failure removes the wrapper and rolls back collected cleanup. Async cleanup stays owner-visible until quiescence, and Cordis's internal effect composition joins an already-running cleanup while repeated public disposer calls retain their upstream single-shot result. Effect creation is rejected while the owner is `UNLOADING` (while `PENDING` and `LOADING` remain legal), preventing cleanup-time registrations from escaping the unload snapshot. Child fibers register and receive their parent-owned disposer before `internal/plugin` publication, resolve dependency declarations added by that notification before activation, drain effects attached while pending, skip plugin execution when reentrant disposal invalidates the load epoch before its first checkpoint, and contain teardown-notification failures per observer so one callback cannot starve peers or interrupt ownership cleanup. +6. **`cordis/src/fiber.ts` lifecycle hardening**: locally closes three reentrant disposal gaps. An effect's owner-list wrapper is registered before its setup body runs, so an unload begun from inside setup awaits setup and every collected cleanup; synchronous setup failure removes the wrapper and rolls back collected cleanup. Async cleanup stays owner-visible until quiescence, and Cordis's internal effect composition joins an already-running cleanup while repeated public disposer calls retain their upstream single-shot result. Effect creation is rejected while the owner is `UNLOADING` (while `PENDING` and `LOADING` remain legal), preventing cleanup-time registrations from escaping the unload snapshot. Child fibers register and receive their parent-owned disposer before `internal/plugin` publication, resolve dependency declarations added by that notification before activation, drain effects attached while pending, skip plugin execution when reentrant disposal invalidates the load epoch before its first checkpoint, and contain teardown-notification failures per observer so one callback cannot starve peers or interrupt ownership cleanup. `Fiber.update()` returns its `internal/update` waterfall result, allowing Loader callers to await a restart while preserving synchronous config validation. 7. **`cordis/src/*.ts` JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context` (class, statics, and the `Context` interface properties incl. `root`), `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork. -8. **`include/src/index.ts` hot-reload hardening**: `refresh()` awaits the full read-and-update and catches failures (logging a warning and keeping the last good entry tree) instead of rethrowing — upstream's throw escaped `@cordisjs/plugin-hmr`'s async watcher callback as an unhandled rejection, so one bad `cordis.yml` edit killed a live app. `read()` rejects a non-array parse result (an empty or mid-write truncated file parses to `undefined`, which upstream later crashed on) and commits `content`/`data` only on success, so reverting an edit to the exact last good content reads as "unchanged". `refresh()` and the `internal/update` listener re-apply `config.patches` before `root.update()`, matching initial load; upstream applied patches only in `[Service.init]`, so any config hot-reload silently reverted overlay-patched entries and removed inserted ones. `applyPatches` deep-copies via `structuredClone` instead of mutating the cached parse (repeated application converges; removing a patch reverts), and the veto-style `internal/update` listener persists the incoming config itself (`Fiber.update` only assigns behind `next()`), so later re-reads use the new patches. `[Service.init]` falls back to `initial` only on `ENOENT`; an existing-but-invalid file fails loud with its real parse error instead of "config file not found" (or a silent overwrite). `applyPatches` 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`. -9. **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. -10. **`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. +8. **Transactional Loader/Include config reconciliation**: Loader imports a changed entry name before disposal, awaits lifecycle settlement, and restores the previous plugin or config when candidate application fails. Loader settlement rechecks service-gated fibers after current tasks drain, rejects failures, and leaves fibers with absent dependencies pending. Group updates run sequentially, undo earlier changes and additions on failure, await removal, preserve programmatic option identity, and persist direct or tree-level mutations only after success. Include reads and validates detached candidate content, applies patches to a clone, reconciles the tree, and only then commits its cached content/data; direct refresh failures propagate for the caller to contain. A non-array parse is invalid, patches re-apply on every file or Include-config update, and initial content falls back to `initial` only on `ENOENT`. Covered by `packages/ui/app-boot/tests/config-reload.spec.ts` and `packages/host/webserver/tests/webserver.spec.ts`. +9. **`hmr/src/index.ts` exact config watching**: `registerConfig()` watches one absolute config path outside module roots, including a path under missing parents, serializes and coalesces refreshes, and returns an async disposer that closes the watcher and drains active work. Refresh failures are normalized to `Error`, logged, and broadcast through the parallel `hmr/config-update-failed` event; observer failures are contained. Config-file changes discovered by the ordinary HMR watcher use the same serialized path. Covered by `packages/ui/app-boot/tests/hmr-config.spec.ts`. +10. **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. +11. **`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`. ## Sync procedure diff --git a/vendor/cordis/src/events.ts b/vendor/cordis/src/events.ts index 7831fa75d1..2e862c97d4 100644 --- a/vendor/cordis/src/events.ts +++ b/vendor/cordis/src/events.ts @@ -334,7 +334,7 @@ export interface Events { /** Interception hook for a service binding (no core producer). */ 'internal/service'(this: Context, name: string, value: any): void /** Waterfall: a fiber config update is being applied; skip `next()` to veto. */ - 'internal/update'(this: Fiber, config: any, noSave: boolean, next: () => void): void + 'internal/update'(this: Fiber, config: any, noSave: boolean, next: () => void | Promise): void | Promise /** Waterfall: a service is being read through the context proxy. */ 'internal/get'(ctx: Context, name: string, error: Error, next: () => any): any /** Waterfall: a service is being written through the context proxy. */ diff --git a/vendor/cordis/src/fiber.ts b/vendor/cordis/src/fiber.ts index 61de8bed04..5511b39036 100644 --- a/vendor/cordis/src/fiber.ts +++ b/vendor/cordis/src/fiber.ts @@ -728,13 +728,13 @@ export class Fiber { * * @param config — the new raw config; validated before anything restarts. * @param noSave — hint for persistence hooks not to write the change back. - * @returns nothing; the restart runs behind the `internal/update` waterfall. - * @throws {ValidationError} when the new config fails validation. + * @returns the update waterfall result; the default restart returns a promise. + * @throws when validation, an update listener, or the restarted plugin fails. */ update(config: any, noSave = false) { this.assertActive() config = resolveConfig(this.runtime!, config) - this.context.waterfall(this, 'internal/update', config, noSave, () => { + return this.context.waterfall(this, 'internal/update', config, noSave, () => { this.config = config this._error = undefined return this.restart() diff --git a/vendor/hmr/src/index.ts b/vendor/hmr/src/index.ts index 9727580efd..65ce923dc3 100644 --- a/vendor/hmr/src/index.ts +++ b/vendor/hmr/src/index.ts @@ -1,9 +1,10 @@ -import { Context, Inject, Service, type Plugin } from 'cordis' +import { Context, Service, type Plugin } from 'cordis' import type { Dict } from 'cosmokit' import { ModuleLoader, type ModuleJob, type ResolveResult } from '@cordisjs/plugin-loader' import type { Include } from '@cordisjs/plugin-include' import { FSWatcher, watch, type ChokidarOptions } from 'chokidar' -import { relative, resolve } from 'node:path' +import { dirname, relative, resolve } from 'node:path' +import { stat } from 'node:fs/promises' import { handleError } from './error.ts' import type {} from '@cordisjs/plugin-timer' import { fileURLToPath, pathToFileURL } from 'node:url' @@ -19,6 +20,13 @@ declare module 'cordis' { interface Events { 'hmr/change'(url: string): void 'hmr/reload'(reloads: Map): void + /** + * A watched config-file refresh failed. + * @param filename - Absolute path observed by HMR. + * @param error - Normalized refresh failure. + * @mode parallel + */ + 'hmr/config-update-failed'(filename: string, error: Error): Promise | void } } @@ -44,13 +52,42 @@ interface Reload { runtime?: Plugin.Runtime } -@Inject('loader') -@Inject('timer') +interface ConfigRefresh { + dirty: boolean + running?: Promise +} + +interface ConfigRegistration { + watcher: FSWatcher +} + +async function findWatchRoot(filename: string): Promise<{ root: string; depth: number }> { + let root = dirname(filename) + let depth = 0 + while (true) { + try { + if (!(await stat(root)).isDirectory()) throw new Error(`config watch parent is not a directory: ${root}`) + return { root, depth } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + const parent = dirname(root) + if (parent === root) throw error + root = parent + depth += 1 + } + } +} + class Hmr extends Service { + static inject = ['loader', 'timer'] + public baseDir: string private internal: ModuleLoader private watcher!: FSWatcher + private readonly configs = new Map() + private readonly configRefreshes = new WeakMap() + private readonly refreshTasks = new Set>() /** * Changes from externals will always trigger a full reload. @@ -82,6 +119,65 @@ class Hmr extends Service { this.baseDir = fileURLToPath(new URL(config.base || '.', ctx.baseUrl)) } + /** + * Watch one exact config path outside the configured module roots. + * @param filename - Config path, resolved against the HMR base directory. + * @param refresh - Refresh callback run serially on add, change, or unlink. + * @returns an asynchronous disposer once the exact watch is ready. + * @throws when HMR is inactive, the path is already registered, or watcher startup fails. + */ + async registerConfig(filename: string, refresh: () => Promise | void): Promise<() => Promise> { + if (!this.watcher) throw new Error('HMR is not active') + filename = resolve(this.baseDir, filename) + if (this.configs.has(filename)) throw new Error(`config path already registered: ${filename}`) + + const { root, depth } = await findWatchRoot(filename) + const watcher = watch(root, { + ...this.config, + cwd: undefined, + depth, + ignored: undefined, + ignoreInitial: false, + }) + const registration = { watcher } + this.configs.set(filename, registration) + const onChange = (path: string) => { + if (resolve(path) !== filename) return + this.refreshConfig(registration, filename, refresh) + } + watcher.on('add', onChange) + watcher.on('change', onChange) + watcher.on('unlink', onChange) + + const ready = Promise.withResolvers() + let readyState: 'pending' | 'resolved' | 'rejected' = 'pending' + watcher.once('ready', () => { + readyState = 'resolved' + ready.resolve() + }) + watcher.on('error', (error) => { + if (readyState === 'pending') { + readyState = 'rejected' + ready.reject(error) + } else { + this.ctx.logger.warn(error) + } + }) + + try { + await ready.promise + return this.ctx.effect(() => async () => { + if (this.configs.get(filename) === registration) this.configs.delete(filename) + await watcher.close() + await this.configRefreshes.get(registration)?.running + }, 'hmr.registerConfig()') + } catch (error) { + this.configs.delete(filename) + await watcher.close() + throw error + } + } + /** * Resolve a module specifier to a URL, compatible with Node 22-24. */ @@ -93,7 +189,12 @@ class Hmr extends Service { } async* [Service.init]() { - yield () => this.watcher?.close() + yield async () => { + await this.watcher?.close() + await Promise.allSettled([...this.configs.values()].map(registration => registration.watcher.close())) + this.configs.clear() + await Promise.allSettled([...this.refreshTasks]) + } const { loader } = this.ctx const { root, ignored } = this.config @@ -122,9 +223,18 @@ class Hmr extends Service { const partialReload = this.ctx.debounce(() => this.partialReload(), this.config.debounce) - this.watcher.on('change', async (path) => { - this.ctx.logger.debug('change detected at %C', path) + const onChange = (kind: 'add' | 'change' | 'unlink', path: string) => { + this.ctx.logger.debug('%s detected at %C', kind, path) const filename = resolve(this.baseDir, path) + // Config reload: the file is a loader config file (e.g. cordis.yml). + for (const entry of loader.entries()) { + const include = entry.subtree as Include | undefined + if (include?.filename !== filename) continue + this.refreshConfig(include, filename, () => include.refresh()) + return + } + + if (kind !== 'change') return const url = pathToFileURL(filename).href // Full reload: the changed file is part of the framework @@ -138,16 +248,40 @@ class Hmr extends Service { return partialReload() } - // Config reload: the file is a loader config file (e.g. cordis.yml) - for (const entry of this.ctx.loader.entries()) { - const include = entry.subtree as Include | undefined - if (include?.filename !== filename) continue - await include.refresh() - return - } - this.ctx.emit('hmr/change', url) + } + this.watcher.on('add', path => onChange('add', path)) + this.watcher.on('change', path => onChange('change', path)) + this.watcher.on('unlink', path => onChange('unlink', path)) + } + + private refreshConfig(key: object, filename: string, refresh: () => Promise | void) { + const state = this.configRefreshes.get(key) ?? { dirty: false } + this.configRefreshes.set(key, state) + state.dirty = true + if (state.running) return + const task = (async () => { + do { + state.dirty = false + try { + await refresh() + } catch (reason) { + const error = reason instanceof Error ? reason : new Error(String(reason), { cause: reason }) + this.ctx.logger.warn('config reload at %C failed', filename) + this.ctx.logger.warn(error) + try { + await this.ctx.parallel('hmr/config-update-failed', filename, error) + } catch (rejection) { + this.ctx.logger.warn(rejection) + } + } + } while (state.dirty) + })().finally(() => { + state.running = undefined + this.refreshTasks.delete(task) }) + state.running = task + this.refreshTasks.add(task) } // hide stack trace from HMR diff --git a/vendor/include/src/index.ts b/vendor/include/src/index.ts index 29c894401c..43860dfd56 100644 --- a/vendor/include/src/index.ts +++ b/vendor/include/src/index.ts @@ -35,7 +35,8 @@ const supported = new Set(Object.keys(writable)) * Apply patch lists to an entry list — THE patch semantics of this include, * shared by mounting (`applyPatches`) and offline config tooling * (`dsh --dump-config`) so a dump can never drift from what boots. The input - * is never mutated: patching shared entry objects would bake earlier patch + * is never mutated and the result is always detached from it (even with no + * patches): patching or mounting shared entry objects would bake earlier * values into the cached parse, so repeated application (config hot-reloads) * could never revert a removed or changed patch. Inserted entries are indexed * as they are added, so a later patch in the same list can target a row an @@ -50,8 +51,8 @@ export function applyEntryPatches( patches: PatchOptions[] | undefined, warn: (message: string, ...args: any[]) => void, ): EntryOptions[] { - if (!patches?.length) return [...data] data = structuredClone(data) + if (!patches?.length) return data const entryMap = new Map() const buildMap = (entries: EntryOptions[]) => { @@ -117,6 +118,20 @@ export function applyEntryPatches( return data } +type ConfigUpdateStage = 'read' | 'parse' | 'validate' + +interface ReadCandidate { + content: string + data: EntryOptions[] +} + +class ConfigFileError extends Error { + constructor(public readonly stage: ConfigUpdateStage, path: string, cause: unknown) { + super(`failed to ${stage} config file ${path}`, { cause }) + this.name = 'ConfigFileError' + } +} + /** Runtime patch applied to entries loaded from an included config file. */ export interface PatchOptions { id?: string @@ -169,17 +184,11 @@ export class Include extends EntryTree { this.readonly = !this.type this.ctx.baseUrl = new URL('.', pathToFileURL(this.filename)).href - ctx.on('internal/update', (config, _, next) => { + ctx.on('internal/update', async (config, _, next) => { if (config.path !== this.config.path) return next() - // Veto the fiber restart (children update in place), but persist the new - // config ourselves — `Fiber.update` only assigns `this.config` behind - // `next()`, and a stale `this.config.patches` would make the next - // `refresh()` re-apply the old overlay. + const data = this.applyPatches(this.data!, config.patches) + await this.root.update(data) this.config = config - this.root.update(this.applyPatches(this.data!, config.patches)).catch((error) => { - this.ctx.logger.warn('config update at %C failed', this.filename) - this.ctx.logger.warn(error) - }) }) } @@ -192,30 +201,31 @@ export class Include extends EntryTree { } } - private async read(forced = false) { - const content = await readFile(this.filename, 'utf8') - if (!forced && this.content === content) return false + private async read(forced = false): Promise { + let content: string + try { + content = await readFile(this.filename, 'utf8') + } catch (error) { + throw new ConfigFileError('read', this.filename, error) + } + if (!forced && this.content === content) return let data: any - if (this.type === 'application/yaml') { - data = yaml.load(content, { schema }) - } else if (this.type === 'application/json') { - data = JSON.parse(content) - } else { - const module = await import(/* @vite-ignore */ this.filename) - data = module.default || module + try { + if (this.type === 'application/yaml') { + data = yaml.load(content, { schema }) + } else if (this.type === 'application/json') { + data = JSON.parse(content) + } else { + const module = await import(/* @vite-ignore */ this.filename) + data = module.default || module + } + } catch (error) { + throw new ConfigFileError('parse', this.filename, error) } - // An empty or truncated file (common mid-edit: editors and `sed -i` write - // through temp states) parses to `undefined`, not an error; reject every - // non-array shape here so callers see one "invalid file" signal. Content - // and data commit only on success, so an edit that is later reverted to - // the exact last good content correctly reads as "unchanged". if (!Array.isArray(data)) { - throw new TypeError(`config file must be a top-level array of entries: ${this.filename}`) + throw new ConfigFileError('validate', this.filename, new TypeError('config file must be a top-level array')) } - this.content = content - this.data = data - await this.checkAccess() - return true + return { content, data } } private applyPatches(data: EntryOptions[], patches = this.config.patches): EntryOptions[] { @@ -225,42 +235,44 @@ export class Include extends EntryTree { } async* [Service.init]() { + let candidate: ReadCandidate try { - await this.read() + candidate = (await this.read(true))! } catch (error) { - // Only a missing file falls back to `initial` (or the not-found error): - // an existing-but-invalid file must fail loud with its real parse error, - // never be mislabelled as absent or silently overwritten. - if ((error as NodeJS.ErrnoException | null)?.code !== 'ENOENT') throw error + if (!(error instanceof ConfigFileError) || error.stage !== 'read' || (error.cause as NodeJS.ErrnoException)?.code !== 'ENOENT') throw error if (this.config.initial) { - this.writeFile(this.config.initial as any) - await this.read() + await this._writeFile(this.config.initial as any) + candidate = (await this.read(true))! } else { throw new Error(`config file not found: ${this.filename}`) } } yield () => this.stop() - await this.root.update(this.applyPatches(this.data!)) + await this.apply(candidate) } - stop() { - this.root.stop() + async stop() { + await this.root.stop() } /** - * Re-read the file and refresh child entries when content changed. An - * unreadable or unparsable file logs a warning and keeps the last good - * tree: a hot-reload of a live app must never take the process down. + * Re-read the file and transactionally refresh child entries when content changed. + * @returns a promise resolving after the new tree commits, or immediately when unchanged. + * @throws when reading, parsing, validation, application, or rollback fails; the last good tree remains active when rollback succeeds. */ async refresh() { - try { - if (!await this.read()) return - await this.root.update(this.applyPatches(this.data!)) - } catch (error) { - this.ctx.logger.warn('config reload at %C failed; keeping the running tree', this.filename) - this.ctx.logger.warn(error) - } + const candidate = await this.read() + if (!candidate) return + await this.apply(candidate) + } + + private async apply(candidate: ReadCandidate) { + const data = this.applyPatches(candidate.data) + await this.root.update(data) + this.content = candidate.content + this.data = candidate.data + await this.checkAccess() } private async _writeFile(config: EntryOptions[]) { diff --git a/vendor/loader/src/config/entry.ts b/vendor/loader/src/config/entry.ts index c2959fe61e..d479fa6c0f 100644 --- a/vendor/loader/src/config/entry.ts +++ b/vendor/loader/src/config/entry.ts @@ -21,6 +21,11 @@ export interface EntryOptions { inject?: Inject | null } +function updateError(stage: 'import' | 'dispose' | 'apply' | 'rollback', options: EntryOptions, cause: unknown) { + const detail = cause instanceof Error ? cause.message : String(cause) + return new Error(`failed to ${stage} loader entry ${options.id} (${options.name}): ${detail}`, { cause }) +} + function takeEntries(object: {}, keys: string[]) { const result: [string, any][] = [] for (const key of keys) { @@ -38,6 +43,11 @@ function sortKeys(object: T, prepend = ['id', 'name'], append = [' return Object.assign(object, Object.fromEntries([...part1, ...rest, ...part2])) } +function replaceKeys(target: T, source: T): T { + for (const key of Object.keys(target)) Reflect.deleteProperty(target, key) + return Object.assign(target, source) +} + /** One configured plugin node inside an `EntryTree`. */ export class Entry { static readonly key = Symbol.for('cordis.entry') @@ -51,6 +61,7 @@ export class Entry { public subtree?: EntryTree _initTask?: Promise + _disposing = 0 constructor(public loader: Loader) { this.ctx = loader.ctx.extend({ [Entry.key]: this }) @@ -71,13 +82,18 @@ export class Entry { /** True when this entry or any owning parent entry is disabled. */ get disabled() { + return this._disabled(this.options) + } + + private _disabled(options: EntryOptions) { // group is always enabled - if (this.options.group) return false - let entry: Entry | undefined = this - do { + if (options.group) return false + if (options.disabled) return true + let entry = this.parent.ctx.fiber.entry + while (entry) { if (entry.options.disabled) return true entry = entry.parent.ctx.fiber.entry - } while (entry) + } return false } @@ -90,12 +106,12 @@ export class Entry { return interpolate(this.ctx, this.options.config) } - private _patchContext(diff: string[]) { - this.context.waterfall('loader/patch-context', this, () => { + private async _patchContext(diff: string[]) { + await this.context.waterfall('loader/patch-context', this, async () => { Object.setPrototypeOf(this.ctx, this.parent.ctx) if (this.fiber?.uid && (diff.includes('config') || this.options.group)) { - this.fiber.update(this._resolveConfig(this.fiber.runtime!.callback), true) + await this.fiber.update(this._resolveConfig(this.fiber.runtime!.callback), true) } }) } @@ -106,41 +122,122 @@ export class Entry { await this.init() } + async _dispose(fiber = this.fiber) { + if (!fiber) return + if (this.fiber === fiber) this.fiber = undefined + this._disposing += 1 + try { + await fiber.dispose() + } finally { + this._disposing -= 1 + } + } + /** Merge new options, restart as needed, and persist through the parent tree. */ async update(options: Partial, create = false, force = false) { - const legacy = { ...this.options } - - // step 1: update options - if (create) { - this.options = options as EntryOptions - } else { + const previousOptions = this.options + const legacy = { ...previousOptions } + const candidate = create ? options as EntryOptions : { ...previousOptions } + if (!create) { for (const [key, value] of Object.entries(options)) { if (isNullable(value)) { - delete this.options[key] + delete candidate[key as keyof EntryOptions] } else { - this.options[key] = value + candidate[key as keyof EntryOptions] = value as never } } } - sortKeys(this.options) + sortKeys(candidate) - // step 2: execute - if (this.disabled) { - this.fiber?.dispose() + const diff = Object + .keys({ ...candidate, ...legacy }) + .filter(key => !deepEqual(candidate[key as keyof EntryOptions], legacy[key as keyof EntryOptions])) + if (!diff.length && !force) return + + const commit = () => { + if (create) return + this.options = replaceKeys(previousOptions, candidate) + } + + const previous = this.fiber + if (!previous?.uid) { + this.fiber = undefined + this.options = candidate + try { + if (!this._disabled(candidate)) await this.init() + } catch (error) { + this.options = previousOptions + throw error + } + commit() return } - // step 3: check if options are changed - if (this.fiber?.uid) { - const diff = Object - .keys({ ...this.options, ...legacy }) - .filter(key => !deepEqual(this.options[key], legacy[key])) - if (!diff.length && !force) return + if (this._disabled(candidate)) { + this.options = candidate + try { + await this._dispose(previous) + } catch (error) { + this.options = previousOptions + throw updateError('dispose', candidate, error) + } + commit() this.context.emit('loader/partial-dispose', this, legacy, true) - this._patchContext(diff) - } else { - await this.init() + return } + + const replace = diff.some(key => key === 'name' || key === 'inject' || key === 'group') + if (!replace) { + this.options = candidate + try { + await this._patchContext(diff) + } catch (error) { + this.options = previousOptions + try { + await this._patchContext(diff) + } catch (rollbackError) { + throw updateError('rollback', legacy, new AggregateError([error, rollbackError])) + } + this.context.emit('loader/partial-dispose', this, candidate, true) + throw updateError('apply', candidate, error) + } + commit() + this.context.emit('loader/partial-dispose', this, legacy, true) + return + } + + let plugin: any + try { + plugin = diff.includes('name') + ? this.loader.unwrapExports(await this.parent.tree.import(candidate.name, this.getOuterStack)) + : previous.runtime!.callback + } catch (error) { + throw updateError('import', candidate, error) + } + + const previousPlugin = previous.runtime!.callback + this.options = candidate + try { + await this._dispose(previous) + } catch (error) { + this.options = previousOptions + throw updateError('dispose', candidate, error) + } + + try { + await this._start(plugin) + } catch (error) { + this.options = previousOptions + try { + await this._start(previousPlugin) + } catch (rollbackError) { + throw updateError('rollback', legacy, new AggregateError([error, rollbackError])) + } + this.context.emit('loader/partial-dispose', this, candidate, true) + throw updateError('apply', candidate, error) + } + commit() + this.context.emit('loader/partial-dispose', this, legacy, true) } getOuterStack = () => { @@ -159,26 +256,39 @@ export class Entry { await (this._initTask ??= this._init()) } finally { this._initTask = undefined + if (!this.loader.getTasks().length) this.ctx.reflect.notify(['loader']) } - this.fiber?.await().finally(() => { - if (this.loader.getTasks().length) return - this.ctx.reflect.notify(['loader']) - }) + await this.fiber?.await() } private async _init() { - let exports: any + let plugin: any try { - exports = await this.parent.tree.import(this.options.name, this.getOuterStack) + plugin = this.loader.unwrapExports(await this.parent.tree.import(this.options.name, this.getOuterStack)) } catch (error) { - this.ctx.logger.error(error) - return - } finally { - this._initTask = undefined + throw updateError('import', this.options, error) } - const plugin = this.loader.unwrapExports(exports) - this._patchContext([]) + try { + await this._start(plugin) + } catch (error) { + throw updateError('apply', this.options, error) + } + } + + private async _start(plugin: any) { + let fiber: Fiber | undefined + try { + fiber = await this._create(plugin) + await fiber.await() + } catch (error) { + await this._dispose(fiber) + throw error + } + } + + private async _create(plugin: any): Promise { + await this._patchContext([]) this.loader.showLog(this, 'apply') - this.fiber = this.ctx.registry.plugin(plugin, this._resolveConfig(plugin), this.getOuterStack) + return this.fiber = this.ctx.registry.plugin(plugin, this._resolveConfig(plugin), this.getOuterStack) } } diff --git a/vendor/loader/src/config/group.ts b/vendor/loader/src/config/group.ts index a73e4dea0f..cdd613caf6 100644 --- a/vendor/loader/src/config/group.ts +++ b/vendor/loader/src/config/group.ts @@ -19,12 +19,23 @@ export class EntryGroup { async create(options: Omit) { const id = this.tree.ensureId(options) - const entry: Entry = this.tree.store[id] ??= new Entry(this.ctx.loader) + const existing = this.tree.store[id] + const entry: Entry = existing ?? (this.tree.store[id] = new Entry(this.ctx.loader)) + const previousParent = entry.parent // Entry may be moved from another group, // so we need to update the parent reference. entry.parent = this // Use `create: true` to replace existing entry.options. - await entry.update(options, true, true) + try { + await entry.update(options, true, true) + } catch (error) { + if (existing) { + entry.parent = previousParent + } else { + delete this.tree.store[id] + } + throw error + } return entry.id } @@ -34,10 +45,10 @@ export class EntryGroup { if (index >= 0) config.splice(index, 1) } - remove(id: string, isDispose = false) { + async remove(id: string, isDispose = false) { const entry = this.tree.store[id] if (!entry) return - entry.fiber?.dispose() + await entry._dispose() if (!isDispose) { this.unlink(entry.options) } @@ -47,26 +58,47 @@ export class EntryGroup { async update(config: EntryOptions[]) { const oldConfig = this.data as EntryOptions[] - this.data = config + const seen = new Set() + for (const options of config) { + const id = this.tree.ensureId(options) + if (seen.has(id)) throw new TypeError(`duplicate loader entry id: ${id}`) + seen.add(id) + } const oldMap = Object.fromEntries(oldConfig.map(options => [options.id, options])) - const newMap = Object.fromEntries(config.map(options => [options.id ?? Symbol('anonymous'), options])) + const newMap = Object.fromEntries(config.map(options => [options.id, options])) - // update inner plugins - const ids = Reflect.ownKeys({ ...oldMap, ...newMap }) as string[] - await Promise.all(ids.map(async (id) => { - if (newMap[id]) { - await this.create(newMap[id]).catch((error) => { - this.ctx.logger.error(error) - }) - } else { - this.remove(id) + try { + for (const options of config) await this.create(options) + for (const id of Object.keys(oldMap)) { + if (!newMap[id]) await this.remove(id, true) } - })) + this.data = config + } catch (error) { + const rollbackErrors: unknown[] = [] + for (const id of Object.keys(newMap).reverse()) { + if (oldMap[id]) continue + try { + await this.remove(id, true) + } catch (rollbackError) { + rollbackErrors.push(rollbackError) + } + } + for (const options of oldConfig) { + try { + await this.create(options) + } catch (rollbackError) { + rollbackErrors.push(rollbackError) + } + } + this.data = oldConfig + if (rollbackErrors.length) throw new AggregateError([error, ...rollbackErrors], 'loader entry rollback failed') + throw error + } } - stop() { + async stop() { for (const options of this.data) { - this.remove(options.id, true) + await this.remove(options.id, true) } } } @@ -78,9 +110,7 @@ export class Group extends EntryGroup { constructor(public ctx: Context, public config: EntryOptions[]) { super(ctx, ctx.fiber.entry!.parent.tree) - ctx.on('internal/update', (config) => { - this.update(config) - }) + ctx.on('internal/update', config => this.update(config)) } async* [Service.init]() { diff --git a/vendor/loader/src/config/isolate.ts b/vendor/loader/src/config/isolate.ts index 2361b41aaa..9142f3fda5 100644 --- a/vendor/loader/src/config/isolate.ts +++ b/vendor/loader/src/config/isolate.ts @@ -93,7 +93,7 @@ export default function isolate(ctx: Context) { entry.ctx[Context.isolate] = Object.create(entry.ctx[Context.isolate]) }) - ctx.on('loader/patch-context', (entry, next) => { + ctx.on('loader/patch-context', async (entry, next) => { // step 1: generate new isolate map const newMap: Dict = Object.create(entry.parent.ctx[Context.isolate]) for (const name of Object.keys(entry.options.isolate ?? {})) { @@ -126,7 +126,7 @@ export default function isolate(ctx: Context) { swap(entry.ctx[Context.intercept], entry.options.intercept) // step 4: reload fiber - next() + await next() // step 5: replace service impl for (const [symbol1, symbol2, flag1, flag2] of Object.values(diff)) { diff --git a/vendor/loader/src/config/tree.ts b/vendor/loader/src/config/tree.ts index 79db440601..8cb9fb984d 100644 --- a/vendor/loader/src/config/tree.ts +++ b/vendor/loader/src/config/tree.ts @@ -39,12 +39,27 @@ export abstract class EntryTree { .filter(isNonNullable) } - /** Wait until this tree has no pending import or lifecycle tasks. */ + /** + * Wait until this tree has no active import or lifecycle tasks. + * @throws a settled fiber failure, or an aggregate when several fibers failed. + */ async await() { while (true) { const tasks = this.getTasks() - if (!tasks.length) return - await Promise.allSettled(tasks) + if (tasks.length) { + await Promise.allSettled(tasks) + continue + } + const outcomes = await Promise.allSettled( + [...this.entries()].map(entry => entry.fiber?.await()), + ) + const failures = outcomes + .filter((outcome): outcome is PromiseRejectedResult => outcome.status === 'rejected') + .map(outcome => outcome.reason) + if (failures.length === 1) throw failures[0] + if (failures.length > 1) throw new AggregateError(failures, 'loader fibers failed') + this.ctx.reflect.notify(['loader']) + if (!this.getTasks().length) return } } @@ -81,15 +96,17 @@ export abstract class EntryTree { /** Create an entry in the root group or a nested group. */ async create(options: Omit, parent: string | null = null, position = Infinity) { const group = this.resolveGroup(parent) - group.data.splice(position, 0, options as EntryOptions) + const id = await group.create(options) + const entry = this.resolve(id) + group.data.splice(position, 0, entry.options) group.tree.write() - return group.create(options) + return id } /** Stop and remove an entry from its parent group. */ - remove(id: string) { + async remove(id: string) { const entry = this.resolve(id) - entry.parent.remove(id) + await entry.parent.remove(id) entry.parent.tree.write() } @@ -97,15 +114,31 @@ export abstract class EntryTree { async update(id: string, options: Omit, parent?: string | null, position?: number) { const entry = this.resolve(id) const source = entry.parent + const sourceIndex = source.data.indexOf(entry.options) + let target = source if (parent !== undefined) { - const target = this.resolveGroup(parent) + target = this.resolveGroup(parent) source.unlink(entry.options) target.data.splice(position ?? Infinity, 0, entry.options) - target.tree.write() entry.parent = target } + try { + await entry.update(options, false, true) + } catch (error) { + if (parent !== undefined) { + target.unlink(entry.options) + source.data.splice(sourceIndex < 0 ? source.data.length : sourceIndex, 0, entry.options) + entry.parent = source + try { + await entry.update({}, false, true) + } catch (rollbackError) { + throw new AggregateError([error, rollbackError], `failed to roll back loader entry move ${id}`) + } + } + throw error + } source.tree.write() - return entry.update(options, false, true) + if (target !== source) target.tree.write() } /** Import a plugin module from a specifier or `cordis:` builtin. */ diff --git a/vendor/loader/src/index.ts b/vendor/loader/src/index.ts index 1e963ea073..798354c7b0 100644 --- a/vendor/loader/src/index.ts +++ b/vendor/loader/src/index.ts @@ -24,7 +24,7 @@ declare module 'cordis' { 'loader/config-update'(): void 'loader/entry-init'(entry: Entry): void 'loader/partial-dispose'(entry: Entry, legacy: Partial, active: boolean): void - 'loader/patch-context'(entry: Entry, next: () => void): void + 'loader/patch-context'(entry: Entry, next: () => void | Promise): void | Promise } interface Context { @@ -87,12 +87,12 @@ export class Loader extends EntryTree { ctx.reflect.provide('loader', this, this[Service.check]) - ctx.on('internal/update', function (config, noSave, next) { + ctx.on('internal/update', async function (config, noSave, next) { if (!this.entry || noSave || this.parent.fiber?.entry === this.entry) return next() + await next() const unparse = this.runtime?.Config?.['simplify'] this.entry.options.config = unparse ? unparse(config) : config this.entry.parent.tree.write() - return next() }, { global: true, prepend: true }) ctx.on('internal/update', function (config, _, next) { @@ -129,9 +129,12 @@ export class Loader extends EntryTree { // case 5: the entry's tree is being disposed if (!fiber.entry.parent.tree.ctx.fiber.uid) return + // case 6: Loader is replacing or removing this exact fiber + if (fiber.entry._disposing) return + this.showLog(fiber.entry, 'unload') - // case 6: fiber is disposed by loader behavior + // case 7: fiber is disposed by loader behavior // such as inject checker, config file update, ancestor group disable if (fiber.entry.disabled) return