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.
| Bound | Armed with | Once it fires |
|---|---|---|
| Instruction budget | vm.addPreempt(slice) | Immediately continuable. |
| Memory ceiling | vm.setMemoryLimit | Sticky until the limit is raised. |
| Stop request | vm.requestStop() | Sticky until vm.clearStop(). |
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.
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.
| Option | Effect |
|---|---|
once | Retire after firing instead of rearming, a deadline rather than a repeating tick. |
maskable | A 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:
| Registered | When the entry comes due |
|---|---|
| with a handler | It runs, then execution continues automatically. |
| without one | There 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.
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; }
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.
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");
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.
run() or resume(); those throw.
Capping Memory
The counterpart to bounding time. 0, the default, means unlimited.
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"); } }
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
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.
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
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.
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_"));
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
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 through | What 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 native | The VM is terminated. |
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); }
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:
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:
-
Registering entries from inside a native is fine.
addPreempt,removePreempt,setMemoryLimit,requestStopall work normally there. Only the moment a bound fires is constrained. - Continuations follow the same rule. A script that captures a continuation spanning a host call is refused with an error naming the function, rather than quietly producing one that is not properly delimited.
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.
| Field | Meaning |
|---|---|
capacity | 32, fixed at build time. |
used / free | used + free === capacity. |
hostUsed | Registered through addPreempt. |
scriptUsed | Registered by the script itself. |
reserve | Slots held back from script. |
scriptCapacity | capacity - reserve. |
scriptAvailable | What 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:
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.
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}`); }
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.