Imogen workers

The runtime machinery behind Cruft's worker-hosted compartments: how a worker is spawned from a descriptor, the three lanes a value can take across a thread boundary (SendIR clone-and-rebuild, shared SharedArrayBuffer bytes, and Tier-2 string-arena handles for interned strings), the GC contract with epoch-deferred reclamation, and how worker memory scales sub-linearly.

Node's worker_threads gives real parallelism but charges for it twice: a Worker is a separate V8 isolate, so spawning one is heavy, and everything mutable shared between workers goes through postMessage and structured-clone. The practical result is that workers stay few and chunky because they are expensive, and the serialization boundary is a cost to plan around. Imogen is Cruft's answer to both costs. This page goes beneath the API to the runtime machinery: how worker-hosted Compartments are built, how values cross thread boundaries, and how memory is shared without a shared mutable heap. It assumes Compartments and capabilities.

The problem Imogen answers

JavaScript runtimes have historically offered two worker shapes, each with a known cost:

  • Isolate-per-worker (Node worker_threads, one V8 isolate each): safe, because nothing is shared, but each worker duplicates an entire VM heap and runtime machinery, which is expensive at high worker counts.
  • One shared mutable heap across threads: cheap to share, and a data-race generator, because object identity, prototype mutation, and GC reachability all become cross-thread concerns.

Imogen is a deliberate third shape. Its rule fits in three lines:

Mutable object identity is local.
Shared bytes are explicit.
Immutable strings are shared through a handle; everything else is copied.

Isolation is kept at the realm/runtime ownership layer rather than the whole-VM layer, so many isolated workers coexist without multiplying the VM surface per tenant.

How a worker is born: the descriptor

new Compartment({ worker: true, ... }) does not move a runtime object to another thread, runtimes are thread-local by construction and never cross. Instead, the main thread sends a descriptor: a plain, Send-safe recipe carrying the initial globals, the boundary policy, the onMessageSource text, and module-loader configuration. The worker thread then builds everything locally from that recipe, its own Runtime, its own intrinsics, its own realm and globalThis.

This is why onMessageSource must be a string of source rather than a function value: a main-thread closure captures main-thread heap objects and cannot lawfully cross. The worker compiles the handler source inside its own runtime, so nothing thread-unsafe crosses the thread boundary. The API shape reflects the safety model directly.

Crossing the boundary: three lanes

Every value that moves between workers takes exactly one of three lanes, chosen by what the value is.

Lane 1: SendIR (the default, clone and rebuild)

Ordinary objects, arrays, and records are lowered to SendIR, a portable intermediate representation; the IR crosses the thread; the receiver rematerializes a fresh, equivalent object graph in its own heap:

Sending a value between workers
  1. LowerThe sender's object graph is lowered to a flat send-IR.
  2. SendThe send-IR crosses the thread boundary.
  3. RematerializeThe receiver rebuilds a fresh, equivalent object graph in its own heap.

The receiver's handler gets a Web-style event whose .data is the rebuilt value. Identity does not survive the trip, the receiver's object is a new object, which is exactly the point: no reference into another worker's mutable heap ever exists.

Values that cannot be lowered (functions, live host objects) are rejected at send time with a clear error. That rejection is a deliberate part of the safety model: a function is realm-local authority, and authority crosses only by explicit grant, never by smuggling.

Lane 2: shared bytes (SharedArrayBuffer)

A SharedArrayBuffer in a payload is the one thing that crosses by reference: the receiver's object points at the same backing memory.

const sab = new SharedArrayBuffer(8);
const main = new Int32Array(sab);
const c = new Compartment({
  worker: true,
  onMessageSource: `(e) => {
    const ta = new Int32Array(e.data);
    Atomics.add(ta, 0, 100);
  }`,
});
Atomics.store(main, 0, 5);
c.send(sab);   // worker and main now see the same 8 bytes

