diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index ec94f92fe3..05fc95a484 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.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 docs/subsystems/core.md -core.md: 5b1a249d4d0b2df05750e62044ee539a24b32b0e -core.zh.md: 2c38ffe6ec9956fc436d4a4f7e2fcc3d1f7420f8 +core.md: a5f61dddd033ad5332fdc2f940b48029c1eedd27 +core.zh.md: 1b0599e9121702dae5163fe93438044491574072 diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index 5b1a249d4d..a5f61dddd0 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -948,6 +948,14 @@ userDisables(entry: Entry): boolean * @throws when preparation fails or the root Include cannot accept the update. */ async recompose(options: { reloadBundles?: boolean } = {}): Promise + +/** + * Wait for recompositions already queued when called, including removed-fiber cleanup. + * Observers may read accepted composition facts afterwards; a failed operation + * still reports its error to its caller and does not reject this observation. + * @returns after the current recomposition queue settles. + */ +whenIdle(): Promise ``` Source: [`packages/boot/app-boot/src/profile-runtime.ts`](../../packages/boot/app-boot/src/profile-runtime.ts) diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index 2c38ffe6ec..1b0599e912 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -958,6 +958,14 @@ userDisables(entry: Entry): boolean * @throws when preparation fails or the root Include cannot accept the update. */ async recompose(options: { reloadBundles?: boolean } = {}): Promise + +/** + * Wait for recompositions already queued when called, including removed-fiber cleanup. + * Observers may read accepted composition facts afterwards; a failed operation + * still reports its error to its caller and does not reject this observation. + * @returns after the current recomposition queue settles. + */ +whenIdle(): Promise ``` Source: [`packages/boot/app-boot/src/profile-runtime.ts`](../../packages/boot/app-boot/src/profile-runtime.ts) diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index bc7ccd3a77..f601cf75ee 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: 2bcdeff9ca96f8ee54e0a8ef2474496e8cf0776e -README.zh.md: 2cbb7e7e022abe6cebc6b4f5ae468598040476ae +README.md: 455b55910f8089a8833b46d99673fd55928f5f01 +README.zh.md: c941328f77e663b5bfd75fde20fa7ca3f213c219 diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index 2bcdeff9ca..455b55910f 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -60,7 +60,7 @@ Inserted plugin names may be absolute filesystem paths, file URLs, or package sp Installed dependencies default to `external`; template bundles and packages in `dsh.profile.firstParty` are `builtin`. Effective stage is `dsh.profile.stages[package]`, then the package’s `dsh.bundle.stage`, then `runtime`. Stage selects startup failure policy, not a later execution phase. Bundle patches retain their declared ids, parents, and ordering. Built-in and boot-staged layers claim ids first; an optional bundle with a duplicate id is omitted whole and reported, while a conflicting user insert is omitted per row. `dependencies` records installation; `dsh.profile.bundles` selects enabled layers, including all their inserts and overrides. -The launcher provides `ctx.profileRuntime` before any configuration entry mounts. It owns row provenance, accepted composition, conflicts, and user-disabled rows. Watchers and management operations share its serial recomposition queue. Recomposition waits for current entries and removed fibers, then publishes accepted options and reports per-entry issues; a failed update can leave a fiber running its previous valid config. `installFailLoud` remains installed until shutdown for process-level unhandled rejections. +The launcher provides `ctx.profileRuntime` before any configuration entry mounts. It owns row provenance, accepted composition, conflicts, and user-disabled rows. Watchers and management operations share its serial recomposition queue. Recomposition waits for current entries and removed fibers, then publishes accepted options and reports per-entry issues; a failed update can leave a fiber running its previous valid config. `installFailLoud` remains installed until shutdown for process-level unhandled rejections. Observers can await `whenIdle()` before publishing a refreshed view of composition ownership. ### Previewing the effective configuration diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index 2cbb7e7e02..c941328f77 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -60,7 +60,7 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head 已安装依赖默认为 `external`;模板组合包和 `dsh.profile.firstParty` 中的包为 `builtin`。有效 stage 依次取 `dsh.profile.stages[package]`、包的 `dsh.bundle.stage`,最后默认为 `runtime`。stage 决定启动失败策略,不表示稍后执行。组合包 patch 保留声明的 id、父组和顺序。内置与 boot 阶段层优先占有 id;可选组合包有重复 id 时整层被排除并报告,冲突的用户插入则逐行排除。`dependencies` 记录安装;`dsh.profile.bundles` 选择启用的层,包括它们的全部插入和覆盖。 -launcher 在任何配置行挂载前提供 `ctx.profileRuntime`。它拥有行来源、已接受的组合、冲突和用户禁用行信息。文件监听与管理操作共用它的串行重组队列。重组等待当前条目和已移除 fiber 完成后,发布已接受的选项并报告逐行问题;更新失败时,fiber 可能仍使用先前的有效配置运行。`installFailLoud` 保持到应用关闭,处理进程级未处理 rejection。 +launcher 在任何配置行挂载前提供 `ctx.profileRuntime`。它拥有行来源、已接受的组合、冲突和用户禁用行信息。文件监听与管理操作共用它的串行重组队列。重组等待当前条目和已移除 fiber 完成后,发布已接受的选项并报告逐行问题;更新失败时,fiber 可能仍使用先前的有效配置运行。`installFailLoud` 保持到应用关闭,处理进程级未处理 rejection。 观测方可等待 `whenIdle()` 后,再发布包含组合归属的刷新视图。 ### 预览生效配置 diff --git a/packages/boot/app-boot/src/profile-runtime.ts b/packages/boot/app-boot/src/profile-runtime.ts index 00c2abd388..02f59f6ec3 100644 --- a/packages/boot/app-boot/src/profile-runtime.ts +++ b/packages/boot/app-boot/src/profile-runtime.ts @@ -183,6 +183,16 @@ export class ProfileRuntime extends Service { this.committed = { profile, stack } return inspectEntryIssues(this.ctx) } + + /** + * Wait for recompositions already queued when called, including removed-fiber cleanup. + * Observers may read accepted composition facts afterwards; a failed operation + * still reports its error to its caller and does not reject this observation. + * @returns after the current recomposition queue settles. + */ + whenIdle(): Promise { + return this.queue + } } /** The walk behind {@link ProfileRuntime.userDisables}: the entry, then each group holding it, outward. */ diff --git a/packages/boot/app-boot/tests/profile-runtime.spec.ts b/packages/boot/app-boot/tests/profile-runtime.spec.ts index 99bad963b3..50ad127a82 100644 --- a/packages/boot/app-boot/tests/profile-runtime.spec.ts +++ b/packages/boot/app-boot/tests/profile-runtime.spec.ts @@ -147,10 +147,14 @@ describe('ProfileRuntime', () => { // Enabling a bundle re-reads the profile and waits on the tree; a watcher fires meanwhile. const enabling = runtime.recompose({ reloadBundles: true }) const watching = runtime.recompose() + const observed = vi.fn() + const idle = runtime.whenIdle().then(observed) await Promise.resolve() expect(applied).toEqual(['composed-for-2']) + expect(observed).not.toHaveBeenCalled() release() - await Promise.all([enabling, watching]) + await Promise.all([enabling, watching, idle]) + expect(observed).toHaveBeenCalledOnce() // The watcher composed from the profile the enable committed, not the one before it. expect(applied).toEqual(['composed-for-2', 'composed-for-2']) expect(compose.mock.calls.at(-1)?.[0]).toBe(reloaded) @@ -169,6 +173,7 @@ describe('ProfileRuntime', () => { const second = runtime.recompose() await expect(first).rejects.toThrow('rejected') await expect(second).resolves.toEqual([]) + await expect(runtime.whenIdle()).resolves.toBeUndefined() }) it('keeps the committed profile, provenance, and conflicts when the root include rejects the update', async () => { diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 079dad5fc1..0401d8143c 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1434,6 +1434,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'current entry issues after application, without rolling back successful siblings.', throws: ['when preparation fails or the root Include cannot accept the update.'], }, + { + signature: 'whenIdle(): Promise', + description: 'Wait for recompositions already queued when called, including removed-fiber cleanup. Observers may read accepted composition facts afterwards; a failed operation still reports its error to its caller and does not reject this observation.', + parameters: [], + returns: 'after the current recomposition queue settles.', + }, ], }, {