From 2376d210bd776d2a529fc4e0e4342b7a83ed7510 Mon Sep 17 00:00:00 2001 From: turtle1999 Date: Thu, 10 Sep 2026 13:36:05 +0800 Subject: [PATCH] Preserve nested plugin errors after Cordis revert --- packages/boot/app-boot/README.i18n.yaml | 4 ++-- packages/boot/app-boot/README.md | 2 +- packages/boot/app-boot/README.zh.md | 2 +- packages/boot/app-boot/src/index.ts | 12 ++++++++---- packages/boot/app-boot/tests/app-boot.spec.ts | 18 ++++++++++++++++++ 5 files changed, 30 insertions(+), 8 deletions(-) diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index 389919113a..a9eadabcc5 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/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/boot/app-boot/README.md -README.md: 8ead27c1a6a3544f34b391facfc3888f50ba479f -README.zh.md: a074afa1dde0ff9eb90b7dc5a8348ac9bb3920b6 +README.md: 2d67d7061c48205a5592e54e404a23d93ed17b3a +README.zh.md: e177bfa86ca28768b08a4be3a48617b20a68a1e7 diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index 8ead27c1a6..2d67d7061c 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -89,7 +89,7 @@ This section explains how the outcomes above are realized and points at the code - **Profile module fallback.** Bare plugin specifiers resolve through the Loader from the config directory. Plain Node maintains one symlink per package in the installation dependency closure. A packaged executable instead reads each installed export map with Node ESM conditions and writes real proxy packages that re-export virtual module URLs, because an operating-system symlink cannot enter pkg's `/snapshot` tree. Missing exports stay unavailable, malformed maps fail startup, and a cross-process writer lock replaces stale entries without exposing partial proxies. A selected external bundle absent from the installation closure receives a profile-local `.dsh-module-fallback` link; existing pnpm entries win, projected links are excluded from later closure discovery, and cleanup removes only dsh-owned links. - **One rejection checkpoint.** `assertEntriesActivated` keeps the exact reasons it folds into the boot diagnostic visible through the next process rejection checkpoint, so `installFailLoud` coalesces Loader's duplicate notification while unrelated unhandled rejections remain fatal. - **Update completion.** App boot observes restart failures through the `internal/update` waterfall. Live patch reloads wait for the tree's fibers before auditing activation; `Fiber.update()` and `Entry.update()` alone do not establish restart success. -- **Two-stage failure labels.** `boot()` distinguishes `host preparation failed` — `prepare` threw before any config-tree entry mounted — from `plugin tree failed to load`, and appends the deepest plugin error's stack so the startup diagnostic preserves the original activation error instead of only the wrap chain. +- **Two-stage failure labels.** `boot()` distinguishes `host preparation failed` — `prepare` threw before any config-tree entry mounted — from `plugin tree failed to load`. Plugin diagnostics include original stacks, nested causes, and aggregate member failures. ### Helper behavior diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index a074afa1dd..e177bfa86c 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -89,7 +89,7 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head - **Profile 模块后备机制。** 裸插件 specifier 由 Loader 从配置目录解析。普通 Node 会为安装依赖闭包中的每个包维护一个符号链接。打包可执行文件无法让操作系统符号链接进入 pkg 的 `/snapshot` 树,因此会按 Node ESM 条件读取已安装包的 export map,并写入重新导出虚拟模块 URL 的真实代理包。缺失 export 保持不可用,错误 export map 会让启动失败,跨进程 writer lock 则会在不暴露部分代理的情况下替换陈旧条目。所选外部组合包若不在安装闭包中,则会获得 profile 本地的 `.dsh-module-fallback` 链接;已有 pnpm 条目优先,后续闭包发现会排除投影链接,清理也只删除 dsh 自有链接。 - **单一 rejection 检查点。** `assertEntriesActivated` 把折入启动诊断的确切原因保持到下一个进程级 rejection 检查点可见,使 `installFailLoud` 能合并 Loader 的重复通知,而所有无关的未处理 rejection 仍然致命。 - **更新完成。** App boot 通过 `internal/update` waterfall 观察重启失败。实时 patch 重载在检查激活状态前等待配置树中的 fiber;单独调用 `Fiber.update()` 或 `Entry.update()` 不能确定重启成功。 -- **两阶段失败标签。** `boot()` 区分 `host preparation failed`(`prepare` 在任何配置树条目挂载前抛出)与 `plugin tree failed to load`(此后的一切失败),并追加最深层插件错误的堆栈,使启动诊断保留原始激活错误,而不只是包装链。 +- **两阶段失败标签。** `boot()` 区分 `host preparation failed`(`prepare` 在任何配置树条目挂载前抛出)与 `plugin tree failed to load`。插件诊断包含原始堆栈、嵌套原因和聚合错误中的各项失败。 ### Helper 行为 diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts index 05da302eb0..5f1906e2b4 100644 --- a/packages/boot/app-boot/src/index.ts +++ b/packages/boot/app-boot/src/index.ts @@ -706,15 +706,19 @@ const FIBER_PENDING = 0 as FiberState.PENDING const FIBER_ACTIVE = 2 as FiberState.ACTIVE const FIBER_FAILED = 3 as FiberState.FAILED -/** Render a thrown plugin value without discarding an Error's original stack. */ +/** Render plugin stacks, nested causes, and aggregate member failures. */ function formatActivationError(error: unknown): string { - return error instanceof Error ? error.stack ?? error.message : String(error) + if (!(error instanceof Error)) return String(error) + const details = [error.stack ?? error.message] + if (error.cause !== undefined) details.push(formatActivationError(error.cause)) + if (error instanceof AggregateError) details.push(...error.errors.map(formatActivationError)) + return details.join('\n') } /** * Reject a settled Loader tree when an enabled entry failed or remains inactive. - * Plugin failures include the original thrown stack; pending entries name their - * unresolved services because no plugin error exists for that state. Active + * Plugin failures include original stacks, nested causes, and aggregate members. + * Pending entries name their unresolved services because no plugin error exists for that state. Active * entries require no further wait; only failed fibers are awaited to recover * their private rejection reason. * @param ctx - the settled context whose Loader entries to audit. diff --git a/packages/boot/app-boot/tests/app-boot.spec.ts b/packages/boot/app-boot/tests/app-boot.spec.ts index 537d4efad5..bfb8e24355 100644 --- a/packages/boot/app-boot/tests/app-boot.spec.ts +++ b/packages/boot/app-boot/tests/app-boot.spec.ts @@ -580,6 +580,24 @@ describe('assertEntriesActivated', () => { ]), NAME)).rejects.toThrow(`${NAME}: 2 entries did not activate\nstackless: stackless failure\nplain: plain failure`) }) + it('preserves nested activation causes and aggregate member failures', async () => { + const original = new Error('tool discovery failed') + const aggregate = new AggregateError([original, 'transport closed'], 'connection failed', { + cause: new Error('server rejected discovery'), + }) + const wrapper = new Error('plugin activation failed', { cause: aggregate }) + await expect(assertEntriesActivated(ctxWith([ + { fiber: fiber(3, wrapper), options: { name: 'wrapped-plugin' } }, + ]), NAME)).rejects.toThrow([ + `${NAME}: 1 entry did not activate`, + `wrapped-plugin: ${wrapper.stack!}`, + aggregate.stack!, + (aggregate.cause as Error).stack!, + original.stack!, + 'transport closed', + ].join('\n')) + }) + it('reports unresolved services for pending entries', async () => { let awaitCalls = 0 const expected = [