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: 9 additions & 1 deletion lib/quickbeam.ex
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,11 @@ defmodule QuickBEAM do
automatically bundled — imports are resolved from the filesystem and
`node_modules/`, then compiled into a single script via OXC.
* `:memory_limit` — maximum JS heap in bytes (default: 256 MB)
* `:max_stack_size` — maximum JS call stack in bytes (default: 4 MB)
* `:max_stack_size` — maximum JS call stack in bytes (default: 8 MB)
* `:wasm_stack_size` — WASM operand stack in bytes for guests started via the JS
`WebAssembly.instantiate` path (default: 65536). Distinct from `:max_stack_size`
(the JS call stack); raise it for guests whose deep init overflows the 64 KB default.
* `:wasm_heap_size` — WASM auxiliary heap in bytes for the same path (default: 65536)
* `:max_convert_depth` — maximum nesting depth for JS→BEAM value conversion (default: 32)
* `:max_convert_nodes` — maximum total nodes for JS→BEAM value conversion (default: 10,000)

Expand Down Expand Up @@ -83,6 +87,10 @@ defmodule QuickBEAM do

* `:memory_limit` — maximum JS heap in bytes (default: 256 MB)
* `:max_stack_size` — maximum JS call stack in bytes (default: 8 MB)
* `:wasm_stack_size` — WASM operand stack in bytes for guests started via the JS
`WebAssembly.instantiate` path (default: 65536). Distinct from `:max_stack_size`
(the JS call stack); raise it for guests whose deep init overflows the 64 KB default.
* `:wasm_heap_size` — WASM auxiliary heap in bytes for the same path (default: 65536)
* `:max_convert_depth` — maximum nesting depth for JS→BEAM value conversion (default: 32)
* `:max_convert_nodes` — maximum total nodes for JS→BEAM value conversion (default: 10,000)
"""
Expand Down
13 changes: 12 additions & 1 deletion lib/quickbeam/context_pool.ex
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ defmodule QuickBEAM.ContextPool do
* `:size` — number of runtime threads (default: `System.schedulers_online()`)
* `:memory_limit` — maximum JS heap per thread in bytes (default: 256 MB)
* `:max_stack_size` — maximum JS call stack in bytes (default: 8 MB)
* `:wasm_stack_size` — WASM operand stack in bytes for guests started via the JS
`WebAssembly.instantiate` path (default: 65536). Distinct from `:max_stack_size`
(the JS call stack); raise it for guests whose deep init overflows the 64 KB default.
* `:wasm_heap_size` — WASM auxiliary heap in bytes for the same path (default: 65536)
* `:max_convert_depth` — maximum nesting depth for JS→BEAM value conversion (default: 32)
* `:max_convert_nodes` — maximum total nodes for JS→BEAM value conversion (default: 10,000)
"""
Expand All @@ -46,7 +50,14 @@ defmodule QuickBEAM.ContextPool do

nif_opts =
opts
|> Keyword.take([:memory_limit, :max_stack_size, :max_convert_depth, :max_convert_nodes])
|> Keyword.take([
:memory_limit,
:max_stack_size,
:wasm_stack_size,
:wasm_heap_size,
:max_convert_depth,
:max_convert_nodes
])
|> Map.new()

