Skip to content

Instantly share code, notes, and snippets.

@kripken
Last active October 30, 2025 19:04
Show Gist options
  • Save kripken/8ad57a092d1128cc8cce201d2bca3f27 to your computer and use it in GitHub Desktop.
Save kripken/8ad57a092d1128cc8cce201d2bca3f27 to your computer and use it in GitHub Desktop.

Wasm host GC integration demo

This lets code compiled to linear memory wasm (like a C++ or Rust game engine) use the host GC instead of shipping its own, with the benefits of

  • Not shipping a GC.
  • Not needing to manage a shadow stack in userspace.
  • Other host GC benefits, like existing optimzations there, integration with system memory pressure events, etc.

Atm the API is implemented in JS to use WeakRefs and FinalizationRegistry. As we need to call out to JS for those, using WasmGC for the rest of this library does not seem optimal. If WasmGC added weak references and finalization, we could move entirely to wasm.

See gc.h for the API, gc.js for the API implementation, and test.cpp for an example.

Testing

echo "build"
~/Dev/emscripten/emcc test.cpp -I. --js-library gc.js

echo "run"
nodejs --expose_gc a.out.js

Example output

build
run
[iter 1]

new node
Node(42)
[iter 2]
unroot it to allow collection
[iter 3]
~Node(42)
[iter 4]
[iter 5]

another new node
Node(1337)
[iter 6]
link to itself (once linked, it can be collected)
[iter 7]
~Node(1337)
[iter 8]
[iter 9]

tree with 2 branches
Node(10)
Node(20)
Node(-1)
[iter 10]
[iter 11]
[iter 12]
[iter 13]
[iter 14]
[iter 15]
[iter 16]
unlink one arm, see collection
~Node(10)
[iter 17]
[iter 18]
unroot the root, see collection of it and the last branch
[iter 19]
~Node(-1)
~Node(20)
[iter 20]
[iter 21]

