diff --git a/lib/quickbeam.ex b/lib/quickbeam.ex index 8271c0fc3..6d8c4315f 100644 --- a/lib/quickbeam.ex +++ b/lib/quickbeam.ex @@ -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) @@ -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) """ diff --git a/lib/quickbeam/context_pool.ex b/lib/quickbeam/context_pool.ex index 52c2b1d33..ed5e71f30 100644 --- a/lib/quickbeam/context_pool.ex +++ b/lib/quickbeam/context_pool.ex @@ -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) """ @@ -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 = diff --git a/lib/quickbeam/context_types.zig b/lib/quickbeam/context_types.zig index d22555acf..fb8f4c4aa 100644 --- a/lib/quickbeam/context_types.zig +++ b/lib/quickbeam/context_types.zig @@ -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. + 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), diff --git a/lib/quickbeam/context_worker.zig b/lib/quickbeam/context_worker.zig index 57454cd23..c18ae159b 100644 --- a/lib/quickbeam/context_worker.zig +++ b/lib/quickbeam/context_worker.zig @@ -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; diff --git a/lib/quickbeam/quickbeam.zig b/lib/quickbeam/quickbeam.zig index fb2b32f5b..b0bd813cd 100644 --- a/lib/quickbeam/quickbeam.zig +++ b/lib/quickbeam/quickbeam.zig @@ -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); } @@ -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); } diff --git a/lib/quickbeam/runtime.ex b/lib/quickbeam/runtime.ex index 5ceb6cbfc..294c8838b 100644 --- a/lib/quickbeam/runtime.ex +++ b/lib/quickbeam/runtime.ex @@ -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) diff --git a/lib/quickbeam/types.zig b/lib/quickbeam/types.zig index 4efcb9f94..6fa05f47f 100644 --- a/lib/quickbeam/types.zig +++ b/lib/quickbeam/types.zig @@ -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. + 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 = .{}, diff --git a/lib/quickbeam/wasm.ex b/lib/quickbeam/wasm.ex index f0fad3992..30c1da203 100644 --- a/lib/quickbeam/wasm.ex +++ b/lib/quickbeam/wasm.ex @@ -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() diff --git a/lib/quickbeam/wasm_js.zig b/lib/quickbeam/wasm_js.zig index d09cc3c14..d0a3bebaf 100644 --- a/lib/quickbeam/wasm_js.zig +++ b/lib/quickbeam/wasm_js.zig @@ -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. + wasm_stack_size: u32 = 65_536, + wasm_heap_size: u32 = 65_536, instances: std.AutoHashMapUnmanaged(u64, InstanceEntry) = .{}, fn deinit(self: *ContextState) void { @@ -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; @@ -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)); @@ -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(); diff --git a/lib/quickbeam/worker.zig b/lib/quickbeam/worker.zig index 030f39921..c78c2823f 100644 --- a/lib/quickbeam/worker.zig +++ b/lib/quickbeam/worker.zig @@ -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); } }; diff --git a/test/wasm_test.exs b/test/wasm_test.exs index b0f783f8f..cd4f52ff0 100644 --- a/test/wasm_test.exs +++ b/test/wasm_test.exs @@ -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); + """) + + 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, """