Bounds & Control

How a JS host runs a script it did not write without hanging the tab: instruction-counted preemption, a memory ceiling, and a stop switch, none of which throw the VM away.

Suspension, Not Termination

Running code you did not write means being able to take control back from it. A script can loop forever, allocate without end, or simply take longer than you are willing to wait, and none of that should be able to hang the page or exhaust the tab.

zym-js gives you three bounds, and the property they share matters more than any one of them: all three suspend the VM rather than destroy it. The frames, the stack, and the instruction pointer stay intact, so you can inspect what happened and continue if you want to.

BoundArmed withOnce it fires
Instruction budgetvm.addPreempt(slice)Immediately continuable.
Memory ceilingvm.setMemoryLimitSticky until the limit is raised.
Stop requestvm.requestStop()Sticky until vm.clearStop().
One restriction, stated up front: a bound can only pause work that run() is executing. Anything reached through vm.call, a getFunc callable, or a registered native is terminated instead. That is the subject of What Can and Cannot Be Paused, and it is worth reading before you design around any of the rest of this page.

Preemption Entries

A preemption entry says “hand control back every N instructions”. Slices count VM instructions, not milliseconds, so they are deterministic and machine-independent.

JS
const id = vm.addPreempt(slice, handler?, options?);
vm.removePreempt(id);
vm.setPreemptSlice(id, slice);   // retune its cadence

The Two Options

Both are off by default.

OptionEffect
onceRetire after firing instead of rearming, a deadline rather than a repeating tick.
maskableA script may suppress it with Preempt.shield; without this it always fires.

once is the addEventListener sense of the word, and the entry frees its slot as it fires. Note that it stops bounding the run once it has gone off, so it is not a watchdog over unbounded code. For that you want a rearming entry, which is the default.

Leaving maskable off is what makes a watchdog a watchdog: script cannot shield itself from it.

Handler or No Handler

Whether you pass a handler is the whole distinction:

RegisteredWhen the entry comes due
with a handlerIt runs, then execution continues automatically.
without oneThere is nothing to run, so the VM stays suspended and run() throws ZymSuspended.

Entries are independent. You can have several at different slices, each with its own handler, and each is addressed by its own id. The table holds 32 per VM; addPreempt throws if it is full. The table is shared with the script, which is why budgeting it is its own subject.

Stopping Runaway Code

An entry with no handler is the simplest thing you can build: a hard bound on how long a script may run.

JS
const vm = await Zym.newVM();
vm.addPreempt(1_000_000);                     // no handler: hand control back

try {
    vm.run(`var i = 0
while (true) { i = i + 1 }`);
} catch (e) {
    if (e instanceof ZymSuspended) {
        console.log("stopped:", e.cause === CAUSE.PREEMPT ? "ran too long" : "other");
    } else throw e;
}
Output
stopped: ran too long

ZymSuspended is deliberately not the same as ZymError. One means you stopped it, the other means it failed on its own, and you almost always want to report those differently.

An Event Pump Into the Script

Give the entry a handler and it becomes something more useful than a limit: a periodic hook. The handler may call into the parked VM, so a script can expose its own trigger and let the host drive it.

JS
const vm = await Zym.newVM();

vm.addPreempt(200_000, () => {
    vm.call("onTick");                        // the script's own hook
});

vm.run(`
var beats = 0
func onTick() { beats = beats + 1 }

var total = 0
var i = 0
while (i < 1000000) { total = total + i
 i = i + 1 }
func beatCount() { return beats }
`);

console.log("script saw", vm.call("beatCount"), "ticks");
Output
script saw 50 ticks

The script's own state is untouched by the calls, and it runs to completion normally. This is also the only moment JS code runs while a script executes: everything is synchronous, so nothing else gets a turn until the script finishes or an entry fires.

What a handler may do. Call into the VM, register or remove entries, request a stop, or free the VM. It may not start a nested run() or resume(); those throw.

Capping Memory

The counterpart to bounding time. 0, the default, means unlimited.

JS
vm.setMemoryLimit(vm.memoryUsed() + 1024 * 1024);   // +1 MiB

