From 05d73d657103eeaa4667468147c6066b77ae60d1 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 31 Jul 2026 03:19:48 +0800 Subject: [PATCH] fix(app-boot): await only failed activation fibers --- ...tree-boot-and-transport-layering.i18n.yaml | 4 +-- ...config-tree-boot-and-transport-layering.md | 2 +- ...fig-tree-boot-and-transport-layering.zh.md | 2 +- packages/ui/app-boot/src/index.ts | 21 +++++++----- packages/ui/app-boot/tests/app-boot.spec.ts | 33 ++++++++++++++++--- 5 files changed, 45 insertions(+), 17 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml index 8ac6ef31e0..d50428d5ed 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.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 .agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md -2026-07-24-web-config-tree-boot-and-transport-layering.md: 0aec4714238ed71c6878ce723b55fa7a7434af89 -2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: b20d29e3265c91aa54eb0b274c1f95353460f46e +2026-07-24-web-config-tree-boot-and-transport-layering.md: 88f94b1f58ae7a3451c7772f4a9ff7d6564254c0 +2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: ea2a8f70a6c2d4207d4388a9303fbc6ce6e94238 diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md index 0aec471423..88f94b1f58 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md @@ -12,7 +12,7 @@ English | [中文](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md) ## Decision -**Composition is one flat assembled tree.** `apps/cli/config/base.cordis.yml` plus `apps/cli/config/web.cordis.yml` holds every row — the host runtime (32 rows), the `api-gateway` row, the `webserver` row, and the `dshClient` rows (the browser roster; the modules row is simultaneously a host row). No spine bundle: every plugin is one row and every config field is yml-editable. That stance later became repository-wide, with the rows both surfaces share factored into `apps/cli/config/base.cordis.yml` and each surface reduced to an overlay ([shared-base overlays](../simplification/2026-07-29-shared-base-config-overlays.md)). `--dev` appends the `dsh-client-hmr` row in code before the settle audit — prod and dev differ by exactly that row. Row order carries no load semantics; activation is service-availability driven. The shared audit rejects imports with no fiber, awaits settled fibers to recover original activation errors, and reports services that leave a fiber `PENDING`; before throwing, it marks those exact rejection reasons through one process checkpoint so `installFailLoud` coalesces Loader's duplicate notification while unrelated unhandled rejections remain fatal. The Node app-boot artifact embeds `@cordisjs/plugin-include` while leaving `@cordisjs/plugin-loader` external, so the include's `EntryTree` and the host bind to one Loader peer instead of splitting a config tree across two Loader implementations. +**Composition is one flat assembled tree.** `apps/cli/config/base.cordis.yml` plus `apps/cli/config/web.cordis.yml` holds every row — the host runtime (32 rows), the `api-gateway` row, the `webserver` row, and the `dshClient` rows (the browser roster; the modules row is simultaneously a host row). No spine bundle: every plugin is one row and every config field is yml-editable. That stance later became repository-wide, with the rows both surfaces share factored into `apps/cli/config/base.cordis.yml` and each surface reduced to an overlay ([shared-base overlays](../simplification/2026-07-29-shared-base-config-overlays.md)). `--dev` appends the `dsh-client-hmr` row in code before the settle audit — prod and dev differ by exactly that row. Row order carries no load semantics; activation is service-availability driven. The shared audit rejects imports with no fiber, awaits only failed fibers to recover original activation errors, and reports services that leave a fiber `PENDING`; before throwing, it marks those exact rejection reasons through one process checkpoint so `installFailLoud` coalesces Loader's duplicate notification while unrelated unhandled rejections remain fatal. The Node app-boot artifact embeds `@cordisjs/plugin-include` while leaving `@cordisjs/plugin-loader` external, so the include's `EntryTree` and the host bind to one Loader peer instead of splitting a config tree across two Loader implementations. **Boot glue is a class pair.** `AppCLIEntry` (apps/cli) and `AppWebEntry` (the shell kernel) hold only what must exist independently of cordis: argv facts, the composed patch set, the parsed boot manifest, the module system instance, loading-page handles — everything else lives in plugins. `AppCLIEntry.run()` is three stages: layered env (ambient > cwd `.env` > `$DSH_HOME/.env`, closing the defect above) → patch composition → Loader include boot plus the activation audit. `AppWebEntry.run()` mirrors it browser-side: parse `window.__DSH_BOOT__` into a `BootManifest` (two views: npm-package rows for the module table, cordis-plugin rows for entry composition; malformed wire throws), build the module system, render the loading page, prefetch the `immediately` tier in parallel with Context/Loader setup, **await the prefetch before creating entries** (materialization is `tree.import`'s synchronous require, unprotected by fiber inject waiting; cross-package require edges such as i18n → runtime/client need every immediately-tier factory registered first — an empirically found 10–25% boot race otherwise), adopt the modules entry, create the graph rows, settle, sweep. diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md index b20d29e326..ea2a8f70a6 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -**组合结果是一棵平铺配置树。** `apps/cli/config/base.cordis.yml` 与 `apps/cli/config/web.cordis.yml` 共同持有全部行——host runtime(32 行)、`api-gateway` 行、`webserver` 行、`dshClient` 行(浏览器 roster;modules 行同时是 host 行)。不做 spine bundle:每插件一行、每个 config 字段 yml 可改。这一立场后来推广到全仓:两个 surface 共享的配置项被抽取进 `apps/cli/config/base.cordis.yml`,各 surface 则收敛为一份 overlay([共享 base overlay](../simplification/2026-07-29-shared-base-config-overlays.md))。`--dev` 在 settle audit 之前由代码追加 `dsh-client-hmr` 行——prod 与 dev 的全部差异就是这一行。行序无装载语义;激活由服务可用性驱动。共享 audit 会拒绝没有 fiber 的 import、等待 settled fiber 以恢复原始激活错误,并报告让 fiber 停在 `PENDING` 的服务;抛出错误前,审计会通过一个进程级检查点标记这些 rejection 的确切原因,从而让 `installFailLoud` 将 Loader 的重复通知合并为一次,而无关的未处理 rejection 仍然致命。Node app-boot 产物内嵌 `@cordisjs/plugin-include`,但将 `@cordisjs/plugin-loader` 保持为外部依赖,因此 include 的 `EntryTree` 与 host 会绑定到同一个 Loader peer,而不会让一棵配置树横跨两个 Loader 实现。 +**组合结果是一棵平铺配置树。** `apps/cli/config/base.cordis.yml` 与 `apps/cli/config/web.cordis.yml` 共同持有全部行——host runtime(32 行)、`api-gateway` 行、`webserver` 行、`dshClient` 行(浏览器 roster;modules 行同时是 host 行)。不做 spine bundle:每插件一行、每个 config 字段 yml 可改。这一立场后来推广到全仓:两个 surface 共享的配置项被抽取进 `apps/cli/config/base.cordis.yml`,各 surface 则收敛为一份 overlay([共享 base overlay](../simplification/2026-07-29-shared-base-config-overlays.md))。`--dev` 在 settle audit 之前由代码追加 `dsh-client-hmr` 行——prod 与 dev 的全部差异就是这一行。行序无装载语义;激活由服务可用性驱动。共享 audit 会拒绝没有 fiber 的 import、仅等待失败的 fiber 以恢复原始激活错误,并报告让 fiber 停在 `PENDING` 的服务;抛出错误前,审计会通过一个进程级检查点标记这些 rejection 的确切原因,从而让 `installFailLoud` 将 Loader 的重复通知合并为一次,而无关的未处理 rejection 仍然致命。Node app-boot 产物内嵌 `@cordisjs/plugin-include`,但将 `@cordisjs/plugin-loader` 保持为外部依赖,因此 include 的 `EntryTree` 与 host 会绑定到同一个 Loader peer,而不会让一棵配置树横跨两个 Loader 实现。 **boot 胶水是一对 class。** `AppCLIEntry`(apps/cli)与 `AppWebEntry`(壳内核)只持有独立于 cordis 必须提前存在的东西:argv 事实、合成的 patch 集、解析出的 boot manifest、模块系统实例、loading 页句柄——其余一律进插件。`AppCLIEntry.run()` 三段:分层 env(ambient > cwd `.env` > `$DSH_HOME/.env`,顺手关掉上述缺陷)→ patch 合成 → Loader include boot 加 activation audit。`AppWebEntry.run()` 在浏览器侧镜像它:把 `window.__DSH_BOOT__` 解析成 `BootManifest`(双视角:npm 包行给模块表、cordis 插件行给 entry 组合;畸形 wire 大声抛)、建模块系统、渲染 loading 页、immediately 层预取与 Context/Loader 准备并行、**create entry 之前等预取齐**(物化是 `tree.import` 的同步 require,不受 fiber inject 等待保护;i18n → runtime/client 这类跨包 require 边要求 immediately 层工厂全部注册完——否则有实测 10–25% 的 boot 竞态)、收编 modules entry、逐图行 create、settle、sweep。 diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index e18346d166..015898f2cb 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -229,6 +229,7 @@ export function assertEntriesLoaded(ctx: Context, binName: string): void { */ 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. */ function formatActivationError(error: unknown): string { @@ -238,7 +239,9 @@ function formatActivationError(error: unknown): string { /** * 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. + * 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. * @param binName - the diagnostic prefix on the thrown error. * @returns nothing when every enabled entry is active. @@ -252,15 +255,17 @@ export async function assertEntriesActivated(ctx: Context, binName: string): Pro for (const entry of ctx.loader.entries()) { const fiber = entry.fiber if (fiber === undefined || entry.disabled) continue - try { - await fiber.await() - } catch (error) { - rejectionReasons.push(error) - failures.push(`${entry.options.name}: ${formatActivationError(error)}`) - continue - } const state = fiber.state if (state === FIBER_ACTIVE) continue + if (state === FIBER_FAILED) { + try { + await fiber.await() + } catch (error) { + rejectionReasons.push(error) + failures.push(`${entry.options.name}: ${formatActivationError(error)}`) + } + continue + } if (state === FIBER_PENDING) { const missing = Object.keys(fiber.inject).filter(service => fiber.ctx.get(service) === undefined) const subject = missing.length === 1 ? 'service' : 'services' diff --git a/packages/ui/app-boot/tests/app-boot.spec.ts b/packages/ui/app-boot/tests/app-boot.spec.ts index 1866e50076..c756f28b51 100644 --- a/packages/ui/app-boot/tests/app-boot.spec.ts +++ b/packages/ui/app-boot/tests/app-boot.spec.ts @@ -209,10 +209,22 @@ describe('assertEntriesActivated', () => { }) it('passes active entries and ignores disabled entries', async () => { + let awaitCalls = 0 + const active = fiber(2) + active.await = async () => { + awaitCalls++ + return undefined + } + const disabled = fiber(3, new Error('disabled failure')) + disabled.await = async () => { + awaitCalls++ + throw new Error('disabled failure') + } await expect(assertEntriesActivated(ctxWith([ - { fiber: fiber(2), options: { name: 'active' } }, - { disabled: true, options: { name: 'disabled' } }, + { fiber: active, options: { name: 'active' } }, + { fiber: disabled, disabled: true, options: { name: 'disabled' } }, ]), NAME)).resolves.toBeUndefined() + expect(awaitCalls).toBe(0) }) it('reports the plugin name and original activation stack instead of fiber state 3', async () => { @@ -232,17 +244,28 @@ describe('assertEntriesActivated', () => { }) it('reports unresolved services for pending entries', async () => { + let awaitCalls = 0 const expected = [ `${NAME}: 3 entries did not activate`, 'waiting: pending (waiting for services: missingA, missingB)', 'single-wait: pending (waiting for service: missing)', 'unknown-wait: pending (waiting for services: unknown)', ].join('\n') + const waiting = fiber(0, undefined, { ready: {}, missingA: {}, missingB: {} }, ['ready']) + const singleWait = fiber(0, undefined, { missing: {} }) + const unknownWait = fiber(0) + for (const item of [waiting, singleWait, unknownWait]) { + item.await = async () => { + awaitCalls++ + return undefined + } + } await expect(assertEntriesActivated(ctxWith([ - { fiber: fiber(0, undefined, { ready: {}, missingA: {}, missingB: {} }, ['ready']), options: { name: 'waiting' } }, - { fiber: fiber(0, undefined, { missing: {} }), options: { name: 'single-wait' } }, - { fiber: fiber(0), options: { name: 'unknown-wait' } }, + { fiber: waiting, options: { name: 'waiting' } }, + { fiber: singleWait, options: { name: 'single-wait' } }, + { fiber: unknownWait, options: { name: 'unknown-wait' } }, ]), NAME)).rejects.toThrow(expected) + expect(awaitCalls).toBe(0) }) it('retains the numeric diagnostic for a settled unexpected state', async () => {