fix(cordis): make config reload transactional

This commit is contained in:
Tianyi Cui
2026-07-30 03:11:16 +08:00
parent f8eb6a9b84
commit 195f7fa9af
33 changed files with 1020 additions and 268 deletions

View File

@@ -21,6 +21,11 @@ export interface EntryOptions {
inject?: Inject | null
}
function updateError(stage: 'import' | 'dispose' | 'apply' | 'rollback', options: EntryOptions, cause: unknown) {
const detail = cause instanceof Error ? cause.message : String(cause)
return new Error(`failed to ${stage} loader entry ${options.id} (${options.name}): ${detail}`, { cause })
}
function takeEntries(object: {}, keys: string[]) {
const result: [string, any][] = []
for (const key of keys) {
@@ -38,6 +43,11 @@ function sortKeys<T extends {}>(object: T, prepend = ['id', 'name'], append = ['
return Object.assign(object, Object.fromEntries([...part1, ...rest, ...part2]))
}
function replaceKeys<T extends {}>(target: T, source: T): T {
for (const key of Object.keys(target)) Reflect.deleteProperty(target, key)
return Object.assign(target, source)
}
/** One configured plugin node inside an `EntryTree`. */
export class Entry {
static readonly key = Symbol.for('cordis.entry')
@@ -51,6 +61,7 @@ export class Entry {
public subtree?: EntryTree
_initTask?: Promise<void>
_disposing = 0
constructor(public loader: Loader) {
this.ctx = loader.ctx.extend({ [Entry.key]: this })
@@ -71,13 +82,18 @@ export class Entry {
/** True when this entry or any owning parent entry is disabled. */
get disabled() {
return this._disabled(this.options)
}
private _disabled(options: EntryOptions) {
// group is always enabled
if (this.options.group) return false
let entry: Entry | undefined = this
do {
if (options.group) return false
if (options.disabled) return true
let entry = this.parent.ctx.fiber.entry
while (entry) {
if (entry.options.disabled) return true
entry = entry.parent.ctx.fiber.entry
} while (entry)
}
return false
}
@@ -90,12 +106,12 @@ export class Entry {
return interpolate(this.ctx, this.options.config)
}
private _patchContext(diff: string[]) {
this.context.waterfall('loader/patch-context', this, () => {
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)) {
this.fiber.update(this._resolveConfig(this.fiber.runtime!.callback), true)
await this.fiber.update(this._resolveConfig(this.fiber.runtime!.callback), true)
}
})
}
@@ -106,41 +122,122 @@ export class Entry {
await this.init()
}
async _dispose(fiber = this.fiber) {
if (!fiber) return
if (this.fiber === fiber) this.fiber = undefined
this._disposing += 1
try {
await fiber.dispose()
} finally {
this._disposing -= 1
}
}
/** Merge new options, restart as needed, and persist through the parent tree. */
async update(options: Partial<EntryOptions>, create = false, force = false) {
const legacy = { ...this.options }
// step 1: update options
if (create) {
this.options = options as EntryOptions
} else {
const previousOptions = this.options
const legacy = { ...previousOptions }
const candidate = create ? options as EntryOptions : { ...previousOptions }
if (!create) {
for (const [key, value] of Object.entries(options)) {
if (isNullable(value)) {
delete this.options[key]
delete candidate[key as keyof EntryOptions]
} else {
this.options[key] = value
candidate[key as keyof EntryOptions] = value as never
}
}
}
sortKeys(this.options)
sortKeys(candidate)
// step 2: execute
if (this.disabled) {
this.fiber?.dispose()
const diff = Object
.keys({ ...candidate, ...legacy })
.filter(key => !deepEqual(candidate[key as keyof EntryOptions], legacy[key as keyof EntryOptions]))
if (!diff.length && !force) return
const commit = () => {
if (create) return
this.options = replaceKeys(previousOptions, candidate)
}
const previous = this.fiber
if (!previous?.uid) {
this.fiber = undefined
this.options = candidate
try {
if (!this._disabled(candidate)) await this.init()
} catch (error) {
this.options = previousOptions
throw error
}
commit()
return
}
// step 3: check if options are changed
if (this.fiber?.uid) {
const diff = Object
.keys({ ...this.options, ...legacy })
.filter(key => !deepEqual(this.options[key], legacy[key]))
if (!diff.length && !force) return
if (this._disabled(candidate)) {
this.options = candidate
try {
await this._dispose(previous)
} catch (error) {
this.options = previousOptions
throw updateError('dispose', candidate, error)
}
commit()
this.context.emit('loader/partial-dispose', this, legacy, true)
this._patchContext(diff)
} else {
await this.init()
return
}
const replace = diff.some(key => key === 'name' || key === 'inject' || key === 'group')
if (!replace) {
this.options = candidate
try {
await this._patchContext(diff)
} catch (error) {
this.options = previousOptions
try {
await this._patchContext(diff)
} catch (rollbackError) {
throw updateError('rollback', legacy, new AggregateError([error, rollbackError]))
}
this.context.emit('loader/partial-dispose', this, candidate, true)
throw updateError('apply', candidate, error)
}
commit()
this.context.emit('loader/partial-dispose', this, legacy, true)
return
}
let plugin: any
try {
plugin = diff.includes('name')
? this.loader.unwrapExports(await this.parent.tree.import(candidate.name, this.getOuterStack))
: previous.runtime!.callback
} catch (error) {
throw updateError('import', candidate, error)
}
const previousPlugin = previous.runtime!.callback
this.options = candidate
try {
await this._dispose(previous)
} catch (error) {
this.options = previousOptions
throw updateError('dispose', candidate, error)
}
try {
await this._start(plugin)
} catch (error) {
this.options = previousOptions
try {
await this._start(previousPlugin)
} catch (rollbackError) {
throw updateError('rollback', legacy, new AggregateError([error, rollbackError]))
}
this.context.emit('loader/partial-dispose', this, candidate, true)
throw updateError('apply', candidate, error)
}
commit()
this.context.emit('loader/partial-dispose', this, legacy, true)
}
getOuterStack = () => {
@@ -159,26 +256,39 @@ export class Entry {
await (this._initTask ??= this._init())
} finally {
this._initTask = undefined
if (!this.loader.getTasks().length) this.ctx.reflect.notify(['loader'])
}
this.fiber?.await().finally(() => {
if (this.loader.getTasks().length) return
this.ctx.reflect.notify(['loader'])
})
await this.fiber?.await()
}
private async _init() {
let exports: any
let plugin: any
try {
exports = await this.parent.tree.import(this.options.name, this.getOuterStack)
plugin = this.loader.unwrapExports(await this.parent.tree.import(this.options.name, this.getOuterStack))
} catch (error) {
this.ctx.logger.error(error)
return
} finally {
this._initTask = undefined
throw updateError('import', this.options, error)
}
const plugin = this.loader.unwrapExports(exports)
this._patchContext([])
try {
await this._start(plugin)
} catch (error) {
throw updateError('apply', this.options, error)
}
}
private async _start(plugin: any) {
let fiber: Fiber | undefined
try {
fiber = await this._create(plugin)
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')
this.fiber = this.ctx.registry.plugin(plugin, this._resolveConfig(plugin), this.getOuterStack)
return this.fiber = this.ctx.registry.plugin(plugin, this._resolveConfig(plugin), this.getOuterStack)
}
}