Realms #
Status: active Last reviewed: 2026-10-10 Owner: theMackabu
Goal #
Several ECMAScript realms (a global object plus its intrinsics) sharing one
isolate: one heap, GC, interning, JIT and agent. This is the base for
node:vm contexts (runInNewContext, createContext, DONT_CONTEXTIFY),
where a new context sees fresh built-ins: vm.runInNewContext('Array') !== Array. Isolates (separate heaps, for worker_threads) are a different,
later piece of work.
Constraints #
- No cost for single-realm programs beyond one compare on call fast paths.
- Keep the 1,500+
js->sym.*/js->builtins.*/js->globalaccess sites and the JIT'soffsetof(ant_t, ...)loads unchanged. - A Node vm context only gets ECMAScript built-ins (no
console,URL, timers,require), so a new realm runs only the ECMAScript part of bootstrap.
Design #
Realm state is swapped into ant_t #
The active realm's values live in their usual ant_t fields. An inactive
realm keeps its copies in an ant_realm_t. Entering a realm swaps the two
(an X-macro list of fields), so existing code reads the active realm without
an extra load. Per-realm fields:
global,this_val, the global lexicals (global_lexicals, count, cap, index tables).- The prototype and constructor fields of
js->sym(object_proto,array_proto,function_proto, the primitive, promise, iterator, generator protos,array_ctor,string_ctor,promise_ctor). Well-known symbols and theSymbol.forregistry are agent-wide and stay shared. - The ECMAScript-level
js->builtinsentries (error, RegExp, iterator, collection, typed array, Intl, Temporal, wasm protos) and the RegExp legacy statics inmutable_roots. - The protector flags and
array_chain_plain_epoch.
Node-module and web-API builtins entries, modules.*, process_state,
primordials and Ant belong to the main realm and are not swapped.
Inactive realms' fields are GC roots.
Functions belong to a realm #
sv_func_trecords the realm it was compiled in; closures, generators and async coroutines take it from their function. Onesv_func_tbelongs to exactly one realm, so per-function caches (ICs, object-literal templates, JIT code with baked values) stay single-realm. Avm.Scriptrun in several contexts compiles once per realm.- Calls switch realm in C. Errors and suspension come back as return values,
so a C-scoped save/restore also restores on throw and yield. Switch points:
sv_execute_entry_commonandsv_jit_invoke(every C-to-interpreter and C-to-JIT entry), coroutine activate/deactivate, generator resume, JIT compilation (sv_jit_compile*compiles under the function's realm). - Fast paths that push frames or call compiled code directly take only
same-realm callees and otherwise fall back to the C call: interpreter
CALL,CALL_METHOD,TAIL_CALL,TAIL_CALL_METHOD; JIT direct calls,new,applyforwarding, devirtualized calls; inlining requirescallee->realm == caller->realmat compile time.
Builtins belong to a realm #
Each realm interns its own copies of the C builtins (kTypeBuiltin), and a
builtin runs in its own realm whoever calls it, so indirect eval,
Function, species and Array creation use the builtin's realm. Natives take
the prototypes of the objects they create from per-realm tables filled at
init, never from the global's current bindings.
Protectors #
Protector flags are per realm. A mutation can reach another realm's
intrinsic (objects pass between contexts), so once a second realm exists
invalidation also checks every inactive realm's saved intrinsics and flags.
Objects that hold a protected property carry the protector_holder flag, so
the inline check is a flag test before any key compare. The out-of-line check
(src/protectors.c) runs against a protector_view_t built from the isolate
or from an inactive ant_realm_t, which name their fields alike. Arrays and
promises can belong to any realm, so a write on one invalidates in every
realm.
Realm creation #
Init is split into agent-level setup (once: intern, pools, VM, well-known
symbols, library registry, roots, snapshot, primordials) and realm-level
intrinsic creation (isolate_init's object block plus the ECMAScript
init_* modules), which runs again inside a new realm. Prototypes resolved
lazily against the active realm (T_NEEDS_PROTO_FALLBACK objects with an
undefined proto) are set explicitly for objects a realm creates.
Contextified globals (node:vm) #
V8 separates a realm's global object from its global proxy, and Node's
contextify puts interceptors on the global object. Ant mirrors the split
with two realm fields instead of making js->global exotic (the global ICs,
JIT snapshots, own-property checks and internal slots all assume an
ordinary js->global):
js->globalstays the realm's ordinary global: built-ins, internal slots,var/function bindings when the realm is not contextified.js->global_sandboxis the context object of a contextified realm (undefined otherwise). The global variable helpers (ops/globals.h,ops/eval_env.h) consult it in their slow paths: reads try global lexicals, then the sandbox (prototype chain included), then the global; writes,varand function declarations go to the sandbox; deletes remove the sandbox's property, else the global's. Global ICs and JIT snapshots do not fill in a contextified realm, since the sandbox can shadow any global at any time, so every access reaches those helpers.js->global_thisis whatthis,globalThisand sloppythissee: the global itself, or in a contextified realm a proxy whose C traps apply the same sandbox-then-global rules to property access (globalThis.x, the object returned to the outer realm), refuse to be frozen, and report the sandbox's keys plus the global's.
Scripts run by node:vm use repl-style global declaration (top-level var
and functions become global properties; let/const persist per realm, as
V8's script context table does) with the script parse goal, so top-level
await stays a SyntaxError. A vm.Script parses when it is constructed
(syntax errors throw there) and compiles once per realm on first run, since
a compiled function belongs to one realm.
Phases #
- Realm struct, field swap, GC roots, split init; create a realm and evaluate code in it with no cross-realm calls. (done)
- Function realms, call switching, interpreter and JIT guards, compile under the function's realm, coroutines. (done)
- Per-realm protectors and cross-realm invalidation. (done)
node:vm:Script,runInThisContext,runInNewContext,createContext/runInContext(contextified globals backed by the context object),DONT_CONTEXTIFY,isContext,compileFunction. (done)
Status #
Phase 1 done:
include/realm.h,src/realm.c:ant_realm_t, the per-realm field lists (include/realm/fields.h,include/realm/sym_fields.h, andANT_REALM_BUILTIN/ANT_REALM_MUTABLE_ROOTentries inisolate_values.h), save/load onant_realm_enter, GC marking of inactive realms fromgc_mark_roots(minor and major).isolate_initbuilds the main realm throughrealm_init_core; a new realm runsrealm_init_coreandant_bootstrap_realm_intrinsics(the ECMAScriptinit_*modules;init_symbol_ctorsplit from the agent-wide well-known symbols) inside itself.this_valis per-call state, not realm state: natives set it to their receiver, so it is not swapped.ant:internal/realm(create,evaluate) is the test vehicle untilnode:vmexists;evaluateuses repl compilation so a realm keeps its top-level lexicals and returns completion values.- Validation:
tests/test_realm_basics.cjs(also underANT_GC_STRESS), harness, spec suite. Against master: instructions within 0.1%, startup +40k instructions.
Phase 2 done:
sv_func_t.realmis set when the compiler allocates the function, so closures, bound functions (which sharefunc), generators and async functions all carry it.- C entry switches the realm:
sv_execute_entry_common,sv_resume_suspended(a continuation's frames share one realm, since inline calls stay within one),sv_call_resolve_closurefor generator and async calls (the generator object or promise belongs to the callee's realm), JIT compilation, and lazy function-object creation. sv_jit_invokecosts one compare: a callee of another realm goes through the coldsv_jit_invoke_in_realm.sv_callback_preparesends another realm's callback to the generic call, so per-element callback calls use the uncheckedsv_jit_invoke_same_realm.- The interpreter's inline call paths (
CALL,CALL_METHOD, both tail calls) take only same-realm callees. - Compiled code is guarded only once a second realm exists, so programs with
one realm compile exactly as before. Code compiled after that gets caller
guards on direct calls,
new,applyforwarding and devirtualized calls (mir_emit_realm_guard), plus a prologue check that re-enters throughjit_helper_realm_reenterwhen entered from another realm. The prologue covers code compiled before the realm existed, which has no guards (for example a top-level loop entered by OSR that creates a realm midway). Inlining requires the callee's realm to match. - Native function objects (
js_obj_to_func_ex) get an explicitFunction.prototypefrom the realm that creates them, so another realm sees their own; they used to resolve lazily against the active realm. - Validation:
tests/test_realm_calls.cjs(also underANT_GC_STRESS), harness, spec suite. Against master: call benchmarks within 0.1% or better, bench-v8 -0.1% instructions, ytdlp flat.
Builtins:
- A builtin value points at an interned registry entry whose record starts
with the meta and the builtin's realm (
ant_cfunc_record_t; NULL is the main realm). While a realm initializes,ant_cfunc_intern_realmmakes interning return that realm's own copies, so builtins differ between realms (and so do their promoted objects and name-exposed copies). sv_invoke_builtinruns a builtin in its realm: one compare againstjs->builtin_realm(NULL while the main realm is active), with the switch in the coldsv_invoke_builtin_in_realm. IndirectevalandFunctiontherefore compile in their own realm. The JIT's builtin fast call always compares the builtin's realm with the compiling function's (a load and a branch), because code compiled before a second realm existed can be handed that realm's builtins later and C builtins have no prologue to catch it. This costs +0.15% instructions on a builtin-call loop and moves cycles by about 1-2% either way through layout.- Builtins made at runtime get the active realm too: realm activation keeps
ant_cfunc_intern_realm(forjs_mkfuninterning) in step withjs->builtin_realm, andjs_mkfun_dyn(js, fn)(promise resolvers,js_heavy_mkfun, Date and Temporal methods) looks records up by(fn, realm). Before, these were main-realm builtins in every realm, so a realm's promise resolvers ran in the main realm andDate.prototypemethods were shared between realms. Registry lookups no longer take the lock (entries are never freed and only prepended; a miss rechecks and inserts under it), which more than pays for the realm checks on async code: -0.8% instructions against master on an await loop. - The builtin-valued
js->symfields (promise_then,array_push_fn,math_fns,eval_fn, ...) are per realm;js_builtin_evalreturns the active realm's, which the compiler uses to recognise direct eval. - A builtin's prototype is its realm's
Function.prototype(Object.getPrototypeOf(parseInt)used to benull), promotion runs in the builtin's realm, and builtins are valid left operands ofinstanceof. ArraySpeciesCreatetreats another realm's%Array%as no constructor.
Globals of a new realm match a Node vm context: Buffer, atob, btoa,
AsyncIterator, InternalError and import are removed and console has
methods that do nothing (ant_realm_install_console).
Natives find prototypes in per-realm builtins tables filled at init
instead of looking up the constructor's name on the global, which was
user-writable and belonged to the wrong realm (js_get_ctor_proto is gone):
typedarray_protos[TypedArrayType], error_protos[js_err_type_t],
arraybuffer_proto, sharedarraybuffer_proto, dataview_proto,
regexp_proto, date_proto, map_proto, set_proto, weakmap_proto,
weakset_proto, weakref_proto, finreg_proto, suppressederror_proto,
disposable_stack_proto, async_disposable_stack_proto; RegExp species and
String.prototype.matchAll use regexp_ctor_value. Main-realm web types use
their main-only entries (buffer_proto, event_proto, eventtarget_proto,
message_event_proto, observable_proto). js_mkdate makes Dates for fs,
cron, structured clone and N-API. Typed array creation is 7.6% faster and
Map/Set/RegExp/Date/error creation about 4% fewer instructions;
SharedArrayBuffer.prototype.slice now returns a SharedArrayBuffer.
Validation: tests/test_realm_builtins.cjs, tests/test_realm_intrinsics.cjs
(also under ANT_GC_STRESS), harness, spec suite. Against master: bench-v8
-0.25% instructions and -0.9% cycles, ytdlp flat.
Known gaps:
- Lazy prototypes of other object kinds are unchanged.
- The
vm.Modulefamily (behind--experimental-vm-modulesin Node) is not implemented.
Phase 3 done:
ant_property_mutation_invalidateandant_symbol_property_mutation_invalidatecheck the active realm inline and, with several realms, every inactive one (ant_protectors_invalidate,ant_symbol_protectors_invalidate). Writes while a realm initializes (ant_realm_t.initializing) touch only its new objects and skip the other realms; before this, creating a realm turned off the main realm's array species and iteration fast paths, becauseArray.prototypeis an array.- Compiled stores of protector keys read
js->realms->nextat run time and calljit_helper_protector_storewhen several realms exist, since the inline check compares against the compiling realm's intrinsics. No test reaches that inline path with another realm's object today (realm creation resets inline caches and realms' prototypes do not share shapes); the check keeps it correct without relying on that. - RegExp state is per realm (
ant_regexp_realm_t js->regexp): the legacy statics' match offsets (only the subject was swapped before, soRegExp.$1after another realm's match threw "invalid regexp static snapshot") and the exec/replace write guards. A realm's own RegExp init arms its guard, so creating a realm no longer re-arms the main realm's afterexecwas patched; a write after init clears every armed realm's guard, since the written object's realm is unknown. - Validation:
tests/test_realm_protectors.cjs(each case fails on the phase 2 build except the main-realm exec one, which a later write happened to cover), also underANT_GC_STRESS; harness; spec suite. Against master: bench-v8 -0.24% instructions and -0.3% cycles, ytdlp +0.1% instructions with flat cycles, the call benchmarks within noise.
Phase 4 done:
src/modules/vm.cisnode:vm:Script(parsed when made, compiled once per realm on first run,filename/lineOffset/columnOffset,sourceMapURL; code caches are accepted and produced empty),runInThisContext/runInContext/runInNewContext,createContext(contextify orDONT_CONTEXTIFY),isContext,compileFunction(params,parsingContext,filename),measureMemory,constants.- Scripts compile with
js_vm_script_compile(script grammar, repl declarations,<script>frames) and run withjs_vm_script_run(this=js->global_this). - A contextified realm's global is flagged exotic, so the inline global
store, global ICs and JIT snapshots refuse it and every access takes the
slow paths (
sv_global_put_slow,sv_global_get_interned_miss,sv_global_undefined_check,sv_sandbox_global_delete), which consult the sandbox out of line; the common paths are unchanged. - The global proxy's traps live in
vm.c;preventExtensionsthrows "Cannot freeze" as in Node. microtaskMode: 'afterEvaluate'gives the realm its own job queue:js->microtask_realm(a realm field, NULL normally) routes promise jobs queued while the realm is active, andrunInContextruns them after the script (ant_realm_run_microtasks).nextTickstays global.- Found while making contexts in a loop, both since builtins got realms:
the builtin promotion cache's
uint8_tcapacity wrapped from 128 to 0 and read freed memory (crash after about 150 realms), and every realm's builtin records shared the registry's fixed bucket chains, so creating realms was quadratic (1,000 took 17 s). The promotion and name caches are nowant_ptr_pair_map_thash maps, a realm's builtin records live in its own map (ant_realm_t.builtin_records), and the main realm's in the shared registry; 1,000 realms take 0.3 s. - Classes without
extendstake%Object.prototype%instead of looking up the globalObjectby name. - Validation:
tests/test_vm.cjs(documentation examples, globals of a contextified realm, compiled code reading a changing context object, a proxy context,compileFunction, script errors and offsets, microtasks, 300 contexts), also underANT_GC_STRESS; harness; spec suite. - Interpreter instruction counts move between two states with unrelated
changes (LTO code generation of
sv_execute_frame):--jitlessarithmetic loops ran 6% below master from the builtins work through phase 3 and are back at master's count now. A separate investigation.
Phase 4 gaps closed:
withscopes are compile-time scopes: the object lives in an unnamed hidden local, and every identifier awithmay shadow compiles toOP_WITH_TRY_GET/CALL/PUT/DELper enclosingwith(innermost first, found through upvalues across functions), each jumping past the plain access when the object has the binding and@@unscopablesallows it. Closures made inside awithcapture the object like any local, sowith (o) { f = () => x }readso.x(it read the global before); the frame's singlewith_objis gone. Code withoutwithcompiles as before.compileFunctioncontextExtensionswraps the function in onewithper extension (params and body are checked by a plain compile first).codeGenerationis a realm field (codegen_blocked):eval, theFunctionfamily and inlined literalevalthrow EvalError, and wasm compilation throws, when the bit is set.name/originare validated.importModuleDynamicallyis stored on the script's function tree (sv_func_debug_t.import_handler/import_referrer, marked with the function):OP_IMPORTwith a handler calls it, the main-context symbol uses the main loader, and none rejects withERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING.timeout/breakOnSigint: a watchdog thread or the SIGINT handler setsjs->interrupt_pending. The interpreter reads it on the GC tick's slow path (every 1,024 back-edges) and in inline tail calls, so the hot path is unchanged. Compiled code polls only once a run has armed interrupts (js->interrupts_armed; arming drops existing JIT code). The thrown error carriesSLOT_VM_TERMINATION,js->terminatingskips handlers until the run's boundary converts it toERR_SCRIPT_EXECUTION_TIMEOUTorERR_SCRIPT_EXECUTION_INTERRUPTED, and the rejection and uncaught reporters ignore it.- Realms are collected. Major GCs mark only the main and active realms as
roots; a realm is reached through a live closure (
func->realm) or a live builtin (its record's realm), and a context object holds a builtin made in its realm (SLOT_VM_CONTEXT).ant_realm_collectfrees the unreached ones (not those with queuedafterEvaluatejobs) before the sweep, with their lexicals, maps, iterator registry (now a realm field; the shared one pinned every realm) and per-realm script cache (vm_scripts). Their builtin records go to a pool reused only for an identical meta (same fn, name, length and flags), since compiled code may compare a record's address. Making 20,000 contexts peaks at 237 MB (3.3 GB before, and 5,000 at 193 MB against 945 MB). - Validation:
tests/test_with_scopes.cjs,tests/test_vm.cjs(extensions, codeGeneration, importModuleDynamically, timeout, SIGINT),tests/test_vm_realm_gc.cjs, each also underANT_GC_STRESS; harness; spec suite. Against HEAD (no-PGO): bench-v8 flat (cycles +0.06%, instructions -0.06%);--jitlessarithmetic, call, global and sloppy loops 4-7% fewer instructions and up to 3% fewer cycles. - Interpreter regression found and fixed:
--jitlessclass-method loops ran 8-9% more cycles than HEAD with fewer instructions. CPU counters showed 4-5x more memory-order violations (ST_MEM_ORDER_VIOL_LD_NONSPEC), at thevm->spstores ending put-field and the stack-push handlers. The cause wassv_err:VM_CHECKwrites every op's result to it andOP_CATCHread it, so it stayed live across the whole dispatch loop. In the register allocation the codeGeneration check happened to produce, it lived in a stack slot written before most dispatches.OP_CATCHnow takes the caught value from the frame's completion (sv_vm_throwrecords it there and pushes it), sosv_errdies after each check. Interpreter loops now run 2-11% fewer instructions than HEAD. - Class methods (
HAS_SUPERfrom their home object) now take the inlineCALL/CALL_METHODpath withframe->super_valset from the closure, instead of a C call throughsv_vm_call. Against HEAD:--jitlessmethod loops 16-20% fewer cycles, the JITc_methodloop 11%. - Splay after the merge into master (no-PGO builds of
c4c96d72and28895982, instructions retired onsplay-long.js, same checksum): the realms commit runs 0.80% fewer instructions in total, 0.46% fewer with GC disabled, and about 2.0% fewer in GC (total minus GC-disabled run: 7,790 M against 7,947 M). The 0.5% GC increase measured on the branch before the merge does not appear against the merged master. bench-v8 overall is flat.
Review fixes after the merge into master:
varand Annex B function declarations insidewithare hoisted (collect_var_names,hoist_var_decls,annex_b_collect_block_var_funcshad noN_WITHcase).- Compiler paths that write or type a local directly check
local_with_shadowed:+=throughADD_LOCAL, the for-updateINC_LOCAL/DEC_LOCAL,varinitializers,for (var k in ...)targets andinfer_expr_type. ant_realm_mainreadsjs->main_realm.- A realm is a minor-GC root only while
gc_dirty: set when its fields are saved or its promoted-builtin or script maps gain an entry, cleared when it is marked. Every minor GC promotes its survivors, so a clean realm holds no young values. WITH_TRY_*look the name up once: a shape walk over ordinary objects (interned atom, data properties, nolength), the unscopables cache, and the value read from the same lookup. Exotic objects, proxies, accessors andlengthtake the genericin/get path. Stores usesetprop_interned.
Second review round:
- Termination is not converted into a rejection: async function starts,
the Promise executor and async generator
next/return/throwreturn the termination error whilejs->terminatingis set (js_terminating_error,include/interrupt.h).sv_vm_throwtreats every frame as handler-free while terminating instead of returning early. - Runs with interrupt options form a stack (
js->interrupt_run); ending a fired run keepsinterrupt_pendingandterminatingset when an outer run has fired. - The
importModuleDynamicallyhandler and referrer live on the code unit (16 bytes less per function, no tree walk). REPL-mode compiles, which include vm Scripts, now get a code unit, so their code is collectable. A Script's referrer is held through an internal WeakRef (js_weakref_create), so the realm's script cache no longer keeps the Script alive. - A Script records refcounted realm tokens (
ant_realm_token_t) for the realms it compiled into; the finalizer removes only those cache entries, and a freed realm clears its token. - Realms with queued or processing microtasks are major-GC roots. The
ant:internal/realmhandle holds an anchor builtin instead of a raw pointer. compileFunctionshifts source positions back by the wrapper lines, so error lines match Node with and withoutcontextExtensions.withemission goes throughemit_with_triesandsv_patch_list_t; hidden locals are unnamed (add_hidden_local). Inner functions capture them by owner and slot (resolve_upvalue_to) instead of by name, assuper,argumentsandnew.targetnow do too, so no\x01names are formatted or interned.- Realm reach during marking is gated by
js->gc.realm_reach(!minor && ant_realm_multiple), computed per collection. Splay instructions did not move (+0.05%, within noise), so it is not the source of the splay +0.5%. - Builtin libraries imported through ESM get a cached namespace
(
ant_library_namespace): named exports plusdefault=module.exports, taggedModule, as in Node.requireandprocess.getBuiltinModulestill return the exports object.
Realm switch cost:
realm_savecopies the intrinsics (symfields, math functions,ANT_REALM_BUILTINentries) once per realm, on its first save (intrinsics_saved). Later saves copy only state that changes after init:realm/fields.h,ANT_REALM_MUTABLE_ROOTentries, and the two lazily created intrinsics,sym.eval_fnandbuiltins.callsite_proto. Every other intrinsic writer runs from bootstrap init.- Builtins flagged
CFUNC_REALM_FREE(allMathfunctions,parseInt,parseFloat, global andNumberisNaN/isFinite,Number.isInteger,Number.isSafeInteger) skip the switch when every argument is a primitive, so no user code can run during the call. Errors they create take their prototypes fromjs->error_realm, the builtin's realm. Object arguments still take the full switch. - A cross-realm
Math.maxcall went from 61.8 ns to about 12 ns (Node 3.9 ns). Other builtins called across realms (push: 76 to 60 ns) still payrealm_load, which is most of the remaining cost.
Main-realm global reads in loops (s += x, 100M iterations, M-series):
- Hot-tier functions that read a global inside a loop load the global
object and its shape from a two-word MIR data item in the prologue
(
global_hoist), so snapshot guards stop rebuilding 64-bit immediates per site: 37 to 25 instructions per iteration. for (let ...)skips the per-iteration copy of the loop variable when no closure, class or directevalin the condition, update or body can capture it. The copy put a register move on the induction chain (1.10 to 0.68 ns for upvalue and parameter bounds).- Locals behind a
SET_LOCAL_UNDEFkeep their double register when the maybe-unassigned dataflow proves no read before the first write, and OSR seeds local type feedback from live frame values that have none. Together these keepconst y = x; s += ynumeric (2.84 to 0.70 ns). - Every variant now runs at the
s += 1floor, about 2.8 cycles (0.6 to 0.7 ns), which is the latency of the loop-carriedfadd. Going lower needs integer accumulators.
Risks #
- Protector invalidation for inactive realms: silent wrong results if a path is missed.
- Values baked at compile time (global snapshot, inlined sloppy
this, promise protector pointers) are correct only while compilation runs in the function's realm.