packages/nwasm/src/interp.nu — a small WebAssembly interpreter (pure NURL).
Executes wasm: i32/i64/f32/f64 const, locals, globals, the integer and float arithmetic/comparison/bitwise ops, all int↔float conversions, structured control flow (block / loop / if / else / br / br_if / return), drop / select, direct and indirect calls, and linear memory load/store. Each value occupies one 64-bit cell; i32 wraps + sign-extends to 32 bits, and floats are held as their IEEE-754 bit pattern (reinterpreted via std/floatbits for arithmetic). Imports (the WASI surface) are the remaining milestone — so this runs self-contained compute modules today.
Control flow uses an explicit control stack. Entering a block/if computes its matching end by a one-pass immediate-skipping scan; br to a loop re-enters at the loop start, to a block jumps past its end.
& m @ rint f x → fround-to-nearest-even (wasm f*.nearest) — libm rint honours the default mode.
& c @ nurl_rand_fill *u buf i n → iOS entropy (runtime helper; getrandom/urandom under the hood).
& c @ nurl_code_alloc i n → *uThe JIT substrate: an executable page and a call through a raw address. On wasm32 alloc returns 0 (no executable memory) and the runtime stays on the interpreter — a capability probe, not an error.
& c @ nurl_code_seal *u p i n → i& c @ nurl_call_code *u fn *u a → i& c @ nurl_call_code_at *u fn *u a i off → i& c @ nurl_call_code2 *u fn *u a *u b → i& c @ nurl_call_code2_sj *u fn *u a *u b → i& c @ nurl_code_trap_addr → i& c @ nurl_call_code_at2 *u fn i off *u a *u b → i& c @ nurl_code_free *u p i n → v& c @ nurl_vmem_reserve i span → *uGuard-page linear memory: an 8 GiB PROT_NONE reservation swallows every address a 32-bit index + 32-bit offset can form, so JIT code needs no bounds checks — an out-of-bounds access faults and the runtime's SIGSEGV handler steers the faulting frame to its function's trap stub. Reserve returning 0 means the platform has no fault-to-trap plumbing and the bounds-checked path stays — a capability probe, like nurl_code_alloc.
& c @ nurl_vmem_commit *u base i old i new → i& c @ nurl_vmem_release *u base i span → v& c @ nurl_guard_mem_add *u base i span → i& c @ nurl_guard_mem_del *u base → v& c @ nurl_guard_code_room → i& c @ nurl_guard_code_add *u code i len *u stub → i& c @ nurl_guard_code_del *u code → v& c @ pthread_mutex_init *u m *u attr → i32The one lock behind the 0xfe atomics (see __atom_init). Declared here rather than taken from std/thread.nu because the handles live in file globals, and a NURL global holds a pointer as an integer.
& c @ pthread_mutex_lock *u m → i32& c @ pthread_mutex_unlock *u m → i32& c @ pthread_cond_init *u c *u attr → i32& c @ pthread_cond_wait *u c *u m → i32& c @ pthread_cond_broadcast *u c → i32& c @ unlink s path → i32unlink(2): path_unlink_file must NOT remove directories (remove(3) would).
& c @ nurl_tcp_connect s host i port → i── raw sockets, for the "nurl_net" host-import bridge ──────────── The listen/accept/read/write/close/err_kind/peer_addr/set_timeout half of this ABI is preamble-declared by the compiler; the rest lives in stdlib FFI declares, so name it here the same way std/dns.nu and ext/http_pure.nu do.
& c @ nurl_tcp_timeout_ms i handle → i& c @ nurl_tcp_get_fd i handle → i& c @ nurl_tcp_set_nonblock i handle i on → v& c @ nurl_tcp_ref i handle → v& c @ nurl_tcp_unref i handle → v& c @ nurl_tcp_local_addr i handle → s& c @ nurl_udp_bind s host i port → i& c @ nurl_udp_connect i handle s host i port → i& c @ nurl_udp_send i handle s buf i n → i& c @ nurl_udp_recv i handle s buf i n → i& c @ nurl_udp_send_to i handle s buf i n s host i port → i& c @ nurl_udp_recv_from i handle s buf i n → i& c @ nurl_udp_close i handle → v& c @ nurl_udp_err_kind i handle → i& c @ nurl_udp_peer_addr i handle → s& c @ nurl_udp_local_addr i handle → s& c @ nurl_udp_set_timeout i handle i ms → v& c @ nurl_udp_set_nonblock i handle i on → v& c @ nurl_udp_family i handle → i& c @ nurl_udp_get_fd i handle → i& c @ nurl_udp_set_broadcast i handle i on → i& c @ nurl_udp_join_group i handle s group s iface → i& c @ nurl_udp_leave_group i handle s group s iface → i& c @ nurl_udp_set_multicast_ttl i handle i ttl → i& c @ nurl_udp_set_multicast_loop i handle i on → i& c @ nurl_dns_resolve s host → s& c @ nurl_dns_resolve_port s host i port → s& c @ nurl_dns_reverse s ip → s& cuda @ cuInit i32 flags → i32── CUDA driver + NVRTC (the GPU host-import bridge) ────────────── A wasm module built from a GPU-using NURL package (packages/gpu → onnx → objdet) imports these under module "env"; nwasm resolves them to the real libcuda/libnvrtc here, marshalling guest linear memory ↔ host. nurl.sh auto-links libcuda/libnvrtc when these symbols appear, and links stub objects on a GPU-less host (so nwasm always builds; the guest then just sees nonzero CUresult codes). Handle types match the portable i64 model in packages/gpu/src/cuda.nu.
& cuda @ cuDeviceGetCount *u count → i32& cuda @ cuDeviceGet *u device i32 ordinal → i32& cuda @ cuDeviceGetName *u name i32 len i32 dev → i32& cuda @ cuCtxCreate *u pctx i32 flags i32 dev → i32& cuda @ cuCtxDestroy i ctx → i32& cuda @ cuCtxSynchronize → i32& cuda @ cuModuleLoadData *u module *u image → i32& cuda @ cuModuleUnload i module → i32& cuda @ cuModuleGetFunction *u hfunc i hmod s name → i32& cuda @ cuMemAlloc *u dptr i bytesize → i32& cuda @ cuMemFree i dptr → i32& cuda @ cuMemcpyHtoD i dst *u src i n → i32& cuda @ cuMemcpyDtoH *u dst i src i n → i32& cuda @ cuLaunchKernel i f i32 gx i32 gy i32 gz i32 bx i32 by i32 bz i32 sh i stream *u params i extra → i32& nvrtc @ nvrtcCreateProgram *u prog s src s name i32 nh *u headers *u incs → i32& nvrtc @ nvrtcCompileProgram i prog i32 nopt *u opts → i32& nvrtc @ nvrtcGetPTXSize i prog *u sz → i32& nvrtc @ nvrtcGetPTX i prog *u ptx → i32& nvrtc @ nvrtcGetProgramLogSize i prog *u sz → i32& nvrtc @ nvrtcGetProgramLog i prog *u log → i32& nvrtc @ nvrtcDestroyProgram *u prog → i32: Arg { ( Vec u ) bytes }: Ctrl { i is_loop i start_pc i end_pc i height i arity }: WFd { i kind ( Vec u ) data i pos ( Vec u ) host ( Vec u ) name b writable b dirty b append }A WASI file descriptor. kind: 0 closed, 1 stdio, 2 preopened dir, 3 file, 4 opened (non-preopen) directory. For a file, data holds the contents, pos the read/write offset, host the on-disk path (for open/flush), and name (a dir) the guest-visible preopen name reported by fd_prestat_*. append = O_APPEND: every write lands at the end regardless of pos.
: Interp: Interp {
s mod // *Module
( Vec i ) vs // value stack
( Vec u ) mem // linear memory (bytes)
i mem_pages // current size in 64 KiB pages
// Bytes the guest may address = mem_pages * 64 KiB. Usually the whole
// buffer, but a SHARED memory reserves its declared maximum up front —
// growth must never move the buffer, because another thread is reading
// it through the same pointer — so there the buffer is larger than
// what the guest may touch, and every bounds check reads this instead
// of the Vec's length.
i mem_bytes
// Guard-page mode: the raw base of an 8 GiB PROT_NONE reservation
// (0 = the memory lives in the `mem` Vec instead). Growth commits
// pages in place, so this address never moves; every reader goes
// through __mem_base, which picks whichever representation is live.
i mem_raw
( Vec i ) globals // mutable global values
( Vec i ) table // runtime funcref table (mutable via table.set/grow/…)
( Vec i ) data_dropped // 1 per data segment once dropped / active
( Vec i ) elem_dropped // 1 per element segment once dropped / active
( Vec s ) argv // *Arg — WASI program arguments (argv[0] = program)
( Vec s ) envp // *Arg — WASI environment entries ("NAME=VALUE")
( Vec s ) fds // *WFd — file-descriptor table (0/1/2 stdio, 3 preopen, …)
// Why execution stopped, as one word so the driver's hot loop tests one
// load instead of two byte fields: bit 0 = trapped, bit 1 = exited (via
// proc_exit). `interp_trapped` / `interp_exited` are the readers; nothing
// outside `__trap` and `__wasi_proc_exit` sets it.
i halt
i exit_code
( Vec u ) trapmsg
i pending_call // callee set by call/call_indirect for the driver (-1 none)
i max_depth // frame-stack depth limit (trap when exceeded)
i fuel // remaining budget in predecoded records (-1 = unlimited)
i jit_ctx_free // freelist of reusable 7-word JIT call contexts (0 = empty)
i jit_lc_fidx // last JIT callee fidx (-1 = none) and its resolved PFunc
i jit_lc_pf
// Inline call-out bridge: a capture-decomposed closure the emitted
// code calls C-ABI-style instead of parking the whole native frame
// chain. cur_cd is the live JIT context for the innermost run.
i jit_co_fn
i jit_co_env
i jit_cur_cd
// Tier-6 direct-call state. The slab is a bump stack of raw slot
// frames for JIT execution. jit_spcell is the JIT anchor block, held
// in r8 for the whole native execution: word 0 = the absolute
// address of the next free slab slot, word 1 = the slab end, words
// 2.. = one direct-entry address per defined function (0 = call
// through the driver).
i jit_slab // raw slab base address (0 = not yet allocated)
i jit_slab_end // slab base + byte length
i jit_spcell // address of the anchor block [sp, slab end, ftab..]
b gpu_ok // env/CUDA host imports enabled (opt-in; default off)
b net_ok // nurl_net host imports (real sockets) enabled (opt-in; default off)
// Shared memory changes what a narrow store is allowed to touch: see
// __mem_store. Cached here because every store reads it.
b shared_mem
// Guest socket handle → host handle. The guest never sees a host
// pointer: it gets an index into this table, so a forged handle can
// only miss, never become a host address the runtime dereferences.
// Slot 0 is reserved — the stdlib reads handle 0 as "failed".
( Vec i ) nethandles
( Vec i ) netkinds // 1 = TCP, 2 = UDP (which close the runtime owes it)
b cap // capture stdout/stderr into capout/caperr instead of the host streams
( Vec u ) capout // captured module stdout (raw bytes, NULs preserved)
( Vec u ) caperr // captured module stderr
( Vec s ) pfuncs // *PFunc per defined function, predecoded lazily (#s 0 until first call)
// Threads (wasi-threads). A spawned thread runs its OWN Interp — own
// value stack, frames, globals and therefore its own `__stack_pointer`,
// exactly as the proposal's "one instance per thread" says — over the
// SHARED linear memory, table and module. `owner` points at the
// instantiating Interp (0 in it), and the host-side tables that must
// stay common (file descriptors, socket handles, captured output) are
// reached through it; `__host` is that hop.
s owner // *Interp of the instantiating thread, 0 when this IS it
i next_tid // owner only: thread ids handed to wasi.thread-spawn
( Vec s ) thread_holders // owner only: *TStart, keeping spawn closures alive
( Vec i ) thread_kids // owner only: live child *Interp, for memory.grow
( Vec i ) thread_joins // owner only: host Thread handles, joined before free
i tstart_fidx // cached export index of `wasi_thread_start` (-2 = not looked up)
i sp_global // cached global index of `__stack_pointer` (-2 = not looked up)
}
: TStart { ( @ v ) f }One spawned thread's closure, heap-held so it outlives the host call that created it (thread_spawn borrows the closure and its captures).
: Frame { i fidx ( Vec i ) regs i pos i end i code_start s pins i ret_dst s prev i depth }One activation record on the explicit call stack: the function, its locals, its control stack, and the instruction cursor [pos, end) — pos/end are record indices into the function's predecoded instruction array, not byte positions; pins borrows the *PFunc owned by it.pfuncs.
: PFunc { ( Vec i ) code ( Vec i ) aux i count i nlocals i nslots i nparams i nresults i code_start i sbase ( Vec i ) kv s free ( Vec i ) bytes s jit i jitlen }A predecoded function body in REGISTER FORM. wasm validation guarantees a static stack height at every instruction, so the value at stack position h lives in slot (nlocals + h) of one flat per-frame array — locals first, stack after — and predecode resolves every operand to an absolute slot index. There is no value-stack traffic and no runtime control stack left: local.get/set/tee become register moves, block/loop/end emit nothing at all, and branches are direct jumps that carry (dst, src, n) result-move triples computed statically. Layout: 6 x i64 per record — [0] micro-op a dense internal opcode, NOT the wasm byte — see __iop [1..4] A B C D operands: slot indices, jump targets (record indices), immediates, packed move triples. A load's D is its index slot — the address add folded into the access, or the pool's zero slot when there was none (__fuse_addr). [5] BYTE the instruction's byte offset in the module image, for trap backtraces — the one thing byte positions are still good for aux holds br_table rows, 4 words per label: target, dst, src, n. The 0xfc family is the one group still bridged through the old value stack: the record copies its operands from slots onto it.vs, runs the existing executor arm, and copies the result back — correctness identical, speed unchanged for them, and no duplicated semantics. Floats and the int/float conversions are register form like everything else. nparams / nresults are the callee's arity, copied out of the type section once at predecode time. Every call and every return needs them, and re-deriving them per call means walking funcs → typeidx → types and two vec_lens inside module_func_type — pure repeat work on a value that is fixed for the life of the module.
: ~ i g_guard 1Guard-page memory is on wherever the runtime supports it; main.nu turns it off for NURL_NWASM_GUARD=0 (the A/B and debugging escape).
@ interp_disable_guard → v@ interp_new * Module m → *Interp@ interp_free * Interp it → v@ interp_capture * Interp it → vCapture the module's stdout/stderr into buffers instead of writing them to the host's own streams. This is the embedder's switch: a host program that runs a module as a function call wants the module's output back as a VALUE, not interleaved into its own console. Enable before exec_func; read the buffers after it.
@ interp_stdout_bytes * Interp it → ( Vec u )The captured bytes — BORROWED views into the Interp (valid until interp_free; do not free). Raw bytes, exactly as the module wrote them: a NUL neither truncates nor terminates.
@ interp_stderr_bytes * Interp it → ( Vec u )@ interp_trapped * Interp it → bDid the guest trap? Did it call proc_exit? The two halves of halt. Both can be set: a trap while unwinding an exiting module leaves the trap visible, which is what an embedder wants to report.
@ interp_exited * Interp it → b@ interp_push_arg * Interp it s str → vAppend a program argument (copied from a NUL-terminated host string).
@ interp_push_env * Interp it s str → vAppend an environment entry ("NAME=VALUE", copied).
@ interp_allow_gpu * Interp it → vEnable the module "env" GPU/CUDA host-import bridge. Off by default: those imports hand the guest raw host pointers into linear memory and forward them to libcuda, so they are only safe for trusted compute — the embedder must opt in explicitly (the CLI does so with --allow-gpu).
@ interp_allow_net * Interp it → vEnable the module "nurl_net" socket bridge — the guest's TCP/UDP/DNS calls become this process's. Off by default for the same reason the GPU bridge is: it is the guest reaching the network through us (the CLI opts in with --allow-net).
@ interp_set_preopen * Interp it s host_path s guest_name → vGrant the module one preopened host directory, visible to it as guest_name (the path it resolves opens against). Installed as fd 3.
@ interp_thread_new * Interp parent → *InterpA fresh Interp for a spawned thread: its own stacks and globals over the owner's memory, table and module. The shared Vecs are copied by HANDLE — safe because a threaded module's memory is allocated at its maximum up front and never moves — and interp_free on a thread releases only what the thread itself owns.
@ interp_run_start * Interp it → v: PBlk { i kind i base i params i results i live_entry i t0 i else_br ( Vec i ) patches }One open block/loop/if during predecode. patches holds forward-branch sites that must learn "the record index just past this frame's end": value site2 → record A-field, site2+1 → aux word site.
: ~ i g_jit 0 // tier-0 JIT opt-in (NURL_NWASM_JIT); off by defaultExecute function fidx to completion on an EXPLICIT frame stack — guest recursion depth is bounded by max_depth, not by the host's native stack. Arguments are already on the value stack; results are left on top.
Two loops, not one. The outer loop owns the frame stack: it loads the top frame's bases into registers and, when the inner loop comes back, either returns from that frame or picks up the one a call just pushed. The inner loop owns the records of a single frame and its condition is one compare — everything that has to leave a frame (a call, a return, a trap, exhausted fuel) parks the cursor at pend and falls out of it. That is what the old single loop paid for on every record: a vec_len on the frame stack, a reload test, and two byte loads for the trap and exit flags, none of which can change between two records of the same basic block.
: ~ i g_jit_depth 0 // JIT call-out nesting; beyond a cap, callees interpret (1M-deep-safe): ~ i g_pin 1 // tier-7 slot pinning (NURL_NWASM_PIN=0 keeps every slot in memory; A/B, debug): ~ i g_jitdump 0 // NURL_NWASM_JIT_DUMP=1: emit every sealed page as decimal bytes on stderr@ interp_enable_jit → v@ interp_disable_pin → v@ interp_enable_jitdump → v: ~ i g_jit_noax 0Set by an emitter that used a pin-direct form: the record ran without touching rax, so the walk keeps its rax cache instead of assuming the result went through it. Consumed (and reset) once per record.
@ exec_func * Interp it i fidx → v@ interp_flush * Interp it → vFlush every open dirty file — called on proc_exit and when start returns, so buffered writes are never lost to a missing fdclose.
@ interp_net_close_all * Interp it → vClose every socket the guest left open. A wasm module that exits (or traps) mid-flight would otherwise leak the host's descriptors, which matters most for the embedded case: swarm-mcp runs kernels in-process.
: ~ i g_atom_mx 0 // *pthread_mutex_t, the one atomics lock: ~ i g_atom_cv 0 // *pthread_cond_t, where waiters sleep: ~ i g_atom_gen 0 // bumped by every notify; a waiter watches it change: ~ i g_atom_waiters 0 // sleepers right now, for notify's return valuepackages/nwasm/src/module.nu — WebAssembly binary decoder (pure NURL).
Decodes a wasm32 module's structure: the magic/version header and the sections this runtime understands (type, function, table, memory, global, export, element, code, data). Imports and the custom/start sections are skipped. The byte cursor + LEB128 readers here are reused by the interpreter (interp.nu) to walk instruction streams.
: Wc { ( Vec u ) buf i pos i len }@ wc_new ( Vec u ) buf → *Wc@ wc_free * Wc c → vWc does not own buf (the module bytes outlive it); free only the struct.
@ wc_eof * Wc c → b@ wc_u8 * Wc c → i@ wc_peek * Wc c → i@ wc_uleb * Wc c → iUnsigned LEB128 → i (NURL i is 64-bit, covers u32/u64).
Only the first ten groups can carry a 64-bit value. A longer encoding is malformed wasm, and << x shift with shift >= 64 is poison in LLVM (docs/spec.md, § operators) — a corrupted continuation byte turning a terminal group into a running one is exactly how a module reaches it. Groups past the tenth are consumed, so pos still lands after the encoding, but they contribute nothing: the value stays inside 64 bits and every caller's range check still sees a number it can judge.
@ wc_sleb * Wc c → iSigned LEB128 → i (sign-extended).
@ wc_skip * Wc c i n → vSkip n bytes.
@ wc_avail * Wc c → iBytes physically remaining in the input (never negative).
: FuncType { ( Vec i ) params ( Vec i ) results }: WFunc { i typeidx ( Vec i ) locals i code_start i code_end }A defined function: its type index, its declared (non-param) local valtypes expanded flat, and the [start,end) byte range of its instruction stream.
: WExport { ( Vec u ) name i kind i index }: DataSeg { i offset ( Vec u ) bytes i passive }A data segment. Active (passive=0): copied to memory at offset during instantiation. Passive (passive=1): source material for memory.init only.
: ElemSeg { ( Vec i ) funcs i passive }An element segment: function indices (−1 = null ref). Active segments are applied to the table at decode; passive ones feed table.init; declared (and applied active) ones count as dropped at instantiation.
: NameBuf { ( Vec u ) bytes }An owned byte buffer behind an opaque pointer (name-section entries).
: WImport { ( Vec u ) module ( Vec u ) field i typeidx }An imported function (the only import kind this runtime resolves): module + field name select the host (WASI) implementation; typeidx gives its signature. Non-function imports are a decode error (nothing satisfies them).
: Module: Module {
( Vec s ) types // *FuncType
( Vec i ) functypes // type index per defined function
( Vec s ) funcs // *WFunc
( Vec s ) exports // *WExport
( Vec u ) code // the whole module byte image (functions index into it)
i has_mem
i mem_min // initial pages (64 KiB each)
i mem_max // 0 if unbounded
i mem_shared // 1 = threads-proposal shared memory (max is mandatory)
( Vec s ) datas // *DataSeg
( Vec i ) global_init // initial value per global
( Vec i ) global_mut // 1 if mutable
i has_table
( Vec i ) table // initial table image: function indices (−1 = null)
i table_max // declared table maximum (0 = none)
( Vec s ) elems // *ElemSeg — element segments, in order
( Vec s ) imports // *WImport (imported functions, in index order)
i num_import_funcs // imported funcs occupy func indices 0..n-1
i start_func // start-section function index (-1 = none)
( Vec i ) name_idx // function indices with a "name"-section entry…
( Vec s ) name_str // …and their names (*( Vec u )), parallel (sparse)
b ok
( Vec u ) err
}
@ module_free * Module m → v@ module_func_name * Module m i fidx → ( Vec u )The name-section name of function fidx as a fresh byte vector (empty if unknown). Cold path — linear scan is fine (used only for trap backtraces).
@ module_decode ( Vec u ) bytes → *ModuleDecode a whole module. On error, .ok is F and .err carries a message.
@ module_export_global * Module m s name → iFind an exported GLOBAL's index by name (-1 if absent). wasi-threads needs one: __stack_pointer is what gives a spawned thread its own stack, and only the host can set it before the thread's first call.
@ module_export_func * Module m s name → iFind an exported function index by name (-1 if absent).
@ module_func_type * Module m i fidx → sThe *FuncType of any function index — imported (low indices) or defined — as an opaque pointer; #s 0 if out of range.
@ functype_eq * FuncType a * FuncType b → bStructural function-type equality (the call_indirect runtime check): same parameter and result valtypes, in order.
packages/nwasm/src/main.nu — nwasm: a WebAssembly runtime in pure NURL.
nwasm run --invoke <export> <module.wasm> [int args…]
Loads a wasm module and either runs its _start as a wasm32-wasi command or invokes one exported function directly, printing the result. The engine itself is module.nu (decoder) + interp.nu (interpreter and template JIT).
@ usage → v@ run_invoke s export s path i first_arg i argc i allow_gpu i allow_net → i@ run_command s path i prog_start i argc ( Vec String ) dirs ( Vec String ) envs i fuel i allow_gpu i allow_net → iWASI command: run the module's _start with argv = [module, prog args…], the given preopened directories and environment entries.
@ main → iEvery host option is read from the argv PREFIX that ends at the module path — a guest's own --help, --version or --allow-gpu belongs to the guest. (Scanning all of argv for those three meant nwasm run app.wasm --help printed the RUNTIME's usage and never started the module; the bug surfaced on the first guest with a CLI of its own.) -- ends the host options explicitly, for a module path that starts with a dash.