fix(boot): expose profile recomposition settlement to observers

This commit is contained in:
Yichen Jiang committed 2026-09-11 16:11:46 +08:00
1 parent 1144cf1e8e
commit 8c33df5d8e
9 files changed
+44 -7

No files matched your search

+2 -2
View File
@@ -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
+8
View File
@@ -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<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
View File
@@ -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<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
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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()` 后,再发布包含组合归属的刷新视图。
### 预览生效配置
@@ -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<void> {
return this.queue
}
}
/** The walk behind {@link ProfileRuntime.userDisables}: the entry, then each group holding it, outward. */
@@ -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 () => {
@@ -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<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.',
},
],
},
{