diff --git a/packages/SwingSet/misc-tools/replay-transcript.js b/packages/SwingSet/misc-tools/replay-transcript.js index 441a2ba72f43..aea9078b574f 100644 --- a/packages/SwingSet/misc-tools/replay-transcript.js +++ b/packages/SwingSet/misc-tools/replay-transcript.js @@ -16,7 +16,8 @@ import { file as tmpFile, tmpName } from 'tmp'; import bundleSource from '@endo/bundle-source'; import { makeMeasureSeconds } from '@agoric/internal'; import { makeSnapStore } from '@agoric/swing-store'; -import { entryPaths } from '@agoric/xsnap-lockdown/src/paths.js'; +import { entryPaths as lockdownEntryPaths } from '@agoric/xsnap-lockdown/src/paths.js'; +import { entryPaths as supervisorEntryPaths } from '@agoric/swingset-xs-supervisor/src/paths.js'; import { waitUntilQuiescent } from '../src/lib-nodejs/waitUntilQuiescent.js'; import { makeStartXSnap } from '../src/controller/controller.js'; import { makeXsSubprocessFactory } from '../src/kernel/vat-loader/manager-subprocess-xsnap.js'; @@ -81,12 +82,10 @@ async function makeBundles() { // we explicitly re-bundle these entry points, rather than using // getLockdownBundle(), because if you're calling this, you're // probably editing the sources anyways - const lockdown = await bundleSource(entryPaths.lockdown, 'nestedEvaluate'); - const srcGE = rel => - bundleSource(new URL(rel, controllerUrl).pathname, 'getExport'); - const supervisor = await srcGE( - '../supervisors/subprocess-xsnap/supervisor-subprocess-xsnap.js', - ); + const lockdownPath = lockdownEntryPaths.lockdown; + const lockdown = await bundleSource(lockdownPath, 'nestedEvaluate'); + const supervisorPath = supervisorEntryPaths.supervisor; + const supervisor = await bundleSource(supervisorPath, 'nestedEvaluate'); fs.writeFileSync('lockdown-bundle', JSON.stringify(lockdown)); fs.writeFileSync('supervisor-bundle', JSON.stringify(supervisor)); console.log(`xs bundles written`); diff --git a/packages/SwingSet/package.json b/packages/SwingSet/package.json index bd2bc92d5445..8631920c7ca1 100644 --- a/packages/SwingSet/package.json +++ b/packages/SwingSet/package.json @@ -35,6 +35,7 @@ "@agoric/store": "^0.8.3", "@agoric/swing-store": "^0.8.1", "@agoric/swingset-liveslots": "^0.9.0", + "@agoric/swingset-xs-supervisor": "^0.9.0", "@agoric/time": "^0.2.1", "@agoric/vat-data": "^0.4.3", "@agoric/xsnap": "^0.13.2", diff --git a/packages/SwingSet/src/controller/initializeSwingset.js b/packages/SwingSet/src/controller/initializeSwingset.js index e6faf1d1cbba..251fc49e7bbf 100644 --- a/packages/SwingSet/src/controller/initializeSwingset.js +++ b/packages/SwingSet/src/controller/initializeSwingset.js @@ -5,6 +5,7 @@ import path from 'path'; import { resolve as resolveModuleSpecifier } from 'import-meta-resolve'; import { assert, Fail } from '@agoric/assert'; import { getLockdownBundle } from '@agoric/xsnap-lockdown'; +import { getSupervisorBundle } from '@agoric/swingset-xs-supervisor'; import bundleSource from '@endo/bundle-source'; import '../types-ambient.js'; @@ -31,10 +32,6 @@ const allValues = async obj => const bundleRelative = rel => bundleSource(new URL(rel, import.meta.url).pathname); -const bundleRelativeCallable = rel => - bundleSource(new URL(rel, import.meta.url).pathname, { - format: 'nestedEvaluate', - }); /** * Build the source bundles for the kernel. makeSwingsetController() @@ -53,6 +50,7 @@ export async function buildKernelBundle() { */ export async function buildVatAndDeviceBundles() { const lockdownP = getLockdownBundle(); // throws if bundle is not built + const supervisorP = getSupervisorBundle(); // ditto const bundles = await allValues({ adminDevice: bundleRelative('../devices/vat-admin/device-vat-admin.js'), adminVat: bundleRelative('../vats/vat-admin/vat-vat-admin.js'), @@ -61,9 +59,7 @@ export async function buildVatAndDeviceBundles() { timer: bundleRelative('../vats/timer/vat-timer.js'), lockdown: lockdownP, - supervisor: bundleRelativeCallable( - '../supervisors/subprocess-xsnap/supervisor-subprocess-xsnap.js', - ), + supervisor: supervisorP, }); return harden(bundles); diff --git a/packages/SwingSet/test/test-xsnap-errors.js b/packages/SwingSet/test/test-xsnap-errors.js index a51a3e87869d..7e7e32615f00 100644 --- a/packages/SwingSet/test/test-xsnap-errors.js +++ b/packages/SwingSet/test/test-xsnap-errors.js @@ -5,18 +5,15 @@ import { test } from '../tools/prepare-test-env-ava.js'; import { spawn } from 'child_process'; import bundleSource from '@endo/bundle-source'; import { getLockdownBundle } from '@agoric/xsnap-lockdown'; +import { getSupervisorBundle } from '@agoric/swingset-xs-supervisor'; import { makeXsSubprocessFactory } from '../src/kernel/vat-loader/manager-subprocess-xsnap.js'; import { makeStartXSnap } from '../src/controller/controller.js'; import { kser } from '../src/lib/kmarshal.js'; test('child termination distinguished from meter exhaustion', async t => { - const makeb = rel => - bundleSource(new URL(rel, import.meta.url).pathname, 'getExport'); const lockdown = await getLockdownBundle(); - const supervisor = await makeb( - '../src/supervisors/subprocess-xsnap/supervisor-subprocess-xsnap.js', - ); + const supervisor = await getSupervisorBundle(); const bundles = [lockdown, supervisor]; /** @type { ReturnType } */ diff --git a/packages/agoric-cli/src/sdk-package-names.js b/packages/agoric-cli/src/sdk-package-names.js index e71a084aa261..b2929593d999 100644 --- a/packages/agoric-cli/src/sdk-package-names.js +++ b/packages/agoric-cli/src/sdk-package-names.js @@ -31,6 +31,7 @@ export default [ "@agoric/swingset-liveslots", "@agoric/swingset-runner", "@agoric/swingset-vat", + "@agoric/swingset-xs-supervisor", "@agoric/telemetry", "@agoric/time", "@agoric/ui-components", diff --git a/packages/swingset-xs-supervisor/.gitignore b/packages/swingset-xs-supervisor/.gitignore new file mode 100644 index 000000000000..1521c8b7652b --- /dev/null +++ b/packages/swingset-xs-supervisor/.gitignore @@ -0,0 +1 @@ +dist diff --git a/packages/swingset-xs-supervisor/README.md b/packages/swingset-xs-supervisor/README.md new file mode 100644 index 000000000000..1bd2441a44fc --- /dev/null +++ b/packages/swingset-xs-supervisor/README.md @@ -0,0 +1,56 @@ +# swingset-xs-supervisor + +This package provides the bundle necessary to convert an `xsnap` SES environment into a SwingSet vat worker. + +The basic xsnap package (@agoric/xsnap) provides two components. The first is the `xsnap` executable, which embeds the XS JavaScript engine inside a driver program that listens for commands on a socket. The second is a library which can launch that program as a child process, and sends/receives messages over the socket. The `@agoric/xsnap` API lets you perform three basic operations on the worker: evaluate a string of code, deliver a string to a globally-registered handler function, and instruct the XS engine to write out a heap snapshot. + +However this is not quite sufficient for use as a vat host, which needs a SES environment which can accept vat deliveries and issue syscalls. To build something more suitable, we need additional layers: + +* We must evaluate a "lockdown" bundle within the worker, transforming it from plain (unsecured) JS to our preferred Endo environment. This tames the global constructors, adds the `Compartment` constructor, and removes ambient authority. It also creates a `console` object around the basic XS `print` function. +* Then, we need to install a "supervisor" (i.e. evaluate the supervisor bundle), which hooks into the globally-registered handler function to accept delivery messages. The supervisor includes the "liveslots" code, which constructs an object-capability environment with messages routed through syscalls, as well as virtual/durable object support (through the vat-data package). +* Finally, we'll install the actual vat bundle and run its `buildRootObject()` function. + +This package provides the "supervisor" bundle, which incorporates liveslots and the delivery/syscall management path. It also builds a special console object that sends any logged messages back to the kernel (for logging or display from the kernel's stdout, rather than the worker's). + +The bundle is generated at build time, so the contents are fixed for any given version of the `@agoric/swingset-xs-supervisor` package. The exported API includes a function which returns the supervisor bundle contents. + +## Bundle Stability + +The main purpose of this `@agoric/swingset-xs-supervisor` package is to maintain the stability of the supervisor bundle, even when the versions of other components (XS, `xsnap`, the lockdown bundle, or the kernel) might change. Deterministic execution is critical for a consensus/blockchain application, and a kernel upgrade should not accidentally cause the contents of the supervisor bundle to change. + +When the Endo source bundler is used, with `{ moduleFormat: 'endoZipBase64' }`, the generated bundles include package names and version numbers of all the source files in the transitive import graph starting from the entry point. This means the bundle contents are sensitive to version-number changes, even if the code itself remains the same. + +The supervisor bundle does not use this format: the initial `xsnap` environment does not yet have a loader for `endoZipBase64`-format bundles, so instead we use `nestedEvaluate`, which is fairly easy to evaluate into place. This format does not include as much metadata (like version numbers), nevertheless using a separate package for the bundle makes it easier to maintain stability of the contents. + +## API + +Bundles are JS Objects with a `moduleFormat` property (a string), and then a few other properties that depend on the module format. The `nestedEvaluate` format includes a `.source` property (a large string, suitable for IIFE evaluation) and sometimes a `.sourceMap` property. We can also JSON-serialize the bundle for storage in a single string, on disk or in a database. + +The primary job of this package is to provide the supervisor bundle in a form that can be delivered to the `xsnap` process for evaluation in its "Start Compartment". So the primary API is a `getSupervisorBundle()`, which returns the JS Object form. + +```js +import { getSupervisorBundle } from '@agoric/swingset-xs-supervisor'; +const bundle = await getSupervisorBundle(); + +assert.equal(bundle.moduleFormat, 'nestedEvaluate'); +await worker.evaluate(`(${bundle.source}\n)()`.trim()); +``` + +To help detect version drift or build/import problems, the package also exports the hex-encoded SHA256 hash of the JSON-stringified bundle. This should be identical to running `/usr/bin/shasum -a 256` on the pathname recorded in the internal `bundlePaths.supervisor` (typically `swingset-xs-supervisor/bundles/supervisor.bundle`). Clients which have selected a particular version of `@agoric/swingset-xs-supervisor` in their `package.json` can retrieve this hash at import time and compare it against a hard-coded copy, to assert that their preferred version is actually in use. Such clients would need to update their copy of the hash each time they deliberately switch to a different version. + +```js +import { supervisorBundleSHA256 } from '@agoric/swingset-xs-supervisor'; +const expected = '54434e4a0eb0c2883e30cc43a30ac66bb792bec3b4975bb147cb8f25c2c6365a'; +assert.equal(supervisorBundleSHA256, expected, 'somehow got wrong version'); +``` +## Bundle Generation + +If you have a source tree, you must run `yarn build` to generate the bundles, before this package can be used. + +The files in `src/` are visible to downstream users of this package, and they implement the API described above. The files in `lib/` are used to create the bundle during `yarn build`, and are *not* directly visible to the downstream users of this package. + +This package has no direct dependencies (the `package.json` field named `dependencies` is empty). The input to the bundler comes from `devDependencies`, which are only used at build time. + +The intention is that most users of this package will get their copy from the NPM registry. + +We will figure out a different approach for "dev" development, where downstream clients want to use unstable/recent versions instead. Follow https://github.com/Agoric/agoric-sdk/issues/7056 for details. diff --git a/packages/swingset-xs-supervisor/jsconfig.json b/packages/swingset-xs-supervisor/jsconfig.json new file mode 100644 index 000000000000..46c287a4baa9 --- /dev/null +++ b/packages/swingset-xs-supervisor/jsconfig.json @@ -0,0 +1,21 @@ +// This file can contain .js-specific Typescript compiler config. +{ + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "checkJs": true, + "noEmit": true, + "downlevelIteration": true, + "strictNullChecks": true, + "noImplicitThis": true, + "moduleResolution": "node", + }, + "include": [ + "*.js", + "lib/**/*.js", + "src/**/*.d.ts", + "src/**/*.js", + "test/**/*.js", + "tools/**/*.js", + ], +} diff --git a/packages/swingset-xs-supervisor/lib/capdata.js b/packages/swingset-xs-supervisor/lib/capdata.js new file mode 100644 index 000000000000..2eeebaa25479 --- /dev/null +++ b/packages/swingset-xs-supervisor/lib/capdata.js @@ -0,0 +1,20 @@ +import { Fail } from '@agoric/assert'; + +/* eslint-disable jsdoc/require-returns-check */ +/** + * Assert function to ensure that something expected to be a capdata object + * actually is. A capdata object should have a .body property that's a string + * and a .slots property that's an array of strings. + * + * @param {any} capdata The object to be tested + * @throws {Error} if, upon inspection, the parameter does not satisfy the above + * criteria. + * @returns {asserts capdata is import('@endo/marshal').CapData} + */ +export function insistCapData(capdata) { + typeof capdata.body === 'string' || + Fail`capdata has non-string .body ${capdata.body}`; + Array.isArray(capdata.slots) || + Fail`capdata has non-Array slots ${capdata.slots}`; + // TODO check that the .slots array elements are actually strings +} diff --git a/packages/swingset-xs-supervisor/lib/entry.js b/packages/swingset-xs-supervisor/lib/entry.js new file mode 100644 index 000000000000..aa4128b79fc8 --- /dev/null +++ b/packages/swingset-xs-supervisor/lib/entry.js @@ -0,0 +1 @@ +import './supervisor-subprocess-xsnap.js'; diff --git a/packages/swingset-xs-supervisor/lib/gc-and-finalize.js b/packages/swingset-xs-supervisor/lib/gc-and-finalize.js new file mode 100644 index 000000000000..0c4f72d0c2b0 --- /dev/null +++ b/packages/swingset-xs-supervisor/lib/gc-and-finalize.js @@ -0,0 +1,91 @@ +/* global setImmediate */ + +/* A note on our GC terminology: + * + * We define four states for any JS object to be in: + * + * REACHABLE: There exists a path from some root (live export or top-level + * global) to this object, making it ineligible for collection. Userspace vat + * code has a strong reference to it (and userspace is not given access to + * WeakRef, so it has no weak reference that might be used to get access). + * + * UNREACHABLE: There is no strong reference from a root to the object. + * Userspace vat code has no means to access the object, although liveslots + * might (via a WeakRef). The object is eligible for collection, but that + * collection has not yet happened. The liveslots WeakRef is still alive: if + * it were to call `.deref()`, it would get the object. + * + * COLLECTED: The JS engine has performed enough GC to notice the + * unreachability of the object, and has collected it. The liveslots WeakRef + * is dead: `wr.deref() === undefined`. Neither liveslots nor userspace has + * any way to reach the object, and never will again. A finalizer callback + * has been queued, but not yet executed. + * + * FINALIZED: The JS engine has run the finalizer callback. Once the + * callback completes, the object is thoroughly dead and unremembered, + * and no longer exists in one of these four states. + * + * The transition from REACHABLE to UNREACHABLE always happens as a result of + * a message delivery or resolution notification (e.g when userspace + * overwrites a variable, deletes a Map entry, or a callback on the promise + * queue which closed over some objects is retired and deleted). + * + * The transition from UNREACHABLE to COLLECTED can happen spontaneously, as + * the JS engine decides it wants to perform GC. It will also happen + * deliberately if we provoke a GC call with a magic function like `gc()` + * (when Node.js imports `engine-gc`, which is morally-equivalent to + * running with `--expose-gc`, or when XS is configured to provide it as a + * C-level callback). We can force GC, but we cannot prevent it from happening + * at other times. + * + * FinalizationRegistry callbacks are defined to run on their own turn, so + * the transition from COLLECTED to FINALIZED occurs at a turn boundary. + * Node.js appears to schedule these finalizers on the timer/IO queue, not + * the promise/microtask queue. So under Node.js, you need a `setImmediate()` + * or two to ensure that finalizers have had a chance to run. XS is different + * but responds well to similar techniques. + */ + +/* + * `gcAndFinalize` must be defined in the start compartment. It uses + * platform-specific features to provide a function which provokes a full GC + * operation: all "UNREACHABLE" objects should transition to "COLLECTED" + * before it returns. In addition, `gcAndFinalize()` returns a Promise. This + * Promise will resolve (with `undefined`) after all FinalizationRegistry + * callbacks have executed, causing all COLLECTED objects to transition to + * FINALIZED. If the caller can manage call gcAndFinalize with an empty + * promise queue, then their .then callback will also start with an empty + * promise queue, and there will be minimal uncollected unreachable objects + * in the heap when it begins. + * + * `gcAndFinalize` depends upon platform-specific tools to provoke a GC sweep + * and wait for finalizers to run: a `gc()` function, and `setImmediate`. If + * these tools do not exist, this function will do nothing, and return a + * dummy pre-resolved Promise. + */ + +export function makeGcAndFinalize(gcPower) { + if (typeof gcPower !== 'function') { + if (gcPower !== false) { + // We weren't explicitly disabled, so warn. + console.warn( + Error(`no gcPower() function; skipping finalizer provocation`), + ); + } + } + return async function gcAndFinalize() { + if (typeof gcPower !== 'function') { + return; + } + + // on Node.js, GC seems to work better if the promise queue is empty first + await new Promise(setImmediate); + // on xsnap, we must do it twice for some reason + await new Promise(setImmediate); + gcPower(); + // this gives finalizers a chance to run + await new Promise(setImmediate); + // Node.js seems to need another for promises to get cleared out + await new Promise(setImmediate); + }; +} diff --git a/packages/swingset-xs-supervisor/lib/message.js b/packages/swingset-xs-supervisor/lib/message.js new file mode 100644 index 000000000000..fd5fc69537cd --- /dev/null +++ b/packages/swingset-xs-supervisor/lib/message.js @@ -0,0 +1,219 @@ +// This file is only used to generate the published bundle, so +// @endo/init is in devDependencies (and this package should have no +// direct dependencies), but eslint doesn't know that, so disable the +// complaint. + +/* eslint-disable import/no-extraneous-dependencies */ + +import { assert, details as X } from '@agoric/assert'; +import { insistCapData } from './capdata.js'; + +/** + * Assert function to ensure that something expected to be a message object + * actually is. A message object should have a .method property that's a + * string, a .args property that's a capdata object, and optionally a .result + * property that, if present, must be a string. + * + * @param {any} message The object to be tested + * + * @throws {Error} if, upon inspection, the parameter does not satisfy the above + * criteria. + * + * @returns {asserts message is import('@agoric/swingset-liveslots').Message} + */ + +export function insistMessage(message) { + insistCapData(message.methargs); + if (message.result) { + assert.typeof( + message.result, + 'string', + X`message has non-string non-null .result ${message.result}`, + ); + } +} + +/** + * @param {unknown} vdo + * @returns {asserts vdo is import('@agoric/swingset-liveslots').VatDeliveryObject} + */ + +export function insistVatDeliveryObject(vdo) { + assert(Array.isArray(vdo)); + const [type, ...rest] = vdo; + switch (type) { + case 'message': { + const [target, msg] = rest; + assert.typeof(target, 'string'); + insistMessage(msg); + break; + } + case 'notify': { + const [resolutions] = rest; + assert(Array.isArray(resolutions)); + for (const [vpid, rejected, data] of resolutions) { + assert.typeof(vpid, 'string'); + assert.typeof(rejected, 'boolean'); + insistCapData(data); + } + break; + } + case 'dropExports': + case 'retireExports': + case 'retireImports': { + const [slots] = rest; + assert(Array.isArray(slots)); + for (const slot of slots) { + assert.typeof(slot, 'string'); + } + break; + } + case 'changeVatOptions': { + assert(rest.length === 1); + break; + } + case 'startVat': { + assert(rest.length === 1); + const [vatParameters] = rest; + insistCapData(vatParameters); + break; + } + case 'stopVat': { + assert(rest.length === 1); + const [disconnectObjectCapData] = rest; + insistCapData(disconnectObjectCapData); + break; + } + case 'bringOutYourDead': { + assert(rest.length === 0); + break; + } + default: + assert.fail(`unknown delivery type ${type}`); + } +} + +/** + * @param {unknown} vdr + * @returns {asserts vdr is import('@agoric/swingset-liveslots').VatDeliveryResult} + */ + +export function insistVatDeliveryResult(vdr) { + assert(Array.isArray(vdr)); + const [type, problem, _usage] = vdr; + switch (type) { + case 'ok': { + assert.equal(problem, null); + break; + } + case 'error': { + assert.typeof(problem, 'string'); + break; + } + default: + assert.fail(`unknown delivery result type ${type}`); + } +} + +/** + * + * @param {unknown} vso + * @returns {asserts vso is import('@agoric/swingset-liveslots').VatSyscallObject} + */ + +export function insistVatSyscallObject(vso) { + assert(Array.isArray(vso)); + const [type, ...rest] = vso; + switch (type) { + case 'send': { + const [target, msg] = rest; + assert.typeof(target, 'string'); + insistMessage(msg); + break; + } + case 'callNow': { + const [target, method, args] = rest; + assert.typeof(target, 'string'); + assert.typeof(method, 'string'); + insistCapData(args); + break; + } + case 'subscribe': { + const [vpid] = rest; + assert.typeof(vpid, 'string'); + break; + } + case 'resolve': { + const [resolutions] = rest; + assert(Array.isArray(resolutions)); + for (const [vpid, rejected, data] of resolutions) { + assert.typeof(vpid, 'string'); + assert.typeof(rejected, 'boolean'); + insistCapData(data); + } + break; + } + case 'exit': { + const [isFailure, info] = rest; + assert.typeof(isFailure, 'boolean'); + insistCapData(info); + break; + } + case 'vatstoreGet': { + const [key] = rest; + assert.typeof(key, 'string'); + break; + } + case 'vatstoreSet': { + const [key, data] = rest; + assert.typeof(key, 'string'); + assert.typeof(data, 'string'); + break; + } + case 'vatstoreGetNextKey': { + const [priorKey] = rest; + assert.typeof(priorKey, 'string'); + break; + } + case 'vatstoreDelete': { + const [key] = rest; + assert.typeof(key, 'string'); + break; + } + case 'dropImports': + case 'retireImports': + case 'retireExports': + case 'abandonExports': { + const [slots] = rest; + assert(Array.isArray(slots)); + for (const slot of slots) { + assert.typeof(slot, 'string'); + } + break; + } + default: + assert.fail(`unknown syscall type ${type}`); + } +} + +/** + * @param {unknown} vsr + * @returns {asserts vsr is import('@agoric/swingset-liveslots').VatSyscallResult} + */ + +export function insistVatSyscallResult(vsr) { + assert(Array.isArray(vsr)); + const [type, ...rest] = vsr; + switch (type) { + case 'ok': { + break; + } + case 'error': { + const [err] = rest; + assert.typeof(err, 'string'); + break; + } + default: + assert.fail(`unknown syscall result type ${type}`); + } +} diff --git a/packages/swingset-xs-supervisor/lib/supervisor-helper.js b/packages/swingset-xs-supervisor/lib/supervisor-helper.js new file mode 100644 index 000000000000..d3e028396263 --- /dev/null +++ b/packages/swingset-xs-supervisor/lib/supervisor-helper.js @@ -0,0 +1,173 @@ +// This file is only used to generate the published bundle, so +// @endo/init is in devDependencies (and this package should have no +// direct dependencies), but eslint doesn't know that, so disable the +// complaint. + +/* eslint-disable import/no-extraneous-dependencies */ + +import { assert } from '@agoric/assert'; +import { insistVatSyscallObject, insistVatSyscallResult } from './message.js'; + +/** + * @typedef {import('@agoric/swingset-liveslots').VatDeliveryObject} VatDeliveryObject + * @typedef {import('@agoric/swingset-liveslots').VatDeliveryResult} VatDeliveryResult + * @typedef {import('@agoric/swingset-liveslots').VatSyscallObject} VatSyscallObject + * @typedef {import('@agoric/swingset-liveslots').VatSyscaller} VatSyscaller + * @typedef {import('@endo/marshal').CapData} SwingSetCapData + * @typedef { (delivery: VatDeliveryObject) => (VatDeliveryResult | Promise) } VatDispatcherSyncAsync + * @typedef { (delivery: VatDeliveryObject) => Promise } VatDispatcher + */ + +/** + * Given the liveslots 'dispatch' function, return a version that never + * rejects. It will always return a VatDeliveryResult, even if liveslots + * throws or rejects. All supervisors should wrap the liveslots `dispatch` + * function with this one, and call it in response to messages from the + * manager process. + * + * @param {VatDispatcherSyncAsync} dispatch + * @returns {VatDispatcher} + */ +function makeSupervisorDispatch(dispatch) { + /** + * @param {VatDeliveryObject} delivery + * @returns {Promise} + */ + async function dispatchToVat(delivery) { + // the (low-level) vat is responsible for giving up agency, but we still + // protect against exceptions + return Promise.resolve(delivery) + .then(dispatch) + .then( + () => harden(['ok', null, null]), + err => { + // TODO react more thoughtfully, maybe terminate the vat + console.warn(`error during vat dispatch() of ${delivery}`, err); + return harden(['error', `${err}`, null]); + }, + ); + } + + return harden(dispatchToVat); +} +harden(makeSupervisorDispatch); +export { makeSupervisorDispatch }; + +/** + * This returns a function that is provided to liveslots as the 'syscall' + * argument: an object with one method per syscall type. These methods return + * data, or nothing. If the kernel experiences a problem executing the syscall, + * the method will throw, or the kernel will kill the vat, or both. + * + * I should be given a `syscallToManager` function that accepts a + * VatSyscallObject and (synchronously) returns a VatSyscallResult. + * + * @param {VatSyscaller} syscallToManager + * @param {boolean} workerCanBlock + * @typedef { unknown } TheSyscallObjectWithMethodsThatLiveslotsWants + * @returns {TheSyscallObjectWithMethodsThatLiveslotsWants} + */ +function makeSupervisorSyscall(syscallToManager, workerCanBlock) { + function doSyscall(fields) { + insistVatSyscallObject(fields); + /** @type { VatSyscallObject } */ + const vso = harden(fields); + let r; + try { + r = syscallToManager(vso); + } catch (err) { + console.warn(`worker got error during syscall:`, err); + throw err; + } + if (!workerCanBlock) { + // we don't expect an answer + return null; + } + const vsr = r; + insistVatSyscallResult(vsr); + const [type, ...rest] = vsr; + switch (type) { + case 'ok': { + const [data] = rest; + return data; + } + case 'error': { + const [err] = rest; + throw Error(`syscall.${fields[0]} failed: ${err}`); + } + default: + throw Error(`unknown result type ${type}`); + } + } + + // this will be given to liveslots, it should have distinct methods that + // return immediate results or throw errors + const syscallForVat = { + /** @type {(target: string, method: string, args: SwingSetCapData, result?: string) => unknown } */ + send: (target, methargs, result) => + doSyscall(['send', target, { methargs, result }]), + subscribe: vpid => doSyscall(['subscribe', vpid]), + resolve: resolutions => doSyscall(['resolve', resolutions]), + exit: (isFailure, data) => doSyscall(['exit', isFailure, data]), + dropImports: vrefs => doSyscall(['dropImports', vrefs]), + retireImports: vrefs => doSyscall(['retireImports', vrefs]), + retireExports: vrefs => doSyscall(['retireExports', vrefs]), + abandonExports: vrefs => doSyscall(['abandonExports', vrefs]), + + // These syscalls should be omitted if the worker cannot get a + // synchronous return value back from the kernel, such as when the worker + // is in a child process or thread, and cannot be blocked until the + // result gets back. vatstoreSet and vatstoreDelete are included because + // vatstoreSet requires a result, and we offer them as a group. + callNow: (target, method, args) => + doSyscall(['callNow', target, method, args]), + vatstoreGet: key => { + const result = doSyscall(['vatstoreGet', key]); + return result === null ? undefined : result; + }, + vatstoreGetNextKey: priorKey => doSyscall(['vatstoreGetNextKey', priorKey]), + vatstoreSet: (key, value) => doSyscall(['vatstoreSet', key, value]), + vatstoreDelete: key => doSyscall(['vatstoreDelete', key]), + }; + + const blocking = [ + 'callNow', + 'vatstoreGet', + 'vatstoreGetNextKey', + 'vatstoreSet', + 'vatstoreDelete', + ]; + + if (!workerCanBlock) { + for (const name of blocking) { + const err = `this non-blocking worker transport cannot syscall.${name}`; + syscallForVat[name] = () => assert.fail(err); + } + } + + return harden(syscallForVat); +} + +harden(makeSupervisorSyscall); +export { makeSupervisorSyscall }; + +/** + * Create a vat console from a log stream maker. + * + * TODO: consider other methods per SES VirtualConsole. + * See https://github.com/Agoric/agoric-sdk/issues/2146 + * + * @param {(level: string) => (...args: any[]) => void} makeLog + */ +function makeVatConsole(makeLog) { + return harden({ + debug: makeLog('debug'), + log: makeLog('log'), + info: makeLog('info'), + warn: makeLog('warn'), + error: makeLog('error'), + }); +} + +harden(makeVatConsole); +export { makeVatConsole }; diff --git a/packages/SwingSet/src/supervisors/subprocess-xsnap/supervisor-subprocess-xsnap.js b/packages/swingset-xs-supervisor/lib/supervisor-subprocess-xsnap.js similarity index 94% rename from packages/SwingSet/src/supervisors/subprocess-xsnap/supervisor-subprocess-xsnap.js rename to packages/swingset-xs-supervisor/lib/supervisor-subprocess-xsnap.js index 3fc3ecfeab3f..20517ce6cdfa 100644 --- a/packages/SwingSet/src/supervisors/subprocess-xsnap/supervisor-subprocess-xsnap.js +++ b/packages/swingset-xs-supervisor/lib/supervisor-subprocess-xsnap.js @@ -1,22 +1,26 @@ +// This file is only used to generate the published bundle, so +// @endo/init is in devDependencies (and this package should have no +// direct dependencies), but eslint doesn't know that, so disable the +// complaint. + +/* eslint-disable import/no-extraneous-dependencies */ + /* global globalThis WeakRef FinalizationRegistry */ import { assert, Fail } from '@agoric/assert'; import { importBundle } from '@endo/import-bundle'; import { makeMarshal } from '@endo/marshal'; import { makeLiveSlots } from '@agoric/swingset-liveslots'; -import '../../types-ambient.js'; +// import '../../types-ambient.js'; // grumble... waitUntilQuiescent is exported and closes over ambient authority -import { waitUntilQuiescent } from '../../lib-nodejs/waitUntilQuiescent.js'; -import { makeGcAndFinalize } from '../../lib-nodejs/gc-and-finalize.js'; -import { - insistVatDeliveryObject, - insistVatSyscallResult, -} from '../../lib/message.js'; +import { waitUntilQuiescent } from './waitUntilQuiescent.js'; +import { makeGcAndFinalize } from './gc-and-finalize.js'; +import { insistVatDeliveryObject, insistVatSyscallResult } from './message.js'; import { makeSupervisorDispatch, makeSupervisorSyscall, makeVatConsole, -} from '../supervisor-helper.js'; +} from './supervisor-helper.js'; /** * @typedef {import('@agoric/swingset-liveslots').VatDeliveryObject} VatDeliveryObject diff --git a/packages/swingset-xs-supervisor/lib/waitUntilQuiescent.js b/packages/swingset-xs-supervisor/lib/waitUntilQuiescent.js new file mode 100644 index 000000000000..f0b5794552b5 --- /dev/null +++ b/packages/swingset-xs-supervisor/lib/waitUntilQuiescent.js @@ -0,0 +1,18 @@ +/* global setImmediate */ +import { makePromiseKit } from '@endo/promise-kit'; + +// This can only be imported from the Start Compartment, where 'setImmediate' +// is available. + +export function waitUntilQuiescent() { + // the delivery might cause some number of (native) Promises to be + // created and resolved, so we use the IO queue to detect when the + // Promise queue is empty. The IO queue (setImmediate and setTimeout) is + // lower-priority than the Promise queue on browsers and Node 11, but on + // Node 10 it is higher. So this trick requires Node 11. + // https://jsblog.insiderattack.net/new-changes-to-timers-and-microtasks-from-node-v11-0-0-and-above-68d112743eb3 + /** @type {import('@endo/promise-kit').PromiseKit} */ + const { promise: queueEmptyP, resolve } = makePromiseKit(); + setImmediate(() => resolve()); + return queueEmptyP; +} diff --git a/packages/swingset-xs-supervisor/package.json b/packages/swingset-xs-supervisor/package.json new file mode 100644 index 000000000000..8e9ae054dbd5 --- /dev/null +++ b/packages/swingset-xs-supervisor/package.json @@ -0,0 +1,48 @@ +{ + "name": "@agoric/swingset-xs-supervisor", + "version": "0.9.0", + "description": "Supervisor/Liveslots bundle for swingset xsnap workers", + "author": "Agoric", + "license": "Apache-2.0", + "type": "module", + "main": "./src/index.js", + "scripts": { + "build:bundle": "node scripts/build-bundle.js", + "build": "yarn build:bundle", + "clean": "rm -rf dist", + "lint": "run-s --continue-on-error lint:*", + "lint:js": "eslint 'lib/**/*.js' 'src/**/*.js' 'scripts/**/*.js' 'test/**/*.js'", + "lint:types": "tsc -p jsconfig.json", + "lint-fix": "eslint --fix 'lib/**/*.js' 'src/**/*.js' 'scripts/**/*.js' 'test/**/*.js'", + "test": "ava", + "test:c8": "c8 $C8_OPTIONS ava --config=ava-nesm.config.js", + "test:xs": "exit 0" + }, + "dependencies": {}, + "devDependencies": { + "@agoric/assert": "^0.5.1", + "@agoric/swingset-liveslots": "^0.9.0", + "@endo/bundle-source": "^2.4.2", + "@endo/import-bundle": "^0.3.0", + "@endo/init": "^0.5.52", + "@endo/marshal": "^0.8.1", + "ava": "^5.1.0", + "c8": "^7.12.0" + }, + "files": [ + "LICENSE*", + "lib", + "dist", + "src" + ], + "publishConfig": { + "access": "public" + }, + "ava": { + "files": [ + "test/**/test-*.js" + ], + "timeout": "2m", + "workerThreads": false + } +} diff --git a/packages/swingset-xs-supervisor/scripts/build-bundle.js b/packages/swingset-xs-supervisor/scripts/build-bundle.js new file mode 100644 index 000000000000..20ca02f71aef --- /dev/null +++ b/packages/swingset-xs-supervisor/scripts/build-bundle.js @@ -0,0 +1,35 @@ +#! /usr/bin/env node +import '@endo/init'; +import path from 'path'; +import fs from 'fs'; +import crypto from 'crypto'; + +import bundleSource from '@endo/bundle-source'; +import { bundlePaths, entryPaths } from '../src/paths.js'; + +/** @param {Uint8Array} bytes */ +const computeSha256 = bytes => { + const hash = crypto.createHash('sha256'); + hash.update(bytes); + return hash.digest().toString('hex'); +}; + +const supervisorBundleSpec = bundlePaths.supervisor; +const supervisorHashFile = `${supervisorBundleSpec}.sha256.js`; +const template = ` +export const supervisorBundleSHA256 = 'HASH'; +`; + +const run = async () => { + fs.mkdirSync(path.dirname(supervisorBundleSpec), { recursive: true }); + const format = 'nestedEvaluate'; + const bundle = await bundleSource(entryPaths.supervisor, { format }); + const bundleString = JSON.stringify(bundle); + const sha256 = computeSha256(bundleString); + fs.writeFileSync(supervisorBundleSpec, bundleString); + fs.writeFileSync(supervisorHashFile, template.replace('HASH', sha256)); + console.log(`wrote ${supervisorBundleSpec}: ${bundleString.length} bytes`); + console.log(`supervisor.bundle SHA256: ${sha256}`); +}; + +run().catch(err => console.log(err)); diff --git a/packages/swingset-xs-supervisor/src/index.js b/packages/swingset-xs-supervisor/src/index.js new file mode 100644 index 000000000000..f7f7a1d49551 --- /dev/null +++ b/packages/swingset-xs-supervisor/src/index.js @@ -0,0 +1,23 @@ +import fs from 'fs'; +import { bundlePaths } from './paths.js'; + +export { supervisorBundleSHA256 } from '../dist/supervisor.bundle.sha256.js'; + +/** + * + * @typedef {{ moduleFormat: string, source: string, sourceMap: [string] }} Bundle + * @returns { Promise } + */ +export const getSupervisorBundle = async () => { + const path = bundlePaths.supervisor; + const bundleStringP = fs.promises.readFile(path, { encoding: 'utf-8' }); + return bundleStringP + .catch(err => { + console.error(`supervisor bundle not present at ${path}`); + console.error( + `perhaps run 'yarn build' in @agoric/swingset-xs-supervisor`, + ); + throw err; + }) + .then(bundleString => JSON.parse(bundleString)); +}; diff --git a/packages/swingset-xs-supervisor/src/paths.js b/packages/swingset-xs-supervisor/src/paths.js new file mode 100644 index 000000000000..bc10f09c736f --- /dev/null +++ b/packages/swingset-xs-supervisor/src/paths.js @@ -0,0 +1,10 @@ +// define these here, in one place, so both the builder and the +// client-facing API can see the same paths + +export const bundlePaths = { + supervisor: new URL('../dist/supervisor.bundle', import.meta.url).pathname, +}; + +export const entryPaths = { + supervisor: new URL('../lib/entry.js', import.meta.url).pathname, +}; diff --git a/packages/swingset-xs-supervisor/test/test-bundle.js b/packages/swingset-xs-supervisor/test/test-bundle.js new file mode 100644 index 000000000000..9a73fcbdd7c2 --- /dev/null +++ b/packages/swingset-xs-supervisor/test/test-bundle.js @@ -0,0 +1,42 @@ +import '@endo/init/debug.js'; +import test from 'ava'; +import fs from 'fs'; +import crypto from 'crypto'; + +import { getSupervisorBundle, supervisorBundleSHA256 } from '../src/index.js'; +import { bundlePaths } from '../src/paths.js'; + +test('getSupervisorBundle', async t => { + const bundle = await getSupervisorBundle(); + t.is(typeof bundle, 'object'); + t.is(bundle.moduleFormat, 'nestedEvaluate'); + t.is(typeof bundle.source, 'string'); +}); + +/** @param {string} string */ +const encode = string => new TextEncoder().encode(string); + +/** @param {Uint8Array} bytes */ +const sha256 = bytes => { + const hash = crypto.createHash('sha256'); + hash.update(bytes); + return hash.digest().toString('hex'); +}; + +test('bundle hash', async t => { + // the bundle string on disk is ready to hash + const supervisorBundleSpec = bundlePaths.supervisor; + const bundleString = fs.readFileSync(supervisorBundleSpec, { + encoding: 'utf-8', + }); + t.is(sha256(encode(bundleString)), supervisorBundleSHA256); + + // The bundle object can be JSON-stringified and then hashed. This + // serialization should be deterministic (JSON.stringify uses + // property insertion order, but so did the JSON.parse done by + // getSupervisorBundle) + + const bundle = await getSupervisorBundle(); + const bundleString2 = JSON.stringify(bundle); + t.is(sha256(encode(bundleString2)), supervisorBundleSHA256); +});