Zym API

Nested in-process virtual machines with capability gating — a parent script embeds a child VM, grants it a chosen slice of the native catalog, and keeps the power to stop it no matter what it runs.

Overview

The global identifier Zym is a top-level singleton namespace — there is no create(...) constructor of its own, and methods are invoked as Zym.method(...). It lets a script spin up a fresh VM inside the current process, wire it up exactly the way it wants, and bridge values across the boundary. The parent script plays the role of an embedder for its child: it decides which native modules the child can see, defines the child's globals, registers parent-side natives the child can call, drives compile and run, and reads diagnostics back.

The surface has two halves. registerCliNative is the capability half: a fresh child begins with an empty grantable set, and every native it can reach was placed there deliberately by its parent. Capabilities can only shrink — a child's grantable set is always a subset of its parent's, there is no API to broaden a set after the fact, and grants are not retroactive: once a child has been spawned with a given set, future grants to the parent never reach the existing child. A child cannot widen its own authority. Zym itself is a regular catalog entry, so the ability to sandbox is itself a capability the parent can withhold: a child that was not granted Zym has no Zym global in scope and cannot spawn nested VMs at all.

The sandbox controls are the resource half. The parent gets the same guarantee a C embedder does — a child can always be stopped, no matter what it does. A watchdog bounds how long the child runs, a memory ceiling bounds how much it retains, and a stop ends it outright.

The shortest end-to-end path: spin up a VM, register one parent native, compile and run a script that uses it, call a function on the child, then free the VM. Everything else on this page is either an elaboration of one of these steps (capability grants, multi-file compiles, bytecode round-trips, diagnostics) or a different value shape crossing the bridge.

quick start
var src = "
    func greet(who) {
        return hostHello(who)
    }
"

var vm = Zym.newVM()
vm.registerNative("hostHello(who)", func(who) { return "hi " + who })

vm.run(src)

vm.call("greet", ["ada"])
print(vm.callResult())            // hi ada

vm.free()

For OS-specific information that feeds these utilities (user home, data/config/cache directories, executable path, env vars) see System. For binary blob handling that crosses VM boundaries see Buffer.

Conventions

Calling-VM perspective. Every Zym.* method answers in terms of the VM the script is running in. Zym.cliNatives() returns this VM's grantable set, never some hidden global list. From inside a sandboxed child, names that were not granted are simply not observable.

Buffer is implicit. Buffer is the only always-present native — it is part of the language surface (treat it like list / map / string) and is not returned from cliNatives(). Every other native — including Zym itself — is a grantable catalog entry. Buffer is auto-installed on every child, does not need to be granted, and granting it is a no-op. It is the recommended way to pass bulk data across VM boundaries: Buffers cross by byte-copy, so the child's copy and the parent's copy are independent.

Setup → execution is one-way. A fresh child VM begins in setup phase. The first call to any of compile, runChunk, call, or deserializeChunk (or the helpers built on them: run, runBytecode, callv) flips it into execution phase. Setup-only methods (registerCliNative, defineGlobal, registerNative, setPreemptReserve) cease to be callable after the flip; re-running the same chunk does not reopen setup.

Errors. Bad argument types or arity raise a Zym runtime error of the form Zym.method(args) .... Capability and lifecycle failures all collapse to a single no such native error — an unknown name, a name withheld by the parent, calling a method on a freed VM, or calling a setup method on a VM that has already entered the execution phase. A sandboxed child cannot distinguish between these cases by error message.

Pipeline status codes. The mutating pipeline calls (compile, runChunk, call, deserializeChunk) return values from Zym.STATUS. There is one suspended status rather than one per reason, because it is one VM state: a watchdog, requestStop, and the memory ceiling all land there and are told apart by asking the child, not by the status.

Top-Level Singleton

Zym.cliNatives()

Returns the list of native names the calling VM is allowed to grant to its children, in grant order (catalog declaration order at the root). Buffer is omitted — it is universal, not grantable. The list is per-VM, not global: two VMs in the same process can return different lists, and there is no way to query a global catalog from script. A parent that received Zym as part of its own grant set sees "Zym" in the list; granting "Zym" onward is what lets a descendant nest further.

Returns: A list of strings.

Zym.newVM()

Allocates a fresh in-process VM. The returned ChildVM value is a struct of method closures bound to the new VM. The child starts with Buffer auto-installed and an empty grantable set; the parent must explicitly grant any other natives via registerCliNative before the child enters the execution phase. The call is zero-argument by design: the child's allocator is inherited from the parent and is never script-selectable. What a script can configure is how much that allocator hands out — setMemoryLimit caps the child's budget — but which allocator is used, and how it obtains memory, stays a host decision.

Returns: A ChildVM — every method in the sections below is reached through it.

Zym.STATE

Constant map describing what a child is. Compare against vm.info().state.

ConstantMeaning
IDLENot executing; a run that finished normally lands here.
RUNNINGCurrently executing.
SUSPENDEDPaused with frames intact; a candidate for resume().
FAILEDThe child failed. A failure is a different state, not a different reason for the same one, so it can never be mistaken for something to resume.
Zym.CAUSE

Constant map describing why a child is in its state. Compare against vm.info().cause. New reasons to stop are added here rather than to STATE or STATUS, so existing branches keep meaning what they meant.

ConstantMeaning
NONENo stop reason recorded.
SCRIPT_YIELDReserved: the language has no cooperative yield yet, so nothing sets it.
PREEMPTA preemption entry (such as a watchdog) fired; info().preemptId identifies the entry.
PREEMPT_BLOCKEDPreemption-related, like PREEMPT; info().preemptId is meaningful here as well.
HOST_STOPThe parent asked the child to stop via requestStop().
MEMORY_LIMITThe child crossed its memory ceiling; info().bytesWanted reports the allocation that crossed it.
RUNTIME_ERRORThe child failed on its own.
COMPILE_ERRORCompilation failed.
Zym.STATUS

