diff --git a/.github/workflows/precompile.yml b/.github/workflows/precompile.yml index 60699f45e..121d6f903 100644 --- a/.github/workflows/precompile.yml +++ b/.github/workflows/precompile.yml @@ -41,6 +41,16 @@ jobs: --target "${{ matrix.target }}" \ --out artifacts + # Always expose the build as a downloadable run artifact. This lets a + # workflow_dispatch on a feature branch produce a per-branch NIF (e.g. the + # aarch64-macos-none .tar.gz) for local testing without cutting a release. + - name: Upload build artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.target }} + path: artifacts/*.tar.gz + if-no-files-found: error + - name: Upload to release if: startsWith(github.ref, 'refs/tags/') uses: softprops/action-gh-release@v1 diff --git a/lib/quickbeam/native.ex b/lib/quickbeam/native.ex index 89330da73..a0a33fc7b 100644 --- a/lib/quickbeam/native.ex +++ b/lib/quickbeam/native.ex @@ -30,6 +30,7 @@ defmodule QuickBEAM.Native do "-DWASM_ENABLE_LIBC_WASI=0", "-DWASM_ENABLE_MULTI_MODULE=0", "-DWASM_ENABLE_BULK_MEMORY=1", + "-DWASM_ENABLE_BULK_MEMORY_OPT=1", "-DWASM_ENABLE_REF_TYPES=1", "-DWASM_ENABLE_SIMD=0", "-DWASM_ENABLE_TAIL_CALL=1", @@ -67,7 +68,12 @@ defmodule QuickBEAM.Native do "-I#{@c_src_dir}/wamr/shared/mem-alloc", "-I#{@c_src_dir}/wamr/shared/platform/#{if(:os.type() == {:unix, :darwin}, do: "darwin", else: "linux")}" ] - @wamr_cflags @wamr_base_cflags ++ @hidden_cflags + @wamr_cflags @wamr_base_cflags ++ + @hidden_cflags ++ + if(System.get_env("QUICKBEAM_WAMR_NOSAN") == "1", + do: ["-fno-sanitize=undefined", "-fno-sanitize-trap=undefined"], + else: [] + ) @wamr_src (Path.wildcard("priv/c_src/wamr/interpreter/wasm_loader.c") ++ Path.wildcard("priv/c_src/wamr/interpreter/wasm_interp_classic.c") ++ diff --git a/lib/quickbeam/quickbeam.zig b/lib/quickbeam/quickbeam.zig index fb2b32f5b..749e8f896 100644 --- a/lib/quickbeam/quickbeam.zig +++ b/lib/quickbeam/quickbeam.zig @@ -24,6 +24,14 @@ export fn quickbeam_wasm_host_invoke_js(runtime_data: ?*anyopaque, callback_name return worker.quickbeam_wasm_host_invoke_js_impl(runtime_data, callback_name_z, signature_z, raw_args, err_buf, err_buf_size); } +// C-ABI seam for the WAMR memory-alias detach (impl in wasm_js.zig). Lives in +// the root module so the symbol is reliably emitted into the linked NIF; the +// JS host-import boundary (wasm_host_imports.zig) reaches it via an `extern`. +const wasm_js = @import("wasm_js.zig"); +export fn quickbeam_wasm_invalidate_alias_for_inst(runtime_data: ?*anyopaque, module_inst: ?*anyopaque) callconv(.c) void { + wasm_js.quickbeam_wasm_invalidate_alias_for_inst_impl(runtime_data, module_inst); +} + const std = types.std; const beam = @import("beam"); const e = types.e; diff --git a/lib/quickbeam/text_encoding.zig b/lib/quickbeam/text_encoding.zig index b348fd6e5..82dd8e51f 100644 --- a/lib/quickbeam/text_encoding.zig +++ b/lib/quickbeam/text_encoding.zig @@ -293,6 +293,41 @@ fn text_decoder_decode( const ptr = qjs.JS_GetArrayBuffer(ctx, &len, input) orelse return qjs.JS_NewString(ctx, ""); data = @as([*]const u8, @ptrCast(ptr))[0..len]; + } else if (qjs.JS_IsDataView(input)) { + // A DataView is a BufferSource but not a TypedArray, so + // JS_GetTypedArrayBuffer rejects it; read its view window directly. + // (Go's wasm_exec.js loadString passes a DataView here.) + const buffer_val = qjs.JS_GetPropertyStr(ctx, input, "buffer"); + defer qjs.JS_FreeValue(ctx, buffer_val); + if (js.js_is_exception(buffer_val)) return js.js_exception(); + const offset_val = qjs.JS_GetPropertyStr(ctx, input, "byteOffset"); + defer qjs.JS_FreeValue(ctx, offset_val); + if (js.js_is_exception(offset_val)) return js.js_exception(); + const length_val = qjs.JS_GetPropertyStr(ctx, input, "byteLength"); + defer qjs.JS_FreeValue(ctx, length_val); + if (js.js_is_exception(length_val)) return js.js_exception(); + + var byte_offset_i64: i64 = 0; + var byte_len_i64: i64 = 0; + if (qjs.JS_ToInt64(ctx, &byte_offset_i64, offset_val) != 0 or byte_offset_i64 < 0 or + qjs.JS_ToInt64(ctx, &byte_len_i64, length_val) != 0 or byte_len_i64 < 0) + { + return qjs.JS_ThrowTypeError(ctx, "argument must be a BufferSource"); + } + + if (byte_len_i64 == 0) return qjs.JS_NewString(ctx, ""); + + var ab_size: usize = 0; + // A null return with a pending exception (e.g. detached buffer) must + // propagate, not silently decode as "". + const buf_ptr = qjs.JS_GetArrayBuffer(ctx, &ab_size, buffer_val) orelse + return js.js_exception(); + const off: usize = @intCast(byte_offset_i64); + const blen: usize = @intCast(byte_len_i64); + if (off > ab_size or blen > ab_size - off) { + return qjs.JS_ThrowTypeError(ctx, "DataView out of bounds of its ArrayBuffer"); + } + data = @as([*]const u8, @ptrCast(buf_ptr + off))[0..blen]; } else { var byte_offset: usize = 0; var byte_len: usize = 0; diff --git a/lib/quickbeam/wamr.zig b/lib/quickbeam/wamr.zig index 8cba5427c..2d0be9b79 100644 --- a/lib/quickbeam/wamr.zig +++ b/lib/quickbeam/wamr.zig @@ -6,6 +6,7 @@ pub const wamr = @cImport({ @cDefine("WASM_ENABLE_LIBC_WASI", "0"); @cDefine("WASM_ENABLE_MULTI_MODULE", "0"); @cDefine("WASM_ENABLE_BULK_MEMORY", "1"); + @cDefine("WASM_ENABLE_BULK_MEMORY_OPT", "1"); @cDefine("WASM_ENABLE_REF_TYPES", "1"); @cDefine("WASM_ENABLE_SIMD", "0"); @cDefine("WASM_ENABLE_TAIL_CALL", "1"); diff --git a/lib/quickbeam/wasm_host_imports.zig b/lib/quickbeam/wasm_host_imports.zig index d7344189e..dafc09ae7 100644 --- a/lib/quickbeam/wasm_host_imports.zig +++ b/lib/quickbeam/wasm_host_imports.zig @@ -20,6 +20,18 @@ extern fn quickbeam_wasm_host_invoke_js( err_buf_size: u32, ) bool; +// C-ABI seam exported from the root module (quickbeam.zig), delegating to the +// impl in wasm_js.zig. Reached via `extern` rather than an import (wasm_js +// imports THIS module, so a back-import would cycle). Detaches the cached +// aliasing ArrayBuffer for the instance owning `module_inst` if its linear +// memory has moved — called at the JS host-import boundary so a guest that grew +// memory mid-call hands the host a detached (browser-faithful) stale buffer, +// not a dangling alias. +extern fn quickbeam_wasm_invalidate_alias_for_inst( + runtime_data: ?*anyopaque, + module_inst: ?*anyopaque, +) void; + pub const ImportSpec = struct { module_name: []const u8, symbol: []const u8, @@ -95,6 +107,14 @@ fn invoke_host_import(comptime mode: CallbackMode, exec_env: wamr.wasm_exec_env_ const attachment: *Attachment = @ptrCast(@alignCast(attachment_ptr)); var err_buf = std.mem.zeroes([256]u8); + // Before re-entering a JS callback that may read `mem.buffer`, detach the + // cached alias if a prior guest memory.grow in THIS call moved the backing + // store. runtime_data is the JSContext; module_inst maps back to the owning + // instance. (The BEAM path copies memory, so it needs no invalidation.) + if (mode == .js) { + quickbeam_wasm_invalidate_alias_for_inst(attachment.runtime_data, @ptrCast(module_inst)); + } + const ok = switch (mode) { .beam => quickbeam_wasm_host_invoke( attachment.runtime_data, diff --git a/lib/quickbeam/wasm_js.zig b/lib/quickbeam/wasm_js.zig index d09cc3c14..dd804249e 100644 --- a/lib/quickbeam/wasm_js.zig +++ b/lib/quickbeam/wasm_js.zig @@ -12,8 +12,64 @@ const wamr = @import("wamr.zig").wamr; const InstanceEntry = struct { mod: *wamr.WamrModule, managed: *wasm_common.ManagedInstance, + // Cached live-aliased ArrayBuffer over WAMR linear memory (see + // wasm_memory_buffer_impl). `buffer_base`/`buffer_size` record the WAMR + // base+size the cached buffer aliases; when memory grows (base may move, + // size changes) the cached buffer is detached so stale JS views fault + // instead of reading freed/moved memory — matching browser detach-on-grow. + buffer_val: ?qjs.JSValue = null, + buffer_base: [*c]u8 = null, + buffer_size: u32 = 0, }; +// Unconditionally detach + drop the cached aliasing ArrayBuffer. Any JS views +// over it fault afterwards (byteLength 0), matching browser detach-on-grow, and +// the next `.buffer` access mints a fresh alias. +fn detach_cached_buffer(ctx: *qjs.JSContext, entry: *InstanceEntry) void { + const bv = entry.buffer_val orelse return; + qjs.JS_DetachArrayBuffer(ctx, bv); + qjs.JS_FreeValue(ctx, bv); + entry.buffer_val = null; + entry.buffer_base = null; + entry.buffer_size = 0; +} + +// Detach the cached aliasing ArrayBuffer only if WAMR's linear memory has moved +// or resized since it was created (e.g. after a guest memory.grow inside a +// call). Unchanged memory keeps its stable-identity buffer. +fn invalidate_buffer_if_moved(ctx: *qjs.JSContext, entry: *InstanceEntry) void { + if (entry.buffer_val == null) return; + var size: u32 = 0; + const base = wamr.wamr_bridge_memory_data(entry.managed.inst, &size); + if (base == entry.buffer_base and size == entry.buffer_size) return; + detach_cached_buffer(ctx, entry); +} + +// Reverse-map a host-import exec_env's WAMR module instance back to the owning +// InstanceEntry and detach its cached alias if linear memory has moved. Called +// from the JS host-import boundary (wasm_host_imports.zig) BEFORE re-entering a +// JS callback, so a host that grew memory mid-guest-call sees the stale alias +// already detached (browser-faithful) instead of writing into freed/moved +// memory. The C-ABI `export` seam that wasm_host_imports.zig reaches via its +// `extern` declaration lives in the root module (quickbeam.zig) and delegates +// here — only root-module exports are reliably emitted into the linked NIF. +pub fn quickbeam_wasm_invalidate_alias_for_inst_impl( + ctx_raw: ?*anyopaque, + module_inst: ?*anyopaque, +) void { + const ctx: *qjs.JSContext = @ptrCast(@alignCast(ctx_raw orelse return)); + const want = @intFromPtr(module_inst orelse return); + const state = get_context_state(ctx) orelse return; + var it = state.instances.iterator(); + while (it.next()) |e| { + const got = wamr.wamr_bridge_module_inst(e.value_ptr.managed.inst) orelse continue; + if (@intFromPtr(got) == want) { + invalidate_buffer_if_moved(ctx, e.value_ptr); + return; + } + } +} + const ContextState = struct { next_instance_id: u64 = 1, max_reductions: i64 = 0, @@ -77,6 +133,18 @@ pub fn destroy_context(ctx: *qjs.JSContext) void { states_mutex.unlock(); if (removed) |entry| { + // Detach any live aliasing ArrayBuffers BEFORE the WAMR instances (and + // their linear memory) are freed in deinit(), so no QuickJS object is + // left carrying a dangling native pointer. ctx is still valid here + // (destroy_context runs before JS_FreeContext). + var it = entry.value.instances.iterator(); + while (it.next()) |e| { + if (e.value_ptr.buffer_val) |bv| { + qjs.JS_DetachArrayBuffer(ctx, bv); + qjs.JS_FreeValue(ctx, bv); + e.value_ptr.buffer_val = null; + } + } entry.value.deinit(); gpa.destroy(entry.value); } @@ -90,6 +158,7 @@ pub fn install(ctx: *qjs.JSContext, global: qjs.JSValue, max_reductions: i64) vo _ = qjs.JS_SetPropertyStr(ctx, global, "__qb_wasm_memory_size", qjs.JS_NewCFunction(ctx, &wasm_memory_size_impl, "__qb_wasm_memory_size", 1)); _ = qjs.JS_SetPropertyStr(ctx, global, "__qb_wasm_memory_grow", qjs.JS_NewCFunction(ctx, &wasm_memory_grow_impl, "__qb_wasm_memory_grow", 2)); _ = qjs.JS_SetPropertyStr(ctx, global, "__qb_wasm_read_memory", qjs.JS_NewCFunction(ctx, &wasm_read_memory_impl, "__qb_wasm_read_memory", 3)); + _ = qjs.JS_SetPropertyStr(ctx, global, "__qb_wasm_memory_buffer", qjs.JS_NewCFunction(ctx, &wasm_memory_buffer_impl, "__qb_wasm_memory_buffer", 1)); _ = qjs.JS_SetPropertyStr(ctx, global, "__qb_wasm_read_global", qjs.JS_NewCFunction(ctx, &wasm_read_global_impl, "__qb_wasm_read_global", 2)); _ = qjs.JS_SetPropertyStr(ctx, global, "__qb_wasm_write_global", qjs.JS_NewCFunction(ctx, &wasm_write_global_impl, "__qb_wasm_write_global", 3)); } @@ -401,6 +470,10 @@ fn wasm_call_impl( return throw_error(ctx, std.mem.sliceTo(&err_buf, 0)); } + // The call may have executed a guest `memory.grow`, moving the linear + // memory; detach any cached alias so stale JS views don't read freed memory. + invalidate_buffer_if_moved(ctx, entry); + if (result_count == 0) return js.js_undefined(); if (result_count == 1) return wasm_to_js_value(ctx, results[0]); @@ -442,6 +515,10 @@ fn wasm_memory_grow_impl( const entry = get_instance_entry(state, @intCast(instance_id_i64)) orelse return throw_error(ctx, "instance not found"); const grown = wamr.wamr_bridge_memory_grow(entry.managed.inst, @intCast(delta)); if (grown < 0) return throw_error(ctx, "memory grow failed"); + // Browser WebAssembly.Memory.grow detaches the old buffer on EVERY call, + // including grow(0) and in-place growth where the base doesn't move. + // Detach unconditionally so identity + detach-on-grow are browser-faithful. + detach_cached_buffer(ctx, entry); return qjs.JS_NewInt64(ctx, grown); } @@ -472,6 +549,76 @@ fn wasm_read_memory_impl( return make_uint8array(ctx, bytes.ptr, bytes.len); } +// The ArrayBuffer returned by wasm_memory_buffer_impl aliases WAMR's linear +// memory directly; WAMR owns that storage, so the JS GC must not free it. +fn wasm_buffer_noop_free( + _: ?*qjs.JSRuntime, + _: ?*anyopaque, + _: ?*anyopaque, +) callconv(.c) void {} + +fn wasm_memory_buffer_impl( + ctx_opt: ?*qjs.JSContext, + _: qjs.JSValue, + argc: c_int, + argv: [*c]qjs.JSValue, +) callconv(.c) qjs.JSValue { + const ctx = ctx_opt orelse return js.js_exception(); + if (argc < 1) return throw_error(ctx, "instance id required"); + const state = get_context_state(ctx) orelse return throw_error(ctx, "missing wasm context state"); + var instance_id_i64: i64 = 0; + if (qjs.JS_ToInt64(ctx, &instance_id_i64, argv[0]) != 0 or instance_id_i64 < 0) return throw_error(ctx, "invalid instance handle"); + const entry = get_instance_entry(state, @intCast(instance_id_i64)) orelse return throw_error(ctx, "instance not found"); + + var size: u32 = 0; + const base = wamr.wamr_bridge_memory_data(entry.managed.inst, &size); + + // Return the SAME ArrayBuffer object on repeated access (stable identity, + // browser-faithful) as long as the backing store hasn't moved/resized. This + // covers BOTH the live-alias and the zero-page 0-length case below: the + // zero-page buffer is cached with base=null/size=0, so a repeat access + // matches here and dups the same empty object, and a later grow (0 -> N) + // changes base/size and detaches it (here, or via the host-boundary + // invalidate_buffer_if_moved). First access has buffer_val == null and + // skips this — the null/0 struct defaults never false-match an alias. + if (entry.buffer_val) |bv| { + if (base == entry.buffer_base and size == entry.buffer_size) { + return qjs.JS_DupValue(ctx, bv); + } + // Memory grew since the cached buffer was minted: detach it (any JS + // views over it fault) before exposing a fresh alias. + qjs.JS_DetachArrayBuffer(ctx, bv); + qjs.JS_FreeValue(ctx, bv); + entry.buffer_val = null; + } + + if (base == null or size == 0) { + // Zero-page linear memory: the browser returns a 0-length ArrayBuffer, + // not an error (`new WebAssembly.Memory({initial: 0}).buffer`). There is + // nothing to alias, so mint a fresh empty owned (detachable) buffer — + // and cache it (base=null/size=0) so repeated access returns the SAME + // object and a later grow detaches it, matching the live-alias contract. + var empty: [1]u8 = .{0}; + const buf = qjs.JS_NewArrayBufferCopy(ctx, &empty, 0); + if (js.js_is_exception(buf)) return buf; + entry.buffer_val = qjs.JS_DupValue(ctx, buf); + entry.buffer_base = null; + entry.buffer_size = 0; + return buf; + } + + // Live alias: DataView/TypedArray writes on this buffer land directly in + // WAMR linear memory, and reads observe live guest state. No copy. WAMR + // owns the storage, so the free-func is a no-op. + const buf = qjs.JS_NewArrayBuffer(ctx, base, size, &wasm_buffer_noop_free, null, false); + if (js.js_is_exception(buf)) return buf; + // Keep our own ref so we can detach it on the next grow / on teardown. + entry.buffer_val = qjs.JS_DupValue(ctx, buf); + entry.buffer_base = base; + entry.buffer_size = size; + return buf; +} + fn wasm_read_global_impl( ctx_opt: ?*qjs.JSContext, _: qjs.JSValue, diff --git a/priv/c_src/wamr_bridge.c b/priv/c_src/wamr_bridge.c index d0e1cb682..c63302169 100644 --- a/priv/c_src/wamr_bridge.c +++ b/priv/c_src/wamr_bridge.c @@ -432,7 +432,9 @@ wamr_bridge_memory_grow(WamrInstance *inst, uint32_t delta_pages) if (!inst || !inst->inst) return -1; uint32_t cur = wamr_bridge_memory_size(inst) / 65536; - if (!wasm_runtime_enlarge_memory(inst->inst, (cur + delta_pages) * 65536)) + /* wasm_runtime_enlarge_memory takes inc_page_count (pages to ADD), not a + * byte count or an absolute page count. */ + if (!wasm_runtime_enlarge_memory(inst->inst, (uint64_t)delta_pages)) return -1; return (int32_t)cur; } @@ -475,6 +477,33 @@ wamr_bridge_write_memory(WamrInstance *inst, uint32_t offset, return true; } +uint8_t * +wamr_bridge_memory_data(WamrInstance *inst, uint32_t *out_size) +{ + if (out_size) + *out_size = 0; + if (!inst || !inst->inst) + return NULL; + + uint32_t mem_size = wamr_bridge_memory_size(inst); + if (mem_size == 0) + return NULL; + + void *native = wasm_runtime_addr_app_to_native(inst->inst, 0); + if (!native) + return NULL; + + if (out_size) + *out_size = mem_size; + return (uint8_t *)native; +} + +void * +wamr_bridge_module_inst(WamrInstance *inst) +{ + return inst ? inst->inst : NULL; +} + static bool read_global_value(const wasm_global_inst_t *global, wasm_val_t *value, char *err_buf, uint32_t err_buf_size) diff --git a/priv/c_src/wamr_bridge.h b/priv/c_src/wamr_bridge.h index 401b9beba..245a2b09f 100644 --- a/priv/c_src/wamr_bridge.h +++ b/priv/c_src/wamr_bridge.h @@ -98,6 +98,18 @@ bool wamr_bridge_read_memory(WamrInstance *inst, uint32_t offset, bool wamr_bridge_write_memory(WamrInstance *inst, uint32_t offset, const uint8_t *buf, uint32_t len); +/* Native base pointer of the instance's linear memory (for live aliasing). + * Returns NULL and sets *out_size to 0 when no memory is present. The + * returned pointer is owned by WAMR and is valid until the memory grows or + * the instance is destroyed; callers must not free it. */ +uint8_t *wamr_bridge_memory_data(WamrInstance *inst, uint32_t *out_size); + +/* The underlying WAMR module instance backing this WamrInstance, as an opaque + * pointer. Lets code that cannot see the opaque struct internals reverse-map a + * host-import exec_env's module instance (wasm_runtime_get_module_inst) back to + * the owning WamrInstance. Returns NULL for a NULL input. */ +void *wamr_bridge_module_inst(WamrInstance *inst); + bool wamr_bridge_read_global(WamrInstance *inst, const char *name, wasm_val_t *value, char *err_buf, uint32_t err_buf_size); diff --git a/priv/ts/webassembly.ts b/priv/ts/webassembly.ts index 4c06d9b23..1e9da2d0c 100644 --- a/priv/ts/webassembly.ts +++ b/priv/ts/webassembly.ts @@ -74,6 +74,8 @@ declare function __qb_wasm_read_memory( length: number ): BufferSource +declare function __qb_wasm_memory_buffer(instanceHandle: number): ArrayBuffer + declare function __qb_wasm_read_global(instanceHandle: number, name: string): unknown declare function __qb_wasm_write_global( @@ -179,12 +181,12 @@ class WasmMemory { } const handle = this._handle - const size = qbWasmCall(() => __qb_wasm_memory_size(handle), 'memory size failed') as number - const bytes = qbWasmCall( - () => __qb_wasm_read_memory(handle, 0, size), - 'memory read failed' - ) as BufferSource - return wasmToUint8Array(bytes).slice().buffer + // Live alias of WAMR linear memory: host writes via DataView/TypedArray + // propagate into the guest, and reads observe live guest state. The same + // ArrayBuffer object is returned on repeated access until a memory.grow + // moves the backing store, at which point the native side detaches the old + // buffer (browser detach-on-grow) and this returns a fresh alias. + return qbWasmCall(() => __qb_wasm_memory_buffer(handle), 'memory buffer failed') as ArrayBuffer } grow(delta: number): number { diff --git a/test/wasm_test.exs b/test/wasm_test.exs index b0f783f8f..3bb4b23e8 100644 --- a/test/wasm_test.exs +++ b/test/wasm_test.exs @@ -476,6 +476,34 @@ defmodule QuickBEAM.WASMTest do @custom_section_wasm <<0x00, 0x61, 0x73, 0x6D, 0x01, 0x00, 0x00, 0x00, 0x00, 0x08, 0x04, 0x6D, 0x65, 0x74, 0x61, 0x61, 0x62, 0x63>> + # Bulk-memory regression fixture. Exercises every opcode gated by the WAMR + # config flags WASM_ENABLE_BULK_MEMORY / _OPT (priv/c_src/wamr/config.h): + # memory.fill (fc 0b), memory.copy (fc 0a) <- _OPT (fc 0a is the blocker) + # memory.init (fc 08), data.drop (fc 09) <- BULK_MEMORY + # Toolchains like Go's GOOS=js GOARCH=wasm, TinyGo, and Rust wasm-bindgen emit + # these unconditionally; with the gates off WAMR rejects them at compile time + # with "unsupported opcode fc 0a". + # + # WAT (assembled with wat2wasm, byte-exact-verified against a reference runtime): + # (module + # (memory (export "mem") 1) + # (data $seg "\de\ad\be\ef") + # (func (export "run") (result i32) + # (memory.fill (i32.const 0) (i32.const 0xAB) (i32.const 16)) + # (memory.copy (i32.const 100) (i32.const 0) (i32.const 16)) + # (memory.init $seg (i32.const 200) (i32.const 0) (i32.const 4)) + # (data.drop $seg) + # (i32.add + # (i32.mul (i32.load8_u (i32.const 100)) (i32.const 256)) + # (i32.load8_u (i32.const 200))))) + # run() = load8_u(100)*256 + load8_u(200) = 0xAB*256 + 0xDE = 171*256 + 222 = 43998. + @bulk_memory_wasm <<0, 97, 115, 109, 1, 0, 0, 0, 1, 5, 1, 96, 0, 1, 127, 3, 2, 1, 0, 5, 3, 1, 0, + 1, 7, 13, 2, 3, 109, 101, 109, 2, 0, 3, 114, 117, 110, 0, 0, 12, 1, 1, 10, + 56, 1, 54, 0, 65, 0, 65, 171, 1, 65, 16, 252, 11, 0, 65, 228, 0, 65, 0, 65, + 16, 252, 10, 0, 0, 65, 200, 1, 65, 0, 65, 4, 252, 8, 0, 0, 252, 9, 0, 65, + 228, 0, 45, 0, 0, 65, 128, 2, 108, 65, 200, 1, 45, 0, 0, 106, 11, 11, 7, 1, + 1, 4, 222, 173, 190, 239>> + describe "disasm/1" do test "parses a minimal add module" do assert {:ok, %Module{} = mod} = WASM.disasm(@add_wasm) @@ -654,6 +682,17 @@ defmodule QuickBEAM.WASMTest do end end + describe "bulk memory opcodes (WASM_ENABLE_BULK_MEMORY_OPT)" do + test "runs memory.fill/copy/init + data.drop (fc 08–0b)" do + # run() = load8_u(100)*256 + load8_u(200) after fill→copy→init→data.drop. + # Without WASM_ENABLE_BULK_MEMORY_OPT this module fails to compile with + # "unsupported opcode fc 0a". + {:ok, pid} = WASM.start(module: @bulk_memory_wasm) + assert {:ok, 43_998} = WASM.call(pid, "run", []) + WASM.stop(pid) + end + end + describe "supervision" do test "child_spec works" do spec = QuickBEAM.WASM.child_spec(name: :test_wasm, module: @add_wasm) @@ -923,6 +962,15 @@ defmodule QuickBEAM.WASMTest do assert result == ["abc"] end + test "WebAssembly.instantiate compiles bulk-memory opcodes (memory.copy, fc 0a)", %{rt: rt} do + {:ok, 43_998} = + QuickBEAM.eval(rt, """ + const bytes = new Uint8Array([#{Enum.join(:binary.bin_to_list(@bulk_memory_wasm), ", ")}]); + const {instance} = await WebAssembly.instantiate(bytes); + instance.exports.run(); + """) + end + test "WebAssembly.compileStreaming", %{rt: rt} do {:ok, result} = QuickBEAM.eval(rt, """ @@ -946,6 +994,169 @@ defmodule QuickBEAM.WASMTest do end end + describe "live Memory.buffer + TextDecoder DataView (Go-wasm host memory)" do + setup do + {:ok, rt} = QuickBEAM.start() + %{rt: rt} + end + + # (module + # (import "env" "fill" (func $fill (param i32))) + # (memory (export "mem") 1) + # (func (export "test") (result i32) + # (call $fill (i32.const 16)) ;; host writes at mem[16] via mem.buffer + # (i32.load (i32.const 16)))) ;; guest reads it back + @memwrite_bytes """ + new Uint8Array([ + 0,97,115,109,1,0,0,0,1,9,2,96,1,127,0,96,0,1,127,2,12,1,3,101,110,118, + 4,102,105,108,108,0,0,3,2,1,1,5,3,1,0,1,7,14,2,3,109,101,109,2,0,4,116, + 101,115,116,0,1,10,13,1,11,0,65,16,16,0,65,16,40,2,0,11 + ]) + """ + + # (module + # (import "env" "capture" (func $capture)) ;; host grabs mem.buffer + # (import "env" "check" (func $check)) ;; host re-entry after grow + # (memory (export "mem") 1) + # (func (export "test") + # (call $capture) ;; capture mem.buffer (pre-grow) + # (drop (memory.grow (i32.const 1)));; grow → backing store moves + # (call $check))) ;; host re-entry; alias must be detached + @reentry_bytes """ + new Uint8Array([ + 0,97,115,109,1,0,0,0,1,4,1,96,0,0,2,27,2,3,101,110,118,7,99,97,112,116, + 117,114,101,0,0,3,101,110,118,5,99,104,101,99,107,0,0,3,2,1,0,5,3,1,0,1, + 7,14,2,3,109,101,109,2,0,4,116,101,115,116,0,2,10,13,1,11,0,16,0,65,1,64, + 0,26,16,1,11 + ]) + """ + + # (module (memory (export "mem") 0)) ;; zero-page memory + @zeropage_bytes """ + new Uint8Array([0,97,115,109,1,0,0,0,5,3,1,0,0,7,7,1,3,109,101,109,2,0]) + """ + + test "host writes through Memory.buffer DataView are visible to the guest", %{rt: rt} do + # The import callback writes 123456 at mem[16] via new DataView(mem.buffer); + # the guest then i32.load's mem[16]. A copy-based buffer would read 0. + assert {:ok, 123_456} = + QuickBEAM.eval(rt, """ + const bytes = #{@memwrite_bytes}; + const holder = {}; + const imp = { env: { fill: (ptr) => { + new DataView(holder.inst.exports.mem.buffer).setInt32(ptr, 123456, true); + }}}; + const { instance } = await WebAssembly.instantiate(bytes, imp); + holder.inst = instance; + instance.exports.test(); + """) + end + + test "Memory.buffer has stable identity until grow", %{rt: rt} do + assert {:ok, true} = + QuickBEAM.eval(rt, """ + const bytes = #{@memwrite_bytes}; + const { instance } = await WebAssembly.instantiate(bytes, {env: {fill() {}}}); + instance.exports.mem.buffer === instance.exports.mem.buffer; + """) + end + + test "memory.grow detaches the old buffer; a fresh buffer reflects the new size", %{rt: rt} do + # Browser detach-on-grow: after grow the previously handed-out buffer is + # detached (byteLength 0) and a fresh alias reflects the grown memory. + assert {:ok, [65_536, 0, 131_072, true]} = + QuickBEAM.eval(rt, """ + const bytes = #{@memwrite_bytes}; + const { instance } = await WebAssembly.instantiate(bytes, {env: {fill() {}}}); + const mem = instance.exports.mem; + const buf0 = mem.buffer; + const before = buf0.byteLength; + mem.grow(1); + const detached = buf0.byteLength; + const buf1 = mem.buffer; + [before, detached, buf1.byteLength, buf0 !== buf1]; + """) + end + + test "memory.grow(0) still detaches the buffer (browser-faithful)", %{rt: rt} do + # Browsers detach on EVERY grow call, including a no-op grow(0) where the + # backing store neither moves nor resizes. + assert {:ok, [0, 65_536, true]} = + QuickBEAM.eval(rt, """ + const bytes = #{@memwrite_bytes}; + const { instance } = await WebAssembly.instantiate(bytes, {env: {fill() {}}}); + const mem = instance.exports.mem; + const buf0 = mem.buffer; + mem.grow(0); + const buf1 = mem.buffer; + [buf0.byteLength, buf1.byteLength, buf0 !== buf1]; + """) + end + + test "a buffer captured before an in-call grow is detached at the next host re-entry", %{rt: rt} do + # test() calls capture (host grabs mem.buffer), then grows memory, then + # calls check (a second host re-entry) — all within one guest call. The + # fix detaches the moved alias at the host-import boundary, so the captured + # buffer reads byteLength 0 by the time check runs (instead of aliasing + # freed/moved memory, as Go's wasm_exec.js cached DataView would). + assert {:ok, [65_536, 0]} = + QuickBEAM.eval(rt, """ + const bytes = #{@reentry_bytes}; + const holder = {}; + const imp = { env: { + capture: () => { holder.buf = holder.inst.exports.mem.buffer; holder.before = holder.buf.byteLength; }, + check: () => { holder.after = holder.buf.byteLength; }, + }}; + const { instance } = await WebAssembly.instantiate(bytes, imp); + holder.inst = instance; + instance.exports.test(); + [holder.before, holder.after]; + """) + end + + test "zero-page memory exposes a stable 0-length buffer instead of throwing", %{rt: rt} do + # The 0-length buffer must also honor the stable-identity contract: + # repeated `.buffer` access returns the SAME object (browser-faithful), + # so the empty buffer is cached like a live alias, not re-minted per call. + assert {:ok, [true, 0]} = + QuickBEAM.eval(rt, """ + const bytes = #{@zeropage_bytes}; + const { instance } = await WebAssembly.instantiate(bytes); + const a = instance.exports.mem.buffer; + const b = instance.exports.mem.buffer; + [a === b, a.byteLength]; + """) + end + + test "growing zero-page memory replaces the cached empty buffer with a live alias", %{rt: rt} do + # The cached 0-length buffer must be invalidated on a 0 -> N grow: the + # pre-grow object is no longer returned, and a fresh stably-cached alias + # of the grown size takes its place. Exercises the zero-page cache's + # detach-on-grow path (the live-alias detach itself is covered above for + # non-zero memory; here byteLength-0 is degenerate, so we assert via + # object identity + the grown size). + assert {:ok, [true, true, 65_536, true]} = + QuickBEAM.eval(rt, """ + const bytes = #{@zeropage_bytes}; + const { instance } = await WebAssembly.instantiate(bytes); + const mem = instance.exports.mem; + const buf0 = mem.buffer; + const stableBefore = buf0 === mem.buffer; + mem.grow(1); + const buf1 = mem.buffer; + [stableBefore, buf0 !== buf1, buf1.byteLength, buf1 === mem.buffer]; + """) + end + + test "TextDecoder.decode accepts a DataView, including a non-zero byteOffset", %{rt: rt} do + assert {:ok, "ello"} = + QuickBEAM.eval(rt, """ + const buf = new Uint8Array([72, 101, 108, 108, 111]).buffer; + new TextDecoder().decode(new DataView(buf, 1, 4)); + """) + end + end + describe "edge cases" do test "empty module" do wasm = <<0x00, 0x61, 0x73, 0x6D, 0x01, 0x00, 0x00, 0x00>>