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
+149 -15
View File
@@ -1,9 +1,10 @@
import { Context, Inject, Service, type Plugin } from 'cordis'
import { Context, Service, type Plugin } from 'cordis'
import type { Dict } from 'cosmokit'
import { ModuleLoader, type ModuleJob, type ResolveResult } from '@cordisjs/plugin-loader'
import type { Include } from '@cordisjs/plugin-include'
import { FSWatcher, watch, type ChokidarOptions } from 'chokidar'
import { relative, resolve } from 'node:path'
import { dirname, relative, resolve } from 'node:path'
import { stat } from 'node:fs/promises'
import { handleError } from './error.ts'
import type {} from '@cordisjs/plugin-timer'
import { fileURLToPath, pathToFileURL } from 'node:url'
@@ -19,6 +20,13 @@ declare module 'cordis' {
interface Events {
'hmr/change'(url: string): void
'hmr/reload'(reloads: Map<Plugin, Reload>): void
/**
* A watched config-file refresh failed.
* @param filename - Absolute path observed by HMR.
* @param error - Normalized refresh failure.
* @mode parallel
*/
'hmr/config-update-failed'(filename: string, error: Error): Promise<void> | void
}
}
@@ -44,13 +52,42 @@ interface Reload {
runtime?: Plugin.Runtime
}
@Inject('loader')
@Inject('timer')
interface ConfigRefresh {
dirty: boolean
running?: Promise<void>
}
interface ConfigRegistration {
watcher: FSWatcher
}
async function findWatchRoot(filename: string): Promise<{ root: string; depth: number }> {
let root = dirname(filename)
let depth = 0
while (true) {
try {
if (!(await stat(root)).isDirectory()) throw new Error(`config watch parent is not a directory: ${root}`)
return { root, depth }
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
const parent = dirname(root)
if (parent === root) throw error
root = parent
depth += 1
}
}
}
class Hmr extends Service {
static inject = ['loader', 'timer']
public baseDir: string
private internal: ModuleLoader
private watcher!: FSWatcher
private readonly configs = new Map<string, ConfigRegistration>()
private readonly configRefreshes = new WeakMap<object, ConfigRefresh>()
private readonly refreshTasks = new Set<Promise<void>>()
/**
* Changes from externals will always trigger a full reload.
@@ -82,6 +119,65 @@ class Hmr extends Service {
this.baseDir = fileURLToPath(new URL(config.base || '.', ctx.baseUrl))
}
/**
* Watch one exact config path outside the configured module roots.
* @param filename - Config path, resolved against the HMR base directory.
* @param refresh - Refresh callback run serially on add, change, or unlink.
* @returns an asynchronous disposer once the exact watch is ready.
* @throws when HMR is inactive, the path is already registered, or watcher startup fails.
*/
async registerConfig(filename: string, refresh: () => Promise<void> | void): Promise<() => Promise<void>> {
if (!this.watcher) throw new Error('HMR is not active')
filename = resolve(this.baseDir, filename)
if (this.configs.has(filename)) throw new Error(`config path already registered: ${filename}`)
const { root, depth } = await findWatchRoot(filename)
const watcher = watch(root, {
...this.config,
cwd: undefined,
depth,
ignored: undefined,
ignoreInitial: false,
})
const registration = { watcher }
this.configs.set(filename, registration)
const onChange = (path: string) => {
if (resolve(path) !== filename) return
this.refreshConfig(registration, filename, refresh)
}
watcher.on('add', onChange)
watcher.on('change', onChange)
watcher.on('unlink', onChange)
const ready = Promise.withResolvers<void>()
let readyState: 'pending' | 'resolved' | 'rejected' = 'pending'
watcher.once('ready', () => {
readyState = 'resolved'
ready.resolve()
})
watcher.on('error', (error) => {
if (readyState === 'pending') {
readyState = 'rejected'
ready.reject(error)
} else {
this.ctx.logger.warn(error)
}
})
try {
await ready.promise
return this.ctx.effect(() => async () => {
if (this.configs.get(filename) === registration) this.configs.delete(filename)
await watcher.close()
await this.configRefreshes.get(registration)?.running
}, 'hmr.registerConfig()')
} catch (error) {
this.configs.delete(filename)
await watcher.close()
throw error
}
}
/**
* Resolve a module specifier to a URL, compatible with Node 22-24.
*/
@@ -93,7 +189,12 @@ class Hmr extends Service {
}
async* [Service.init]() {
yield () => this.watcher?.close()
yield async () => {
await this.watcher?.close()
await Promise.allSettled([...this.configs.values()].map(registration => registration.watcher.close()))
this.configs.clear()
await Promise.allSettled([...this.refreshTasks])
}
const { loader } = this.ctx
const { root, ignored } = this.config
@@ -122,9 +223,18 @@ class Hmr extends Service {
const partialReload = this.ctx.debounce(() => this.partialReload(), this.config.debounce)
this.watcher.on('change', async (path) => {
this.ctx.logger.debug('change detected at %C', path)
const onChange = (kind: 'add' | 'change' | 'unlink', path: string) => {
this.ctx.logger.debug('%s detected at %C', kind, path)
const filename = resolve(this.baseDir, path)
// Config reload: the file is a loader config file (e.g. cordis.yml).
for (const entry of loader.entries()) {
const include = entry.subtree as Include | undefined
if (include?.filename !== filename) continue
this.refreshConfig(include, filename, () => include.refresh())
return
}
if (kind !== 'change') return
const url = pathToFileURL(filename).href
// Full reload: the changed file is part of the framework
@@ -138,16 +248,40 @@ class Hmr extends Service {
return partialReload()
}
// Config reload: the file is a loader config file (e.g. cordis.yml)
for (const entry of this.ctx.loader.entries()) {
const include = entry.subtree as Include | undefined
if (include?.filename !== filename) continue
await include.refresh()
return
}
this.ctx.emit('hmr/change', url)
}
this.watcher.on('add', path => onChange('add', path))
this.watcher.on('change', path => onChange('change', path))
this.watcher.on('unlink', path => onChange('unlink', path))
}
private refreshConfig(key: object, filename: string, refresh: () => Promise<void> | void) {
const state = this.configRefreshes.get(key) ?? { dirty: false }
this.configRefreshes.set(key, state)
state.dirty = true
if (state.running) return
const task = (async () => {
do {
state.dirty = false
try {
await refresh()
} catch (reason) {
const error = reason instanceof Error ? reason : new Error(String(reason), { cause: reason })
this.ctx.logger.warn('config reload at %C failed', filename)
this.ctx.logger.warn(error)
try {
await this.ctx.parallel('hmr/config-update-failed', filename, error)
} catch (rejection) {
this.ctx.logger.warn(rejection)
}
}
} while (state.dirty)
})().finally(() => {
state.running = undefined
this.refreshTasks.delete(task)
})
state.running = task
this.refreshTasks.add(task)
}
// hide stack trace from HMR