From f64dac44346d50c10e6845b9f8250797e339cbb6 Mon Sep 17 00:00:00 2001 From: Dan Connolly Date: Tue, 13 Oct 2026 21:28:38 -0500 Subject: [PATCH] capsite/toybox: an OCapN counter vat; no import GC in workerd peers vats/counter.js keeps its count in the vat's storage; the test checks that it counts and that two counter vats count apart. Running it after the greeter test hung: a FinalizationRegistry callback in the vatAdmin, collecting an import, sent op:gc over the client's WebSocket, and workerd forbids I/O outside a request. The vatAdmin's and the vats' OCapN clients now run with enableImportCollection: false, so imports go when their session does. Co-Authored-By: Claude Opus 5.5 (1M context) --- toybox/supervisor.js | 9 ++++++++- toybox/test/ocapn.test.js | 11 +++++++++++ toybox/vat-runtime.js | 9 ++++++++- toybox/vats/counter.js | 10 ++++++++++ 4 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 toybox/vats/counter.js diff --git a/toybox/supervisor.js b/toybox/supervisor.js index f63cc5b..9fa6a6c 100644 --- a/toybox/supervisor.js +++ b/toybox/supervisor.js @@ -201,7 +201,14 @@ export class VatAdmin extends DurableObject { return harden({ id: id.toString(), root }); }, }); - this.ocapn = makeClient({ debugLabel: VAT_ADMIN, swissnumTable: new Map([[VAT_ADMIN, vatAdmin]]) }); + // workerd runs FinalizationRegistry callbacks outside any request, where + // sending the resulting GC message is forbidden I/O. So imports are + // never collected here; they go when their session does. + this.ocapn = makeClient({ + debugLabel: VAT_ADMIN, + swissnumTable: new Map([[VAT_ADMIN, vatAdmin]]), + enableImportCollection: false, + }); const netlayer = await this.ocapn.registerNetlayer( makeRpcNetLayer({ designator: VAT_ADMIN, openGateway: (designator) => this.gateway(designator) }), ); diff --git a/toybox/test/ocapn.test.js b/toybox/test/ocapn.test.js index 456241e..c20d2a6 100644 --- a/toybox/test/ocapn.test.js +++ b/toybox/test/ocapn.test.js @@ -66,3 +66,14 @@ test("createVat passes a remote object in; the vat's root comes back", async () assert.equal(await E(root).hello(), "hello from the vat"); assert.equal(await E(root).greetVia("vat"), "hi vat, from carol"); }); + +test("a counter vat counts; two counter vats count apart", async () => { + const src = await vatSrc("counter.js"); + const { root: a } = await E(client.vatAdmin).createVat(src); + const { root: b } = await E(client.vatAdmin).createVat(src); + assert.equal(await E(a).increment(), 1); + assert.equal(await E(a).increment(), 2); + assert.equal(await E(b).increment(), 1); + assert.equal(await E(a).read(), 2); + assert.equal(await E(b).read(), 1); +}); diff --git a/toybox/vat-runtime.js b/toybox/vat-runtime.js index 8b9c6b4..2f1231e 100644 --- a/toybox/vat-runtime.js +++ b/toybox/vat-runtime.js @@ -37,7 +37,14 @@ export class App extends DurableObject { return root; }, }); - const ocapn = makeClient({ debugLabel: vatId.slice(0, 8), swissnumTable: new Map([[VAT_SWISSNUM, bootstrap]]) }); + // workerd runs FinalizationRegistry callbacks outside any request, where + // sending the resulting GC message is forbidden I/O. So imports are + // never collected here; they go when their session does. + const ocapn = makeClient({ + debugLabel: vatId.slice(0, 8), + swissnumTable: new Map([[VAT_SWISSNUM, bootstrap]]), + enableImportCollection: false, + }); const netlayer = await ocapn.registerNetlayer( makeRpcNetLayer({ designator: vatId, openGateway: (designator) => router.gateway(designator) }), ); diff --git a/toybox/vats/counter.js b/toybox/vats/counter.js new file mode 100644 index 0000000..a37f686 --- /dev/null +++ b/toybox/vats/counter.js @@ -0,0 +1,10 @@ +/** A counter whose count lives in the vat's storage. */ +export const buildRootObject = ({ Far, storage }) => + Far("counterRoot", { + increment: () => { + const n = (storage.get("count") ?? 0) + 1; + storage.put("count", n); + return n; + }, + read: () => storage.get("count") ?? 0, + });