try {
    vm.run(`var hoard = []
var i = 0
while (true) { push(hoard, [i, i])
 i = i + 1 }`);
} catch (e) {
    if (e.cause === CAUSE.MEMORY_LIMIT) {
        console.log("hit the ceiling wanting", e.bytesWanted, "more bytes");
    }
}
Output
hit the ceiling wanting 64 more bytes

Crossing the ceiling does not fail the allocation. The VM suspends afterwards, so it stays consistent and you decide what happens: grant more room with another setMemoryLimit, or discard it. Raising the limit above current usage clears the condition on its own. Garbage is never charged against the budget, only what the script retains.

Size the limit relative to vm.memoryUsed() on a fresh VM rather than picking an absolute number, since a new VM already carries its own runtime footprint.

Stopping on Demand

JS
vm.requestStop();       // sticky and unmaskable
vm.stopRequested();     // true
vm.clearStop();         // before reusing the VM

A stop outranks everything else and nothing in the script can suppress it. It is sticky by design: the VM stays suspended until you clear it.

Inspecting a Stopped VM

vm.info() is one snapshot, taken together so the fields cannot disagree.

JS
const i = vm.info();
i.state === STATE.SUSPENDED;   // paused, frames intact
i.cause === CAUSE.PREEMPT;     // why
i.resumable;                   // would resume() get anywhere

STATE is what the VM is: IDLE, RUNNING, SUSPENDED, FAILED. CAUSE is why: PREEMPT, HOST_STOP, MEMORY_LIMIT, OUT_OF_MEMORY, RUNTIME_ERROR, and a few more.

They are separate on purpose. Every pause looks the same as a state; the cause is what tells you whether to grant more time, grant more memory, or give up. A failure is FAILED, never SUSPENDED, so it can never be mistaken for something to continue.

Read resumable rather than working it out yourself. A preemption leaves the VM immediately continuable; a stop and a memory ceiling are both sticky and read false until cleared. That single field is the difference between a loop that makes progress and one that spins.

Resuming

JS
vm.resume();

Continues a suspended VM, returning once the script completes, throwing ZymSuspended if it pauses again and ZymError if it fails. Whatever suspended it has to be cleared first, which is what resumable tells you.

You rarely call it directly: an entry with a handler resumes for you, so slicing a long script into chunks and reporting progress is just a handler that returns true.

JS
const started = Date.now();
let ticks = 0;

vm.addPreempt(100_000, () => {
    ticks++;
    return Date.now() - started < 2000;        // false stops the run
});

vm.run(`var total = 0
var i = 0
while (i < 2000000) { total = total + i
 i = i + 1 }
func total_() { return total }`);

console.log("finished after", ticks, "ticks, total =", vm.call("total_"));
Output
finished after 200 ticks, total = 1999999000000

Returning false from a handler stops instead of resuming, which is how you express a wall-clock deadline: the clock is only checked when a handler runs, so the granularity is your slice size.

What Can and Cannot Be Paused

The rule: a bound can only pause work that run() is executing. Everything else it can only stop.

This is the one rule in this section that will bite you, because the same script, the same watchdog, and the same runaway loop behave differently depending on how you reached the code:

Reached throughWhat a bound does
vm.run(src) and chunk.run()The VM suspends. Inspect it, then resume().
vm.call(name, ...)The VM is terminated, so there is nothing to resume.
a callable from vm.getFunc(name)The VM is terminated.
anything you call from inside a registered nativeThe VM is terminated.
JS
const DEF = `func work() { var i = 0
 while (i < 5000000) { i = i + 1 }
 return i }`;

// Work that run() is executing can be paused.
const a = await Zym.newVM();
a.addPreempt(200_000);
try { a.run(DEF + "\nwork()"); } catch (e) {
    console.log("run():  suspended:", e instanceof ZymSuspended,
                " resumable:", a.info().resumable);
}

// The same work reached through a host call cannot be paused.
const b = await Zym.newVM();
b.run(DEF);
b.addPreempt(200_000);
try { b.call("work"); } catch (e) {
    console.log("call(): suspended:", e instanceof ZymSuspended,
                " resumable:", b.info().resumable);
}
Output
run():  suspended: true  resumable: true
call(): suspended: false  resumable: false

