Skip to content

Live-alias WebAssembly.Memory.buffer to WAMR linear memory (browser-faithful detach-on-grow)#19

Open
e-fu wants to merge 9 commits into
elixir-volt:masterfrom
ZenHive:wasm-memory-alias
Open

Live-alias WebAssembly.Memory.buffer to WAMR linear memory (browser-faithful detach-on-grow)#19
e-fu wants to merge 9 commits into
elixir-volt:masterfrom
ZenHive:wasm-memory-alias

Conversation

@e-fu

@e-fu e-fu commented Jun 19, 2026

Copy link
Copy Markdown

Problem

master returns WebAssembly.Memory.prototype.buffer as a copy of WAMR's
linear memory (.slice().buffer). That breaks the browser contract that
mem.buffer aliases the live backing store: a host that writes through a
DataView/TypedArray over mem.buffer can't make those writes visible to the
guest, and a guest write isn't visible to a previously-handed-out view. The
concrete casualty is Go's wasm_exec.js, which caches a DataView over
mem.buffer and relies on host↔guest writes propagating through it.

Change

Live-alias Memory.buffer directly onto WAMR's linear memory, with browser-faithful semantics:

  • Stable identity — repeated .buffer access returns the same ArrayBuffer
    while the backing store hasn't moved/resized.
  • Detach-on-growWebAssembly.Memory.grow detaches the previously
    handed-out buffer on every call (including grow(0) and in-place growth),
    matching browsers; a fresh .buffer reflects the grown size.
  • Mid-call grow safety — a guest memory.grow opcode inside a call moves
    the backing store; the cached alias is now invalidated at the JS host-import
    re-entry boundary (via a C-ABI seam exported from the root NIF module that
    reverse-maps the host exec_env's module instance to the owning instance), so
    a host callback never reads through a stale/dangling alias.
  • Zero-page memory(memory 0) now exposes a 0-length ArrayBuffer
    (new WebAssembly.Memory({initial: 0}).buffer) instead of throwing.
  • TextDecoder.decode accepts a DataView (incl. non-zero byteOffset),
    with bounds-checking.

Tests

test/wasm_test.exs covers: host writes through a mem.buffer DataView are
visible to the guest; stable buffer identity until grow; detach-on-grow and
grow(0) detach; a buffer captured before an in-call grow is detached at the
next host re-entry; zero-page 0-length buffer; TextDecoder.decode of a
DataView.

Builds under the CI toolchain pin (OTP 27.0 / Elixir 1.18 / Zig 0.15.2); CI
runs the full suite.

e-fu added 8 commits June 19, 2026 06:46
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.
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.
Copilot AI review requested due to automatic review settings June 19, 2026 04:16

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates QuickBEAM’s WebAssembly polyfill/runtime integration to make WebAssembly.Memory.prototype.buffer a live alias of WAMR’s linear memory (browser-faithful), including detach-on-grow semantics and safety when memory.grow happens mid-call before host re-entry. It also enables WAMR bulk-memory opcode support and extends TextDecoder.decode to accept DataView, with new regression tests.

Changes:

  • Live-alias Memory.buffer onto WAMR linear memory with stable identity, detach-on-grow, and host-import-boundary invalidation.
  • Enable WAMR bulk-memory “_OPT” opcodes and add fixtures/tests to ensure they compile and run.
  • Accept DataView in TextDecoder.decode (with bounds checking) and add workflow artifact uploads for easier branch testing.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
test/wasm_test.exs Adds regression fixtures and tests for bulk-memory opcodes plus Memory.buffer aliasing/detach semantics and TextDecoder DataView decoding.
priv/ts/webassembly.ts Switches WasmMemory.buffer to a native-backed live buffer accessor instead of returning a copied buffer.
priv/c_src/wamr_bridge.h Exposes new C bridge APIs to retrieve linear-memory base/size and the underlying module instance pointer.
priv/c_src/wamr_bridge.c Fixes memory.grow semantics for WAMR and implements linear-memory base/size and module-inst accessors.
lib/quickbeam/wasm_js.zig Implements cached, live-aliased ArrayBuffer creation/detachment, plus invalidation on grow and after calls.
lib/quickbeam/wasm_host_imports.zig Adds a host-import boundary hook to invalidate/detach stale memory aliases before JS callbacks.
lib/quickbeam/wamr.zig Enables WASM_ENABLE_BULK_MEMORY_OPT in the WAMR cImport configuration.
lib/quickbeam/text_encoding.zig Extends TextDecoder.decode to accept DataView inputs correctly and safely.
lib/quickbeam/quickbeam.zig Adds a root-module C-ABI export seam for alias invalidation reachable from host-import code.
lib/quickbeam/native.ex Enables WASM_ENABLE_BULK_MEMORY_OPT in WAMR C flags and adds an opt-out for UBSan flags via env var.
.github/workflows/precompile.yml Uploads build artifacts for all runs (not only tags) to support feature-branch testing.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread lib/quickbeam/wasm_js.zig Outdated
Comment on lines +575 to +582
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);
}
Comment thread test/wasm_test.exs Outdated
Comment on lines +1117 to +1124
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
@e-fu

e-fu commented Jun 19, 2026

Copy link
Copy Markdown
Author

Known limitation: guest-opcode memory.grow(0) and the mem.buffer alias

A cross-family (Codex) correctness review of this branch flagged one fidelity gap worth disclosing for a conscious accept/defer decision. No memory-safety issue — purely a deviation from the browser's "any grow detaches" contract.

The gap. Two paths invalidate the cached aliasing mem.buffer:

  1. The JS WebAssembly.Memory.prototype.grow wrapper detaches unconditionally on every call (incl. grow(0)) — covered by the new regression tests.
  2. The host-import boundary (invalidate_buffer_if_moved, reached before re-entering any JS host import) detaches only when the linear-memory base or size changed — the catch-all for a guest that grows memory via the raw memory.grow opcode, bypassing path 1.

A guest-side memory.grow(0) executed via the opcode leaves base and size unchanged, so path 2 does not detach the cached alias at the next host re-entry — unlike a browser, which detaches on any grow call.

Why this is not a safety bug. grow(0) allocates zero pages, so WAMR never reallocates the backing store; base/size are genuinely stable and the cached alias still points at valid, correctly-sized memory. There is no use-after-free or dangling pointer — only a semantic-fidelity deviation.

Why it isn't fixed inline here. The host-boundary hook runs before every JS host import. Making it detach unconditionally would destroy the zero-copy aliasing optimization (every host call would invalidate and force a mem.buffer re-fetch). A correct fix that preserves the optimization needs a dirty-flag set by the WAMR memory.grow opcode — a WAMR-internal grow hook, which felt like an architectural call for the maintainer rather than something to bake in silently with a performance tradeoff. The opcode grow(0) (vs. the JS Memory API) is also an uncommon guest pattern, so practical exposure is low.

Happy to implement the WAMR grow-hook approach if you'd prefer the contract fully honored — just let me know.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants