fix(vendor/include): config hot-reload keeps the last good tree and its patches

This commit is contained in:
Turtle
2026-07-22 10:55:17 +08:00
parent a409f8b4ba
commit a2f17d71ed
6 changed files with 260 additions and 14 deletions

1
vendor/README.md vendored
View File

@@ -37,6 +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.
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. **`include/src/index.ts` hot-reload hardening**: `refresh()` awaits the full read-and-update and catches failures (logging a warning and keeping the last good entry tree) instead of rethrowing — upstream's throw escaped `@cordisjs/plugin-hmr`'s async watcher callback as an unhandled rejection, so one bad `cordis.yml` edit killed a live app. `read()` rejects a non-array parse result (an empty or mid-write truncated file parses to `undefined`, which upstream later crashed on) and commits `content`/`data` only on success, so reverting an edit to the exact last good content reads as "unchanged". `refresh()` and the `internal/update` listener re-apply `config.patches` before `root.update()`, matching initial load; upstream applied patches only in `[Service.init]`, so any config hot-reload silently reverted overlay-patched entries and removed inserted ones. `applyPatches` deep-copies via `structuredClone` instead of mutating the cached parse (repeated application converges; removing a patch reverts), and the veto-style `internal/update` listener persists the incoming config itself (`Fiber.update` only assigns behind `next()`), so later re-reads use the new patches. `[Service.init]` falls back to `initial` only on `ENOENT`; an existing-but-invalid file fails loud with its real parse error instead of "config file not found" (or a silent overwrite). Covered by `packages/ui/app-boot/tests/config-reload.spec.ts`.
## Sync procedure

View File

@@ -77,7 +77,15 @@ export class Include extends EntryTree {
ctx.on('internal/update', (config, _, next) => {
if (config.path !== this.config.path) return next()
this.root.update(this.data!)
// Veto the fiber restart (children update in place), but persist the new
// config ourselves — `Fiber.update` only assigns `this.config` behind
// `next()`, and a stale `this.config.patches` would make the next
// `refresh()` re-apply the old overlay.
this.config = config
this.root.update(this.applyPatches(this.data!, config.patches)).catch((error) => {
this.ctx.logger.warn('config update at %C failed', this.filename)
this.ctx.logger.warn(error)
})
})
}
@@ -93,22 +101,37 @@ export class Include extends EntryTree {
private async read(forced = false) {
const content = await readFile(this.filename, 'utf8')
if (!forced && this.content === content) return false
this.content = content
let data: any
if (this.type === 'application/yaml') {
this.data = yaml.load(this.content, { schema }) as any
data = yaml.load(content, { schema })
} else if (this.type === 'application/json') {
this.data = JSON.parse(this.content) as any
data = JSON.parse(content)
} else {
const module = await import(/* @vite-ignore */ this.filename)
this.data = module.default || module
data = module.default || module
}
// An empty or truncated file (common mid-edit: editors and `sed -i` write
// through temp states) parses to `undefined`, not an error; reject every
// non-array shape here so callers see one "invalid file" signal. Content
// and data commit only on success, so an edit that is later reverted to
// the exact last good content correctly reads as "unchanged".
if (!Array.isArray(data)) {
throw new TypeError(`config file must be a top-level array of entries: ${this.filename}`)
}
this.content = content
this.data = data
await this.checkAccess()
return true
}
private applyPatches(data: EntryOptions[]): EntryOptions[] {
const { patches } = this.config
if (!patches?.length) return data
private applyPatches(data: EntryOptions[], patches = this.config.patches): EntryOptions[] {
// Always detach from the cached parse: patching shared entry objects would
// bake earlier patch values into `this.data`, so repeated application
// (config hot-reloads) could never revert a removed or changed patch. The
// supported extensions guarantee JSON-safe plain data, so `structuredClone`
// cannot throw here.
if (!patches?.length) return [...data]
data = structuredClone(data)
const entryMap = new Map<string, EntryOptions>()
const buildMap = (entries: EntryOptions[]) => {
@@ -174,7 +197,11 @@ export class Include extends EntryTree {
async* [Service.init]() {
try {
await this.read()
} catch {
} catch (error) {
// Only a missing file falls back to `initial` (or the not-found error):
// an existing-but-invalid file must fail loud with its real parse error,
// never be mislabelled as absent or silently overwritten.
if ((error as NodeJS.ErrnoException | null)?.code !== 'ENOENT') throw error
if (this.config.initial) {
this.writeFile(this.config.initial as any)
await this.read()
@@ -184,18 +211,26 @@ export class Include extends EntryTree {
}
yield () => this.stop()
const data = this.applyPatches([...this.data!])
await this.root.update(data)
await this.root.update(this.applyPatches(this.data!))
}
stop() {
this.root.stop()
}
/** Re-read the file and refresh child entries when content changed. */
/**
* Re-read the file and refresh child entries when content changed. An
* unreadable or unparsable file logs a warning and keeps the last good
* tree: a hot-reload of a live app must never take the process down.
*/
async refresh() {
if (!await this.read()) return
this.root.update(this.data!)
try {
if (!await this.read()) return
await this.root.update(this.applyPatches(this.data!))
} catch (error) {
this.ctx.logger.warn('config reload at %C failed; keeping the running tree', this.filename)
this.ctx.logger.warn(error)
}
}
private async _writeFile(config: EntryOptions[]) {