threads =
Expand Down
6 changes: 6 additions & 0 deletions lib/quickbeam/context_types.zig
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,12 @@ pub const PoolData = struct {
thread: ?std.Thread,
memory_limit: usize = 256 * 1024 * 1024,
max_stack_size: usize = 8 * 1024 * 1024,
// WASM operand stack / heap for the JS `WebAssembly.instantiate` path
// (distinct from `max_stack_size`, the JS call stack). Default mirrors the
// WASM NIF path; raised via the pool `:wasm_stack_size` opt. Copied into
// each context's RuntimeData at create time.
Comment on lines +144 to +147
wasm_stack_size: u32 = 65_536,
wasm_heap_size: u32 = 65_536,
max_convert_depth: u32 = 32,
max_convert_nodes: u32 = 10_000,
shutting_down: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
Expand Down
2 changes: 2 additions & 0 deletions lib/quickbeam/context_worker.zig
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,8 @@ fn handle_create_context(
.thread = null,
.max_convert_depth = pd.max_convert_depth,
.max_convert_nodes = pd.max_convert_nodes,
.wasm_stack_size = pd.wasm_stack_size,
.wasm_heap_size = pd.wasm_heap_size,
};
entry.owner_pid = p.owner_pid;
entry.id = p.context_id;
Expand Down
24 changes: 24 additions & 0 deletions lib/quickbeam/quickbeam.zig
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,18 @@ pub fn start_runtime(owner_pid: beam.pid, opts: beam.term) !RuntimeResource {
if (get_map_uint(env, opts.v, "max_stack_size")) |v| {
data.max_stack_size = v;
}
if (get_map_uint(env, opts.v, "wasm_stack_size")) |v| {
data.wasm_stack_size = std.math.cast(u32, v) orelse {
gpa.destroy(data);
return error.WasmStackSizeTooLarge;
};
}
if (get_map_uint(env, opts.v, "wasm_heap_size")) |v| {
data.wasm_heap_size = std.math.cast(u32, v) orelse {
gpa.destroy(data);
return error.WasmHeapSizeTooLarge;
};
}
if (get_map_uint(env, opts.v, "max_convert_depth")) |v| {
data.max_convert_depth = @intCast(v);
}
Expand Down Expand Up @@ -573,6 +585,18 @@ pub fn pool_start(opts: beam.term) !PoolResource {
if (get_map_uint(env, opts.v, "max_stack_size")) |v| {
data.max_stack_size = v;
}
if (get_map_uint(env, opts.v, "wasm_stack_size")) |v| {
data.wasm_stack_size = std.math.cast(u32, v) orelse {
gpa.destroy(data);
return error.WasmStackSizeTooLarge;
};
}
if (get_map_uint(env, opts.v, "wasm_heap_size")) |v| {
data.wasm_heap_size = std.math.cast(u32, v) orelse {
gpa.destroy(data);
return error.WasmHeapSizeTooLarge;
};
}
if (get_map_uint(env, opts.v, "max_convert_depth")) |v| {
data.max_convert_depth = @intCast(v);
}
Expand Down
9 changes: 8 additions & 1 deletion lib/quickbeam/runtime.ex
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,14 @@ defmodule QuickBEAM.Runtime do

nif_opts =
opts
|> Keyword.take([:memory_limit, :max_stack_size, :max_convert_depth, :max_convert_nodes])
|> Keyword.take([
:memory_limit,
:max_stack_size,
:wasm_stack_size,
:wasm_heap_size,
:max_convert_depth,
:max_convert_nodes
])
|> Map.new()

resource = QuickBEAM.Native.start_runtime(self(), nif_opts)
Expand Down
5 changes: 5 additions & 0 deletions lib/quickbeam/types.zig
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ pub const RuntimeData = struct {
thread: ?std.Thread,
memory_limit: usize = 256 * 1024 * 1024,
max_stack_size: usize = 8 * 1024 * 1024,
// WASM operand stack / heap for the JS `WebAssembly.instantiate` path
// (distinct from `max_stack_size`, the JS call stack). Default mirrors the
// WASM NIF path; raised via the runtime `:wasm_stack_size` opt.
Comment on lines +27 to +29
wasm_stack_size: u32 = 65_536,
wasm_heap_size: u32 = 65_536,
max_convert_depth: u32 = 32,
max_convert_nodes: u32 = 10_000,
sync_slots_mutex: std.Thread.Mutex = .{},
Expand Down
9 changes: 9 additions & 0 deletions lib/quickbeam/wasm.ex
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,15 @@ defmodule QuickBEAM.WASM do
* `:name` — GenServer name registration
* `:stack_size` — execution stack in bytes (default: 65536)
* `:heap_size` — auxiliary heap in bytes (default: 65536)

> #### JS `WebAssembly` path {: .info}
>
> These `:stack_size`/`:heap_size` options apply to this native NIF path. Guests
> started from JavaScript via `WebAssembly.instantiate` instead take their WASM
> operand stack / heap from the owning runtime's `:wasm_stack_size` /
> `:wasm_heap_size` options (see `QuickBEAM.Runtime` / `QuickBEAM.ContextPool`),
> which also default to 65536. Raise those for guests (e.g. Go `GOOS=js`) whose
> deep initialization overflows the 64 KB default.
"""

@type instance :: GenServer.server()
Expand Down
19 changes: 14 additions & 5 deletions lib/quickbeam/wasm_js.zig
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ const InstanceEntry = struct {
const ContextState = struct {
next_instance_id: u64 = 1,
max_reductions: i64 = 0,
// WASM operand stack / auxiliary heap for instances started via the JS
// `WebAssembly.instantiate` path. Distinct from the JS call stack
// (`max_stack_size`). Default mirrors the WASM NIF path; a consumer raises
// it (via the runtime/pool `:wasm_stack_size` opt) for guests whose deep
// init would otherwise overflow the 64 KB default.
Comment on lines +20 to +24
wasm_stack_size: u32 = 65_536,
wasm_heap_size: u32 = 65_536,
instances: std.AutoHashMapUnmanaged(u64, InstanceEntry) = .{},

fn deinit(self: *ContextState) void {
Expand Down Expand Up @@ -46,18 +53,20 @@ fn context_key(ctx: *qjs.JSContext) usize {
return @intFromPtr(ctx);
}

fn ensure_context_state(ctx: *qjs.JSContext, max_reductions: i64) ?*ContextState {
fn ensure_context_state(ctx: *qjs.JSContext, max_reductions: i64, wasm_stack_size: u32, wasm_heap_size: u32) ?*ContextState {
states_mutex.lock();
defer states_mutex.unlock();

const key = context_key(ctx);
if (states.get(key)) |state| {
state.max_reductions = max_reductions;
state.wasm_stack_size = wasm_stack_size;
state.wasm_heap_size = wasm_heap_size;
return state;
}

const state = gpa.create(ContextState) catch return null;
state.* = .{ .max_reductions = max_reductions };
state.* = .{ .max_reductions = max_reductions, .wasm_stack_size = wasm_stack_size, .wasm_heap_size = wasm_heap_size };
states.put(gpa, key, state) catch {
gpa.destroy(state);
return null;
Expand All @@ -82,8 +91,8 @@ pub fn destroy_context(ctx: *qjs.JSContext) void {
}
}

pub fn install(ctx: *qjs.JSContext, global: qjs.JSValue, max_reductions: i64) void {
_ = ensure_context_state(ctx, max_reductions) orelse return;
pub fn install(ctx: *qjs.JSContext, global: qjs.JSValue, max_reductions: i64, wasm_stack_size: u32, wasm_heap_size: u32) void {
_ = ensure_context_state(ctx, max_reductions, wasm_stack_size, wasm_heap_size) orelse return;

_ = qjs.JS_SetPropertyStr(ctx, global, "__qb_wasm_start", qjs.JS_NewCFunction(ctx, &wasm_start_impl, "__qb_wasm_start", 3));
_ = qjs.JS_SetPropertyStr(ctx, global, "__qb_wasm_call", qjs.JS_NewCFunction(ctx, &wasm_call_impl, "__qb_wasm_call", 3));
Expand Down Expand Up @@ -327,7 +336,7 @@ fn wasm_start_impl(
wasm_host_imports.PreparedImports.empty();
errdefer prepared_imports.deinit();

const managed = wasm_common.start_managed_instance(mod orelse return throw_error(ctx, "null module"), 65_536, 65_536, if (prepared_imports.registrations.len > 0) &prepared_imports else null, &err_buf) orelse return throw_error(ctx, std.mem.sliceTo(&err_buf, 0));
const managed = wasm_common.start_managed_instance(mod orelse return throw_error(ctx, "null module"), state.wasm_stack_size, state.wasm_heap_size, if (prepared_imports.registrations.len > 0) &prepared_imports else null, &err_buf) orelse return throw_error(ctx, std.mem.sliceTo(&err_buf, 0));
const mod_nn = mod orelse return throw_error(ctx, "null module");
errdefer managed.destroy();

Expand Down
2 changes: 1 addition & 1 deletion lib/quickbeam/worker.zig
Original file line number Diff line number Diff line change
Expand Up @@ -899,7 +899,7 @@ pub const WorkerState = struct {

const global = qjs.JS_GetGlobalObject(self.ctx);
defer qjs.JS_FreeValue(self.ctx, global);
wasm_js.install(self.ctx, global, self.max_reductions);
wasm_js.install(self.ctx, global, self.max_reductions, self.rd.wasm_stack_size, self.rd.wasm_heap_size);
}
};

Expand Down
55 changes: 55 additions & 0 deletions test/wasm_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -696,6 +696,61 @@ defmodule QuickBEAM.WASMTest do
""")
end

# exports `rec(n)`, which recurses `n` deep. WAMR keeps call frames on the
# operand-stack buffer sized by `stack_size`, so a deep call overflows a
# small stack but fits a larger one — used below to prove the JS
# `WebAssembly.instantiate` path honors the runtime's `:wasm_stack_size`.
@stack_deep_wasm """
new Uint8Array([
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x01, 0x06, 0x01, 0x60,
0x01, 0x7f, 0x01, 0x7f, 0x03, 0x02, 0x01, 0x00, 0x07, 0x07, 0x01, 0x03,
0x72, 0x65, 0x63, 0x00, 0x00, 0x0a, 0x15, 0x01, 0x13, 0x00, 0x20, 0x00,
0x45, 0x04, 0x40, 0x41, 0x00, 0x0f, 0x0b, 0x20, 0x00, 0x41, 0x01, 0x6b,
0x10, 0x00, 0x0f, 0x0b
])
"""

test "JS instantiate path overflows the default 64 KB WASM operand stack", %{rt: rt} do
{:error, err} =
QuickBEAM.eval(rt, """
const bytes = #{@stack_deep_wasm};
const {instance} = await WebAssembly.instantiate(bytes);
instance.exports.rec(10000);
""")

assert err.message =~ "stack"
end

test "JS instantiate path honors a raised :wasm_stack_size" do
{:ok, rt} = QuickBEAM.start(wasm_stack_size: 8 * 1024 * 1024)

assert {:ok, 0} =
QuickBEAM.eval(rt, """
const bytes = #{@stack_deep_wasm};
const {instance} = await WebAssembly.instantiate(bytes);
instance.exports.rec(10000);
""")
Comment on lines +724 to +732

QuickBEAM.stop(rt)
end

test "ContextPool propagates :wasm_stack_size to the pooled JS instantiate path" do
# Exercises the pool threading path (PoolData -> RuntimeData copy in
# context_worker), which the standalone QuickBEAM.start/1 test above does
# not cover. Without the copy the pooled context would silently keep 64 KB.
{:ok, pool} = QuickBEAM.ContextPool.start_link(size: 1, wasm_stack_size: 8 * 1024 * 1024)
{:ok, ctx} = QuickBEAM.Context.start_link(pool: pool)

assert {:ok, 0} =
QuickBEAM.Context.eval(ctx, """
const bytes = #{@stack_deep_wasm};
const {instance} = await WebAssembly.instantiate(bytes);
instance.exports.rec(10000);
""")

QuickBEAM.Context.stop(ctx)
end

test "WebAssembly.compile + instantiate", %{rt: rt} do
{:ok, 300} =
QuickBEAM.eval(rt, """
Expand Down