#!/usr/bin/env node // wasmrun: minimal standalone runner for wackywasm userspace binaries. // Used in Phase 1 to validate the toolchain WITHOUT booting a kernel. // It implements just enough of the "linux" import namespace (write, read, // exit_group, brk, set_tid_address) to run hello world. // // Usage: wasmrun path/to/binary.wasm [args...] // // In real operation, HardwareJS plays this role; this script is a fixture // for testing. // // MODULE FORMAT (ticket #10): this file must load in BOTH module systems. // The package's `"type": "module"` makes Node parse this extensionless // entry as ESM, so the old CommonJS `require()` crashed on startup // ("require is not defined in ES module scope") — while a copy taken // OUTSIDE the package (e.g. an /opt install) is parsed as CJS, where // `import` would be the crash instead. process.getBuiltinModule (Node >= // 22.3) is format-agnostic: no require, no import, valid either way. 'use strict'; const fs = process.getBuiltinModule('node:fs'); const SYS_read = 0; const SYS_write = 1; const SYS_close = 3; const SYS_mmap = 9; const SYS_mprotect = 10; const SYS_munmap = 11; const SYS_brk = 12; const SYS_writev = 20; const SYS_getpid = 39; const SYS_exit = 60; const SYS_arch_prctl = 158; const SYS_gettid = 186; const SYS_set_tid_address = 218; const SYS_clock_gettime = 228; const SYS_exit_group = 231; function syscallName(nr) { const map = { [SYS_read]: 'read', [SYS_write]: 'write', [SYS_close]: 'close', [SYS_brk]: 'brk', [SYS_writev]: 'writev', [SYS_getpid]: 'getpid', [SYS_exit]: 'exit', [SYS_exit_group]: 'exit_group', [SYS_set_tid_address]: 'set_tid_address', [SYS_arch_prctl]: 'arch_prctl', [SYS_gettid]: 'gettid', [SYS_clock_gettime]: 'clock_gettime', }; return map[nr] || `unknown(${nr})`; } async function main() { const argv = process.argv.slice(2); if (argv.length < 1) { console.error('usage: wasmrun program.wasm [args...]'); process.exit(2); } const wasmPath = argv[0]; const bytes = fs.readFileSync(wasmPath); // Read the import minimum from the .wasm so we always satisfy it. function readMemoryMinFromWasm(bytes) { // Walk sections; section id 2 is the import section. if (bytes[0] !== 0 || bytes[1] !== 0x61 || bytes[2] !== 0x73 || bytes[3] !== 0x6d) return 16; let off = 8; function readULEB() { let r = 0, s = 0, b; do { b = bytes[off++]; r |= (b & 0x7f) << s; s += 7; } while (b & 0x80); return r; } function readName() { const len = readULEB(); const s = Buffer.from(bytes.subarray(off, off + len)).toString('utf8'); off += len; return s; } while (off < bytes.length) { const id = bytes[off++]; const sz = readULEB(); const end = off + sz; if (id === 2) { const n = readULEB(); for (let i = 0; i < n; i++) { const mod = readName(); const name = readName(); const kind = bytes[off++]; if (kind === 2) { // memory const flags = bytes[off++]; const min = readULEB(); if (flags & 0x01) readULEB(); // max /* wasmld links with --import-memory=env,user_memory; older * images used env.memory. Honor whichever the image declares * (ticket #10: matching only env.memory sent user_memory * images to the 32-page fallback -> LinkError at import). */ if (mod === 'env' && (name === 'user_memory' || name === 'memory')) return min; } else if (kind === 0 || kind === 3) { readULEB(); } else if (kind === 1) { readULEB(); // table elemtype byte + limits const tflags = bytes[off++]; readULEB(); if (tflags & 0x01) readULEB(); } } return 16; } off = end; } return 16; } const INITIAL_PAGES = Math.max(readMemoryMinFromWasm(bytes), 32); const MAX_PAGES = 16384; const memory = new WebAssembly.Memory({ initial: INITIAL_PAGES, maximum: MAX_PAGES, shared: true, }); let brk = 0; // set below // mmap bump tip: park anonymous mappings far above the initial layout so // they never collide with brk growth (both grow the same linear memory). let mmapTip = (INITIAL_PAGES + 256) * 65536; const u8 = () => new Uint8Array(memory.buffer); const u32 = () => new Uint32Array(memory.buffer); const verbose = !!process.env.WASMRUN_VERBOSE; function trace(...args) { if (verbose) console.error('[wasmrun]', ...args); } function syscall(nr, a0, a1, a2, a3, a4, a5) { trace(syscallName(nr), { a0, a1, a2, a3, a4, a5 }); switch (nr) { case SYS_write: { const fd = a0 | 0, buf = a1 >>> 0, len = a2 >>> 0; if (fd !== 1 && fd !== 2) return -9; // -EBADF const slice = u8().subarray(buf, buf + len); const out = fd === 2 ? process.stderr : process.stdout; out.write(Buffer.from(slice)); return len; } case SYS_writev: { const fd = a0 | 0, iov = a1 >>> 0, iovcnt = a2 >>> 0; if (fd !== 1 && fd !== 2) return -9; const v = u32(); let total = 0; const out = fd === 2 ? process.stderr : process.stdout; for (let i = 0; i < iovcnt; i++) { const base = v[(iov >> 2) + i * 2 + 0]; const len = v[(iov >> 2) + i * 2 + 1]; out.write(Buffer.from(u8().subarray(base, base + len))); total += len; } return total; } case SYS_read: { const fd = a0 | 0; if (fd !== 0) return -9; // Non-blocking, returns 0 (EOF) for now. return 0; } case SYS_brk: { const want = a0 >>> 0; if (want === 0) return brk; const pageSize = 65536; const currentBytes = memory.buffer.byteLength; if (want > currentBytes) { const need = Math.ceil((want - currentBytes) / pageSize); try { memory.grow(need); } catch { return brk; } } brk = want; return brk; } case SYS_mmap: { /* Anonymous-mapping bump allocator: musl's malloc falls back to * mmap for large blocks and the coro runtime's frame arena grows * via raw SYS_mmap. Grow the memory to back the mapping. */ const len = a1 >>> 0; const pageSize = 65536; let tip = mmapTip; tip = (tip + 4095) & ~4095; const mapEnd = tip + len; if (mapEnd > memory.buffer.byteLength) { const need = Math.ceil((mapEnd - memory.buffer.byteLength) / pageSize); try { memory.grow(need); } catch { return -12; } // -ENOMEM } mmapTip = mapEnd; return tip; } case SYS_mprotect: case SYS_munmap: return 0; case SYS_close: return 0; case SYS_set_tid_address: return 1; case SYS_arch_prctl: return 0; case SYS_getpid: case SYS_gettid: return 1; case SYS_clock_gettime: { const tsp = a1 >>> 0; const now = process.hrtime.bigint(); const sec = now / 1000000000n; const nsec = now % 1000000000n; const dv = new DataView(memory.buffer); // struct timespec { i64 tv_sec; i64 tv_nsec; } -- but on wasm32 it's // { i32 tv_sec; i32 tv_nsec; } when long=int32. Use i64 to be safe. dv.setBigInt64(tsp, sec, true); dv.setBigInt64(tsp + 8, nsec, true); return 0; } case SYS_exit: case SYS_exit_group: process.exit(a0 & 0xff); return 0; default: process.stderr.write(`wasmrun: ENOSYS ${syscallName(nr)} (nr=${nr})\n`); return -38; // -ENOSYS } } const imports = { /* Match the wasmld wrapper's --import-memory=env,user_memory. * Earlier wasmrun used env.memory matching wasmld's default * import name; the K5-C2 spawn-handler integration switched * the wasmld import to env.user_memory so it lines up with * the HardwareJS worker.mjs convention. wasmrun follows. */ env: { user_memory: memory, memory }, /* Coro-arm images import the park boundary. This standalone runner * has no pump to service a park (no fork, no reschedule), so park is * a no-op: the parked continuation resumes immediately, which is * correct for the degenerate single-task case this fixture serves. * A fork park under wasmrun would misbehave — HardwareJS is the real * host; this is a toolchain-validation fixture only. */ hwjs_suspend: { park: (reason) => { if (verbose) console.error('[wasmrun] hwjs_suspend.park reason=', reason, '(no-op: standalone runner has no pump)'); return 0; }, }, linux: { /* The wasm32 syscall ABI returns `long` (i32 on wasm32). The * kernel-side dispatcher signals errors via negative values * which fit in i32. wasmrun previously wrapped this in * BigInt, but wasm32 binaries import the result as i32 so * the BigInt conversion caused a TypeError when the * imported function actually returned. K5 hello-world is * the first userspace .wasm that lands real syscalls * through this path; the fix is to return a plain number. */ syscall: (...a) => syscall(...a) | 0, now_ns: () => process.hrtime.bigint(), dmesg: (level, buf, len) => { const slice = u8().subarray(buf, buf + len); process.stderr.write(`[dmesg ${level}] ` + Buffer.from(slice).toString('utf8')); }, }, }; const { instance } = await WebAssembly.instantiate(bytes, imports); // initialize brk to top of static data brk = (instance.exports.__heap_base?.value) ?? (instance.exports.__data_end?.value) ?? 0; // Lane-0 attach at fresh-image bring-up (PLAN-THREADS §4 step 4): the // kernel-packaged RT is laned now and its occupancy table refuses RT ops on // an unattached lane (the two-driver detector). This runner IS the image's // driver, so it self-attaches lane 0 exactly like the production pump // (worker.mjs fresh-image bring-up). Un-laned RT artifacts export no // __hwjs_lane_attach: byte-for-byte pre-lane behavior. if (typeof instance.exports.__hwjs_lane_attach === 'function') { if (typeof instance.exports.__wasm32_set_lane_id === 'function') instance.exports.__wasm32_set_lane_id(0); const laneGen = instance.exports.__hwjs_lane_attach(0) | 0; if (laneGen <= 0) { throw new Error(`hwjs-lanes: lane-0 attach REFUSED (rc=${laneGen}) at fresh-image bring-up — ` + 'the occupancy table says lane 0 is already driven (two-driver shape) or the RT/lane-id binding is skewed.'); } } // F86 budget policy: the pass owns the weak-0 storage and its startup-sanity // ctor traps unless the driver binds a budget before the ctors. This runner // IS the driver for a USER image, so it binds what the pump binds a user // image (worker.mjs HWJS_USER_INITIAL_BUDGET, same env knob), through the // accessor. An image that exports none is un-budgeted: unchanged behavior. // BRINTOS-WORKAROUND: brintos/brintium#4 revisit — this block IS the fix and lives on emc-dev, so it is not removed when the issue closes: when #4 closes, check that main's brintrun binds the budget before the ctors and drop it at the next emc-dev rebase. if (typeof instance.exports.__hwjs_budget_set === 'function') { const b = parseInt(process.env.HWJS_INITIAL_BUDGET || '', 10); instance.exports.__hwjs_budget_set((Number.isFinite(b) && b > 0) ? b : 16384); } if (instance.exports.__wasm_call_ctors) instance.exports.__wasm_call_ctors(); try { const rc = instance.exports._start(); if (typeof rc === 'number') process.exit(rc & 0xff); process.exit(0); } catch (e) { if (e && e.message && e.message.includes('unreachable')) { // _start exited via process.exit(); not actually an error return; } throw e; } } main().catch((e) => { console.error(e); process.exit(1); });