Skip to content

Commit

Permalink
Merge pull request #3136 from Agoric/2660-gc-and-finalize
Browse files Browse the repository at this point in the history
fix(swingset,xsnap): add gcAndFinalize, tests
  • Loading branch information
warner committed May 21, 2021
2 parents e1cd645 + d4bc617 commit 6a8833b
Show file tree
Hide file tree
Showing 6 changed files with 340 additions and 2 deletions.
3 changes: 3 additions & 0 deletions packages/SwingSet/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@
"files": [
"test/**/test-*.js"
],
"nodeArguments": [
"--expose-gc"
],
"require": [
"esm"
],
Expand Down
78 changes: 78 additions & 0 deletions packages/SwingSet/src/gc.js
Original file line number Diff line number Diff line change
@@ -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);
}
96 changes: 96 additions & 0 deletions packages/SwingSet/test/test-gc.js
Original file line number Diff line number Diff line change
@@ -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');
});
10 changes: 8 additions & 2 deletions packages/xsnap/src/xsnap.c
Original file line number Diff line number Diff line change
Expand Up @@ -1111,9 +1111,15 @@ void fxRunLoop(txMachine* the)
txJob** address;
for (;;) {
while (the->promiseJobs) {
the->promiseJobs = 0;
fxRunPromiseJobs(the);
while (the->promiseJobs) {
the->promiseJobs = 0;
fxRunPromiseJobs(the);
}
// give finalizers a chance to run after the promise queue is empty
fxEndJob(the);
// if that added to the promise queue, start again
}
// at this point the promise queue is empty
c_gettimeofday(&tv, NULL);
when = ((txNumber)(tv.tv_sec) * 1000.0) + ((txNumber)(tv.tv_usec) / 1000.0);
address = (txJob**)&(the->timerJobs);
Expand Down
83 changes: 83 additions & 0 deletions packages/xsnap/test/gc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/* global gc setImmediate */

// This is a copy of a utility from packages/SwingSet/src/gc.js, which
// swingset uses to force GC in the middle of a crank. We copy it into xsnap
// to make sure xsnap's gc() and finalizer scheduling will provide what
// swingset needs.

/* 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);
}
72 changes: 72 additions & 0 deletions packages/xsnap/test/test-gc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/* global gc FinalizationRegistry WeakRef */
import test from 'ava';

import * as childProcess from 'child_process';
import * as os from 'os';
import { xsnap } from '../src/xsnap';
import { gcAndFinalize } from './gc';

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 };
}

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');
});

0 comments on commit 6a8833b

Please sign in to comment.