new node, which will never be collected
Node(99999)
[iter 22]
[iter 23]
[iter 24]
[iter 25]
[iter 26]
[iter 27]
[iter 28]
[iter 29]
[iter 30]
[iter 31]
[iter 32]
extern "C" {
//
// GC C API, just a few methods to allocate and manage GC "mirror objects."
//
// Each "mirror" object contains GC references, parallel to the ones in
// linear memory, and exists so that the host GC can trace and collect the
// object (and do so without any interaction with linear memory).
//
// Mirror objects are referred to by the numeric address of the main object.
// That is if |this| == 0x12345678 then that is the handle we use to refer to
// the mirror object.
//
// To use this API, you must:
// * Create mirror objects when you create objects you want GC'd.
// * Update links between those mirror objects - any time a link is added or
// changed, the mirror must be updated. Remember, the host will trace the
// mirrors!
// * Root objects as needed, if nothing links to them but you are still using
// them.
//
// GC data is referred to by a handle (in the same way as a GL texture is).
typedef void* gc_handle;
typedef void (*on_collect_callback)(gc_handle);
// Allocate a GC object, paired as the twin of some |self| (some normal data in
// linear memory). The GC object only stores GC references (which the normal
// object will no longer does). We are given a callback to run when it is
// collected.
//
// Allocated objects are automatically rooted until something points to them.
// This avoids them getting immediately cleaned up. If you never point anything
// to them (i.e. you never call gc_set with them as the value) then you must
// call gc_unroot if you do not want them to live forever. (We could also do
// something like frame-based cleanup, where anything not linked to in an entire
// game frame is unrooted.)
extern void gc_alloc(gc_handle self, on_collect_callback on_collect);
// Set a value on a GC object. We are given the handle to that object, the index
// at which to write, and the value to write there, which is another handle (or
// 0, in which case we write a null). For example, after doing
//
// gc_set(foo, 3, bar);
//
// then object foo will refer to object bar, from the third element in its list
// of outgoing references.
//
// |value| can be null, in which case we write a null, removing any previous
// link.
extern void gc_set(gc_handle self, size_t index, gc_handle value);
// (Note we can set links, but not read them. We could also add an API for that,
// which would allow removing the links in linear memory in theory, but which
// might be slower.)
// Mark an object as a root (uncollectable). These can be stacked, that is,
// after rooting 3 times you must unroot 3 times for the item to be collectable.
extern void gc_root(gc_handle self);
// Unmark an object as a root (making it collectable).
extern void gc_unroot(gc_handle self);
}
//
// Host GC integration library, see gc.h.
//
addToLibrary({
$gcMap: 'new Map()',
$gcWeakRefMap: 'new Map()',
$gcFinalizer: null,
gc_alloc__deps: ['$gcMap', '$gcWeakRefMap', '$gcFinalizer'],
gc_alloc: (self, onCollect) => {
if (!gcFinalizer) {
gcFinalizer = new FinalizationRegistry((held) => {
const [self, onCollect] = held;
{{{ makeDynCall('vii', 'onCollect') }}}(self);
});
}
const mirror = {
self,
onCollect,
links: [],
// Start at one root, until we are linked to.
roots: 1,
linkedTo: false,
};
gcMap.set(self, mirror);
gcWeakRefMap.set(self, new WeakRef(mirror));
gcFinalizer.register(mirror, [self, onCollect]);
},
gc_set__deps: ['gc_unroot'],
gc_set: (self, index, value) => {
// The value written may be null.
const valueMirror = gcWeakRefMap.get(value)?.deref();
gcWeakRefMap.get(self).deref().links[index] = valueMirror;
if (valueMirror && !valueMirror.linkedTo) {
valueMirror.linkedTo = true;
_gc_unroot(value);
}
},
gc_root: (self) => {
const mirror = gcWeakRefMap.get(self).deref();
mirror.roots++;
if (mirror.roots == 1) {
gcMap.set(self, mirror);
}
},
gc_unroot: (self) => {
const mirror = gcWeakRefMap.get(self).deref();
mirror.roots--;
if (mirror.roots == 0) {
gcMap.delete(self);
}
if (mirror.roots < 0) {
throw new Error('bad unroot');
}
},
});
#include <cassert>
#include <iostream>
#include <emscripten.h>
#include <emscripten/html5.h>
#include "gc.h"
// Generic GC-managed object base. It handles creation and destruction.
struct GCed {
GCed() {
// Allocate our GC "mirror", with the proper collection callback.
gc_alloc(this, (on_collect_callback)&on_collect);
}
// When the host collects the mirror object, we are notified, and run the
// proper destructor.
virtual ~GCed() {}
static void on_collect(void* self) {
delete (GCed*)self;
}
};
// Helper for the test.
static int alive = 0;
// A node with two outgoing links.
struct Node : GCed {
int data;
GCed* left;
GCed* right;
Node(int data, GCed* left_=nullptr, GCed* right_=nullptr) : data(data) {
// Setting left and right must be done with an update of the mirror.
setLeft(left_);
setRight(right_);
std::cout << "Node(" << data << ")\n";
alive++;
}
~Node() {
std::cout << "~Node(" << data << ")\n";
alive--;
}
void setLeft(GCed* value) {
// Update in linear memory and in the mirror. Left has slot 0, right 1.
left = value;
gc_set(this, 0, value);
}
void setRight(GCed* value) {
right = value;
gc_set(this, 1, value);
}
};
void one_iter() {
static int iter = 0;
std::cout << "[iter " << ++iter << "]\n";
static Node* node;
static int timeMarker;
// Test tasks. We keep trying the next task until it returns true, after which
// we move on.
static std::vector<std::function<bool ()>> tasks = {
// Test collection after unrooting.
[&]() {
std::cout << "\nnew node\n";
node = new Node(42);
return true;
},
[&]() {
std::cout << "unroot it to allow collection\n";
gc_unroot(node);
return true;
},
[&]() {
// Wait for collection.
return !alive;
},
// Test collection after linking.
[&]() {
std::cout << "\nanother new node\n";
node = new Node(1337);
return true;
},
[&]() {
std::cout << "link to itself (once linked, it can be collected)\n";
gc_set(node, 0, node);
return true;
},
[&]() {
// Wait for collection.
return !alive;
},
// Test a rooted tree.
[&]() {
std::cout << "\ntree with 2 branches\n";
auto left = new Node(10);
auto right = new Node(20);
node = new Node(-1, left, right);
timeMarker = iter;
return true;
},
[&]() {
// Wait for a while to see no collection happens.
return iter > timeMarker + 5;
},
[&]() {
std::cout << "unlink one arm, see collection\n";
node->setLeft(nullptr);
return true;
},
[&]() {
// Wait for collection of exactly one item, leaving 2.
return alive == 2;
},
[&]() {
std::cout << "unroot the root, see collection of it and the last branch\n";
gc_unroot(node);
return true;
},
[&]() {
// Wait for collection.
return !alive;
},
// Final test, after which we run forever.
[&]() {
std::cout << "\nnew node, which will never be collected\n";
node = new Node(99999);
return true;
},
[&]() {
return false;
},
};
static int nextTask = 0;
if (nextTask == tasks.size()) {
// All done.
emscripten_cancel_main_loop();
return;
}
// Run the current tasks, and increment if it said to.
if (tasks[nextTask]()) {
nextTask++;
}
// Constantly gc.
EM_ASM({ gc(); });
}
int main() {
emscripten_set_main_loop(one_iter, 1, 0);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment