diff --git a/.github/workflows/test-all-packages.yml b/.github/workflows/test-all-packages.yml index e0121710068..35f93c40185 100644 --- a/.github/workflows/test-all-packages.yml +++ b/.github/workflows/test-all-packages.yml @@ -228,6 +228,9 @@ jobs: - name: yarn test (agoric-cli) if: (success() || failure()) run: cd packages/agoric-cli && yarn ${{ steps.vars.outputs.test }} | $TEST_COLLECT + - name: yarn test (async-flow) + if: (success() || failure()) + run: cd packages/async-flow && yarn ${{ steps.vars.outputs.test }} | $TEST_COLLECT - name: yarn test (base-zone) if: (success() || failure()) run: cd packages/base-zone && yarn ${{ steps.vars.outputs.test }} | $TEST_COLLECT diff --git a/packages/agoric-cli/src/sdk-package-names.js b/packages/agoric-cli/src/sdk-package-names.js index 2af489833f2..8eab6fa1d8e 100644 --- a/packages/agoric-cli/src/sdk-package-names.js +++ b/packages/agoric-cli/src/sdk-package-names.js @@ -3,6 +3,7 @@ export default [ "@agoric/access-token", "@agoric/assert", + "@agoric/async-flow", "@agoric/base-zone", "@agoric/benchmark", "@agoric/boot", diff --git a/packages/async-flow/CHANGELOG.md b/packages/async-flow/CHANGELOG.md new file mode 100644 index 00000000000..420e6f23d0e --- /dev/null +++ b/packages/async-flow/CHANGELOG.md @@ -0,0 +1 @@ +# Change Log diff --git a/packages/async-flow/README.md b/packages/async-flow/README.md new file mode 100644 index 00000000000..daabc3e4cd3 --- /dev/null +++ b/packages/async-flow/README.md @@ -0,0 +1,40 @@ +# `@agoric/async-flow` + +***Beware that this module may migrate to the endo repository as `@endo/async-flow`.*** + + +Upgrade while suspended at `await` points! Uses membrane to log and replay everything that happened before each upgrade. + +In the first incarnation, somewhere, using a ***closed*** async function argument +```js +const wrapperFunc = asyncFlow( + zone, + 'funcName`, + async (...) => {... await ...; ...}, +); +``` +then elsewhere, as often as you'd like +```js +const outcomeVow = wrapperFunc(...); +``` + +For all these `asyncFlow` calls that happened in the first incarnation, in the first crank of all later incarnations +```js +asyncFlow( + zone, + 'funcName`, + async (...) => {... await ...; ...}, +); +``` +with async functions that reproduce the original's logged behavior. In these later incarnations, you only need to capture the returned `wrapperFunc` if you want to create new activations. Regardless, the old activations continue. + +--- + +> [!IMPORTANT] +> The async function argument should be ***closed***, meaning that it should not use any lexically captured variables other than powerless globals. Any direct access to mutable state or ability to cause effects may introduce bugs, since these effects will happen again under replay outside the control of the asyncFlow isolation and deterministic replay mechanisms. + +## Loopholes for purely diagnostic information +> +> We make an explicit exception to the closed-function requirement for `console`, since log messages sent to `console` are only for diagnostic purposes, and `console` as a whole is write-only. We consider the ability to read the console log output to be similar to the ability to view computation through a debugger. Not counting either as "observing effects", the `console` does not cause "observable effects". During replay, such out-of-band console log events may appear again. For the same reason, the async function has no obligation to reproduce previous runs of such out-of-band console logging events, since they are outside the replay mechanisms. Likewise, the guest function has no obligation to reproduce the experience of viewing it through a debugger. + +> When comparing arguments sent by the guest function during replay with what the log recorded the guest function to have sent, we are extremely permissive in judging whether a sent error is the "same" as it was on a previous run. We only care that it is an error, and that the value of the `error.name` property is the same string. That string is normally the name of the error "class", such as `TypeError` or `URIError`, and is the only aspect of an error that programs may legitimately use to make a semantically significant decision. Everything else carried by an error, expecially its `error.message`, call-stack information, and subsidiary errors, are only for diagnostic purposes and need not be the same on replay. diff --git a/packages/async-flow/docs/async-flow-states.key b/packages/async-flow/docs/async-flow-states.key new file mode 100755 index 00000000000..50239a536c0 Binary files /dev/null and b/packages/async-flow/docs/async-flow-states.key differ diff --git a/packages/async-flow/docs/async-flow-states.md b/packages/async-flow/docs/async-flow-states.md new file mode 100644 index 00000000000..dbfa1aae08d --- /dev/null +++ b/packages/async-flow/docs/async-flow-states.md @@ -0,0 +1,15 @@ +# Async Flow States + +![async flow state diagram](./async-flow-states.png) + + A prepared guest async function is like an exoClass (and is internally implemented by an exoClass). It is primarily represented by the host wrapper function that `asyncFlow` returns. Each call on that wrapper function creates an activation of that guest function. A guest activation is like an exoClass instance (and is internally implemented as an instance of the function's internal exoClass). The state diagram shows the lifecycle of a guest function activation + +- ***Running***. Invoking the wrapper function creates an activation that is initially in the ***Running*** state. Actions the guest takes in the ***Running*** state, like invoking a host-provided API, cause actual effects and are also recorded for replay. The log records both actions initiated by the guest such as `checkCall`, and actions initiated by the host such as `doFulfill`. But it both cases it logs only host-side objects, since the log needs to survive an upgrade. + +- ***Sleeping***. An activation that was ***Running*** just before an upgrade revives into the new incarnation in the ***Sleeping*** state ready to replay from scratch once it awakens. The previous log is intact, but the log's "program counter" is reset to zero. The membrane bijection starts empty since no guest object survives an upgrade. Since an upgrade can only happen between cranks, and therefore between turns, the ***Running*** activation must have been awaiting a vow. When a vow settles, then any ***Sleeping*** activation that might have been awaiting that vow wakes and starts ***Replaying***. An activation can also optionally be configured to be an "eager waker". On revival, a ***Sleeping*** eager waker immediately wakes and starts ***Replaying***. The tradeoff is when to pay the costs of replay. + +- ***Replaying***. To start ***Replaying***, the activation first translates the saved activation arguments from host to guest, invokes the guest function, and starts the membrane replaying from its durable log. The replay is finished when the last log entry has been replayed. Once replaying is finished, the activation has caught up and transitions back to ***Running***. + +- ***Failed***. If during the ***Replaying*** state the guest activation fails to exactly reproduce its previously logged behavior, it goes into the inactive ***Failed*** state, with a diagnostic explaining how the replay failed, so it can be repaired by another future upgrade. As of the next reincarnation, the failure status is cleared and it starts ***Replaying*** again, hoping not to fail this time. If replay failed because the guest async function did not reproduce its previous behavior, then the upgrade needs to replace the function with one which does. If the replay failed because of a failure of the `asyncFlow` mechanism, whether a bug or merely hitting a case that is not yet implemented, then the upgrade needs to replace the relevant part of `asyncFlow`'s mechanism. + +- ***Done***. The guest async function invocation returned a promise for its eventual outcome. Once that promise settles, we assume that the job of the guest activation is done. It then goes into a durably ***Done*** state, dropping all its bookkeeping beyond just remembering the corresponding settled outcome vow, and that it is ***Done***. The replay logs and membrane state of this activation are dropped, to be garbage collected. diff --git a/packages/async-flow/docs/async-flow-states.png b/packages/async-flow/docs/async-flow-states.png new file mode 100644 index 00000000000..34cce0e82ce Binary files /dev/null and b/packages/async-flow/docs/async-flow-states.png differ diff --git a/packages/async-flow/index.js b/packages/async-flow/index.js new file mode 100644 index 00000000000..5f86d81eac1 --- /dev/null +++ b/packages/async-flow/index.js @@ -0,0 +1 @@ +export * from './src/async-flow.js'; diff --git a/packages/async-flow/package.json b/packages/async-flow/package.json new file mode 100644 index 00000000000..e364d41a20e --- /dev/null +++ b/packages/async-flow/package.json @@ -0,0 +1,66 @@ +{ + "name": "@agoric/async-flow", + "version": "0.1.0", + "description": "Upgrade async functions at await points by replay", + "type": "module", + "repository": "https://github.com/Agoric/agoric-sdk", + "main": "./index.js", + "scripts": { + "build": "exit 0", + "prepack": "tsc --build tsconfig.build.json", + "postpack": "git clean -f '*.d.ts*'", + "test": "ava", + "test:c8": "c8 $C8_OPTIONS ava --config=ava-nesm.config.js", + "test:xs": "exit 0", + "lint-fix": "yarn lint:eslint --fix", + "lint": "run-s --continue-on-error lint:*", + "lint:types": "tsc", + "lint:eslint": "eslint ." + }, + "exports": { + ".": "./index.js" + }, + "keywords": [], + "author": "Agoric", + "license": "Apache-2.0", + "dependencies": { + "@agoric/base-zone": "^0.1.0", + "@agoric/store": "^0.9.2", + "@agoric/vow": "^0.1.0", + "@endo/pass-style": "^1.4.0", + "@endo/common": "^1.2.2", + "@endo/errors": "^1.2.2", + "@endo/eventual-send": "^1.2.2", + "@endo/marshal": "^1.5.0", + "@endo/patterns": "^1.4.0", + "@endo/promise-kit": "^1.1.2" + }, + "devDependencies": { + "@agoric/internal": "^0.3.2", + "@agoric/swingset-liveslots": "^0.10.2", + "@agoric/zone": "^0.2.2", + "@endo/env-options": "^1.1.4", + "@endo/ses-ava": "^1.2.2", + "ava": "^5.3.0" + }, + "publishConfig": { + "access": "public" + }, + "engines": { + "node": "^18.12 || ^20.9" + }, + "ava": { + "files": [ + "test/**/test-*.*", + "test/**/*.test.*" + ], + "require": [ + "@endo/init/debug.js" + ], + "timeout": "20m", + "workerThreads": false + }, + "typeCoverage": { + "atLeast": 96.68 + } +} diff --git a/packages/async-flow/src/async-flow.js b/packages/async-flow/src/async-flow.js new file mode 100644 index 00000000000..2fb12dd2534 --- /dev/null +++ b/packages/async-flow/src/async-flow.js @@ -0,0 +1,502 @@ +import { annotateError, Fail, makeError, q, X } from '@endo/errors'; +import { E } from '@endo/eventual-send'; +import { M } from '@endo/patterns'; +import { makeScalarWeakMapStore } from '@agoric/store'; +import { PromiseWatcherI } from '@agoric/base-zone'; +import { prepareVowTools, toPassableCap, VowShape } from '@agoric/vow'; +import { makeReplayMembrane } from './replay-membrane.js'; +import { prepareLogStore } from './log-store.js'; +import { prepareBijection } from './bijection.js'; +import { LogEntryShape, FlowStateShape } from './type-guards.js'; + +/** + * @import { WeakMapStore } from '@agoric/store' + */ + +const { defineProperties } = Object; + +const AsyncFlowIKit = harden({ + flow: M.interface('Flow', { + getFlowState: M.call().returns(FlowStateShape), + restart: M.call().optional(M.boolean()).returns(), + wake: M.call().returns(), + getOutcome: M.call().returns(VowShape), + dump: M.call().returns(M.arrayOf(LogEntryShape)), + getOptFatalProblem: M.call().returns(M.opt(M.error())), + }), + admin: M.interface('FlowAdmin', { + reset: M.call().returns(), + complete: M.call().returns(), + panic: M.call(M.error()).returns(M.not(M.any())), // only throws + }), + wakeWatcher: PromiseWatcherI, +}); + +const AdminAsyncFlowI = M.interface('AsyncFlowAdmin', { + getFailures: M.call().returns(M.mapOf(M.remotable('asyncFlow'), M.error())), + wakeAll: M.call().returns(), + getFlowForOutcomeVow: M.call(VowShape).returns(M.opt(M.remotable('flow'))), +}); + +/** + * @param {Zone} outerZone + * @param {PreparationOptions} [outerOptions] + */ +export const prepareAsyncFlowTools = (outerZone, outerOptions = {}) => { + const { + vowTools = prepareVowTools(outerZone), + makeLogStore = prepareLogStore(outerZone), + makeBijection = prepareBijection(outerZone), + } = outerOptions; + const { watch, makeVowKit } = vowTools; + + const failures = outerZone.mapStore('asyncFuncFailures', { + keyShape: M.remotable('flow'), // flowState === 'Failed' + valueShape: M.error(), + }); + + const eagerWakers = outerZone.setStore(`asyncFuncEagerWakers`, { + keyShape: M.remotable('flow'), // flowState !== 'Done' + }); + + /** @type WeakMapStore */ + const membraneMap = makeScalarWeakMapStore('membraneFor', { + keyShape: M.remotable('flow'), + valueShape: M.remotable('membrane'), + }); + + const hasMembrane = flow => membraneMap.has(flow); + const getMembrane = flow => membraneMap.get(flow); + const initMembrane = (flow, membrane) => membraneMap.init(flow, membrane); + const deleteMembrane = flow => membraneMap.delete(flow); + + /** + * So we can give out wrapper functions easily and recover flow objects + * for their activations later. + */ + const flowForOutcomeVowKey = outerZone.mapStore('flowForOutcomeVow', { + keyShape: M.remotable('toPassableCap'), + valueShape: M.remotable('flow'), // flowState !== 'Done' + }); + + /** + * @param {Zone} zone + * @param {string} tag + * @param {GuestAsyncFunc} guestAsyncFunc + * @param {{ startEager?: boolean }} [options] + */ + const prepareAsyncFlowKit = (zone, tag, guestAsyncFunc, options = {}) => { + typeof guestAsyncFunc === 'function' || + Fail`guestAsyncFunc must be a callable function ${guestAsyncFunc}`; + const { + // May change default to false, once instances reliably wake up + startEager = true, + } = options; + + const internalMakeAsyncFlowKit = zone.exoClassKit( + tag, + AsyncFlowIKit, + activationArgs => { + harden(activationArgs); + const log = makeLogStore(); + const bijection = makeBijection(); + + return { + activationArgs, // replay starts by reactivating with these + log, // log to be accumulated or replayed + bijection, // membrane's guest-host mapping + outcomeKit: makeVowKit(), // outcome of activation as host vow + isDone: false, // persistently done + }; + }, + { + flow: { + /** + * @returns {FlowState} + */ + getFlowState() { + const { state, facets } = this; + const { log, outcomeKit, isDone } = state; + const { flow } = facets; + + if (isDone) { + !hasMembrane(flow) || + Fail`Done flow must drop membrane ${flow} ${getMembrane(flow)}`; + !failures.has(flow) || + Fail`Done flow must not be in failures ${flow} ${failures.get(flow)}`; + !eagerWakers.has(flow) || + Fail`Done flow must not be in eagerWakers ${flow}`; + !flowForOutcomeVowKey.has(outcomeKit.vow) || + Fail`Done flow must drop flow lookup from vow ${outcomeKit.vow}`; + (log.getIndex() === 0 && log.getLength() === 0) || + Fail`Done flow must empty log ${flow} ${log}`; + return 'Done'; + } + if (failures.has(flow)) { + return 'Failed'; + } + if (!hasMembrane(flow)) { + log.getIndex() === 0 || + Fail`Sleeping flow must play from log start ${flow} ${log.getIndex()}`; + return 'Sleeping'; + } + if (log.isReplaying()) { + return 'Replaying'; + } + return 'Running'; + }, + + /** + * Calls the guest function, either for the initial run or at the + * start of a replay. + * + * @param {boolean} [eager] + */ + restart(eager = startEager) { + const { state, facets } = this; + const { activationArgs, log, bijection, outcomeKit } = state; + const { flow, admin, wakeWatcher } = facets; + + const startFlowState = flow.getFlowState(); + + startFlowState !== 'Done' || + // separate line so I can set a breakpoint + Fail`Cannot restart a done flow ${flow}`; + + admin.reset(); + if (eager) { + eagerWakers.add(flow); + } else if (eagerWakers.has(flow)) { + eagerWakers.delete(flow); + } + + const wakeWatch = vowish => { + // Extra paranoid because we're getting + // "promise watcher must be a virtual object" + // in the general vicinity. + zone.isStorable(vowish) || + Fail`vowish must be storable in this zone (usually, must be durable): ${vowish}`; + zone.isStorable(wakeWatcher) || + Fail`wakeWatcher must be storable in this zone (usually, must be durable): ${wakeWatcher}`; + watch(vowish, wakeWatcher); + }; + const panic = err => admin.panic(err); + const membrane = makeReplayMembrane( + log, + bijection, + vowTools, + wakeWatch, + panic, + ); + initMembrane(flow, membrane); + const guestArgs = membrane.hostToGuest(activationArgs); + + const flowState = flow.getFlowState(); + flowState === 'Running' || + flowState === 'Replaying' || + Fail`Restarted flow must be Running or Replaying ${flow}`; + + // In case some host vows were settled before the guest makes + // the first call to a host object. + membrane.wake(); + + // We do *not* call the guestAsyncFunc by having the membrane make + // a host wrapper for the function. Rather, we special case this + // host-to-guest call by "manually" sending the arguments through + // and calling the guest function ourselves. Likewise, we + // special case the handling of the guestResultP, rather than + // ask the membrane to make a host vow for a guest promise. + // To support this special casing, we store additional replay + // data in this internal flow instance -- the host activationArgs + // and the host outcome vow kit. + const guestResultP = (async () => + // async IFFE ensures guestResultP is a fresh promise + guestAsyncFunc(...guestArgs))(); + + if (flow.getFlowState() !== 'Failed') { + // If the flow fails, that resets the bijection. Without this + // gating condition, the next line could grow the bijection + // of a failed flow, subverting other gating checks on bijection + // membership. + bijection.init(guestResultP, outcomeKit.vow); + } + // log is driven at first by guestAyncFunc interaction through the + // membrane with the host activationArgs. At the end of its first + // turn, it returns a promise for its eventual guest result. + // It then proceeds to interact with the host through the membrane + // in further turns by `await`ing (or otherwise registering) + // on host vows turned into guest promises, and by calling + // the guest presence of other host objects. + // + // `bijection.hasGuest(guestResultP)` can be false in a delayed + // guest - to - host settling from a previous run. + // In that case, the bijection was reset and all guest caps + // created in the previous run were unregistered, + // including `guestResultP`. + void E.when( + guestResultP, + gFulfillment => { + if (bijection.hasGuest(guestResultP)) { + outcomeKit.resolver.resolve( + membrane.guestToHost(gFulfillment), + ); + admin.complete(); + } + }, + guestReason => { + // The `guestResultP` might be a failure thrown by `panic` + // indicating a failure to replay. In that case, we must not + // settle the outcomeVow, since the outcome vow only represents + // the settled result of the async function itself. + // Fortunately, `panic` resets the bijection, again resulting + // in the `guestResultP` being absent from the bijection, + // so this leave the outcome vow unsettled, as it must. + if (bijection.hasGuest(guestResultP)) { + outcomeKit.resolver.reject(membrane.guestToHost(guestReason)); + admin.complete(); + } + }, + ); + }, + wake() { + const { facets } = this; + const { flow } = facets; + + const flowState = flow.getFlowState(); + switch (flowState) { + case 'Done': + case 'Failed': { + return; + } + case 'Running': + case 'Replaying': { + // Safe to call membrane wake for a replaying or running flow + // because it is idempotent. membrane.wake already has reentrancy + // protection. Aside from harmless reentrancy, calling + // membrane.wake won't cause it to do anything that it would + // not have done on its own. + // + // An interesting edge case is that when the guest proceeds + // from a top-level doReturn or doThrow, while we're still in + // the guest turn, if somehow flow.wake were to be called then, + // and if the next thing in the replay log was a `doCall` + // (a future feature), then the `doCall` would call the guest + // while it was still in the middle of a "past" turn. However, + // this cannot happen because `flow` is host-side. For it to + // be called while the guest is active, the membrane's + // `callStack` would not be empty. membrane.wake checks and + // already throws an error in that case. + // + // More important, during a replay, no guest action can actually + // call host functions at all. Rather, the host is fully + // emulated from the log. So this case cannot arise. + // + // This analysis *assumes* that the guest function has no access + // to the flow outside the membrane, i.e., the "closed guest" + // assumption. + getMembrane(flow).wake(); + return; + } + case 'Sleeping': { + flow.restart(); + return; + } + default: { + // Should be a at-ts-expect-error that this case is unreachable + // which TS clearly knows anyway because it thinks the following + // `flowState` variable in this context has type `never`. + throw Fail`unexpected flowState ${q(flowState)}`; + } + } + }, + getOutcome() { + const { state } = this; + const { outcomeKit } = state; + return outcomeKit.vow; + }, + dump() { + const { state } = this; + const { log } = state; + + return log.dump(); + }, + getOptFatalProblem() { + const { facets } = this; + const { flow } = facets; + + return failures.has(flow) ? failures.get(flow) : undefined; + }, + }, + admin: { + reset() { + const { state, facets } = this; + const { bijection, log } = state; + const { flow } = facets; + !state.isDone || Fail`Cannot reset a done flow`; + + if (failures.has(flow)) { + failures.delete(flow); + } + if (hasMembrane(flow)) { + getMembrane(flow).stop(); + deleteMembrane(flow); + } + log.reset(); + bijection.reset(); + }, + complete() { + const { state, facets } = this; + const { log } = state; + const { flow, admin } = facets; + + admin.reset(); + if (eagerWakers.has(flow)) { + eagerWakers.delete(flow); + } + flowForOutcomeVowKey.delete(toPassableCap(flow.getOutcome())); + state.isDone = true; + log.dispose(); + flow.getFlowState() === 'Done' || + Fail`Complete flow must be Done ${flow}`; + }, + panic(fatalProblem) { + const { state, facets } = this; + const { bijection, log } = state; + const { flow } = facets; + + if (failures.has(flow)) { + const prevErr = failures.get(flow); + annotateError( + prevErr, + X`doubly failed somehow with ${fatalProblem}`, + ); + // prevErr likely to be the more relevant diagnostic to report + fatalProblem = prevErr; + } else { + failures.init(flow, fatalProblem); + } + + if (hasMembrane(flow)) { + getMembrane(flow).stop(); + deleteMembrane(flow); + } + log.reset(); + bijection.reset(); + + flow.getFlowState() === 'Failed' || + Fail`Panicked flow must be Failed ${flow}`; + + // This is not an expected throw, so in theory arbitrary chaos + // may ensue from throwing it. But at this point + // we should have successfully isolated this activation from + // having any observable effects on the host, aside from + // console logging and + // resource exhaustion, including infinite loops + const err = makeError( + X`In a Failed state: see getFailures() or getOptFatalProblem() for more information`, + ); + annotateError(err, X`due to ${fatalProblem}`); + throw err; + }, + }, + wakeWatcher: { + onFulfilled(_fulfillment) { + const { facets } = this; + facets.flow.wake(); + }, + onRejected(_fulfillment) { + const { facets } = this; + facets.flow.wake(); + }, + }, + }, + ); + const makeAsyncFlowKit = activationArgs => { + const asyncFlowKit = internalMakeAsyncFlowKit(activationArgs); + const { flow } = asyncFlowKit; + + const vow = toPassableCap(flow.getOutcome()); + flowForOutcomeVowKey.init(toPassableCap(vow), flow); + flow.restart(); + return asyncFlowKit; + }; + return harden(makeAsyncFlowKit); + }; + + /** + * @param {Zone} zone + * @param {string} tag + * @param {GuestAsyncFunc} guestFunc + * @param {{ startEager?: boolean }} [options] + * @returns {HostAsyncFuncWrapper} + */ + const asyncFlow = (zone, tag, guestFunc, options = undefined) => { + const makeAsyncFlowKit = prepareAsyncFlowKit(zone, tag, guestFunc, options); + const hostFuncName = `${tag}_hostFlow`; + const wrapperFunc = { + [hostFuncName](...args) { + const { flow } = makeAsyncFlowKit(args); + return flow.getOutcome(); + }, + }[hostFuncName]; + defineProperties(wrapperFunc, { + length: { value: guestFunc.length }, + }); + return harden(wrapperFunc); + }; + + const adminAsyncFlow = outerZone.exo('AdminAsyncFlow', AdminAsyncFlowI, { + getFailures() { + return failures.snapshot(); + }, + wakeAll() { + // [...stuff.keys()] in order to snapshot before iterating + const failuresToRestart = [...failures.keys()]; + const flowsToWake = [...eagerWakers.keys()]; + for (const flow of failuresToRestart) { + flow.restart(); + } + for (const flow of flowsToWake) { + flow.wake(); + } + }, + getFlowForOutcomeVow(outcomeVow) { + return flowForOutcomeVowKey.get(toPassableCap(outcomeVow)); + }, + }); + + // Cannot call this until everything is prepared, so postpone to a later + // turn. (Ideally, we'd postpone to a later crank because prepares are + // allowed anytime in the first crank. But there's currently no pleasant + // way to postpone to a later crank.) + // See https://github.com/Agoric/agoric-sdk/issues/9377 + const allWokenP = E.when(null, () => adminAsyncFlow.wakeAll()); + + return harden({ + prepareAsyncFlowKit, + asyncFlow, + adminAsyncFlow, + allWokenP, + }); +}; +harden(prepareAsyncFlowTools); + +/** + * @typedef {ReturnType} AsyncFlowTools + */ + +/** + * @typedef {AsyncFlowTools['adminAsyncFlow']} AdminAsyncFlow + */ + +/** + * @typedef {ReturnType} MakeAsyncFlowKit + */ + +/** + * @typedef {ReturnType} AsyncFlowKit + */ + +/** + * @typedef {AsyncFlowKit['flow']} AsyncFlow + */ diff --git a/packages/async-flow/src/bijection.js b/packages/async-flow/src/bijection.js new file mode 100644 index 00000000000..ac9c7cf05a8 --- /dev/null +++ b/packages/async-flow/src/bijection.js @@ -0,0 +1,132 @@ +import { b, Fail } from '@endo/errors'; +import { M } from '@endo/patterns'; +import { Far } from '@endo/pass-style'; +import { toPassableCap } from '@agoric/vow'; +import { makeEphemera } from './ephemera.js'; + +const BijectionI = M.interface('Bijection', { + reset: M.call().returns(), + init: M.call(M.any(), M.any()).returns(), + hasGuest: M.call(M.any()).returns(M.boolean()), + hasHost: M.call(M.any()).returns(M.boolean()), + has: M.call(M.any(), M.any()).returns(M.boolean()), + guestToHost: M.call(M.any()).returns(M.any()), + hostToGuest: M.call(M.any()).returns(M.any()), +}); + +/** + * Makes a store like a WeakMapStore except that Promises and Vows can also be + * used as keys. + * NOTE: This depends on promise identity being stable! + * + * @param {string} name + */ +const makeVowishStore = name => { + // This internal map could be (and was) a WeakMap. But there are various ways + // in which a WeakMap is more expensive than a Map. The main advantage is + // that a WeakMap can drop entries whose keys are not otherwise retained. + // But async-flow only uses a bijection together with a log-store that happens + // to durably retain all the host-side keys of the associated bijection, so + // this additional feature of the bijection is irrelevant. When the bijection + // is reset or revived in a new incarnation, these vowishStores will be gone + // anyway, dropping all the guest-side objects. + const map = new Map(); + + return Far(name, { + init: (k, v) => { + const k2 = toPassableCap(k); + !map.has(k2) || + // separate line so I can set a breakpoint + Fail`${b(name)} key already bound: ${k} -> ${map.get(k2)} vs ${v}`; + map.set(k2, v); + }, + has: k => map.has(toPassableCap(k)), + get: k => { + const k2 = toPassableCap(k); + map.has(k2) || + // separate line so I can set a breakpoint + Fail`${b(name)} key not found: ${k}`; + return map.get(k2); + }, + }); +}; + +/** @typedef {ReturnType} VowishStore */ + +/** + * @param {Zone} zone + */ +export const prepareBijection = zone => { + /** @type {Ephemera} */ + const g2h = makeEphemera(() => makeVowishStore('guestToHost')); + /** @type {Ephemera} */ + const h2g = makeEphemera(() => makeVowishStore('hostToGuest')); + + return zone.exoClass('Bijection', BijectionI, () => ({}), { + reset() { + const { self } = this; + + g2h.resetFor(self); + h2g.resetFor(self); + }, + init(g, h) { + const { self } = this; + const guestToHost = g2h.for(self); + const hostToGuest = h2g.for(self); + + !hostToGuest.has(h) || + Fail`hostToGuest key already bound: ${h} -> ${hostToGuest.get(h)} vs ${g}`; + guestToHost.init(g, h); + hostToGuest.init(h, g); + self.has(g, h) || + // separate line so I can set a breakpoint + Fail`internal: ${g} <-> ${h}`; + }, + hasGuest(g) { + const { self } = this; + const guestToHost = g2h.for(self); + + return guestToHost.has(g); + }, + hasHost(h) { + const { self } = this; + const hostToGuest = h2g.for(self); + + return hostToGuest.has(h); + }, + has(g, h) { + const { self } = this; + const guestToHost = g2h.for(self); + const hostToGuest = h2g.for(self); + + if (guestToHost.has(g)) { + toPassableCap(guestToHost.get(g)) === toPassableCap(h) || + Fail`internal: g->h ${g} -> ${h} vs ${guestToHost.get(g)}`; + hostToGuest.get(h) === g || + Fail`internal h->g: ${h} -> ${g} vs ${hostToGuest.get(h)}`; + return true; + } else { + !hostToGuest.has(h) || + Fail`internal: unexpected h->g ${h} -> ${hostToGuest.get(h)}`; + return false; + } + }, + guestToHost(g) { + const { self } = this; + const guestToHost = g2h.for(self); + + return guestToHost.get(g); + }, + hostToGuest(h) { + const { self } = this; + const hostToGuest = h2g.for(self); + + return hostToGuest.get(h); + }, + }); +}; +harden(prepareBijection); + +/** + * @typedef {ReturnType>} Bijection + */ diff --git a/packages/async-flow/src/convert.js b/packages/async-flow/src/convert.js new file mode 100644 index 00000000000..981dbdda261 --- /dev/null +++ b/packages/async-flow/src/convert.js @@ -0,0 +1,131 @@ +import { Fail, X, annotateError, makeError, q } from '@endo/errors'; +import { throwLabeled } from '@endo/common/throw-labeled.js'; +import { + getErrorConstructor, + getTag, + isObject, + makeTagged, + passStyleOf, +} from '@endo/pass-style'; +import { isVow } from '@agoric/vow/src/vow-utils.js'; +import { objectMap } from '@endo/common/object-map.js'; + +const makeConvert = (convertRemotable, convertPromiseOrVow, convertError) => { + const convertRecur = (specimen, label) => { + // Open code the synchronous part of applyLabelingError, because + // we need to preserve returned promise identity. + // TODO switch to Richard Gibson's suggestion for a better way + // to keep track of the error labeling. + // See https://github.com/endojs/endo/pull/1795#issuecomment-1756093032 + if (label === undefined) { + // eslint-disable-next-line no-use-before-define + return innerConvert(specimen); + } + try { + // eslint-disable-next-line no-use-before-define + return innerConvert(specimen); + } catch (err) { + throwLabeled(err, label); + } + }; + + const innerConvert = specimen => { + if (!isObject(specimen)) { + return specimen; + } + const passStyle = passStyleOf(specimen); + switch (passStyle) { + case 'copyArray': { + return specimen.map((element, i) => convertRecur(element, i)); + } + case 'copyRecord': { + return objectMap(specimen, (value, name) => convertRecur(value, name)); + } + case 'tagged': { + if (isVow(specimen)) { + return convertPromiseOrVow(specimen); + } + const tag = getTag(specimen); + const { payload } = specimen; + return makeTagged(tag, convertRecur(payload, `${tag} payload`)); + } + case 'error': { + return convertError(specimen); + } + case 'remotable': { + return convertRemotable(specimen); + } + case 'promise': { + return convertPromiseOrVow(specimen); + } + default: { + throw Fail`unexpected passStyle ${q(passStyle)}`; + } + } + }; + + /** + * @param {Passable} specimen + * @param {string} [label] + */ + const convert = (specimen, label = undefined) => + convertRecur(harden(specimen), label); + return harden(convert); +}; + +export const makeConvertKit = ( + bijection, + makeGuestForHostRemotable, + makeGuestForHostVow, +) => { + const guestToHost = makeConvert( + gRem => { + if (bijection.hasGuest(gRem)) { + return bijection.guestToHost(gRem); + } + throw Fail`cannot yet send guest remotables ${gRem}`; + }, + gProm => { + if (bijection.hasGuest(gProm)) { + return bijection.guestToHost(gProm); + } + throw Fail`cannot yet send guest promises ${gProm}`; + }, + gErr => { + const hErr = harden( + makeError(gErr.message, getErrorConstructor(gErr.name)), + ); + annotateError(hErr, X`from guest error ${gErr}`); + return hErr; + }, + ); + + const hostToGuest = makeConvert( + hRem => { + if (bijection.hasHost(hRem)) { + return bijection.hostToGuest(hRem); + } + const gRem = makeGuestForHostRemotable(hRem); + bijection.init(gRem, hRem); + return gRem; + }, + hVow => { + if (bijection.hasHost(hVow)) { + return bijection.hostToGuest(hVow); + } + const gP = makeGuestForHostVow(hVow); + bijection.init(gP, hVow); + return gP; + }, + hErr => { + const gErr = harden( + makeError(hErr.message, getErrorConstructor(hErr.name)), + ); + annotateError(gErr, X`from host error ${hErr}`); + return gErr; + }, + ); + + return harden({ guestToHost, hostToGuest }); +}; +harden(makeConvertKit); diff --git a/packages/async-flow/src/ephemera.js b/packages/async-flow/src/ephemera.js new file mode 100644 index 00000000000..a4e8a54debe --- /dev/null +++ b/packages/async-flow/src/ephemera.js @@ -0,0 +1,35 @@ +/** + * Used by a possibly-durable exo to store per-instance ephemeral state. + * Each ephemera is created at the exo class prepare level, and then + * used from within the exo class methods to get state `eph.for(self)`. + * At the beginning of a new incarnation, there is no such state, so + * the first time it is accessed, it is initialized from `reinit(self)`. + * The ephemeral state can be dropped explicitly during an incarnation + * with `eph.resetFor(self)`, in which case the `eph.for(self)` will + * call it to be reinitialized again from `reinit(self)`. + * + * TODO consolidate with `makeEphemeraProvider` from `@agoric/zoe`, since + * they are serving similar purposes in similar ways. + * + * @template {WeakKey} [S=WeakKey] + * @template {any} [V=any] + * @param {(self: S) => V} reinit + * @returns {Ephemera} + */ +export const makeEphemera = reinit => { + /** @type {WeakMap} */ + const map = new WeakMap(); + + return harden({ + for(self) { + if (!map.has(self)) { + map.set(self, reinit(self)); + } + return /** @type {V} */ (map.get(self)); + }, + resetFor(self) { + return map.delete(self); + }, + }); +}; +harden(makeEphemera); diff --git a/packages/async-flow/src/equate.js b/packages/async-flow/src/equate.js new file mode 100644 index 00000000000..f9ab177ad12 --- /dev/null +++ b/packages/async-flow/src/equate.js @@ -0,0 +1,123 @@ +import { Fail, X, annotateError, q } from '@endo/errors'; +import { throwLabeled } from '@endo/common/throw-labeled.js'; +import { getTag, isObject, passStyleOf } from '@endo/pass-style'; +import { isVow } from '@agoric/vow/src/vow-utils.js'; +import { recordNames } from '@endo/marshal'; + +const { is } = Object; + +export const makeEquate = bijection => { + const equate = (g, h, label) => { + // Open code the synchronous part of applyLabelingError, because + // we need to preserve returned promise identity. + // TODO switch to Richard Gibson's suggestion for a better way + // to keep track of the error labeling. + if (label === undefined) { + // eslint-disable-next-line no-use-before-define + innerEquate(g, h); + } + try { + // eslint-disable-next-line no-use-before-define + innerEquate(g, h); + } catch (err) { + throwLabeled(err, label); + } + }; + + const innerEquate = (g, h) => { + if (!isObject(g)) { + is(g, h) || + // separate line so I can set a breakpoint + Fail`unequal ${g} vs ${h}`; + return; + } + if (bijection.has(g, h)) { + return; + } + const gPassStyle = passStyleOf(g); + if (gPassStyle === 'promise' && isVow(h)) { + // Important special case, because vows have passStyle 'tagged'. + // However, we do not yet support passing guest promise to host. + // TODO when we do, delete the `throw Fail` line and uncomment + // the two lines below it. + // We *do* support passing a guest wrapper of a hostVow back + // to the host, but that would be cause by `bijection.has` above. + throw Fail`guest promises not yet passable`; + // `init` does not yet do enough checking anyway. For this case, + // we should ensure that h is a host wrapper of a guest promise, + // which is a wrapping we don't yet support. + // bijection.init(g, h); + // return; + } + const hPassStyle = passStyleOf(h); + gPassStyle === hPassStyle || + Fail`unequal passStyles ${q(gPassStyle)} vs ${q(hPassStyle)}`; + switch (gPassStyle) { + case 'copyArray': { + equate(g.length, h.length, 'length'); + // eslint-disable-next-line github/array-foreach + g.forEach((gEl, i) => equate(gEl, h[i], i)); + return; + } + case 'copyRecord': { + const gNames = recordNames(g); + const hNames = recordNames(h); + equate(gNames, hNames, 'propertyNames'); + for (const name of gNames) { + equate(g[name], h[name], name); + } + return; + } + case 'tagged': { + equate(getTag(g), getTag(h), 'tag'); + equate(g.payload, h.payload, 'payload'); + return; + } + case 'error': { + equate(g.name, h.name, 'error name'); + // For errors, all that needs to agree on replay is the `name` + // property. All others can differ. That's because everything else + // is assumed to be diagnostic info useful to programmers, which + // we'd like to improve over time. No programmatic use of additional + // error diagnostic info other than to better inform developers. + // A program should not take a semantically significant branch + // based on any of this diagnostic info, aside from `name`. + // + // Error annotations are not observable outside the console output, + // so this does not breach membrane isolation. + annotateError(g, X`replay of error ${h}`); + annotateError(h, X`replayed as error ${g}`); + return; + } + case 'remotable': { + // Note that we can send a guest wrapping of a host remotable + // back to the host, + // but that should have already been taken care of by the + // `bijection.has` above. + throw Fail`cannot yet send guest remotables to host ${g} vs ${h}`; + // `init` does not yet do enough checking anyway. For this case, + // we should ensure that h is a host wrapper of a guest remotable, + // which is a wrapping we don't yet support. + // bijection.init(g, h); + // return; + } + case 'promise': { + // Note that we can send a guest wrapping of a host promise + // (or vow) back to the host, + // but that should have already been taken care of by the + // `bijection.has` above. + throw Fail`cannot yet send guest promises to host ${g} vs ${h}`; + // `init` does not yet do enough checking anyway. For this case, + // we should ensure that h is a host wrapper of a guest promise, + // which is a wrapping we don't yet support. + // bijection.init(g, h); + // return; + } + default: { + throw Fail`unexpected passStyle ${q(gPassStyle)}`; + } + } + }; + return harden(equate); +}; +harden(makeEquate); diff --git a/packages/async-flow/src/log-store.js b/packages/async-flow/src/log-store.js new file mode 100644 index 00000000000..fa98b064bf5 --- /dev/null +++ b/packages/async-flow/src/log-store.js @@ -0,0 +1,165 @@ +import { Fail, q } from '@endo/errors'; +import { makePromiseKit } from '@endo/promise-kit'; +import { M } from '@endo/patterns'; +import { LogEntryShape } from './type-guards.js'; +import { makeEphemera } from './ephemera.js'; + +/** + * @import {MapStore} from '@agoric/store' + */ + +const LogStoreI = M.interface('LogStore', { + reset: M.call().returns(), + dispose: M.call().returns(), + getIndex: M.call().returns(M.number()), + getLength: M.call().returns(M.number()), + isReplaying: M.call().returns(M.boolean()), + peekEntry: M.call().returns(LogEntryShape), + nextEntry: M.call().returns(LogEntryShape), + pushEntry: M.call(LogEntryShape).returns(M.number()), + dump: M.call().returns(M.arrayOf(LogEntryShape)), + promiseReplayDone: M.call().returns(M.promise()), +}); + +/** + * A growable, replayable, sequence of `LogEntry`s. + * + * @param {Zone} zone + */ +export const prepareLogStore = zone => { + /** + * @type {Ephemera + * }>} + */ + const tmp = makeEphemera(log => { + const result = { + index: 0, + replayDoneKit: makePromiseKit(), + }; + if (log.getLength() === 0) { + result.replayDoneKit.resolve(undefined); + } + return result; + }); + + return zone.exoClass( + 'LogStore', + LogStoreI, + () => { + /** + * Really used to emulate a zone-storable vector, i.e., what in + * conventional JS you'd use a mutable array for, where you mutate + * only by `.push` + * + * @type {MapStore} + */ + const mapStore = zone.detached().mapStore('logMapStore', { + keyShape: M.number(), + valueShape: LogEntryShape, + }); + return { + mapStore, + }; + }, + { + reset() { + const { self } = this; + tmp.resetFor(self); + + // TODO: Should we resolve replayDoneKit here, in case we're + // transitioning to a Failed state, so that any pending watchers + // can exit? + }, + dispose() { + const { state, self } = this; + const { mapStore } = state; + + tmp.resetFor(self); + mapStore.clear(); + }, + getIndex() { + const { self } = this; + const eph = tmp.for(self); + + return eph.index; + }, + getLength() { + const { state } = this; + const { mapStore } = state; + + return mapStore.getSize(); + }, + isReplaying() { + const { state, self } = this; + const { mapStore } = state; + const eph = tmp.for(self); + + return eph.index < mapStore.getSize(); + }, + peekEntry() { + const { state, self } = this; + const { mapStore } = state; + const eph = tmp.for(self); + + self.isReplaying() || + Fail`No longer replaying: ${q(eph.index)} vs ${q( + mapStore.getSize(), + )}`; + const result = mapStore.get(eph.index); + return result; + }, + nextEntry() { + const { self } = this; + const eph = tmp.for(self); + + const result = self.peekEntry(); + eph.index += 1; + if (!self.isReplaying()) { + eph.replayDoneKit.resolve(undefined); + } + return result; + }, + pushEntry(entry) { + const { state, self } = this; + const { mapStore } = state; + const eph = tmp.for(self); + + !self.isReplaying() || + Fail`still replaying: ${q(eph.index)} vs ${q(mapStore.getSize())}`; + eph.index === mapStore.getSize() || + Fail`internal: index confusion ${q(eph.index)} vs ${q( + mapStore.getSize(), + )}`; + mapStore.init(eph.index, entry); + eph.index += 1; + eph.index === mapStore.getSize() || + Fail`internal: index confusion ${q(eph.index)} vs ${q( + mapStore.getSize(), + )}`; + return eph.index; + }, + dump() { + const { state } = this; + const { mapStore } = state; + const len = mapStore.getSize(); + const result = []; + for (let i = 0; i < len; i += 1) { + result.push(mapStore.get(i)); + } + return harden(result); + }, + promiseReplayDone() { + const { self } = this; + const eph = tmp.for(self); + + return eph.replayDoneKit.promise; + }, + }, + ); +}; + +/** + * @typedef {ReturnType>} LogStore + */ diff --git a/packages/async-flow/src/replay-membrane.js b/packages/async-flow/src/replay-membrane.js new file mode 100644 index 00000000000..569e7c43f9c --- /dev/null +++ b/packages/async-flow/src/replay-membrane.js @@ -0,0 +1,435 @@ +/* eslint-disable no-use-before-define */ +import { Fail, b, q } from '@endo/errors'; +import { Far, Remotable, getInterfaceOf } from '@endo/pass-style'; +import { E } from '@endo/eventual-send'; +import { getMethodNames } from '@endo/eventual-send/utils.js'; +import { makePromiseKit } from '@endo/promise-kit'; +import { makeEquate } from './equate.js'; +import { makeConvertKit } from './convert.js'; + +const { fromEntries, defineProperties } = Object; + +/** + * @param {LogStore} log + * @param {Bijection} bijection + * @param {VowTools} vowTools + * @param {(vowish: Promise | Vow) => void} watchWake + * @param {(problem: Error) => never} panic + */ +export const makeReplayMembrane = ( + log, + bijection, + vowTools, + watchWake, + panic, +) => { + const { when } = vowTools; + + const equate = makeEquate(bijection); + + const guestPromiseMap = new WeakMap(); + + let stopped = false; + + // ////////////// Host or Interpreter to Guest /////////////////////////////// + + /** + * When replaying, this comes from interpreting the log. + * Otherwise, it is triggered by a watcher watching hostVow, + * that must also log it. + * + * @param {HostVow} hostVow + * @param {Host} hostFulfillment + */ + const doFulfill = (hostVow, hostFulfillment) => { + const guestPromise = hostToGuest(hostVow); + const status = guestPromiseMap.get(guestPromise); + if (!status || status === 'settled') { + Fail`doFulfill should only be called on a registered unresolved promise`; + } + const guestFulfillment = hostToGuest(hostFulfillment); + status.resolve(guestFulfillment); + guestPromiseMap.set(guestPromise, 'settled'); + }; + + /** + * When replaying, this comes from interpreting the log. + * Otherwise, it is triggered by a watcher watching hostVow, + * that must also log it. + * + * @param {HostVow} hostVow + * @param {Host} hostReason + */ + const doReject = (hostVow, hostReason) => { + const guestPromise = hostToGuest(hostVow); + const status = guestPromiseMap.get(guestPromise); + if (!status || status === 'settled') { + Fail`doReject should only be called on a registered unresolved promise`; + } + const guestReason = hostToGuest(hostReason); + status.reject(guestReason); + guestPromiseMap.set(guestPromise, 'settled'); + }; + + /** + * When replaying, after the guest thinks it has called a host method, + * triggering `checkCall`, that host method emulator consumes one of + * these entries from the log to return what it is supposed to. + * It returns an Outcome describing either a throw or return, because we + * reserve the actual throw channels for replay errors and internal + * errors. + * + * @param {number} callIndex + * @param {Host} hostResult + * @returns {Outcome} + */ + const doReturn = (callIndex, hostResult) => { + unnestInterpreter(callIndex); + const guestResult = hostToGuest(hostResult); + return harden({ + kind: 'return', + result: guestResult, + }); + }; + + /** + * When replaying, after the guest thinks it has called a host method, + * triggering `checkCall`, that host method emulator consumes one of + * these entries from the log to return what it is supposed to. + * It returns an Outcome describing either a throw or return, because we + * reserve the actual throw channels for replay errors and internal + * errors. + * + * @param {number} callIndex + * @param {Host} hostProblem + * @returns {Outcome} + */ + const doThrow = (callIndex, hostProblem) => { + unnestInterpreter(callIndex); + const guestProblem = hostToGuest(hostProblem); + return harden({ + kind: 'throw', + problem: guestProblem, + }); + }; + + // ///////////// Guest to Host or consume log //////////////////////////////// + + const performCall = (hostTarget, optVerb, hostArgs, callIndex) => { + let hostResult; + try { + hostResult = optVerb + ? hostTarget[optVerb](...hostArgs) + : hostTarget(...hostArgs); + // Try converting here just to route the error correctly + hostToGuest(hostResult, `converting ${optVerb || 'host'} result`); + } catch (hostProblem) { + return logDo(nestDispatch, harden(['doThrow', callIndex, hostProblem])); + } + return logDo(nestDispatch, harden(['doReturn', callIndex, hostResult])); + }; + + const guestCallsHost = (guestTarget, optVerb, guestArgs, callIndex) => { + if (stopped || !bijection.hasGuest(guestTarget)) { + // This happens in a delayed guest-to-host call from a previous run. + // In that case, the bijection was reset and all guest caps + // created in the previous run were unregistered, + // including guestTarget. + // Throwing an error back to the old guest caller may cause + // it to proceed in all sorts of crazy ways. But that old run + // should now be isolated and unable to cause any observable effects. + // Well, except for resource exhaustion including infinite loops, + // which would be a genuine problem. + // + // Console logging of unhandled rejections, errors thrown to the top + // of the event loop, or anything else are not problematic effects. + // At this level of abstraction, we don't consider console logging + // activity to be observable. Thus, it is also ok for the guest + // function, which should otherwise be closed, to + // capture (lexically "close over") the `console`. + const extraDiagnostic = + callStack.length === 0 + ? '' + : // This case should only happen when the callStack is empty + ` with non-empty callstack ${q(callStack)};`; + Fail`Called from a previous run: ${guestTarget}${b(extraDiagnostic)}`; + } + /** @type {Outcome} */ + let outcome; + try { + const guestEntry = harden([ + 'checkCall', + guestTarget, + optVerb, + guestArgs, + callIndex, + ]); + if (log.isReplaying()) { + const entry = log.nextEntry(); + equate( + guestEntry, + entry, + `replay ${callIndex}: + ${q(guestEntry)} + vs ${q(entry)} + `, + ); + outcome = /** @type {Outcome} */ (nestInterpreter(callIndex)); + } else { + const entry = guestToHost(guestEntry); + log.pushEntry(entry); + const [_, ...args] = entry; + nestInterpreter(callIndex); + outcome = performCall(...args); + } + } catch (fatalError) { + throw panic(fatalError); + } + + switch (outcome.kind) { + case 'return': { + return outcome.result; + } + case 'throw': { + throw outcome.problem; + } + default: { + // @ts-expect-error TS correctly knows this case would be outside + // the type. But that's what we want to check. + throw Fail`unexpected outcome kind ${q(outcome.kind)}`; + } + } + }; + + // //////////////// Converters /////////////////////////////////////////////// + + const makeGuestForHostRemotable = hRem => { + // Nothing here that captures `hRem` should make any use of it after the + // `makeGuestForHostRemotable` returns. This invariant enables + // `makeGuestForHostRemotable` to clear the `hRem` variable just before + // it returns, so any implementation-level capture of the variable does + // not inadvertently retain the host remotable which was the original + // value of the `hRem` variable. + let gRem; + /** @param {PropertyKey} [optVerb] */ + const makeGuestMethod = (optVerb = undefined) => { + const guestMethod = (...guestArgs) => { + const callIndex = log.getIndex(); + return guestCallsHost(gRem, optVerb, guestArgs, callIndex); + }; + if (optVerb) { + defineProperties(guestMethod, { + name: { value: String(hRem[optVerb].name || optVerb) }, + length: { value: Number(hRem[optVerb].length || 0) }, + }); + } else { + defineProperties(guestMethod, { + name: { value: String(hRem.name || 'anon') }, + length: { value: Number(hRem.length || 0) }, + }); + } + return guestMethod; + }; + const iface = String(getInterfaceOf(hRem) || 'remotable'); + const guestIface = `${iface} guest wrapper`; // just for debugging clarity + if (typeof hRem === 'function') { + // NOTE: Assumes that a far function has no "static" methods. This + // is the current marshal design, but revisit this if we change our + // minds. + gRem = Remotable(guestIface, undefined, makeGuestMethod()); + // NOTE: If we ever do support that, probably all we need + // to do is remove the following `throw Fail` line. + throw Fail`host far functions not yet passable`; + } else { + const methodNames = getMethodNames(hRem); + const guestMethods = methodNames.map(name => [ + name, + makeGuestMethod(name), + ]); + // TODO in order to support E *well*, + // use HandledPromise to make gRem a remote presence for hRem + gRem = Remotable(guestIface, undefined, fromEntries(guestMethods)); + } + // See note at the top of the function to see why clearing the `hRem` + // variable is safe, and what invariant the above code needs to maintain so + // that it remains safe. + hRem = undefined; + return gRem; + }; + harden(makeGuestForHostRemotable); + + const makeGuestForHostVow = hVow => { + // TODO in order to support E *well*, + // use HandledPromise to make `promise` a handled promise for hVow + const { promise, resolve, reject } = makePromiseKit(); + guestPromiseMap.set(promise, harden({ resolve, reject })); + + watchWake(hVow); + + // The replay membrane is the only component inserting entries into + // the log. In particular, the flow's vow durable watcher does not log the + // settlement outcome, and instead it's the responsibility of the + // membrane's ephemeral handler. Because of this, the membrane's handler + // must be careful to: + // - Be added to the vow if the settlement has not yet been recorded in + // the log. + // - Insert a single settlement outcome in the log for the given vow. + // + // In practice the former is accomplished by a handler always being + // added to the host vow when creating a guest promise, and the + // handler checking after replay is complete, whether the guest promise + // is already settled (by the log replay) or not. The latter is + // accomplished by checking that the membrane has not been stopped + // before updating the log. + + void when( + hVow, + async hostFulfillment => { + await log.promiseReplayDone(); // should never reject + if (!stopped && guestPromiseMap.get(promise) !== 'settled') { + /** @type {LogEntry} */ + const entry = harden(['doFulfill', hVow, hostFulfillment]); + log.pushEntry(entry); + try { + interpretOne(topDispatch, entry); + } catch { + // interpretOne does its own try/catch/panic, so failure would + // already be registered. Here, just return to avoid the + // Unhandled rejection. + } + } + }, + async hostReason => { + await log.promiseReplayDone(); // should never reject + if (!stopped && guestPromiseMap.get(promise) !== 'settled') { + /** @type {LogEntry} */ + const entry = harden(['doReject', hVow, hostReason]); + log.pushEntry(entry); + try { + interpretOne(topDispatch, entry); + } catch { + // interpretOne does its own try/catch/panic, so failure would + // already be registered. Here, just return to avoid the + // Unhandled rejection. + } + } + }, + ); + return promise; + }; + harden(makeGuestForHostVow); + + const { guestToHost, hostToGuest } = makeConvertKit( + bijection, + makeGuestForHostRemotable, + makeGuestForHostVow, + ); + + // /////////////////////////////// Interpreter /////////////////////////////// + + /** + * These are the only ones that are driven from the interpreter loop + */ + const topDispatch = harden({ + doFulfill, + doReject, + // doCall, // unimplemented in the current plan + }); + + /** + * These are the only ones that are driven from the interpreter loop + */ + const nestDispatch = harden({ + // doCall, // unimplemented in the current plan + doReturn, + doThrow, + }); + + const interpretOne = (dispatch, [op, ...args]) => { + try { + op in dispatch || + // separate line so I can set a breakpoint + Fail`unexpected dispatch op: ${q(op)}`; + return dispatch[op](...args); + } catch (problem) { + throw panic(problem); + } + }; + + const logDo = (dispatch, entry) => { + log.pushEntry(entry); + return interpretOne(dispatch, entry); + }; + + const callStack = []; + + let unnestFlag = false; + + /** + * @param {number} callIndex + * @returns {Outcome | undefined} + */ + const nestInterpreter = callIndex => { + callStack.push(callIndex); + while (log.isReplaying() && !stopped) { + const entry = log.nextEntry(); + const optOutcome = interpretOne(nestDispatch, entry); + if (unnestFlag) { + optOutcome || + // separate line so I can set a breakpoint + Fail`only unnest with an outcome: ${q(entry[0])}`; + unnestFlag = false; + return optOutcome; + } + } + unnestFlag = false; + }; + + /** + * @param {number} callIndex + */ + const unnestInterpreter = callIndex => { + !stopped || + Fail`This membrane stopped. Restart with new membrane ${replayMembrane}`; + callStack.length >= 1 || + // separate line so I can set a breakpoint + Fail`Unmatched unnest: ${q(callIndex)}`; + const i = callStack.pop(); + i === callIndex || + // separate line so I can set a breakpoint + Fail`Unexpected unnest: ${q(callIndex)} vs ${q(i)}`; + unnestFlag = true; + if (callStack.length === 0) { + void E.when(undefined, wake); + } + }; + + const wake = () => { + while (log.isReplaying() && !stopped) { + callStack.length === 0 || + Fail`wake only with empty callStack: ${q(callStack)}`; + const entry = log.peekEntry(); + const op = entry[0]; + if (!(op in topDispatch)) { + return; + } + void log.nextEntry(); + interpretOne(topDispatch, entry); + } + }; + + const stop = () => { + stopped = true; + }; + + const replayMembrane = Far('replayMembrane', { + hostToGuest, + guestToHost, + wake, + stop, + }); + return replayMembrane; +}; +harden(makeReplayMembrane); + +/** @typedef {ReturnType} ReplayMembrane */ diff --git a/packages/async-flow/src/type-guards.js b/packages/async-flow/src/type-guards.js new file mode 100644 index 00000000000..65551bd5f89 --- /dev/null +++ b/packages/async-flow/src/type-guards.js @@ -0,0 +1,54 @@ +import { M } from '@endo/patterns'; +import { VowShape } from '@agoric/vow'; + +export const FlowStateShape = M.or( + 'Running', + 'Sleeping', + 'Replaying', + 'Failed', + 'Done', +); + +export const PropertyKeyShape = M.or(M.string(), M.symbol()); + +export const LogEntryShape = M.or( + // ////////////////////////////// From Host to Guest ///////////////////////// + ['doFulfill', VowShape, M.any()], + ['doReject', VowShape, M.any()], + // [ + // 'doCall', + // M.remotable('host wrapper of guest target'), + // M.opt(PropertyKeyShape), + // M.arrayOf(M.any()), + // M.number(), + // ], + // [ + // 'doSend', + // M.or(M.remotable('host wrapper of guest target'), VowShape), + // M.opt(PropertyKeyShape), + // M.arrayOf(M.any()), + // M.number(), + // ], + ['doReturn', M.number(), M.any()], + ['doThrow', M.number(), M.any()], + + // ////////////////////////////// From Guest to Host ///////////////////////// + // ['checkFulfill', VowShape, M.any()], + // ['checkReject', VowShape, M.any()], + [ + 'checkCall', + M.remotable('host target'), + M.opt(PropertyKeyShape), + M.arrayOf(M.any()), + M.number(), + ], + // [ + // 'checkSend', + // M.or(M.remotable('host target'), VowShape), + // M.opt(PropertyKeyShape), + // M.arrayOf(M.any()), + // M.number(), + // ], + // ['checkReturn', M.number(), M.any()], + // ['checkThrow', M.number(), M.any()], +); diff --git a/packages/async-flow/src/types.js b/packages/async-flow/src/types.js new file mode 100644 index 00000000000..f018c081a51 --- /dev/null +++ b/packages/async-flow/src/types.js @@ -0,0 +1,164 @@ +/** + * @import {PromiseKit} from '@endo/promise-kit' + * @import {Passable} from '@endo/pass-style' + * @import {Zone} from '@agoric/base-zone' + * @import {Vow, VowTools} from '@agoric/vow' + * @import {LogStore} from './log-store.js' + * @import {Bijection} from './bijection.js' + * @import {ReplayMembrane} from './replay-membrane.js' + */ + +/** + * @typedef {'Running' | + * 'Sleeping' | + * 'Replaying' | + * 'Failed' | + * 'Done' + * } FlowState + */ + +/** + * @template {Passable} [T=Passable] + * @typedef {T} Guest + */ + +/** + * @template {Passable} [T=Passable] + * @typedef {T} Host + */ + +/** + * A HostVow must be durably storable. It corresponds to an + * ephemeral guest promise. + * + * @template {Passable} [T=Passable] + * @typedef {Host>} HostVow + */ + +/** + * @typedef {(...activationArgs: Guest[]) => Guest} GuestAsyncFunc + */ + +/** + * @typedef {(...activationArgs: Host[]) => HostVow} HostAsyncFuncWrapper + */ + +/** + * @typedef {object} PreparationOptions + * @property {VowTools} [vowTools] + * @property {() => LogStore} [makeLogStore] + * @property {() => Bijection} [makeBijection] + */ + +/** + * @typedef {'return'|'throw'} OutcomeKind + */ + +/** + * @typedef {{kind: 'return', result: any} + * | {kind: 'throw', problem: any} + * } Outcome + */ + +/** + * @template {WeakKey} [S=WeakKey] + * @template {any} [V=any] + * @typedef {object} Ephemera + * @property {(self: S) => V} for + * @property {(self: S) => void} resetFor + */ + +/** + * This is the typedef for the membrane log entries we currently implement. + * See comment below for the commented-out typedef for the full + * membrane log entry, which we do not yet support. + * + * @typedef {[ // ///////////////// From Host to Guest ///////////////////////// + * op: 'doFulfill', + * vow: HostVow, + * fulfillment: Host, + * ] | [ + * op: 'doReject', + * vow: HostVow, + * reason: Host, + * ] | [ + * op: 'doReturn', + * callIndex: number, + * result: Host, + * ] | [ + * op: 'doThrow', + * callIndex: number, + * problem: Host, + * ] | [ // ///////////////////// From Guest to Host ///////////////////////// + * op: 'checkCall', + * target: Host, + * optVerb: PropertyKey|undefined, + * args: Host[], + * callIndex: number + * ]} LogEntry + */ + +/** + * This would be the typedef for the full membrane log, if we supported + * - the guest sending guest-promises and guest-remotables to the host + * - the guest using `E` to eventual-send to guest wrappers of host + * vows and remotables. + * + * at-typedef {[ // ///////////////// From Host to Guest /////////////////////// + * op: 'doFulfill', + * vow: HostVow, + * fulfillment: Host, + * ] | [ + * op: 'doReject', + * vow: HostVow, + * reason: Host, + * ] | [ + * op: 'doCall', + * target: Host, + * optVerb: PropertyKey|undefined, + * args: Host[], + * callIndex: number + * ] | [ + * op: 'doSend', + * target: Host, + * optVerb: PropertyKey|undefined, + * args: Host[], + * callIndex: number + * ] | [ + * op: 'doReturn', + * callIndex: number, + * result: Host, + * ] | [ + * op: 'doThrow', + * callIndex: number, + * problem: Host, + * ] | [ // ///////////////////// From Guest to Host ///////////////////////// + * op: 'checkFulfill', + * vow: HostVow, + * fulfillment: Host, + * ] | [ + * op: 'checkReject', + * vow: HostVow, + * reason: Host, + * ] | [ + * op: 'checkCall', + * target: Host, + * optVerb: PropertyKey|undefined, + * args: Host[], + * callIndex: number + * ] | [ + * op: 'checkSend', + * target: Host, + * optVerb: PropertyKey|undefined, + * args: Host[], + * callIndex: number + * ] | [ + * op: 'checkReturn', + * callIndex: number, + * result: Host, + * ] | [ + * op: 'checkThrow', + * callIndex: number, + * problem: Host, + * ]} LogEntry + */ diff --git a/packages/async-flow/test/async-flow-crank.test.js b/packages/async-flow/test/async-flow-crank.test.js new file mode 100644 index 00000000000..1dfa89c7d8d --- /dev/null +++ b/packages/async-flow/test/async-flow-crank.test.js @@ -0,0 +1,96 @@ +// The purpose of this test file is to demonstrate +// https://github.com/Agoric/agoric-sdk/issues/9377 +// as the `test.serial.failing` test at the end. + +// eslint-disable-next-line import/order +import { + test, + getBaggage, + annihilate, + nextLife, +} from './prepare-test-env-ava.js'; + +import { eventLoopIteration } from '@agoric/internal/src/testing-utils.js'; +import { prepareVowTools } from '@agoric/vow'; +import { makeDurableZone } from '@agoric/zone/durable.js'; + +import { prepareAsyncFlowTools } from '../src/async-flow.js'; + +const neverSettlesP = new Promise(() => {}); + +/** + * @param {any} t + * @param {Zone} zone + */ +const testPlay1 = async (t, zone) => { + const vowTools = prepareVowTools(zone); + const { asyncFlow } = prepareAsyncFlowTools(zone, { + vowTools, + }); + + const guestFunc = async () => neverSettlesP; + + const wrapperFunc = asyncFlow(zone, 'guestFunc', guestFunc); + + wrapperFunc(); + t.pass(); +}; + +/** + * @param {any} t + * @param {Zone} zone + */ +const testPlay2 = async (t, zone) => { + const vowTools = prepareVowTools(zone); + const { asyncFlow } = prepareAsyncFlowTools(zone, { + vowTools, + }); + + const guestFunc = async () => neverSettlesP; + + t.notThrows(() => asyncFlow(zone, 'guestFunc', guestFunc)); +}; + +/** + * @param {any} t + * @param {Zone} zone + */ +const testPlay3 = async (t, zone) => { + const vowTools = prepareVowTools(zone); + const { asyncFlow, allWokenP } = prepareAsyncFlowTools(zone, { + vowTools, + }); + + await null; + + const guestFunc = async () => neverSettlesP; + t.notThrows(() => asyncFlow(zone, 'guestFunc', guestFunc)); + t.notThrowsAsync( + () => allWokenP, + 'will actually throw due to crank bug #9377', + ); +}; + +test.serial('test durable first-crank hazard 1', async t => { + annihilate(); + const zone1 = makeDurableZone(getBaggage(), 'durableRoot'); + await testPlay1(t, zone1); + + await eventLoopIteration(); +}); + +test.serial('test durable first-crank hazard 2', async t => { + nextLife(); + const zone2 = makeDurableZone(getBaggage(), 'durableRoot'); + await testPlay2(t, zone2); + + await eventLoopIteration(); +}); + +test.serial.failing('test durable first-crank hazard 3', async t => { + nextLife(); + const zone3 = makeDurableZone(getBaggage(), 'durableRoot'); + await testPlay3(t, zone3); + + return eventLoopIteration(); +}); diff --git a/packages/async-flow/test/async-flow-no-this.js b/packages/async-flow/test/async-flow-no-this.js new file mode 100644 index 00000000000..eb6d7993a82 --- /dev/null +++ b/packages/async-flow/test/async-flow-no-this.js @@ -0,0 +1,59 @@ +// eslint-disable-next-line import/order +import { test, getBaggage, annihilate } from './prepare-test-env-ava.js'; + +import { eventLoopIteration } from '@agoric/internal/src/testing-utils.js'; +import { prepareVowTools } from '@agoric/vow'; +import { makeHeapZone } from '@agoric/zone/heap.js'; +import { makeVirtualZone } from '@agoric/zone/virtual.js'; +import { makeDurableZone } from '@agoric/zone/durable.js'; + +import { prepareAsyncFlowTools } from '../src/async-flow.js'; + +const { apply } = Reflect; + +/** + * @param {any} t + * @param {Zone} zone + */ +const testPlay = async (t, zone) => { + const vowTools = prepareVowTools(zone); + const { asyncFlow } = prepareAsyncFlowTools(zone, { + vowTools, + }); + + const { guestFunc } = { + async guestFunc() { + t.is(this, undefined); + }, + }; + + const wrapperFunc = asyncFlow(zone, 'guestFunc', guestFunc); + + // Demonstrates that even if something is passed as `this` to the wrapperFunc, + // the guestFunc will run with `this` bound to `undefined`. + apply(wrapperFunc, 'bogus', []); +}; + +test.serial('test heap no guest this', async t => { + annihilate(); + const zone = makeHeapZone('heapRoot'); + await testPlay(t, zone); + + await eventLoopIteration(); +}); + +test.serial('test virtual no guest this', async t => { + annihilate(); + const zone = makeVirtualZone('virtualRoot'); + await testPlay(t, zone); + + await eventLoopIteration(); +}); + +test.serial('test durable no guest this', async t => { + annihilate(); + const zone = makeDurableZone(getBaggage(), 'durableRoot'); + await testPlay(t, zone); + + await eventLoopIteration(); +}); diff --git a/packages/async-flow/test/async-flow.test.js b/packages/async-flow/test/async-flow.test.js new file mode 100644 index 00000000000..8f70bdbb616 --- /dev/null +++ b/packages/async-flow/test/async-flow.test.js @@ -0,0 +1,380 @@ +// eslint-disable-next-line import/order +import { + test, + getBaggage, + annihilate, + nextLife, +} from './prepare-test-env-ava.js'; + +import { Fail } from '@endo/errors'; +import { passStyleOf } from '@endo/pass-style'; +import { makeCopyMap } from '@endo/patterns'; +import { makePromiseKit } from '@endo/promise-kit'; +import { eventLoopIteration } from '@agoric/internal/src/testing-utils.js'; +import { isVow } from '@agoric/vow/src/vow-utils.js'; +import { prepareVowTools } from '@agoric/vow'; +import { makeHeapZone } from '@agoric/zone/heap.js'; +import { makeVirtualZone } from '@agoric/zone/virtual.js'; +import { makeDurableZone } from '@agoric/zone/durable.js'; + +import { prepareAsyncFlowTools } from '../src/async-flow.js'; + +/** + * @import {AsyncFlow} from '../src/async-flow.js' + */ + +/** + * @param {Zone} zone + * @param {number} [k] + */ +const prepareOrchestra = (zone, k = 1) => + zone.exoClass( + 'Orchestra', + undefined, + (factor, vow, resolver) => ({ factor, vow, resolver }), + { + scale(n) { + const { state } = this; + return k * state.factor * n; + }, + vow() { + const { state } = this; + return state.vow; + }, + resolve(x) { + const { state } = this; + state.resolver.resolve(x); + }, + }, + ); + +/** @typedef {ReturnType>} Orchestra */ + +const firstLogLen = 7; + +/** + * @param {any} t + * @param {Zone} zone + */ +const testFirstPlay = async (t, zone) => { + t.log('firstPlay started'); + const vowTools = prepareVowTools(zone); + const { asyncFlow, adminAsyncFlow } = prepareAsyncFlowTools(zone, { + vowTools, + }); + const makeOrchestra = prepareOrchestra(zone); + const { makeVowKit } = vowTools; + + const { vow: v1, resolver: r1 } = zone.makeOnce('v1', () => makeVowKit()); + const { vow: v2, resolver: r2 } = zone.makeOnce('v2', () => makeVowKit()); + const { vow: v3, resolver: _r3 } = zone.makeOnce('v3', () => makeVowKit()); + const hOrch7 = zone.makeOnce('hOrch7', () => makeOrchestra(7, v2, r2)); + + // purposely violate rule that guestMethod is closed. + const { promise: promiseStep, resolve: resolveStep } = makePromiseKit(); + + const { guestMethod } = { + async guestMethod(gOrch7, g1, _p3) { + t.log(' firstPlay about to await g1'); + t.is(await g1, 'x'); + const p2 = gOrch7.vow(); + const prod = gOrch7.scale(3); + t.is(prod, 21); + + let gErr; + try { + gOrch7.scale(9n); + } catch (e) { + gErr = e; + } + t.is(gErr.name, 'TypeError'); + + resolveStep(true); + t.log(' firstPlay to hang awaiting p2'); + // awaiting a promise that won't be resolved until next incarnation + await p2; + t.fail('must not reach here in first incarnation'); + }, + }; + + const wrapperFunc = asyncFlow(zone, 'AsyncFlow1', guestMethod); + + const outcomeV = zone.makeOnce('outcomeV', () => wrapperFunc(hOrch7, v1, v3)); + + t.true(isVow(outcomeV)); + r1.resolve('x'); + + const flow = zone.makeOnce('flow', () => + adminAsyncFlow.getFlowForOutcomeVow(outcomeV), + ); + t.is(passStyleOf(flow), 'remotable'); + + await promiseStep; + + const logDump = flow.dump(); + t.is(logDump.length, firstLogLen); + t.deepEqual(logDump, [ + ['doFulfill', v1, 'x'], + ['checkCall', hOrch7, 'vow', [], 1], + ['doReturn', 1, v2], + ['checkCall', hOrch7, 'scale', [3], 3], + ['doReturn', 3, 21], + ['checkCall', hOrch7, 'scale', [9n], 5], + [ + 'doThrow', + 5, + TypeError('Cannot mix BigInt and other types, use explicit conversions'), + ], + ]); + t.log('firstPlay done'); +}; + +/** + * @param {any} t + * @param {Zone} zone + */ +const testBadReplay = async (t, zone) => { + t.log('badReplay started'); + const vowTools = prepareVowTools(zone); + const { asyncFlow, adminAsyncFlow } = prepareAsyncFlowTools(zone, { + vowTools, + }); + prepareOrchestra(zone); + const { when } = vowTools; + const hOrch7 = /** @type {Orchestra} */ ( + zone.makeOnce('hOrch7', () => Fail`need hOrch7`) + ); + // purposely violate rule that guestMethod is closed. + const { promise: promiseStep, resolve: resolveStep } = makePromiseKit(); + + const { guestMethod } = { + async guestMethod(gOrch7, g1, _p3) { + t.log(' badReplay about to await g1'); + await g1; + gOrch7.vow(); + resolveStep(true); + // This is a replay error + gOrch7.scale(4); + t.fail('badReplay must not reach here'); + }, + }; + + // `asyncFlow` can be used simply to re-prepare the guest function + // by ignoring the returned wrapper function. If the wrapper function is + // invoked, that would be a *new* activation with a new outcome and + // flow, and would have nothing to do with the existing one. + asyncFlow(zone, 'AsyncFlow1', guestMethod); + + const outcomeV = /** @type {Vow} */ ( + zone.makeOnce('outcomeV', () => Fail`need outcomeV`) + ); + const flow = /** @type {AsyncFlow} */ ( + zone.makeOnce('flow', () => Fail`need flow`) + ); + const flow1 = adminAsyncFlow.getFlowForOutcomeVow(outcomeV); + t.is(flow, flow1); + t.is(passStyleOf(flow), 'remotable'); + + // This unblocks `await p2;` but only after the replay failure is fixed in + // the next incarnation. + hOrch7.resolve('y'); + await promiseStep; + t.is(await when(hOrch7.vow()), 'y'); + + const logDump = flow.dump(); + t.is(logDump.length, firstLogLen); + + const replayProblem = flow.getOptFatalProblem(); + t.true(replayProblem instanceof Error); + + t.deepEqual( + adminAsyncFlow.getFailures(), + makeCopyMap([[flow, replayProblem]]), + ); + + t.log(' badReplay failures', flow.getOptFatalProblem()); + t.log('badReplay done'); +}; + +/** + * @param {any} t + * @param {Zone} zone + */ +const testGoodReplay = async (t, zone) => { + t.log('goodReplay started'); + const vowTools = prepareVowTools(zone); + const { asyncFlow, adminAsyncFlow } = prepareAsyncFlowTools(zone, { + vowTools, + }); + prepareOrchestra(zone, 2); // Note change in new behavior + const { when } = vowTools; + const hOrch7 = /** @type {Orchestra} */ ( + zone.makeOnce('hOrch7', () => Fail`need hOrch7`) + ); + // purposely violate rule that guestMethod is closed. + const { promise: promiseStep, resolve: resolveStep } = makePromiseKit(); + + const { guestMethod } = { + async guestMethod(gOrch7, g1, p3) { + t.log(' goodReplay about to await g1'); + await g1; + const p2 = gOrch7.vow(); + const prod = gOrch7.scale(3); + t.is(prod, 21); + + let gErr; + try { + gOrch7.scale(9n); + } catch (e) { + gErr = e; + } + t.is(gErr.name, 'TypeError'); + + resolveStep(true); + t.log(' goodReplay about to await p2'); + // awaiting a promise that won't be resolved until this incarnation + await p2; + t.log(' goodReplay woke up!'); + const prod2 = gOrch7.scale(3); + // same question. different answer + t.is(prod2, 42); + t.log('about to await p3'); + await p3; + t.log('p3 settled'); + }, + }; + + // `asyncFlow` can be used simply to re-prepare the guest function + // by ignoring the returned wrapper function. If the wrapper function is + // invoked, that would be a *new* activation with a new outcome and + // flow, and would have nothing to do with the existing one. + asyncFlow(zone, 'AsyncFlow1', guestMethod); + + const outcomeV = /** @type {Vow} */ ( + zone.makeOnce('outcomeV', () => Fail`need outcomeV`) + ); + const flow = /** @type {AsyncFlow} */ ( + zone.makeOnce('flow', () => Fail`need flow`) + ); + const flow1 = adminAsyncFlow.getFlowForOutcomeVow(outcomeV); + t.is(flow, flow1); + t.is(passStyleOf(flow), 'remotable'); + + const v2 = hOrch7.vow(); + t.is(await when(v2), 'y'); + await eventLoopIteration(); + + await promiseStep; + + const { vow: v1 } = zone.makeOnce('v1', () => Fail`need v1`); + const { resolver: r3 } = zone.makeOnce('v3', () => Fail`need v3`); + + const logDump = flow.dump(); + t.is(logDump.length, firstLogLen + 3); + t.deepEqual(logDump, [ + ['doFulfill', v1, 'x'], + ['checkCall', hOrch7, 'vow', [], 1], + ['doReturn', 1, v2], + ['checkCall', hOrch7, 'scale', [3], 3], + ['doReturn', 3, 21], + ['checkCall', hOrch7, 'scale', [9n], 5], + [ + 'doThrow', + 5, + TypeError('Cannot mix BigInt and other types, use explicit conversions'), + ], + // new stuff + ['doFulfill', v2, 'y'], + ['checkCall', hOrch7, 'scale', [3], firstLogLen + 1], + // same question. different answer + ['doReturn', firstLogLen + 1, 42], + ]); + + // @ts-expect-error TS doesn't know it is a resolver + r3.resolve('done'); + await eventLoopIteration(); + + t.is(await when(outcomeV), undefined); + t.deepEqual(flow.dump(), []); + + t.log('goodReplay done', await promiseStep); +}; + +/** + * @param {any} t + * @param {Zone} zone + */ +const testAfterPlay = async (t, zone) => { + t.log('testAfterPlay started'); + const vowTools = prepareVowTools(zone); + const { asyncFlow, adminAsyncFlow } = prepareAsyncFlowTools(zone, { + vowTools, + }); + prepareOrchestra(zone); + + const { guestMethod } = { + async guestMethod(_gOrch7, _gP, _p3) { + t.fail('Must not replay this'); + }, + }; + + // `asyncFlow` can be used simply to re-prepare the guest function + // by ignoring the returned wrapper function. If the wrapper function is + // invoked, that would be a *new* activation with a new outcome and + // flow, and would have nothing to do with the existing one. + asyncFlow(zone, 'AsyncFlow1', guestMethod); + + const outcomeV = /** @type {Vow} */ ( + zone.makeOnce('outcomeV', () => Fail`need outcomeV`) + ); + const flow = /** @type {AsyncFlow} */ ( + zone.makeOnce('flow', () => Fail`need flow`) + ); + t.is(passStyleOf(flow), 'remotable'); + + t.throws(() => adminAsyncFlow.getFlowForOutcomeVow(outcomeV), { + message: + 'key "[Alleged: VowInternalsKit vowV0]" not found in collection "flowForOutcomeVow"', + }); + + // Even this doesn't wake it up. + flow.wake(); + await eventLoopIteration(); + + t.log('testAfterDoneReplay done'); +}; + +test.serial('test heap async-flow', async t => { + const zone = makeHeapZone('heapRoot'); + return testFirstPlay(t, zone); +}); + +test.serial('test virtual async-flow', async t => { + annihilate(); + const zone = makeVirtualZone('virtualRoot'); + return testFirstPlay(t, zone); +}); + +test.serial('test durable async-flow', async t => { + annihilate(); + const zone1 = makeDurableZone(getBaggage(), 'durableRoot'); + await testFirstPlay(t, zone1); + + await eventLoopIteration(); + + nextLife(); + const zone2 = makeDurableZone(getBaggage(), 'durableRoot'); + await testBadReplay(t, zone2); + + await eventLoopIteration(); + + nextLife(); + const zone3 = makeDurableZone(getBaggage(), 'durableRoot'); + await testGoodReplay(t, zone3); + + await eventLoopIteration(); + + nextLife(); + const zone4 = makeDurableZone(getBaggage(), 'durableRoot'); + return testAfterPlay(t, zone4); +}); diff --git a/packages/async-flow/test/bad-host.test.js b/packages/async-flow/test/bad-host.test.js new file mode 100644 index 00000000000..574982bfdfb --- /dev/null +++ b/packages/async-flow/test/bad-host.test.js @@ -0,0 +1,205 @@ +// eslint-disable-next-line import/order +import { + test, + getBaggage, + annihilate, + nextLife, +} from './prepare-test-env-ava.js'; + +import { Fail } from '@endo/errors'; +import { M } from '@endo/patterns'; +import { makePromiseKit } from '@endo/promise-kit'; +import { eventLoopIteration } from '@agoric/internal/src/testing-utils.js'; +import { prepareVowTools } from '@agoric/vow'; +import { makeHeapZone } from '@agoric/zone/heap.js'; +import { makeVirtualZone } from '@agoric/zone/virtual.js'; +import { makeDurableZone } from '@agoric/zone/durable.js'; + +import { prepareAsyncFlowTools } from '../src/async-flow.js'; + +const nonPassableFunc = () => 'non-passable-function'; +harden(nonPassableFunc); +const guestCreatedPromise = harden(Promise.resolve('guest-created')); +let badResult; + +/** + * @param {Zone} zone + */ +const prepareBadHost = zone => + zone.exoClass( + 'BadHost', + M.interface('BadHost', {}, { defaultGuards: 'raw' }), + () => ({}), + { + badMethod(_badArg = undefined) { + return badResult; + }, + }, + ); + +/** @typedef {ReturnType>} BadHost */ + +/** + * @param {any} t + * @param {Zone} zone + */ +const testBadHostFirstPlay = async (t, zone) => { + t.log('badHost firstPlay started'); + const vowTools = prepareVowTools(zone); + const { asyncFlow, adminAsyncFlow } = prepareAsyncFlowTools(zone, { + vowTools, + }); + const makeBadHost = prepareBadHost(zone); + const { makeVowKit } = vowTools; + + const { vow: v1, resolver: _r1 } = zone.makeOnce('v1', () => makeVowKit()); + // purposely violate rule that guestMethod is closed. + const { promise: promiseStep, resolve: resolveStep } = makePromiseKit(); + + const { guestMethod } = { + async guestMethod(badGuest, _p1) { + // nothing bad yet baseline + t.is(badGuest.badMethod(), undefined); + + t.throws(() => badGuest.badMethod(guestCreatedPromise), { + message: + 'In a Failed state: see getFailures() or getOptFatalProblem() for more information', + }); + + resolveStep(true); + t.log(' badHost firstPlay about to return "bogus"'); + // Must not settle outcomeVow + return 'bogus'; + }, + }; + + const wrapperFunc = asyncFlow(zone, 'AsyncFlow1', guestMethod); + + const badHost = zone.makeOnce('badHost', () => makeBadHost()); + + const outcomeV = zone.makeOnce('outcomeV', () => wrapperFunc(badHost, v1)); + + const flow = adminAsyncFlow.getFlowForOutcomeVow(outcomeV); + await promiseStep; + + const fatalProblem = flow.getOptFatalProblem(); + t.throws( + () => { + throw fatalProblem; + }, + { + message: '[3]: [0]: cannot yet send guest promises "[Promise]"', + }, + ); + + t.deepEqual(flow.dump(), [ + ['checkCall', badHost, 'badMethod', [], 0], + ['doReturn', 0, undefined], + // Notice that the bad call was not recorded in the log + ]); + t.log('badHost firstPlay done'); +}; + +/** + * @param {any} t + * @param {Zone} zone + */ +const testBadHostReplay1 = async (t, zone) => { + t.log('badHost replay1 started'); + const vowTools = prepareVowTools(zone); + const { asyncFlow, adminAsyncFlow } = prepareAsyncFlowTools(zone, { + vowTools, + }); + prepareBadHost(zone); + + // const { vow: v1, resolver: r1 } = zone.makeOnce('v1', () => Fail`need v1`); + // purposely violate rule that guestMethod is closed. + const { promise: promiseStep, resolve: resolveStep } = makePromiseKit(); + + const { guestMethod } = { + async guestMethod(badGuest, p1) { + // nothing bad yet baseline + t.is(badGuest.badMethod(), undefined); + + // purposely violate rule that guestMethod is closed. + badResult = nonPassableFunc; + + let gErr; + try { + badGuest.badMethod(); + } catch (err) { + gErr = err; + } + t.throws( + () => { + throw gErr; + }, + { + message: + 'converting badMethod result: Remotables must be explicitly declared: "[Function nonPassableFunc]"', + }, + ); + t.log(' badHost replay1 guest error caused by host error', gErr); + + // show that flow is not Failed by host error + badResult = 'fine'; + t.is(badGuest.badMethod(), 'fine'); + + resolveStep(true); + t.log(' badHost replay1 to hang awaiting p1'); + // awaiting a promise that won't be resolved until next incarnation + await p1; + t.fail('must not reach here in replay 1'); + }, + }; + + asyncFlow(zone, 'AsyncFlow1', guestMethod); + + const badHost = zone.makeOnce('badHost', () => Fail`need badHost`); + + const outcomeV = zone.makeOnce('outcomeV', () => Fail`need outcomeV`); + + const flow = adminAsyncFlow.getFlowForOutcomeVow(outcomeV); + await promiseStep; + + const logDump = flow.dump(); + + t.deepEqual(logDump, [ + ['checkCall', badHost, 'badMethod', [], 0], + ['doReturn', 0, undefined], + ['checkCall', badHost, 'badMethod', [], 2], + [ + 'doThrow', + 2, + Error( + 'converting badMethod result: Remotables must be explicitly declared: "[Function nonPassableFunc]"', + ), + ], + ['checkCall', badHost, 'badMethod', [], 4], + ['doReturn', 4, 'fine'], + ]); + t.log('badHost replay1 done'); +}; + +test.serial('test heap async-flow bad host', async t => { + const zone = makeHeapZone('heapRoot'); + return testBadHostFirstPlay(t, zone); +}); + +test.serial('test virtual async-flow bad host', async t => { + annihilate(); + const zone = makeVirtualZone('virtualRoot'); + return testBadHostFirstPlay(t, zone); +}); + +test.serial('test durable async-flow bad host', async t => { + annihilate(); + const zone1 = makeDurableZone(getBaggage(), 'durableRoot'); + await testBadHostFirstPlay(t, zone1); + + await eventLoopIteration(); + + nextLife(); + const zone3 = makeDurableZone(getBaggage(), 'durableRoot'); + return testBadHostReplay1(t, zone3); +}); diff --git a/packages/async-flow/test/bijection.test.js b/packages/async-flow/test/bijection.test.js new file mode 100644 index 00000000000..0324897bb51 --- /dev/null +++ b/packages/async-flow/test/bijection.test.js @@ -0,0 +1,118 @@ +// eslint-disable-next-line import/order +import { + test, + getBaggage, + annihilate, + nextLife, +} from './prepare-test-env-ava.js'; + +import { Far } from '@endo/pass-style'; +import { prepareVowTools } from '@agoric/vow'; +import { isVow, toPassableCap } from '@agoric/vow/src/vow-utils.js'; +import { makeHeapZone } from '@agoric/zone/heap.js'; +import { makeVirtualZone } from '@agoric/zone/virtual.js'; +import { makeDurableZone } from '@agoric/zone/durable.js'; + +import { prepareBijection } from '../src/bijection.js'; + +/** + * @param {any} t + * @param {Zone} zone + */ +const testBijection = (t, zone) => { + const { makeVowKit } = prepareVowTools(zone); + const makeBijection = prepareBijection(zone); + const bij = zone.makeOnce('bij', makeBijection); + + const h1 = zone.exo('h1', undefined, {}); + const h2 = zone.exo('h2', undefined, {}); + const h3 = zone.makeOnce('h3', () => makeVowKit().vow); + t.true(isVow(h3)); + + const g1 = Far('g1', {}); + const g2 = Far('g2', {}); + const g3 = harden(Promise.resolve('g3')); + + t.false(bij.has(g1, h1)); + t.throws(() => bij.guestToHost(g1), { + message: 'guestToHost key not found: "[Alleged: g1]"', + }); + t.throws(() => bij.hostToGuest(h1), { + message: 'hostToGuest key not found: "[Alleged: h1]"', + }); + t.false(bij.hasGuest(g1)); + t.false(bij.hasHost(h1)); + t.false(bij.hasGuest(h1)); + t.false(bij.hasHost(g1)); + + bij.init(g1, h1); + + t.true(bij.has(g1, h1)); + t.is(toPassableCap(bij.guestToHost(g1)), toPassableCap(h1)); + t.is(bij.hostToGuest(h1), g1); + t.true(bij.hasGuest(g1)); + t.true(bij.hasHost(h1)); + t.false(bij.hasGuest(h1)); + t.false(bij.hasHost(g1)); + + t.throws(() => bij.init(g1, h2), { + message: + 'guestToHost key already bound: "[Alleged: g1]" -> "[Alleged: h1]" vs "[Alleged: h2]"', + }); + t.throws(() => bij.init(g2, h1), { + message: + 'hostToGuest key already bound: "[Alleged: h1]" -> "[Alleged: g1]" vs "[Alleged: g2]"', + }); + t.throws(() => bij.has(g1, h2), { + message: + 'internal: g->h "[Alleged: g1]" -> "[Alleged: h2]" vs "[Alleged: h1]"', + }); + t.false(bij.has(g2, h2)); + bij.init(g2, h2); + t.true(bij.has(g2, h2)); + + t.false(bij.has(g3, h3)); + bij.init(g3, h3); + t.true(bij.has(g3, h3)); + t.false(bij.has(h3, g3)); +}; + +test('test heap bijection', t => { + const zone = makeHeapZone('heapRoot'); + testBijection(t, zone); +}); + +test.serial('test virtual bijection', t => { + annihilate(); + const zone = makeVirtualZone('virtualRoot'); + testBijection(t, zone); +}); + +test.serial('test durable bijection', t => { + annihilate(); + + nextLife(); + const zone1 = makeDurableZone(getBaggage(), 'durableRoot'); + testBijection(t, zone1); + + // Bijections persist but revive empty since all the guests disappear anyway + + nextLife(); + const zone2 = makeDurableZone(getBaggage(), 'durableRoot'); + testBijection(t, zone2); +}); + +test('test heap bijection reset', t => { + const zone = makeHeapZone('heapRoot'); + const makeBijection = prepareBijection(zone); + const bij = makeBijection(); + + const h1 = zone.exo('h1', undefined, {}); + const g1 = Far('g1', {}); + + t.false(bij.has(g1, h1)); + bij.init(g1, h1); + t.true(bij.has(g1, h1)); + bij.reset(); + t.false(bij.has(g1, h1)); +}); diff --git a/packages/async-flow/test/convert.test.js b/packages/async-flow/test/convert.test.js new file mode 100644 index 00000000000..7458e6afdf5 --- /dev/null +++ b/packages/async-flow/test/convert.test.js @@ -0,0 +1,127 @@ +// eslint-disable-next-line import/order +import { + test, + getBaggage, + annihilate, + nextLife, + asyncFlowVerbose, +} from './prepare-test-env-ava.js'; + +import { X, makeError, q } from '@endo/errors'; +import { Far, getInterfaceOf, makeTagged, passStyleOf } from '@endo/pass-style'; +import { prepareVowTools } from '@agoric/vow'; +import { isVow } from '@agoric/vow/src/vow-utils.js'; +import { makeHeapZone } from '@agoric/zone/heap.js'; +import { makeVirtualZone } from '@agoric/zone/virtual.js'; +import { makeDurableZone } from '@agoric/zone/durable.js'; + +import { makeConvertKit } from '../src/convert.js'; +import { prepareBijection } from '../src/bijection.js'; + +/** + * @param {any} t + * @param {Zone} zone + * @param {boolean} [showOnConsole] + */ +const testConvert = (t, zone, showOnConsole = false) => { + const { makeVowKit } = prepareVowTools(zone); + const makeBijection = prepareBijection(zone); + const bij = zone.makeOnce('bij', makeBijection); + + const makeGuestForHostRemotable = hRem => { + const iface = getInterfaceOf(hRem); + return Far(`${iface} guest wrapper`, {}); + }; + + const makeGuestForHostVow = _hVow => Promise.resolve('guest P'); + + const { guestToHost, hostToGuest } = makeConvertKit( + bij, + makeGuestForHostRemotable, + makeGuestForHostVow, + ); + + t.is(hostToGuest(8), 8); + const h1 = zone.exo('h1', undefined, {}); + const h2 = zone.exo('h2', undefined, {}); + const h3 = zone.makeOnce('h3', () => makeVowKit().vow); + t.true(isVow(h3)); + + const g1 = hostToGuest(h1); + const g2 = hostToGuest(h2); + const g3 = hostToGuest(h3); + t.is(passStyleOf(g1), 'remotable'); + t.is(passStyleOf(g2), 'remotable'); + t.is(passStyleOf(g3), 'promise'); + t.not(g1, g2); + t.is(hostToGuest(h1), g1); + t.is(hostToGuest(h2), g2); + t.is(hostToGuest(h3), g3); + + const h4 = makeError(X`open ${'redacted'} ${q('quoted')}`); + const g4a = hostToGuest(h4); + const g4b = hostToGuest(h4); + t.not(g4a, g4b); + t.deepEqual(g4a, g4b); + + t.is(guestToHost(g1), h1); + t.is(guestToHost(g2), h2); + t.is(guestToHost(g3), h3); + + t.deepEqual(guestToHost(harden([g1, g3])), [h1, h3]); + t.deepEqual(hostToGuest(harden([h1, h3])), [g1, g3]); + t.deepEqual(guestToHost(harden(URIError('msg'))), URIError('msg')); + t.deepEqual(hostToGuest(harden(URIError('msg'))), URIError('msg')); + + // guestToHost and hostToGuest does its own hardening + t.deepEqual(guestToHost([g1, g3]), [h1, h3]); + t.deepEqual(hostToGuest([h1, h3]), [g1, g3]); + t.deepEqual(guestToHost(URIError('msg')), URIError('msg')); + t.deepEqual(hostToGuest(URIError('msg')), URIError('msg')); + + t.deepEqual(guestToHost({ o1: g1, o2: g2 }), { o1: h1, o2: h2 }); + t.deepEqual(hostToGuest({ o1: h1, o2: h2 }), { o1: g1, o2: g2 }); + t.deepEqual(guestToHost(makeTagged('t', g1)), makeTagged('t', h1)); + t.deepEqual(hostToGuest(makeTagged('t', h1)), makeTagged('t', g1)); + + const gErr1 = makeError(X`error ${'redacted message'}`, URIError); + const hErr1 = guestToHost(gErr1); + const gErr2 = hostToGuest(hErr1); + + t.not(gErr1, hErr1); + t.not(hErr1, gErr2); + t.not(gErr1, gErr2); + t.is(gErr1.name, 'URIError'); + t.is(hErr1.name, 'URIError'); + t.is(gErr2.name, 'URIError'); + + if (showOnConsole) { + t.log('gErr2', gErr2); + } +}; + +test('test heap convert', t => { + const zone = makeHeapZone('heapRoot'); + testConvert(t, zone, asyncFlowVerbose()); +}); + +test.serial('test virtual convert', t => { + annihilate(); + const zone = makeVirtualZone('virtualRoot'); + testConvert(t, zone); +}); + +test.serial('test durable convert', t => { + annihilate(); + + nextLife(); + const zone1 = makeDurableZone(getBaggage(), 'durableRoot'); + testConvert(t, zone1); + + // These converters keep their state only in the bijection, + // which loses all its memory between incarnations. + + nextLife(); + const zone2 = makeDurableZone(getBaggage(), 'durableRoot'); + testConvert(t, zone2); +}); diff --git a/packages/async-flow/test/equate.test.js b/packages/async-flow/test/equate.test.js new file mode 100644 index 00000000000..c43d0a4948d --- /dev/null +++ b/packages/async-flow/test/equate.test.js @@ -0,0 +1,116 @@ +// eslint-disable-next-line import/order +import { + test, + getBaggage, + annihilate, + nextLife, + asyncFlowVerbose, +} from './prepare-test-env-ava.js'; + +import { X, makeError } from '@endo/errors'; +import { Far } from '@endo/pass-style'; +import { prepareVowTools } from '@agoric/vow'; +import { isVow } from '@agoric/vow/src/vow-utils.js'; +import { makeHeapZone } from '@agoric/zone/heap.js'; +import { makeVirtualZone } from '@agoric/zone/virtual.js'; +import { makeDurableZone } from '@agoric/zone/durable.js'; + +import { prepareBijection } from '../src/bijection.js'; +import { makeEquate } from '../src/equate.js'; + +/** + * @param {any} t + * @param {Zone} zone + * @param {boolean} [showOnConsole] + */ +const testEquate = (t, zone, showOnConsole = false) => { + const { makeVowKit } = prepareVowTools(zone); + const makeBijection = prepareBijection(zone); + const bij = zone.makeOnce('bij', makeBijection); + + t.throws(() => zone.makeOnce('equate', () => makeEquate(bij)), { + message: 'maker return value "[Function equate]" is not storable', + }); + + const equate = makeEquate(bij); + + equate(8, 8); + t.throws(() => equate(8, 9), { + message: 'unequal 8 vs 9', + }); + + const h1 = zone.exo('h1', undefined, {}); + const h2 = zone.makeOnce('h2', () => makeVowKit().vow); + t.true(isVow(h2)); + + const g1 = Far('g1', {}); + const g2 = harden(Promise.resolve('g2')); + + t.throws(() => equate(g1, h1), { + message: + 'cannot yet send guest remotables to host "[Alleged: g1]" vs "[Alleged: h1]"', + }); + bij.init(g1, h1); + t.notThrows(() => equate(g1, h1)); + t.throws(() => equate(g1, h2), { + message: 'internal: g->h "[Alleged: g1]" -> "[Vow]" vs "[Alleged: h1]"', + }); + t.throws(() => equate(g2, h1), { + message: 'internal: unexpected h->g "[Alleged: h1]" -> "[Alleged: g1]"', + }); + bij.init(g2, h2); + equate(g2, h2); + + t.throws(() => equate(g1, h2), { + message: 'internal: g->h "[Alleged: g1]" -> "[Vow]" vs "[Alleged: h1]"', + }); + t.throws(() => equate(g2, h1), { + message: 'internal: g->h "[Promise]" -> "[Alleged: h1]" vs "[Vow]"', + }); + + equate(harden([g1, g2]), harden([h1, h2])); + t.throws(() => equate(harden([g1, g2]), harden([h1, h1])), { + message: '[1]: internal: g->h "[Promise]" -> "[Alleged: h1]" vs "[Vow]"', + }); + + const gErr1 = harden(makeError(X`error ${'redacted message'}`, URIError)); + const hErr1 = harden(makeError(X`another message`, URIError)); + const gErr2 = harden(makeError(X`another error`, TypeError)); + + equate(gErr1, hErr1); + t.throws(() => equate(gErr2, hErr1), { + message: 'error name: unequal "TypeError" vs "URIError"', + }); + + if (showOnConsole) { + // To see the annotation chain. Once we're synced with the next ses-ava, + // change this to a t.log, so we will see the annotation chain in context. + t.log('hErr1', hErr1); + } +}; + +test('test heap equate', t => { + const zone = makeHeapZone('heapRoot'); + testEquate(t, zone, asyncFlowVerbose()); +}); + +test.serial('test virtual equate', t => { + annihilate(); + const zone = makeVirtualZone('virtualRoot'); + testEquate(t, zone); +}); + +test.serial('test durable equate', t => { + annihilate(); + + nextLife(); + const zone1 = makeDurableZone(getBaggage(), 'durableRoot'); + testEquate(t, zone1); + + // equate keeps its state only in the bijection, + // which loses all its memory between incarnations. + + nextLife(); + const zone2 = makeDurableZone(getBaggage(), 'durableRoot'); + testEquate(t, zone2); +}); diff --git a/packages/async-flow/test/log-store.test.js b/packages/async-flow/test/log-store.test.js new file mode 100644 index 00000000000..2ee21f45d85 --- /dev/null +++ b/packages/async-flow/test/log-store.test.js @@ -0,0 +1,112 @@ +// eslint-disable-next-line import/order +import { + test, + getBaggage, + annihilate, + nextLife, +} from './prepare-test-env-ava.js'; + +import { Fail } from '@endo/errors'; +import { prepareVowTools, toPassableCap } from '@agoric/vow'; +import { makeHeapZone } from '@agoric/zone/heap.js'; +import { makeVirtualZone } from '@agoric/zone/virtual.js'; +import { makeDurableZone } from '@agoric/zone/durable.js'; + +import { prepareLogStore } from '../src/log-store.js'; + +/** + * @param {any} t + * @param {Zone} zone + */ +const testLogStorePlay = async (t, zone) => { + const { makeVowKit } = prepareVowTools(zone); + const makeLogStore = prepareLogStore(zone); + + const log = zone.makeOnce('log', () => makeLogStore()); + const v1 = zone.makeOnce('v1', () => makeVowKit().vow); + const v2 = zone.makeOnce('v2', () => makeVowKit().vow); + + t.is(log.getIndex(), 0); + t.is(log.getLength(), 0); + t.throws(() => log.pushEntry(['bogus']), { + message: + /^In "pushEntry" method of \(LogStore\): arg 0: \["bogus"\] - Must match one of/, + }); + t.false(log.isReplaying()); + t.is(await log.promiseReplayDone(), undefined); + + t.is(log.pushEntry(harden(['doFulfill', v1, 'x'])), 1); + t.is(log.pushEntry(harden(['doReject', v2, 'y'])), 2); + t.deepEqual(log.dump(), [ + ['doFulfill', v1, 'x'], + ['doReject', v2, 'y'], + ]); + // Because t.deepEqual is too tolerant + // @ts-expect-error data dependent typing + t.is(toPassableCap(log.dump()[0][1]), toPassableCap(v1)); + // @ts-expect-error data dependent typing + t.is(toPassableCap(log.dump()[1][1]), toPassableCap(v2)); + + t.is(log.getIndex(), 2); + t.is(log.getLength(), 2); + t.false(log.isReplaying()); + t.is(await log.promiseReplayDone(), undefined); +}; + +/** + * @param {any} t + * @param {Zone} zone + */ +const testLogStoreReplay = async (t, zone) => { + prepareVowTools(zone); + prepareLogStore(zone); + + const log = /** @type {LogStore} */ ( + zone.makeOnce('log', () => Fail`need log`) + ); + const v1 = /** @type {Vow} */ (zone.makeOnce('v1', () => Fail`need v1`)); + const v2 = /** @type {Vow} */ (zone.makeOnce('v2', () => Fail`need v2`)); + + t.is(log.getIndex(), 0); + t.is(log.getLength(), 2); + t.true(log.isReplaying()); + + t.deepEqual(log.dump(), [ + ['doFulfill', v1, 'x'], + ['doReject', v2, 'y'], + ]); + // Because t.deepEqual is too tolerant + // @ts-expect-error data dependent typing + t.is(toPassableCap(log.dump()[0][1]), toPassableCap(v1)); + // @ts-expect-error data dependent typing + t.is(toPassableCap(log.dump()[1][1]), toPassableCap(v2)); + + t.deepEqual(log.nextEntry(), ['doFulfill', v1, 'x']); + t.deepEqual(log.nextEntry(), ['doReject', v2, 'y']); + t.is(log.getIndex(), 2); + t.false(log.isReplaying()); + t.is(await log.promiseReplayDone(), undefined); +}; + +await test('test heap log-store', async t => { + const zone = makeHeapZone('heapRoot'); + return testLogStorePlay(t, zone); +}); + +test.serial('test virtual log-store', async t => { + annihilate(); + const zone = makeVirtualZone('virtualRoot'); + return testLogStorePlay(t, zone); +}); + +test.serial('test durable log-store', async t => { + annihilate(); + + nextLife(); + const zone1 = makeDurableZone(getBaggage(), 'durableRoot'); + await testLogStorePlay(t, zone1); + + nextLife(); + const zone2 = makeDurableZone(getBaggage(), 'durableRoot'); + return testLogStoreReplay(t, zone2); +}); diff --git a/packages/async-flow/test/prepare-test-env-ava.js b/packages/async-flow/test/prepare-test-env-ava.js new file mode 100644 index 00000000000..fd00b0531ba --- /dev/null +++ b/packages/async-flow/test/prepare-test-env-ava.js @@ -0,0 +1,28 @@ +import '@agoric/swingset-liveslots/tools/prepare-test-env.js'; +import { wrapTest } from '@endo/ses-ava'; +import rawTest from 'ava'; + +import { environmentOptionsListHas } from '@endo/env-options'; +import { reincarnate } from '@agoric/swingset-liveslots/tools/setup-vat-data.js'; + +export const test = wrapTest(rawTest); + +/** @type {ReturnType} */ +let incarnation; + +export const annihilate = () => { + incarnation = reincarnate({ relaxDurabilityRules: false }); +}; + +export const getBaggage = () => { + return incarnation.fakeVomKit.cm.provideBaggage(); +}; + +export const nextLife = () => { + incarnation = reincarnate(incarnation); +}; + +export const asyncFlowVerbose = () => { + // TODO figure out how we really want to control this. + return environmentOptionsListHas('DEBUG', 'async-flow-verbose'); +}; diff --git a/packages/async-flow/test/replay-membrane-settlement.test.js b/packages/async-flow/test/replay-membrane-settlement.test.js new file mode 100644 index 00000000000..d197c023491 --- /dev/null +++ b/packages/async-flow/test/replay-membrane-settlement.test.js @@ -0,0 +1,154 @@ +// eslint-disable-next-line import/order +import { + test, + getBaggage, + annihilate, + nextLife, +} from './prepare-test-env-ava.js'; + +import { Fail } from '@endo/errors'; +import { eventLoopIteration } from '@agoric/internal/src/testing-utils.js'; +import { prepareVowTools } from '@agoric/vow'; +import { makeHeapZone } from '@agoric/zone/heap.js'; +import { makeVirtualZone } from '@agoric/zone/virtual.js'; +import { makeDurableZone } from '@agoric/zone/durable.js'; + +import { prepareLogStore } from '../src/log-store.js'; +import { prepareBijection } from '../src/bijection.js'; +import { makeReplayMembrane } from '../src/replay-membrane.js'; + +const watchWake = _vowish => {}; +const panic = problem => Fail`panic over ${problem}`; + +/** + * @param {Zone} zone + */ +const preparePingee = zone => + zone.exoClass('Pingee', undefined, () => ({}), { + ping() {}, + }); + +/** + * @param {any} t + * @param {Zone} zone + */ +const testFirstPlay = async (t, zone) => { + const vowTools = prepareVowTools(zone); + const { makeVowKit } = vowTools; + const makeLogStore = prepareLogStore(zone); + const makeBijection = prepareBijection(zone); + const makePingee = preparePingee(zone); + const { vow: v1, resolver: r1 } = zone.makeOnce('v1', () => makeVowKit()); + const { vow: _v2, resolver: _r2 } = zone.makeOnce('v2', () => makeVowKit()); + + const log = zone.makeOnce('log', () => makeLogStore()); + const bij = zone.makeOnce('bij', makeBijection); + + const mem = makeReplayMembrane(log, bij, vowTools, watchWake, panic); + + const p1 = mem.hostToGuest(v1); + t.deepEqual(log.dump(), []); + + const pingee = zone.makeOnce('pingee', () => makePingee()); + const guestPingee = mem.hostToGuest(pingee); + t.deepEqual(log.dump(), []); + + guestPingee.ping(); + t.deepEqual(log.dump(), [ + // keep on separate lines + ['checkCall', pingee, 'ping', [], 0], + ['doReturn', 0, undefined], + ]); + + r1.resolve('x'); + t.is(await p1, 'x'); + + t.deepEqual(log.dump(), [ + ['checkCall', pingee, 'ping', [], 0], + ['doReturn', 0, undefined], + ['doFulfill', v1, 'x'], + ]); +}; + +/** + * @param {any} t + * @param {Zone} zone + */ +const testReplay = async (t, zone) => { + const vowTools = prepareVowTools(zone); + prepareLogStore(zone); + prepareBijection(zone); + preparePingee(zone); + const { vow: v1 } = zone.makeOnce('v1', () => Fail`need v1`); + const { vow: v2, resolver: r2 } = zone.makeOnce('v2', () => Fail`need v2`); + + const log = /** @type {LogStore} */ ( + zone.makeOnce('log', () => Fail`need log`) + ); + const bij = /** @type {Bijection} */ ( + zone.makeOnce('bij', () => Fail`need bij`) + ); + + const pingee = zone.makeOnce('pingee', () => Fail`need pingee`); + + t.deepEqual(log.dump(), [ + ['checkCall', pingee, 'ping', [], 0], + ['doReturn', 0, undefined], + ['doFulfill', v1, 'x'], + ]); + + const mem = makeReplayMembrane(log, bij, vowTools, watchWake, panic); + t.true(log.isReplaying()); + t.is(log.getIndex(), 0); + + const guestPingee = mem.hostToGuest(pingee); + const p2 = mem.hostToGuest(v2); + // @ts-expect-error TS doesn't know that r2 is a resolver + r2.resolve('y'); + await eventLoopIteration(); + + const p1 = mem.hostToGuest(v1); + mem.wake(); + t.true(log.isReplaying()); + t.is(log.getIndex(), 0); + t.deepEqual(log.dump(), [ + ['checkCall', pingee, 'ping', [], 0], + ['doReturn', 0, undefined], + ['doFulfill', v1, 'x'], + ]); + + guestPingee.ping(); + t.is(await p1, 'x'); + t.is(await p2, 'y'); + t.false(log.isReplaying()); + + t.deepEqual(log.dump(), [ + ['checkCall', pingee, 'ping', [], 0], + ['doReturn', 0, undefined], + ['doFulfill', v1, 'x'], + ['doFulfill', v2, 'y'], + ]); +}; + +test.serial('test heap replay-membrane settlement', async t => { + const zone = makeHeapZone('heapRoot'); + return testFirstPlay(t, zone); +}); + +test.serial('test virtual replay-membrane settlement', async t => { + annihilate(); + const zone = makeVirtualZone('virtualRoot'); + return testFirstPlay(t, zone); +}); + +test.serial('test durable replay-membrane settlement', async t => { + annihilate(); + + nextLife(); + const zone1 = makeDurableZone(getBaggage(), 'durableRoot'); + await testFirstPlay(t, zone1); + + nextLife(); + const zone3 = makeDurableZone(getBaggage(), 'durableRoot'); + return testReplay(t, zone3); +}); diff --git a/packages/async-flow/test/replay-membrane-zombie.test.js b/packages/async-flow/test/replay-membrane-zombie.test.js new file mode 100644 index 00000000000..bb8e5b8f56e --- /dev/null +++ b/packages/async-flow/test/replay-membrane-zombie.test.js @@ -0,0 +1,158 @@ +// eslint-disable-next-line import/order +import { + test, + getBaggage, + annihilate, + nextLife, +} from './prepare-test-env-ava.js'; + +import { Fail } from '@endo/errors'; +import { eventLoopIteration } from '@agoric/internal/src/testing-utils.js'; +import { prepareVowTools } from '@agoric/vow'; +import { makeHeapZone } from '@agoric/zone/heap.js'; +import { makeVirtualZone } from '@agoric/zone/virtual.js'; +import { makeDurableZone } from '@agoric/zone/durable.js'; + +import { prepareLogStore } from '../src/log-store.js'; +import { prepareBijection } from '../src/bijection.js'; +import { makeReplayMembrane } from '../src/replay-membrane.js'; + +const watchWake = _vowish => {}; +const panic = problem => Fail`panic over ${problem}`; + +/** + * @param {any} t + * @param {Zone} zone + */ +const testMissingStop = async (t, zone) => { + const vowTools = prepareVowTools(zone); + const { makeVowKit } = vowTools; + const makeLogStore = prepareLogStore(zone); + const makeBijection = prepareBijection(zone); + + const log = makeLogStore(); + const bij = makeBijection(); + + const memA = makeReplayMembrane(log, bij, vowTools, watchWake, panic); + + const { vow: v1, resolver: r1 } = makeVowKit(); + + const p1A = memA.hostToGuest(v1); + t.true(bij.has(p1A, v1)); + + await eventLoopIteration(); + + t.deepEqual(log.dump(), []); + + // do all the steps to drop an old membrane and set up a new membrane, + // except stopping the old membrane, + // to demonstate why `makeGuestForHostVow` also tests`stopped`. + log.reset(); + bij.reset(); + const memB = makeReplayMembrane(log, bij, vowTools, watchWake, panic); + + const p1B = memB.hostToGuest(v1); + t.true(bij.has(p1B, v1)); + t.false(bij.hasGuest(p1A)); + + await eventLoopIteration(); + + t.deepEqual(log.dump(), []); + + r1.resolve('x'); + + await eventLoopIteration(); + + t.deepEqual(log.dump(), [ + // keep line break + ['doFulfill', v1, 'x'], + ['doFulfill', v1, 'x'], // this duplication is wrong, is the point + ]); +}; + +/** + * @param {any} t + * @param {Zone} zone + */ +const testProperStop = async (t, zone) => { + const vowTools = prepareVowTools(zone); + const { makeVowKit } = vowTools; + const makeLogStore = prepareLogStore(zone); + const makeBijection = prepareBijection(zone); + + const log = makeLogStore(); + const bij = makeBijection(); + + const memA = makeReplayMembrane(log, bij, vowTools, watchWake, panic); + + const { vow: v1, resolver: r1 } = makeVowKit(); + + const p1A = memA.hostToGuest(v1); + t.true(bij.has(p1A, v1)); + + await eventLoopIteration(); + + t.deepEqual(log.dump(), []); + + // do all the steps to drop an old membrane and set up a new membrane, + // including stopping the old membrane, + // to demonstate why `makeGuestForHostVow` also tests`stopped`. + log.reset(); + bij.reset(); + memA.stop(); // the point + const memB = makeReplayMembrane(log, bij, vowTools, watchWake, panic); + + const p1B = memB.hostToGuest(v1); + t.true(bij.has(p1B, v1)); + t.false(bij.hasGuest(p1A)); + + await eventLoopIteration(); + + t.deepEqual(log.dump(), []); + + r1.resolve('x'); + + await eventLoopIteration(); + + t.deepEqual(log.dump(), [ + // keep line break + ['doFulfill', v1, 'x'], + ]); +}; + +test.serial('test heap replay-membrane missing stop', async t => { + const zone = makeHeapZone('heapRoot'); + return testMissingStop(t, zone); +}); + +test.serial('test heap replay-membrane proper stop', async t => { + annihilate(); + const zone = makeHeapZone('heapRoot'); + return testProperStop(t, zone); +}); + +test.serial('test virtual replay-membrane missing stop', async t => { + annihilate(); + const zone = makeVirtualZone('virtualRoot'); + return testMissingStop(t, zone); +}); + +test.serial('test virtual replay-membrane proper stop', async t => { + annihilate(); + const zone = makeVirtualZone('virtualRoot'); + return testProperStop(t, zone); +}); + +test.serial('test durable replay-membrane missing stop', async t => { + annihilate(); + nextLife(); + const zone = makeDurableZone(getBaggage(), 'durableRoot'); + return testMissingStop(t, zone); +}); + +test.serial('test durable replay-membrane proper stop', async t => { + annihilate(); + nextLife(); + const zone = makeDurableZone(getBaggage(), 'durableRoot'); + return testProperStop(t, zone); +}); diff --git a/packages/async-flow/test/replay-membrane.test.js b/packages/async-flow/test/replay-membrane.test.js new file mode 100644 index 00000000000..e891db9fe5b --- /dev/null +++ b/packages/async-flow/test/replay-membrane.test.js @@ -0,0 +1,271 @@ +// eslint-disable-next-line import/order +import { + test, + getBaggage, + annihilate, + nextLife, + asyncFlowVerbose, +} from './prepare-test-env-ava.js'; + +import { Fail } from '@endo/errors'; +import { isPromise } from '@endo/promise-kit'; +import { prepareVowTools } from '@agoric/vow'; +import { makeHeapZone } from '@agoric/zone/heap.js'; +import { makeVirtualZone } from '@agoric/zone/virtual.js'; +import { makeDurableZone } from '@agoric/zone/durable.js'; + +import { prepareLogStore } from '../src/log-store.js'; +import { prepareBijection } from '../src/bijection.js'; +import { makeReplayMembrane } from '../src/replay-membrane.js'; + +const watchWake = _vowish => {}; +const panic = problem => Fail`panic over ${problem}`; + +/** + * @param {Zone} zone + * @param {number} [k] + */ +const prepareOrchestra = (zone, k = 1) => + zone.exoClass( + 'Orchestra', + undefined, + (factor, vow, resolver) => ({ factor, vow, resolver }), + { + scale(n) { + const { state } = this; + return k * state.factor * n; + }, + vow() { + const { state } = this; + return state.vow; + }, + resolve(x) { + const { state } = this; + state.resolver.resolve(x); + }, + }, + ); + +/** + * @param {any} t + * @param {Zone} zone + * @param {boolean} [showOnConsole] + */ +const testFirstPlay = async (t, zone, showOnConsole = false) => { + const vowTools = prepareVowTools(zone); + const { makeVowKit } = vowTools; + const makeLogStore = prepareLogStore(zone); + const makeBijection = prepareBijection(zone); + const makeOrchestra = prepareOrchestra(zone); + const { vow: v1, resolver: r1 } = makeVowKit(); + const { vow: v2, resolver: r2 } = makeVowKit(); + + const log = zone.makeOnce('log', () => makeLogStore()); + const bij = zone.makeOnce('bij', makeBijection); + + const mem = makeReplayMembrane(log, bij, vowTools, watchWake, panic); + + const g1 = mem.hostToGuest(v1); + t.true(isPromise(g1)); + r1.resolve('x'); + t.is(await g1, 'x'); + + const hOrch7 = makeOrchestra(7, v2, r2); + t.false(bij.hasHost(hOrch7)); + const gOrch7 = mem.hostToGuest(hOrch7); + t.true(bij.has(gOrch7, hOrch7)); + + const prod = gOrch7.scale(3); + t.is(prod, 21); + + let gErr; + try { + gOrch7.scale(9n); + } catch (e) { + gErr = e; + } + + // TODO make E work across the membrane *well* + // TODO also try E on remote promise + // const prodP = E(gOrch7).scale(33); + // t.is(await prodP, 231); + // const badP = E(gOrch7).scale(99n); + // let gErr1; + // try { + // await badP; + // } catch (e) { + // gErr1 = e; + // } + // t.is(gErr1.name, 'TypeError'); + + t.deepEqual(log.dump(), [ + ['doFulfill', v1, 'x'], + ['checkCall', hOrch7, 'scale', [3], 1], + ['doReturn', 1, 21], + ['checkCall', hOrch7, 'scale', [9n], 3], + ['doThrow', 3, mem.guestToHost(gErr)], + ]); + + if (showOnConsole) { + // To see the annotation chain. Once we're synced with the next ses-ava, + // change this to a t.log, so we will see the annotation chain in context. + t.log('gErr', gErr); + } +}; + +/** + * @param {any} t + * @param {Zone} zone + */ +const testBadReplay = async (t, zone) => { + const vowTools = prepareVowTools(zone); + prepareLogStore(zone); + prepareBijection(zone); + prepareOrchestra(zone); + + const log = /** @type {LogStore} */ ( + zone.makeOnce('log', () => Fail`need log`) + ); + const bij = /** @type {Bijection} */ ( + zone.makeOnce('bij', () => Fail`need bij`) + ); + + const dump = log.dump(); + const v1 = dump[0][1]; + const hOrch7 = dump[1][1]; + const hErr = dump[4][2]; + + t.false(bij.hasHost(hOrch7)); + + t.deepEqual(dump, [ + ['doFulfill', v1, 'x'], + ['checkCall', hOrch7, 'scale', [3], 1], + ['doReturn', 1, 21], + ['checkCall', hOrch7, 'scale', [9n], 3], + ['doThrow', 3, hErr], + ]); + + const mem = makeReplayMembrane(log, bij, vowTools, watchWake, panic); + + const g1 = mem.hostToGuest(v1); + mem.wake(); + t.is(await g1, 'x'); + const gOrch7 = mem.hostToGuest(hOrch7); + t.true(bij.has(gOrch7, hOrch7)); + + // failure of guest to reproduce behavior from previous incarnations + t.throws(() => gOrch7.scale(4), { + message: /^panic over "\[Error: replay/, + }); +}; + +/** + * @param {any} t + * @param {Zone} zone + */ +const testGoodReplay = async (t, zone) => { + const vowTools = prepareVowTools(zone); + prepareLogStore(zone); + prepareBijection(zone); + prepareOrchestra(zone, 2); // 2 is new incarnation behavior change + + const log = /** @type {LogStore} */ ( + zone.makeOnce('log', () => Fail`need log`) + ); + const bij = /** @type {Bijection} */ ( + zone.makeOnce('bij', () => Fail`need bij`) + ); + + const dump = log.dump(); + const v1 = dump[0][1]; + const hOrch7 = dump[1][1]; + const hErr = dump[4][2]; + + t.false(bij.hasHost(hOrch7)); + + t.deepEqual(dump, [ + ['doFulfill', v1, 'x'], + ['checkCall', hOrch7, 'scale', [3], 1], + ['doReturn', 1, 21], + ['checkCall', hOrch7, 'scale', [9n], 3], + ['doThrow', 3, hErr], + ]); + + const oldLogLen = dump.length; + + const mem = makeReplayMembrane(log, bij, vowTools, watchWake, panic); + + const g1 = mem.hostToGuest(v1); + mem.wake(); + t.is(await g1, 'x'); + const gOrch7 = mem.hostToGuest(hOrch7); + t.true(bij.has(gOrch7, hOrch7)); + + // replay + const prodA = gOrch7.scale(3); + t.is(prodA, 21); // According to log of earlier incarnations + // let gErr; + try { + gOrch7.scale(9n); + } catch (e) { + // gErr = e; + } + + // new play + const prodB = gOrch7.scale(3); + t.is(prodB, 42); // According to new incarnation behavior + + const g2 = gOrch7.vow(); + const h2 = mem.guestToHost(g2); + t.true(isPromise(g2)); + const pairA = [gOrch7, g1]; + gOrch7.resolve(pairA); + const pairB = await g2; + const [gOrchB, gB] = pairB; + t.not(pairB, pairA); + t.is(gOrchB, gOrch7); + t.is(gB, g1); + + t.deepEqual(log.dump(), [ + ['doFulfill', v1, 'x'], + ['checkCall', hOrch7, 'scale', [3], 1], + ['doReturn', 1, 21], + ['checkCall', hOrch7, 'scale', [9n], 3], + ['doThrow', 3, hErr], + + ['checkCall', hOrch7, 'scale', [3], oldLogLen], + ['doReturn', oldLogLen, 42], + ['checkCall', hOrch7, 'vow', [], oldLogLen + 2], + ['doReturn', oldLogLen + 2, h2], + ['checkCall', hOrch7, 'resolve', [[hOrch7, v1]], oldLogLen + 4], + ['doReturn', oldLogLen + 4, undefined], + ['doFulfill', h2, [hOrch7, v1]], + ]); +}; + +test.serial('test heap replay-membrane', async t => { + const zone = makeHeapZone('heapRoot'); + return testFirstPlay(t, zone, asyncFlowVerbose()); +}); + +test.serial('test virtual replay-membrane', async t => { + annihilate(); + const zone = makeVirtualZone('virtualRoot'); + return testFirstPlay(t, zone); +}); + +test.serial('test durable replay-membrane', async t => { + annihilate(); + + nextLife(); + const zone1 = makeDurableZone(getBaggage(), 'durableRoot'); + await testFirstPlay(t, zone1); + + nextLife(); + const zone2 = makeDurableZone(getBaggage(), 'durableRoot'); + await testBadReplay(t, zone2); + + nextLife(); + const zone3 = makeDurableZone(getBaggage(), 'durableRoot'); + return testGoodReplay(t, zone3); +}); diff --git a/packages/async-flow/tsconfig.build.json b/packages/async-flow/tsconfig.build.json new file mode 100644 index 00000000000..d005fe6d8f2 --- /dev/null +++ b/packages/async-flow/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": [ + "./tsconfig.json", + "../../tsconfig-build-options.json" + ], + "exclude": [ + "scripts", + "test", + "**/exports.js", + ] +} diff --git a/packages/async-flow/tsconfig.json b/packages/async-flow/tsconfig.json new file mode 100644 index 00000000000..028175d9012 --- /dev/null +++ b/packages/async-flow/tsconfig.json @@ -0,0 +1,13 @@ +// This file can contain .js-specific Typescript compiler config. +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "maxNodeModuleJsDepth": 2, + }, + "include": [ + "*.js", + "scripts", + "src/**/*.js", + "test/**/*.js", + ], +} diff --git a/packages/async-flow/typedoc.json b/packages/async-flow/typedoc.json new file mode 100644 index 00000000000..3c5d5b005ee --- /dev/null +++ b/packages/async-flow/typedoc.json @@ -0,0 +1,8 @@ +{ + "extends": [ + "../../typedoc.base.json" + ], + "entryPoints": [ + "index.js", + ] +} diff --git a/packages/vow/src/index.js b/packages/vow/src/index.js index 82e8e5d47b9..d3033e6933f 100644 --- a/packages/vow/src/index.js +++ b/packages/vow/src/index.js @@ -1,7 +1,7 @@ // @ts-check export * from './tools.js'; export { default as makeE } from './E.js'; -export { VowShape } from './vow-utils.js'; +export { VowShape, toPassableCap } from './vow-utils.js'; // eslint-disable-next-line import/export export * from './types.js'; diff --git a/packages/vow/src/vow-utils.js b/packages/vow/src/vow-utils.js index f18fac6d24e..63ba2ecf6aa 100644 --- a/packages/vow/src/vow-utils.js +++ b/packages/vow/src/vow-utils.js @@ -3,7 +3,10 @@ import { E as basicE } from '@endo/eventual-send'; import { isPassable } from '@endo/pass-style'; import { M, matches } from '@endo/patterns'; -/** @import {VowPayload, Vow} from './types' */ +/** + * @import {PassableCap} from '@endo/pass-style' + * @import {VowPayload, Vow} from './types.js' + */ export { basicE }; @@ -14,6 +17,10 @@ export const VowShape = M.tagged( }), ); +/** + * @param {unknown} specimen + * @returns {specimen is Vow} + */ export const isVow = specimen => isPassable(specimen) && matches(specimen, VowShape); harden(isVow); @@ -38,3 +45,31 @@ export const getVowPayload = specimen => { return vow.payload; }; harden(getVowPayload); + +/** + * For when you have a Vow or a `PassableCap` (`RemotableObject` or + * passable `Promise`) and you need `PassableCap`, + * typically to serve as a key in a `Map`, `WeakMap`, `Store`, or `WeakStore`. + * + * Relies on, and encapsulates, the current "V0" representation of a vow + * as containing a unique remotable shortener. + * + * Note: if `k` is not a `Vow`, `toPassableCap` does no enforcement that `k` + * is already a `PassableCap`. Rather, it just acts as an identity function + * returning `k` without further checking. The types only describe the + * intended use. (If warranted, we may later add such enforcement, so please + * do not rely on either the presence or absence of such enforcement.) + * + * @param {PassableCap | Vow} k + * @returns {PassableCap} + */ +export const toPassableCap = k => { + const payload = getVowPayload(k); + if (payload === undefined) { + return /** @type {PassableCap} */ (k); + } + const { vowV0 } = payload; + // vowMap.set(vowV0, h); + return vowV0; +}; +harden(toPassableCap);