fiber.ts 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742
  1. import { Awaitable, defineProperty, Dict, isNullable } from 'cosmokit'
  2. import { Context } from './context.ts'
  3. import { Plugin } from './registry.ts'
  4. import { buildOuterStack, composeError, DisposableList, getTraceable, isConstructor, isObject, symbols } from './utils.ts'
  5. import { Impl } from './reflect.ts'
  6. import { StandardSchemaV1 } from '@standard-schema/spec'
  7. declare module './context.ts' {
  8. export interface Context extends Pick<Fiber, 'effect'> {
  9. /** The fiber (plugin runtime instance) that owns this context. */
  10. fiber: Fiber
  11. }
  12. }
  13. const kValidationError = Symbol.for('ValidationError')
  14. /** Error raised when plugin configuration fails standard-schema validation. */
  15. export class ValidationError extends TypeError {
  16. name = 'ValidationError'
  17. /**
  18. * Build the aggregated message from schema issues.
  19. *
  20. * @param issues — the standard-schema issues, one message line each.
  21. */
  22. constructor(issues: readonly StandardSchemaV1.Issue[]) {
  23. super(`invalid config:\n` + issues.map(issue => {
  24. if (issue.path) {
  25. return ` - ${issue.message} (at ${issue.path.join('.')})`
  26. } else {
  27. return ` - ${issue.message}`
  28. }
  29. }).join('\n'))
  30. }
  31. }
  32. Object.defineProperty(ValidationError.prototype, kValidationError, {
  33. value: true,
  34. })
  35. /**
  36. * Validate and normalize config for a plugin runtime before it starts.
  37. *
  38. * @param runtime — the plugin runtime whose `Config` schema to apply.
  39. * @param config — the raw user config.
  40. * @returns the validated config, or `config` unchanged if the runtime has no schema.
  41. * @throws {ValidationError} when validation reports issues.
  42. */
  43. export function resolveConfig(runtime: Plugin.Runtime, config: any) {
  44. if (!runtime.Config) return config
  45. // TODO: async validation
  46. const result = runtime.Config['~standard'].validate(config)
  47. if ('then' in result) {
  48. throw new TypeError('Async config validation is not supported')
  49. }
  50. if (result.issues) {
  51. throw new ValidationError(result.issues)
  52. } else {
  53. return result.value
  54. }
  55. }
  56. interface AsyncDisposable<T extends Awaitable<void> = Awaitable<void>> extends PromiseLike<() => T> {
  57. (): T
  58. }
  59. /**
  60. * Function returned by an effect to release resources during disposal.
  61. *
  62. * Disposers run in reverse registration order when the owning fiber unloads;
  63. * they may be async, in which case unloading awaits them.
  64. */
  65. export type Disposable<T = any> = () => T
  66. /**
  67. * Effect body result accepted by `ctx.effect()` and plugin startup.
  68. *
  69. * Either a single disposer, a promise of one, or a (possibly async) iterable
  70. * yielding several — generator effects register each yielded disposer as it
  71. * is produced.
  72. */
  73. export type Effect<T = any> =
  74. | SyncEffect<T>
  75. | AsyncEffect<T>
  76. type SyncEffect<T = any> =
  77. | Disposable<T>
  78. | Iterable<Disposable<T>, void, void>
  79. type AsyncEffect<T = any> =
  80. | Promise<Disposable<T>>
  81. | AsyncIterable<Disposable<T>, void, void>
  82. /** Tree node used to expose nested effect labels for diagnostics. */
  83. export interface EffectMeta {
  84. /** Human-readable effect label, e.g. `ctx.on("event")` or `ctx.provide("name")`. */
  85. label: string
  86. /** Metadata of nested effects registered while this effect ran. */
  87. children: EffectMeta[]
  88. }
  89. interface EffectRunner<T> {
  90. epoch: T
  91. execute: () => any
  92. collect: (dispose: Disposable) => void
  93. getOuterStack: () => string[]
  94. }
  95. // Public effect disposers remain single-shot, but structural owners and outer
  96. // effects must still be able to join a cleanup that another caller started.
  97. const effectInertia = new WeakMap<Disposable, () => void | Promise<void>>()
  98. function runDisposable(dispose: Disposable) {
  99. const result = dispose()
  100. return effectInertia.get(dispose)?.() ?? result
  101. }
  102. /** Notify plugin teardown without allowing one observer to break ownership cleanup. */
  103. function emitPluginDisposed(context: Context, fiber: Fiber) {
  104. const args: any[] = ['internal/plugin', fiber]
  105. let callbacks: Function[]
  106. try {
  107. callbacks = context.events.dispatch('emit', args)
  108. } catch (error) {
  109. context.logger.error(error)
  110. return
  111. }
  112. for (const callback of callbacks) {
  113. try {
  114. const returned = callback(...args)
  115. void Promise.resolve(returned).catch(error => context.logger.error(error))
  116. } catch (error) {
  117. context.logger.error(error)
  118. }
  119. }
  120. }
  121. /**
  122. * Lifecycle state for one plugin fiber.
  123. *
  124. * `PENDING` — waiting for required services; `LOADING` — the plugin callback
  125. * is running; `ACTIVE` — loaded and providing; `FAILED` — the callback or its
  126. * config threw; `UNLOADING` — disposers are running; `DISPOSED` — the fiber
  127. * was removed and cannot restart.
  128. */
  129. export const enum FiberState {
  130. PENDING,
  131. LOADING,
  132. ACTIVE,
  133. FAILED,
  134. DISPOSED,
  135. UNLOADING,
  136. }
  137. /** Framework error with a stable machine-readable code. */
  138. export class CordisError extends Error {
  139. /**
  140. * @param code — the stable error code; also the default message.
  141. * @param message — optional human-readable override.
  142. */
  143. constructor(public code: CordisError.Code, message?: string) {
  144. super(message ?? CordisError.Code[code])
  145. }
  146. }
  147. /** Cordis error code definitions. */
  148. export namespace CordisError {
  149. export type Code = keyof typeof Code
  150. export const Code = {
  151. INACTIVE_EFFECT: 'cannot create effect on inactive context',
  152. } as const
  153. }
  154. const INACTIVE = '__INACTIVE__'
  155. /**
  156. * Runtime instance of one plugin application.
  157. *
  158. * A fiber tracks dependency state, validated config, lifecycle effects, and
  159. * cleanup for the plugin context returned by `ctx.plugin()`.
  160. */
  161. export class Fiber {
  162. /** Unique id within the registry; 0 for the root fiber, `null` once disposed. */
  163. public uid: number | null
  164. /** The context this fiber's plugin runs in (extends the parent context). */
  165. public readonly ctx: Context
  166. /** The validated plugin config (updated by `update()`). */
  167. public config: any
  168. /** Current lifecycle state; transitions emit `internal/status`. */
  169. public state = FiberState.PENDING
  170. /** Dispose this fiber: unload the plugin, then settle once cleanup finished. */
  171. public readonly dispose: () => Promise<void>
  172. /** Snapshot of required service implementations while loaded; `undefined` otherwise. */
  173. public store: Dict<Impl> | undefined
  174. /** The in-flight load/unload transition, if one is currently running. */
  175. public inertia: Promise<void> | undefined
  176. public readonly _hooks: Dict<DisposableList<Function>> = Object.create(null)
  177. public readonly _disposables = new DisposableList<Disposable>()
  178. // Same as `this.ctx`, but with a more specific type.
  179. protected context: Context
  180. private _error: any
  181. private _runner: EffectRunner<string>
  182. private _store: Dict<Impl> = Object.create(null)
  183. /**
  184. * Create a fiber. Plugin authors normally obtain fibers from `ctx.plugin()`
  185. * rather than constructing them directly.
  186. *
  187. * @param parent — the context the plugin was loaded from.
  188. * @param config — raw config, validated against the runtime's schema.
  189. * @param inject — resolved dependency map (service name → intercept config).
  190. * @param runtime — the shared plugin runtime, or `null` for the root fiber.
  191. * @param getOuterStack — captures the caller stack for effect diagnostics.
  192. */
  193. constructor(
  194. public parent: Context,
  195. config: any,
  196. public inject: Dict<any>,
  197. public runtime: Plugin.Runtime | null,
  198. getOuterStack: () => string[],
  199. ) {
  200. const collect = (dispose: Disposable) => {
  201. this._disposables.push(dispose)
  202. }
  203. if (runtime) {
  204. this.uid = parent.registry.counter
  205. this.ctx = this.context = parent.extend({ fiber: this })
  206. const injectEntries = Object.entries(this.inject)
  207. if (injectEntries.length) {
  208. this.ctx[Context.intercept] = Object.create(parent[Context.intercept])
  209. for (const [name, config] of injectEntries) {
  210. if (isNullable(config)) continue
  211. this.ctx[Context.intercept][name] = config
  212. }
  213. }
  214. this._runner = {
  215. epoch: INACTIVE,
  216. getOuterStack,
  217. execute: function () {
  218. if (isConstructor(runtime.callback)) {
  219. // eslint-disable-next-line new-cap
  220. const instance = new runtime.callback(this.ctx, this.config)
  221. for (const hook of instance?.[symbols.initHooks] ?? []) {
  222. hook()
  223. }
  224. return instance?.[symbols.init]?.()
  225. } else {
  226. return runtime.callback(this.ctx, this.config)
  227. }
  228. },
  229. collect,
  230. }
  231. let shouldRefresh = false
  232. this.dispose = parent.fiber.effect(() => {
  233. const remove = runtime.fibers.push(this)
  234. try {
  235. this.config = resolveConfig(runtime, config)
  236. shouldRefresh = true
  237. } catch (error) {
  238. this.ctx.logger.error(error)
  239. this._error = error
  240. }
  241. return async () => {
  242. this.uid = null
  243. emitPluginDisposed(this.context, this)
  244. if (this.ctx.registry.has(runtime.callback)) {
  245. remove()
  246. if (!runtime.fibers.length) {
  247. this.ctx.registry.delete(runtime.callback)
  248. }
  249. }
  250. this._setEpoch(INACTIVE)
  251. // A PENDING fiber can already own effects registered by an
  252. // internal/plugin observer. Its epoch is still INACTIVE, so
  253. // _setEpoch() has no transition to drive; explicitly unload that
  254. // pre-activation work before reporting disposal complete.
  255. if (!this.inertia) {
  256. this._updateState(() => {
  257. this.inertia = this._unload()
  258. return FiberState.UNLOADING
  259. })
  260. }
  261. // `this.inertia` itself should never reject — both `_reload` and
  262. // `_unload` swallow their own work errors via `ctx.logger.error`.
  263. // If it *does* reject, the only remaining cause is the logger
  264. // itself failing, which we can't recover from in this exact spot
  265. // (calling the logger again is what just failed). Let the
  266. // rejection propagate; process-level crash is the honest outcome.
  267. while (this.inertia) {
  268. await this.inertia
  269. }
  270. }
  271. }, 'ctx.plugin()')
  272. try {
  273. // Publish only after the parent owns a fully assigned disposer. A
  274. // synchronous observer may dispose either this fiber or its parent.
  275. this.context.emit('internal/plugin', this)
  276. } catch (error) {
  277. // Publication failed synchronously. The disposer removes the child
  278. // from both the parent and runtime before control escapes.
  279. void Promise.resolve(this.dispose()).catch(reason => this.ctx.logger.error(reason))
  280. throw error
  281. }
  282. // Keep the initial notification's historical PENDING view. The loader
  283. // may also extend `inject` in that notification, so resolve dependencies
  284. // only after publication. A reentrant parent unload makes the child
  285. // disposer responsible for draining any PENDING effects instead.
  286. if (this.uid !== null && parent.fiber.state !== FiberState.UNLOADING) {
  287. for (const name of Object.keys(this.inject)) {
  288. this._checkImpl(name)
  289. }
  290. if (shouldRefresh) this._refresh()
  291. }
  292. } else {
  293. this.uid = 0
  294. this.ctx = this.context = parent
  295. this.state = FiberState.ACTIVE
  296. this.store = Object.create(null)
  297. this._runner = {
  298. epoch: '',
  299. getOuterStack,
  300. execute: () => {},
  301. collect,
  302. }
  303. this.dispose = () => this.restart()
  304. }
  305. }
  306. /** The plugin's display name, inherited from the nearest named ancestor, else `'root'`. */
  307. get name() {
  308. let fiber: Fiber = this
  309. do {
  310. if (fiber.runtime?.name) return fiber.runtime.name
  311. fiber = fiber.parent.fiber
  312. } while (fiber !== fiber.parent.fiber)
  313. return 'root'
  314. }
  315. /**
  316. * Throw if the fiber has already been disposed.
  317. *
  318. * @returns nothing when the fiber is still active.
  319. * @throws {CordisError} `INACTIVE_EFFECT` when the fiber's uid has been cleared.
  320. */
  321. assertActive() {
  322. if (this.uid !== null) return
  323. throw new CordisError('INACTIVE_EFFECT')
  324. }
  325. private _execute<T>(runner: EffectRunner<T>) {
  326. const oldEpoch = runner.epoch
  327. return composeError((info) => {
  328. const safeCollect = (dispose: void | Disposable) => {
  329. if (typeof dispose === 'function') {
  330. runner.collect(dispose)
  331. } else if (!isNullable(dispose)) {
  332. throw new TypeError('Invalid effect')
  333. }
  334. }
  335. const effect: Effect = runner.execute.call(this)
  336. if (typeof effect === 'function') {
  337. return runner.collect(effect)
  338. } else if (isNullable(effect)) {
  339. // return
  340. } else if (!isObject(effect)) {
  341. throw new TypeError('Invalid effect')
  342. } else if ('then' in effect) {
  343. return effect.then(safeCollect)
  344. } else if (Symbol.iterator in effect) {
  345. info.error = new Error()
  346. const iter = effect[Symbol.iterator]()
  347. while (true) {
  348. const result = iter.next()
  349. safeCollect(result.value)
  350. if (result.done) return
  351. }
  352. } else if (Symbol.asyncIterator in effect) {
  353. const iter = effect[Symbol.asyncIterator]()
  354. return (async () => {
  355. // force async stack trace
  356. await Promise.resolve()
  357. info.error = new Error()
  358. while (true) {
  359. if (runner.epoch !== oldEpoch) return
  360. const result = await iter.next()
  361. safeCollect(result.value)
  362. if (result.done) return
  363. }
  364. })()
  365. } else {
  366. throw new TypeError('Invalid effect')
  367. }
  368. }, runner.getOuterStack)
  369. }
  370. /**
  371. * Register a cleanup-aware effect on this fiber.
  372. *
  373. * `execute` runs immediately; the disposers it produces are collected and
  374. * run (in reverse order) either when the returned disposer is called or
  375. * when the fiber unloads, whichever comes first. Calling the disposer twice
  376. * is a no-op. Throws `CordisError('INACTIVE_EFFECT')` if the fiber is
  377. * already disposed, and `TypeError` if `execute` returns an invalid shape.
  378. *
  379. * @param execute — the effect body; see {@link Effect} for accepted shapes.
  380. * @param label — effect label shown in `getEffects()` diagnostics.
  381. * @returns a disposer that tears the effect down and settles once done.
  382. */
  383. effect(execute: () => SyncEffect, label?: string): Disposable<Promise<void>>
  384. /** Same as above for async effects; the disposer is also awaitable. */
  385. effect(execute: () => Effect, label?: string): AsyncDisposable<Promise<void>>
  386. effect(execute: () => Effect, label = 'anonymous'): any {
  387. this.assertActive()
  388. if (this.state === FiberState.UNLOADING) {
  389. throw new CordisError('INACTIVE_EFFECT')
  390. }
  391. const disposables: Disposable[] = []
  392. let disposing = false
  393. let disposalTask: void | Promise<void>
  394. const dispose = () => {
  395. if (disposing) return disposalTask
  396. disposing = true
  397. let task!: void | Promise<void>
  398. for (const disposable of disposables.splice(0).reverse()) {
  399. if (task) {
  400. task = task.then(() => runDisposable(disposable))
  401. } else {
  402. const result = runDisposable(disposable)
  403. if (isObject(result) && 'then' in result) {
  404. task = result as any
  405. }
  406. }
  407. }
  408. return disposalTask = task
  409. }
  410. const meta: EffectMeta = { label, children: [] }
  411. const runner: EffectRunner<boolean> = {
  412. execute,
  413. epoch: true,
  414. collect: (dispose) => {
  415. disposables.push(dispose)
  416. this._disposables.delete(dispose)
  417. if (dispose[symbols.effect]) {
  418. meta.children.push(dispose[symbols.effect])
  419. }
  420. },
  421. getOuterStack: buildOuterStack(),
  422. }
  423. let task: void | Promise<void>
  424. let executing = true
  425. let resolveSetup: (() => void) | undefined
  426. let rejectSetup: ((reason: unknown) => void) | undefined
  427. let setupBarrier: Promise<void> | undefined
  428. let setupFailed = false
  429. let inFlight: void | Promise<void>
  430. let removeWrapper = () => false
  431. const waitForSetup = () => {
  432. setupBarrier ??= new Promise<void>((resolve, reject) => {
  433. resolveSetup = resolve
  434. rejectSetup = reject
  435. })
  436. return setupBarrier
  437. }
  438. const disposeAfter = (setup: PromiseLike<void>) => {
  439. return Promise.resolve(setup).then(
  440. () => dispose(),
  441. async (reason) => {
  442. await dispose()
  443. throw reason
  444. },
  445. )
  446. }
  447. const finalizeDisposal = (callback: () => void | Promise<void>) => {
  448. let result: void | Promise<void>
  449. try {
  450. result = callback()
  451. } catch (error) {
  452. removeWrapper()
  453. throw error
  454. }
  455. if (isObject(result) && 'then' in result) {
  456. const pending = Promise.resolve(result).finally(() => {
  457. removeWrapper()
  458. if (inFlight === pending) inFlight = undefined
  459. })
  460. return inFlight = pending
  461. }
  462. removeWrapper()
  463. return result
  464. }
  465. const wrapper = defineProperty(() => {
  466. // A synchronous setup failure can race an owner unload that already
  467. // captured this wrapper but has not invoked it yet. The failed effect is
  468. // never returned publicly, so let that internal caller await rollback.
  469. if (!runner.epoch) return setupFailed ? inFlight : undefined
  470. runner.epoch = false
  471. return finalizeDisposal(() => {
  472. if (executing) return disposeAfter(waitForSetup())
  473. return task ? disposeAfter(task) : dispose()
  474. })
  475. }, symbols.effect, meta) as AsyncDisposable
  476. effectInertia.set(wrapper, () => inFlight)
  477. // Make the effect visible to a reentrant owner unload before execute()
  478. // runs any plugin code. Async teardown stays owner-visible until it
  479. // settles, allowing an outer effect to join cleanup another caller began.
  480. removeWrapper = this._disposables.push(wrapper)
  481. try {
  482. task = this._execute(runner)
  483. } catch (reason) {
  484. executing = false
  485. setupFailed = true
  486. runner.epoch = false
  487. let cleanup: void | Promise<void>
  488. try {
  489. cleanup = finalizeDisposal(dispose)
  490. } finally {
  491. rejectSetup?.(reason)
  492. }
  493. if (isObject(cleanup) && 'then' in cleanup) {
  494. cleanup.catch(error => this.ctx.logger.error(error))
  495. }
  496. throw reason
  497. }
  498. executing = false
  499. if (setupBarrier) {
  500. Promise.resolve(task).then(resolveSetup, rejectSetup)
  501. }
  502. // prevent unhandled rejection — both from `task` itself and from the
  503. // disposer chain if it fails to settle cleanly.
  504. task?.catch(() => {
  505. if (!runner.epoch) return dispose()
  506. return finalizeDisposal(dispose)
  507. }).catch((error) => this.ctx.logger.error(error))
  508. const disposeAsync = () => {
  509. if (!runner.epoch) return
  510. runner.epoch = false
  511. return finalizeDisposal(dispose)
  512. }
  513. wrapper.then = async (onFulfilled, onRejected) => {
  514. return Promise.resolve(task)
  515. .then(() => disposeAsync)
  516. .then(onFulfilled, onRejected)
  517. }
  518. return wrapper
  519. }
  520. /**
  521. * Return metadata for currently registered effects.
  522. *
  523. * @returns one {@link EffectMeta} tree per labeled live effect.
  524. */
  525. getEffects() {
  526. return [...this._disposables]
  527. .map<EffectMeta>(dispose => dispose[symbols.effect])
  528. .filter(Boolean)
  529. }
  530. private _getState() {
  531. if (this.uid === null) return FiberState.DISPOSED
  532. if (this._error) return FiberState.FAILED
  533. if (this._runner.epoch !== INACTIVE) return FiberState.ACTIVE
  534. return FiberState.PENDING
  535. }
  536. private _updateState(callback: () => void | FiberState) {
  537. const oldState = this.state
  538. this.state = callback() ?? this._getState()
  539. if (oldState === this.state) return
  540. // FIXME internal/fiber-info
  541. this.context.emit('internal/status', this, oldState)
  542. // only notify changes between ACTIVE and NON-ACTIVE states
  543. if (oldState !== FiberState.ACTIVE && this.state !== FiberState.ACTIVE) return
  544. for (const key of Reflect.ownKeys(this.ctx.reflect.store)) {
  545. const impl = this.ctx.reflect.store[key as symbol]
  546. if (impl.fiber !== this) continue
  547. this.ctx.reflect.notify([impl.name])
  548. }
  549. }
  550. _checkImpl(name: string) {
  551. const impl = this.ctx.reflect._getImpl(name, true)
  552. if (!impl) return delete this._store[name]
  553. try {
  554. if (impl.check && !impl.check.call(getTraceable(this.ctx, impl.value))) {
  555. return delete this._store[name]
  556. }
  557. } catch (error) {
  558. impl.fiber.ctx.logger.error(error)
  559. return delete this._store[name]
  560. }
  561. this._store[name] = impl
  562. }
  563. _refresh() {
  564. let epoch: string | boolean = false
  565. epoch = ''
  566. for (const name of Object.keys(this.inject)) {
  567. const impl = this._store[name]
  568. if (!impl) {
  569. epoch = INACTIVE
  570. break
  571. }
  572. epoch += ':' + impl.fiber.uid
  573. }
  574. this._setEpoch(epoch)
  575. }
  576. private _setEpoch(epoch: string) {
  577. const oldEpoch = this._runner.epoch
  578. if (epoch === oldEpoch) return
  579. this._runner.epoch = epoch
  580. if (this.inertia) return
  581. this._updateState(() => {
  582. if (epoch !== INACTIVE && oldEpoch === INACTIVE) {
  583. this.inertia = this._reload()
  584. return FiberState.LOADING
  585. } else {
  586. this.inertia = this._unload()
  587. return FiberState.UNLOADING
  588. }
  589. })
  590. }
  591. private async _reload() {
  592. this.store = { ...this._store }
  593. const oldEpoch = this._runner.epoch
  594. try {
  595. await Promise.resolve()
  596. // A disposer queued before this checkpoint may already have invalidated
  597. // the load. Do not run plugin code for a stale epoch; the state update
  598. // below will drain any effects collected while the fiber was PENDING.
  599. if (this._runner.epoch === oldEpoch) {
  600. await this._execute(this._runner)
  601. }
  602. } catch (reason) {
  603. // impl guarantees that the error is non-null (?)
  604. this.ctx.logger.error(reason)
  605. this._error = reason
  606. this._runner.epoch = INACTIVE
  607. }
  608. this._updateState(() => {
  609. if (this._runner.epoch === oldEpoch) {
  610. this.inertia = undefined
  611. } else {
  612. this.inertia = this._unload()
  613. return FiberState.UNLOADING
  614. }
  615. })
  616. }
  617. private async _unload() {
  618. await Promise.all(this._disposables.clear().map(async (dispose) => {
  619. try {
  620. await composeError(async (info) => {
  621. await Promise.resolve()
  622. info.error = new Error()
  623. await runDisposable(dispose)
  624. }, this._runner.getOuterStack)
  625. } catch (reason) {
  626. this.ctx.logger.error(reason)
  627. }
  628. }))
  629. this.store = undefined
  630. this._updateState(() => {
  631. if (this._runner.epoch === INACTIVE) {
  632. this.inertia = undefined
  633. } else {
  634. this.inertia = this._reload()
  635. return FiberState.LOADING
  636. }
  637. })
  638. }
  639. /**
  640. * Wait for current lifecycle work and rethrow startup errors.
  641. *
  642. * @returns this fiber, once it has settled into a stable state.
  643. * @throws the config-validation or plugin-startup error, if any.
  644. */
  645. async await() {
  646. while (this.inertia) {
  647. await this.inertia
  648. }
  649. if (this._error) throw this._error
  650. return this
  651. }
  652. /**
  653. * Dispose and immediately reload this plugin with its current config.
  654. *
  655. * @returns a promise resolving once the reload settled.
  656. * @throws {CordisError} `INACTIVE_EFFECT` when the fiber is already disposed.
  657. */
  658. async restart() {
  659. this.assertActive()
  660. this._setEpoch(INACTIVE)
  661. this._refresh()
  662. await this.await()
  663. }
  664. /**
  665. * Validate and apply new config, then restart the plugin.
  666. *
  667. * Runs the `internal/update` waterfall first, so update hooks (and HMR)
  668. * can veto or replace the restart.
  669. *
  670. * @param config — the new raw config; validated before anything restarts.
  671. * @param noSave — hint for persistence hooks not to write the change back.
  672. * @returns nothing; the restart runs behind the `internal/update` waterfall.
  673. * @throws {ValidationError} when the new config fails validation.
  674. */
  675. update(config: any, noSave = false) {
  676. this.assertActive()
  677. config = resolveConfig(this.runtime!, config)
  678. this.context.waterfall(this, 'internal/update', config, noSave, () => {
  679. this.config = config
  680. this._error = undefined
  681. return this.restart()
  682. })
  683. }
  684. }