Просмотр исходного кода

Merge profile settlement and await it before runtime notifications

Yichen Jiang 1 месяц назад
Родитель
Сommit
3ace0d0cf7

+ 2 - 2
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: 26ce43696593831eb01994c53d238504f5846217
-core.zh.md: 3475c279228af2c856c671d45aa0f6a6d288435b
+core.md: 0e96a41ec96b246fd104a9625783211753eac429
+core.zh.md: 1b02373d427e297763114ff9c34af2f957d84ab7

+ 8 - 0
docs/subsystems/core.md

@@ -1055,6 +1055,14 @@ userDisables(entry: Entry): boolean
  * @throws when preparation fails or the root Include cannot accept the update.
  */
 async recompose(options: { reloadBundles?: boolean } = {}): Promise<readonly EntryIssue[]>
+
+/**
+ * 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<void>
 ```
 
 Source: [`packages/boot/app-boot/src/profile-runtime.ts`](../../packages/boot/app-boot/src/profile-runtime.ts)

+ 8 - 0
docs/subsystems/core.zh.md

@@ -1065,6 +1065,14 @@ userDisables(entry: Entry): boolean
  * @throws when preparation fails or the root Include cannot accept the update.
  */
 async recompose(options: { reloadBundles?: boolean } = {}): Promise<readonly EntryIssue[]>
+
+/**
+ * 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<void>
 ```
 
 Source: [`packages/boot/app-boot/src/profile-runtime.ts`](../../packages/boot/app-boot/src/profile-runtime.ts)

+ 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: 9815609d4638d7adfd981d76dcf318386fa9aa1b
-README.zh.md: 4f59bffb0ab75d2bdd48f7cad384204320585748
+README.md: 27fa2d9039b95e39631a574e02359b312f3d9312
+README.zh.md: a9de7f1b4048adbf4d7b3e502ca5c6811e129af4

+ 1 - 1
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.
 
 <a id="patch-files"></a>
 ### Patch files

+ 1 - 1
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()` 后,再发布包含组合归属的刷新视图。
 
 <a id="patch-files"></a>
 ### 补丁文件

+ 10 - 0
packages/boot/app-boot/src/profile-runtime.ts

@@ -190,6 +190,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<void> {
+    return this.queue
+  }
 }
 
 /** The walk behind {@link ProfileRuntime.userDisables}: the entry, then each group holding it, outward. */

+ 6 - 1
packages/boot/app-boot/tests/profile-runtime.spec.ts

@@ -149,10 +149,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)
@@ -171,6 +175,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 () => {

+ 6 - 0
packages/extensions/tool-cordis/src/api-catalog.ts

@@ -1512,6 +1512,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<void>',
+        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.',
+      },
     ],
   },
   {

+ 2 - 2
packages/host/plugin-manager/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/plugin-manager/README.md
-README.md: b69895b5b2495380dd03443291019f8da3997fa6
-README.zh.md: 658caa69738ab4e62ab3cdadf2f242242ddd209b
+README.md: 94f4debb4c8ae061d417a9a9da0affa22e07621c
+README.zh.md: 5d4e7c997110ac588aa91821de47732f167f8b67

+ 1 - 1
packages/host/plugin-manager/README.md

@@ -53,7 +53,7 @@ A manager failure reaches the client as a `RemoteError` with the same `code` and
 
 ### A relay, not a second manager
 
-The adapter supplies per-call profile, preset and agent readers to one `PluginManager`, relays each Remote method, and maps domain errors. It observes native entry/status events and publishes a coalesced `plugins/changed` notification after Loader settlement. Disposal cancels pending notifications and removes the listeners. Tests may replace the manager, pnpm spawner or static metadata reader.
+The adapter supplies per-call profile, preset and agent readers to one `PluginManager`, relays each Remote method, and maps domain errors. It observes native entry/status events and publishes a coalesced `plugins/changed` notification after Loader and queued profile recomposition settle. Disposal cancels pending notifications and removes the listeners. Tests may replace the manager, pnpm spawner or static metadata reader.
 
 ### Source map
 

+ 1 - 1
packages/host/plugin-manager/README.zh.md

@@ -53,7 +53,7 @@ kind: "package-reference"
 
 ### 转接,不是第二个管理器
 
-适配器为一个 `PluginManager` 提供逐次调用的 profile、预设与 agent 读取器,转接各 Remote 方法并映射领域错误。它观测原生条目与状态事件,在 Loader 完成后合并发布 `plugins/changed` 通知。销毁会取消待发通知并移除监听器。测试可替换管理器、pnpm 启动器或静态元信息读取器。
+适配器为一个 `PluginManager` 提供逐次调用的 profile、预设与 agent 读取器,转接各 Remote 方法并映射领域错误。它观测原生条目与状态事件,在 Loader 和已排队的 profile 重组完成后合并发布 `plugins/changed` 通知。销毁会取消待发通知并移除监听器。测试可替换管理器、pnpm 启动器或静态元信息读取器。
 
 ### 源码地图
 

+ 1 - 0
packages/host/plugin-manager/src/index.ts

@@ -95,6 +95,7 @@ export class PluginManagerRemote extends TypertRemoteService {
       // Loader events precede asynchronous import/update completion; publish only after settlement.
       void Promise.resolve().then(async () => {
         await loader.await()
+        await ctx.get('profileRuntime')?.whenIdle()
         if (!disposed && requested === generation) ctx.emit('plugins/changed', { reason: 'runtime' })
       }).catch((error: unknown) => { ctx.logger.warn('plugin inventory refresh failed', error) })
     }

+ 21 - 0
packages/host/plugin-manager/tests/plugin-manager.spec.ts

@@ -45,6 +45,27 @@ async function mount(manager?: PluginManager): Promise<PluginManagerRemote> {
 }
 
 describe('PluginManagerRemote', () => {
+  it('waits for queued profile recomposition before publishing refreshed ownership', async () => {
+    const ctx = new Context()
+    contexts.push(ctx)
+    await ctx.plugin(Loader)
+    ctx.loader.builtins.good = () => {}
+    const id = await ctx.loader.create({ name: 'cordis:good' })
+    await ctx.plugin(PluginManagerRemote, CONFIG)
+    let release!: () => void
+    const gate = new Promise<void>((resolve) => { release = resolve })
+    const whenIdle = vi.fn(() => gate)
+    ctx.provide('profileRuntime', { whenIdle } as never)
+    const changes: string[] = []
+    ctx.on('plugins/changed', ({ reason }) => { changes.push(reason) })
+    ctx.emit('loader/entry-init', ctx.loader.resolve(id))
+    try {
+      await vi.waitFor(() => { expect(whenIdle).toHaveBeenCalledOnce() })
+      expect(changes).toEqual([])
+    } finally { release() }
+    await vi.waitFor(() => { expect(changes).toEqual(['runtime']) })
+  })
+
   it('reports a failed refresh and cancels publication when disposed during settlement', async () => {
     const ctx = new Context()
     contexts.push(ctx)