Constant map of status codes returned by the pipeline calls. SUSPENDED means the child paused with its frames intact and can be resumed — a watchdog expiring, requestStop(), or the memory ceiling. It is deliberately distinct from RUNTIME_ERROR so a parent can tell "I stopped it" from "it failed on its own"; use oomPending() to tell a memory pause from a time one.

ConstantValueMeaning
OK0The call succeeded.
COMPILE_ERROR1The unit failed to compile; drain diagnostics() for details.
RUNTIME_ERROR2The child failed on its own.
SUSPENDED3The child paused with frames intact and can be resumed.

Setup Phase

The four methods below are only valid before the child enters execution phase. After the flip they raise no such native.

vm.registerCliNative(arg)

Grants one or more native modules to the child. Names must be in the calling VM's own grantable set; granting a name that is unknown or withheld raises no such native — the same error in both cases. Idempotent: re-granting a name already on the child is a silent no-op. With "ALL", every name from the calling VM's grantable set is granted in declaration order.

Returns: true on success.

spawn, grant, inspect
var sandbox = Zym.newVM()
sandbox.registerCliNative(["File", "Path"])
print(sandbox.cliNatives())          // [File, Path]

// Grant the universe at once.
var open = Zym.newVM()
open.registerCliNative("ALL")

// Idempotent re-grants, no error.
var sb = Zym.newVM()
sb.registerCliNative("Path")
sb.registerCliNative("Path")
sb.registerCliNative(["File", "Path"])
print(sb.cliNatives())               // [Path, File]

// Withhold the ability to nest further: leaf has no Zym in its set,
// so any code running inside it cannot spawn its own children.
var leaf = Zym.newVM()
leaf.registerCliNative(["File", "Dir", "Path"])
vm.defineGlobal(name, value)

Defines a global on the child. The value is marshalled across the VM boundary as a full graph copy — primitives, strings, lists, maps, structs, enums, Buffer byte-copy, and closures wrapped as cross-VM callables. Last-write-wins on name collision (matches the underlying C API). There is no getGlobal: reading a value back from the child is done by calling a child function that returns it. defineGlobal is the only direction in which globals cross the boundary, which mirrors the C API and keeps the bridge a single direction at any one moment.

var vm = Zym.newVM()
vm.defineGlobal("USER",  "ada")
vm.defineGlobal("LIMIT", 100)
vm.defineGlobal("DEBUG", true)
vm.defineGlobal("TAGS",  ["alpha", "beta", "gamma"])

vm.run("func userInfo() { return { name: USER, limit: LIMIT, debug: DEBUG } }")
vm.call("userInfo", [])
var info = vm.callResult()
print(info.name)                     // ada
print(info.limit)                    // 100
print(info.debug)                    // true
vm.setPreemptReserve(slots)

Holds slots preemption entries back from the child, so the parent can still arm a watchdog or a deadline after the child has been running. The child's own ceiling becomes preemptCapacity() − slots. Setup-phase only by design: a child must be able to treat its preemption budget as fixed, so whatever it reads at the start of a run is still bindable at the end. Raises for a value outside 0 to preemptCapacity().

The preemption table is shared between parent and child, and it is not large. A child that registers greedily can leave the parent unable to arm a watchdog later — the parent keeps its ability to requestStop, which needs no slot, but loses the softer supervision. The alternative to a reserve is registering the watchdog up front, which costs a live entry that joins every rearm and expiry scan and fires on its own schedule whether or not you want it yet. A reserve buys the slot without the countdown.

reserving slots
var vm = Zym.newVM()

// Keep 8 slots for ourselves before the child runs anything.
vm.setPreemptReserve(8)

