Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/precompile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion lib/quickbeam/native.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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") ++
Expand Down
8 changes: 8 additions & 0 deletions lib/quickbeam/quickbeam.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
35 changes: 35 additions & 0 deletions lib/quickbeam/text_encoding.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions lib/quickbeam/wamr.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
20 changes: 20 additions & 0 deletions lib/quickbeam/wasm_host_imports.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
147 changes: 147 additions & 0 deletions lib/quickbeam/wasm_js.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
}
Expand All @@ -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));
}
Expand Down Expand Up @@ -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]);

Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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,
Expand Down
31 changes: 30 additions & 1 deletion priv/c_src/wamr_bridge.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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)
Expand Down
12 changes: 12 additions & 0 deletions priv/c_src/wamr_bridge.h
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading