NURLNURL registrynurl-lang.org →

← nwasm

nwasm 1.0.0 API

interp.nu

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.

API

& m @ rint f x → f

round-to-nearest-even (wasm f*.nearest) — libm rint honours the default mode.

& c @ nurl_rand_fill *u buf i n → i

OS entropy (runtime helper; getrandom/urandom under the hood).

& c @ nurl_code_alloc i n → *u

The 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_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 → *u

Guard-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 → i32

The 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 → i32

unlink(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
    // Tier-6 direct-call state. The slab is a bump stack of raw slot
    // frames for JIT execution; the sp cell holds the absolute address of
    // the next free slot; ftab has one direct-entry address per defined
    // function (0 = call through the driver); jit_pure memoizes the
    // no-call-out fixpoint (0 unknown, 1 pure, 2 impure, 3 in progress).
    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 8-byte top-of-stack cell
    // The pause chain: when JIT code needs the driver (imports, grow,
    // call_indirect, the fc bridge), every native frame between the
    // call-out and the driver parks itself here as (page, resume, rbx,
    // args) and returns, so the driver can resume them innermost-first.
    i jit_chain  // chain base address
    i jit_chain_cell  // address of the 8-byte chain-top cell
    ( Vec i ) jit_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 1

Guard-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 → v

Capture 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 → b

Did 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 → v

Append a program argument (copied from a NUL-terminated host string).

@ interp_push_env * Interp it s str → v

Append an environment entry ("NAME=VALUE", copied).

@ interp_allow_gpu * Interp it → v

Enable 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 → v

Enable 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 → v

Grant 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 → *Interp

A 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 default

Execute 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

@ exec_func * Interp it i fidx → v

@ interp_flush * Interp it → v

Flush 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 → v

Close 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 value


module.nu

packages/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.

API

: Wc { ( Vec u ) buf i pos i len }

@ wc_new ( Vec u ) buf → *Wc

@ wc_free * Wc c → v

Wc 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 → i

Unsigned 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 → i

Signed LEB128 → i (sign-extended).

@ wc_skip * Wc c i n → v

Skip n bytes.

@ wc_avail * Wc c → i

Bytes 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 → *Module

Decode a whole module. On error, .ok is F and .err carries a message.

@ module_export_global * Module m s name → i

Find 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 → i

Find an exported function index by name (-1 if absent).

@ module_func_type * Module m i fidx → s

The *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 → b

Structural function-type equality (the call_indirect runtime check): same parameter and result valtypes, in order.


main.nu

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).

API

@ 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 → i

WASI command: run the module's _start with argv = [module, prog args…], the given preopened directories and environment entries.

@ main → i

Every 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.