fix(cordis): make config reload transactional
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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-'))
|
||||
|
||||
142
packages/ui/app-boot/tests/hmr-config.spec.ts
Normal file
142
packages/ui/app-boot/tests/hmr-config.spec.ts
Normal 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()
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user