Skip to content

Commit 635d678

Browse files
authored
Merge pull request #1205 from endojs/mfig-captp-robustness
`@endo/captp` client robustness features
2 parents b86dd8b + 86eea88 commit 635d678

11 files changed

+636
-127
lines changed

packages/captp/src/captp.js

+192-92
Large diffs are not rendered by default.

packages/captp/src/finalize.js

+130
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
/* global globalThis */
2+
import { Far, isObject } from '@endo/marshal';
3+
4+
// @ts-check
5+
const { WeakRef, FinalizationRegistry } = globalThis;
6+
7+
/**
8+
* @template K
9+
* @template {object} V
10+
* @typedef {Pick<Map<K, V>, 'get' | 'has' | 'delete'> &
11+
* {
12+
* set: (key: K, value: V) => void,
13+
* clearWithoutFinalizing: () => void,
14+
* getSize: () => number,
15+
* }} FinalizingMap
16+
*/
17+
18+
/**
19+
*
20+
* Elsewhere this is known as a "Weak Value Map". Whereas a std JS WeakMap
21+
* is weak on its keys, this map is weak on its values. It does not retain these
22+
* values strongly. If a given value disappears, then the entries for it
23+
* disappear from every weak-value-map that holds it as a value.
24+
*
25+
* Just as a WeakMap only allows gc-able values as keys, a weak-value-map
26+
* only allows gc-able values as values.
27+
*
28+
* Unlike a WeakMap, a weak-value-map unavoidably exposes the non-determinism of
29+
* gc to its clients. Thus, both the ability to create one, as well as each
30+
* created one, must be treated as dangerous capabilities that must be closely
31+
* held. A program with access to these can read side channels though gc that do
32+
* not* rely on the ability to measure duration. This is a separate, and bad,
33+
* timing-independent side channel.
34+
*
35+
* This non-determinism also enables code to escape deterministic replay. In a
36+
* blockchain context, this could cause validators to differ from each other,
37+
* preventing consensus, and thus preventing chain progress.
38+
*
39+
* JS standards weakrefs have been carefully designed so that operations which
40+
* `deref()` a weakref cause that weakref to remain stable for the remainder of
41+
* that turn. The operations below guaranteed to do this derefing are `has`,
42+
* `get`, `set`, `delete`. Note that neither `clearWithoutFinalizing` nor
43+
* `getSize` are guaranteed to deref. Thus, a call to `map.getSize()` may
44+
* reflect values that might still be collected later in the same turn.
45+
*
46+
* @template K
47+
* @template {object} V
48+
* @param {(key: K) => void} [finalizer]
49+
* @returns {FinalizingMap<K, V> &
50+
* import('@endo/eventual-send').RemotableBrand<{}, FinalizingMap<K, V>>
51+
* }
52+
*/
53+
export const makeFinalizingMap = finalizer => {
54+
if (!WeakRef || !FinalizationRegistry) {
55+
/** @type Map<K, V> */
56+
const keyToVal = new Map();
57+
return Far('fakeFinalizingMap', {
58+
clearWithoutFinalizing: keyToVal.clear.bind(keyToVal),
59+
get: keyToVal.get.bind(keyToVal),
60+
has: keyToVal.has.bind(keyToVal),
61+
set: (key, val) => {
62+
keyToVal.set(key, val);
63+
},
64+
delete: keyToVal.delete.bind(keyToVal),
65+
getSize: () => keyToVal.size,
66+
});
67+
}
68+
/** @type Map<K, WeakRef<any>> */
69+
const keyToRef = new Map();
70+
const registry = new FinalizationRegistry(key => {
71+
// Because this will delete the current binding of `key`, we need to
72+
// be sure that it is not called because a previous binding was collected.
73+
// We do this with the `unregister` in `set` below, assuming that
74+
// `unregister` *immediately* suppresses the finalization of the thing
75+
// it unregisters. TODO If this is not actually guaranteed, i.e., if
76+
// finalizations that have, say, already been scheduled might still
77+
// happen after they've been unregistered, we will need to revisit this.
78+
// eslint-disable-next-line no-use-before-define
79+
finalizingMap.delete(key);
80+
});
81+
const finalizingMap = Far('finalizingMap', {
82+
/**
83+
* `clearWithoutFinalizing` does not `deref` anything, and so does not
84+
* suppress collection of the weakly-pointed-to values until the end of the
85+
* turn. Because `clearWithoutFinalizing` immediately removes all entries
86+
* from this map, this possible collection is not observable using only this
87+
* map instance. But it is observable via other uses of WeakRef or
88+
* FinalizationGroup, including other map instances made by this
89+
* `makeFinalizingMap`.
90+
*/
91+
clearWithoutFinalizing: () => {
92+
for (const ref of keyToRef.values()) {
93+
registry.unregister(ref);
94+
}
95+
keyToRef.clear();
96+
},
97+
// Does deref, and thus does guarantee stability of the value until the
98+
// end of the turn.
99+
get: key => keyToRef.get(key)?.deref(),
100+
has: key => finalizingMap.get(key) !== undefined,
101+
// Does deref, and thus does guarantee stability of both old and new values
102+
// until the end of the turn.
103+
set: (key, ref) => {
104+
assert(isObject(ref));
105+
finalizingMap.delete(key);
106+
const newWR = new WeakRef(ref);
107+
keyToRef.set(key, newWR);
108+
registry.register(ref, key, newWR);
109+
},
110+
delete: key => {
111+
const wr = keyToRef.get(key);
112+
if (!wr) {
113+
return false;
114+
}
115+
116+
registry.unregister(wr);
117+
keyToRef.delete(key);
118+
119+
// Our semantics are to finalize upon explicit `delete`, `set` (which
120+
// calls `delete`) or garbage collection (which also calls `delete`).
121+
// `clearWithoutFinalizing` is exempt.
122+
if (finalizer) {
123+
finalizer(key);
124+
}
125+
return true;
126+
},
127+
getSize: () => keyToRef.size,
128+
});
129+
return finalizingMap;
130+
};

