From a1fdde33451a0e38fa745e1703f94241f9ea5b8b Mon Sep 17 00:00:00 2001 From: "E.FU" Date: Thu, 18 Jun 2026 08:26:04 +0800 Subject: [PATCH 1/9] Enable bulk-memory copy/fill opcodes in WAMR Go's GOOS=js GOARCH=wasm compiler (and TinyGo, Rust wasm-bindgen) emit bulk-memory opcodes unconditionally. WASM_ENABLE_BULK_MEMORY and WASM_ENABLE_REF_TYPES were already set via -D in native.ex, but the _OPT sub-feature gating memory.copy (fc 0a) / memory.fill (fc 0b) was not, so any such module failed to instantiate with 'unsupported opcode fc 0a'. Add -DWASM_ENABLE_BULK_MEMORY_OPT=1 beside the existing flags (the build's single source of truth; the vendored config.h #ifndef defaults are overridden by these -D defines). Regression test runs a module exercising all four bulk-memory opcodes (memory.fill/copy/init + data.drop) via both the native WASM API and the JS WebAssembly API, asserting run() == 43998. --- lib/quickbeam/native.ex | 1 + test/wasm_test.exs | 48 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/lib/quickbeam/native.ex b/lib/quickbeam/native.ex index 89330da73..13b97597d 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", diff --git a/test/wasm_test.exs b/test/wasm_test.exs index b0f783f8f..d64867818 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, """ From 3b8245e4079c40252796bed1d385d5cabc5bd31b Mon Sep 17 00:00:00 2001 From: "E.FU" Date: Thu, 18 Jun 2026 08:26:04 +0800 Subject: [PATCH 2/9] Publish precompiled NIFs as per-branch CI artifacts The precompile workflow only uploaded to a GitHub release on v* tags, so a workflow_dispatch run on a feature branch built the NIF but left it unreachable. Add an actions/upload-artifact step (always) so a manual dispatch on any branch yields a downloadable per-target build (e.g. aarch64-macos-none) without cutting a release. --- .github/workflows/precompile.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) 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 From 31f0b23a218f70d8de18d8b3dfc8d69be7e33860 Mon Sep 17 00:00:00 2001 From: "E.FU" Date: Thu, 18 Jun 2026 12:52:57 +0800 Subject: [PATCH 3/9] Mirror WASM_ENABLE_BULK_MEMORY_OPT in wamr.zig cImport defines --- lib/quickbeam/wamr.zig | 1 + 1 file changed, 1 insertion(+) 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"); From 47a1c8a7bd5e95fd42a385c1ebf1d1e7c8f5dbc0 Mon Sep 17 00:00:00 2001 From: "E.FU" Date: Thu, 18 Jun 2026 12:53:07 +0800 Subject: [PATCH 4/9] Add QUICKBEAM_WAMR_NOSAN escape hatch to disable WAMR sanitize traps --- lib/quickbeam/native.ex | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/quickbeam/native.ex b/lib/quickbeam/native.ex index 13b97597d..a0a33fc7b 100644 --- a/lib/quickbeam/native.ex +++ b/lib/quickbeam/native.ex @@ -68,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") ++ From 3bbac3f1fc782f084dff3b243f6f6f917f847561 Mon Sep 17 00:00:00 2001 From: "E.FU" Date: Thu, 18 Jun 2026 13:32:17 +0800 Subject: [PATCH 5/9] fix: live-alias WebAssembly.Memory.buffer to WAMR linear memory --- lib/quickbeam/wasm_js.zig | 31 +++++++++++++++++++++++++++++++ priv/c_src/wamr_bridge.c | 21 +++++++++++++++++++++ priv/c_src/wamr_bridge.h | 6 ++++++ priv/ts/webassembly.ts | 13 +++++++------ 4 files changed, 65 insertions(+), 6 deletions(-) diff --git a/lib/quickbeam/wasm_js.zig b/lib/quickbeam/wasm_js.zig index d09cc3c14..5386ef604 100644 --- a/lib/quickbeam/wasm_js.zig +++ b/lib/quickbeam/wasm_js.zig @@ -90,6 +90,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)); } @@ -472,6 +473,36 @@ 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); + if (base == null or size == 0) return throw_error(ctx, "memory not available"); + + // Live alias: DataView/TypedArray writes on this buffer land directly in + // WAMR linear memory, and reads observe live guest state. No copy. + return qjs.JS_NewArrayBuffer(ctx, base, size, &wasm_buffer_noop_free, null, false); +} + 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..64fb5b99b 100644 --- a/priv/c_src/wamr_bridge.c +++ b/priv/c_src/wamr_bridge.c @@ -475,6 +475,27 @@ 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; +} + 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..3460640a8 100644 --- a/priv/c_src/wamr_bridge.h +++ b/priv/c_src/wamr_bridge.h @@ -98,6 +98,12 @@ 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); + 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..cc8680f23 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,11 @@ 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. A grow + // may move the backing store, so callers must re-read `.buffer` afterward + // (matching browser detach-on-grow semantics). + return qbWasmCall(() => __qb_wasm_memory_buffer(handle), 'memory buffer failed') as ArrayBuffer } grow(delta: number): number { From e55a883c61b4fc70a52faae9c10508f8f56a9724 Mon Sep 17 00:00:00 2001 From: "E.FU" Date: Thu, 18 Jun 2026 13:32:17 +0800 Subject: [PATCH 6/9] fix: accept DataView in TextDecoder.decode --- lib/quickbeam/text_encoding.zig | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/lib/quickbeam/text_encoding.zig b/lib/quickbeam/text_encoding.zig index b348fd6e5..46603350d 100644 --- a/lib/quickbeam/text_encoding.zig +++ b/lib/quickbeam/text_encoding.zig @@ -293,6 +293,29 @@ 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); + const offset_val = qjs.JS_GetPropertyStr(ctx, input, "byteOffset"); + defer qjs.JS_FreeValue(ctx, offset_val); + const length_val = qjs.JS_GetPropertyStr(ctx, input, "byteLength"); + defer qjs.JS_FreeValue(ctx, length_val); + + 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"); + } + + var ab_size: usize = 0; + const buf_ptr = qjs.JS_GetArrayBuffer(ctx, &ab_size, buffer_val) orelse + return qjs.JS_NewString(ctx, ""); + data = @as([*]const u8, @ptrCast(buf_ptr + @as(usize, @intCast(byte_offset_i64))))[0..@intCast(byte_len_i64)]; } else { var byte_offset: usize = 0; var byte_len: usize = 0; From f3a5952c8a761eb99f32b17f569a10dd0c316c5c Mon Sep 17 00:00:00 2001 From: "E.FU" Date: Thu, 18 Jun 2026 14:05:35 +0800 Subject: [PATCH 7/9] harden: detach live mem.buffer on grow/teardown, bounds-check DataView, regression tests --- lib/quickbeam/text_encoding.zig | 16 +++++++- lib/quickbeam/wasm_js.zig | 65 ++++++++++++++++++++++++++++++++- priv/c_src/wamr_bridge.c | 4 +- priv/ts/webassembly.ts | 7 ++-- test/wasm_test.exs | 54 +++++++++++++++++++++++++++ 5 files changed, 138 insertions(+), 8 deletions(-) diff --git a/lib/quickbeam/text_encoding.zig b/lib/quickbeam/text_encoding.zig index 46603350d..82dd8e51f 100644 --- a/lib/quickbeam/text_encoding.zig +++ b/lib/quickbeam/text_encoding.zig @@ -299,10 +299,13 @@ fn text_decoder_decode( // (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; @@ -312,10 +315,19 @@ fn text_decoder_decode( 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 qjs.JS_NewString(ctx, ""); - data = @as([*]const u8, @ptrCast(buf_ptr + @as(usize, @intCast(byte_offset_i64))))[0..@intCast(byte_len_i64)]; + 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/wasm_js.zig b/lib/quickbeam/wasm_js.zig index 5386ef604..8451c0e73 100644 --- a/lib/quickbeam/wasm_js.zig +++ b/lib/quickbeam/wasm_js.zig @@ -12,8 +12,31 @@ 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, }; +// Detach + drop the cached aliasing ArrayBuffer if WAMR's linear memory has +// moved or resized since it was created (e.g. after a guest or host +// memory.grow). The next `.buffer` access then mints a fresh alias. +fn invalidate_buffer_if_moved(ctx: *qjs.JSContext, entry: *InstanceEntry) void { + const bv = entry.buffer_val orelse 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; + qjs.JS_DetachArrayBuffer(ctx, bv); + qjs.JS_FreeValue(ctx, bv); + entry.buffer_val = null; + entry.buffer_base = null; + entry.buffer_size = 0; +} + const ContextState = struct { next_instance_id: u64 = 1, max_reductions: i64 = 0, @@ -77,6 +100,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); } @@ -402,6 +437,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]); @@ -443,6 +482,8 @@ 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"); + // Detach the cached alias: the backing store may have moved/resized. + invalidate_buffer_if_moved(ctx, entry); return qjs.JS_NewInt64(ctx, grown); } @@ -498,9 +539,29 @@ fn wasm_memory_buffer_impl( const base = wamr.wamr_bridge_memory_data(entry.managed.inst, &size); if (base == null or size == 0) return throw_error(ctx, "memory not available"); + // Return the SAME ArrayBuffer object on repeated access (stable identity, + // browser-faithful) as long as the backing store hasn't moved/resized. + 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; + } + // Live alias: DataView/TypedArray writes on this buffer land directly in - // WAMR linear memory, and reads observe live guest state. No copy. - return qjs.JS_NewArrayBuffer(ctx, base, size, &wasm_buffer_noop_free, null, false); + // 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( diff --git a/priv/c_src/wamr_bridge.c b/priv/c_src/wamr_bridge.c index 64fb5b99b..6d0bf1664 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; } diff --git a/priv/ts/webassembly.ts b/priv/ts/webassembly.ts index cc8680f23..1e9da2d0c 100644 --- a/priv/ts/webassembly.ts +++ b/priv/ts/webassembly.ts @@ -182,9 +182,10 @@ class WasmMemory { const handle = this._handle // Live alias of WAMR linear memory: host writes via DataView/TypedArray - // propagate into the guest, and reads observe live guest state. A grow - // may move the backing store, so callers must re-read `.buffer` afterward - // (matching browser detach-on-grow semantics). + // 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 } diff --git a/test/wasm_test.exs b/test/wasm_test.exs index d64867818..da7bda131 100644 --- a/test/wasm_test.exs +++ b/test/wasm_test.exs @@ -994,6 +994,60 @@ 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 + ]) + """ + + 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 "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>> From 10a65e0280fd21f7d2a5aa06c0352e44c1921384 Mon Sep 17 00:00:00 2001 From: "E.FU" Date: Fri, 19 Jun 2026 11:02:58 +0800 Subject: [PATCH 8/9] harden: invalidate live mem.buffer alias at host re-entry; browser-faithful grow/zero-page detach --- lib/quickbeam/quickbeam.zig | 8 +++ lib/quickbeam/wasm_host_imports.zig | 20 +++++++ lib/quickbeam/wasm_js.zig | 62 +++++++++++++++++---- priv/c_src/wamr_bridge.c | 6 +++ priv/c_src/wamr_bridge.h | 6 +++ test/wasm_test.exs | 84 +++++++++++++++++++++++++++++ 6 files changed, 176 insertions(+), 10 deletions(-) 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/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 8451c0e73..91c5b0175 100644 --- a/lib/quickbeam/wasm_js.zig +++ b/lib/quickbeam/wasm_js.zig @@ -22,14 +22,11 @@ const InstanceEntry = struct { buffer_size: u32 = 0, }; -// Detach + drop the cached aliasing ArrayBuffer if WAMR's linear memory has -// moved or resized since it was created (e.g. after a guest or host -// memory.grow). The next `.buffer` access then mints a fresh alias. -fn invalidate_buffer_if_moved(ctx: *qjs.JSContext, entry: *InstanceEntry) void { +// 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; - 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; qjs.JS_DetachArrayBuffer(ctx, bv); qjs.JS_FreeValue(ctx, bv); entry.buffer_val = null; @@ -37,6 +34,42 @@ fn invalidate_buffer_if_moved(ctx: *qjs.JSContext, entry: *InstanceEntry) void { 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, @@ -482,8 +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"); - // Detach the cached alias: the backing store may have moved/resized. - invalidate_buffer_if_moved(ctx, entry); + // 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); } @@ -537,7 +572,14 @@ fn wasm_memory_buffer_impl( var size: u32 = 0; const base = wamr.wamr_bridge_memory_data(entry.managed.inst, &size); - if (base == null or size == 0) return throw_error(ctx, "memory not available"); + 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, and wasm memory cannot shrink, so no cached buffer + // exists to reuse — mint a fresh empty owned (detachable) buffer. + var empty: [1]u8 = .{0}; + return qjs.JS_NewArrayBufferCopy(ctx, &empty, 0); + } // Return the SAME ArrayBuffer object on repeated access (stable identity, // browser-faithful) as long as the backing store hasn't moved/resized. diff --git a/priv/c_src/wamr_bridge.c b/priv/c_src/wamr_bridge.c index 6d0bf1664..c63302169 100644 --- a/priv/c_src/wamr_bridge.c +++ b/priv/c_src/wamr_bridge.c @@ -498,6 +498,12 @@ wamr_bridge_memory_data(WamrInstance *inst, uint32_t *out_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 3460640a8..245a2b09f 100644 --- a/priv/c_src/wamr_bridge.h +++ b/priv/c_src/wamr_bridge.h @@ -104,6 +104,12 @@ bool wamr_bridge_write_memory(WamrInstance *inst, uint32_t offset, * 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/test/wasm_test.exs b/test/wasm_test.exs index da7bda131..2b8648f07 100644 --- a/test/wasm_test.exs +++ b/test/wasm_test.exs @@ -1014,6 +1014,28 @@ defmodule QuickBEAM.WASMTest do ]) """ + # (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. @@ -1039,6 +1061,68 @@ defmodule QuickBEAM.WASMTest do """) 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 0-length buffer instead of throwing", %{rt: rt} do + assert {:ok, 0} = + QuickBEAM.eval(rt, """ + const bytes = #{@zeropage_bytes}; + const { instance } = await WebAssembly.instantiate(bytes); + instance.exports.mem.buffer.byteLength; + """) + end + test "TextDecoder.decode accepts a DataView, including a non-zero byteOffset", %{rt: rt} do assert {:ok, "ello"} = QuickBEAM.eval(rt, """ From ef7186b28407269e0a5b0748f06337024a4c2579 Mon Sep 17 00:00:00 2001 From: "E.FU" Date: Fri, 19 Jun 2026 14:25:58 +0800 Subject: [PATCH 9/9] fix: cache zero-page mem.buffer for stable identity and detach-on-grow --- lib/quickbeam/wasm_js.zig | 31 ++++++++++++++++++++++--------- test/wasm_test.exs | 31 ++++++++++++++++++++++++++++--- 2 files changed, 50 insertions(+), 12 deletions(-) diff --git a/lib/quickbeam/wasm_js.zig b/lib/quickbeam/wasm_js.zig index 91c5b0175..dd804249e 100644 --- a/lib/quickbeam/wasm_js.zig +++ b/lib/quickbeam/wasm_js.zig @@ -572,17 +572,15 @@ fn wasm_memory_buffer_impl( var size: u32 = 0; const base = wamr.wamr_bridge_memory_data(entry.managed.inst, &size); - 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, and wasm memory cannot shrink, so no cached buffer - // exists to reuse — mint a fresh empty owned (detachable) buffer. - var empty: [1]u8 = .{0}; - return qjs.JS_NewArrayBufferCopy(ctx, &empty, 0); - } // Return the SAME ArrayBuffer object on repeated access (stable identity, - // browser-faithful) as long as the backing store hasn't moved/resized. + // 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); @@ -594,6 +592,21 @@ fn wasm_memory_buffer_impl( 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. diff --git a/test/wasm_test.exs b/test/wasm_test.exs index 2b8648f07..3bb4b23e8 100644 --- a/test/wasm_test.exs +++ b/test/wasm_test.exs @@ -1114,12 +1114,37 @@ defmodule QuickBEAM.WASMTest do """) end - test "zero-page memory exposes a 0-length buffer instead of throwing", %{rt: rt} do - assert {:ok, 0} = + 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); - instance.exports.mem.buffer.byteLength; + 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