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

@@ -325,6 +325,22 @@ describe('boot', () => {
}
})
it('disposes partial host setup and labels non-Error preparation failures', async () => {
const dir = tmp()
const failure = 42
let disposed = false
const task = boot(NAME, join(dir, 'cordis.yml'), undefined, (ctx) => {
ctx.effect(() => () => { disposed = true })
throw failure
})
await expect(task).rejects.toMatchObject({
message: `${NAME}: plugin tree failed to load: ${failure}`,
cause: failure,
})
expect(disposed).toBe(true)
})
it('exposes dshHomePath to Loader config expressions', async () => {
const dir = tmp()
const dshHome = join(dir, 'home')
@@ -375,7 +391,37 @@ describe('boot', () => {
it('rejects (never exits 0 half-empty) when a config names a plugin that cannot be imported', async () => {
const dir = tmp()
writeFileSync(join(dir, 'cordis.yml'), '- id: ghost\n name: ./missing.mjs\n')
await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow(`${NAME}: plugin(s) failed to load: ./missing.mjs`)
await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow(
`${NAME}: plugin tree failed to load: failed to apply loader entry`,
)
})
it('appends the deepest cause with its original stack to the load failure', async () => {
const dir = tmp()
writeFileSync(join(dir, 'failing.mjs'), [
'export function apply() {',
" const failure = new Error('pinned activation failure')",
" failure.stack = 'Error: pinned activation failure\\n at failing-fixture'",
' throw failure',
'}',
'',
].join('\n'))
writeFileSync(join(dir, 'cordis.yml'), '- id: failing\n name: ./failing.mjs\n')
await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow(new RegExp([
String.raw`failed to apply loader entry failing \(\./failing\.mjs\): pinned activation failure\n`,
String.raw`Error: pinned activation failure\n {4}at failing-fixture$`,
].join('')))
})
it('falls back to the deepest cause message when its stack was erased', async () => {
const dir = tmp()
const deepest = new Error('stackless deep failure')
delete (deepest as { stack?: string }).stack
await expect(boot(NAME, join(dir, 'cordis.yml'), undefined, () => {
throw new Error('host preparation failed', { cause: deepest })
})).rejects.toThrow(
`${NAME}: plugin tree failed to load: host preparation failed\nstackless deep failure`,
)
})
it('reports a pending real Loader fiber and the service unresolved in its own context', async () => {

View File

@@ -1,12 +1,7 @@
/**
* Config hot-reload resilience of the booted include tree. `dsh-app-boot`
* installs a fail-loud unhandled-rejection handler, so a `refresh()` that
* rethrows a config-file parse error would kill a live app on one bad
* `cordis.yml` edit (the HMR watcher awaits `refresh()` in an async event
* callback nobody else catches). These tests pin the vendored
* `@cordisjs/plugin-include` contract that boot relies on: an invalid file
* keeps the last good tree, and a valid re-read re-applies overlay patches
* exactly like the initial load.
* Transactional config replacement through the booted Include and Loader tree.
* HMR contains rejected refreshes; direct callers receive the error after the
* previous generation has been retained or restored.
*/
import { mkdtempSync, writeFileSync } from 'node:fs'
@@ -15,6 +10,7 @@ import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import type { Context } from 'cordis'
import type { Include } from '@cordisjs/plugin-include'
import { Group } from '@cordisjs/plugin-loader'
import { boot } from '../src/index.ts'
const NAME = 'dsh-test-bin'
@@ -27,9 +23,10 @@ interface TreeFixture {
include: Include
}
async function bootTree(configBody: string): Promise<TreeFixture> {
async function bootTree(configBody: string, files: Record<string, string> = {}): Promise<TreeFixture> {
const dir = mkdtempSync(join(tmpdir(), 'dsh-config-reload-'))
writeFileSync(join(dir, 'noop.mjs'), NOOP_PLUGIN)
for (const [name, content] of Object.entries(files)) writeFileSync(join(dir, name), content)
writeFileSync(join(dir, 'cordis.yml'), configBody)
const ctx = await boot(NAME, join(dir, 'cordis.yml'))
const entry = [...ctx.loader.entries()].find(candidate => candidate.subtree !== undefined)
@@ -41,20 +38,41 @@ function entryConfig(ctx: Context, id: string): unknown {
return [...ctx.loader.entries()].find(entry => entry.options.id === id)?.options.config
}
function entryById(ctx: Context, id: string) {
const entry = [...ctx.loader.entries()].find(entry => entry.options.id === id)
if (!entry) throw new Error(`missing loader entry ${id}`)
return entry
}
function plugin(name: string, body = ''): string {
return `export default function ${name}(_ctx, config = {}) { ${body} }\n`
}
async function expectUpdateFailure(task: Promise<void>, stage: string): Promise<void> {
try {
await task
} catch (error) {
expect(error).toBeInstanceOf(Error)
expect((error as Error).message).toContain(`failed to ${stage} loader entry`)
return
}
throw new Error(`expected loader update to fail during ${stage}`)
}
describe('include refresh with an invalid file', () => {
it('keeps the last good tree instead of throwing, then applies the next valid edit', async () => {
it('rejects while keeping the last good tree, then applies the next valid edit', async () => {
const { ctx, dir, include } = await bootTree('- id: noop\n name: ./noop.mjs\n config:\n value: 1\n')
try {
expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 })
writeFileSync(join(dir, 'cordis.yml'), 'invalid: [unclosed\n')
await expect(include.refresh()).resolves.toBeUndefined()
await expect(include.refresh()).rejects.toThrow('failed to parse config file')
expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 })
// An empty file parses to `undefined` without a YAML error; it must be
// treated exactly like a parse failure, not crash the entry walk.
writeFileSync(join(dir, 'cordis.yml'), '')
await expect(include.refresh()).resolves.toBeUndefined()
await expect(include.refresh()).rejects.toThrow('failed to validate config file')
expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 })
writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: 2\n')
@@ -67,6 +85,200 @@ describe('include refresh with an invalid file', () => {
})
})
describe('loader entry replacement', () => {
it('imports a changed name before replacing the running plugin', async () => {
const { ctx } = await bootTree('- id: target\n name: ./old.mjs\n', {
'old.mjs': plugin('oldPlugin'),
'new.mjs': plugin('newPlugin'),
})
try {
const entry = entryById(ctx, 'target')
await entry.update({ name: './new.mjs' })
expect(entry.options.name).toBe('./new.mjs')
expect(entry.parent.data.find(options => options.id === 'target')).toBe(entry.options)
expect(entry.fiber?.runtime?.callback.name).toBe('newPlugin')
expect(entry.options.disabled).toBeUndefined()
await entry.fiber?.await()
} finally {
await ctx.fiber.dispose()
}
})
it('retains the running plugin when the replacement cannot be imported', async () => {
const { ctx } = await bootTree('- id: target\n name: ./old.mjs\n', {
'old.mjs': plugin('oldPlugin'),
})
try {
const entry = entryById(ctx, 'target')
const fiber = entry.fiber
await expectUpdateFailure(entry.update({ name: './missing.mjs' }), 'import')
expect(entry.options.name).toBe('./old.mjs')
expect(entry.fiber === fiber).toBe(true)
await fiber?.await()
} finally {
await ctx.fiber.dispose()
}
})
it('restores the previous plugin after replacement application fails', async () => {
const { ctx } = await bootTree('- id: target\n name: ./old.mjs\n', {
'old.mjs': plugin('oldPlugin'),
'bad.mjs': plugin('badPlugin', 'throw new Error("candidate apply failed")'),
})
try {
const entry = entryById(ctx, 'target')
const previous = entry.fiber
await expectUpdateFailure(entry.update({ name: './bad.mjs' }), 'apply')
expect(entry.options.name).toBe('./old.mjs')
expect(entry.fiber === previous).toBe(false)
expect(entry.fiber?.runtime?.callback.name).toBe('oldPlugin')
expect(entry.options.disabled).toBeUndefined()
await entry.fiber?.await()
} finally {
await ctx.fiber.dispose()
}
})
it('restores the previous config when an in-place restart fails', async () => {
const { ctx } = await bootTree('- id: target\n name: ./configurable.mjs\n config:\n fail: false\n', {
'configurable.mjs': plugin('configurablePlugin', 'if (config.fail) throw new Error("candidate config failed")'),
})
try {
const entry = entryById(ctx, 'target')
const fiber = entry.fiber
await expectUpdateFailure(entry.update({ config: { fail: true } }), 'apply')
expect(entry.options.config).toEqual({ fail: false })
expect(entry.fiber === fiber).toBe(true)
await fiber?.await()
} finally {
await ctx.fiber.dispose()
}
})
it('does not persist a failed direct fiber update', async () => {
const { ctx } = await bootTree('- id: target\n name: ./configurable.mjs\n config:\n fail: false\n', {
'configurable.mjs': plugin('configurablePlugin', 'if (config.fail) throw new Error("candidate config failed")'),
})
try {
const entry = entryById(ctx, 'target')
const fiber = entry.fiber
if (!fiber) throw new Error('target entry has no fiber')
await expect(fiber.update({ fail: true })).rejects.toThrow('candidate config failed')
expect(entry.options.config).toEqual({ fail: false })
expect(entry.parent.data.find(options => options.id === 'target')).toBe(entry.options)
} finally {
await ctx.fiber.dispose()
}
})
})
describe('loader tree replacement', () => {
it('rolls back earlier updates and additions when a later entry fails', async () => {
const { ctx, dir, include } = await bootTree([
'- id: existing',
' name: ./configurable.mjs',
' config:',
' value: old',
'',
].join('\n'), {
'configurable.mjs': plugin('configurablePlugin'),
'bad.mjs': plugin('badPlugin', 'throw new Error("candidate apply failed")'),
})
try {
writeFileSync(join(dir, 'cordis.yml'), [
'- id: existing',
' name: ./configurable.mjs',
' config:',
' value: candidate',
'- id: added',
' name: ./noop.mjs',
'- id: bad',
' name: ./bad.mjs',
'',
].join('\n'))
await expect(include.refresh()).rejects.toThrow('failed to apply loader entry bad')
expect(entryConfig(ctx, 'existing')).toEqual({ value: 'old' })
expect([...ctx.loader.entries()].some(entry => entry.options.id === 'added')).toBe(false)
expect([...ctx.loader.entries()].some(entry => entry.options.id === 'bad')).toBe(false)
writeFileSync(join(dir, 'cordis.yml'), [
'- id: existing',
' name: ./configurable.mjs',
' config:',
' value: committed',
'- id: added',
' name: ./noop.mjs',
'',
].join('\n'))
await include.refresh()
expect(entryConfig(ctx, 'existing')).toEqual({ value: 'committed' })
expect(entryById(ctx, 'added').fiber).toBeDefined()
} finally {
await ctx.fiber.dispose()
}
})
it('stops and restores descendants when an ancestor group is disabled and re-enabled', async () => {
const { ctx, dir, include } = await bootTree('- id: noop\n name: ./noop.mjs\n')
ctx.loader.builtins.group = Group
try {
const config = (disabled: boolean) => [
'- id: parent',
' name: cordis:group',
' group: true',
` disabled: ${disabled}`,
' config:',
' - id: child',
' name: ./noop.mjs',
'',
].join('\n')
writeFileSync(join(dir, 'cordis.yml'), config(false))
await include.refresh()
expect(entryById(ctx, 'child').fiber).toBeDefined()
writeFileSync(join(dir, 'cordis.yml'), config(true))
await include.refresh()
expect(entryById(ctx, 'child').fiber).toBeUndefined()
writeFileSync(join(dir, 'cordis.yml'), config(false))
await include.refresh()
expect(entryById(ctx, 'child').fiber).toBeDefined()
} finally {
await ctx.fiber.dispose()
}
})
it('restores a programmatic entry move when its update fails', async () => {
const { ctx } = await bootTree('- id: noop\n name: ./noop.mjs\n', {
'movable.mjs': plugin('movablePlugin', 'if (config.fail) throw new Error("candidate config failed")'),
})
ctx.loader.builtins.group = Group
try {
const groupId = await ctx.loader.create({ name: 'cordis:group', group: true, config: [] })
const targetId = await ctx.loader.create({ name: './movable.mjs', config: { fail: false } })
const target = entryById(ctx, targetId)
const source = target.parent
const sourceIndex = source.data.indexOf(target.options)
const destination = entryById(ctx, groupId).subgroup
if (!destination) throw new Error('created loader group has no subgroup')
await expectUpdateFailure(
ctx.loader.update(targetId, { config: { fail: true } }, groupId),
'apply',
)
expect(target.parent).toBe(source)
expect(Object.getPrototypeOf(target.ctx)).toBe(source.ctx)
expect(source.data.indexOf(target.options)).toBe(sourceIndex)
expect(destination.data).not.toContain(target.options)
expect(target.options.config).toEqual({ fail: false })
} finally {
await ctx.fiber.dispose()
}
})
})
describe('include refresh with overlay patches', () => {
it('re-applies entry patches and inserted entries on every re-read (parity with initial load)', async () => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-config-reload-overlay-'))

View File

@@ -0,0 +1,142 @@
import { mkdirSync, mkdtempSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { Context } from 'cordis'
import Hmr from '@cordisjs/plugin-hmr'
import Loader from '@cordisjs/plugin-loader'
import Timer from '@cordisjs/plugin-timer'
import { describe, expect, it } from 'vitest'
async function bootHmr(dir: string): Promise<Context> {
const ctx = new Context()
ctx.baseUrl = pathToFileURL(dir).href + '/'
await ctx.plugin(Loader)
await ctx.plugin(Timer)
await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 })
return ctx
}
async function eventually(test: () => boolean, message: string): Promise<void> {
const deadline = Date.now() + 10_000
while (!test()) {
if (Date.now() >= deadline) throw new Error(message)
await new Promise(resolve => setTimeout(resolve, 10))
}
}
describe('HMR exact config paths', () => {
it('observes add, change, and unlink outside its module roots', { timeout: 20_000 }, async () => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-'))
const filename = join(dir, 'plugins.yml')
const ctx = await bootHmr(dir)
const observed: string[] = []
try {
await ctx.hmr.registerConfig(filename, () => {
try {
observed.push(readFileSync(filename, 'utf8'))
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
observed.push('missing')
}
})
writeFileSync(filename, 'one', { flag: 'wx' })
await eventually(() => observed.includes('one'), 'HMR did not observe config creation')
writeFileSync(filename, 'two')
await eventually(() => observed.includes('two'), 'HMR did not observe config change')
unlinkSync(filename)
await eventually(() => observed.includes('missing'), 'HMR did not observe config removal')
} finally {
await ctx.fiber.dispose()
}
})
it('observes creation when the config parent did not exist at registration', { timeout: 20_000 }, async () => {
const root = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-'))
const dir = join(root, 'later')
const filename = join(dir, 'plugins.yml')
const ctx = await bootHmr(root)
const observed: string[] = []
try {
await ctx.hmr.registerConfig(filename, () => {
observed.push(readFileSync(filename, 'utf8'))
})
mkdirSync(dir)
writeFileSync(filename, 'created')
await eventually(() => observed.includes('created'), 'HMR did not observe config creation under a new parent')
} finally {
await ctx.fiber.dispose()
}
})
it('serializes refreshes and waits for them during disposal', { timeout: 20_000 }, async () => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-'))
const filename = join(dir, 'plugins.yml')
writeFileSync(filename, 'one')
const ctx = await bootHmr(dir)
const started = Promise.withResolvers<undefined>()
const release = Promise.withResolvers<undefined>()
const observed: string[] = []
let active = 0
let maxActive = 0
try {
const dispose = await ctx.hmr.registerConfig(filename, async () => {
active += 1
maxActive = Math.max(maxActive, active)
observed.push(readFileSync(filename, 'utf8'))
if (observed.length === 1) {
started.resolve(undefined)
await release.promise
}
active -= 1
})
await started.promise
writeFileSync(filename, 'two')
// Chokidar coalesces atomic writes for 100 ms by default. Wait beyond
// that window so this edit is queued before registration disposal.
await new Promise(resolve => setTimeout(resolve, 250))
let disposed = false
const disposal = dispose().then(() => { disposed = true })
await Promise.resolve()
expect(disposed).toBe(false)
release.resolve(undefined)
await disposal
expect(maxActive).toBe(1)
expect(observed).toEqual(['one', 'two'])
} finally {
release.resolve(undefined)
await ctx.fiber.dispose()
}
})
it('normalizes refresh failures and broadcasts them without escaping the watcher', { timeout: 20_000 }, async () => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-'))
const filename = join(dir, 'plugins.yml')
const ctx = await bootHmr(dir)
const failure = Promise.withResolvers<{ filename: string; error: Error }>()
let failureCount = 0
try {
ctx.on('hmr/config-update-failed', () => {
throw new Error('observer failed')
})
ctx.on('hmr/config-update-failed', (failedFilename, error) => {
failureCount += 1
failure.resolve({ filename: failedFilename, error })
})
await ctx.hmr.registerConfig(filename, () => { throw 42 })
writeFileSync(filename, 'invalid')
const observed = await failure.promise
expect(observed.filename).toBe(filename)
expect(observed.error).toBeInstanceOf(Error)
expect(observed.error.message).toBe('42')
writeFileSync(filename, 'invalid again')
await eventually(() => failureCount === 2, 'HMR stopped broadcasting after an observer rejected')
} finally {
await ctx.fiber.dispose()
}
})
})