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
+2 -1
View File
@@ -37,7 +37,7 @@ Keep this log exhaustive — every divergence from upstream must be listed.
5. **`schemastery/tsdown.config.ts` and `logger-console/tsdown.config.ts`**: ours, not upstream files — per-package build-shape overrides (dual ESM+CJS output; separate node/browser entries) for the repo-root tsdown build. They read the JS emitted under `lib/types` and then write the publish runtime entries under `lib/`. Like the regenerated tsconfigs, they are not part of the upstream sync surface.
6. **`cordis/src/fiber.ts` lifecycle hardening**: locally closes three reentrant disposal gaps. An effect's owner-list wrapper is registered before its setup body runs, so an unload begun from inside setup awaits setup and every collected cleanup; synchronous setup failure removes the wrapper and rolls back collected cleanup. Async cleanup stays owner-visible until quiescence, and Cordis's internal effect composition joins an already-running cleanup while repeated public disposer calls retain their upstream single-shot result. Effect creation is rejected while the owner is `UNLOADING` (while `PENDING` and `LOADING` remain legal), preventing cleanup-time registrations from escaping the unload snapshot. Child fibers register and receive their parent-owned disposer before `internal/plugin` publication, resolve dependency declarations added by that notification before activation, drain effects attached while pending, skip plugin execution when reentrant disposal invalidates the load epoch before its first checkpoint, and contain teardown-notification failures per observer so one callback cannot starve peers or interrupt ownership cleanup. `Fiber.update()` returns its `internal/update` waterfall result, allowing Loader callers to await a restart while preserving synchronous config validation.
7. **`cordis/src/*.ts` JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context` (class, statics, and the `Context` interface properties incl. `root`), `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork.
8. **Transactional Loader/Include config reconciliation**: Loader imports a changed entry name before disposal, awaits lifecycle settlement, and restores the previous plugin or config when candidate application fails. Loader settlement rechecks service-gated fibers after current tasks drain, rejects failures, and leaves fibers with absent dependencies pending. Group updates start candidates concurrently, await every outcome, undo changes and additions on failure, await removal, preserve programmatic option identity, and persist direct or tree-level mutations only after success. Include reads and validates detached candidate content, applies patches to a clone, reconciles the tree, and only then commits its cached content/data; direct refresh failures propagate for the caller to contain. A non-array parse is invalid, patches re-apply on every file or Include-config update, an omitted patch list clears the overlay, and initial content falls back to `initial` only on `ENOENT`. Covered by `packages/boot/app-boot/tests/config-reload.spec.ts` and `packages/host/webserver/tests/webserver.spec.ts`.
8. **Transactional Loader/Include config reconciliation**: Loader imports a changed entry name before disposal, awaits lifecycle settlement, and restores the previous plugin or config when candidate application fails. Loader settlement rechecks service-gated fibers after current tasks drain, rejects failures, and leaves fibers with absent dependencies pending. Group updates start candidates concurrently, await every outcome, contain sibling-start failures after their owning tree is disposed, undo changes and additions on live-update failure, await removal, preserve programmatic option identity, and persist direct or tree-level mutations only after success. Include reads and validates detached candidate content, applies patches to a clone, reconciles the tree, and only then commits its cached content/data; direct refresh failures propagate for the caller to contain. A non-array parse is invalid, patches re-apply on every file or Include-config update, an omitted patch list clears the overlay, and initial content falls back to `initial` only on `ENOENT`. Covered by `packages/boot/app-boot/tests/config-reload.spec.ts` and `packages/host/webserver/tests/webserver.spec.ts`.
9. **`hmr/src/index.ts` exact config watching**: `registerConfig()` watches one absolute config path outside module roots, including a path under missing parents, serializes and coalesces refreshes, and returns an async disposer that closes the watcher and drains active work. Module watches realpath their existing base directory, attach change listeners before declaring the service ready, and use that spelling for Node module-cache identity; exact config watches realpath the deepest existing watch ancestor and restore the missing suffix. Those native paths prevent Windows short-name aliases from colliding with long-form libuv event paths while exact-config callbacks keep the requested filename. Refresh failures are normalized to `Error`, logged, and broadcast through the parallel `hmr/config-update-failed` event; observer failures are contained. Config-file changes discovered by the ordinary HMR watcher use the same serialized path. Covered by `packages/boot/app-boot/tests/hmr-config.spec.ts`.
10. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`; its built ESM/CJS entries retain explicit `.mjs`/`.cjs` extensions.
11. **`include/src/index.ts` patch-semantics export**: extracted the private `applyPatches` body into the exported pure function `applyEntryPatches(data, patches, warn)` (the method delegates to it) and exported the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes and prints exactly what the include would mount without booting a tree. Behavior-preserving for mounting; the extraction exists because config tooling must never reimplement (and drift from) the patch algorithm. `applyEntryPatches` also indexes each `insert`ed entry as it is added, so a later patch in the same list can configure or disable a row an earlier patch inserted; upstream built the id index once before the patch loop, leaving inserted rows silently unpatchable. That matters because `dsh` composes an empty profile root with each bundle's patch layer, the profile's and the home-level `cordis.patch.yml`, and any `--patch` overlays as sibling patch lists at one include level — patches never cross an include boundary, so surface-only rows would otherwise be unreachable from user config. Covered by `packages/boot/app-boot/tests/config-reload.spec.ts`.
@@ -45,6 +45,7 @@ Keep this log exhaustive — every divergence from upstream must be listed.
13. **`include/src/index.ts` `writeTask` type**: widened the optional `writeTask?: NodeJS.Timeout` property to `NodeJS.Timeout | undefined` — the debounced writer assigns `undefined` on flush, which `exactOptionalPropertyTypes` rejects on a plain optional. Type-only; no behavior change.
14. **`include/src/index.ts` durable debounced writes**: serialized and tracked config-file writes, retried transient `EACCES`/`EBUSY`/`EPERM` rename failures with a bounded backoff, observed asynchronous timer rejections, and drained the latest write during Include teardown. Windows can briefly retain a destination handle after a Loader child disposes; the upstream fire-and-forget rename escaped as an unhandled rejection and could lose the persisted `disabled` state. A terminal failure is logged by the asynchronous writer and remains on the queue so `Include.stop()` rethrows it instead of silently declaring persistence complete; Cordis's ordinary fiber teardown retains its separate error-containment contract. Covered by `packages/host/directory-picker-auto/tests/loader-composition.spec.ts` with injected transient and terminal rename failures.
15. **`@deepseek-ai` rescope**: every vendored manifest `name`, every internal dependency entry among the vendored set, and every module specifier that reaches them use the scoped names in the manifest table's `npm name` column. Directory names, version numbers, and dependency ranges are unchanged, and no upstream runtime identifier is renamed — `Symbol.for('schemastery')` and Schemastery's `vendor:` metadata field keep their upstream values. Re-apply with `pnpm run rescope-vendor --apply` after a sync; the table's two name columns are the mapping, restated for consumers in [docs/rescope.md](../docs/rescope.md).
16. **Lazy Loader config resolution across `cordis/src/{events,fiber}.ts`, `loader/src/{index,config/entry}.ts`, `include/src/index.ts`, and `hmr/src/index.ts`**: ports [cordiverse/cordis#41](https://github.com/cordiverse/cordis/pull/41), retaining raw fiber config and resolving it through `internal/config` only after declared injections are active. Provider replacement re-resolves the raw expression, pending updates retain it, and HMR transfers it. Resolution applies only to the entry root, so child plugins mounted by a row keep caller-owned config identity. Include adds a static entry-config resolver so its own options interpolate while nested row `!!js` nodes remain deferred. Deferred failures retain the owning row diagnostic, and tree teardown does not persist failure-driven self-disposal. Covered by `packages/boot/app-boot/tests/{app-boot,user-patches}.spec.ts`, `packages/boot/cmdline/tests/cmdline.spec.ts`, `apps/cli/tests/web-agent-presets.e2e.ts`, and the built custom-profile cases in `apps/cli/tests/built-bin.e2e.ts`.
## Sync procedure
+6
View File
@@ -331,6 +331,12 @@ export interface Events {
'internal/plugin'(fiber: Fiber): void
/** A fiber changed lifecycle state; receives the fiber and its previous state. */
'internal/status'(fiber: Fiber, oldValue: FiberState): void
/**
* Resolve raw plugin config after the fiber's injections become active.
* @param config - the raw config for this activation.
* @mode waterfall
*/
'internal/config'(this: Fiber, config: any, next: () => any): any
/** Interception hook for a service binding (no core producer). */
'internal/service'(this: Context, name: string, value: any): void
/** Waterfall: a fiber config update is being applied; skip `next()` to veto. */
+21 -10
View File
@@ -188,6 +188,8 @@ export class Fiber {
public readonly ctx: Context
/** The validated plugin config (updated by `update()`). */
public config: any
/** The raw plugin config, re-resolved before each activation. */
public _config: any
/** Current lifecycle state; transitions emit `internal/status`. */
public state = FiberState.PENDING
/** Dispose this fiber: unload the plugin, then settle once cleanup finished. */
@@ -224,6 +226,7 @@ export class Fiber {
public runtime: Plugin.Runtime | null,
getOuterStack: () => string[],
) {
this._config = config
const collect = (dispose: Disposable) => {
this._disposables.push(dispose)
}
@@ -259,16 +262,8 @@ export class Fiber {
collect,
}
let shouldRefresh = false
this.dispose = parent.fiber.effect(() => {
const remove = runtime.fibers.push(this)
try {
this.config = resolveConfig(runtime, config)
shouldRefresh = true
} catch (error) {
this.ctx.logger.error(error)
this._error = error
}
return async () => {
this.uid = null
emitPluginDisposed(this.context, this)
@@ -320,7 +315,7 @@ export class Fiber {
for (const name of Object.keys(this.inject)) {
this._checkImpl(name)
}
if (shouldRefresh) this._refresh()
this._refresh()
}
} else {
this.uid = 0
@@ -643,6 +638,11 @@ export class Fiber {
})
}
private _resolveConfig(config: any) {
config = this.context.waterfall(this, 'internal/config', config, () => config)
return this.runtime ? resolveConfig(this.runtime, config) : config
}
private async _reload() {
this.store = { ...this._store }
const oldEpoch = this._runner.epoch
@@ -652,7 +652,9 @@ export class Fiber {
// the load. Do not run plugin code for a stale epoch; the state update
// below will drain any effects collected while the fiber was PENDING.
if (this._runner.epoch === oldEpoch) {
this.config = this._resolveConfig(this._config)
await this._execute(this._runner)
this._error = undefined
}
} catch (reason) {
// impl guarantees that the error is non-null (?)
@@ -733,7 +735,16 @@ export class Fiber {
*/
update(config: any, noSave = false) {
this.assertActive()
config = resolveConfig(this.runtime!, config)
this._config = config
if (this.state !== FiberState.ACTIVE) {
// Config resolution may access injected services, so defer it until the
// fiber can activate.
this._error = undefined
this._setEpoch(INACTIVE)
this._refresh()
return
}
config = this._resolveConfig(config)
return this.context.waterfall(this, 'internal/update', config, noSave, () => {
this.config = config
this._error = undefined
+1 -1
View File
@@ -502,7 +502,7 @@ class Hmr extends Service {
const reload = (plugin: any, runtime: Plugin.Runtime) => {
if (!runtime) return
for (const oldFiber of runtime.fibers) {
const fiber = oldFiber.parent.registry.plugin(plugin, oldFiber.config, this.getOuterStack)
const fiber = oldFiber.parent.registry.plugin(plugin, oldFiber._config, this.getOuterStack)
fiber.entry = oldFiber.entry
if (fiber.entry) fiber.entry.fiber = fiber
}
+16 -1
View File
@@ -1,4 +1,4 @@
import { EntryTree, isJsExpr, type EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
import { EntryConfigResolver, EntryTree, interpolate, isJsExpr, type EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
import { Context, Service } from '@deepseek-ai/cordis'
import { extname } from 'node:path'
import { access, constants, readFile, rename, writeFile } from 'node:fs/promises'
@@ -174,6 +174,21 @@ export namespace Include {
export class Include extends EntryTree {
static inject = ['loader']
/**
* Resolve Include's own options while preserving nested entry expressions.
* @param ctx - the Include plugin context.
* @param config - the raw Include config.
* @returns resolved Include options with `initial` and `patches` untouched.
*/
static [EntryConfigResolver](ctx: Context, config: Include.Config): Include.Config {
const { initial, patches, ...own } = config
return {
...interpolate(ctx, own),
...(initial === undefined ? {} : { initial }),
...(patches === undefined ? {} : { patches }),
}
}
public filename: string
private type?: string
private readonly: boolean
+20 -15
View File
@@ -3,7 +3,13 @@ import { deepEqual, isNullable } from '@deepseek-ai/cosmokit'
import { Loader } from '../index.ts'
import { EntryGroup } from './group.ts'
import { EntryTree } from './tree.ts'
import { evaluate, interpolate } from './utils.ts'
import { evaluate } from './utils.ts'
/** Static plugin hook for resolving a container config while preserving nested entry configs. */
export const EntryConfigResolver = Symbol.for('cordis.loader.entry-config-resolver')
/** Resolver installed at {@link EntryConfigResolver}. */
export type EntryConfigResolver = (ctx: Context, config: any) => any
/** Serialized plugin entry options stored in loader config files. */
export interface EntryOptions {
@@ -101,17 +107,12 @@ export class Entry {
return evaluate(this.ctx, expr)
}
_resolveConfig(plugin: any): [any, any?] {
if (plugin[EntryGroup.key]) return this.options.config
return interpolate(this.ctx, this.options.config)
}
private async _patchContext(diff: string[]) {
await this.context.waterfall('loader/patch-context', this, async () => {
Object.setPrototypeOf(this.ctx, this.parent.ctx)
if (this.fiber?.uid && (diff.includes('config') || this.options.group)) {
await this.fiber.update(this._resolveConfig(this.fiber.runtime!.callback), true)
await this.fiber.update(this.options.config, true)
}
})
}
@@ -258,7 +259,15 @@ export class Entry {
this._initTask = undefined
if (!this.loader.getTasks().length) this.ctx.reflect.notify(['loader'])
}
await this.fiber?.await()
await this._await()
}
async _await() {
try {
await this.fiber?.await()
} catch (error) {
throw updateError('apply', this.options, error)
}
}
private async _init() {
@@ -278,17 +287,13 @@ export class Entry {
private async _start(plugin: any) {
let fiber: Fiber | undefined
try {
fiber = await this._create(plugin)
await this._patchContext([])
this.loader.showLog(this, 'apply')
fiber = this.fiber = this.ctx.registry.plugin(plugin, this.options.config, this.getOuterStack)
await fiber.await()
} catch (error) {
await this._dispose(fiber)
throw error
}
}
private async _create(plugin: any): Promise<Fiber> {
await this._patchContext([])
this.loader.showLog(this, 'apply')
return this.fiber = this.ctx.registry.plugin(plugin, this._resolveConfig(plugin), this.getOuterStack)
}
}
+4
View File
@@ -69,6 +69,10 @@ export class EntryGroup {
try {
const outcomes = await Promise.allSettled(config.map(options => this.create(options)))
// Disposal owns termination: sibling starts can still be settling after
// the containing tree has gone away, but their failures no longer
// describe a live update to roll back.
if (this.ctx.fiber.uid === null) return
const failures = outcomes
.filter((outcome): outcome is PromiseRejectedResult => outcome.status === 'rejected')
.map(outcome => outcome.reason)
+1 -1
View File
@@ -51,7 +51,7 @@ export abstract class EntryTree {
continue
}
const outcomes = await Promise.allSettled(
[...this.entries()].map(entry => entry.fiber?.await()),
[...this.entries()].map(entry => entry._await()),
)
const failures = outcomes
.filter((outcome): outcome is PromiseRejectedResult => outcome.status === 'rejected')
+20 -3
View File
@@ -1,9 +1,16 @@
import { Context, Inject, Service } from '@deepseek-ai/cordis'
import { Context, FiberState, Inject, Service, type Fiber } from '@deepseek-ai/cordis'
import { defineProperty, isNullable, type Dict } from '@deepseek-ai/cosmokit'
import { ModuleLoader } from './internal.ts'
import { Entry, type EntryOptions } from './config/entry.ts'
import {
Entry,
EntryConfigResolver,
type EntryConfigResolver as ConfigResolver,
type EntryOptions,
} from './config/entry.ts'
import { EntryGroup } from './config/group.ts'
import isolate from './config/isolate.ts'
import { EntryTree } from './config/tree.ts'
import { interpolate } from './config/utils.ts'
/** Re-export entry node APIs. */
export * from './config/entry.ts'
@@ -87,6 +94,15 @@ export class Loader extends EntryTree {
ctx.reflect.provide('loader', this, this[Service.check])
ctx.on('internal/config', function (this: Fiber, _config, next) {
const config = next()
if (!this.entry || this.parent.fiber?.entry === this.entry) return config
const plugin = this.runtime?.callback as Record<PropertyKey, unknown> | undefined
if (plugin?.[EntryGroup.key]) return config
const resolve = plugin?.[EntryConfigResolver] as ConfigResolver | undefined
return resolve ? resolve(this.ctx, config) : interpolate(this.ctx, config)
}, { global: true })
ctx.on('internal/update', async function (config, noSave, next) {
if (!this.entry || noSave || this.parent.fiber?.entry === this.entry) return next()
await next()
@@ -127,7 +143,8 @@ export class Loader extends EntryTree {
if (!ctx.registry.has(fiber.runtime!.callback)) return
// case 5: the entry's tree is being disposed
if (!fiber.entry.parent.tree.ctx.fiber.uid) return
const treeOwner = fiber.entry.parent.tree.ctx.fiber
if (!treeOwner.uid || treeOwner.state === FiberState.UNLOADING) return
// case 6: Loader is replacing or removing this exact fiber
if (fiber.entry._disposing) return