diff --git a/packages/SwingSet/package.json b/packages/SwingSet/package.json index 1ca8471c9feb..b81876b72c19 100644 --- a/packages/SwingSet/package.json +++ b/packages/SwingSet/package.json @@ -87,6 +87,9 @@ "files": [ "test/**/test-*.js" ], + "nodeArguments": [ + "--expose-gc" + ], "require": [ "esm" ], diff --git a/packages/SwingSet/src/gc.js b/packages/SwingSet/src/gc.js new file mode 100644 index 000000000000..d4408367235b --- /dev/null +++ b/packages/SwingSet/src/gc.js @@ -0,0 +1,78 @@ +/* global gc 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. After this point, + * 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 is run 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 async function gcAndFinalize() { + if (typeof gc !== 'function') { + console.log(`unable to gc(), skipping`); + 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); + gc(); + // this gives finalizers a chance to run + await new Promise(setImmediate); +} diff --git a/packages/SwingSet/test/test-gc.js b/packages/SwingSet/test/test-gc.js new file mode 100644 index 000000000000..df500f4a927f --- /dev/null +++ b/packages/SwingSet/test/test-gc.js @@ -0,0 +1,96 @@ +/* global gc FinalizationRegistry WeakRef */ +// eslint-disable-next-line import/order +import { test } from '../tools/prepare-test-env-ava'; + +import * as childProcess from 'child_process'; +import * as os from 'os'; +import { xsnap } from '@agoric/xsnap'; +import { gcAndFinalize } from '../src/gc'; + +test(`have gc() on Node.js`, async t => { + t.is(typeof gc, 'function', 'environment is missing top-level gc()'); + // Under Node.js, you must use `node --expose-gc PROGRAM`. Under AVA+Node, + // add `nodeArguments: [ "--expose-gc" ]` to the package.json 'ava:' + // stanza. Under XS, make sure your application (e.g. xsnap) provides a + // `gc` C callback on the global object. +}); + +function setup() { + const victim = { doomed: 'oh no' }; + const finalized = ['finalizer not called']; + const fr = new FinalizationRegistry(_tag => { + finalized[0] = 'finalizer was called'; + }); + const wr = new WeakRef(victim); + fr.register(victim, 'tag'); + return { finalized, fr, wr }; +} + +async function provokeGC() { + // the transition from REACHABLE to UNREACHABLE happens as soon as setup() + // finishes, and the local 'victim' binding goes out of scope + + // we must retain the FinalizationRegistry to let the callback fire + // eslint-disable-next-line no-unused-vars + const { finalized, fr, wr } = setup(); + + // the transition from UNREACHABLE to COLLECTED can happen at any moment, + // but is far more likely to happen if we force it + await gcAndFinalize(); + // that also moves it from COLLECTED to FINALIZED + const wrState = wr.deref() ? 'weakref is live' : 'weakref is dead'; + const finalizerState = finalized[0]; + return { wrState, finalizerState }; +} + +let ltest = test; +if ( + typeof WeakRef !== 'function' || + typeof FinalizationRegistry !== 'function' +) { + // Node-12.x lacks both, but we can still test xsnap below + ltest = test.skip; +} + +ltest(`can provoke gc on Node.js`, async t => { + const { wrState, finalizerState } = await provokeGC(); + t.is(wrState, 'weakref is dead'); + t.is(finalizerState, 'finalizer was called'); +}); + +const xsnapOptions = { + name: 'xsnap test worker', + spawn: childProcess.spawn, + os: os.type(), + stderr: 'inherit', + stdout: 'inherit', +}; + +const decoder = new TextDecoder(); + +function options() { + const messages = []; + async function handleCommand(message) { + messages.push(decoder.decode(message)); + return new Uint8Array(); + } + return { ...xsnapOptions, handleCommand, messages }; +} + +test(`can provoke gc on xsnap`, async t => { + const opts = options(); + const vat = xsnap(opts); + const code = ` +${gcAndFinalize} +${setup} +${provokeGC} +provokeGC().then(data => issueCommand(ArrayBuffer.fromString(JSON.stringify(data)))); +`; + await vat.evaluate(code); + await vat.close(); + t.truthy(opts.messages.length === 1, `xsnap didn't send response`); + const { wrState, finalizerState } = JSON.parse(opts.messages[0]); + // console.log([wrState, finalizerState]); + t.is(wrState, 'weakref is dead'); + t.is(finalizerState, 'finalizer was called'); +});