src/nsengine/executor.ts
JSExecutor is the reference implementation of the nanoscript VM: a stack machine
whose stack slots hold raw JavaScript values.
const executor = new JSExecutor({
stackSlots: 65536, // operand-stack bound; this is the default
maxInstructions: 5_000_000, // optional execution budget; off when omitted
});
const result = executor.execute(program);
const result = executor.executeDebug(program); // traces each instruction + stackA bare number is still accepted (new JSExecutor(1024)) and is treated as
stackSlots — the historical "max stack size" argument, honoured since
plan.md #35b.
class JSExecutor {
stack: any[] = []; // operand stack AND call frames AND locals
heap: any[] = []; // reserved; no opcode currently emitted touches it
ip = 0; // instruction pointer
sp = 0; // tracked but not authoritative — stack.length is
fp = 0; // frame pointer (an index into stack)
ret: any; // the return-value register
ops: Function[]; // opcode → bound handler
}The stack is a plain Array used with push/pop, so stack.length is the real
stack pointer. sp is maintained alongside it by OP_ALLOC_STACK/OP_POP_STACK
but nothing reads it. stack.length is bounded by stackSlots (default 65 536,
matching the WebAssembly backend): frame pushes (OP_CALL_INTERNAL,
OP_CALL_STACK, host reentry), OP_ALLOC_STACK, OP_DUP, and — via the
checkedPush helper — every handler whose WebAssembly counterpart is
delegated through Host.push (literals, member/element loads, iteration,
string building, …) enforce it at the same sites with the same boundary as
the WebAssembly backend, throwing the shared nanoscript stack overflow
NSError (plan.md #35b). Plain loads are unchecked on both backends, so a
single expression's temporaries may transiently exceed the bound before the
next checked operation.
while (program.instructions[this.ip].opcode != prg.OP_TERM) {
this.ops[program.instructions[this.ip].opcode]();
}ops is built in the constructor as an array of this.op_x.bind(this) in exact
opcode order. Each handler is responsible for advancing ip itself — which is
what lets jumps and branches be ordinary handlers.
When maxInstructions is set, a second variant of this loop runs instead,
decrementing a budget counter before each dispatch and throwing the shared
nanoscript execution budget exceeded NSError when it goes negative; the
unmetered loop above is untouched so the budget-off path costs nothing
(plan.md #35a). The counter is armed in reset() only, so one budget spans a
whole top-level run including re-entrant host→script calls.
Adding an opcode means editing three parallel structures: the constant in
program.ts, its name in OP_NAMES, and its handler position in ops. The
ops.test.ts suite exists to catch drift between the first two.
Several opcodes share one handler:
| Handler | Opcodes |
|---|---|
op_add |
OP_ADDi, OP_ADDf, OP_ADDs |
op_load_local |
OP_LOAD_LOCAL8/32/64 |
op_store_local |
OP_STORE_LOCAL8/32/64 |
op_equal |
OP_EQUALi/f/b/s/o |
op_return32 |
OP_STACKPOP_RET8/32/64 (three identical methods) |
op_increment_local_post |
OP_INCREMENT_LOCALi32, OP_INCREMENT_LOCALf64 |
if (this.stack.length > 0) return this.stack.pop();
return 0;OP_TERM therefore returns whatever is on top of the stack. A top-level return x
compiles to "push x; OP_TERM", and a trailing expression statement leaves its
value there too.
execute() and executeDebug() clear stack, heap, ret, ip, sp and
fp — and re-arm the instruction budget — before they start, so one executor
can run any number of programs and the long-lived executor NSEngine holds
cannot accumulate a previous script's frame. The budget is deliberately not
re-armed on re-entrant host→script calls (invokeFunctionValue): one budget
per top-level run.
<arg1> … <argN> pushed left-to-right
<callee load>
OP_CALL_INTERNAL target
OP_POP_STACK N
OP_PUSH_RETURN64 (only if the value is wanted)
this.stack.push(this.fp); // save caller frame pointer
this.stack.push(this.ip + 1); // return address
this.fp = this.stack.length; // new frame starts here
this.ip = operand;index contents
──────────────────────────────────────────
fp - 3 - k argument (k = 0 is the last argument)
…
fp - 3 last argument
fp - 2 saved fp
fp - 1 return address
fp + 0 first local ← OP_ALLOC_STACK reserves these
fp + 1 second local
… operand scratch space
Arguments live below the saved fp/return-address pair, which is why the compiler
numbers them from -3 downwards. Locals are at non-negative offsets.
this.ret = this.stack.pop(); // the return value → register
while (this.stack.length > this.fp) this.stack.pop(); // discard locals
this.ip = this.stack.pop(); // return address
this.fp = this.stack.pop(); // restore caller frameArguments are not popped here — OP_POP_STACK N at the call site does that.
this.ret = null;
while (this.stack.length > this.fp) this.stack.pop();
this.ip = this.stack.pop();
this.fp = this.stack.pop();Identical to the RET variants apart from not producing a value. The unwinding
loop is what keeps a function that declares locals and falls off the end from
reading a local as its return address, and clearing ret keeps a void call from
handing back the previous call's value.
let fn = this.stack.pop();
if (argCount < 1) {
this.ret = fn();
} else {
let args = [];
for (let i = 0; i < argCount; i++) args.push(this.stack.at(-(i+1)));
this.ret = fn(...args.reverse());
}Arguments are read, not popped — OP_POP_STACK at the call site removes them.
The callee was pushed above the arguments, so it is popped first and the args are
then read from the new top downwards and reversed.
op_load_member() {
let obj = this.stack.pop();
if (obj === null || obj === undefined) throw new Error(`Cannot read member ${name} of ${obj}`);
const member = obj[name];
if (member === undefined) { this.stack.push(null); return; }
this.stack.push(typeof member === 'function' ? member.bind(obj) : member);
}Binding is what makes console.log(x) and arr.push(x) work: the method is
detached from its receiver by the time OP_CALL_EXTERNAL invokes it.
An absent member reads as null, matching the language's null-not-undefined
model; a property whose value is undefined is indistinguishable from a missing
one. Reading a member of null is still an error, since that is a mistake
rather than a lookup miss.
op_wrap_collection() {
this.stack.push(new CollectionIterator(this.stack.pop()));
}
op_increment_iterator() {
const it = this.stack.at(-1); // peek — the iterator stays
if (!it.hasNext()) {
this.stack.pop(); // drop the iterator
this.ip = operand; // exit the loop
} else {
this.stack.push(it.next());
this.ip++;
}
}CollectionIterator (src/utilities/collection_iterator.ts) chooses its
strategy once, at construction: an index cursor for arrays and strings, the value
iterator for Set/Map/anything with Symbol.iterator, and Object.values for
a plain object. Anything else throws Value of type … is not iterable rather
than iterating zero times.
OP_NEW_INSTANCE allocates Object.create(prototype) for the class named by
its operand (an index into Program.classes) and assigns each field its
default, cloned. The per-class prototypes are built once per Program
(cached in a WeakMap, re-used across runs) and hold one plain-function
wrapper per method; a wrapper re-enters the executor through
invokeMethodValue — the same reentry as a function value, except the
fp - 3 slot carries the receiver instead of a cells array. Because
op_load_member binds function-valued members to their receiver, an
instance flowing through any (or handed to a host function) dispatches
methods with zero new machinery, and instances arrive host-side as plain
JavaScript objects with working methods.
Generator in the same file implements ICollectionIterator for lazy sequences
but is never instantiated.
Per instruction the JS backend pays for:
- an array index into
program.instructions - a property read for
.opcode - an array index into
ops - a megamorphic function call through a bound closure
- a second
program.instructions[this.ip]lookup inside the handler to read the operand push/popon a polymorphicany[]
Steps 1–5 are pure overhead — roughly 10–40× the cost of the arithmetic they surround. This is precisely what the WebAssembly backend removes: it compiles the instruction stream into straight-line native code so that only step 6's equivalent (a typed-array load/store) remains. See wasm-engine.md.