vm.run("
var mine = []
while (Preempt.available() > 0) {
    push(mine, Preempt.every(900000, func() { var z = 0 }))
}
func held() { return length(mine) }
")

vm.call("held", [])
print("child took %v of %v slots", vm.callResult(), vm.preemptCapacity())
// child took 24 of 32 slots

// The reserve is still ours, after the child took everything it could.
var wd = vm.setWatchdog(500000)

The child is not told it was limited — Preempt.capacity() inside it simply reports the reduced figure, and it has no way to see the parent's entries at all. Preempt needs no grant: it is part of the language surface, like Buffer, not a catalog entry.

vm.registerNative(signature, fn)

Registers a parent closure as a native on the child. signature follows the C-side "name(arg1, arg2)" convention and also accepts the script-natural rest form "name(a, ...rest)" / "name(...rest)" for variadics. The child sees a regular native; calling it from the child marshals the arguments back to the parent, runs fn, and marshals the result back to the child — exactly as if the closure had been invoked from inside the same VM.

var vm = Zym.newVM()
vm.registerNative("double(x)", func(x) { return x * 2 })
vm.registerNative("label(name, ...parts)", func(name, ...parts) { return [name, parts] })

vm.run("func go(n) { return double(n) + 1 }")
vm.call("go", [10])
print(vm.callResult())               // 21

Querying a Child

vm.cliNatives()

Returns the names this child has been granted, in grant order. Equivalent to "what this child could grant onward".

vm.hasFunction(name, arity)

Returns true iff the child has a top-level function name with exactly the given fixed arity. Strict slot probe.

vm.hasFunc(name, arity?)

Existence probe with an optional arity. With one argument, returns true if any callable named name exists at any arity, fixed or variadic. With two arguments, returns true if calling name with exactly arity args can dispatch — either an exact fixed-arity match or a variadic with arity at or above its fixed prefix. Useful for entry-point discovery before any call. Not intended for hot paths. Variadics are detected too, whether defined in script or registered via registerNative.

probing before calling
var vm = Zym.newVM()
vm.run("
    func answer() { return 42 }
    func add(a, b) { return a + b }
    func collect(...parts) { return parts }
    func label(name, ...rest) { return [name, rest] }
")

print(vm.hasFunc("answer"))          // true
print(vm.hasFunc("missing"))         // false
print(vm.hasFunc("add", 2))          // true
print(vm.hasFunc("add", 3))          // false
print(vm.hasFunc("collect", 0))      // true (variadic accepts 0)
print(vm.hasFunc("label", 0))        // false (below fixed prefix)

// Idiomatic optional entry-point dispatch.
if (vm.hasFunc("main")) {
    vm.call("main", [argv])
}
vm.info()

Returns one snapshot of the child: { state, cause, resumable, preemptId, bytesWanted, memoryLimit, memoryUsed }. Compare state against Zym.STATE.* and cause against Zym.CAUSE.*. resumable folds "is it suspended" together with "has every sticky condition been cleared", which is otherwise three separate checks. preemptId is meaningful for the preemption causes, bytesWanted for MEMORY_LIMIT. Taken as one call so the fields cannot disagree with each other.

vm.preemptCapacity()

Returns the total preemption slots the VM was built with. A build-time constant (32 in this CLI, 8 by default in zym_core), not per-child.

vm.preemptReserve()

Returns the slots currently held back from the child. 0 unless setPreemptReserve was called.

vm.preemptUsed()

Returns the live entries in the child's table, parent-registered and child-registered together.

vm.diagnostics()

Drains the child's diagnostic sink. Each entry is { severity, file, fileId, line, column, startByte, length, message }, where severity is one of "error", "warning", "info", "hint". After this call the child's sink is empty.

Returns: A list of maps.

Compile & Run Pipeline

The full pipeline mirrors the C-side executor step-for-step: allocate a source map and chunk, register the source, preprocess, compile, run. run and runBytecode collapse it into a single call when the intermediate objects are not needed.

vm.newSourceMap()

Allocates a fresh source map on the child. The returned value is a small struct exposing free(). Released automatically when the parent VM tears down.

vm.newChunk()

Allocates a fresh chunk on the child. Same lifetime semantics as SourceMap.

vm.registerSourceFile(path, source)

Registers a buffer with the child's file registry. The returned fileId is what preprocess and diagnostics use to refer to this source.

Returns: A fileId integer.

vm.preprocess(source, sourceMap, fileId)

Runs the preprocessor. On success, source in the result is the expanded buffer and status is Zym.STATUS.OK; on failure source is null and the status is non-OK (drain via diagnostics()).

Returns: { source, status }.

vm.compile(source, chunk, sourceMap, entryFile, opts)

Compiles source into chunk. sourceMap may be null for raw text. Flips the child into execution phase.

Returns: A Zym.STATUS code.

full pipeline
var src = "func answer() { return 42 }"

var vm  = Zym.newVM()
var sm  = vm.newSourceMap()
var fid = vm.registerSourceFile("entry.zym", src)
var pre = vm.preprocess(src, sm, fid)
var ch  = vm.newChunk()

if (vm.compile(pre.source, ch, sm, "entry.zym", { includeLineInfo: true }) == Zym.STATUS.OK) {
    vm.runChunk(ch)
    if (vm.hasFunction("answer", 0)) {
        if (vm.call("answer", []) == Zym.STATUS.OK) {
            print(vm.callResult())   // 42
        }
    }
}

Stripping is name-only: the instruction stream is byte-for-byte identical to an unstripped build, and calls still resolve because both the definition and every reference are rewritten together. Anything data-bearing survives, so map keys, struct fields, and enum variants read the same as before.

stripping symbols
var vm = Zym.newVM()
var src = "func helperName(n){ return n * 2 }\nfunc main(){ return helperName(21) }"

var chunk = vm.newChunk()
vm.compile(src, chunk, null, "app.zym", {
    includeLineInfo: false,
    stripSymbols: true,
    keepNames: ["main"],
})

vm.runChunk(chunk)
print(vm.hasFunc("main"))            // true  (kept name callable)
print(vm.hasFunc("helperName"))      // false (renamed away)
vm.call("main", [])
print(vm.callResult())               // 42
vm.disassembleChunk(chunk, name)

Returns a human-readable disassembly of a compiled or deserialized chunk — the same listing zym <file> --dump produces. name labels the top-level chunk in the output; pass null for the default "chunk". Returns null if the listing could not be captured (an environmental failure such as an unwritable temp dir), so a failed dump never aborts a caller mid-pipeline.

var listing = vm.disassembleChunk(chunk, "f")
if (listing != null) {
    print("%v", listing)
}
// == f ==
// 0000    1 LOAD_CONST       R0 ,    1 'null'
// ...
vm.serializeChunk(chunk, opts)

Serializes a compiled chunk to a Buffer of .zbc bytes. opts.includeLineInfo mirrors compile. The returned Buffer can cross VM boundaries (byte-copy) and be fed to deserializeChunk on a fresh VM.

Returns: { status, bytes }.

vm.deserializeChunk(chunk, bytes)

Loads .zbc bytes (a Buffer) into a freshly-allocated chunk. Flips the child into execution phase.

Returns: A Zym.STATUS code.

round-trip through .zbc bytes
var ser = vm.serializeChunk(ch, { includeLineInfo: true })

var vm2 = Zym.newVM()
var ch2 = vm2.newChunk()
vm2.deserializeChunk(ch2, ser.bytes)
vm2.runChunk(ch2)
vm2.call("answer", [])
print(vm2.callResult())              // 42
vm.runChunk(chunk)

Runs a compiled or deserialized chunk on the child. Continues automatically only past a preempt callback that could not be run; a watchdog, a stop, or the memory ceiling is handed back as SUSPENDED. Flips the child into execution phase.

Returns: A Zym.STATUS code.

vm.resume()

Continues a child that stopped. An abort suspends rather than unwinds: frames, instruction pointer, and stack are intact, so the child picks up at the exact instruction that was interrupted. Auto-loops on yield. Whatever caused the stop is still in force, so clear it first (see Sandbox Controls) or the resume aborts again immediately.

Returns: A Zym.STATUS code.

running in slices
var vm = Zym.newVM()
var work = "var total = 0\nfor(var i=0;i<200000;i=i+1){ total = total + 1 }\nfunc get(){ return total }"

var chunk = vm.newChunk()
vm.compile(work, chunk, null, "work.zym", {})

var wd = vm.setWatchdog(50000)
var status = vm.runChunk(chunk)
var slices = 0

while (status == Zym.STATUS.SUSPENDED) {
    slices = slices + 1
    // ... host work between slices ...
    status = vm.resume()
}

vm.clearWatchdog(wd)
vm.call("get", [])
print("finished after %v slices, total = %v", slices, vm.callResult())
// finished after 28 slices, total = 200000

Use this shape when you want cooperative progress. For a hard ceiling, do not loop: treat the first SUSPENDED as final and tear the child down with free().

vm.run(source)

One-shot helper: registers a hidden source file, runs the preprocessor, compiles, and runs the chunk in a single call. source is a string or a Buffer of UTF-8 source bytes — not a .zbc Buffer; use runBytecode for that. status is a Zym.STATUS code; result is the marshalled top-level return value, or null on non-OK status. Flips the child into execution phase, so any setup-only call must happen before it.

Returns: { status, result }.

one-shot run
var vm = Zym.newVM()
var r  = vm.run("func answer() { return 42 } answer()")
print("%v", r)                       // {"status": 0, "result": 42}

// Source from a Buffer (e.g. read from a file or sent across a VM
// boundary) works the same.
var blob = Buffer.fromString("func three() { return 3 } three()")
print("%v", Zym.newVM().run(blob))   // {"status": 0, "result": 3}

// run runs the preprocessor first, so directives expand transparently.
var pp = Zym.newVM().run("#define ANSWER 42\nfunc a() { return ANSWER } a()")
print("%v", pp)                      // {"status": 0, "result": 42}
vm.runBytecode(bytes)

One-shot helper for serialized bytecode: deserializes bytes (a Buffer produced by serializeChunk) into a fresh chunk and runs it. Same return shape as run. Flips the child into execution phase.

Returns: { status, result }.

// Compile once, ship the bytes, run somewhere else.
var ser = vm.serializeChunk(ch, { includeLineInfo: true })

var vm2 = Zym.newVM()
print("%v", vm2.runBytecode(ser.bytes))   // {"status": 0, "result": ...}

Calling Into the Child

Every value crossing the VM boundary is copied. Mutating one side's copy has no effect on the other: lists, maps, structs, and enums cross by recursive deep copy, Buffers by byte-copy. Closures cross both directions as opaque cross-VM callables — the closure itself stays in its origin VM, and invocations re-enter that VM through the bridge. A parent closure handed to a child becomes a callable inside the child, and a closure returned from a child call comes back as a callable on the parent.

vm.call(name, args)

Calls a top-level function on the child by name with positional args. Arguments are marshalled across the VM boundary as a full graph copy. Auto-loops on yield. For a variadic func f(a, ...rest) on the child, the args list is flattened onto the call frame and the child's variadic binding packs the trailing arguments into rest itself. Flips the child into execution phase.

Returns: A Zym.STATUS code; fetch the value with callResult().

vm.callv(name, ...args)

Positional-args sibling to call. vm.callv("greet", "ada") is equivalent to vm.call("greet", ["ada"]), just spelled with positional args at the call site rather than an explicit list. Both write to the same backing slot, so callResult() reads the result of whichever was used most recently. Mirrors the C-side zym_callv / zym_call split. callv is most useful when the args are literals or come from a small, named set; call is the right choice when the args are already a list — a forwarded rest parameter, a parsed JSON payload, or a list built up by map/reduce.

call vs callv
var vm = Zym.newVM()
vm.run("
    func greet(who) { return \"hi \" + who }
    func add(a, b)  { return a + b }
    func collect(...parts) { return parts }
")

// Equivalent, pick whichever fits the call site:
vm.call("greet", ["ada"])
vm.callv("greet", "ada")             // same result, no list

vm.callv("add", 2, 3)
print(vm.callResult())               // 5

// Variadics resolve naturally: trailing positional args are packed
// into the child's rest parameter.
vm.callv("collect", 1, 2, 3, 4)
print(vm.callResult())               // [1, 2, 3, 4]
vm.callResult()

Returns the marshalled return value of the most recent successful call / callv. Lists, maps, structs, enums, Buffers, and closures (wrapped on the parent side) all round-trip back.

a closure crossing back
vm.run("
    var counter = 0
    func makeCounter() { return func() { counter = counter + 1; return counter } }
")

vm.call("makeCounter", [])
var bump = vm.callResult()           // a callable bound to the child VM

print(bump())                        // 1
print(bump())                        // 2
print(bump())                        // 3
vm.getFunc(name)

Returns a parent-side callable that forwards into the child function set named name — every fixed overload plus any variadic, with overload resolution performed by the child per call (the same logic call / callv go through). Invoking it returns the marshalled value directly; no callResult step needed. Returns null if no such name exists on the child. Identity-stable: calling getFunc(name) twice on the same VM returns the same callable.

Returns: A callable, or null.

getFunc itself does not change phase — it is a read against the child's compiled global table. The returned callable, when invoked, flips the child into execution phase if it is not already (same trigger as call / callv). Looking up a name before the child has been compiled returns null because the function genuinely is not there yet. The result is a normal value: store it in a list or map, pass it to a parent-side higher-order function, hand it to another VM via registerNative — wherever a callable is accepted, the dispatcher fits in.

store and call like a native
var greet   = vm.getFunc("greet")
var collect = vm.getFunc("collect")

print(greet("ada"))                  // hi ada
print(collect(1, 2, 3, 4))           // [1, 2, 3, 4]

// Identity-stable: same name, same VM, same callable.
print(vm.getFunc("greet") == greet)  // true

// Absent name gives null. Idiomatic guard:
var maybeMain = vm.getFunc("main")
if (maybeMain != null) {
    maybeMain()
}

Module Loading

vm.loadModules(source, sourceMap, entryFile, callback, opts)

Multi-file compile. The parent callback(path) mirrors the C-side read-and-preprocess callback: for each imported module it must return { source, sourceMap, fileId }, or null to signal a missing file — the loader then pushes a diagnostic and continues. On success the result carries the combined preprocessed source together with the combined source map; on failure the result is { status, error }, combinedSourceMap is absent, and full details land in diagnostics().

Returns: { status, combinedSource, combinedSourceMap, modulePaths } on success, { status, error } on failure.

Pass combinedSourceMap — not the entry-only map — to the subsequent compile: it is sized for the combined buffer, and using the entry map produces misaligned diagnostics on the post-loader source. combinedSourceMap is owned by the script after the call (transferred out of the internal result struct); it is freed automatically when its wrapper is collected, or call combinedSourceMap.free() explicitly. modulePaths lists the resolved paths in load order — useful for diagnostics, caching, and watch-mode reloads. loadModules does not flip the child into execution phase on its own; the subsequent compile call does.

The read callback

The sourceMap the callback returns is the per-module map produced by preprocess for that module's raw source — exactly what the C-side callback hands back. The native trampoline deep-clones it into the child VM's allocator before forwarding, so there is no cross-allocator hazard: the parent wrapper retains ownership of the original (freed by its own finalizer or an explicit free()), while the clone is owned by loadModules and released through the child's allocator. Returning the per-module map is what gives diagnostics full sub-line origin precision — originStartByte / originLength / originLine point at the exact byte range in the raw module source. Passing null as the sourceMap is also accepted: origin attribution falls back to fileId for every line of source, which is correct at file/line granularity but loses sub-line precision after preprocessor expansion.

The callback runs with the parent's capabilities — it is a parent closure — so a child without File cannot read modules; the closure simply is not expressible from inside it. The callback closes over whatever parent state it needs (the VM handle, a packed-bytecode index, a virtual filesystem), and different loadModules calls can pass different callbacks.

multi-file compile
var entry = "
    import \"./mathx\"
    func main() { return mathx_double(21) }
"
var mathx = "func mathx_double(x) { return x * 2 }"

var vm  = Zym.newVM()
var sm  = vm.newSourceMap()
var fid = vm.registerSourceFile("entry.zym", entry)
var pre = vm.preprocess(entry, sm, fid)

// A tiny in-memory module map keeps the example self-contained; real
// code would consult File / Path here.
var modules = { "./mathx": mathx }

var loaded = vm.loadModules(pre.source, sm, "entry.zym",
    func(path) {
        if (!modules[path]) { return null }       // miss: diagnostic
        var raw    = modules[path]
        var sub    = vm.registerSourceFile(path, raw)
        var sub_sm = vm.newSourceMap()             // per-module map
        var pp     = vm.preprocess(raw, sub_sm, sub)
        return { source: pp.source, sourceMap: sub_sm, fileId: sub }
    },
    { debugNames: true }
)

if (loaded.status == Zym.STATUS.OK) {
    var ch = vm.newChunk()
    // Pass loaded.combinedSourceMap, NOT the entry-only sm.
    vm.compile(loaded.combinedSource, ch, loaded.combinedSourceMap, "entry.zym", { includeLineInfo: true })
    vm.runChunk(ch)
    vm.call("main", [])
    print(vm.callResult())                         // 42
    print(loaded.modulePaths)                      // [./mathx, entry.zym]
} else {
    // loaded.error is set; full details are in vm.diagnostics()
    for (d in vm.diagnostics()) { print(d.message) }
}

Loader context

The read callback keeps its single-argument shape — that is the common case (resolve path against the script directory, read the file, return its preprocessed source) and it is also the shape an MCU / runtime-only build needs. For the cases where the callback needs to know who is asking — typically because a previously-resolved module lives outside the entry script's directory and now wants to do sibling imports — the child VM handle exposes a small query surface.

vm.moduleLoader.getCaller()

Returns the resolved module path of the immediate parent of the module whose read callback is currently running — the module that issued the import that triggered this callback. Returns null when the entry module is being loaded, since it has no caller.

Returns: A string, or null at the entry hop.

vm.moduleLoader.getStack()

Returns the full chain of in-flight read-callback invocations on this VM. stack[0] is the entry module and the last element equals the path the current callback was invoked with; getCaller() is equivalent to the second-to-last element (or null at the entry). Use it for diagnostics ("module a → b → c failed to resolve") or policy decisions without maintaining your own parents map.

Returns: A list of strings, always non-empty inside a callback.

Both methods are only meaningful inside an active read-callback invocation on that VM. Calling them at any other time — including from a closure captured during a callback and invoked later, from a coroutine resumed outside the loader, or from a different VM's callback — raises a runtime error of the form vm.moduleLoader.getCaller(): not valid outside of a read_callback invocation. This method is only meaningful while the module loader is actively resolving an import. The handle itself is fine to write down (var ml = vm.moduleLoader), but the methods enforce the scope at call time. There is no silent "return stale data" mode — misuse is always loud.

Caching: getStack() reflects the chain that triggered the load of the currently-resolving module, not the chain of every subsequent import that resolves to the same module — those are served from the loader's internal cache and never call back. Per-import-site chains are not well-defined when caching is in play, the same constraint Node, esbuild, and Deno loader hooks all operate under.
routing bare names to a data directory
var MODULE_PATH = Path.join(System.dataDir(), "zym", "modules")
var dataDirModules = {}            // resolved path -> true
var SCRIPT_PATH = Path.dirname(entryPath)

func readAndPreprocessCallback(path)
{
    var caller = vm.moduleLoader.getCaller()  // null for the entry hop

    var resolved
    if (caller != null && dataDirModules[caller]) {
        // Sibling import inside a data-dir module: resolve relative
        // to MODULE_PATH so a module next to the caller is found.
        resolved = Path.normalize(Path.join(MODULE_PATH, path))
        dataDirModules[path] = true
    } else if (Path.extension(path) == "") {
        // Bare name from the entry tree: data dir lookup.
        resolved = Path.normalize(Path.join(MODULE_PATH, path + ".zym"))
        dataDirModules[path] = true
    } else {
        resolved = Path.normalize(Path.join(SCRIPT_PATH, path))
    }

    var source = readFile(resolved)
    if (!source) { return null }

    var sm  = vm.newSourceMap()
    var fid = vm.registerSourceFile(path, source)
    var ps  = vm.preprocess(source, sm, fid)
    return { source: ps.source, sourceMap: sm, fileId: fid }
}

Resolve callback

getCaller() / getStack() are sufficient for a callback that just needs to know who asked in order to decide what file to read. They are not sufficient when two physically-distinct modules would otherwise collide on the same key in the loader's cycle detector and module cache. Given script -> m1 -> (dataDir)m2 -> (dataDir)m1, the loader resolves the second m1 to the same key already on its import stack from the first hop, and fires a false-positive Circular import detected: m1 -> m2 -> m1 before the read callback ever runs — no script-side bookkeeping inside the read callback can rescue it. Symmetrically, two parallel imports of the same name meant to come from different places silently alias into a single cache slot.

opts.resolveCallback plugs in at exactly the seam where this matters: the loader hands the resolver the raw import spec (the string as it appeared in the import, e.g. "@/foo.zym", "./bar.zym", "std/json") together with the importer's canonical path, before any path math (no directory join, no path normalization) and before the cycle-detector and module-cache lookups. The string the resolver returns becomes the canonical key the loader uses for cycle detection, caching, the subsequent read-callback path argument, and the importer of any transitive imports. Returning null (or omitting the option) falls back to the loader's default resolution against the importer's directory for that spec — byte-identical to the no-resolver path.

The two arguments are independently useful: spec is what the source literally said — the loader does not pre-join it with the importer's directory — and importer is the canonical path of the module that issued the import, i.e. whatever key the loader stored that module under. For the entry module's own deps there is no importer; the script receives null in that slot. Because the resolver runs upstream of path math, an @/... prefix, a leading /, or a pkg: / std/ / data: alias is just a string the script can classify on its own terms — the loader stops appending the importer's directory or normalizing once the resolver returns a non-null value.

getCaller() and getStack() are valid inside the resolve callback too, with the same semantics: getCaller() is the requester of the import currently being resolved (the same value as the importer argument for non-entry imports), and the last element of getStack() is the requester, since the about-to-be-resolved module has not yet been pushed onto the loader's stack.

namespaced keys for a data-dir module system
// Decide the canonical key before the loader does any path math.
// Returning null means "fall back to the loader default" for this spec.
func resolveCallback(spec, importer)
{
    // Sibling import inside an already-known data-dir module: keep it
    // in the data-dir namespace so its key cannot collide with a local
    // module of the same name.
    if (importer != null && Path.startsWith(importer, "data:")) {
        return "data:" + spec
    }

    // Bare name from the entry tree: route to the data dir.
    if (Path.extension(spec) == "") {
        return "data:" + spec
    }

    return null   // keep the loader default for ordinary local imports
}

var loaded = vm.loadModules(pre.source, sm, "entry.zym",
    readAndPreprocessCallback,
    { resolveCallback: resolveCallback })

With this in place, script -> m1 -> (dataDir)m2 -> (dataDir)m1 appears to the loader as ["m1", "data:m2", "data:m1"] — no collision with the entry-tree m1, no false cycle, and a genuine data:m1 -> data:m2 -> data:m1 would still trip the cycle detector correctly. modulePaths and any diagnostics that bubble up also become self-documenting: the data: prefix tells you at a glance which side of the namespace boundary a module came from.

resolveCallback must be either a closure or absent/null; anything else raises a runtime error from loadModules. When absent, every import takes the default path — no resolver trampoline is invoked and there is no per-import marshalling cost; returning null from an installed resolver has the same effect on a per-spec basis. The string the resolver returns is borrowed at the C boundary (the loader copies it internally on return), so scripts do not need to reason about lifetime. The resolver is invoked once per import edge — including for imports that ultimately hit the module cache. That is the whole point: it has to run before the cache lookup in order to influence which slot is consulted.

Supported characters in returned keys

The string a resolver returns becomes both the canonical cache key and the source of the __module_<encoded> identifier emitted at every call site. That identifier has to be a legal identifier, so the loader can only encode a fixed alphabet. AZ, az, 09, and _ pass through unchanged; the punctuation below is mapped to a reversible escape so runtime error frames can decode the key back to the original spec.

CharacterEncoded asNotes
/_slash_path separator
\_slash_encode-only alias for / (collapses)
._dot_extensions, dotted segments
-_dash_kebab-case
space_space_space
:_colon_scheme/namespace separator, e.g. pkg:foo
@_at_project-root sigil, e.g. @/foo
$_dollar_sigil
#_hash_sigil
%_pct_sigil
&_amp_sigil
*_star_sigil
~_tilde_home-style sigil
!_bang_sigil

Characters not in this set (e.g. ?, =, +, parentheses, commas, angle brackets, quotes, ;, |, ^, non-ASCII bytes) are currently passed through one-byte-for-one-byte by the encoder; the generated __module_... identifier is then syntactically invalid and the compile of any module that references such a key will fail. To expose a spec that contains them, do the mapping inside the resolver — e.g. turn pkg:foo?v=2 into pkg:foo/v2 before returning it. \ collapses to / by design (Windows-style separators canonicalize to POSIX), so do not rely on a\b and a/b being distinguishable keys.

Runtime-only builds: scripts that never read vm.moduleLoader.* pay no cost. The trampoline does not allocate or marshal any extra parent-VM values per import, and the loader handle itself is a thin proxy. The fields that back these accessors are zeroed on VM init and only written during module loading, so a binary that never calls loadModules carries the cost of two pointer-sized fields on the VM and nothing else.

Sandbox Controls

The parent is the host of a child VM, and gets the same guarantees a C embedder does: a child can always be stopped, no matter what it does. These are the resource half of the sandbox; registerCliNative is the capability half. A watchdog bounds how long the child runs. A memory ceiling bounds how much it allocates. A stop ends it outright.

All three mechanisms are deliberately abort-only — they take no callback. A watchdog that called back into the child would be something the child could intercept, mishandle, or loop inside. On expiry the child unwinds to Zym.STATUS.SUSPENDED with no diagnostic pushed and no script-visible handler run, so it cannot observe or block its own termination.

vm.setWatchdog(instructions)

Aborts the child once it executes instructions more instructions, rearming each time. Registered non-maskable, so a preemption shield inside the child does not suppress it. Raises if the child's preemption table is full. The rearming is what makes "run in slices" work: every resume() buys another instructions worth of execution. Register with a one-shot budget instead if you want a single hard ceiling with no second chances.

Returns: An id for clearWatchdog.

vm.clearWatchdog(id)

Removes a watchdog previously returned by setWatchdog. Returns false if the id is unknown.

vm.requestStop()

Asks the child to stop at its next instruction. Sticky and unmaskable: it is checked before any masking, so a shield, an in-flight preempt callback, or an empty preemption table cannot suppress it. Not cleared automatically. Needs no preemption slot. A stop is per-VM: stopping a child leaves the parent running.

Returns: null.

vm.stopRequested()

Returns whether a stop is pending on the child.

vm.clearStop()

Clears a pending stop so the child VM can be reused.

vm.setMemoryLimit(bytes)

Caps the child's heap at bytes. 0 means unlimited, which is the default. Raising the limit above current usage also retires a pending condition, so a grant loop does not need clearOom as well.

capping a child's memory
var vm = Zym.newVM()
var greedy = "var hoard = []\nvar i = 0\nwhile(true){ push(hoard, [i, i, i])\n i = i + 1 }"

vm.setMemoryLimit(vm.memoryUsed() + 262144)   // 256 KiB of headroom

var status = vm.run(greedy)

if (status.status == Zym.STATUS.SUSPENDED && vm.oomPending()) {
    print("child hit its memory ceiling at %v bytes", vm.memoryUsed())
    vm.free()
}
vm.memoryLimit()

Returns the child's current ceiling in bytes; 0 if unlimited.

vm.memoryUsed()

Returns the bytes the child currently has allocated. Useful for sizing a ceiling relative to a freshly spawned VM — a new child's figure already includes its own runtime footprint, so size the ceiling relative to it rather than picking an absolute number.

vm.oomPending()

Returns whether the child is suspended on its memory ceiling. This is what tells a memory pause apart from a time one, since both report SUSPENDED.

vm.clearOom()

Clears the pending memory condition without changing the limit. Use it to release the child without giving it more room — after dropping references on the parent side, say. A child that is still over its limit simply trips again on its next allocation.

How the memory ceiling behaves

Crossing the ceiling does not fail an allocation. The allocation succeeds and the child is then suspended at the next instruction boundary, so it is left in a consistent, resumable state rather than half-built; overshoot is bounded by that one allocation. A collection runs before the ceiling is declared crossed, so a child that merely produces garbage is never charged for it — only what it retains counts. The ceiling bounds the child's own allocation; it is not protection against the machine genuinely running out of memory, which remains fatal.

Resuming a stopped child

A stopped child is not dead: resume() continues it from the interrupted instruction. What must be cleared first depends on what stopped it. If more than one condition is pending, all of them have to be cleared — a requestStop outranks the memory ceiling, so clearing only the memory side leaves the child suspended.

Stopped byTo resume
requestStop()clearStop() first; the flag is sticky by design
a rearming watchdogjust resume(); each resume grants one fresh slice
a watchdog you are done withclearWatchdog(id)
a watchdog needing a different budgetsetWatchdog a new one, or drop the old and register another
the memory ceilinggive it room with setMemoryLimit(...), or clearOom(); sticky like a stop

Lifecycle

vm.freeChunk(chunk)

Releases a chunk's resources early. Equivalent to chunk.free(). Returns true on the first call, false if already freed.

vm.free()

Tears the child VM down explicitly. Returns true on the first call, false if already freed. After free(), every other ChildVM method raises no such native. The child is also freed automatically when the parent VM tears down its globals — free() is just a way to release resources earlier.

Examples

Sandboxing Untrusted Code

The two halves of the sandbox in one place: registerCliNative decides what the child can reach, setWatchdog decides how long it can run. The child below loops forever and wraps itself in a preemption shield, which suppresses its own preemption entries — the watchdog is registered non-maskable, so it fires anyway.

var vm = Zym.newVM()
vm.registerCliNative("print")          // capability half: only print

var untrusted = "func spin(){ var i = 0\n while(true){ i = i + 1 }\n }\nPreempt.shield(spin)"

var chunk = vm.newChunk()
vm.compile(untrusted, chunk, null, "untrusted.zym", {})

var wd = vm.setWatchdog(1000000)       // resource half: 1M instructions
var status = vm.runChunk(chunk)

if (status == Zym.STATUS.SUSPENDED) {
    print("child exceeded its budget and was stopped")
} else if (status != Zym.STATUS.OK) {
    print("child failed on its own")
}
vm.clearWatchdog(wd)

The parent keeps running: a stop is per-VM. Testing SUSPENDED separately from RUNTIME_ERROR is what lets you distinguish "I killed it" from "it threw", which matters when reporting back to whoever supplied the code. For an open-ended run where the deadline is not known up front, drive it from the outside instead.

vm.requestStop()                       // sticky, unmaskable
print("%v", vm.stopRequested())        // true
vm.clearStop()                         // before reusing the VM

Telling Apart Why a Child Stopped

Every pause returns the same Zym.STATUS.SUSPENDED, because it is one VM state. Which one it was — and what to do about it — comes from info().

func describe(vm) {
    var i = vm.info()
    if (i.cause == Zym.CAUSE.PREEMPT)      { return "out of time (entry " + str(i.preemptId) + ")" }
    if (i.cause == Zym.CAUSE.MEMORY_LIMIT) { return "out of memory (wanted " + str(i.bytesWanted) + " B)" }
    if (i.cause == Zym.CAUSE.HOST_STOP)    { return "stopped by us" }
    if (i.cause == Zym.CAUSE.RUNTIME_ERROR){ return "it failed on its own" }
    return "finished"
}

var a = Zym.newVM()
a.setWatchdog(200000)
a.run("var i = 0\nwhile (true) { i = i + 1 }")
print("A: %v  (resumable: %v)", describe(a), a.info().resumable)
// A: out of time (entry 1)  (resumable: true)

var b = Zym.newVM()
b.setMemoryLimit(b.memoryUsed() + 262144)
b.run("var h = []\nvar i = 0\nwhile (true) { push(h, [i, i])\n i = i + 1 }")
print("B: %v  (resumable: %v)", describe(b), b.info().resumable)
// B: out of memory (wanted 64 B)  (resumable: false)

var c = Zym.newVM()
c.requestStop()
c.run("var i = 0\nwhile (true) { i = i + 1 }")
print("C: %v  (resumable: %v)", describe(c), c.info().resumable)
// C: stopped by us  (resumable: false)

var d = Zym.newVM()
d.run("var bad = null\nbad.nope()")
print("D: %v  (state == FAILED: %v)", describe(d), d.info().state == Zym.STATE.FAILED)
// D: it failed on its own  (state == FAILED: true)

var e = Zym.newVM()
e.run("var x = 1 + 1")
print("E: %v  (state == IDLE: %v)", describe(e), e.info().state == Zym.STATE.IDLE)
// E: finished  (state == IDLE: true)

Read the resumable field rather than inferring it. A watchdog leaves the child immediately continuable, so it reads true; a memory ceiling and a stop are both sticky and read false until cleared. That one field is the difference between a resume loop that makes progress and one that spins. Case D lands in Zym.STATE.FAILED, not SUSPENDED — a failure is a different state, not a different reason for the same one, so it can never be mistaken for something to resume.

Granting Memory in Instalments

Because the ceiling suspends rather than kills, it can be used as a meter instead of a wall. Each time the child asks for more, the parent decides whether it has earned it.

var vm = Zym.newVM()

// Retains a bounded amount, so it can actually finish once given room.
var work = "var rows = []\nvar i = 0\nwhile(i < 20000){ push(rows, [i, i])\n i = i + 1 }\nfunc count(){ return length(rows) }"

var chunk = vm.newChunk()
vm.compile(work, chunk, null, "work.zym", {})

var budget = 65536                       // start it on a tight 64 KiB
vm.setMemoryLimit(vm.memoryUsed() + budget)

var status = vm.runChunk(chunk)
var grants = 0

while (status == Zym.STATUS.SUSPENDED && vm.oomPending() && grants < 32) {
    grants = grants + 1
    // Decide, per grant, whether this child has earned more room.
    vm.setMemoryLimit(vm.memoryUsed() + budget)
    status = vm.resume()
}

vm.call("count", [])
print("finished after %v grants, rows = %v, used %v bytes",
      grants, vm.callResult(), vm.memoryUsed())
// finished after 30 grants, rows = 20000, used 2209521 bytes

The grants < 32 bound is the point of the pattern: it is what turns an open-ended appetite into a hard total. Without it the loop grants forever and you have written an unlimited VM the slow way. Raising the limit above current usage clears the pending condition on its own, which is why the loop needs no clearOom().