Same script, same entry, same loop. The only difference is run() versus call().

The reason is that pausing means keeping the whole stack for later, and a host call puts your JS frame in the middle of that stack. Suspending would mean unwinding your vm.call(...) to hand control back, then rebuilding it on resume(). There is no way to rebuild a JS function that already returned. So the VM does the only other thing it can: it stops the script for good and reports why. That applies to the memory ceiling and requestStop() exactly as it does to a preemption entry.

It also applies one level deeper. A native you register is a JS function, and if it calls back into the VM through vm.call, a getFunc callable, or another chunk, then everything the script does underneath it is inside a host call too:

JS
vm.registerNative("render()", () => {
    vm.call("draw");        // anything that fires in here terminates,
});                         // it cannot suspend back out through render()

Practical shape: do the bounded work inside run(). Let the script drive its own loop and use call() for short, trusted entry points you do not need to interrupt. If you must bound a call(), treat the bound as a kill switch rather than a pause, which is usually what you wanted from an entry point that overran anyway.

Two things are not affected, and are worth knowing so you do not over-correct:

Budgeting the Entry Table

The table is shared: entries you register from JS and entries the script registers for itself with Preempt.every come out of the same 32 slots. vm.preempts() is one snapshot of it, taken together so the numbers cannot disagree.

FieldMeaning
capacity32, fixed at build time.
used / freeused + free === capacity.
hostUsedRegistered through addPreempt.
scriptUsedRegistered by the script itself.
reserveSlots held back from script.
scriptCapacitycapacity - reserve.
scriptAvailableWhat script could still take right now.
entries[{ id, remaining, handler }, ...].

remaining counts instructions until that entry fires. handler is whether this VM has a JS function bound to that id, which is exactly the difference between an entry that resumes itself and one that suspends, so script-registered entries always read false.

For a single entry, vm.preemptRemaining(id) answers the same question without building the array, which is what makes it usable from inside a handler retuning its own cadence. An unknown id reads -1, so it doubles as a liveness check.

vm.triggerPreempt(id) arms an entry to fire at the next instruction instead of when its countdown runs out. Since nothing else in JS runs while a script does, this is not a way to interrupt from outside: use it from inside another entry's handler, or between run() and resume().

Holding Slots Back From Script

A script that registers entries until the table is full leaves you unable to arm a watchdog over it. The reserve is the fix:

JS
vm.setPreemptReserve(2);   // 2 slots script can never take
vm.preemptReserve();       // read it back

It is a floor for you and a ceiling for script. You are never restricted to the reserve, but script simply cannot spend it. It also locks once the VM executes anything, which is what lets a script treat the budget it sees at the start as still bindable at the end. Calling it after the VM has run throws.

JS
const vm = await Zym.newVM();
vm.setPreemptReserve(2);                      // keep 2 slots for the host

vm.addPreempt(1_000_000);                     // spend one of them

vm.run(`
func tick() {}
Preempt.every(250000, tick)
`);

const t = vm.preempts();
console.log(`table  ${t.used}/${t.capacity} used, ${t.free} free`);
console.log(`owners host ${t.hostUsed}, script ${t.scriptUsed}`);
console.log(`script may still take ${t.scriptAvailable} of ${t.scriptCapacity}`);
for (const e of t.entries) {
    console.log(`  #${e.id} fires in ${e.remaining}, handler: ${e.handler}`);
}
Output
table  2/32 used, 30 free
owners host 1, script 1
script may still take 29 of 30
  #1 fires in 1000000, handler: false
  #2 fires in 250000, handler: false

scriptAvailable is 29 rather than 30 because the script already spent one, and it is bounded by the real free slots as well as script's own budget. If you overspend your reserve, script's figure drops to match what actually exists rather than promising room that is gone. It is the same number the script reads from Preempt.available(), so host and script never disagree about how much is left.

preemptCapacity(), preemptUsed() and preemptReserve() remain as direct single-value reads if you only want one of them.