packages/captp/src/loopback.js

+37-20
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Far } from '@endo/marshal';
22
import { E, makeCapTP } from './captp.js';
33
import { nearTrapImpl } from './trap.js';
4+
import { makeFinalizingMap } from './finalize.js';
45

56
export { E };
67

@@ -13,26 +14,30 @@ export { E };
1314
* Create an async-isolated channel to an object.
1415
*
1516
* @param {string} ourId
17+
* @param {import('./captp.js').CapTPOptions} [nearOptions]
18+
* @param {import('./captp.js').CapTPOptions} [farOptions]
1619
* @returns {{
1720
* makeFar<T>(x: T): ERef<T>,
1821
* makeNear<T>(x: T): ERef<T>,
1922
* makeTrapHandler<T>(x: T): T,
23+
* isOnlyNear(x: any): boolean,
24+
* isOnlyFar(x: any): boolean,
25+
* getNearStats(): any,
26+
* getFarStats(): any,
2027
* Trap: Trap
2128
* }}
2229
*/
23-
export const makeLoopback = ourId => {
24-
let nextNonce = 0;
25-
const nonceToRef = new Map();
30+
export const makeLoopback = (ourId, nearOptions, farOptions) => {
31+
let lastNonce = 0;
32+
const nonceToRef = makeFinalizingMap();
2633

27-
const bootstrap = harden({
28-
refGetter: Far('refGetter', {
29-
getRef(nonce) {
30-
// Find the local ref for the specified nonce.
31-
const xFar = nonceToRef.get(nonce);
32-
nonceToRef.delete(nonce);
33-
return xFar;
34-
},
35-
}),
34+
const bootstrap = Far('refGetter', {
35+
getRef(nonce) {
36+
// Find the local ref for the specified nonce.
37+
const xFar = nonceToRef.get(nonce);
38+
nonceToRef.delete(nonce);
39+
return xFar;
40+
},
3641
});
3742

3843
const slotBody = JSON.stringify({
@@ -45,6 +50,8 @@ export const makeLoopback = ourId => {
4550
Trap,
4651
dispatch: nearDispatch,
4752
getBootstrap: getFarBootstrap,
53+
getStats: getNearStats,
54+
isOnlyLocal: isOnlyNear,
4855
// eslint-disable-next-line no-use-before-define
4956
} = makeCapTP(`near-${ourId}`, o => farDispatch(o), bootstrap, {
5057
trapGuest: ({ trapMethod, slot, trapArgs }) => {
@@ -63,38 +70,48 @@ export const makeLoopback = ourId => {
6370
// eslint-disable-next-line no-use-before-define
6471
return [isException, farSerialize(value)];
6572
},
73+
...nearOptions,
6674
});
6775
assert(Trap);
6876

6977
const {
7078
makeTrapHandler,
7179
dispatch: farDispatch,
7280
getBootstrap: getNearBootstrap,
81+
getStats: getFarStats,
82+
isOnlyLocal: isOnlyFar,
7383
unserialize: farUnserialize,
7484
serialize: farSerialize,
75-
} = makeCapTP(`far-${ourId}`, nearDispatch, bootstrap);
85+
} = makeCapTP(`far-${ourId}`, nearDispatch, bootstrap, farOptions);
7686

77-
const farGetter = E.get(getFarBootstrap()).refGetter;
78-
const nearGetter = E.get(getNearBootstrap()).refGetter;
87+
const farGetter = getFarBootstrap();
88+
const nearGetter = getNearBootstrap();
7989

8090
/**
81-
* @param {ERef<{ getRef(nonce: number): any }>} refGetter
91+
* @template T
92+
* @param {ERef<{ getRef(nonce: number): T }>} refGetter
8293
*/
8394
const makeRefMaker =
8495
refGetter =>
8596
/**
86-
* @param {any} x
97+
* @param {T} x
98+
* @returns {Promise<T>}
8799
*/
88100
async x => {
89-
const myNonce = nextNonce;
90-
nextNonce += 1;
91-
nonceToRef.set(myNonce, harden(x));
101+
lastNonce += 1;
102+
const myNonce = lastNonce;
103+
const val = await x;
104+
nonceToRef.set(myNonce, harden(val));
92105
return E(refGetter).getRef(myNonce);
93106
};
94107

95108
return {
96109
makeFar: makeRefMaker(farGetter),
97110
makeNear: makeRefMaker(nearGetter),
111+
isOnlyNear,
112+
isOnlyFar,
113+
getNearStats,
114+
getFarStats,
98115
makeTrapHandler,
99116
Trap,
100117
};

packages/captp/test/engine-gc.js

+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/* global globalThis */
2+
export const detectEngineGC = async () => {
3+
/** @type {() => void} */
4+
const globalGC = globalThis.gc;
5+
if (typeof globalGC === 'function') {
6+
return globalGC;
7+
}
8+
9+
// Node.js v8 wizardry to dynamically find the GC capability, regardless of
10+
// interpreter command line flags.
11+
const { default: vm } = await import('vm');
12+
const nodeGC = vm.runInNewContext(`typeof gc === 'function' && gc`);
13+
if (nodeGC) {
14+
return nodeGC;
15+
}
16+
17+
const { default: v8 } = await import('v8');
18+
v8.setFlagsFromString('--expose_gc');
19+
return vm.runInNewContext('gc');
20+
};
+92
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/* global setImmediate */
2+
3+
/* A note on our GC terminology:
4+
*
5+
* We define four states for any JS object to be in:
6+
*
7+
* REACHABLE: There exists a path from some root (live export or top-level
8+
* global) to this object, making it ineligible for collection. Userspace vat
9+
* code has a strong reference to it (and userspace is not given access to
10+
* WeakRef, so it has no weak reference that might be used to get access).
11+
*
12+
* UNREACHABLE: There is no strong reference from a root to the object.
13+
* Userspace vat code has no means to access the object, although liveslots
14+
* might (via a WeakRef). The object is eligible for collection, but that
15+
* collection has not yet happened. The liveslots WeakRef is still alive: if
16+
* it were to call `.deref()`, it would get the object.
17+
*
18+
* COLLECTED: The JS engine has performed enough GC to notice the
19+
* unreachability of the object, and has collected it. The liveslots WeakRef
20+
* is dead: `wr.deref() === undefined`. Neither liveslots nor userspace has
21+
* any way to reach the object, and never will again. A finalizer callback
22+
* has been queued, but not yet executed.
23+
*
24+
* FINALIZED: The JS engine has run the finalizer callback. After this point,
25+
* the object is thoroughly dead and unremembered, and no longer exists in
26+
* one of these four states.
27+
*
28+
* The transition from REACHABLE to UNREACHABLE always happens as a result of
29+
* a message delivery or resolution notification (e.g when userspace
30+
* overwrites a variable, deletes a Map entry, or a callback on the promise
31+
* queue which closed over some objects is retired and deleted).
32+
*
33+
* The transition from UNREACHABLE to COLLECTED can happen spontaneously, as
34+
* the JS engine decides it wants to perform GC. It will also happen
35+
* deliberately if we provoke a GC call with a magic function like `gc()`
36+
* (when Node.js imports `engine-gc`, which is morally-equivalent to
37+
* running with `--expose-gc`, or when XS is configured to provide it as a
38+
* C-level callback). We can force GC, but we cannot prevent it from happening
39+
* at other times.
40+
*
41+
* FinalizationRegistry callbacks are defined to run on their own turn, so
42+
* the transition from COLLECTED to FINALIZED occurs at a turn boundary.
43+
* Node.js appears to schedule these finalizers on the timer/IO queue, not
44+
* the promise/microtask queue. So under Node.js, you need a `setImmediate()`
45+
* or two to ensure that finalizers have had a chance to run. XS is different
46+
* but responds well to similar techniques.
47+
*/
48+
49+
/*
50+
* `gcAndFinalize` must be defined in the start compartment. It uses
51+
* platform-specific features to provide a function which provokes a full GC
52+
* operation: all "UNREACHABLE" objects should transition to "COLLECTED"
53+
* before it returns. In addition, `gcAndFinalize()` returns a Promise. This
54+
* Promise will resolve (with `undefined`) after all FinalizationRegistry
55+
* callbacks have executed, causing all COLLECTED objects to transition to
56+
* FINALIZED. If the caller can manage call gcAndFinalize with an empty
57+
* promise queue, then their .then callback will also start with an empty
58+
* promise queue, and there will be minimal uncollected unreachable objects
59+
* in the heap when it begins.
60+
*
61+
* `gcAndFinalize` depends upon platform-specific tools to provoke a GC sweep
62+
* and wait for finalizers to run: a `gc()` function, and `setImmediate`. If
63+
* these tools do not exist, this function will do nothing, and return a
64+
* dummy pre-resolved Promise.
65+
*/
66+
67+
export async function makeGcAndFinalize(gcPowerP) {
68+
const gcPower = await gcPowerP;
69+
if (typeof gcPower !== 'function') {
70+
if (gcPower !== false) {
71+
// We weren't explicitly disabled, so warn.
72+
console.warn(
73+
Error(`no gcPower() function; skipping finalizer provocation`),
74+
);
75+
}
76+
}
77+
return async function gcAndFinalize() {
78+
if (typeof gcPower !== 'function') {
79+
return;
80+
}
81+
82+
// on Node.js, GC seems to work better if the promise queue is empty first
83+
await new Promise(setImmediate);
84+
// on xsnap, we must do it twice for some reason
85+
await new Promise(setImmediate);
86+
gcPower();
87+
// this gives finalizers a chance to run
88+
await new Promise(setImmediate);
89+
// Node.js seems to need another for promises to get cleared out
90+
await new Promise(setImmediate);
91+
};
92+
}

0 commit comments

Comments
 (0)