fix(scope): harden lifecycle ownership foundation

Make Cordis construction and teardown ownership reentrancy-safe, then carry caller and provider ownership through reservation, setup, publication, quiescence, and sentinel retirement.

Stabilize registry carriers and factory/workflow boundaries, add adversarial lifecycle regressions, and align the rewritten RFC plus generated contracts with the enforced behavior.
This commit is contained in:
Tianyi Cui
2026-07-12 08:57:05 +08:00
parent 197f7237d2
commit c5b1a7941f
39 changed files with 2945 additions and 461 deletions

1
vendor/README.md vendored
View File

@@ -35,6 +35,7 @@ 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.
## Sync procedure

View File

@@ -80,6 +80,35 @@ interface EffectRunner<T> {
getOuterStack: () => string[]
}
// Public effect disposers remain single-shot, but structural owners and outer
// effects must still be able to join a cleanup that another caller started.
const effectInertia = new WeakMap<Disposable, () => void | Promise<void>>()
function runDisposable(dispose: Disposable) {
const result = dispose()
return effectInertia.get(dispose)?.() ?? result
}
/** Notify plugin teardown without allowing one observer to break ownership cleanup. */
function emitPluginDisposed(context: Context, fiber: Fiber) {
const args: any[] = ['internal/plugin', fiber]
let callbacks: Function[]
try {
callbacks = context.events.dispatch('emit', args)
} catch (error) {
context.logger.error(error)
return
}
for (const callback of callbacks) {
try {
const returned = callback(...args)
void Promise.resolve(returned).catch(error => context.logger.error(error))
} catch (error) {
context.logger.error(error)
}
}
}
/** Lifecycle state for one plugin fiber. */
export const enum FiberState {
PENDING,
@@ -175,24 +204,19 @@ export class Fiber {
collect,
}
this.context.emit('internal/plugin', this)
for (const name of Object.keys(this.inject)) {
this._checkImpl(name)
}
let shouldRefresh = false
this.dispose = parent.fiber.effect(() => {
const remove = runtime.fibers.push(this)
try {
this.config = resolveConfig(runtime, config)
this._refresh()
shouldRefresh = true
} catch (error) {
this.ctx.logger.error(error)
this._error = error
}
return async () => {
this.uid = null
this.context.emit('internal/plugin', this)
emitPluginDisposed(this.context, this)
if (this.ctx.registry.has(runtime.callback)) {
remove()
if (!runtime.fibers.length) {
@@ -200,6 +224,16 @@ export class Fiber {
}
}
this._setEpoch(INACTIVE)
// A PENDING fiber can already own effects registered by an
// internal/plugin observer. Its epoch is still INACTIVE, so
// _setEpoch() has no transition to drive; explicitly unload that
// pre-activation work before reporting disposal complete.
if (!this.inertia) {
this._updateState(() => {
this.inertia = this._unload()
return FiberState.UNLOADING
})
}
// `this.inertia` itself should never reject — both `_reload` and
// `_unload` swallow their own work errors via `ctx.logger.error`.
// If it *does* reject, the only remaining cause is the logger
@@ -211,6 +245,28 @@ export class Fiber {
}
}
}, 'ctx.plugin()')
try {
// Publish only after the parent owns a fully assigned disposer. A
// synchronous observer may dispose either this fiber or its parent.
this.context.emit('internal/plugin', this)
} catch (error) {
// Publication failed synchronously. The disposer removes the child
// from both the parent and runtime before control escapes.
void Promise.resolve(this.dispose()).catch(reason => this.ctx.logger.error(reason))
throw error
}
// Keep the initial notification's historical PENDING view. The loader
// may also extend `inject` in that notification, so resolve dependencies
// only after publication. A reentrant parent unload makes the child
// disposer responsible for draining any PENDING effects instead.
if (this.uid !== null && parent.fiber.state !== FiberState.UNLOADING) {
for (const name of Object.keys(this.inject)) {
this._checkImpl(name)
}
if (shouldRefresh) this._refresh()
}
} else {
this.uid = 0
this.ctx = this.context = parent
@@ -292,21 +348,28 @@ export class Fiber {
effect(execute: () => Effect, label?: string): AsyncDisposable<Promise<void>>
effect(execute: () => Effect, label = 'anonymous'): any {
this.assertActive()
if (this.state === FiberState.UNLOADING) {
throw new CordisError('INACTIVE_EFFECT')
}
const disposables: Disposable[] = []
let disposing = false
let disposalTask: void | Promise<void>
const dispose = () => {
if (disposing) return disposalTask
disposing = true
let task!: void | Promise<void>
for (const dispose of disposables.splice(0).reverse()) {
for (const disposable of disposables.splice(0).reverse()) {
if (task) {
task = task.then(dispose)
task = task.then(() => runDisposable(disposable))
} else {
const result = dispose()
const result = runDisposable(disposable)
if (isObject(result) && 'then' in result) {
task = result as any
}
}
}
return task
return disposalTask = task
}
const meta: EffectMeta = { label, children: [] }
@@ -324,34 +387,107 @@ export class Fiber {
}
let task: void | Promise<void>
let executing = true
let resolveSetup: (() => void) | undefined
let rejectSetup: ((reason: unknown) => void) | undefined
let setupBarrier: Promise<void> | undefined
let setupFailed = false
let inFlight: void | Promise<void>
let removeWrapper = () => false
const waitForSetup = () => {
setupBarrier ??= new Promise<void>((resolve, reject) => {
resolveSetup = resolve
rejectSetup = reject
})
return setupBarrier
}
const disposeAfter = (setup: PromiseLike<void>) => {
return Promise.resolve(setup).then(
() => dispose(),
async (reason) => {
await dispose()
throw reason
},
)
}
const finalizeDisposal = (callback: () => void | Promise<void>) => {
let result: void | Promise<void>
try {
result = callback()
} catch (error) {
removeWrapper()
throw error
}
if (isObject(result) && 'then' in result) {
const pending = Promise.resolve(result).finally(() => {
removeWrapper()
if (inFlight === pending) inFlight = undefined
})
return inFlight = pending
}
removeWrapper()
return result
}
const wrapper = defineProperty(() => {
// A synchronous setup failure can race an owner unload that already
// captured this wrapper but has not invoked it yet. The failed effect is
// never returned publicly, so let that internal caller await rollback.
if (!runner.epoch) return setupFailed ? inFlight : undefined
runner.epoch = false
return finalizeDisposal(() => {
if (executing) return disposeAfter(waitForSetup())
return task ? disposeAfter(task) : dispose()
})
}, symbols.effect, meta) as AsyncDisposable
effectInertia.set(wrapper, () => inFlight)
// Make the effect visible to a reentrant owner unload before execute()
// runs any plugin code. Async teardown stays owner-visible until it
// settles, allowing an outer effect to join cleanup another caller began.
removeWrapper = this._disposables.push(wrapper)
try {
task = this._execute(runner)
} catch (reason) {
dispose()
executing = false
setupFailed = true
runner.epoch = false
let cleanup: void | Promise<void>
try {
cleanup = finalizeDisposal(dispose)
} finally {
rejectSetup?.(reason)
}
if (isObject(cleanup) && 'then' in cleanup) {
cleanup.catch(error => this.ctx.logger.error(error))
}
throw reason
}
executing = false
if (setupBarrier) {
Promise.resolve(task).then(resolveSetup, rejectSetup)
}
// prevent unhandled rejection — both from `task` itself and from the
// disposer chain if it fails to settle cleanly.
task?.catch(dispose).catch((error) => this.ctx.logger.error(error))
const wrapper = defineProperty(() => {
if (!runner.epoch) return
runner.epoch = false
return task ? task.then(dispose) : dispose()
}, symbols.effect, meta) as AsyncDisposable
task?.catch(() => {
if (!runner.epoch) return dispose()
return finalizeDisposal(dispose)
}).catch((error) => this.ctx.logger.error(error))
const disposeAsync = () => {
if (!runner.epoch) return
runner.epoch = false
return dispose()
return finalizeDisposal(dispose)
}
wrapper.then = async (onFulfilled, onRejected) => {
return Promise.resolve(task)
.then(() => disposeAsync)
.then(onFulfilled, onRejected)
}
disposables.push(this._disposables.push(wrapper))
return wrapper
}
@@ -434,7 +570,12 @@ export class Fiber {
const oldEpoch = this._runner.epoch
try {
await Promise.resolve()
await this._execute(this._runner)
// A disposer queued before this checkpoint may already have invalidated
// the load. Do not run plugin code for a stale epoch; the state update
// below will drain any effects collected while the fiber was PENDING.
if (this._runner.epoch === oldEpoch) {
await this._execute(this._runner)
}
} catch (reason) {
// impl guarantees that the error is non-null (?)
this.ctx.logger.error(reason)
@@ -457,7 +598,7 @@ export class Fiber {
await composeError(async (info) => {
await Promise.resolve()
info.error = new Error()
await dispose()
await runDisposable(dispose)
}, this._runner.getOuterStack)
} catch (reason) {
this.ctx.logger.error(reason)