From 0586bc40a658e5a2625dc4b4e5f57776311ce06c Mon Sep 17 00:00:00 2001 From: Marcel Laverdet Date: Mon, 7 Aug 2023 13:32:41 -0500 Subject: [PATCH] Reliable support for conditional imports I was struggling with dynamic imports for a while before I realized I was actually working on the halting problem. This is hinted at in the top-level await proposal: https://github.com/tc39/proposal-top-level-await#rejected-deadlock-prevention-mechanisms Instead, we treat resolved dynamic imports basically as static imports, and they will participate in the cyclic resolution algorithm. Since dynamic imports in practice tend to just be well-behaved conditional or lazy imports this works out well. This are probably some races here if a new dynamic import is dispatched while an update is processing but haven't actually run into it myself. --- __tests__/__fixtures__/module.ts | 6 +- __tests__/accept.ts | 14 + __tests__/dynamic.ts | 25 ++ __tests__/reload.ts | 40 ++ functional.ts | 27 ++ runtime/controller.ts | 710 ++++++++++++++----------------- runtime/hot.ts | 34 +- runtime/instance.ts | 126 +++--- runtime/traverse.ts | 252 ++++++++--- runtime/utility.ts | 22 + 10 files changed, 726 insertions(+), 530 deletions(-) create mode 100644 __tests__/dynamic.ts diff --git a/__tests__/__fixtures__/module.ts b/__tests__/__fixtures__/module.ts index 5dcba16..5c441aa 100644 --- a/__tests__/__fixtures__/module.ts +++ b/__tests__/__fixtures__/module.ts @@ -63,10 +63,12 @@ export class TestModule { return controller.application.requestUpdateResult(); } - async update(source: () => string) { + async update(source?: () => string) { assert(this.environment !== undefined); assert(this.vm !== undefined); - this.source = source; + if (source) { + this.source = source; + } this.vm = undefined; const vm = this.instantiate(this.environment); vm.evaluated = true; diff --git a/__tests__/accept.ts b/__tests__/accept.ts index b58c7d4..8bba850 100644 --- a/__tests__/accept.ts +++ b/__tests__/accept.ts @@ -19,3 +19,17 @@ test("accept handlers should run top down", async () => { const result = await main.releaseUpdate(); expect(result?.type).toBe(UpdateStatus.success); }); + +test("should be able to accept a cycle root", async () => { + const main = new TestModule(() => + `import {} from ${left}; + import.meta.hot.accept(${left});`); + const left: TestModule = new TestModule(() => + `import {} from ${right};`); + const right = new TestModule(() => + `import {} from ${left};`); + await main.dispatch(); + await right.update(() => ""); + const result = await main.releaseUpdate(); + expect(result?.type).toBe(UpdateStatus.success); +}); diff --git a/__tests__/dynamic.ts b/__tests__/dynamic.ts new file mode 100644 index 0000000..5110569 --- /dev/null +++ b/__tests__/dynamic.ts @@ -0,0 +1,25 @@ +/* eslint-disable @typescript-eslint/restrict-template-expressions */ +import { expect, test } from "@jest/globals"; +import { UpdateStatus } from "../runtime/controller.js"; +import { TestModule } from "./__fixtures__/module.js"; + +test("dynamic import with error throws", async () => { + const main = new TestModule(() => + `const module = await import(${error});`); + const error = new TestModule(() => + "throw new Error()"); + await expect(main.dispatch()).rejects.toThrow(); +}); + +test("lazy dynamic import with error is recoverable", async () => { + const main = new TestModule(() => + `const module = import(${error}); + await module.catch(() => {}); + import.meta.hot.accept(${error});`); + const error = new TestModule(() => + "throw new Error()"); + await main.dispatch(); + await error.update(() => ""); + const result = await main.releaseUpdate(); + expect(result?.type).toBe(UpdateStatus.success); +}); diff --git a/__tests__/reload.ts b/__tests__/reload.ts index 5e7cf24..fe7c7a7 100644 --- a/__tests__/reload.ts +++ b/__tests__/reload.ts @@ -109,3 +109,43 @@ test("declined with accept import", async () => { const result = await main.releaseUpdate(); expect(result?.type).toBe(UpdateStatus.success); }); + +test("errors are recoverable", async () => { + const main = new TestModule(() => + `import { counter } from ${accepted}; + import.meta.hot.accept(${accepted}, () => { + expect(counter).toBe(3); + globalThis.seen = true; + });`); + const accepted = new TestModule(() => + "export const counter = 1;"); + await main.dispatch(); + await accepted.update(() => + `export const counter = 2; + throw new Error();`); + const result = await main.releaseUpdate(); + expect(result?.type).toBe(UpdateStatus.evaluationFailure); + await accepted.update(() => + "export const counter = 3;"); + const result2 = await main.releaseUpdate(); + expect(result2?.type).toBe(UpdateStatus.success); + expect(main.global.seen).toBe(true); +}); + +test("errors persist", async () => { + const main = new TestModule(() => + `import {} from ${error}; + import.meta.hot.accept();`); + const error = new TestModule(() => ""); + await main.dispatch(); + await error.update(() => + "throw new Error();"); + const result = await main.releaseUpdate(); + expect(result?.type).toBe(UpdateStatus.evaluationFailure); + await main.update(); + const result2 = await main.releaseUpdate(); + expect(result2?.type).toBe(UpdateStatus.evaluationFailure); + await error.update(() => ""); + const result3 = await main.releaseUpdate(); + expect(result3?.type).toBe(UpdateStatus.success); +}); diff --git a/functional.ts b/functional.ts index e561cc4..15a7ee5 100644 --- a/functional.ts +++ b/functional.ts @@ -27,6 +27,33 @@ export function somePredicate(predicates: Iterable | Predicat (predicate, next) => value => predicate(value) || next(value)); } +/** + * Iterate each item from an iterable of iterables. + * @internal + */ +// TypeScript can't figure out the types on these so extra hints are needed. +export function concat(iterator: Iterable[]): IterableIterator; +/** @internal */ +// eslint-disable-next-line @typescript-eslint/unified-signatures +export function concat(iterator: IterableIterator>): IterableIterator; +/** @internal */ +// eslint-disable-next-line @typescript-eslint/unified-signatures +export function concat(iterator: Iterable>): IterableIterator; + +/** + * Iterate each item in a statically supplied argument vector of iterables. + * @internal + */ +export function concat(...args: Iterable[]): IterableIterator; + +export function *concat(...args: any[]) { + for (const iterable of args.length === 1 ? args[0] : args) { + for (const value of iterable) { + yield value; + } + } +} + /** * Remove all non-truthy elements from a type * @internal diff --git a/runtime/controller.ts b/runtime/controller.ts index a46965e..a428c10 100644 --- a/runtime/controller.ts +++ b/runtime/controller.ts @@ -1,16 +1,14 @@ import type { LoadedModuleRequestEntry, ModuleBody, ModuleDeclaration } from "./declaration.js"; -import type { Hot } from "./hot.js"; import type { BindingEntry, ExportIndirectEntry, ExportIndirectStarEntry, ExportStarEntry } from "./module/binding.js"; -import type{ AbstractModuleController, ModuleNamespace, SelectModuleInstance } from "./module.js"; -import type { TraverseCyclicState } from "./traverse.js"; +import type{ AbstractModuleController, ModuleNamespace } from "./module.js"; import assert from "node:assert/strict"; import Fn from "dynohot/functional"; import { dispose, isAccepted, isAcceptedSelf, isDeclined, isInvalidated, prune, tryAccept, tryAcceptSelf } from "./hot.js"; import { ReloadableModuleInstance } from "./instance.js"; import { BindingType } from "./module/binding.js"; import { ModuleStatus } from "./module.js"; -import { traverseBreadthFirst, traverseDepthFirst } from "./traverse.js"; -import { debounceAsync, debounceTimer, discriminatedTypePredicate, evictModule } from "./utility.js"; +import { makeTraversalState, traverseBreadthFirst, traverseDepthFirst } from "./traverse.js"; +import { debounceAsync, debounceTimer, discriminatedTypePredicate, evictModule, iterateWithRollback } from "./utility.js"; import { FileWatcher } from "./watcher.js"; /** @internal */ @@ -141,63 +139,22 @@ function logUpdate(update: UpdateResult) { } } -function iterateReloadables(select: SelectModuleInstance) { - return function*(controller: ReloadableModuleController) { - for (const child of controller.select(select).declaration.loadedModules) { - const instance = child.controller(); - if (instance.reloadable) { - yield instance; - } - } - }; -} - -function iterateReloadablesWithDynamics(select: SelectModuleInstance) { - return function*(controller: ReloadableModuleController) { - const instance = controller.select(select); - for (const entry of instance.declaration.loadedModules) { - const controller = entry.controller(); - if (controller.reloadable) { - yield controller; - } - } - yield* Fn.map(instance.dynamicImports, entry => entry.controller); - }; -} - -function iterateReloadableInstances(select: SelectModuleInstance) { - return function*(instance: ReloadableModuleInstance) { - for (const child of instance.declaration.loadedModules) { - const instance = child.controller(); - if (instance.reloadable) { - yield instance.select(select); - } - } - }; -} - /** @internal */ export class ReloadableModuleController implements AbstractModuleController { - private wasSelfAccepted: boolean | undefined; - private current: ReloadableModuleInstance | undefined; - private pending: ReloadableModuleInstance | undefined; - private previous: ReloadableModuleInstance | undefined; - private staging: ReloadableModuleInstance | undefined; - private temporary: ReloadableModuleInstance | undefined; - private version = 0; /** This is the physical location of the module, as seen by the loader chain */ readonly location; /** This is the resolutionURL as specified by the loader chain, seen by `import.meta.url` */ readonly url; readonly reloadable = true; - traversalState: TraverseCyclicState | undefined; - visitation = 0; - - static selectCurrent = (controller: ReloadableModuleController) => controller.current; - private static readonly iterateCurrent = iterateReloadables(controller => controller.current); - private static readonly iteratePending = iterateReloadables(controller => controller.pending); - private static readonly iterateTemporary = iterateReloadables(controller => controller.temporary); + private current: ReloadableModuleInstance | undefined; + private pending: ReloadableModuleInstance | undefined; + private previous: ReloadableModuleInstance | undefined; + private staging: ReloadableModuleInstance | undefined; + private temporary: ReloadableModuleInstance | undefined; + private traversal = makeTraversalState(); + private visitIndex = 0; + private version = 0; constructor( public readonly application: Application, @@ -241,33 +198,58 @@ export class ReloadableModuleController implements AbstractModuleController { } async dispatch(this: ReloadableModuleController) { - if (this.current as ReloadableModuleInstance | undefined === undefined) { - // Promote `staging` to `current`, and instantiate all reloadable modules. - traverseBreadthFirst( + if (this.current === undefined) { + // Place `current` from `staging` if it's not set up, instantiate all reloadable + // modules, and perform link. + traverseDepthFirst( this, - iterateReloadables(controller => controller.current ?? controller.staging), - controller => { - if (controller.current === undefined) { - const { staging } = controller; - assert(staging !== undefined); - controller.current = staging; - controller.staging = undefined; + node => node.traversal, + (node, traversal) => { + node.traversal = traversal; + if (node.current === undefined) { + const staging = node.select(controller => controller.staging); + node.current = staging; + node.current.instantiate(); + } + return node.iterate(); + }, + (nodes): undefined => { + const withRollback = iterateWithRollback(nodes, nodes => { + for (const node of nodes) { + if (node.select().unlink()) { + node.current = undefined; + } + } + }); + for (const node of withRollback) { + node.select().link(); + } + }, + pendingNodes => { + for (const node of pendingNodes) { + if (node.select().unlink()) { + node.current = undefined; + } } - controller.current.instantiate(); }); - // Perform initial linkage - assert(this.current !== undefined); - traverseDepthFirst( - this.current, - iterateReloadableInstances(ReloadableModuleController.selectCurrent), - node => node.link(ReloadableModuleController.selectCurrent)); - - // Perform initial evaluation + // Evaluate await traverseDepthFirst( - this.current, - iterateReloadableInstances(ReloadableModuleController.selectCurrent), - node => node.evaluate()); + this, + node => node.traversal, + (node, traversal) => { + node.traversal = traversal; + return node.iterate(); + }, + async nodes => { + for (const node of nodes) { + const current = node.select(); + if (current === node.staging) { + node.staging = undefined; + } + await current.evaluate(); + } + }); } } @@ -323,49 +305,33 @@ export class ReloadableModuleController implements AbstractModuleController { this.staging = new ReloadableModuleInstance(this, declaration); } - lookupSpecifier(hot: Hot, specifier: string) { - const select = ((): SelectModuleInstance | undefined => { - const check = (select: SelectModuleInstance) => { - const instance = select(this); - if (instance !== undefined) { - if ( - instance.state.status !== ModuleStatus.new && - instance.state.environment.hot === hot - ) { - return select; - } - } - }; - return check(ReloadableModuleController.selectCurrent) ?? check(controller => controller.pending); - })(); - if (select !== undefined) { - const instance = this.select(select); - const controller = function() { - const entry = instance.declaration.loadedModules.find(entry => entry.specifier === specifier); - if (entry === undefined) { - const dynamicImport = instance.dynamicImports.find(entry => entry.specifier === specifier); - return dynamicImport?.controller; - } else { - return entry.controller(); - } - }(); - if (controller) { - if (controller.reloadable) { - return controller; - } else { - return null; - } - } - } - } - select(select = ReloadableModuleController.selectCurrent) { const instance = select(this); assert(instance); return instance; } - private async requestUpdate(): Promise { + private *iterate(select = ReloadableModuleController.selectCurrent) { + for (const child of this.select(select).declaration.loadedModules) { + const controller = child.controller(); + if (controller.reloadable) { + yield controller; + } + } + } + + private *iterateWithDynamics( + select = ReloadableModuleController.selectCurrent, + selectDynamic = ReloadableModuleController.selectCurrent, + ) { + yield* this.iterate(select); + const instance = this.select(selectDynamic); + yield* Fn.map(instance.dynamicImports, entry => entry.controller); + } + + private async requestUpdate(this: ReloadableModuleController): Promise { + + // Set up statistics tracking let loads = 0; let reevaluations = 0; const timeStarted = performance.now(); @@ -375,332 +341,288 @@ export class ReloadableModuleController implements AbstractModuleController { duration: performance.now() - timeStarted, }); - // Collect dynamic imports and set up initial `pending` and `previous` state. Dynamic - // imports are traversed first, then the primary graph is traversed. - const dynamicImports = new Set(); + // Dispatch "dry run" to see if it is possible to accept this update. Also mark `previous` + // and `pending`. + interface DryRunResult { + forwardResults: readonly DryRunResult[]; + declined: readonly ReloadableModuleController[]; + invalidated: readonly ReloadableModuleController[]; + hasDecline: boolean; + hasNewCode: boolean; + needsDispatch: boolean; + } const previousControllers: ReloadableModuleController[] = []; - traverseBreadthFirst(this, iterateReloadablesWithDynamics(controller => controller.current), node => { - // Update controller pending state - assert(node.current !== undefined); - assert.equal(node.pending, undefined); - assert.equal(node.previous, undefined); - node.pending = node.staging ?? node.current; - node.previous = node.current; - // Memoize all traversed controllers - previousControllers.push(node); - // Add dynamic imports to the graph - for (const entry of node.current.dynamicImports) { - dynamicImports.add(entry.controller); + const initialResult = traverseDepthFirst( + this, + node => node.traversal, + (node, traversal) => { + node.traversal = traversal; + return node.iterateWithDynamics(); + }, + (cycleNodes, forwardResults: readonly DryRunResult[]): DryRunResult => { + let needsDispatch = false; + let hasNewCode = false; + const forwardUpdates = Array.from(Fn.concat(Fn.map(forwardResults, result => result.invalidated))); + const invalidated = Array.from(Fn.filter(cycleNodes, node => { + previousControllers.push(node); + const current = node.select(); + node.pending = current; + node.previous = current; + if (node.staging !== undefined) { + node.pending = node.staging; + hasNewCode = true; + } + if ( + node.staging !== undefined || + isInvalidated(current) || + !isAccepted(current, forwardUpdates) + ) { + needsDispatch = true; + return !isAcceptedSelf(current); + } + })); + const declined = Array.from(Fn.filter(invalidated, node => isDeclined(node.select()))); + const hasDecline = declined.length > 0 || Fn.some(forwardResults, result => result.hasDecline); + needsDispatch ||= Fn.some(forwardResults, result => result.needsDispatch); + hasNewCode ||= Fn.some(forwardResults, result => result.hasNewCode); + return { forwardResults, declined, invalidated, hasDecline, hasNewCode, needsDispatch }; + }); + + // Rollback routine which undoes the above traversal. + const rollback = () => { + for (const controller of previousControllers) { + controller.pending = undefined; + controller.previous = undefined; } - }); + }; + + // Check result + if (!initialResult.needsDispatch) { + rollback(); + return undefined; + } else if (initialResult.hasDecline) { + rollback(); + const declined = Array.from(function *traverse(result): Iterable { + yield* result.declined; + yield* Fn.transform(result.forwardResults, traverse); + }(initialResult)); + return { type: UpdateStatus.declined, declined }; + } else if (initialResult.invalidated.length > 0) { + rollback(); + return { type: UpdateStatus.unaccepted }; + } - // Iterate dynamic imported roots in reverse order. This kind of works, but I don't think it - // is actually sound. - const dispatchRoots = new Set(Fn.reverse(Array.from(dynamicImports))); - dispatchRoots.add(this); - - // Check for updates, that the updates are potentially accepted, and maybe clone `pending` - // for evaluation if we know a module isn't accepted. - let hasUpdate = false as boolean; - let hasUpdatedCode = false as boolean; - const declined: ReloadableModuleController[] = []; - for (const dispatchRoot of dispatchRoots) { - traverseDepthFirst(dispatchRoot, ReloadableModuleController.iteratePending, { - join(cycleRoot, cycleNodes) { - assert(cycleRoot.current !== undefined); - assert(cycleNodes.every(node => node.current !== undefined)); - // If this node and all cycle nodes have no updated code then we will check if any - // dependencies were updated - const nodes = [ ...cycleNodes, cycleRoot ]; - if (nodes.every(node => !isInvalidated(node.current!) && node.current === node.pending)) { - // First check if any dependency of any cycle node was updated - const hasUpdatedDependencies = Fn.some(Fn.transform( - nodes, - node => Fn.map( - iterateReloadablesWithDynamics(controller => controller.pending)(node), - dependency => dependency.current !== dependency.pending && !dependency.wasSelfAccepted))); - if (hasUpdatedDependencies) { - // Dependencies updated, check if they are accepted. Only non-cycle - // nodes are allowed to accept. - if (cycleNodes.length === 0) { - const updatedControllers = Array.from(Fn.filter( - iterateReloadablesWithDynamics(controller => controller.pending)(cycleRoot), - dependency => { - assert(dependency.current !== undefined); - return dependency.pending !== dependency.current && !dependency.wasSelfAccepted; - })); - if (isAccepted(cycleRoot.current, updatedControllers)) { - return; + // If the update contains new code then we need to make sure it will link. Normally the + // graph will link and then evaluate, but when dispatching a hot update a module is allowed + // to invalidate itself. So, we're not really sure which bindings will end up where. This + // step ensures that a linkage error doesn't cause the whole graph to throw an error. + if (initialResult.hasNewCode) { + const instantiated: ReloadableModuleController[] = []; + try { + traverseDepthFirst( + this, + node => node.traversal, + (node, traversal) => { + node.traversal = traversal; + return node.iterateWithDynamics( + controller => controller.pending, + controller => controller.previous); + }, + (cycleNodes, forwardResults: readonly boolean[]) => { + let hasUpdate = Fn.some(forwardResults); + if (!hasUpdate) { + for (const node of cycleNodes) { + const current = node.select(); + const pending = node.select(controller => controller.pending); + if (current !== pending) { + hasUpdate = true; + break; } } - } else { - // No updates - return; } - } - // An update is definitely required - for (const node of nodes) { - if (isDeclined(node.current!)) { - declined.push(node); - } else { - hasUpdate = true; - if (node.current === node.pending) { - node.pending = node.pending!.clone(); - } else { - hasUpdatedCode = true; + if (hasUpdate) { + for (const node of cycleNodes) { + const pending = node.select(controller => controller.pending); + node.temporary = pending.clone(); + node.temporary.instantiate(); + instantiated.push(node); + } + for (const node of cycleNodes) { + const temporary = node.select(controller => controller.temporary); + temporary.link(controller => controller.temporary ?? controller.pending); } } - } - if (cycleNodes.length === 0) { - cycleRoot.wasSelfAccepted = isAcceptedSelf(cycleRoot.current); - } - }, - }); - } - - let rollBack = false; - try { - if (declined.length > 0) { - rollBack = true; - return { type: UpdateStatus.declined, declined }; - } else if (this.current !== this.pending && !this.wasSelfAccepted) { - rollBack = true; - return { type: UpdateStatus.unaccepted }; - } else if (!hasUpdate) { - rollBack = true; - return undefined; - } - // If the update contains new code then we need to make sure it will link. Normally the - // graph will link and then evaluate, but when dispatching a hot update a module is - // allowed to invalidate itself. So, we're not really sure which bindings will end up - // where. This step ensures that a linkage error doesn't cause the whole graph to throw - // an error. - if (hasUpdatedCode) { - for (const dispatchRoot of dispatchRoots) { - // Instantiate temporary module instances - const nodes: ReloadableModuleController[] = []; - traverseBreadthFirst(dispatchRoot, ReloadableModuleController.iteratePending, node => { - assert.equal(node.temporary, undefined); - nodes.push(node); - node.temporary = node.select(controller => controller.pending).clone(); - node.temporary.instantiate(); + return hasUpdate; }); - try { - // Attempt to link - traverseDepthFirst(dispatchRoot, ReloadableModuleController.iterateTemporary, node => { - assert(node.temporary !== undefined); - node.temporary.link(controller => controller.temporary); - }); - } finally { - // Cleanup temporary instances - for (const node of nodes) { - const { temporary } = node; - node.temporary = undefined; - temporary?.destroy(); - } - } - } - } - } catch (error: any) { - rollBack = true; - return { type: UpdateStatus.linkError, error }; + } catch (error) { + return { type: UpdateStatus.linkError, error }; - } finally { - if (rollBack) { - for (const controller of previousControllers) { - assert(controller.pending !== undefined); - if (controller.pending !== controller.current) { - controller.pending.destroy(); - } - controller.wasSelfAccepted = undefined; - controller.pending = undefined; - controller.previous = undefined; + } finally { + for (const node of instantiated) { + node.select(controller => controller.temporary).unlink(); + node.temporary = undefined; } } } - // Dispatch the update. This performs link & evaluate at the same time. The evaluation order - // of this routine is different than the original evaluation. In the original evaluation, - // non-cyclic dependencies will be evaluated in between modules which form a cycle. Here, - // cycles will be evaluated together. This shouldn't be a problem in all but the most - // pathological of cases. + // Dispatch link & evaluate try { - // `this` should be evaluated after dynamic imports - dispatchRoots.delete(this); - dispatchRoots.add(this); - // Link & evaluate, update `current`, and remove `pending`. This loop very closely - // mirrors the original invocation to `traverseDepthFirst` above. - for (const dispatchRoot of dispatchRoots) { - await traverseDepthFirst(dispatchRoot, iterateReloadables(controller => controller.pending ?? controller.current), { - join: async (cycleRoot, cycleNodes) => { - assert(cycleRoot.current !== undefined); - assert(cycleNodes.every(node => node.current !== undefined)); - if (cycleRoot.pending === undefined) { - // This node is imported by a dynamic import and was already visited - assert(cycleNodes.every(node => node.pending === undefined)); - return; - } else { - assert(cycleNodes.every(node => node.pending !== undefined)); - } - // If this node and all cycle nodes are current then we will check if any - // dependencies were updated - const nodes = [ ...cycleNodes, cycleRoot ]; - if (nodes.every(node => node.pending === node.current)) { - // First check if any dependency of any cycle node was updated - const hasUpdatedDependencies = Fn.some(Fn.transform( - nodes, - node => Fn.map( - iterateReloadablesWithDynamics(controller => controller.pending)(node), - dependency => dependency.previous !== dependency.current && !dependency.wasSelfAccepted))); - if (hasUpdatedDependencies) { - // Dependencies updated, check if they are accepted. Only non-cycle - // nodes are allowed to accept. - if (cycleNodes.length === 0) { - const updatedModules = Array.from(Fn.filter( - iterateReloadablesWithDynamics(controller => controller.pending)(cycleRoot), - dependency => { - assert(dependency.current !== undefined); - return dependency.previous !== dependency.current && !dependency.wasSelfAccepted; - })); - cycleRoot.current.relink(cycleNodes, ReloadableModuleController.selectCurrent); - if (await tryAccept(cycleRoot.current, updatedModules)) { - assert.equal(cycleRoot.current, cycleRoot.pending); - cycleRoot.pending = undefined; - return; - } - } - } else { - // No updates - for (const node of nodes) { - assert.equal(node.current, node.pending); - node.pending = undefined; - } - return; - } + interface RunResult { + forwardResults: readonly RunResult[]; + invalidated: readonly ReloadableModuleController[]; + didUpdate: boolean; + } + await traverseDepthFirst( + this, + node => node.traversal, + (node, traversal) => { + node.traversal = traversal; + return node.iterateWithDynamics( + controller => controller.staging ?? controller.current, + controller => controller.previous); + }, + async (cycleNodes, forwardResults: readonly RunResult[]): Promise => { + let needsUpdate = false; + // Check update due to new code + for (const node of cycleNodes) { + if (node.staging !== undefined) { + needsUpdate = true; + break; } - // This node needs to be replaced. - // Instantiate - for (const node of nodes) { - assert(node.current !== undefined && node.pending !== undefined); - if ( - node.current.state.status === ModuleStatus.linked || - node.current.state.status === ModuleStatus.evaluating || - node.current.state.status === ModuleStatus.evaluatingAsync - ) { - try { - await node.current.state.completion.promise; - } catch {} + } + // Relink and check update due to invalidated dependencies + const didUpdate = Fn.some(forwardResults, result => result.didUpdate); + if (didUpdate || !needsUpdate) { + const forwardUpdates = Array.from(Fn.concat(Fn.map(forwardResults, result => result.invalidated))); + for (const node of cycleNodes) { + const current = node.select(); + if (didUpdate) { + current.relink(); } - const data = await dispose(node.current); - if (node.current === node.pending) { - node.current = node.current.clone(); - } else { - node.current = node.pending; + if (!await tryAccept(node.select(), forwardUpdates)) { + needsUpdate = true; + break; } + } + } + if (!needsUpdate) { + for (const node of cycleNodes) { + assert.equal(node.current, node.pending); node.pending = undefined; - node.current.instantiate(data); } - // Link - for (const node of nodes) { - assert(node.current !== undefined); - node.current.link(ReloadableModuleController.selectCurrent); + return { forwardResults, didUpdate, invalidated: [] }; + } + // These nodes need to be replaced. + // 1) Instantiate + for (const node of cycleNodes) { + const current = node.select(); + const pending = node.select(controller => controller.pending); + const data = await dispose(current); + if (current === pending) { + node.current = current.clone(); + } else { + node.current = pending; } - // Evaluate - for (let ii = 0; ii < nodes.length; ++ii) { - const node = nodes[ii]!; - assert(node.current !== undefined); - assert(node.previous !== undefined); - try { - if (node.current.declaration === node.previous.declaration) { - ++reevaluations; - } else { - ++loads; - } - await node.current.evaluate(); - if (node.current === node.staging) { - node.staging = undefined; - } - } catch (error) { - node.current.destroy(); + node.current.instantiate(data); + } + // 2) Link + for (const node of cycleNodes) { + node.select().link(); + } + // 3) Evaluate + const withRollback = iterateWithRollback(cycleNodes, nodes => { + for (const node of nodes) { + const current = node.select(); + assert(current.state.status === ModuleStatus.evaluated); + if (current.state.evaluationError !== undefined) { node.current = node.previous; - for (let jj = ii + 1; jj < nodes.length; ++jj) { - const node = nodes[jj]!; - const instance = node.current; - assert(instance !== undefined); - assert(instance.state.status === ModuleStatus.linked); - instance.destroy(); - node.current = node.previous; - } - throw error; } } - // Try self-accept - if (cycleNodes.length === 0) { - const namespace = () => cycleRoot.current!.moduleNamespace()(); - assert(cycleRoot.previous !== undefined); - cycleRoot.wasSelfAccepted = await tryAcceptSelf(cycleRoot.previous, namespace); + }); + for (const node of withRollback) { + const current = node.select(); + const previous = node.select(controller => controller.previous); + if (current.declaration === previous.declaration) { + ++reevaluations; + } else { + ++loads; } - }, + await current.evaluate(); + node.pending = undefined; + if (current === node.staging) { + node.staging = undefined; + } + } + // Try self-accept + const invalidated: ReloadableModuleController[] = []; + for (const node of cycleNodes) { + const current = node.select(); + const previous = node.select(controller => controller.previous); + const namespace = () => current.moduleNamespace()(); + if (!await tryAcceptSelf(previous, namespace)) { + invalidated.push(node); + } + } + return { forwardResults, invalidated, didUpdate: true }; }); - } - - // Some kind of success - const unacceptedRoot = this.current !== this.previous && !this.wasSelfAccepted; - return { - type: unacceptedRoot ? UpdateStatus.unacceptedEvaluation : UpdateStatus.success, - stats, - }; } catch (error) { // Re-link everything to ensure consistent internal state. Also, throw away pending // instances. - traverseDepthFirst(this, iterateReloadablesWithDynamics(controller => controller.current), { - join: (cycleRoot, cycleNodes) => { - assert(cycleRoot.current !== undefined); - cycleRoot.current.relink(cycleNodes, ReloadableModuleController.selectCurrent); - for (const node of [ ...cycleNodes, cycleRoot ]) { - if (node.pending !== undefined) { - if (node.pending !== node.current) { - node.pending.destroy(); - } - node.pending = undefined; - } + traverseDepthFirst( + this, + node => node.traversal, + (node, traversal) => { + node.traversal = traversal; + if (node.pending !== undefined) { + node.pending.unlink(); + node.pending = undefined; } + return node.iterateWithDynamics(); }, - }); + (cycleNodes): undefined => { + for (const node of cycleNodes) { + const current = node.select(); + current.relink(); + } + }); return { type: UpdateStatus.evaluationFailure, error, stats }; } finally { // Dispose old modules const currentControllers = new Set(); - const destroyInstances: ReloadableModuleInstance[] = []; - traverseBreadthFirst(this, iterateReloadablesWithDynamics(controller => controller.current), controller => { - currentControllers.add(controller); - assert(controller.pending === undefined); - if ( - controller.previous !== undefined && - controller.previous !== controller.current - ) { - destroyInstances.push(controller.previous); - } - controller.previous = undefined; - controller.wasSelfAccepted = undefined; - }); - await Fn.mapAwait(destroyInstances, instance => instance.destroy()); - // nb: This will prune and destroy lazily loaded dynamic modules. + traverseBreadthFirst( + this, + node => node.visitIndex, + (node, visitIndex) => { + node.visitIndex = visitIndex; + return node.iterateWithDynamics(); + }, + node => { + currentControllers.add(node); + assert(node.pending === undefined); + node.previous = undefined; + }); for (const controller of previousControllers) { if (!currentControllers.has(controller)) { - assert(controller.current !== undefined); - await prune(controller.current); - controller.current.destroy(); + const current = controller.select(); + await prune(current); // Move the instance to staging to setup for `dispatch` in case it's re-imported - controller.staging = controller.current; + controller.staging = current.clone(); controller.current = undefined; controller.previous = undefined; - controller.wasSelfAccepted = undefined; } } } + + return { + type: UpdateStatus.success, + stats, + }; + } + + private static selectCurrent(this: void, controller: ReloadableModuleController) { + return controller.current; } } diff --git a/runtime/hot.ts b/runtime/hot.ts index 30580c3..7965ecd 100644 --- a/runtime/hot.ts +++ b/runtime/hot.ts @@ -29,8 +29,10 @@ export let tryAccept: (instance: ReloadableModuleInstance, modules: readonly Rel /** @internal */ export let tryAcceptSelf: (instance: ReloadableModuleInstance, self: () => ModuleNamespace) => Promise; +/** @internal */ +export type Data = Record; + // Duplicated here to make copying the .d.ts file easier -type Data = Record; type ModuleNamespace = Record; type LocalModuleEntry = { @@ -57,6 +59,7 @@ export class Hot { #declined = false; #dispose: ((data: Data) => Promise | void)[] = []; #dynamicImports = new Set(); + #instance: ReloadableModuleInstance; #invalidated = false; #module; #prune: (() => Promise | void)[] = []; @@ -64,10 +67,12 @@ export class Hot { constructor( module: unknown, + instance: unknown, usesDynamicImport: boolean, public readonly data?: unknown, ) { this.#module = module as ReloadableModuleController; + this.#instance = instance as ReloadableModuleInstance; this.#usesDynamicImport = usesDynamicImport; Object.freeze(this); } @@ -87,16 +92,17 @@ export class Hot { if (hot.#invalidated) { return false; } else { + const imports = new Set(hot.#instance.iterateDependencies()); const acceptedModules = new Set(Fn.transform(hot.#accepts, accepts => { const acceptedModules = accepts.localEntries.map(entry => - entry.found ? entry.module : hot.#module.lookupSpecifier(hot, entry.specifier)); + entry.found ? entry.module : hot.#instance.lookupSpecifier(entry.specifier)); if (acceptedModules.every(module => module != null)) { return acceptedModules as ReloadableModuleController[]; } else { return []; } })); - return modules.every(module => acceptedModules.has(module)); + return modules.every(module => !imports.has(module) || acceptedModules.has(module)); } }; isAcceptedSelf = instance => { @@ -116,9 +122,10 @@ export class Hot { if (hot.#invalidated as boolean) { return false; } else { + const imports = new Set(hot.#instance.iterateDependencies()); const acceptedHandlers = Array.from(Fn.filter(Fn.map(hot.#accepts, accepts => { const acceptedModules = accepts.localEntries.map(entry => - entry.found ? entry.module : hot.#module.lookupSpecifier(hot, entry.specifier)); + entry.found ? entry.module : hot.#instance.lookupSpecifier(entry.specifier)); if (acceptedModules.every(module => module != null)) { return { callback: accepts.callback, @@ -127,14 +134,15 @@ export class Hot { } }))); const acceptedModules = new Set(Fn.transform(acceptedHandlers, handler => handler.modules)); - if (modules.every(module => acceptedModules.has(module))) { - for (const handler of acceptedHandlers) { - if (handler.callback && modules.some(module => handler.modules.includes(module))) { - const namespaces = handler.modules.map(module => module.select().moduleNamespace()()); - await handler.callback(namespaces); - if (hot.#invalidated) { - return false; - } + if (!modules.every(module => !imports.has(module) || acceptedModules.has(module))) { + return false; + } + for (const handler of acceptedHandlers) { + if (handler.callback && modules.some(module => handler.modules.includes(module))) { + const namespaces = handler.modules.map(module => module.select().moduleNamespace()()); + await handler.callback(namespaces); + if (hot.#invalidated) { + return false; } } } @@ -185,7 +193,7 @@ export class Hot { }); } else if (Array.isArray(arg1)) { const localEntries: LocalModuleEntry[] = arg1.map(specifier => { - const module = this.#module.lookupSpecifier(this, specifier); + const module = this.#instance.lookupSpecifier(specifier); if (module == null) { return { found: false, module, specifier }; } else { diff --git a/runtime/instance.ts b/runtime/instance.ts index 9564ba2..d417dbd 100644 --- a/runtime/instance.ts +++ b/runtime/instance.ts @@ -1,6 +1,6 @@ import type { ModuleBodyScope, ModuleDeclaration } from "./declaration.js"; +import type { Data } from "./hot.js"; import type { AbstractModuleInstance, ModuleController, ModuleExports, Resolution, SelectModuleInstance } from "./module.js"; -import type { TraverseCyclicState } from "./traverse.js"; import type { WithResolvers } from "./utility.js"; import assert from "node:assert/strict"; import Fn from "dynohot/functional"; @@ -79,15 +79,12 @@ interface ModuleContinuationAsync { /** @internal */ export class ReloadableModuleInstance implements AbstractModuleInstance { readonly reloadable = true; - + state: ModuleState = { status: ModuleStatus.new }; dynamicImports: { readonly controller: ReloadableModuleController; readonly specifier: string; }[] = []; - state: ModuleState = { status: ModuleStatus.new }; - traversalState: TraverseCyclicState | undefined; - visitation = 0; private namespace: (() => Record) | undefined; constructor( @@ -99,9 +96,9 @@ export class ReloadableModuleInstance implements AbstractModuleInstance { return new ReloadableModuleInstance(this.controller, this.declaration); } - instantiate(data?: unknown) { + instantiate(data?: Data) { if (this.state.status === ModuleStatus.new) { - const hot = new Hot(this.controller, this.declaration.usesDynamicImport, data); + const hot = new Hot(this.controller, this, this.declaration.usesDynamicImport, data); const importMeta = Object.assign(Object.create(this.declaration.meta), { dynoHot: hot, hot, @@ -134,7 +131,7 @@ export class ReloadableModuleInstance implements AbstractModuleInstance { } } - link(select: SelectModuleInstance) { + link(select?: SelectModuleInstance) { assert(this.state.status !== ModuleStatus.new); if (this.state.status === ModuleStatus.linking) { const bindings = initializeEnvironment( @@ -169,6 +166,57 @@ export class ReloadableModuleInstance implements AbstractModuleInstance { } } + relink(select?: SelectModuleInstance) { + assert( + this.state.status === ModuleStatus.linked || + this.state.status === ModuleStatus.evaluated || + this.state.status === ModuleStatus.evaluatingAsync); + const bindings = initializeEnvironment( + this.declaration.loadedModules, + entry => { + const module = entry.controller().select(select); + assert( + module.state.status === ModuleStatus.evaluated || + module.state.status === ModuleStatus.evaluatingAsync); + return module.moduleNamespace(select); + }, + (entry, exportName) => { + const module = entry.controller().select(select); + assert( + module.state.status === ModuleStatus.linked || + module.state.status === ModuleStatus.evaluated || + module.state.status === ModuleStatus.evaluatingAsync); + return module.resolveExport(exportName, select); + }); + if (this.state.status !== ModuleStatus.linked) { + this.state.environment.replace(Object.fromEntries(bindings)); + } + } + + /** + * Reset a module instance from "linked" or "linking" to "new". Returns `true` if the module is + * now in "new" state, false otherwise. + */ + unlink() { + switch (this.state.status) { + case ModuleStatus.new: return true; + + case ModuleStatus.linked: + case ModuleStatus.linking: { + const continuation = this.state.continuation; + if (continuation.async) { + void continuation.iterator.return?.(); + } else { + continuation.iterator.return?.(); + } + this.state = { status: ModuleStatus.new }; + return true; + } + + default: return false; + } + } + evaluate() { switch (this.state.status) { case ModuleStatus.linked: { @@ -247,34 +295,24 @@ export class ReloadableModuleInstance implements AbstractModuleInstance { } } - relink(cycleNodes: readonly ReloadableModuleController[], select: SelectModuleInstance) { - const nodes = [ ...Fn.map(cycleNodes, node => node.select(select)), this ]; - for (const node of nodes) { - assert(node.state.status === ModuleStatus.evaluated); - node.namespace = undefined; - } - for (const node of nodes) { - assert(node.state.status === ModuleStatus.evaluated); - const bindings = initializeEnvironment( - node.declaration.loadedModules, - entry => { - const module = entry.controller().select(select); - assert( - module.state.status === ModuleStatus.evaluated || - module.state.status === ModuleStatus.evaluatingAsync); - return module.moduleNamespace(select); - }, - (entry, exportName) => { - const module = entry.controller().select(select); - assert( - module.state.status === ModuleStatus.evaluated || - module.state.status === ModuleStatus.evaluatingAsync); - return module.resolveExport(exportName, select); - }); - node.state.environment.replace(Object.fromEntries(bindings)); + lookupSpecifier(specifier: string) { + const entry = this.declaration.loadedModules.find(entry => entry.specifier === specifier); + if (entry === undefined) { + const dynamicImport = this.dynamicImports.find(entry => entry.specifier === specifier); + return dynamicImport?.controller; + } else { + const controller = entry.controller(); + if (controller.reloadable) { + return controller; + } } } + *iterateDependencies() { + yield* Fn.map(this.declaration.loadedModules, entry => entry.controller()); + yield* Fn.map(this.dynamicImports, instance => instance.controller); + } + private async dynamicImport(specifier: string, importAssertions?: Record) { assert( this.state.status === ModuleStatus.evaluating || @@ -299,28 +337,10 @@ export class ReloadableModuleInstance implements AbstractModuleInstance { } } - destroy() { - switch (this.state.status) { - case ModuleStatus.linked: - case ModuleStatus.linking: { - const continuation = this.state.continuation; - if (continuation.async) { - void continuation.iterator.return?.(); - } else { - continuation.iterator.return?.(); - } - break; - } - - default: - } - this.state = { status: ModuleStatus.new }; - } - // 10.4.6.12 ModuleNamespaceCreate ( module, exports ) // 16.2.1.6.2 GetExportedNames ( [ exportStarSet ] ) // 16.2.1.10 GetModuleNamespace ( module ) - moduleNamespace(select = ReloadableModuleController.selectCurrent) { + moduleNamespace(select?: SelectModuleInstance) { if (!this.namespace) { assert(this.state.status !== ModuleStatus.new); const namespace = Object.create(null); @@ -379,7 +399,7 @@ export class ReloadableModuleInstance implements AbstractModuleInstance { } // 16.2.1.6.3 ResolveExport ( exportName [ , resolveSet ] ) - resolveExport(exportName: string, select: SelectModuleInstance): Resolution { + resolveExport(exportName: string, select?: SelectModuleInstance): Resolution { // 1. Assert: module.[[Status]] is not new. assert(this.state.status !== ModuleStatus.new); // 2. If resolveSet is not present, set resolveSet to a new empty List. diff --git a/runtime/traverse.ts b/runtime/traverse.ts index 741c1a4..fb2ff4b 100644 --- a/runtime/traverse.ts +++ b/runtime/traverse.ts @@ -1,32 +1,67 @@ import assert from "node:assert/strict"; +import Fn from "dynohot/functional"; -/** @internal */ -export interface TraverseCyclicState { +type NotPromiseLike = + null | undefined | + (bigint | boolean | number | object | string) & + { then?: null | undefined | bigint | boolean | number | object | string }; + +interface TraversalState { + readonly state: CyclicState; + visitIndex: number; +} + +interface CyclicState { readonly index: number; ancestorIndex: number; - completion: MaybePromise; - joined: boolean; + forwardResults: Completion[]> | undefined; + result: Completion> | undefined; +} + +type Completion = CompletionSync | CompletionAsync; + +interface CompletionSync { + readonly sync: true; + readonly resolution: Type; +} + +interface CompletionAsync { + readonly sync: false; + readonly promise: PromiseLike; +} + +interface Collectable { + readonly value: Type; + collectionIndex: number; } let lock = false; -let visitation = 0; +let currentVisitIndex = 0; + +/** @internal */ +export function makeTraversalState(visitIndex = -1, state?: CyclicState): TraversalState { + return { + visitIndex, + state: state!, + }; +} /** @internal */ export function traverseBreadthFirst< - Node extends { visitation: number }, - Completion extends MaybePromise, + Node, + Completion extends MaybePromise = void, >( node: Node, - children: (node: Node) => Iterable, + check: (node: Node) => number, + begin: (node: Node, visitation: number) => Iterable, visit: (node: Node) => Completion, ): Completion { const inner = (node: Node, previousCompletion?: MaybePromise) => { - node.visitation = current; - const nodes = Array.from(children(node)); + const nodes = Array.from(begin(node, visitIndex)); const completion = previousCompletion === undefined ? visit(node) : previousCompletion.then(() => visit(node)); const pending: Promise[] = []; for (const child of nodes) { - if (child.visitation !== current) { + if (check(child) !== visitIndex) { const nextCompletion = inner(child, completion); if (nextCompletion) { pending.push(nextCompletion); @@ -41,7 +76,7 @@ export function traverseBreadthFirst< }; assert(!lock); lock = true; - const current = ++visitation; + const visitIndex = ++currentVisitIndex; try { return inner(node) as Completion; } finally { @@ -55,86 +90,167 @@ export function traverseBreadthFirst< * @internal */ export function traverseDepthFirst< - Node extends { traversalState: TraverseCyclicState | undefined }, - Completion extends MaybePromise, + Node, + Result extends NotPromiseLike, + Join extends MaybePromise, >( root: Node, - children: (node: Node) => Iterable, - handler: ((node: Node) => Completion) | { - dispatch?: (node: Node) => Completion; - join?: (cycleRoot: Node, cycleNodes: readonly Node[]) => Completion; - }, -): Completion { - const inner = (node: Node): TraverseCyclicState => { - assert(node.traversalState === undefined); - const state: TraverseCyclicState = node.traversalState = { + peek: (node: Node) => TraversalState, + begin: (node: Node, state: TraversalState) => Iterable, + join: (nodes: readonly Node[], forwardResults: Result[]) => Join, + unwind?: (nodes: readonly Node[]) => void, +): Join { + const expect = (node: Node) => { + const state = peek(node); + assert(state.visitIndex === visitIndex); + return state as TraversalState; + }; + const inner = (node: Node): CyclicState => { + // Initialize and add to stack + const holder = makeTraversalState(visitIndex, { index, ancestorIndex: index, - completion: undefined, - joined: false, - }; + forwardResults: undefined, + result: undefined, + }); ++index; - everyNode.push(node); + const { state } = holder; + const stackIndex = stack.length; stack.push(node); - const pending: Promise[] = []; - for (const child of children(node)) { - const childState = child.traversalState === undefined ? inner(child) : child.traversalState; - if (childState.completion !== undefined) { - pending.push(childState.completion); - } - if (!childState.joined) { + // Collect forward results + let hasPromise = false as boolean; + const forwardResultsMaybePromise = Array.from(Fn.transform(begin(node, holder), function*(child) { + const holder = peek(child) as TraversalState; + const childState = holder.visitIndex === visitIndex ? holder.state : inner(child); + const { result } = childState; + if (result === undefined) { state.ancestorIndex = Math.min(state.ancestorIndex, childState.ancestorIndex); + } else if (result.sync) { + yield result.resolution; + } else { + hasPromise = true; + yield result.promise; } - } - if (pending.length === 0) { - state.completion = dispatch?.(node); - } else { - state.completion = Promise.all(pending).then(() => dispatch?.(node)); - } - const stackIndex = stack.indexOf(node); + })); + // Detect promise or sync + state.forwardResults = function() { + if (hasPromise) { + return { + sync: false, + promise: Promise.all(forwardResultsMaybePromise), + }; + } else { + return { + sync: true, + resolution: forwardResultsMaybePromise as Collectable[], + }; + } + }(); + // Join cyclic nodes assert(state.ancestorIndex <= state.index); - assert.notStrictEqual(stackIndex, -1); if (state.ancestorIndex === state.index) { const cycleNodes = stack.splice(stackIndex); - state.joined = true; - for (const node of cycleNodes) { - assert(node.traversalState !== undefined); - node.traversalState.joined = true; - } - if (join) { - cycleNodes.shift(); - if (state.completion === undefined) { - state.completion = join(node, cycleNodes); + cycleNodes.reverse(); + // Collect forward results from cycle nodes + let hasPromise = false as boolean; + const cyclicForwardResults = cycleNodes.map(node => { + const { state: { forwardResults } } = expect(node); + assert(forwardResults !== undefined); + if (forwardResults.sync) { + return forwardResults.resolution; + } else { + hasPromise = true; + return forwardResults.promise; + } + }); + // Await completion of forward results of all cycle members + const result: Completion> = function() { + if (hasPromise) { + return { + sync: false, + promise: async function() { + let forwardResults: Result[]; + try { + forwardResults = collect(stackIndex, await Promise.all(cyclicForwardResults)); + } catch (error) { + unwind?.(cycleNodes); + throw error; + } + let result: Result; + const maybePromise = join(cycleNodes, forwardResults); + if (typeof maybePromise?.then === "function") { + result = await maybePromise as Result; + } else { + result = maybePromise as Result; + } + return { + collectionIndex: -1, + value: result, + }; + }(), + }; } else { - state.completion = state.completion.then(() => join(node, cycleNodes)); + const forwardResults = collect(stackIndex, cyclicForwardResults as Iterable>> as any); + const result = join(cycleNodes, forwardResults as any); + if (typeof result?.then === "function") { + return { + sync: false, + promise: async function() { + return { + collectionIndex: -1, + value: await result as Result, + }; + }(), + }; + } else { + return { + sync: true, + resolution: { + collectionIndex: -1, + value: result as Result, + }, + }; + } } + }(); + // Assign state to all cycle members + for (const node of cycleNodes) { + const childState = expect(node).state; + assert.equal(childState.result, undefined); + childState.result = result; } } return state; }; - const { dispatch, join } = function() { - if (typeof handler === "function") { - return { - dispatch: handler, - join: undefined, - }; - } else { - return handler; - } - }(); - assert(!lock); lock = true; + const visitIndex = ++currentVisitIndex; let index = 0; - const everyNode: Node[] = []; const stack: Node[] = []; try { - return inner(root).completion as Completion; - } finally { - for (const node of everyNode) { - node.traversalState = undefined; + const { result } = inner(root); + assert(result !== undefined); + if (result.sync) { + return result.resolution.value as Join; + } else { + return result.promise.then(({ value: foobar }) => foobar) as Join; } + } catch (error) { + unwind?.(stack); + throw error; + } finally { lock = false; } } + +function collect(collectionIndex: number, forwardResultVectors: (readonly Collectable[])[]) { + return Array.from(Fn.transform(forwardResultVectors, function*(forwardResults) { + for (const result of forwardResults) { + if (result.collectionIndex !== collectionIndex) { + result.collectionIndex = collectionIndex; + yield result.value; + } + } + })); +} diff --git a/runtime/utility.ts b/runtime/utility.ts index 95f06ac..ba26040 100644 --- a/runtime/utility.ts +++ b/runtime/utility.ts @@ -85,6 +85,28 @@ export function discriminatedTypePredicate node.type === type; } +/** + * Returns a delegate iterable to an array which invokes a rollback function on the iterated + * elements if iteration didn't complete [due to an exception hopefully]. + * @internal + */ +export function *iterateWithRollback(vector: readonly Type[], rollback: (previous: Iterable) => void) { + let ii = 0; + try { + for (; ii < vector.length; ++ii) { + yield vector[ii]!; + } + } finally { + if (ii !== vector.length) { + rollback(function*() { + for (let jj = ii; jj >= 0; --jj) { + yield vector[jj]!; + } + }()); + } + } +} + /** @internal */ export interface WithResolvers { readonly promise: Promise;