diff --git a/packages/marshal/index.js b/packages/marshal/index.js index 6973cf84d7..0b92024186 100644 --- a/packages/marshal/index.js +++ b/packages/marshal/index.js @@ -29,6 +29,8 @@ export { unionRankCovers, } from './src/rankOrder.js'; +export { makeDotMembraneKit } from './src/dot-membrane.js'; + // eslint-disable-next-line import/export export * from './src/types.js'; diff --git a/packages/marshal/src/dot-membrane.js b/packages/marshal/src/dot-membrane.js index b8144d0a9f..11e79c1ce8 100644 --- a/packages/marshal/src/dot-membrane.js +++ b/packages/marshal/src/dot-membrane.js @@ -2,152 +2,183 @@ /// import { E } from '@endo/eventual-send'; -import { isObject, getInterfaceOf, Far, passStyleOf } from '@endo/pass-style'; +import { getMethodNames } from '@endo/eventual-send/utils.js'; +import { + isObject, + getInterfaceOf, + passStyleOf, + Remotable, +} from '@endo/pass-style'; import { Fail } from '@endo/errors'; import { makeMarshal } from './marshal.js'; -const { fromEntries } = Object; -const { ownKeys } = Reflect; +const { fromEntries, defineProperties } = Object; -// TODO(erights): Add Converter type -/** @param {any} [mirrorConverter] */ -const makeConverter = (mirrorConverter = undefined) => { - /** @type {WeakMap=} */ - let mineToYours = new WeakMap(); - let optReasonString; - const myRevoke = reasonString => { - assert.typeof(reasonString, 'string'); - mineToYours = undefined; - optReasonString = reasonString; - if (optInnerRevoke) { - optInnerRevoke(reasonString); - } - }; - const convertMineToYours = (mine, _optIface = undefined) => { - if (mineToYours === undefined) { - throw harden(ReferenceError(`Revoked: ${optReasonString}`)); - } - if (mineToYours.has(mine)) { - return mineToYours.get(mine); - } - let yours; - const passStyle = passStyleOf(mine); - switch (passStyle) { - case 'promise': { - let yourResolve; - let yourReject; - yours = new Promise((res, rej) => { - yourResolve = res; - yourReject = rej; - }); - E.when( - mine, - myFulfillment => yourResolve(pass(myFulfillment)), - myReason => yourReject(pass(myReason)), - ) - .catch(metaReason => - // This can happen if myFulfillment or myReason is not passable. - // TODO verify that metaReason must be my-side-safe, or rather, - // that the passing of it is your-side-safe. - yourReject(pass(metaReason)), - ) - .catch(metaMetaReason => - // In case metaReason itself doesn't pass - yourReject(metaMetaReason), - ); - break; +/** + * @param {import('@endo/pass-style').Passable} blueTarget + */ +export const makeDotMembraneKit = blueTarget => { + // TODO(erights): Add Converter type + /** + * @param {any} [mirrorConverter] + */ + const makeConverter = (mirrorConverter = undefined) => { + const myColor = mirrorConverter ? 'blue' : 'yellow'; + /** @type {WeakMap=} */ + let memoMineToYours = new WeakMap(); + let optReasonString; + const myRevoke = reasonString => { + assert.typeof(reasonString, 'string'); + memoMineToYours = undefined; + optReasonString = reasonString; + if (optBlueRevoke) { + // In this case, myRevoke is the yellowRevoke + optBlueRevoke(reasonString); + } + }; + const convertMineToYours = (mine, _optIface = undefined) => { + if (memoMineToYours === undefined) { + throw harden(ReferenceError(`Revoked: ${optReasonString}`)); } - case 'remotable': { - /** @param {PropertyKey} [optVerb] */ - const myMethodToYours = - (optVerb = undefined) => - (...yourArgs) => { - // We use mineIf rather than mine so that mine is not accessible - // after revocation. This gives the correct error behavior, - // but may not actually enable mine to be gc'ed, depending on - // the JS engine. - // TODO Could rewrite to keep scopes more separate, so post-revoke - // gc works more often. - const mineIf = passBack(yours); + if (memoMineToYours.has(mine)) { + return memoMineToYours.get(mine); + } + let yours; + const passStyle = passStyleOf(mine); + switch (passStyle) { + case 'promise': { + let yourResolve; + let yourReject; + yours = harden( + new Promise((res, rej) => { + yourResolve = res; + yourReject = rej; + }), + ); + const myResolve = myFulfillment => + yourResolve(mineToYours(myFulfillment)); + const myReject = myReason => yourReject(mineToYours(myReason)); + E.when( + mine, + myFulfillment => myResolve(myFulfillment), + myReason => myReject(myReason), + ) + .catch(metaReason => + // This can happen if myFulfillment or myReason is not passable. + // TODO verify that metaReason must be my-side-safe, or rather, + // that the passing of it is your-side-safe. + myReject(metaReason), + ) + .catch(metaMetaReason => + // In case metaReason itself doesn't mineToYours + myReject(metaMetaReason), + ); + break; + } + case 'remotable': { + /** @param {PropertyKey} [optVerb] */ + const myMethodToYours = (optVerb = undefined) => { + const yourMethod = (...yourArgs) => { + // We use mineIf rather than mine so that mine is not accessible + // after revocation. This gives the correct error behavior, + // but may not actually enable mine to be gc'ed, depending on + // the JS engine. + // TODO Could rewrite to keep scopes more separate, so post-revoke + // gc works more often. + const mineIf = yoursToMine(yours); - assert(!isObject(optVerb)); - const myArgs = passBack(harden(yourArgs)); - let myResult; + assert(!isObject(optVerb)); + const myArgs = yoursToMine(harden(yourArgs)); + let myResult; - try { - myResult = - optVerb === undefined - ? mineIf(...myArgs) - : mineIf[optVerb](...myArgs); - } catch (myReason) { - throw pass(myReason); + try { + myResult = optVerb + ? mineIf[optVerb](...myArgs) + : mineIf(...myArgs); + } catch (myReason) { + const yourReason = mineToYours(harden(myReason)); + throw yourReason; + } + const yourResult = mineToYours(harden(myResult)); + return yourResult; + }; + if (optVerb) { + defineProperties(yourMethod, { + name: { value: String(optVerb) }, + length: { value: Number(mine[optVerb].length || 0) }, + }); + } else { + defineProperties(yourMethod, { + name: { value: String(mine.name || 'anon') }, + length: { value: Number(mine.length || 0) }, + }); } - return pass(myResult); + return yourMethod; }; - const iface = pass(getInterfaceOf(mine)) || 'unlabeled remotable'; - if (typeof mine === '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. - yours = Far(iface, myMethodToYours()); - } else { - const myMethodNames = ownKeys(mine); - const yourMethods = myMethodNames.map(name => [ - name, - myMethodToYours(name), - ]); - yours = Far(iface, fromEntries(yourMethods)); + const iface = String(getInterfaceOf(mine) || 'unlabeled remotable'); + if (typeof mine === '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. + yours = Remotable(iface, undefined, myMethodToYours()); + } else { + const myMethodNames = getMethodNames(mine); + const yourMethods = myMethodNames.map(name => [ + name, + myMethodToYours(name), + ]); + yours = Remotable(iface, undefined, fromEntries(yourMethods)); + } + break; + } + default: { + Fail`internal: Unrecognized passStyle ${passStyle}`; } - break; - } - default: { - Fail`internal: Unrecognized passStyle ${passStyle}`; } + memoMineToYours.set(mine, yours); + memoYoursToMine.set(yours, mine); + return yours; + }; + + const { toCapData: myToYellowCapData, fromCapData: yourFromYellowCapData } = + makeMarshal( + // convert from my value to a yellow slot. undefined is identity. + myColor === 'yellow' ? undefined : convertMineToYours, + // convert from a yellow slot to your value. undefined is identity. + myColor === 'blue' ? undefined : convertMineToYours, + { + serializeBodyFormat: 'smallcaps', + }, + ); + + const mineToYours = mine => { + const yellowCapData = myToYellowCapData(mine); + const yours = yourFromYellowCapData(yellowCapData); + return yours; + }; + const converter = harden({ + memoMineToYours, + mineToYours, + yourToMine: target => yoursToMine(target), + myRevoke, + }); + let optBlueRevoke; + if (mirrorConverter === undefined) { + assert(myColor === 'yellow'); + // in this case, converter is the yellowConverter + // and mirrorConverter will be the blueConverter + mirrorConverter = makeConverter(converter); + optBlueRevoke = mirrorConverter.myRevoke; } - mineToYours.set(mine, yours); - yoursToMine.set(yours, mine); - return yours; - }; - // We need to pass this while convertYoursToMine is still in temporal - // dead zone, so we wrap it in convertSlotToVal. - const convertSlotToVal = (slot, optIface = undefined) => - convertYoursToMine(slot, optIface); - const { serialize: mySerialize, unserialize: myUnserialize } = makeMarshal( - convertMineToYours, - convertSlotToVal, - ); - const pass = mine => { - const myCapData = mySerialize(mine); - const yours = yourUnserialize(myCapData); - return yours; + const { memoMineToYours: memoYoursToMine, mineToYours: yoursToMine } = + mirrorConverter; + return converter; }; - const converter = harden({ - mineToYours, - convertMineToYours, - myUnserialize, - pass, - wrap: target => passBack(target), - myRevoke, - }); - let optInnerRevoke; - if (mirrorConverter === undefined) { - mirrorConverter = makeConverter(converter); - optInnerRevoke = mirrorConverter.myRevoke; - } - const { - mineToYours: yoursToMine, - convertMineToYours: convertYoursToMine, - myUnserialize: yourUnserialize, - pass: passBack, - } = mirrorConverter; - return converter; -}; -export const makeDotMembraneKit = target => { - const converter = makeConverter(); + const yellowConverter = makeConverter(); return harden({ - proxy: converter.wrap(target), - revoke: converter.myRevoke, + yellowProxy: yellowConverter.yourToMine(blueTarget), + yellowRevoke: yellowConverter.myRevoke, }); }; harden(makeDotMembraneKit); diff --git a/packages/marshal/src/equalEnough.js b/packages/marshal/src/equalEnough.js new file mode 100644 index 0000000000..abc7ed043b --- /dev/null +++ b/packages/marshal/src/equalEnough.js @@ -0,0 +1,119 @@ +import { Fail, q } from '@endo/errors'; +import { passStyleOf } from '@endo/pass-style'; +import { compareRank } from './rankOrder.js'; +import { recordNames, recordValues } from './encodePassable.js'; + +const { is } = Object; + +/** + * Since we're starting below the level where Checker is defined, try + * using a Rejector parameter directly. + * + * The pleasantness of this exercise shows we should have used Rejector + * parameters rather than Checker parameters all along. TODO we should seek + * to migrate existing uses of Checker to Rejector. + * + * TODO Also experiment with pleasant error path labeling. + * + * @typedef {false | + * ((template: TemplateStringsArray, ...subs: any[]) => false) + * } Rejector + */ + +/** + * Normally, the `check*` function stays encapsulated + * and only the `is*` and `assert*` functions are exported. + * Here, we export the `check*` function too, because a caller is interested + * in distinguishing diagnostic rejection from internal errors. + * + * @param {import('@endo/pass-style').Passable} x + * @param {import('@endo/pass-style').Passable} y + * @param {Rejector} reject + * @returns {boolean} + */ +export const checkEqualEnough = (x, y, reject) => { + if (is(x, y)) { + return true; + } + harden(x); + harden(y); + if (compareRank(x, y) !== 0) { + return reject && reject`Unequal rank: ${x} vs ${y}`; + } + const passStyle = passStyleOf(x); + passStyle === passStyleOf(y) || + Fail`internal: Same rank should imply same passStyle: ${q( + passStyle, + )} vs ${q(passStyleOf(y))}`; + switch (passStyle) { + case 'undefined': + case 'null': { + throw Fail`internal: x sameRank y should imply x is y: ${x} vs ${y}`; + } + case 'boolean': + case 'number': + case 'bigint': + case 'string': + case 'symbol': + case 'remotable': { + return reject && reject`Unequal: ${x} vs ${y}`; + } + case 'promise': { + // what ya gonna do? + return true; + } + case 'error': { + // TODO equal enough without comparing .message or other properties? + return ( + x.name === y.name || + (reject && reject`Different error names: ${q(x.name)} vs ${q(y.name)}`) + ); + } + case 'copyArray': { + if (x.length !== y.length) { + return ( + reject && reject`Different lengths: ${q(x.length)} vs ${q(y.length)}` + ); + } + return x.every((xel, i) => checkEqualEnough(xel, y[i], reject)); + } + case 'copyRecord': { + const xNames = recordNames(x); + const yNames = recordNames(y); + return ( + checkEqualEnough(xNames, yNames, reject) && + checkEqualEnough( + recordValues(x, xNames), + recordValues(y, xNames), + reject, + ) + ); + } + case 'tagged': { + return ( + checkEqualEnough(x.tag, y.tag, reject) && + checkEqualEnough(x.payload, y.payload, reject) + ); + } + default: { + throw Fail`internal: Unexpected passStyle: ${q(passStyle)}`; + } + } +}; + +/** + * @param { import('@endo/pass-style').Passable } x + * @param { import('@endo/pass-style').Passable } y + * @returns { boolean } + */ +export const equalEnough = (x, y) => checkEqualEnough(x, y, false); +harden(equalEnough); + +/** + * @param { import('@endo/pass-style').Passable } x + * @param { import('@endo/pass-style').Passable } y + */ +export const assertEqualEnough = (x, y) => { + checkEqualEnough(x, y, Fail); +}; +harden(assertEqualEnough); diff --git a/packages/marshal/src/host-log-membrane.js b/packages/marshal/src/host-log-membrane.js new file mode 100644 index 0000000000..67d97dc51c --- /dev/null +++ b/packages/marshal/src/host-log-membrane.js @@ -0,0 +1,312 @@ +/* eslint-disable no-use-before-define */ +import { E } from '@endo/eventual-send'; +import { getMethodNames } from '@endo/eventual-send/utils.js'; +import { + isObject, + getInterfaceOf, + passStyleOf, + Remotable, +} from '@endo/pass-style'; +import { Fail } from '@endo/errors'; +import { makeMarshal } from './marshal.js'; + +const { fromEntries, defineProperties } = Object; + +/** + * @param {import('@endo/pass-style').Passable} guestTarget + * @param {any[]} hostLog + */ +export const makeHostLogMembraneKit = (guestTarget, hostLog) => { + /** @type {WeakMap | undefined} */ + let memoH2G = new WeakMap(); + /** @type {WeakMap | undefined} */ + let memoG2H = new WeakMap(); + let optReasonString; + const revoke = reasonString => { + assert.typeof(reasonString, 'string'); + memoH2G = undefined; + memoG2H = undefined; + optReasonString = reasonString; + }; + + const convertCapH2G = (hostCap, _optIface = undefined) => { + if (memoH2G === undefined) { + throw harden(ReferenceError(`Revoked: ${optReasonString}`)); + } + assert(memoG2H !== undefined); + if (memoH2G.has(hostCap)) { + return memoH2G.get(hostCap); + } + let guestCap; + const passStyle = passStyleOf(hostCap); + switch (passStyle) { + case 'promise': { + let guestResolve; + let guestReject; + guestCap = harden( + new Promise((res, rej) => { + guestResolve = res; + guestReject = rej; + }), + ); + const hostResolve = hostFulfillment => { + hostLog.push(['doFulfill', hostCap, hostFulfillment]); + guestResolve(hostToGuest(hostFulfillment)); + }; + const hostReject = hostReason => { + hostLog.push(['doReject', hostCap, hostReason]); + guestReject(hostToGuest(hostReason)); + }; + E.when( + hostCap, + hostFulfillment => hostResolve(hostFulfillment), + hostReason => hostReject(hostReason), + ) + .catch(metaReason => + // This can happen if hostFulfillment or hostReason is not + // Passable. + // TODO verify that metaReason must be host-side-safe, or rather, + // that the passing of it is guest-side-safe. + hostReject(metaReason), + ) + .catch(metaMetaReason => + // In case metaReason itself doesn't hostToGuest + guestReject(metaMetaReason), + ); + break; + } + case 'remotable': { + /** @param {PropertyKey} [optVerb] */ + const makeGuestMethod = (optVerb = undefined) => { + const guestMethod = (...guestArgs) => { + // We use hostCapIf rather than hostCap so that hostCap is + // not accessible + // after revocation. This gives the correct error behavior, + // but may not actually enable hostCap to be gc'ed, depending on + // the JS engine. + // TODO Could rewrite to keep scopes more separate, so post-revoke + // gc works more often. + const hostCapIf = guestToHost(guestCap); + + assert(!isObject(optVerb)); + const hostArgs = guestToHost(harden(guestArgs)); + let hostResult; + + const callLogIndex = hostLog.length; + hostLog.push([ + 'checkCall', + hostCap, + optVerb, + hostArgs, + callLogIndex, + ]); + + try { + hostResult = optVerb + ? hostCapIf[optVerb](...hostArgs) + : hostCapIf(...hostArgs); + } catch (hostReason) { + const guestReason = hostToGuest(harden(hostReason)); + hostLog.push(['doThrow', callLogIndex, hostReason]); + throw guestReason; + } + const guestResult = hostToGuest(harden(hostResult)); + hostLog.push(['doReturn', callLogIndex, hostResult]); + return guestResult; + }; + if (optVerb) { + defineProperties(guestMethod, { + name: { value: String(optVerb) }, + length: { value: Number(hostCap[optVerb].length || 0) }, + }); + } else { + defineProperties(guestMethod, { + name: { value: String(hostCap.name || 'anon') }, + length: { value: Number(hostCap.length || 0) }, + }); + } + return guestMethod; + }; + const iface = String(getInterfaceOf(hostCap) || 'unlabeled remotable'); + if (typeof hostCap === '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. + guestCap = Remotable(iface, undefined, makeGuestMethod()); + } else { + const methodNames = getMethodNames(hostCap); + const guestMethods = methodNames.map(name => [ + name, + makeGuestMethod(name), + ]); + guestCap = Remotable(iface, undefined, fromEntries(guestMethods)); + } + break; + } + default: { + Fail`internal: Unrecognized passStyle ${passStyle}`; + } + } + memoH2G.set(hostCap, guestCap); + memoG2H.set(guestCap, hostCap); + return guestCap; + }; + + const { toCapData: hostToHostCapData, fromCapData: guestFromHostCapData } = + makeMarshal( + undefined, // undefined is identity + convertCapH2G, // convert a host slot to a guest value + { + serializeBodyFormat: 'smallcaps', + }, + ); + + const hostToGuest = hostVal => { + const hostCapData = hostToHostCapData(hostVal); + const guestVal = guestFromHostCapData(hostCapData); + return guestVal; + }; + + // ///////////////////////// Mirror image //////////////////////////////////// + + const convertCapG2H = (guestCap, _optIface = undefined) => { + if (memoG2H === undefined) { + throw harden(ReferenceError(`Revoked: ${optReasonString}`)); + } + assert(memoH2G !== undefined); + if (memoG2H.has(guestCap)) { + return memoG2H.get(guestCap); + } + let hostCap; + const passStyle = passStyleOf(guestCap); + switch (passStyle) { + case 'promise': { + let hostResolve; + let hostReject; + hostCap = harden( + new Promise((res, rej) => { + hostResolve = res; + hostReject = rej; + }), + ); + const guestResolve = guestFulfillment => { + const hostFulfillment = guestToHost(guestFulfillment); + hostLog.push(['checkFulfill', hostCap, hostFulfillment]); + hostResolve(hostFulfillment); + }; + const guestReject = guestReason => { + const hostReason = guestToHost(guestReason); + hostLog.push(['checkReject', hostCap, hostReason]); + hostReject(hostReason); + }; + E.when( + guestCap, + guestFulfillment => guestResolve(guestFulfillment), + guestReason => guestReject(guestReason), + ) + .catch(metaReason => + // This can happen if guestFulfillment or guestReason is not + // passable. + // TODO verify that metaReason must be guest-side-safe, or rather, + // that the passing of it is host-side-safe. + guestReject(metaReason), + ) + .catch(metaMetaReason => + // In case metaReason itself doesn't guestToHost + hostReject(metaMetaReason), + ); + break; + } + case 'remotable': { + /** @param {PropertyKey} [optVerb] */ + const makeHostMethod = (optVerb = undefined) => { + const hostMethod = (...hostArgs) => { + // We use guestCapIf rather than guestCap so that guestCap is + // not accessible + // after revocation. This gives the correct error behavior, + // but may not actually enable guestCap to be gc'ed, depending on + // the JS engine. + // TODO Could rewrite to keep scopes more separate, so post-revoke + // gc works more often. + const guestCapIf = hostToGuest(hostCap); + + assert(!isObject(optVerb)); + const guestArgs = hostToGuest(harden(hostArgs)); + let guestResult; + + const callLogIndex = hostLog.length; + hostLog.push(['doCall', hostCap, optVerb, hostArgs, callLogIndex]); + + try { + guestResult = optVerb + ? guestCapIf[optVerb](...guestArgs) + : guestCapIf(...guestArgs); + } catch (guestReason) { + const yourReason = guestToHost(harden(guestReason)); + hostLog.push(['checkThrow', callLogIndex, yourReason]); + throw yourReason; + } + const yourResult = guestToHost(harden(guestResult)); + hostLog.push(['checkReturn', callLogIndex, yourResult]); + return yourResult; + }; + if (optVerb) { + defineProperties(hostMethod, { + name: { value: String(optVerb) }, + length: { value: Number(guestCap[optVerb].length || 0) }, + }); + } else { + defineProperties(hostMethod, { + name: { value: String(guestCap.name || 'anon') }, + length: { value: Number(guestCap.length || 0) }, + }); + } + return hostMethod; + }; + const iface = String(getInterfaceOf(guestCap) || 'unlabeled remotable'); + if (typeof guestCap === '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. + hostCap = Remotable(iface, undefined, makeHostMethod()); + } else { + const methodNames = getMethodNames(guestCap); + const hostMethods = methodNames.map(name => [ + name, + makeHostMethod(name), + ]); + hostCap = Remotable(iface, undefined, fromEntries(hostMethods)); + } + break; + } + default: { + Fail`internal: Unrecognized passStyle ${passStyle}`; + } + } + memoG2H.set(guestCap, hostCap); + memoH2G.set(hostCap, guestCap); + return hostCap; + }; + + const { toCapData: guestToHostCapData, fromCapData: hostFromHostCapData } = + makeMarshal( + // convert from my value to a yellow slot. undefined is identity. + convertCapG2H, // convert a guest value to a host slot + undefined, // undefined is identity + { + serializeBodyFormat: 'smallcaps', + }, + ); + + const guestToHost = guestVal => { + const hostCapData = guestToHostCapData(guestVal); + const hostVal = hostFromHostCapData(hostCapData); + return hostVal; + }; + + return harden({ + hostProxy: guestToHost(guestTarget), + revoke, + }); +}; +harden(makeHostLogMembraneKit); diff --git a/packages/marshal/src/replay-bridge.js b/packages/marshal/src/replay-bridge.js new file mode 100644 index 0000000000..42abc6e0db --- /dev/null +++ b/packages/marshal/src/replay-bridge.js @@ -0,0 +1,95 @@ +import { Fail, X, annotateError, q } from '@endo/errors'; +import { isObject, passStyleOf } from '@endo/pass-style'; +import { compareRank } from './rankOrder.js'; +import { recordNames, recordValues } from './encodePassable.js'; + +const { is } = Object; + +/** + * Since we're starting below the level where Checker is defined, try + * using a Rejector parameter directly. + * + * The pleasantness of this exercise shows we should have used Rejector + * parameters rather than Checker parameters all along. TODO we should seek + * to migrate existing uses of Checker to Rejector. + * + * TODO Also experiment with pleasant error path labeling. + */ + +export const makeReplayBridge = (memoG2H, memoH2G) => { + const bind = (g, h) => { + memoG2H.set(g, h); + memoH2G.set(h, g); + }; + + const bridge = (g, h, _label) => { + if (isObject(g)) { + isObject(h) || Fail`isObject must be the same: ${g} vs ${h}`; + if (memoG2H.has(g)) { + memoG2H.get(g) === h || Fail`Unqual objects: ${g} vs ${h}`; + memoH2G.get(h) === g || + Fail`internal: memos inconsistent on: ${g} vs ${h}`; + return; + } else { + !memoH2G.has(h) || Fail`internal: memos inconsistent on: ${g} vs ${h}`; + } + } else { + is(g, h) || Fail`Unqual primitive values: ${g} vs ${h}`; + return; + } + const passStyle = passStyleOf(g); + const hPassStyle = passStyleOf(h); + passStyle === hPassStyle || + Fail`passStyles must be the same: ${q(passStyle)} vs ${q(hPassStyle)}`; + switch (passStyle) { + case 'copyArray': { + g.length === h.length || + Fail`Unqual lengths: ${q(g.length)} vs ${q(h.length)}`; + g.forEach((gElement, i) => bridge(gElement, h[i], i)); + bind(g, h); + return; + } + case 'copyRecord': { + const names = recordNames(g); + const hNames = recordNames(h); + compareRank(names, hNames) === 0 || + Fail`Unequal property names: ${q(names)} vs ${q(hNames)}`; + const gValues = recordValues(g, names); + const hValues = recordValues(h, names); + gValues.forEach((gValue, i) => bridge(gValue, hValues[i], names[i])); + bind(g, h); + return; + } + case 'tagged': { + bridge(g.tag, h.tag, 'tag'); + bridge(g.payload, h.payload, 'payload'); + bind(g, h); + return; + } + case 'error': { + bridge(g.name, h.name, 'error name'); + // Ok for everything else to differ + // but would be nice to warn on different errors + annotateError(g, X`guest for host error ${h}`); + annotateError(h, X`host for guest error ${g}`); + bind(g, h); + return; + } + case 'remotable': { + // Would be nice to want on different iface strings + bind(g, h); + return; + } + case 'promise': { + // Would be nice to want on different iface strings + bind(g, h); + return; + } + default: { + Fail`unrecognized passStyle ${q(passStyle)}`; + } + } + }; + return harden(bridge); +}; +harden(makeReplayBridge); diff --git a/packages/marshal/src/replay-membrane.js b/packages/marshal/src/replay-membrane.js new file mode 100644 index 0000000000..c1b671b26f --- /dev/null +++ b/packages/marshal/src/replay-membrane.js @@ -0,0 +1,514 @@ +/* eslint-disable @endo/restrict-comparison-operands */ +/* eslint-disable no-use-before-define */ +import { E } from '@endo/eventual-send'; +import { getMethodNames } from '@endo/eventual-send/utils.js'; +import { getInterfaceOf, passStyleOf, Remotable } from '@endo/pass-style'; +import { X, makeError, q } from '@endo/errors'; +import { makeMarshal } from './marshal.js'; +import { assertEqualEnough, checkEqualEnough } from './equalEnough.js'; + +const { fromEntries, defineProperties } = Object; + +/** + * @typedef {'return'|'throw'} OutcomeKind + */ + +/** + * @typedef {{kind: 'return', result: any} + * | {kind: 'throw', reason: any} + * } Outcome + */ + +/** + * @param {import('@endo/pass-style').Passable} guestTarget + * @param {any[]} hostLog + */ +export const makeReplayMembraneKit = async (guestTarget, hostLog) => { + /** @type {WeakMap | undefined} */ + let memoH2G = new WeakMap(); + /** @type {WeakMap | undefined} */ + let memoG2H = new WeakMap(); + + let optReason; + /** + * @type {import('./equalEnough.js').Rejector} + */ + const reject = (template, ...subs) => { + if (optReason === undefined) { + optReason = makeError(X(template, ...subs), ReferenceError); + memoH2G = undefined; + memoG2H = undefined; + } + throw optReason; + }; + + /** + * If < hostLog.length, the index of the next hostLog entry to interpret. + * > Q: Why did the program counter increment? + * > A: To get to the next instruction. + * + * Else must be === hostLog.length, in which case we're caught up + * and should switch into pushing new hostLog entries. + * + * @type {number} hostLogIndex + */ + let hostLogIndex = 0; + + const promiseMap = new WeakMap(); + + const doHostLog = entry => { + try { + if (hostLogIndex < hostLog.length) { + assertEqualEnough(entry, hostLog[hostLogIndex]); + } else { + hostLogIndex === hostLog.length || + reject`internal: unexpected hostLogIndex: ${q(hostLogIndex)}`; + hostLog.push(entry); + } + } finally { + hostLogIndex += 1; + } + }; + + const checkHostLog = entry => { + try { + if (hostLogIndex < hostLog.length) { + checkEqualEnough(entry, hostLog[hostLogIndex], reject); + } else { + hostLogIndex === hostLog.length || + reject`internal: unexpected hostLogIndex: ${q(hostLogIndex)}`; + hostLog.push(entry); + } + } finally { + hostLogIndex += 1; + } + }; + + // ////////////// Host or Interpreter to Guest /////////////////////////////// + + const doFulfill = (hostPromise, hostFulfillment) => { + doHostLog(['doFulfill', hostPromise, hostFulfillment]); + promiseMap.get(hostPromise).hostResolve(hostFulfillment); + promiseMap.delete(hostPromise); + }; + + const doReject = (hostPromise, hostReason) => { + doHostLog(['doReject', hostPromise, hostReason]); + promiseMap.get(hostPromise).hostReject(hostReason); + promiseMap.delete(hostPromise); + }; + + /** + * @param {Remotable} hostCap + * @param {PropertyKey | undefined} optVerb + * @param {any[]} hostArgs + * @param {number} callIndex + * @returns {Outcome} + */ + const doCall = (hostCap, optVerb, hostArgs, callIndex) => { + doHostLog(['doCall', hostCap, optVerb, hostArgs, callIndex]); + const guestCap = hostToGuest(hostCap); + const guestArgs = hostToGuest(hostArgs); + let guestResult; + try { + guestResult = + optVerb === undefined + ? guestCap(...guestArgs) + : guestCap[optVerb](...guestArgs); + } catch (guestReason) { + return checkThrow(callIndex, guestReason); + } + return checkReturn(callIndex, guestResult); + }; + + /** + * @param {number} callIndex + * @param {any} hostResult + * @returns {Outcome} + */ + const doReturn = (callIndex, hostResult) => { + doHostLog(['doReturn', callIndex, hostResult]); + unnestInterpreter(callIndex); + const guestResult = hostToGuest(hostResult); + return harden({ + kind: 'return', + result: guestResult, + }); + }; + + /** + * @param {number} callIndex + * @param {any} hostReason + * @returns {Outcome} + */ + const doThrow = (callIndex, hostReason) => { + doHostLog(['doThrow', callIndex, hostReason]); + unnestInterpreter(callIndex); + const guestReason = hostToGuest(hostReason); + return harden({ + kind: 'throw', + reason: guestReason, + }); + }; + + // ///////////// Guest to Host or consume log //////////////////////////////// + + const checkFulfill = (guestPromise, guestFulfillment) => { + const hostPromise = guestToHost(guestPromise); + const hostFulfillment = guestToHost(guestFulfillment); + checkHostLog(['checkFulfill', hostPromise, hostFulfillment]); + promiseMap.get(hostPromise).hostResolve(hostFulfillment); + promiseMap.delete(hostPromise); + }; + + const checkReject = (guestPromise, guestReason) => { + const hostPromise = guestToHost(guestPromise); + const hostReason = guestToHost(guestReason); + checkHostLog(['checkReject', hostPromise, hostReason]); + promiseMap.get(hostPromise).hostReject(hostReason); + promiseMap.delete(hostPromise); + }; + + /** + * @param {Remotable} guestCap + * @param {PropertyKey | undefined} optVerb + * @param {any[]} guestArgs + * @param {number} callIndex + * @returns {Outcome} + */ + const checkCall = (guestCap, optVerb, guestArgs, callIndex) => { + const hostCap = guestToHost(guestCap); + const hostArgs = guestToHost(guestArgs); + checkHostLog(['checkCall', hostCap, optVerb, hostArgs, callIndex]); + if (callIndex < hostLog.length) { + // Simulate everything that happens ending with matching + // doReturn or doThrow + return nestInterpreter(callIndex); + } else { + // Running for real. Actually call the host function or method + let hostResult; + try { + hostResult = optVerb + ? hostCap[optVerb](...hostArgs) + : hostCap(...hostArgs); + } catch (hostReason) { + return doThrow(callIndex, hostReason); + } + return doReturn(callIndex, hostResult); + } + }; + + /** + * @param {number} callIndex + * @param {any} guestResult + * @returns {Outcome} + */ + const checkReturn = (callIndex, guestResult) => { + const hostResult = hostToGuest(guestResult); + checkHostLog(['checkReturn', callIndex, hostResult]); + return harden({ + kind: 'return', + result: hostResult, + }); + }; + + /** + * @param {number} callIndex + * @param {any} guestReason + * @returns {Outcome} + */ + const checkThrow = (callIndex, guestReason) => { + const hostReason = hostToGuest(guestReason); + checkHostLog(['checkThrow', callIndex, hostReason]); + return harden({ + kind: 'throw', + reason: hostReason, + }); + }; + + // //////////////////////// Log interpreters ///////////////////////////////// + + const callIndexStack = []; + + let unnestFlag = false; + + const nestDispatch = harden({ + doCall, + doReturn, + doThrow, + }); + + const interpretEntry = (dispatch, [verb, ...args]) => dispatch[verb](...args); + + /** + * @param {number} callIndex + * @returns {Outcome} + */ + const nestInterpreter = callIndex => { + callIndexStack.push(callIndex); + // eslint-disable-next-line no-constant-condition + while (true) { + hostLogIndex < hostLog.length || + reject`log ended too soon: ${hostLogIndex}`; + try { + // eslint-disable-next-line no-await-in-loop + const optOutcome = interpretEntry(nestDispatch, hostLog[hostLogIndex]); + if (unnestFlag) { + optOutcome || reject`only unnest with an outcome: ${q(hostLogIndex)}`; + unnestFlag = false; + return optOutcome; + } + } catch (problem) { + reject`Playback stopped due to ${q(problem)}`; + } + } + }; + + /** + * @param {number} callIndex + */ + const unnestInterpreter = callIndex => { + const stackLen = callIndexStack.length; + (stackLen >= 1 && callIndexStack[stackLen - 1] === callIndex) || + reject`Unexpected call stack close: ${q(callIndex)}`; + callIndexStack.pop(); + if (hostLogIndex < hostLog.length) { + unnestFlag = true; + } else { + hostLogIndex === hostLog.length || + reject`internal: unexpected hostLogIndex: ${q(hostLogIndex)}`; + } + }; + + const topDispatch = harden({ + doFulfill, + doReject, + doCall, + checkFulfill, + checkReject, + }); + + const topInterpreter = async () => { + while (hostLogIndex < hostLog.length) { + try { + // eslint-disable-next-line no-await-in-loop + const optOutcome = interpretEntry(topDispatch, hostLog[hostLogIndex]); + if (unnestFlag) { + optOutcome || reject`only unnest with an outcome: ${q(hostLogIndex)}`; + unnestFlag = false; + return optOutcome; + } + } catch (problem) { + reject`Playback stopped due to ${q(problem)}`; + } + } + return undefined; + }; + + // /////////////////////////////////////////////////////////////////////////// + + const convertCapH2G = (hostCap, _optIface = undefined) => { + if (memoH2G === undefined) { + throw optReason; + } + assert(memoG2H !== undefined); + if (memoH2G.has(hostCap)) { + return memoH2G.get(hostCap); + } + let guestCap; + const passStyle = passStyleOf(hostCap); + switch (passStyle) { + case 'promise': { + let guestResolve; + let guestReject; + guestCap = harden( + new Promise((res, rej) => { + guestResolve = res; + guestReject = rej; + }), + ); + const hostResolve = hostFulfillment => { + guestResolve(hostToGuest(hostFulfillment)); + }; + const hostReject = hostReason => { + guestReject(hostToGuest(hostReason)); + }; + promiseMap.set(hostCap, harden({ hostResolve, hostReject })); + if (hostLogIndex >= hostLog.length) { + assert(hostLogIndex === hostLog.length); + // A real host, not the log replay + E.when( + hostCap, + hostFulfillment => doFulfill(hostCap, hostFulfillment), + hostReason => doReject(hostCap, hostReason), + ); + } + break; + } + case 'remotable': { + /** @param {PropertyKey} [optVerb] */ + const makeGuestMethod = (optVerb = undefined) => { + const guestMethod = (...guestArgs) => { + const callIndex = hostLogIndex; + return checkCall(guestCap, optVerb, guestArgs, callIndex); + }; + if (optVerb) { + defineProperties(guestMethod, { + name: { value: String(optVerb) }, + length: { value: Number(hostCap[optVerb].length || 0) }, + }); + } else { + defineProperties(guestMethod, { + name: { value: String(hostCap.name || 'anon') }, + length: { value: Number(hostCap.length || 0) }, + }); + } + return guestMethod; + }; + const iface = String(getInterfaceOf(hostCap) || 'unlabeled remotable'); + if (typeof hostCap === '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. + guestCap = Remotable(iface, undefined, makeGuestMethod()); + } else { + const methodNames = getMethodNames(hostCap); + const guestMethods = methodNames.map(name => [ + name, + makeGuestMethod(name), + ]); + guestCap = Remotable(iface, undefined, fromEntries(guestMethods)); + } + break; + } + default: { + reject`internal: Unrecognized passStyle ${passStyle}`; + } + } + memoH2G.set(hostCap, guestCap); + memoG2H.set(guestCap, hostCap); + return guestCap; + }; + + const { toCapData: hostToHostCapData, fromCapData: guestFromHostCapData } = + makeMarshal( + undefined, // undefined is identity + convertCapH2G, // convert a host slot to a guest value + { + serializeBodyFormat: 'smallcaps', + }, + ); + + const hostToGuest = hostVal => { + const hostCapData = hostToHostCapData(harden(hostVal)); + const guestVal = guestFromHostCapData(hostCapData); + return guestVal; + }; + + // ///////////////////////// Mirror image //////////////////////////////////// + + const convertCapG2H = (guestCap, _optIface = undefined) => { + if (memoG2H === undefined) { + throw optReason; + } + assert(memoH2G !== undefined); + if (memoG2H.has(guestCap)) { + return memoG2H.get(guestCap); + } + let hostCap; + const passStyle = passStyleOf(guestCap); + switch (passStyle) { + case 'promise': { + let hostResolve; + let hostReject; + hostCap = harden( + new Promise((res, rej) => { + hostResolve = res; + hostReject = rej; + }), + ); + promiseMap.set(hostCap, harden({ hostResolve, hostReject })); + E.when( + guestCap, + guestFulfillment => checkFulfill(guestCap, guestFulfillment), + guestReason => checkReject(guestCap, guestReason), + ); + break; + } + case 'remotable': { + /** @param {PropertyKey} [optVerb] */ + const makeHostMethod = (optVerb = undefined) => { + const hostMethod = (...hostArgs) => { + const callIndex = hostLogIndex; + return doCall(hostCap, optVerb, hostArgs, callIndex); + }; + if (optVerb) { + defineProperties(hostMethod, { + name: { value: String(optVerb) }, + length: { value: Number(guestCap[optVerb].length || 0) }, + }); + } else { + defineProperties(hostMethod, { + name: { value: String(guestCap.name || 'anon') }, + length: { value: Number(guestCap.length || 0) }, + }); + } + return hostMethod; + }; + const iface = String(getInterfaceOf(guestCap) || 'unlabeled remotable'); + if (typeof guestCap === '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. + hostCap = Remotable(iface, undefined, makeHostMethod()); + } else { + const methodNames = getMethodNames(guestCap); + const hostMethods = methodNames.map(name => [ + name, + makeHostMethod(name), + ]); + hostCap = Remotable(iface, undefined, fromEntries(hostMethods)); + } + break; + } + default: { + reject`internal: Unrecognized passStyle ${passStyle}`; + } + } + memoG2H.set(guestCap, hostCap); + memoH2G.set(hostCap, guestCap); + return hostCap; + }; + + const { toCapData: guestToHostCapData, fromCapData: hostFromHostCapData } = + makeMarshal( + // convert from my value to a yellow slot. undefined is identity. + convertCapG2H, // convert a guest value to a host slot + undefined, // undefined is identity + { + serializeBodyFormat: 'smallcaps', + }, + ); + + const guestToHost = guestVal => { + const hostCapData = guestToHostCapData(harden(guestVal)); + const hostVal = hostFromHostCapData(hostCapData); + return hostVal; + }; + + if (hostLogIndex < hostLog.length) { + const [firstVerb, firstArg, ..._rest] = hostLog[0]; + firstVerb === 'doCall' || + reject`First log entry be a "doCall", not ${q(firstVerb)}`; + const hostTarget = firstArg; + memoG2H.set(guestTarget, hostTarget); + memoH2G.set(hostTarget, guestTarget); + } + + await topInterpreter(); + + return harden({ + hostProxy: guestToHost(guestTarget), + }); +}; +harden(makeReplayMembraneKit); diff --git a/packages/marshal/test/test-dot-membrane.js b/packages/marshal/test/test-dot-membrane.js index b600d88913..a964b88ebc 100644 --- a/packages/marshal/test/test-dot-membrane.js +++ b/packages/marshal/test/test-dot-membrane.js @@ -3,22 +3,36 @@ import test from '@endo/ses-ava/prepare-endo.js'; import { Far } from '@endo/pass-style'; import { makeDotMembraneKit } from '../src/dot-membrane.js'; -test('test dot-membrane basics', t => { +test('test dot-membrane basics', async t => { /** @type {any} */ let blueState; - const blueSetState = Far('blueSetState', newState => { + const blueSetState = Far('blueSetState', async (newState, blueInP) => { blueState = newState; + await blueInP; + return Far('blueObj', { + getBlueState() { + return blueState; + }, + }); }); - const { proxy: yellowSetState, revoke } = makeDotMembraneKit(blueSetState); + const { yellowProxy: yellowSetState, yellowRevoke } = + makeDotMembraneKit(blueSetState); + t.not(blueSetState, yellowSetState); const yellow88 = [88]; const yellow99 = [99]; - yellowSetState(yellow88); + const yellowInP = Promise.resolve('wait for it'); + const yellowObjP = yellowSetState(yellow88, yellowInP); assert(blueState); t.is(blueState[0], 88); t.not(blueState, yellow88); - revoke('Halt!'); + const yellowObj = await yellowObjP; + // eslint-disable-next-line no-underscore-dangle + const methodNames = yellowObj.__getMethodNames__(); + yellowRevoke('Halt!'); t.throws(() => yellowSetState(yellow99), { message: /Revoked: Halt!/, }); + t.is(blueState[0], 88); + t.deepEqual(methodNames, ['__getMethodNames__', 'getBlueState']); }); diff --git a/packages/marshal/test/test-replay-membrane.js b/packages/marshal/test/test-replay-membrane.js new file mode 100644 index 0000000000..c5f083693f --- /dev/null +++ b/packages/marshal/test/test-replay-membrane.js @@ -0,0 +1,90 @@ +import test from '@endo/ses-ava/prepare-endo.js'; + +// eslint-disable-next-line import/order +import { Far } from '@endo/pass-style'; +import { makeHostLogMembraneKit } from '../src/host-log-membrane.js'; +import { makeReplayMembraneKit } from '../src/replay-membrane.js'; +import { equalEnough } from '../src/equalEnough.js'; + +test('test replay-membrane basics', async t => { + /** @type {any} */ + let guestState; + const guestAsyncFuncR = Far( + 'guestSetState', + async (newGuestState, guestOrchestraW) => { + guestState = newGuestState; + const guestPauseWP = guestOrchestraW.getHostPauseP(); + await guestPauseWP; + return Far('guestStateGetterR', { + getGuestState() { + return guestState; + }, + }); + }, + ); + const hostLog = []; + const { hostProxy: hostAsyncFuncW, revoke } = makeHostLogMembraneKit( + guestAsyncFuncR, + hostLog, + ); + t.not(guestAsyncFuncR, hostAsyncFuncW); + const host88 = [88]; + const host99 = [99]; + const hostPauseRP = Promise.resolve('wait for it'); + const hostOrchestraR = Far('hostOrchestra', { + getHostPauseP() { + return hostPauseRP; + }, + }); + const hostStateGetterWP = hostAsyncFuncW(host88, hostOrchestraR); + assert(guestState); + t.is(guestState[0], 88); + t.not(guestState, host88); + const hostStateGetterW = await hostStateGetterWP; + // eslint-disable-next-line no-underscore-dangle + const methodNames = hostStateGetterW.__getMethodNames__(); + const hostState = hostStateGetterW.getGuestState(); + t.is(hostState[0], 88); + revoke('Halt!'); + t.throws(() => hostAsyncFuncW(host99), { + message: /Revoked: Halt!/, + }); + t.throws(() => hostStateGetterW.getGuestState(), { + message: /Revoked: Halt!/, + }); + + t.is(guestState[0], 88); + t.deepEqual(methodNames, ['__getMethodNames__', 'getGuestState']); + + const golden1 = harden([ + ['doCall', hostAsyncFuncW, undefined, [[88], hostOrchestraR], 0], + ['checkCall', hostOrchestraR, 'getHostPauseP', [], 1], + ['doReturn', 1, hostPauseRP], + ['checkReturn', 0, hostStateGetterWP], + ['doFulfill', hostPauseRP, 'wait for it'], + ['checkFulfill', hostStateGetterWP, hostStateGetterW], + ['doCall', hostStateGetterW, '__getMethodNames__', [], 6], + ['checkReturn', 6, ['__getMethodNames__', 'getGuestState']], + ['doCall', hostStateGetterW, 'getGuestState', [], 8], + ['checkReturn', 8, [88]], + ]); + const golden2 = harden([ + ['doCall', hostAsyncFuncW, undefined, [[88], hostOrchestraR], 0], + ['checkCall', hostOrchestraR, 'getHostPauseP', [], 1], + ['doReturn', 1, hostPauseRP], + ['checkReturn', 0, hostStateGetterWP], + ['doFulfill', hostPauseRP, 'wait for it'], + // ['checkFulfill', hostStateGetterWP, hostStateGetterW], + // ['doCall', hostStateGetterW, '__getMethodNames__', [], 6], + // ['checkReturn', 6, ['__getMethodNames__', 'getGuestState']], + // ['doCall', hostStateGetterW, 'getGuestState', [], 8], + // ['checkReturn', 8, [88]], + ]); + t.deepEqual(hostLog, golden1); + t.true(equalEnough(hostLog, golden1)); + + const { hostProxy: _hostSetState2 } = await makeReplayMembraneKit( + guestAsyncFuncR, + [...golden2], + ); +});