The lane is deliberately narrow: bytes only, accessed through typed-array views, synchronized by Atomics. Mutable sharing exists in Imogen, but only where you explicitly asked for it, and only in a representation (raw bytes) where "shared and mutable" has well-defined semantics. Do not treat it as a general object transport; if data has object shape, it belongs in lane 1 or 3.

Lane 3: the Tier-2 string arena

Strings are worth sharing rather than copying, and because a string is immutable it is safe to share without synchronization. Imogen gives them a home: the Tier-2 string arena, a shared store of interned strings addressed by handle (Tier2Handle). This is the only immutable-value lane; frozen objects and arrays are not shared, they copy through SendIR (Lane 1) like any other object.

The lane has two properties:

  • Strings are leaves. A string references nothing else, so the arena holds no graph and can never form a cycle. Plain atomic reference counting is therefore complete for Tier-2, no cycle collector needed for that lane.
  • Opaque to the ordinary GC. A worker's heap holds a Tier-2 string through an opaque external handle that the tracing collector treats as a leaf. The two memory systems couple only at the boundary: when a heap sweep reclaims a handle slot, the runtime drains it and applies the Tier-2 decrement.

The GC contract

Imogen's memory rules, stated as the contract the runtime enforces:

  1. Each runtime/realm owns and collects its ordinary mutable heap independently, a collection in one worker never stops another.
  2. No direct cross-worker references to ordinary mutable objects, ever.
  3. Tier-1 heaps hold Tier-2 data only through opaque external handles.
  4. Handle reclamation at sweep time drains into Tier-2 refcount decrements.
  5. A Tier-2 refcount hitting zero does not free immediately, the handle retires into an epoch-deferred free list and is reclaimed only after a two-epoch grace period.

Rule 5 deserves the extra sentence: a send may be in flight when the last on-heap reference drops. Freeing at zero would let the arena reclaim data while another worker is still rematerializing a handle from a message. Epoch retirement converts "zero references now" into "eligible to free once all in-flight boundary work has cleared."

What this buys: the scaling profile

Because ordinary heaps stay local (and collectible) while the shared lanes don't duplicate per worker, memory scales sub-linearly in worker count and stays bounded under sustained load, the collector runs between worker request/replies, so a busy pool converges to a plateau instead of growing. Measured on a webhook workload with 2 KB payloads:

workerssteady RSS
64~350 MB
256~420 MB
1024~580 MB

Sixteen times the workers costs well under twice the memory, because most of the cost is the fixed per-realm baseline, not per-worker VM duplication. The CRUFT_GC_HEADROOM knob (default 4.0) applies to every worker heap and trades RSS for throughput across the whole pool.

Choosing a transport: the developer rule

When deciding how a value should cross (or whether it should):

Value kindCorrect path
Ordinary object / array / recordSendIR clone and rematerialize
Function / host callbackDon't, grant an explicit bridge in the target realm
Mutable bytesSharedArrayBuffer
Immutable string worth sharingTier-2 string-arena handle
Immutable (frozen) object / arraySendIR clone (copied, not shared)
Realm-local capabilityNever crosses; grant explicitly on the other side

The classification question is always "what is this value?", and each answer has exactly one lawful lane. That one-lane-per-kind property is what makes Imogen programs data-race-free for ordinary objects by construction rather than by care.

Using workers

The usage-level API reference, spawning, message/shared-memory transports, and the relationship between node:worker_threads and Compartment({ worker: true }), is on the Workers page. node:worker_threads is functional: a Worker loaded from a module file round-trips a structured-cloned message through parentPort in both directions. Constructing a Worker from an inline source string with { eval: true } is not supported yet, so point the worker at a module file.

One current limitation to keep in mind: worker-side console.log is not surfaced to the parent process's stdout. A worker's onMessageSource handler runs, but anything it logs is not visible on the main thread — observe its results over an explicit transport (a SharedArrayBuffer synchronized with Atomics, or a parentPort message) rather than through the console.