Browse Source

Preserve nested plugin errors after Cordis revert

turtle1999 1 week ago
parent
commit
2376d210bd

+ 2 - 2
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

+ 1 - 1
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
 

+ 1 - 1
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 行为
 

+ 8 - 4
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.

+ 18 - 0
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 = [