Skip to content

Commit

Permalink
feat: extract swingset-xs-supervisor out to a separate package
Browse files Browse the repository at this point in the history
This extracts the XS supervisor bundle out of packages/SwingSet and
into a new package whose NPM name is `@agoric/swingset-xs-supervisor`,
and lives (for now) in packages/swingset-xs-supervisor .

The bundle is created explicitly, by running `yarn build`. As with
`xsnap-lockdown`, the intention is that eah published version has a
stable bundle, with specific (hashable) contents. Importing clients
can rely upon this stability to e.g. have deterministic xsnap heap
snapshot contents even if the versions of other parts of the system
have changed.

The new README.md describes the API, the stability goals, and how we
achieve them.

Fairly soon, we'll need manage the version number of this package more
explicitly, either by moving it to a different git repository, or
changing out use of `lerna publish` to avoid spurious changes. But for
now we can leave it in place.

refs #6596
  • Loading branch information
warner committed Feb 25, 2023
1 parent d526f13 commit 580c7f0
Show file tree
Hide file tree
Showing 20 changed files with 783 additions and 27 deletions.
13 changes: 6 additions & 7 deletions packages/SwingSet/misc-tools/replay-transcript.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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`);
Expand Down
1 change: 1 addition & 0 deletions packages/SwingSet/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 3 additions & 7 deletions packages/SwingSet/src/controller/initializeSwingset.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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()
Expand All @@ -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'),
Expand All @@ -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);
Expand Down
7 changes: 2 additions & 5 deletions packages/SwingSet/test/test-xsnap-errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof spawn> } */
Expand Down
1 change: 1 addition & 0 deletions packages/agoric-cli/src/sdk-package-names.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/swingset-xs-supervisor/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
dist
56 changes: 56 additions & 0 deletions packages/swingset-xs-supervisor/README.md
Original file line number Diff line number Diff line change
@@ -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.
21 changes: 21 additions & 0 deletions packages/swingset-xs-supervisor/jsconfig.json
Original file line number Diff line number Diff line change
@@ -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",
],
}
20 changes: 20 additions & 0 deletions packages/swingset-xs-supervisor/lib/capdata.js
Original file line number Diff line number Diff line change
@@ -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<string>}
*/
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
}
1 change: 1 addition & 0 deletions packages/swingset-xs-supervisor/lib/entry.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import './supervisor-subprocess-xsnap.js';
91 changes: 91 additions & 0 deletions packages/swingset-xs-supervisor/lib/gc-and-finalize.js
Original file line number Diff line number Diff line change
@@ -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);
};
}
Loading

0 comments on commit 580c7f0

Please sign in to comment.