refactor(loader): resolve config after injected services

This commit is contained in:
Turtle
2026-08-07 17:27:38 +08:00
parent b692f38506
commit 7e3a82eacc
38 changed files with 404 additions and 306 deletions

View File

@@ -199,7 +199,7 @@ export function loadLayeredEnv(
const bootstrapIncludes = new WeakMap<Context, Entry>()
// The include's YAML dialect (`!!js` scalars become expression nodes the
// Loader interpolates against each entry's context at mount time), imported
// Loader interpolates against each entry's injection-ready context), imported
// from the include itself so patch parsing and config dumping can never drift
// from what the include mounts. User patch layers share it so they may
// reference `process.env`.
@@ -527,31 +527,6 @@ export async function mountRootInclude(
return entry
}
/**
* Re-apply the root include's patch list on a booted tree, and wait for the
* result to settle.
*
* This is how a boot mounts its composition in phases: an app's startup row
* resolves what the rest of the tree reads (`!!js ctx.get('webStartup')?.port`),
* and a row's config expressions are evaluated when the include applies them —
* so the rest of the composition must be applied after the startup rows are
* active, not before.
* @param ctx - the booted context whose root include to re-apply.
* @param patches - the full patch list for this generation.
* @returns nothing once the new generation has settled; a disposed tree is a no-op.
* @throws when the tree was booted without the root include.
*/
export async function applyRootPatches(ctx: Context, patches: readonly PatchOptions[]): Promise<void> {
const entry = bootstrapIncludes.get(ctx)
if (entry === undefined) throw new Error('dsh: applying root patches requires the root Include entry')
// A surface can dispose the whole tree while a startup row is still parsing
// (`--help`, or an early SIGTERM); there is then nothing left to mount.
if (ctx.get('loader') === undefined) return
const { patches: _previous, ...includeConfig } = entry.options.config as Include.Config
await entry.update({ config: { ...includeConfig, patches: [...patches] } })
await ctx.get('loader')?.await()
}
/**
* The slice of `process` {@link installFailLoud} needs — injectable so tests
* exercise the handler without registering on (or exiting) the real process.

View File

@@ -1,4 +1,4 @@
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve, sep } from 'node:path'
import { pathToFileURL } from 'node:url'
@@ -699,7 +699,18 @@ describe('boot', () => {
'}',
'',
].join('\n'))
writeFileSync(join(dir, 'cordis.yml'), '- id: exiting\n name: ./exiting.mjs\n')
writeFileSync(join(dir, 'delayed.mjs'), [
'await new Promise(resolve => setTimeout(resolve, 10))',
'export function apply() {}',
'',
].join('\n'))
writeFileSync(join(dir, 'cordis.yml'), [
'- id: exiting',
' name: ./exiting.mjs',
'- id: delayed',
' name: ./delayed.mjs',
'',
].join('\n'))
const ctx = await boot(NAME, join(dir, 'cordis.yml'))
expect(ctx.get('loader')).toBeUndefined()
})
@@ -712,6 +723,25 @@ describe('boot', () => {
)
})
it('labels a deferred config failure with its row and leaves the source file unchanged', async () => {
const dir = tmp()
const configPath = join(dir, 'cordis.yml')
const config = [
'- id: invalid-config',
' name: ./noop.mjs',
' config:',
' value: !!js "JSON.parse(\'invalid\')"',
'',
].join('\n')
writeFileSync(join(dir, 'noop.mjs'), 'export function apply() {}\n')
writeFileSync(configPath, config)
await expect(boot(NAME, configPath)).rejects.toThrow(
'failed to apply loader entry invalid-config (./noop.mjs)',
)
expect(readFileSync(configPath, 'utf8')).toBe(config)
})
it('appends the deepest cause with its original stack to the load failure', async () => {
const dir = tmp()
writeFileSync(join(dir, 'failing.mjs'), [

View File

@@ -11,11 +11,10 @@ import { pathToFileURL } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import Hmr from '@deepseek-ai/cordis-plugin-hmr'
import Include, { type PatchOptions } from '@deepseek-ai/cordis-plugin-include'
import Loader from '@deepseek-ai/cordis-plugin-loader'
import Timer from '@deepseek-ai/cordis-plugin-timer'
import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
import {
applyRootPatches,
boot,
loadOptionalPatches,
PROFILE_PATCH_FILENAME,
@@ -110,61 +109,81 @@ function entryConfig(ctx: Context, id: string): unknown {
return [...ctx.loader.entries()].find(entry => entry.options.id === id)?.options.config
}
describe('applyRootPatches', () => {
it('mounts a later phase whose rows read what the first phase provided', async () => {
// The phased boot in one test: a row's `!!js` config is evaluated when the
// include applies it, so a value an earlier phase provided is what a later
// phase's rows read.
describe('Loader config interpolation', () => {
it("resolves Include's own !!js options", async () => {
const dir = tmp()
writeFileSync(join(dir, 'provider.mjs'), [
'export const name = "provider"',
'export function apply(ctx) { ctx.provide("phaseOne", { value: "resolved" }) }',
'',
].join('\n'))
writeFileSync(join(dir, 'reader.mjs'), [
'export const name = "reader"',
'export const inject = ["phaseOne"]',
'export function apply() {}',
'',
].join('\n'))
writeFileSync(join(dir, 'cordis.yml'), '[]\n')
const composition: PatchOptions[] = [{
insert: [
{ id: 'provider', name: './provider.mjs' },
{
id: 'reader',
name: './reader.mjs',
inject: ['phaseOne'],
config: { value: { __jsExpr: "ctx.get('phaseOne')?.value ?? 'fallback'" } },
},
],
}]
const ctx = await boot(NAME, join(dir, 'cordis.yml'), [
...structuredClone(composition),
{ id: 'reader', disabled: true },
])
writeFileSync(join(dir, 'noop.mjs'), 'export function apply() {}\n')
writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n')
const ctx = new Context()
await ctx.plugin(Loader)
ctx.loader.builtins.include = Include
ctx.provide('includePath', pathToFileURL(join(dir, 'cordis.yml')).href)
try {
// Phase one leaves the reader disabled, so the plugin never ran.
const reader = [...ctx.loader.entries()].find(entry => entry.options.id === 'reader')
expect(reader?.fiber).toBeUndefined()
await applyRootPatches(ctx, structuredClone(composition))
// Phase two evaluates its config expression against the provided value.
expect(entryConfig(ctx, 'reader')).toEqual({ value: 'resolved' })
await ctx.loader.create({
name: 'cordis:include',
config: { path: { __jsExpr: "ctx.get('includePath')" } },
})
await ctx.loader.await()
expect([...ctx.loader.entries()].some(entry => entry.options.id === 'noop')).toBe(true)
} finally {
await ctx.fiber.dispose()
}
})
it('does nothing on a tree that was already disposed', async () => {
it('waits for row injections before resolving !!js and resolves again after provider replacement', async () => {
const dir = tmp()
const ctx = await boot(NAME, writeTree(dir))
await ctx.fiber.dispose()
await expect(applyRootPatches(ctx, [])).resolves.toBeUndefined()
})
writeFileSync(join(dir, 'provider.mjs'), [
'export const name = "provider"',
'export function apply(ctx, config) { ctx.provide("phaseOne", config) }',
'',
].join('\n'))
writeFileSync(join(dir, 'reader.mjs'), [
'export const name = "reader"',
'export const inject = ["phaseOne"]',
'export function apply(ctx, config) { ctx.provide("readerResult", config) }',
'',
].join('\n'))
writeFileSync(join(dir, 'cordis.yml'), '[]\n')
const composition: PatchOptions[] = [{
insert: [
{
// Consumer-first order proves interpolation follows injection
// readiness rather than YAML position.
id: 'reader',
name: './reader.mjs',
inject: ['phaseOne'],
config: { value: { __jsExpr: 'ctx.phaseOne.fail ? (() => { throw new Error("rejected provider") })() : ctx.phaseOne.value' } },
},
{ id: 'provider', name: './provider.mjs', config: { value: 'first' } },
],
}]
const ctx = await boot(NAME, join(dir, 'cordis.yml'), composition)
try {
expect(ctx.get('readerResult')).toEqual({ value: 'first' })
const provider = [...ctx.loader.entries()].find(entry => entry.options.id === 'provider')
expect(provider).toBeDefined()
await provider?.update({ disabled: true })
await ctx.loader.await()
expect(ctx.get('readerResult')).toBeUndefined()
await provider?.update({ config: { value: 'second' } })
await provider?.update({ disabled: false })
await ctx.loader.await()
expect(ctx.get('readerResult')).toEqual({ value: 'second' })
it('fails loud when the tree was booted without the root include', async () => {
const ctx = new Context()
await expect(applyRootPatches(ctx, [])).rejects.toThrow('requires the root Include entry')
await provider?.update({ disabled: true })
await provider?.update({ config: { fail: true } })
await provider?.update({ disabled: false })
await expect(ctx.loader.await()).rejects.toThrow('rejected provider')
expect(ctx.get('readerResult')).toBeUndefined()
await provider?.update({ disabled: true })
await provider?.update({ config: { value: 'recovered' } })
await provider?.update({ disabled: false })
await ctx.loader.await()
expect(ctx.get('readerResult')).toEqual({ value: 'recovered' })
} finally {
await ctx.fiber.dispose()
}
})
})

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/ui/cmdline/README.md
README.md: 242ba184507d88c50e0dcf2ada0a0f7714d87e28
README.zh.md: 76a76ad6090fcc28d50f9ea2a48d4e2581e361f2
README.md: cd3350678d38802c18ff26dd47214b5019b8c404
README.zh.md: ad726cd0726cbbd22736321a8c52b04e23d557fa

View File

@@ -35,7 +35,7 @@ The Loader-row injection is also its discovery declaration, so no bundle manifes
inject: [cmdlineArgs]
```
The launcher finds active rows with that injection in the composed tree and mounts them before everything else.
The launcher uses that injection only to reject arguments for a composition with no command-line owner. Loader mounts the composition once and holds each row until its own injections are active.
Every row the app configures from flags then reads what the startup row resolved, naming the key it takes and the value it falls back to:
@@ -44,19 +44,19 @@ Every row the app configures from flags then reads what the startup row resolved
name: '@deepseek-ai/dsh-host-webserver'
inject: [webStartup]
config:
host: !!js ctx.get('webStartup')?.host ?? '127.0.0.1'
port: !!js ctx.get('webStartup')?.port ?? 3080
host: !!js ctx.webStartup.host ?? '127.0.0.1'
port: !!js ctx.webStartup.port ?? 3080
```
`runStartup` parses the arguments, asks `plan` for the values, and provides them as the service. On `--help`, `--version`, a parse error, or a `program.error(...)` from the plan, it writes commander's text and requests exit — nothing is provided, and the rest of the composition never mounts.
`runStartup` parses the arguments, asks `plan` for the values, and provides them as the service. On `--help`, `--version`, a parse error, or a `program.error(...)` from the plan, it writes commander's text and requests exit — nothing is provided, so rows that depend on the startup service never activate.
`plan` receives the options of every row that injects the service, for a value that has to take the composition into account: the `/api` fence authorities are the shipped example, since a bind the composition configured decides whether LAN literals are derived at all.
`plan` receives the startup context and the options of every row that injects the service, for a value that has to take the composition into account. Include still holds nested expressions raw at this point, so a plan that needs a composed fallback can interpolate the relevant row config against the pre-service startup context; the `/api` fence authorities are the shipped example.
### Why the boot has phases
### How injection orders config
A row's config expressions are evaluated when the include applies it, and a strict `ctx.get` only answers for a service whose providing fiber is already active. A composition therefore mounts in two passes: active `cmdlineArgs` consumers alone, then everything else. The rows of the later pass read live values, a `--help` exits before the second pass exists, and a user editing a live patch file re-runs that pass against services that are still up, so a flag cannot be silently reset.
Loader defers a row's `!!js` interpolation until that row's declared injections are active, then evaluates against the row's plugin context. The example above can therefore read `ctx.webStartup` directly: Cordis has already populated that injected service before Loader asks for `webserver`'s config. Include trees preserve nested expression nodes until each target row reaches this point. Provider replacement and live patch reload repeat interpolation against the current injected services, so a launch flag cannot be silently reset.
`enableRow(ctx, id)` turns on a row a bundle ships disabled because only some invocations want it (`dsh web --dev` and its client-plugin reload chain). Call it from a row that mounts beside the one being enabled, not from the startup row: a row enabled in the first pass would wait for services the second pass has yet to mount.
`enableRow(ctx, id)` turns on a row a bundle ships disabled because only some invocations want it (`dsh web --dev` and its client-plugin reload chain). Loader applies the enabled row's ordinary injection ordering.
### One command line, one owner

View File

@@ -35,7 +35,7 @@ Loader 行的注入同时也是发现声明,因此无需组合包 manifest 字
inject: [cmdlineArgs]
```
启动器在组合结果中找出带有该注入的活跃行,并先于其他一切挂载它们
启动器只用该注入来拒绝那些没有命令行所有者却带有应用参数的组合。Loader 只挂载一次整套组合,并让每一行等待自身的注入激活
应用用 flag 配置的每一行随后读取启动行解析出的取值,各自点名自己取用的键,以及回退时使用的值:
@@ -44,19 +44,19 @@ Loader 行的注入同时也是发现声明,因此无需组合包 manifest 字
name: '@deepseek-ai/dsh-host-webserver'
inject: [webStartup]
config:
host: !!js ctx.get('webStartup')?.host ?? '127.0.0.1'
port: !!js ctx.get('webStartup')?.port ?? 3080
host: !!js ctx.webStartup.host ?? '127.0.0.1'
port: !!js ctx.webStartup.port ?? 3080
```
`runStartup` 解析参数,向 `plan` 索取取值,并把它们作为服务提供出去。遇到 `--help`、`--version`、解析错误,或 `plan` 发出的 `program.error(...)` 时,它输出 commander 的文本并请求退出:什么也不会被提供,组合的其余部分也从不挂载
`runStartup` 解析参数,向 `plan` 索取取值,并把它们作为服务提供出去。遇到 `--help`、`--version`、解析错误,或 `plan` 发出的 `program.error(...)` 时,它输出 commander 的文本并请求退出:什么也不会被提供,因此依赖启动服务的行不会激活
`plan` 收到的是所有注入该服务的行的选项,用于那些必须顾及组合本身的取值:随附的例子是 `/api` 栅栏 authority因为组合所配置的 bind 决定了是否要派生 LAN 字面量
`plan` 收到启动上下文,以及所有注入该服务的行的选项,用于那些必须顾及组合本身的取值。此时 Include 仍保留着嵌套表达式的原始形态,因此需要组合回退值的 plan 可以基于服务提供前的启动上下文插值相关行配置;随附的例子是 `/api` 栅栏 authority
### 为什么 boot 分阶段
### 注入如何排列配置求值
行的配置表达式在 include 施加该行时求值,而严格的 `ctx.get` 只对提供方 fiber 已经 active 的服务作答。因此一套组合分两趟挂载:先是各个活跃的 `cmdlineArgs` 消费方,然后才是其余部分。后一趟的行读到的是活的取值,`--help` 在第二趟存在之前就退出,而用户编辑一个活动 patch 文件时,这一趟会针对仍然在线的服务重新运行,因此 flag 不会被悄悄重置。
Loader 会把一行的 `!!js` 插值推迟到该行声明的注入全部激活之后,再基于该行的插件上下文求值。所以上例可以直接读取 `ctx.webStartup`Loader 索取 `webserver` 的配置之前Cordis 已经填入了这个注入服务。Include 树会保留嵌套表达式节点,直到各个目标行到达这一时点。提供方替换与活动 patch 重载都会针对当前注入服务重新插值,因此启动 flag 不会被悄悄重置。
`enableRow(ctx, id)` 打开某个组合包以禁用状态交付、只有部分调用才需要的行(`dsh web --dev` 及其客户端插件重载链路)。要从与被启用行同一趟挂载的行里调用它,而不是从启动行:在第一趟被启用的行会去等待第二趟才挂载的服务
`enableRow(ctx, id)` 打开某个组合包以禁用状态交付、只有部分调用才需要的行(`dsh web --dev` 及其客户端插件重载链路)。Loader 会对启用后的行应用普通的注入顺序
### 一条命令行,一个所有者