fix(cordis): make config reload transactional
This commit is contained in:
Vendored
+5
-4
@@ -35,11 +35,12 @@ Keep this log exhaustive — every divergence from upstream must be listed.
|
||||
3. **All `tsconfig.json` files**: regenerated to extend the repo-root `tsconfig.base.json`, emit TypeScript intermediates to `lib/types`, and declare project references.
|
||||
4. **Vendored TypeScript source internal specifiers**: changed local relative imports/exports from upstream's specifier shape to explicit `.ts` specifiers so TypeScript rewrites emitted JS to `.js` while declarations keep explicit, NodeNext-safe `.ts` specifiers. This includes `loader/src/config/isolate.ts` using `declare module './entry.ts'`.
|
||||
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.
|
||||
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. `Fiber.update()` returns its `internal/update` waterfall result, allowing Loader callers to await a restart while preserving synchronous config validation.
|
||||
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). `applyPatches` also indexes each `insert`ed entry as it is added, so a later patch in the same list can configure or disable a row an earlier patch inserted; upstream built the id index once before the patch loop, leaving inserted rows silently unpatchable. That matters because `dsh` composes one shared base (`apps/cli/config/base.cordis.yml`) with a surface overlay, an optional `--config` overlay, and the personal `~/.dsh/config.yaml` as sibling patch lists at one include level — patches never cross an include boundary, so surface-only rows would otherwise be unreachable from user config. Covered by `packages/ui/app-boot/tests/config-reload.spec.ts`.
|
||||
9. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`; its built ESM/CJS entries retain explicit `.mjs`/`.cjs` extensions.
|
||||
10. **`include/src/index.ts` patch-semantics export**: extracted the private `applyPatches` body into the exported pure function `applyEntryPatches(data, patches, warn)` (the method delegates to it) and exported the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes and prints exactly what the include would mount without booting a tree. Behavior-preserving for mounting; the extraction exists because config tooling must never reimplement (and drift from) the patch algorithm.
|
||||
8. **Transactional Loader/Include config reconciliation**: Loader imports a changed entry name before disposal, awaits lifecycle settlement, and restores the previous plugin or config when candidate application fails. Loader settlement rechecks service-gated fibers after current tasks drain, rejects failures, and leaves fibers with absent dependencies pending. Group updates run sequentially, undo earlier changes and additions on failure, await removal, preserve programmatic option identity, and persist direct or tree-level mutations only after success. Include reads and validates detached candidate content, applies patches to a clone, reconciles the tree, and only then commits its cached content/data; direct refresh failures propagate for the caller to contain. A non-array parse is invalid, patches re-apply on every file or Include-config update, and initial content falls back to `initial` only on `ENOENT`. Covered by `packages/ui/app-boot/tests/config-reload.spec.ts` and `packages/host/webserver/tests/webserver.spec.ts`.
|
||||
9. **`hmr/src/index.ts` exact config watching**: `registerConfig()` watches one absolute config path outside module roots, including a path under missing parents, serializes and coalesces refreshes, and returns an async disposer that closes the watcher and drains active work. Refresh failures are normalized to `Error`, logged, and broadcast through the parallel `hmr/config-update-failed` event; observer failures are contained. Config-file changes discovered by the ordinary HMR watcher use the same serialized path. Covered by `packages/ui/app-boot/tests/hmr-config.spec.ts`.
|
||||
10. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`; its built ESM/CJS entries retain explicit `.mjs`/`.cjs` extensions.
|
||||
11. **`include/src/index.ts` patch-semantics export**: extracted the private `applyPatches` body into the exported pure function `applyEntryPatches(data, patches, warn)` (the method delegates to it) and exported the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes and prints exactly what the include would mount without booting a tree. Behavior-preserving for mounting; the extraction exists because config tooling must never reimplement (and drift from) the patch algorithm. `applyEntryPatches` also indexes each `insert`ed entry as it is added, so a later patch in the same list can configure or disable a row an earlier patch inserted; upstream built the id index once before the patch loop, leaving inserted rows silently unpatchable. That matters because `dsh` composes one shared base (`apps/cli/config/base.cordis.yml`) with a surface overlay, an optional `--config` overlay, and the personal `~/.dsh/config.yaml` as sibling patch lists at one include level — patches never cross an include boundary, so surface-only rows would otherwise be unreachable from user config. Covered by `packages/ui/app-boot/tests/config-reload.spec.ts`.
|
||||
|
||||
## Sync procedure
|
||||
|
||||
|
||||
Vendored
+1
-1
@@ -334,7 +334,7 @@ export interface Events {
|
||||
/** Interception hook for a service binding (no core producer). */
|
||||
'internal/service'(this: Context, name: string, value: any): void
|
||||
/** Waterfall: a fiber config update is being applied; skip `next()` to veto. */
|
||||
'internal/update'(this: Fiber, config: any, noSave: boolean, next: () => void): void
|
||||
'internal/update'(this: Fiber, config: any, noSave: boolean, next: () => void | Promise<void>): void | Promise<void>
|
||||
/** Waterfall: a service is being read through the context proxy. */
|
||||
'internal/get'(ctx: Context, name: string, error: Error, next: () => any): any
|
||||
/** Waterfall: a service is being written through the context proxy. */
|
||||
|
||||
Vendored
+3
-3
@@ -728,13 +728,13 @@ export class Fiber {
|
||||
*
|
||||
* @param config — the new raw config; validated before anything restarts.
|
||||
* @param noSave — hint for persistence hooks not to write the change back.
|
||||
* @returns nothing; the restart runs behind the `internal/update` waterfall.
|
||||
* @throws {ValidationError} when the new config fails validation.
|
||||
* @returns the update waterfall result; the default restart returns a promise.
|
||||
* @throws when validation, an update listener, or the restarted plugin fails.
|
||||
*/
|
||||
update(config: any, noSave = false) {
|
||||
this.assertActive()
|
||||
config = resolveConfig(this.runtime!, config)
|
||||
this.context.waterfall(this, 'internal/update', config, noSave, () => {
|
||||
return this.context.waterfall(this, 'internal/update', config, noSave, () => {
|
||||
this.config = config
|
||||
this._error = undefined
|
||||
return this.restart()
|
||||
|
||||
Vendored
+149
-15
@@ -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
|
||||
|
||||
Vendored
+63
-51
@@ -35,7 +35,8 @@ const supported = new Set(Object.keys(writable))
|
||||
* Apply patch lists to an entry list — THE patch semantics of this include,
|
||||
* shared by mounting (`applyPatches`) and offline config tooling
|
||||
* (`dsh --dump-config`) so a dump can never drift from what boots. The input
|
||||
* is never mutated: patching shared entry objects would bake earlier patch
|
||||
* is never mutated and the result is always detached from it (even with no
|
||||
* patches): patching or mounting shared entry objects would bake earlier
|
||||
* values into the cached parse, so repeated application (config hot-reloads)
|
||||
* could never revert a removed or changed patch. Inserted entries are indexed
|
||||
* as they are added, so a later patch in the same list can target a row an
|
||||
@@ -50,8 +51,8 @@ export function applyEntryPatches(
|
||||
patches: PatchOptions[] | undefined,
|
||||
warn: (message: string, ...args: any[]) => void,
|
||||
): EntryOptions[] {
|
||||
if (!patches?.length) return [...data]
|
||||
data = structuredClone(data)
|
||||
if (!patches?.length) return data
|
||||
|
||||
const entryMap = new Map<string, EntryOptions>()
|
||||
const buildMap = (entries: EntryOptions[]) => {
|
||||
@@ -117,6 +118,20 @@ export function applyEntryPatches(
|
||||
return data
|
||||
}
|
||||
|
||||
type ConfigUpdateStage = 'read' | 'parse' | 'validate'
|
||||
|
||||
interface ReadCandidate {
|
||||
content: string
|
||||
data: EntryOptions[]
|
||||
}
|
||||
|
||||
class ConfigFileError extends Error {
|
||||
constructor(public readonly stage: ConfigUpdateStage, path: string, cause: unknown) {
|
||||
super(`failed to ${stage} config file ${path}`, { cause })
|
||||
this.name = 'ConfigFileError'
|
||||
}
|
||||
}
|
||||
|
||||
/** Runtime patch applied to entries loaded from an included config file. */
|
||||
export interface PatchOptions {
|
||||
id?: string
|
||||
@@ -169,17 +184,11 @@ export class Include extends EntryTree {
|
||||
this.readonly = !this.type
|
||||
this.ctx.baseUrl = new URL('.', pathToFileURL(this.filename)).href
|
||||
|
||||
ctx.on('internal/update', (config, _, next) => {
|
||||
ctx.on('internal/update', async (config, _, next) => {
|
||||
if (config.path !== this.config.path) return next()
|
||||
// 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.
|
||||
const data = this.applyPatches(this.data!, config.patches)
|
||||
await this.root.update(data)
|
||||
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)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -192,30 +201,31 @@ export class Include extends EntryTree {
|
||||
}
|
||||
}
|
||||
|
||||
private async read(forced = false) {
|
||||
const content = await readFile(this.filename, 'utf8')
|
||||
if (!forced && this.content === content) return false
|
||||
private async read(forced = false): Promise<ReadCandidate | undefined> {
|
||||
let content: string
|
||||
try {
|
||||
content = await readFile(this.filename, 'utf8')
|
||||
} catch (error) {
|
||||
throw new ConfigFileError('read', this.filename, error)
|
||||
}
|
||||
if (!forced && this.content === content) return
|
||||
let data: any
|
||||
if (this.type === 'application/yaml') {
|
||||
data = yaml.load(content, { schema })
|
||||
} else if (this.type === 'application/json') {
|
||||
data = JSON.parse(content)
|
||||
} else {
|
||||
const module = await import(/* @vite-ignore */ this.filename)
|
||||
data = module.default || module
|
||||
try {
|
||||
if (this.type === 'application/yaml') {
|
||||
data = yaml.load(content, { schema })
|
||||
} else if (this.type === 'application/json') {
|
||||
data = JSON.parse(content)
|
||||
} else {
|
||||
const module = await import(/* @vite-ignore */ this.filename)
|
||||
data = module.default || module
|
||||
}
|
||||
} catch (error) {
|
||||
throw new ConfigFileError('parse', this.filename, error)
|
||||
}
|
||||
// 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}`)
|
||||
throw new ConfigFileError('validate', this.filename, new TypeError('config file must be a top-level array'))
|
||||
}
|
||||
this.content = content
|
||||
this.data = data
|
||||
await this.checkAccess()
|
||||
return true
|
||||
return { content, data }
|
||||
}
|
||||
|
||||
private applyPatches(data: EntryOptions[], patches = this.config.patches): EntryOptions[] {
|
||||
@@ -225,42 +235,44 @@ export class Include extends EntryTree {
|
||||
}
|
||||
|
||||
async* [Service.init]() {
|
||||
let candidate: ReadCandidate
|
||||
try {
|
||||
await this.read()
|
||||
candidate = (await this.read(true))!
|
||||
} 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 (!(error instanceof ConfigFileError) || error.stage !== 'read' || (error.cause as NodeJS.ErrnoException)?.code !== 'ENOENT') throw error
|
||||
if (this.config.initial) {
|
||||
this.writeFile(this.config.initial as any)
|
||||
await this.read()
|
||||
await this._writeFile(this.config.initial as any)
|
||||
candidate = (await this.read(true))!
|
||||
} else {
|
||||
throw new Error(`config file not found: ${this.filename}`)
|
||||
}
|
||||
}
|
||||
|
||||
yield () => this.stop()
|
||||
await this.root.update(this.applyPatches(this.data!))
|
||||
await this.apply(candidate)
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.root.stop()
|
||||
async stop() {
|
||||
await this.root.stop()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Re-read the file and transactionally refresh child entries when content changed.
|
||||
* @returns a promise resolving after the new tree commits, or immediately when unchanged.
|
||||
* @throws when reading, parsing, validation, application, or rollback fails; the last good tree remains active when rollback succeeds.
|
||||
*/
|
||||
async refresh() {
|
||||
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)
|
||||
}
|
||||
const candidate = await this.read()
|
||||
if (!candidate) return
|
||||
await this.apply(candidate)
|
||||
}
|
||||
|
||||
private async apply(candidate: ReadCandidate) {
|
||||
const data = this.applyPatches(candidate.data)
|
||||
await this.root.update(data)
|
||||
this.content = candidate.content
|
||||
this.data = candidate.data
|
||||
await this.checkAccess()
|
||||
}
|
||||
|
||||
private async _writeFile(config: EntryOptions[]) {
|
||||
|
||||
Vendored
+151
-41
@@ -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)
|
||||
}
|
||||
}
|
||||
Vendored
+51
-21
@@ -19,12 +19,23 @@ export class EntryGroup {
|
||||
|
||||
async create(options: Omit<EntryOptions, 'id'>) {
|
||||
const id = this.tree.ensureId(options)
|
||||
const entry: Entry = this.tree.store[id] ??= new Entry(this.ctx.loader)
|
||||
const existing = this.tree.store[id]
|
||||
const entry: Entry = existing ?? (this.tree.store[id] = new Entry(this.ctx.loader))
|
||||
const previousParent = entry.parent
|
||||
// Entry may be moved from another group,
|
||||
// so we need to update the parent reference.
|
||||
entry.parent = this
|
||||
// Use `create: true` to replace existing entry.options.
|
||||
await entry.update(options, true, true)
|
||||
try {
|
||||
await entry.update(options, true, true)
|
||||
} catch (error) {
|
||||
if (existing) {
|
||||
entry.parent = previousParent
|
||||
} else {
|
||||
delete this.tree.store[id]
|
||||
}
|
||||
throw error
|
||||
}
|
||||
return entry.id
|
||||
}
|
||||
|
||||
@@ -34,10 +45,10 @@ export class EntryGroup {
|
||||
if (index >= 0) config.splice(index, 1)
|
||||
}
|
||||
|
||||
remove(id: string, isDispose = false) {
|
||||
async remove(id: string, isDispose = false) {
|
||||
const entry = this.tree.store[id]
|
||||
if (!entry) return
|
||||
entry.fiber?.dispose()
|
||||
await entry._dispose()
|
||||
if (!isDispose) {
|
||||
this.unlink(entry.options)
|
||||
}
|
||||
@@ -47,26 +58,47 @@ export class EntryGroup {
|
||||
|
||||
async update(config: EntryOptions[]) {
|
||||
const oldConfig = this.data as EntryOptions[]
|
||||
this.data = config
|
||||
const seen = new Set<string>()
|
||||
for (const options of config) {
|
||||
const id = this.tree.ensureId(options)
|
||||
if (seen.has(id)) throw new TypeError(`duplicate loader entry id: ${id}`)
|
||||
seen.add(id)
|
||||
}
|
||||
const oldMap = Object.fromEntries(oldConfig.map(options => [options.id, options]))
|
||||
const newMap = Object.fromEntries(config.map(options => [options.id ?? Symbol('anonymous'), options]))
|
||||
const newMap = Object.fromEntries(config.map(options => [options.id, options]))
|
||||
|
||||
// update inner plugins
|
||||
const ids = Reflect.ownKeys({ ...oldMap, ...newMap }) as string[]
|
||||
await Promise.all(ids.map(async (id) => {
|
||||
if (newMap[id]) {
|
||||
await this.create(newMap[id]).catch((error) => {
|
||||
this.ctx.logger.error(error)
|
||||
})
|
||||
} else {
|
||||
this.remove(id)
|
||||
try {
|
||||
for (const options of config) await this.create(options)
|
||||
for (const id of Object.keys(oldMap)) {
|
||||
if (!newMap[id]) await this.remove(id, true)
|
||||
}
|
||||
}))
|
||||
this.data = config
|
||||
} catch (error) {
|
||||
const rollbackErrors: unknown[] = []
|
||||
for (const id of Object.keys(newMap).reverse()) {
|
||||
if (oldMap[id]) continue
|
||||
try {
|
||||
await this.remove(id, true)
|
||||
} catch (rollbackError) {
|
||||
rollbackErrors.push(rollbackError)
|
||||
}
|
||||
}
|
||||
for (const options of oldConfig) {
|
||||
try {
|
||||
await this.create(options)
|
||||
} catch (rollbackError) {
|
||||
rollbackErrors.push(rollbackError)
|
||||
}
|
||||
}
|
||||
this.data = oldConfig
|
||||
if (rollbackErrors.length) throw new AggregateError([error, ...rollbackErrors], 'loader entry rollback failed')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
stop() {
|
||||
async stop() {
|
||||
for (const options of this.data) {
|
||||
this.remove(options.id, true)
|
||||
await this.remove(options.id, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,9 +110,7 @@ export class Group extends EntryGroup {
|
||||
|
||||
constructor(public ctx: Context, public config: EntryOptions[]) {
|
||||
super(ctx, ctx.fiber.entry!.parent.tree)
|
||||
ctx.on('internal/update', (config) => {
|
||||
this.update(config)
|
||||
})
|
||||
ctx.on('internal/update', config => this.update(config))
|
||||
}
|
||||
|
||||
async* [Service.init]() {
|
||||
|
||||
Vendored
+2
-2
@@ -93,7 +93,7 @@ export default function isolate(ctx: Context) {
|
||||
entry.ctx[Context.isolate] = Object.create(entry.ctx[Context.isolate])
|
||||
})
|
||||
|
||||
ctx.on('loader/patch-context', (entry, next) => {
|
||||
ctx.on('loader/patch-context', async (entry, next) => {
|
||||
// step 1: generate new isolate map
|
||||
const newMap: Dict<symbol> = Object.create(entry.parent.ctx[Context.isolate])
|
||||
for (const name of Object.keys(entry.options.isolate ?? {})) {
|
||||
@@ -126,7 +126,7 @@ export default function isolate(ctx: Context) {
|
||||
swap(entry.ctx[Context.intercept], entry.options.intercept)
|
||||
|
||||
// step 4: reload fiber
|
||||
next()
|
||||
await next()
|
||||
|
||||
// step 5: replace service impl
|
||||
for (const [symbol1, symbol2, flag1, flag2] of Object.values(diff)) {
|
||||
|
||||
Vendored
+43
-10
@@ -39,12 +39,27 @@ export abstract class EntryTree {
|
||||
.filter(isNonNullable)
|
||||
}
|
||||
|
||||
/** Wait until this tree has no pending import or lifecycle tasks. */
|
||||
/**
|
||||
* Wait until this tree has no active import or lifecycle tasks.
|
||||
* @throws a settled fiber failure, or an aggregate when several fibers failed.
|
||||
*/
|
||||
async await() {
|
||||
while (true) {
|
||||
const tasks = this.getTasks()
|
||||
if (!tasks.length) return
|
||||
await Promise.allSettled(tasks)
|
||||
if (tasks.length) {
|
||||
await Promise.allSettled(tasks)
|
||||
continue
|
||||
}
|
||||
const outcomes = await Promise.allSettled(
|
||||
[...this.entries()].map(entry => entry.fiber?.await()),
|
||||
)
|
||||
const failures = outcomes
|
||||
.filter((outcome): outcome is PromiseRejectedResult => outcome.status === 'rejected')
|
||||
.map(outcome => outcome.reason)
|
||||
if (failures.length === 1) throw failures[0]
|
||||
if (failures.length > 1) throw new AggregateError(failures, 'loader fibers failed')
|
||||
this.ctx.reflect.notify(['loader'])
|
||||
if (!this.getTasks().length) return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,15 +96,17 @@ export abstract class EntryTree {
|
||||
/** Create an entry in the root group or a nested group. */
|
||||
async create(options: Omit<EntryOptions, 'id'>, parent: string | null = null, position = Infinity) {
|
||||
const group = this.resolveGroup(parent)
|
||||
group.data.splice(position, 0, options as EntryOptions)
|
||||
const id = await group.create(options)
|
||||
const entry = this.resolve(id)
|
||||
group.data.splice(position, 0, entry.options)
|
||||
group.tree.write()
|
||||
return group.create(options)
|
||||
return id
|
||||
}
|
||||
|
||||
/** Stop and remove an entry from its parent group. */
|
||||
remove(id: string) {
|
||||
async remove(id: string) {
|
||||
const entry = this.resolve(id)
|
||||
entry.parent.remove(id)
|
||||
await entry.parent.remove(id)
|
||||
entry.parent.tree.write()
|
||||
}
|
||||
|
||||
@@ -97,15 +114,31 @@ export abstract class EntryTree {
|
||||
async update(id: string, options: Omit<EntryOptions, 'id' | 'name'>, parent?: string | null, position?: number) {
|
||||
const entry = this.resolve(id)
|
||||
const source = entry.parent
|
||||
const sourceIndex = source.data.indexOf(entry.options)
|
||||
let target = source
|
||||
if (parent !== undefined) {
|
||||
const target = this.resolveGroup(parent)
|
||||
target = this.resolveGroup(parent)
|
||||
source.unlink(entry.options)
|
||||
target.data.splice(position ?? Infinity, 0, entry.options)
|
||||
target.tree.write()
|
||||
entry.parent = target
|
||||
}
|
||||
try {
|
||||
await entry.update(options, false, true)
|
||||
} catch (error) {
|
||||
if (parent !== undefined) {
|
||||
target.unlink(entry.options)
|
||||
source.data.splice(sourceIndex < 0 ? source.data.length : sourceIndex, 0, entry.options)
|
||||
entry.parent = source
|
||||
try {
|
||||
await entry.update({}, false, true)
|
||||
} catch (rollbackError) {
|
||||
throw new AggregateError([error, rollbackError], `failed to roll back loader entry move ${id}`)
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
source.tree.write()
|
||||
return entry.update(options, false, true)
|
||||
if (target !== source) target.tree.write()
|
||||
}
|
||||
|
||||
/** Import a plugin module from a specifier or `cordis:` builtin. */
|
||||
|
||||
Vendored
+7
-4
@@ -24,7 +24,7 @@ declare module 'cordis' {
|
||||
'loader/config-update'(): void
|
||||
'loader/entry-init'(entry: Entry): void
|
||||
'loader/partial-dispose'(entry: Entry, legacy: Partial<EntryOptions>, active: boolean): void
|
||||
'loader/patch-context'(entry: Entry, next: () => void): void
|
||||
'loader/patch-context'(entry: Entry, next: () => void | Promise<void>): void | Promise<void>
|
||||
}
|
||||
|
||||
interface Context {
|
||||
@@ -87,12 +87,12 @@ export class Loader extends EntryTree {
|
||||
|
||||
ctx.reflect.provide('loader', this, this[Service.check])
|
||||
|
||||
ctx.on('internal/update', function (config, noSave, next) {
|
||||
ctx.on('internal/update', async function (config, noSave, next) {
|
||||
if (!this.entry || noSave || this.parent.fiber?.entry === this.entry) return next()
|
||||
await next()
|
||||
const unparse = this.runtime?.Config?.['simplify']
|
||||
this.entry.options.config = unparse ? unparse(config) : config
|
||||
this.entry.parent.tree.write()
|
||||
return next()
|
||||
}, { global: true, prepend: true })
|
||||
|
||||
ctx.on('internal/update', function (config, _, next) {
|
||||
@@ -129,9 +129,12 @@ export class Loader extends EntryTree {
|
||||
// case 5: the entry's tree is being disposed
|
||||
if (!fiber.entry.parent.tree.ctx.fiber.uid) return
|
||||
|
||||
// case 6: Loader is replacing or removing this exact fiber
|
||||
if (fiber.entry._disposing) return
|
||||
|
||||
this.showLog(fiber.entry, 'unload')
|
||||
|
||||
// case 6: fiber is disposed by loader behavior
|
||||
// case 7: fiber is disposed by loader behavior
|
||||
// such as inject checker, config file update, ancestor group disable
|
||||
if (fiber.entry.disabled) return
|
||||
|
||||
|
||||
Reference in New Issue
Block a user