diff --git a/packages/captp/NEWS.md b/packages/captp/NEWS.md index 84fe77e73be..6bb069f9efd 100644 --- a/packages/captp/NEWS.md +++ b/packages/captp/NEWS.md @@ -7,3 +7,7 @@ User-visible changes in captp: Moved from https://github.com/Agoric/captp into the `packages/captp/` directory in the monorepo at https://github.com/Agoric/agoric-sdk . +## Release 1.8.0 + +* introduce TrapCaps for synchronous "kernel trap" interfaces (see the + README.md). diff --git a/packages/captp/README.md b/packages/captp/README.md index dabfda6eedf..23a8a0ef99b 100644 --- a/packages/captp/README.md +++ b/packages/captp/README.md @@ -23,3 +23,43 @@ E(getBootstrap()).method(args).then(res => console.log('got res', res)); // Tear down the CapTP connection if it fails (e.g. connection is closed). abort(Error('Connection aborted by user.')); ``` + +## Loopback + +The `makeLoopback()` function creates an async barrier between "near" and "far" +objects. This is useful for testing and isolation within the same address +space. + +## TrapCaps + +In addition to the normal CapTP facilities, this library also has the notion of +"TrapCaps", which enable a "guest" endpoint to call a "host" object (which may +resolve an answer promise at its convenience), but the guest synchronously +blocks until it receives the resolved answer. + +This is a specialized and advanced use case, not for mutually-suspicious CapTP +parties, but instead for clear "guest"/"host" relationship, such as user-space +code and synchronous devices. + +1. Supply the `trapHost` and `trapGuest` protocol implementation (such as the + one based on `SharedArrayBuffers` in `src/atomics.js`) to the host and guest + `makeCapTP` calls. +2. On the host side, use the returned `makeTrapHandler(target)` to mark a target + as synchronous-enabled. +3. On the guest side, use the returned `Trap(target)` proxy maker much like + `E(target)`, but it will return a synchronous result. `Trap` will throw an + error if `target` was not marked as a TrapHandler by the host. + +To understand how `trapHost` and `trapGuest` relate, consider the `trapHost` as +a maker of AsyncIterators which don't return any useful value. These specific +iterators are used to drive the transfer of serialized data back to the guest. + +`trapGuest` receives arguments to describe the specific trap request, including +`startTrap()` which sends data to the host to perform the actual work of the +trap. The returned (synchronous) iterator from `startTrap()` drives the async +iterator of the host until it fully transfers the trap results to the guest, and +the guest unblocks. + +The Loopback implementation provides partial support for TrapCaps, except it +cannot unwrap promises. Loopback TrapHandlers must return synchronously, or an +exception will be thrown. diff --git a/packages/captp/SETUP-DELETEME.md b/packages/captp/SETUP-DELETEME.md deleted file mode 100644 index e125c47b1a5..00000000000 --- a/packages/captp/SETUP-DELETEME.md +++ /dev/null @@ -1,43 +0,0 @@ -# Burn after reading - -This file contains instructions for setting up a project based on new-repo. - -After you have completed these instructions, you should do `git rm SETUP-DELETEME.md` - -# Cloning the new-repo repository - -Clone it as: - -``` -$ git clone https://github.com/Agoric/new-repo MyPackageName -$ cd MyPackageName -$ git remote rename origin new-repo -``` - -## Create Agoric/MyPackageName on GitHub - -After creating Agoric/MyPackageName, you should run something like: - -``` -$ git remote add origin git@github.com:Agoric/MyPackageName -$ git push -u origin master -``` - -# Setting up package.json - -The package.json is already set up with organization-prefixed details. You just need to substitute -your package name (usually dash-separated) and your repository name (usually capitalized words -concatenated): - -1. `sed -i.bak -e 's/@PACKAGE@/my-package-name/g; s/@REPO@/MyPackageName/g' package.json` -2. `rm package.json.bak` - -# Setting up README.md - -You will definitely want to edit the `README.md` file, then begin committing and pushing as usual. - -# Setting up CircleCI - -1. Go to https://circleci.com/gh/Agoric -2. Click the "Add Project" button to the left -3. Make sure that your repo has the `.circleci/config.yml` file that new-repo has diff --git a/packages/captp/jsconfig.json b/packages/captp/jsconfig.json new file mode 100644 index 00000000000..4d4c6b8dcf0 --- /dev/null +++ b/packages/captp/jsconfig.json @@ -0,0 +1,18 @@ +// This file can contain .js-specific Typescript compiler config. +{ + "compilerOptions": { + "target": "esnext", + + "noEmit": true, +/* + // The following flags are for creating .d.ts files: + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true, +*/ + "downlevelIteration": true, + "strictNullChecks": true, + "moduleResolution": "node", + }, + "include": ["src/**/*.js", "src/**/*.d.ts", "exported.js"], +} diff --git a/packages/captp/lib/captp.js b/packages/captp/lib/captp.js deleted file mode 100644 index 22a07a57d08..00000000000 --- a/packages/captp/lib/captp.js +++ /dev/null @@ -1,474 +0,0 @@ -// @ts-check - -// Your app may need to `import '@agoric/eventual-send/shim'` to get HandledPromise - -// This logic was mostly lifted from @agoric/swingset-vat liveSlots.js -// Defects in it are mfig's fault. -import { - Remotable as defaultRemotable, - Far as defaultFar, - makeMarshal as defaultMakeMarshal, - QCLASS, -} from '@agoric/marshal'; -import { E, HandledPromise } from '@agoric/eventual-send'; -import { isPromise } from '@agoric/promise-kit'; - -export { E }; - -/** - * @template T - * @typedef {import('@agoric/eventual-send').ERef} ERef - */ - -/** - * @typedef {Object} CapTPOptions the options to makeCapTP - * @property {(err: any) => void} onReject - * @property {typeof defaultRemotable} Remotable - * @property {typeof defaultFar} Far - * @property {typeof defaultMakeMarshal} makeMarshal - * @property {number} epoch - */ -/** - * Create a CapTP connection. - * - * @param {string} ourId our name for the current side - * @param {(obj: Record) => void} rawSend send a JSONable packet - * @param {any} bootstrapObj the object to export to the other side - * @param {Partial} opts options to the connection - */ -export function makeCapTP(ourId, rawSend, bootstrapObj = undefined, opts = {}) { - const { - onReject = err => console.error('CapTP', ourId, 'exception:', err), - Remotable = defaultRemotable, - makeMarshal = defaultMakeMarshal, - epoch = 0, - } = opts; - - const disconnectReason = id => - Error(`${JSON.stringify(id)} connection closed`); - - /** @type {any} */ - let unplug = false; - async function quietReject(reason = undefined, returnIt = true) { - if ((unplug === false || reason !== unplug) && reason !== undefined) { - onReject(reason); - } - if (!returnIt) { - return Promise.resolve(); - } - - // Silence the unhandled rejection warning, but don't affect - // the user's handlers. - const p = Promise.reject(reason); - p.catch(_ => {}); - return p; - } - - /** - * @param {Record} obj - */ - function send(obj) { - // Don't throw here if unplugged, just don't send. - if (unplug === false) { - rawSend(obj); - } - } - - // convertValToSlot and convertSlotToVal both perform side effects, - // populating the c-lists (imports/exports/questions/answers) upon - // marshalling/unmarshalling. As we traverse the datastructure representing - // the message, we discover what we need to import/export and send relevant - // messages across the wire. - const { serialize, unserialize } = makeMarshal( - // eslint-disable-next-line no-use-before-define - convertValToSlot, - // eslint-disable-next-line no-use-before-define - convertSlotToVal, - { - marshalName: `captp:${ourId}`, - // TODO Temporary hack. - // See https://github.com/Agoric/agoric-sdk/issues/2780 - errorIdNum: 20000, - }, - ); - - const valToSlot = new WeakMap(); // exports looked up by val - const slotToVal = new Map(); // reverse - - // Used to construct slot names for promises/non-promises. - // In this verison of CapTP we use strings for export/import slot names. - // prefixed with 'p' if promises and 'o' otherwise; - let lastPromiseID = 0; - let lastExportID = 0; - // Since we decide the numbers for questions, we use this to increment - // the question key - let lastQuestionID = 0; - - const questions = new Map(); // chosen by us - const answers = new Map(); // chosen by our peer - const imports = new Map(); // chosen by our peer - - // Called at marshalling time. Either retrieves an existing export, or if - // not yet exported, records this exported object. If a promise, sets up a - // promise listener to inform the other side when the promise is - // fulfilled/broken. - function convertValToSlot(val) { - if (!valToSlot.has(val)) { - // new export - let slot; - if (isPromise(val)) { - // This is a promise, so we're going to increment the lastPromiseId - // and use that to construct the slot name. Promise slots are prefaced - // with 'p+'. - lastPromiseID += 1; - const promiseID = lastPromiseID; - slot = `p+${promiseID}`; - // Set up promise listener to inform other side when this promise - // is fulfilled/broken - val.then( - res => - send({ - type: 'CTP_RESOLVE', - promiseID, - res: serialize(harden(res)), - }), - rej => - send({ - type: 'CTP_RESOLVE', - promiseID, - rej: serialize(harden(rej)), - }), - ); - } else { - // Since this isn't a promise, we instead increment the lastExportId - // and use that to construct the slot name. - // Non-promises are prefaced with 'o+'. - lastExportID += 1; - const exportID = lastExportID; - slot = `o+${exportID}`; - } - // Now record the export in both valToSlot and slotToVal so we can look it - // up from either the value or the slot name later. - valToSlot.set(val, slot); - slotToVal.set(slot, val); - } - // At this point, the value is guaranteed to be exported, so return the - // associated slot number. - return valToSlot.get(val); - } - - /** - * Generate a new question in the questions table and set up a new - * remote handled promise. - * - * @returns {[number, ReturnType]} - */ - function makeQuestion() { - lastQuestionID += 1; - const questionID = lastQuestionID; - // eslint-disable-next-line no-use-before-define - const pr = makeRemoteKit(questionID); - questions.set(questionID, pr); - return [questionID, pr]; - } - - // Make a remote promise for `target` (an id in the questions table) - function makeRemoteKit(target) { - // This handler is set up such that it will transform both - // attribute access and method invocation of this remote promise - // as also being questions / remote handled promises - const handler = { - get(_o, prop) { - if (unplug !== false) { - return quietReject(unplug); - } - const [questionID, pr] = makeQuestion(); - send({ - type: 'CTP_CALL', - epoch, - questionID, - target, - method: serialize(harden([prop])), - }); - return harden(pr.p); - }, - applyMethod(_o, prop, args) { - if (unplug !== false) { - return quietReject(unplug); - } - // Support: o~.[prop](...args) remote method invocation - const [questionID, pr] = makeQuestion(); - send({ - type: 'CTP_CALL', - epoch, - questionID, - target, - method: serialize(harden([prop, args])), - }); - return harden(pr.p); - }, - }; - - const pr = {}; - pr.p = new HandledPromise((res, rej, resolveWithPresence) => { - pr.rej = rej; - pr.resPres = () => resolveWithPresence(handler); - pr.res = res; - }, handler); - - // Silence the unhandled rejection warning, but don't affect - // the user's handlers. - pr.p.catch(e => quietReject(e, false)); - - return harden(pr); - } - - // Set up import - function convertSlotToVal(theirSlot, iface = undefined) { - let val; - // Invert slot direction from other side. - - // Inverted to prevent namespace collisions between slots we - // allocate and the ones the other side allocates. If we allocate - // a slot, serialize it to the other side, and they send it back to - // us, we need to reference just our own slot, not one from their - // side. - const otherDir = theirSlot[1] === '+' ? '-' : '+'; - const slot = `${theirSlot[0]}${otherDir}${theirSlot.slice(2)}`; - if (!slotToVal.has(slot)) { - // Make a new handled promise for the slot. - const pr = makeRemoteKit(slot); - if (slot[0] === 'o') { - // A new remote presence - const pres = pr.resPres(); - if (iface === undefined) { - iface = `Alleged: Presence ${ourId} ${slot}`; - } - val = Remotable(iface, undefined, pres); - } else { - // A new promise - imports.set(Number(slot.slice(2)), pr); - val = pr.p; - } - slotToVal.set(slot, val); - valToSlot.set(val, slot); - } - return slotToVal.get(slot); - } - - // Message handler used for CapTP dispatcher - const handler = { - // Remote is asking for bootstrap object - async CTP_BOOTSTRAP(obj) { - const { questionID } = obj; - const bootstrap = - typeof bootstrapObj === 'function' ? bootstrapObj(obj) : bootstrapObj; - E.when(bootstrap, bs => { - // console.log('sending bootstrap', bootstrap); - answers.set(questionID, bs); - return send({ - type: 'CTP_RETURN', - epoch, - answerID: questionID, - result: serialize(bs), - }); - }); - }, - // Remote is invoking a method or retrieving a property. - async CTP_CALL(obj) { - // questionId: Remote promise (for promise pipelining) this call is - // to fulfill - // target: Slot id of the target to be invoked. Checks against - // answers first; otherwise goes through unserializer - const { questionID, target } = obj; - const [prop, args] = unserialize(obj.method); - let val; - if (answers.has(target)) { - val = answers.get(target); - } else { - val = unserialize({ - body: JSON.stringify({ - [QCLASS]: 'slot', - index: 0, - }), - slots: [target], - }); - } - // If `args` is supplied, we're applying a method... otherwise this is - // property access - const hp = args - ? HandledPromise.applyMethod(val, prop, args) - : HandledPromise.get(val, prop); - // Answer with our handled promise - answers.set(questionID, hp); - // Set up promise resolver for this handled promise to send - // message to other vat when fulfilled/broken. - return hp - .then(res => - send({ - type: 'CTP_RETURN', - epoch, - answerID: questionID, - result: serialize(harden(res)), - }), - ) - .catch(rej => - send({ - type: 'CTP_RETURN', - epoch, - answerID: questionID, - exception: serialize(harden(rej)), - }), - ) - .catch(rej => quietReject(rej, false)); - }, - // Answer to one of our questions. - async CTP_RETURN(obj) { - const { result, exception, answerID } = obj; - if (!questions.has(answerID)) { - throw new Error( - `Got an answer to a question we have not asked. (answerID = ${answerID} )`, - ); - } - const pr = questions.get(answerID); - if ('exception' in obj) { - pr.rej(unserialize(exception)); - } else { - pr.res(unserialize(result)); - } - }, - // Resolution to an imported promise - async CTP_RESOLVE(obj) { - const { promiseID, res, rej } = obj; - if (!imports.has(promiseID)) { - throw new Error( - `Got a resolvement of a promise we have not imported. (promiseID = ${promiseID} )`, - ); - } - const pr = imports.get(promiseID); - if ('rej' in obj) { - pr.rej(unserialize(rej)); - } else { - pr.res(unserialize(res)); - } - imports.delete(promiseID); - }, - // The other side has signaled something has gone wrong. - // Pull the plug! - async CTP_DISCONNECT(obj) { - const { reason = disconnectReason(ourId) } = obj; - if (unplug === false) { - // Reject with the original reason. - quietReject(obj.reason, false); - unplug = reason; - // Deliver the object, even though we're unplugged. - rawSend(obj); - } - for (const pr of questions.values()) { - pr.rej(reason); - } - for (const pr of imports.values()) { - pr.rej(reason); - } - }, - }; - - // Get a reference to the other side's bootstrap object. - const getBootstrap = async () => { - if (unplug !== false) { - return quietReject(unplug); - } - const [questionID, pr] = makeQuestion(); - send({ - type: 'CTP_BOOTSTRAP', - epoch, - questionID, - }); - return harden(pr.p); - }; - harden(handler); - - // Return a dispatch function. - const dispatch = obj => { - try { - if (unplug !== false) { - return false; - } - const fn = handler[obj.type]; - if (fn) { - fn(obj).catch(e => quietReject(e, false)); - return true; - } - return false; - } catch (e) { - quietReject(e, false); - return false; - } - }; - - // Abort a connection. - const abort = (reason = undefined) => { - dispatch({ type: 'CTP_DISCONNECT', epoch, reason }); - }; - - return harden({ abort, dispatch, getBootstrap, serialize, unserialize }); -} - -/** - * Create an async-isolated channel to an object. - * - * @param {string} ourId - * @returns {{ makeFar(x: T): ERef, makeNear(x: T): ERef }} - */ -export function makeLoopback(ourId) { - let nextNonce = 0; - const nonceToRef = new Map(); - - // TODO use the correct Far, which is not currently in scope here. - const bootstrap = harden({ - refGetter: defaultFar('captp bootstrap', { - getRef(nonce) { - // Find the local ref for the specified nonce. - const xFar = nonceToRef.get(nonce); - nonceToRef.delete(nonce); - return xFar; - }, - }), - }); - - // Create the tunnel. - let farDispatch; - const { dispatch: nearDispatch, getBootstrap: getFarBootstrap } = makeCapTP( - `near-${ourId}`, - o => farDispatch(o), - bootstrap, - ); - const { dispatch, getBootstrap: getNearBootstrap } = makeCapTP( - `far-${ourId}`, - nearDispatch, - bootstrap, - ); - farDispatch = dispatch; - - const farGetter = E.get(getFarBootstrap()).refGetter; - const nearGetter = E.get(getNearBootstrap()).refGetter; - - /** - * @param {ERef<{ getRef(nonce: number): any }>} refGetter - */ - const makeRefMaker = refGetter => - /** - * @param {any} x - */ - async x => { - const myNonce = nextNonce; - nextNonce += 1; - nonceToRef.set(myNonce, harden(x)); - return E(refGetter).getRef(myNonce); - }; - - return { - makeFar: makeRefMaker(farGetter), - makeNear: makeRefMaker(nearGetter), - }; -} diff --git a/packages/captp/lib/index.js b/packages/captp/lib/index.js deleted file mode 100644 index 4f2376ad22d..00000000000 --- a/packages/captp/lib/index.js +++ /dev/null @@ -1,6 +0,0 @@ -import { Nat } from '@agoric/nat'; - -export * from '@agoric/marshal'; - -export * from './captp.js'; -export { Nat }; diff --git a/packages/captp/package.json b/packages/captp/package.json index 4a36ccb06dd..a53bc3a24c0 100644 --- a/packages/captp/package.json +++ b/packages/captp/package.json @@ -13,14 +13,14 @@ "author": "Michael FIG ", "homepage": "https://github.com/Agoric/agoric-sdk#readme", "license": "Apache-2.0", - "main": "lib/captp.js", - "module": "lib/captp.js", + "main": "src/index.js", + "module": "src/index.js", "directories": { - "lib": "lib", + "src": "src", "test": "test" }, "files": [ - "lib" + "src" ], "repository": { "type": "git", @@ -31,14 +31,18 @@ "test": "ava", "test:xs": "exit 0", "lint-check": "yarn lint", - "lint-fix": "eslint --fix '**/*.js'", - "lint": "eslint 'lib/*.js'" + "lint-fix": "yarn lint:eslint --fix && yarn lint:types", + "lint": "yarn lint:eslint && yarn lint:types", + "lint:eslint": "eslint '**/*.js'", + "lint:types": "tsc -p jsconfig.json" }, "devDependencies": { "@agoric/install-ses": "^0.5.20", + "@agoric/swingset-vat": "^0.18.6", "ava": "^3.12.1" }, "dependencies": { + "@agoric/assert": "^0.3.6", "@agoric/eventual-send": "^0.13.22", "@agoric/marshal": "^0.4.19", "@agoric/nat": "^4.1.0", diff --git a/packages/captp/src/atomics.js b/packages/captp/src/atomics.js new file mode 100644 index 00000000000..76e53102db1 --- /dev/null +++ b/packages/captp/src/atomics.js @@ -0,0 +1,176 @@ +// @ts-check +/* global BigUint64Array */ + +import { assert, details as X } from '@agoric/assert'; + +// This is a pathological minimum, but exercised by the unit test. +export const MIN_DATA_BUFFER_LENGTH = 1; + +// Calculate how big the transfer buffer needs to be. +export const TRANSFER_OVERHEAD_LENGTH = + BigUint64Array.BYTES_PER_ELEMENT + Int32Array.BYTES_PER_ELEMENT; +export const MIN_TRANSFER_BUFFER_LENGTH = + MIN_DATA_BUFFER_LENGTH + TRANSFER_OVERHEAD_LENGTH; + +// These are bit flags for the status element of the transfer buffer. +const STATUS_WAITING = 1; +const STATUS_FLAG_DONE = 2; +const STATUS_FLAG_REJECT = 4; + +/** + * Return a status buffer, length buffer, and data buffer backed by transferBuffer. + * + * @param {SharedArrayBuffer} transferBuffer the backing buffer + */ +const splitTransferBuffer = transferBuffer => { + assert( + transferBuffer.byteLength >= MIN_TRANSFER_BUFFER_LENGTH, + X`Transfer buffer of ${transferBuffer.byteLength} bytes is smaller than MIN_TRANSFER_BUFFER_LENGTH ${MIN_TRANSFER_BUFFER_LENGTH}`, + ); + const lenbuf = new BigUint64Array(transferBuffer, 0, 1); + + // The documentation says that this needs to be an Int32Array for use with + // Atomics.notify: + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Atomics/notify#syntax + const statusbuf = new Int32Array(transferBuffer, lenbuf.byteLength, 1); + const overheadLength = lenbuf.byteLength + statusbuf.byteLength; + assert.equal( + overheadLength, + TRANSFER_OVERHEAD_LENGTH, + X`Internal error; actual overhead ${overheadLength} of bytes is not TRANSFER_OVERHEAD_LENGTH ${TRANSFER_OVERHEAD_LENGTH}`, + ); + const databuf = new Uint8Array(transferBuffer, overheadLength); + assert( + databuf.byteLength >= MIN_DATA_BUFFER_LENGTH, + X`Transfer buffer of size ${transferBuffer.byteLength} only supports ${databuf.byteLength} data bytes; need at least ${MIN_DATA_BUFFER_LENGTH}`, + ); + return { statusbuf, lenbuf, databuf }; +}; + +/** + * Create a trapHost that can be paired with makeAtomicsTrapGuest. + * + * This host encodes the transfer buffer and returns it in consecutive slices + * when the guest iterates over it. + * + * @param {SharedArrayBuffer} transferBuffer + * @returns {TrapHost} + */ +export const makeAtomicsTrapHost = transferBuffer => { + const { statusbuf, lenbuf, databuf } = splitTransferBuffer(transferBuffer); + + const te = new TextEncoder(); + + return async function* trapHost([isReject, serialized]) { + // Get the complete encoded message buffer. + const json = JSON.stringify(serialized); + const encoded = te.encode(json); + + // Send chunks in the data transfer buffer. + let i = 0; + let done = false; + while (!done) { + // Copy the next slice of the encoded arry to the data buffer. + const subenc = encoded.subarray(i, i + databuf.length); + databuf.set(subenc); + + // Save the length of the remaining data. + const remaining = BigInt(encoded.length - i); + lenbuf[0] = remaining; + + // Calculate the next slice, and whether this is the last one. + i += subenc.length; + done = i >= encoded.length; + + // Find bitflags to represent the rejected and finished state. + const rejectFlag = isReject ? STATUS_FLAG_REJECT : 0; + const doneFlag = done ? STATUS_FLAG_DONE : 0; + + // Notify our guest for this data buffer. + + // eslint-disable-next-line no-bitwise + statusbuf[0] = rejectFlag | doneFlag; + Atomics.notify(statusbuf, 0, +Infinity); + + if (!done) { + // Wait until the next call to `it.next()`. If the guest calls + // `it.return()` or `it.throw()`, then this yield will return or throw, + // terminating the generator function early. + yield; + } + } + }; +}; + +/** + * Create a trapGuest that can be paired with makeAtomicsTrapHost. + * + * This guest iterates through the consecutive slices of the JSON-encoded data, + * then returns it. + * + * @param {SharedArrayBuffer} transferBuffer + * @returns {TrapGuest} + */ +export const makeAtomicsTrapGuest = transferBuffer => { + const { statusbuf, lenbuf, databuf } = splitTransferBuffer(transferBuffer); + + return ({ startTrap }) => { + // Start by sending the trap call to the host. + const it = startTrap(); + + /** @type {Uint8Array | undefined} */ + let encoded; + let i = 0; + let done = false; + while (!done) { + // Tell that we are ready for another buffer. + statusbuf[0] = STATUS_WAITING; + const { done: itDone } = it.next(); + assert(!itDone, X`Internal error; it.next() returned done=${itDone}`); + + // Wait for the host to wake us. + Atomics.wait(statusbuf, 0, STATUS_WAITING); + + // Determine whether this is the last buffer. + // eslint-disable-next-line no-bitwise + done = (statusbuf[0] & STATUS_FLAG_DONE) !== 0; + + // Accumulate the encoded buffer. + const remaining = Number(lenbuf[0]); + const datalen = Math.min(remaining, databuf.byteLength); + if (!encoded) { + if (done) { + // Special case: we are done on first try, so we don't need to copy + // anything. + encoded = databuf.subarray(0, datalen); + break; + } + // Allocate our buffer for the remaining data. + encoded = new Uint8Array(remaining); + } + + // Copy the next buffer. + encoded.set(databuf.subarray(0, datalen), i); + i += datalen; + } + + // This throw is harmless if the host iterator has already finished, and + // if not finished, captp will correctly raise an error. + // + // TODO: It would be nice to use an error type, but captp is just too + // noisy with spurious "Temporary logging of sent error" messages. + // it.throw(assert.error(X`Trap host has not finished`)); + it.throw(null); + + // eslint-disable-next-line no-bitwise + const isReject = !!(statusbuf[0] & STATUS_FLAG_REJECT); + + // Decode the accumulated encoded buffer. + const td = new TextDecoder('utf-8'); + const json = td.decode(encoded); + + // Parse the JSON data into marshalled form. + const serialized = JSON.parse(json); + return [isReject, serialized]; + }; +}; diff --git a/packages/captp/src/captp.js b/packages/captp/src/captp.js new file mode 100644 index 00000000000..9bfd68a6cd6 --- /dev/null +++ b/packages/captp/src/captp.js @@ -0,0 +1,706 @@ +// @ts-check + +// Your app may need to `import '@agoric/eventual-send/shim'` to get HandledPromise + +// This logic was mostly lifted from @agoric/swingset-vat liveSlots.js +// Defects in it are mfig's fault. +import { Remotable, Far, makeMarshal, QCLASS } from '@agoric/marshal'; +import { E, HandledPromise } from '@agoric/eventual-send'; +import { isPromise, makePromiseKit } from '@agoric/promise-kit'; +import { assert, details as X } from '@agoric/assert'; + +import { makeTrap } from './trap.js'; + +import './types.js'; + +export { E }; + +/** + * @param {any} maybeThenable + * @returns {boolean} + */ +const isThenable = maybeThenable => + maybeThenable && typeof maybeThenable.then === 'function'; + +/** + * @typedef {Object} CapTPOptions the options to makeCapTP + * @property {(err: any) => void} onReject + * @property {number} epoch an integer tag to attach to all messages in order to + * assist in ignoring earlier defunct instance's messages + * @property {TrapGuest} trapGuest if specified, enable this CapTP (guest) to + * use Trap(target) to block while the recipient (host) resolves and + * communicates the response to the message + * @property {TrapHost} trapHost if specified, enable this CapTP (host) to serve + * objects marked with makeTrapHandler to synchronous clients (guests) + */ + +/** + * Create a CapTP connection. + * + * @param {string} ourId our name for the current side + * @param {(obj: Record) => void} rawSend send a JSONable packet + * @param {any} bootstrapObj the object to export to the other side + * @param {Partial} opts options to the connection + */ +export const makeCapTP = ( + ourId, + rawSend, + bootstrapObj = undefined, + opts = {}, +) => { + const { + onReject = err => console.error('CapTP', ourId, 'exception:', err), + epoch = 0, + trapGuest, + trapHost, + } = opts; + + // It's a hazard to have trapGuest and trapHost both enabled, as we may + // encounter deadlock. Without a lot more bookkeeping, we can't detect it for + // more general networks of CapTPs, but we are conservative for at least this + // one case. + assert( + !(trapHost && trapGuest), + X`CapTP ${ourId} can only be one of either trapGuest or trapHost`, + ); + + const disconnectReason = id => + Error(`${JSON.stringify(id)} connection closed`); + + /** @type {Map>>} */ + const trapIteratorResultP = new Map(); + /** @type {Map>} */ + const trapIterator = new Map(); + + /** @type {any} */ + let unplug = false; + const quietReject = async (reason = undefined, returnIt = true) => { + if ((unplug === false || reason !== unplug) && reason !== undefined) { + onReject(reason); + } + if (!returnIt) { + return Promise.resolve(); + } + + // Silence the unhandled rejection warning, but don't affect + // the user's handlers. + const p = Promise.reject(reason); + p.catch(_ => {}); + return p; + }; + + /** + * @param {Record} obj + */ + const send = obj => { + // Don't throw here if unplugged, just don't send. + if (unplug === false) { + rawSend(obj); + } + }; + + /** + * convertValToSlot and convertSlotToVal both perform side effects, + * populating the c-lists (imports/exports/questions/answers) upon + * marshalling/unmarshalling. As we traverse the datastructure representing + * the message, we discover what we need to import/export and send relevant + * messages across the wire. + */ + const { serialize, unserialize } = makeMarshal( + // eslint-disable-next-line no-use-before-define + convertValToSlot, + // eslint-disable-next-line no-use-before-define + convertSlotToVal, + { + marshalName: `captp:${ourId}`, + // TODO Temporary hack. + // See https://github.com/Agoric/agoric-sdk/issues/2780 + errorIdNum: 20000, + }, + ); + + /** @type {WeakMap} */ + const valToSlot = new WeakMap(); // exports looked up by val + /** @type {Map} */ + const slotToVal = new Map(); // reverse + const exportedTrapHandlers = new WeakSet(); + + // Used to construct slot names for promises/non-promises. + // In this version of CapTP we use strings for export/import slot names. + // prefixed with 'p' if promises and 'o' otherwise; + let lastPromiseID = 0; + let lastExportID = 0; + // Since we decide the ids for questions, we use this to increment the + // question key + let lastQuestionID = 0; + + /** @type {Map} */ + const questions = new Map(); // chosen by us + /** @type {Map} */ + const answers = new Map(); // chosen by our peer + /** @type {Map} */ + const imports = new Map(); // chosen by our peer + + /** + * Called at marshalling time. Either retrieves an existing export, or if + * not yet exported, records this exported object. If a promise, sets up a + * promise listener to inform the other side when the promise is + * fulfilled/broken. + * + * @type {ConvertValToSlot} + */ + function convertValToSlot(val) { + if (!valToSlot.has(val)) { + /** + * new export + * + * @type {CapTPSlot} + */ + let slot; + if (isPromise(val)) { + // This is a promise, so we're going to increment the lastPromiseId + // and use that to construct the slot name. Promise slots are prefaced + // with 'p+'. + lastPromiseID += 1; + const promiseID = lastPromiseID; + slot = `p+${promiseID}`; + // Set up promise listener to inform other side when this promise + // is fulfilled/broken + val.then( + res => + send({ + type: 'CTP_RESOLVE', + promiseID, + res: serialize(harden(res)), + }), + rej => + send({ + type: 'CTP_RESOLVE', + promiseID, + rej: serialize(harden(rej)), + }), + ); + } else { + // Since this isn't a promise, we instead increment the lastExportId and + // use that to construct the slot name. Non-promises are prefaced with + // 'o+' for normal objects, or `t+` for syncable. + lastExportID += 1; + const exportID = lastExportID; + if (exportedTrapHandlers.has(val)) { + slot = `t+${exportID}`; + } else { + slot = `o+${exportID}`; + } + } + // Now record the export in both valToSlot and slotToVal so we can look it + // up from either the value or the slot name later. + valToSlot.set(val, slot); + slotToVal.set(slot, val); + } + // At this point, the value is guaranteed to be exported, so return the + // associated slot number. + const slot = valToSlot.get(val); + assert.typeof(slot, 'string'); + return slot; + } + + /** + * Generate a new question in the questions table and set up a new + * remote handled promise. + * + * @returns {[string, ReturnType]} + */ + const makeQuestion = () => { + lastQuestionID += 1; + const questionID = `${ourId}#${lastQuestionID}`; + // eslint-disable-next-line no-use-before-define + const pr = makeRemoteKit(questionID); + questions.set(questionID, pr); + + // To fix #2846: + // We return 'p' to the handler, and the eventual resolution of 'p' will + // be used to resolve the caller's Promise, but the caller never sees 'p' + // itself. The caller got back their Promise before the handler ever got + // invoked, and thus before queueMessage was called. If that caller + // passes the Promise they received as argument or return value, we want + // it to serialize as resultVPID. And if someone passes resultVPID to + // them, we want the user-level code to get back that Promise, not 'p'. + lastPromiseID += 1; + const promiseID = lastPromiseID; + const resultVPID = `p+${promiseID}`; + valToSlot.set(pr.p, resultVPID); + slotToVal.set(resultVPID, pr.p); + + return [questionID, pr]; + }; + + // Make a remote promise for `target` (an id in the questions table) + const makeRemoteKit = target => { + // This handler is set up such that it will transform both + // attribute access and method invocation of this remote promise + // as also being questions / remote handled promises + const handler = { + get(_o, prop) { + if (unplug !== false) { + return quietReject(unplug); + } + const [questionID, pr] = makeQuestion(); + send({ + type: 'CTP_CALL', + epoch, + questionID, + target, + method: serialize(harden([prop])), + }); + return harden(pr.p); + }, + applyFunction(_o, args) { + if (unplug !== false) { + return quietReject(unplug); + } + const [questionID, pr] = makeQuestion(); + send({ + type: 'CTP_CALL', + epoch, + questionID, + target, + method: serialize(harden([null, args])), + }); + return harden(pr.p); + }, + applyMethod(_o, prop, args) { + if (unplug !== false) { + return quietReject(unplug); + } + // Support: o~.[prop](...args) remote method invocation + const [questionID, pr] = makeQuestion(); + send({ + type: 'CTP_CALL', + epoch, + questionID, + target, + method: serialize(harden([prop, args])), + }); + return harden(pr.p); + }, + }; + + const pr = {}; + pr.p = new HandledPromise((res, rej, resolveWithPresence) => { + pr.rej = rej; + pr.resPres = () => resolveWithPresence(handler); + pr.res = res; + }, handler); + + // Silence the unhandled rejection warning, but don't affect + // the user's handlers. + pr.p.catch(e => quietReject(e, false)); + + return harden(pr); + }; + + /** + * Set up import + * + * @type {ConvertSlotToVal} + */ + function convertSlotToVal(theirSlot, iface = undefined) { + let val; + // Invert slot direction from other side. + + // Inverted to prevent namespace collisions between slots we + // allocate and the ones the other side allocates. If we allocate + // a slot, serialize it to the other side, and they send it back to + // us, we need to reference just our own slot, not one from their + // side. + const otherDir = theirSlot[1] === '+' ? '-' : '+'; + const slot = `${theirSlot[0]}${otherDir}${theirSlot.slice(2)}`; + if (!slotToVal.has(slot)) { + // Make a new handled promise for the slot. + const pr = makeRemoteKit(slot); + if (slot[0] === 'o' || slot[0] === 't') { + // A new remote presence + const pres = pr.resPres(); + if (iface === undefined) { + iface = `Alleged: Presence ${ourId} ${slot}`; + } + val = Remotable(iface, undefined, pres); + } else { + // A new promise + imports.set(Number(slot.slice(2)), pr); + val = pr.p; + } + slotToVal.set(slot, val); + valToSlot.set(val, slot); + } + return slotToVal.get(slot); + } + + // Message handler used for CapTP dispatcher + const handler = { + // Remote is asking for bootstrap object + async CTP_BOOTSTRAP(obj) { + const { questionID } = obj; + const bootstrap = + typeof bootstrapObj === 'function' ? bootstrapObj(obj) : bootstrapObj; + E.when(bootstrap, bs => { + // console.log('sending bootstrap', bootstrap); + answers.set(questionID, bs); + return send({ + type: 'CTP_RETURN', + epoch, + answerID: questionID, + result: serialize(bs), + }); + }); + }, + // Remote is invoking a method or retrieving a property. + async CTP_CALL(obj) { + // questionId: Remote promise (for promise pipelining) this call is + // to fulfill + // target: Slot id of the target to be invoked. Checks against + // answers first; otherwise goes through unserializer + const { questionID, target, trap } = obj; + + const [prop, args] = unserialize(obj.method); + let val; + if (answers.has(target)) { + val = answers.get(target); + } else { + val = unserialize({ + body: JSON.stringify({ + [QCLASS]: 'slot', + index: 0, + }), + slots: [target], + }); + } + + /** @type {(isReject: boolean, value: any) => void} */ + let processResult = (isReject, value) => { + send({ + type: 'CTP_RETURN', + epoch, + answerID: questionID, + [isReject ? 'exception' : 'result']: serialize(harden(value)), + }); + }; + if (trap) { + assert( + exportedTrapHandlers.has(val), + X`Refused Trap(${val}) because target was not registered with makeTrapHandler`, + ); + assert.typeof( + trapHost, + 'function', + X`CapTP cannot answer Trap(${val}) without a trapHost function`, + ); + + // We need to create a promise for the "isDone" iteration right now to + // prevent a race with the other side. + const resultPK = makePromiseKit(); + trapIteratorResultP.set(questionID, resultPK.promise); + + processResult = async (isReject, value) => { + const serialized = serialize(harden(value)); + const ait = trapHost([isReject, serialized]); + if (!ait) { + // One-shot, no async iterator. + resultPK.resolve({ done: true }); + return; + } + + // We're ready for them to drive the iterator. + trapIterator.set(questionID, ait); + resultPK.resolve({ done: false }); + }; + } + + // If `args` is supplied, we're applying a method or function... + // otherwise this is property access + let hp; + if (!args) { + hp = HandledPromise.get(val, prop); + } else if (prop === null) { + hp = HandledPromise.applyFunction(val, args); + } else { + hp = HandledPromise.applyMethod(val, prop, args); + } + + // Answer with our handled promise + answers.set(questionID, hp); + + // We let rejections bubble up to our caller, `dispatch`. + await hp + // Process this handled promise method's result when settled. + .then( + fulfilment => processResult(false, fulfilment), + reason => processResult(true, reason), + ); + }, + // Have the host serve more of the reply. + CTP_TRAP_ITERATE: async obj => { + assert(trapHost, X`CTP_TRAP_ITERATE is impossible without a trapHost`); + const { questionID, serialized } = obj; + + const resultP = trapIteratorResultP.get(questionID); + assert(resultP, X`CTP_TRAP_ITERATE did not expect ${questionID}`); + + const [method, args] = unserialize(serialized); + + const getNextResultP = async () => { + const result = await resultP; + + // Done with this trap iterator. + const cleanup = () => { + trapIterator.delete(questionID); + trapIteratorResultP.delete(questionID); + return harden({ done: true }); + }; + + // We want to ensure we clean up the iterator in case of any failure. + try { + if (!result || result.done) { + return cleanup(); + } + + const ait = trapIterator.get(questionID); + if (!ait) { + // The iterator is done, so we're done. + return cleanup(); + } + + // Drive the next iteration. + return await ait[method](...args); + } catch (e) { + cleanup(); + if (!e) { + assert.fail( + X`trapGuest expected trapHost AsyncIterator(${questionID}) to be done, but it wasn't`, + ); + } + assert.note(e, X`trapHost AsyncIterator(${questionID}) threw`); + throw e; + } + }; + + // Store the next result promise. + const nextResultP = getNextResultP(); + trapIteratorResultP.set(questionID, nextResultP); + + // Ensure that our caller handles any rejection. + await nextResultP; + }, + // Answer to one of our questions. + async CTP_RETURN(obj) { + const { result, exception, answerID } = obj; + if (!questions.has(answerID)) { + throw new Error( + `Got an answer to a question we have not asked. (answerID = ${answerID} )`, + ); + } + const pr = questions.get(answerID); + if ('exception' in obj) { + pr.rej(unserialize(exception)); + } else { + pr.res(unserialize(result)); + } + }, + // Resolution to an imported promise + async CTP_RESOLVE(obj) { + const { promiseID, res, rej } = obj; + if (!imports.has(promiseID)) { + throw new Error( + `Got a resolvement of a promise we have not imported. (promiseID = ${promiseID} )`, + ); + } + const pr = imports.get(promiseID); + if ('rej' in obj) { + pr.rej(unserialize(rej)); + } else { + pr.res(unserialize(res)); + } + imports.delete(promiseID); + }, + // The other side has signaled something has gone wrong. + // Pull the plug! + async CTP_DISCONNECT(obj) { + const { reason = disconnectReason(ourId) } = obj; + if (unplug === false) { + // Reject with the original reason. + quietReject(obj.reason, false); + unplug = reason; + // Deliver the object, even though we're unplugged. + rawSend(obj); + } + for (const pr of questions.values()) { + pr.rej(reason); + } + for (const pr of imports.values()) { + pr.rej(reason); + } + }, + }; + + // Get a reference to the other side's bootstrap object. + const getBootstrap = async () => { + if (unplug !== false) { + return quietReject(unplug); + } + const [questionID, pr] = makeQuestion(); + send({ + type: 'CTP_BOOTSTRAP', + epoch, + questionID, + }); + return harden(pr.p); + }; + harden(handler); + + // Return a dispatch function. + const dispatch = obj => { + try { + if (unplug !== false) { + return false; + } + const fn = handler[obj.type]; + if (fn) { + fn(obj).catch(e => quietReject(e, false)); + return true; + } + return false; + } catch (e) { + quietReject(e, false); + return false; + } + }; + + // Abort a connection. + const abort = (reason = undefined) => { + dispatch({ type: 'CTP_DISCONNECT', epoch, reason }); + }; + + const makeTrapHandler = (name, obj) => { + const far = Far(name, obj); + exportedTrapHandlers.add(far); + return far; + }; + + // Put together our return value. + const rets = { + abort, + dispatch, + getBootstrap, + serialize, + unserialize, + makeTrapHandler, + Trap: /** @type {Trap | undefined} */ (undefined), + }; + + if (trapGuest) { + assert.typeof(trapGuest, 'function', X`opts.trapGuest must be a function`); + + // Create the Trap proxy maker. + const makeTrapImpl = implMethod => (target, ...implArgs) => { + assert( + Promise.resolve(target) !== target, + X`Trap(${target}) target cannot be a promise`, + ); + + const slot = valToSlot.get(target); + assert( + slot && slot[1] === '-', + X`Trap(${target}) target was not imported`, + ); + assert( + slot[0] === 't', + X`Trap(${target}) imported target was not created with makeTrapHandler`, + ); + + // Send a "trap" message. + lastQuestionID += 1; + const questionID = `${ourId}#${lastQuestionID}`; + + // Encode the "method" parameter of the CTP_CALL. + let method; + switch (implMethod) { + case 'get': { + const [prop] = implArgs; + method = serialize(harden([prop])); + break; + } + case 'applyFunction': { + const [args] = implArgs; + method = serialize(harden([null, args])); + break; + } + case 'applyMethod': { + const [prop, args] = implArgs; + method = serialize(harden([prop, args])); + break; + } + default: { + assert.fail(X`Internal error; unrecognized implMethod ${implMethod}`); + } + } + + // Set up the trap call with its identifying information and a way to send + // messages over the current CapTP data channel. + const [isException, serialized] = trapGuest({ + trapMethod: implMethod, + slot, + trapArgs: implArgs, + startTrap: () => { + // Send the call metadata over the connection. + send({ + type: 'CTP_CALL', + epoch, + trap: true, // This is the magic marker. + questionID, + target: slot, + method, + }); + + // Return an IterationObserver. + const makeIteratorMethod = (iteratorMethod, done) => (...args) => { + send({ + type: 'CTP_TRAP_ITERATE', + epoch, + questionID, + serialized: serialize(harden([iteratorMethod, args])), + }); + return harden({ done, value: undefined }); + }; + return harden({ + next: makeIteratorMethod('next', false), + return: makeIteratorMethod('return', true), + throw: makeIteratorMethod('throw', true), + }); + }, + }); + + const value = unserialize(serialized); + assert( + !isThenable(value), + X`Trap(${target}) reply cannot be a Thenable; have ${value}`, + ); + + if (isException) { + throw value; + } + return value; + }; + + /** @type {TrapImpl} */ + const trapImpl = { + applyFunction: makeTrapImpl('applyFunction'), + applyMethod: makeTrapImpl('applyMethod'), + get: makeTrapImpl('get'), + }; + harden(trapImpl); + + rets.Trap = makeTrap(trapImpl); + } + + return harden(rets); +}; diff --git a/packages/captp/src/index.js b/packages/captp/src/index.js new file mode 100644 index 00000000000..3eaf55e4828 --- /dev/null +++ b/packages/captp/src/index.js @@ -0,0 +1,7 @@ +export { Nat } from '@agoric/nat'; + +export * from '@agoric/marshal'; + +export * from './captp.js'; +export { makeLoopback } from './loopback.js'; +export * from './atomics.js'; diff --git a/packages/captp/src/loopback.js b/packages/captp/src/loopback.js new file mode 100644 index 00000000000..a59de6bd6c2 --- /dev/null +++ b/packages/captp/src/loopback.js @@ -0,0 +1,100 @@ +import { Far } from '@agoric/marshal'; +import { E, makeCapTP } from './captp.js'; +import { nearTrapImpl } from './trap.js'; + +export { E }; + +/** + * @template T + * @typedef {import('@agoric/eventual-send').ERef} ERef + */ + +/** + * Create an async-isolated channel to an object. + * + * @param {string} ourId + * @returns {{ + * makeFar(x: T): ERef, + * makeNear(x: T): ERef, + * makeTrapHandler(x: T): T, + * Trap: Trap + * }} + */ +export const makeLoopback = ourId => { + let nextNonce = 0; + const nonceToRef = new Map(); + + const bootstrap = harden({ + refGetter: Far('refGetter', { + getRef(nonce) { + // Find the local ref for the specified nonce. + const xFar = nonceToRef.get(nonce); + nonceToRef.delete(nonce); + return xFar; + }, + }), + }); + + const slotBody = JSON.stringify({ + '@qclass': 'slot', + index: 0, + }); + + // Create the tunnel. + const { + Trap, + dispatch: nearDispatch, + getBootstrap: getFarBootstrap, + // eslint-disable-next-line no-use-before-define + } = makeCapTP(`near-${ourId}`, o => farDispatch(o), bootstrap, { + trapGuest: ({ trapMethod, slot, trapArgs }) => { + let value; + let isException = false; + try { + // Cross the boundary to pull out the far object. + // eslint-disable-next-line no-use-before-define + const far = farUnserialize({ body: slotBody, slots: [slot] }); + value = nearTrapImpl[trapMethod](far, trapArgs[0], trapArgs[1]); + } catch (e) { + isException = true; + value = e; + } + harden(value); + // eslint-disable-next-line no-use-before-define + return [isException, farSerialize(value)]; + }, + }); + assert(Trap); + + const { + makeTrapHandler, + dispatch: farDispatch, + getBootstrap: getNearBootstrap, + unserialize: farUnserialize, + serialize: farSerialize, + } = makeCapTP(`far-${ourId}`, nearDispatch, bootstrap); + + const farGetter = E.get(getFarBootstrap()).refGetter; + const nearGetter = E.get(getNearBootstrap()).refGetter; + + /** + * @param {ERef<{ getRef(nonce: number): any }>} refGetter + */ + const makeRefMaker = refGetter => + /** + * @param {any} x + */ + async x => { + const myNonce = nextNonce; + nextNonce += 1; + nonceToRef.set(myNonce, harden(x)); + return E(refGetter).getRef(myNonce); + }; + + return { + makeFar: makeRefMaker(farGetter), + makeNear: makeRefMaker(nearGetter), + makeTrapHandler, + Trap, + }; +}; diff --git a/packages/captp/src/trap.js b/packages/captp/src/trap.js new file mode 100644 index 00000000000..850bd8149fb --- /dev/null +++ b/packages/captp/src/trap.js @@ -0,0 +1,88 @@ +// @ts-check +// Lifted mostly from `@agoric/eventual-send/src/E.js`. + +import './types'; + +/** + * Default implementation of Trap for near objects. + * + * @type {TrapImpl} + */ +export const nearTrapImpl = harden({ + applyFunction(target, args) { + return target(...args); + }, + applyMethod(target, prop, args) { + return target[prop](...args); + }, + get(target, prop) { + return target[prop]; + }, +}); + +/** @type {ProxyHandler} */ +const baseFreezableProxyHandler = { + set(_target, _prop, _value) { + return false; + }, + isExtensible(_target) { + return false; + }, + setPrototypeOf(_target, _value) { + return false; + }, + deleteProperty(_target, _prop) { + return false; + }, +}; + +/** + * A Proxy handler for Trap(x) + * + * @param {*} x Any value passed to Trap(x) + * @param {TrapImpl} trapImpl + * @returns {ProxyHandler} + */ +const TrapProxyHandler = (x, trapImpl) => { + return harden({ + ...baseFreezableProxyHandler, + get(_target, p, _receiver) { + return (...args) => trapImpl.applyMethod(x, p, args); + }, + apply(_target, _thisArg, argArray = []) { + return trapImpl.applyFunction(x, argArray); + }, + has(_target, _p) { + // TODO: has property is not yet transferrable over captp. + return true; + }, + }); +}; + +/** + * @param {TrapImpl} trapImpl + * @returns {Trap} + */ +export const makeTrap = trapImpl => { + const Trap = x => { + const handler = TrapProxyHandler(x, trapImpl); + return harden(new Proxy(() => {}, handler)); + }; + + const makeTrapGetterProxy = x => { + const handler = harden({ + ...baseFreezableProxyHandler, + has(_target, _prop) { + // TODO: has property is not yet transferrable over captp. + return true; + }, + get(_target, prop) { + return trapImpl.get(x, prop); + }, + }); + return new Proxy(Object.create(null), handler); + }; + Trap.get = makeTrapGetterProxy; + + return harden(Trap); +}; diff --git a/packages/captp/src/ts-types.d.ts b/packages/captp/src/ts-types.d.ts new file mode 100644 index 00000000000..65f366d664e --- /dev/null +++ b/packages/captp/src/ts-types.d.ts @@ -0,0 +1,60 @@ +/* eslint-disable */ +// eslint-disable-next-line spaced-comment + +import type { Unpromise } from '@agoric/eventual-send'; + +/** + * In order to type using Trap with a handler TrapHandler, this template type + * examines its parameter, and transforms any Promise function return types + * or Promise object property types into the corresponding resolved type R. + * + * That correctly describes that Trap(target)... "unpromises" any results. + */ +export type TrapHandler = T extends (...args: infer P) => infer R + ? (...args: P) => Unpromise + : T extends Record + ? { + [K in keyof T]: Unpromise; + } + : T; + +/* Types for Trap proxy calls. */ +type TrapSingleMethod = { + readonly [P in keyof T]: ( + ...args: Parameters + ) => Unpromise>; +} +type TrapSingleCall = T extends Function ? + ((...args: Parameters) => Unpromise>) & + ESingleMethod> : ESingleMethod>; +type TrapSingleGet = { + readonly [P in keyof T]: Unpromise; +} + +export interface Trap { + /** + * @template T + * + * Trap(x) returns a proxy on which you can call arbitrary methods. Each of + * these method calls will unwrap a promise result. The method will be + * invoked on a remote 'x', and be synchronous from the perspective of this + * caller. + * + * @param {T} x target for method/function call + * @returns {TrapSingleCall>} method/function call proxy + */ + (x: T): TrapSingleCall>; + + /** + * @template T + * + * Trap.get(x) returns a proxy on which you can get arbitrary properties. + * Each of these properties unwraps a promise result. The value will be the + * property fetched from a remote 'x', and be synchronous from the perspective + * of this caller. + * + * @param {T} x target for property get + * @returns {TrapSingleGet>} property get proxy + */ + readonly get(x: T): TrapSingleGet>; +} diff --git a/packages/captp/src/types.js b/packages/captp/src/types.js new file mode 100644 index 00000000000..dc73c3860ad --- /dev/null +++ b/packages/captp/src/types.js @@ -0,0 +1,57 @@ +// @ts-check + +/** @typedef {string} CapTPSlot */ + +/** + * @typedef {Object} TrapImpl + * @property {(target: any, args: Array) => any} applyFunction function + * application + * @property {( + * target: any, + * method: string | symbol | number, + * args: Array + * ) => any} applyMethod method invocation, which is an atomic lookup of method + * and apply + * @property {(target: any, prop: string | symbol | number) => any} get property + * lookup + */ + +/** + * @typedef {[boolean, CapData]} TrapCompletion The head of the pair + * is the `isRejected` value indicating whether the sync call was an exception, + * and tail of the pair is the serialized fulfillment value or rejection reason. + * (The fulfillment value is a non-thenable. The rejection reason is normally + * an error.) + */ + +/** + * @typedef TrapRequest the argument to TrapGuest + * @property {keyof TrapImpl} trapMethod the TrapImpl method that was called + * @property {CapTPSlot} slot the target slot + * @property {Array} trapArgs arguments to the TrapImpl method + * @property {() => Required>} startTrap start the + * trap process on the trapHost, and drive the other side. + */ + +/** + * @callback TrapGuest Use out-of-band communications to synchronously return a + * TrapCompletion value indicating the final results of a Trap call. + * + * @param {TrapRequest} req + * @returns {TrapCompletion} + */ + +/** + * @callback TrapHost start the process of transferring the Trap request's + * results + * @param {TrapCompletion} completion + * @returns {AsyncIterator | undefined} If an AsyncIterator is + * returned, it will satisfy a future guest IterationObserver. + */ + +/** @typedef {import('./ts-types').Trap} Trap */ + +/** + * @template T + * @typedef {import('./ts-types').TrapHandler} TrapHandler + */ diff --git a/packages/captp/test/test-crosstalk.js b/packages/captp/test/test-crosstalk.js index da7dadc28f6..324b772fced 100644 --- a/packages/captp/test/test-crosstalk.js +++ b/packages/captp/test/test-crosstalk.js @@ -1,7 +1,7 @@ import '@agoric/install-ses'; import { Far } from '@agoric/marshal'; import test from 'ava'; -import { makeLoopback, E } from '../lib/captp.js'; +import { makeLoopback, E } from '../src/loopback.js'; test('prevent crosstalk', async t => { const { makeFar } = makeLoopback('alice'); diff --git a/packages/captp/test/test-disco.js b/packages/captp/test/test-disco.js index 7753efedd46..3cb033dd0c3 100644 --- a/packages/captp/test/test-disco.js +++ b/packages/captp/test/test-disco.js @@ -1,7 +1,7 @@ import '@agoric/install-ses'; import { Far } from '@agoric/marshal'; import test from 'ava'; -import { E, makeCapTP } from '../lib/captp.js'; +import { E, makeCapTP } from '../src/captp.js'; test('try disconnecting captp', async t => { const objs = []; @@ -9,13 +9,11 @@ test('try disconnecting captp', async t => { const { getBootstrap, abort } = makeCapTP( 'us', obj => objs.push(obj), - // TODO Can we avoid the function wrapper? makeCapTP does the needed test - () => - Far('test hello', { - method() { - return 'hello'; - }, - }), + Far('test hello', { + method() { + return 'hello'; + }, + }), { onReject(e) { rejected.push(e); @@ -41,7 +39,7 @@ test('try disconnecting captp', async t => { ); t.deepEqual( objs, - [{ type: 'CTP_BOOTSTRAP', questionID: 1, epoch: 0 }], + [{ type: 'CTP_BOOTSTRAP', questionID: 'us#1', epoch: 0 }], 'expected bootstrap messages', ); ps.push( @@ -56,7 +54,7 @@ test('try disconnecting captp', async t => { t.deepEqual( objs, [ - { type: 'CTP_BOOTSTRAP', questionID: 1, epoch: 0 }, + { type: 'CTP_BOOTSTRAP', questionID: 'us#1', epoch: 0 }, { type: 'CTP_DISCONNECT', reason: undefined, epoch: 0 }, ], 'expected clean disconnect', @@ -70,13 +68,11 @@ test('try aborting captp with reason', async t => { const { getBootstrap, abort } = makeCapTP( 'us', obj => objs.push(obj), - // TODO Can we avoid the function wrapper? makeCapTP does the needed test - () => - Far('test hello', { - method() { - return 'hello'; - }, - }), + Far('test hello', { + method() { + return 'hello'; + }, + }), { onReject(e) { rejected.push(e); @@ -102,7 +98,7 @@ test('try aborting captp with reason', async t => { ); t.deepEqual( objs, - [{ type: 'CTP_BOOTSTRAP', questionID: 1, epoch: 0 }], + [{ type: 'CTP_BOOTSTRAP', questionID: 'us#1', epoch: 0 }], 'expected bootstrap messages', ); ps.push( @@ -121,7 +117,7 @@ test('try aborting captp with reason', async t => { ); t.deepEqual( objs, - [{ type: 'CTP_BOOTSTRAP', questionID: 1, epoch: 0 }, aborted], + [{ type: 'CTP_BOOTSTRAP', questionID: 'us#1', epoch: 0 }, aborted], 'expected unclean disconnect', ); await Promise.all(ps); diff --git a/packages/captp/test/test-loopback.js b/packages/captp/test/test-loopback.js index dba56d0c57d..ce1ce714d27 100644 --- a/packages/captp/test/test-loopback.js +++ b/packages/captp/test/test-loopback.js @@ -2,7 +2,7 @@ import '@agoric/install-ses'; import { Far } from '@agoric/marshal'; import test from 'ava'; -import { E, makeLoopback } from '../lib/captp.js'; +import { E, makeLoopback } from '../src/loopback.js'; test('try loopback captp', async t => { const pr = {}; diff --git a/packages/captp/test/test-trap.js b/packages/captp/test/test-trap.js new file mode 100644 index 00000000000..22238b074c9 --- /dev/null +++ b/packages/captp/test/test-trap.js @@ -0,0 +1,57 @@ +/* global __dirname */ +import { test } from '@agoric/swingset-vat/tools/prepare-test-env-ava'; + +import { Worker } from 'worker_threads'; +import { MIN_TRANSFER_BUFFER_LENGTH } from '../src/atomics.js'; + +import { E, makeLoopback } from '../src/loopback'; + +import { + createHostBootstrap, + makeGuest, + makeHost, + runTrapTests, +} from './traplib'; + +const makeWorkerTests = isHost => async t => { + const initFn = isHost ? makeHost : makeGuest; + for (let len = 0; len < MIN_TRANSFER_BUFFER_LENGTH; len += 1) { + t.throws(() => initFn(() => {}, new SharedArrayBuffer(len)), { + message: /^Transfer buffer/, + instanceOf: Error, + }); + } + + // Small shared array buffer to test iterator. + const transferBuffer = new SharedArrayBuffer(MIN_TRANSFER_BUFFER_LENGTH); + const worker = new Worker(`${__dirname}/worker.cjs`); + worker.addListener('error', err => t.fail(err)); + worker.postMessage({ type: 'TEST_INIT', transferBuffer, isGuest: isHost }); + + const { dispatch, getBootstrap, Trap } = initFn( + obj => worker.postMessage(obj), + transferBuffer, + ); + + worker.addListener('message', obj => { + // console.error('test received', obj); + dispatch(obj); + }); + + const bs = getBootstrap(); + // console.error('have bs', bs); + if (Trap) { + await runTrapTests(t, Trap, bs, true); + } else { + t.assert(await E(bs).runTrapTests(true)); + } +}; + +test('try Node.js worker trap, main host', makeWorkerTests(true)); +test('try Node.js worker trap, main guest', makeWorkerTests(false)); + +test('try restricted loopback trap', async t => { + const { makeFar, Trap, makeTrapHandler } = makeLoopback('us'); + const bs = makeFar(createHostBootstrap(makeTrapHandler)); + await runTrapTests(t, Trap, bs, false); +}); diff --git a/packages/captp/test/traplib.js b/packages/captp/test/traplib.js new file mode 100644 index 00000000000..7a653b207b4 --- /dev/null +++ b/packages/captp/test/traplib.js @@ -0,0 +1,108 @@ +// @ts-check +/* global setTimeout */ + +import { assert, details as X } from '@agoric/assert'; +import { Far } from '@agoric/marshal'; +import { E, makeCapTP } from '../src/captp.js'; + +import { makeAtomicsTrapGuest, makeAtomicsTrapHost } from '../src/atomics.js'; + +export const createHostBootstrap = makeTrapHandler => { + // Create a remotable that has a syncable return value. + return Far('test traps', { + getTraps(n) { + return makeTrapHandler('getNTraps', { + getN() { + return n; + }, + getPromise() { + return new Promise(resolve => setTimeout(() => resolve(n), 10)); + }, + }); + }, + }); +}; + +export const runTrapTests = async (t, Trap, bs, unwrapsPromises) => { + // Demonstrate async compatibility of traps. + const pn = E(E(bs).getTraps(3)).getN(); + t.is(Promise.resolve(pn), pn); + t.is(await pn, 3); + + // Demonstrate Trap cannot be used on a promise. + const ps = E(bs).getTraps(4); + t.throws(() => Trap(ps).getN(), { + instanceOf: Error, + message: /target cannot be a promise/, + }); + + // Demonstrate Trap used on a remotable. + const s = await ps; + t.is(Trap(s).getN(), 4); + + // Try Trap unwrapping of a promise. + if (unwrapsPromises) { + t.is(Trap(s).getPromise(), 4); + } else { + // If it's not supported, verify that an exception is thrown. + t.throws(() => Trap(s).getPromise(), { + instanceOf: Error, + message: /reply cannot be a Thenable/, + }); + } + + // Demonstrate Trap fails on an unmarked remotable. + const b = await bs; + t.throws(() => Trap(b).getTraps(5), { + instanceOf: Error, + message: /imported target was not created with makeTrapHandler/, + }); +}; + +const createGuestBootstrap = (Trap, other) => { + return Far('tests', { + async runTrapTests(unwrapsPromises) { + const mockT = { + is(a, b) { + assert.equal(a, b, X`${a} !== ${b}`); + }, + throws(thunk, _spec) { + let ret; + try { + ret = thunk(); + } catch (e) { + return; + } + assert.fail(X`Thunk did not throw: ${ret}`); + }, + }; + await runTrapTests(mockT, Trap, other, unwrapsPromises); + return true; + }, + }); +}; + +export const makeHost = (send, sab) => { + const { dispatch, getBootstrap, makeTrapHandler } = makeCapTP( + 'host', + send, + () => createHostBootstrap(makeTrapHandler), + { + trapHost: makeAtomicsTrapHost(sab), + }, + ); + + return { dispatch, getBootstrap }; +}; + +export const makeGuest = (send, sab) => { + const { dispatch, getBootstrap, Trap } = makeCapTP( + 'guest', + send, + () => createGuestBootstrap(Trap, getBootstrap()), + { + trapGuest: makeAtomicsTrapGuest(sab), + }, + ); + return { dispatch, getBootstrap, Trap }; +}; diff --git a/packages/captp/test/worker.cjs b/packages/captp/test/worker.cjs new file mode 100644 index 00000000000..89a75f6c49b --- /dev/null +++ b/packages/captp/test/worker.cjs @@ -0,0 +1,2 @@ +/* global require module */ +require('esm')(module)('./worker.js'); diff --git a/packages/captp/test/worker.js b/packages/captp/test/worker.js new file mode 100644 index 00000000000..bb3200a1fbb --- /dev/null +++ b/packages/captp/test/worker.js @@ -0,0 +1,25 @@ +import '@agoric/install-ses'; +import { assert, details as X } from '@agoric/assert'; + +import { parentPort } from 'worker_threads'; +import { makeGuest, makeHost } from './traplib'; + +let dispatch; +parentPort.addListener('message', obj => { + switch (obj.type) { + case 'TEST_INIT': { + assert(!dispatch, X`Internal error; duplicate initialization`); + const { transferBuffer, isGuest } = obj; + const initFn = isGuest ? makeGuest : makeHost; + const ret = initFn(o => parentPort.postMessage(o), transferBuffer); + dispatch = ret.dispatch; + break; + } + default: { + if (dispatch) { + dispatch(obj); + } + break; + } + } +}); diff --git a/packages/eventual-send/src/E.js b/packages/eventual-send/src/E.js index 2a24e2b1464..6128a8cc916 100644 --- a/packages/eventual-send/src/E.js +++ b/packages/eventual-send/src/E.js @@ -3,7 +3,8 @@ import { trackTurns } from './track-turns.js'; // eslint-disable-next-line spaced-comment /// -const readOnlyProxyHandler = { +/** @type {ProxyHandler} */ +const baseFreezableProxyHandler = { set(_target, _prop, _value) { return false; }, @@ -27,7 +28,7 @@ const readOnlyProxyHandler = { */ function EProxyHandler(x, HandledPromise) { return harden({ - ...readOnlyProxyHandler, + ...baseFreezableProxyHandler, get(_target, p, _receiver) { // Harden this Promise because it's our only opportunity to ensure // p1=E(x).foo() is hardened. The Handled Promise API does not (yet) @@ -56,7 +57,7 @@ function EProxyHandler(x, HandledPromise) { */ function EsendOnlyProxyHandler(x, HandledPromise) { return harden({ - ...readOnlyProxyHandler, + ...baseFreezableProxyHandler, get(_target, p, _receiver) { return (...args) => { HandledPromise.applyMethod(x, p, args); @@ -82,7 +83,7 @@ export default function makeE(HandledPromise) { const makeEGetterProxy = x => new Proxy(Object.create(null), { - ...readOnlyProxyHandler, + ...baseFreezableProxyHandler, has(_target, _prop) { return true; }, diff --git a/packages/marshal/src/marshal.js b/packages/marshal/src/marshal.js index 4b5309ebeaa..b2f6b0d4855 100644 --- a/packages/marshal/src/marshal.js +++ b/packages/marshal/src/marshal.js @@ -148,15 +148,7 @@ const makeRemotableProto = (oldProto, iface) => { const QCLASS = '@qclass'; export { QCLASS }; -/** - * @template Slot - * @type {ConvertValToSlot} - */ const defaultValToSlotFn = x => x; -/** - * @template Slot - * @type {ConvertSlotToVal} - */ const defaultSlotToValFn = (x, _) => x; /** diff --git a/packages/marshal/src/types.js b/packages/marshal/src/types.js index 4a64f2ae3ca..9c455c36625 100644 --- a/packages/marshal/src/types.js +++ b/packages/marshal/src/types.js @@ -145,10 +145,10 @@ /** * @template Slot * @callback MakeMarshal - * @param {ConvertValToSlot=} convertValToSlot - * @param {ConvertSlotToVal=} convertSlotToVal + * @param {ConvertValToSlot=} convertValToSlot + * @param {ConvertSlotToVal=} convertSlotToVal * @param {MakeMarshalOptions=} options - * @returns {Marshal} + * @returns {Marshal} */ /** diff --git a/packages/solo/src/captp.js b/packages/solo/src/captp.js index abb775b04d0..cd260062ff0 100644 --- a/packages/solo/src/captp.js +++ b/packages/solo/src/captp.js @@ -1,7 +1,4 @@ -// Avoid importing the full captp bundle, which would carry -// in its own makeHardener, etc. -import { makeCapTP } from '@agoric/captp/lib/captp'; -import { E } from '@agoric/eventual-send'; +import { E, makeCapTP } from '@agoric/captp'; export const getCapTPHandler = ( send,