From dd4398df5fab07f5c7757da6ed700d31bc83411b Mon Sep 17 00:00:00 2001 From: Sleepy Date: Sat, 28 Feb 2026 22:29:42 +0000 Subject: [PATCH] wasm-runner: add timeout support and run/map convenience functions - Add :timeout option to call_single/4 and call_single!/4 that passes through to Firebird.Runtime.call/4 for per-call timeout control - Thread :timeout through run_batch/3, run_concurrent/4 via extract_call_opts - Add run/4 and run!/4 one-shot convenience functions with timeout support - Add map/4 for applying a WASM function over a list of argument lists - Add extract_call_opts/1 private helper to cleanly separate call-level options (timeout) from start-level options (cache, wasi) - Add comprehensive test suite (21 tests) covering all timeout paths --- lib/firebird/wasm_runner.ex | 403 +++++++++++++++++++++--------- test/wasm_runner_timeout_test.exs | 179 +++++++++++++ 2 files changed, 468 insertions(+), 114 deletions(-) create mode 100644 test/wasm_runner_timeout_test.exs diff --git a/lib/firebird/wasm_runner.ex b/lib/firebird/wasm_runner.ex index 2053d27..0e7d750 100644 --- a/lib/firebird/wasm_runner.ex +++ b/lib/firebird/wasm_runner.ex @@ -188,6 +188,7 @@ defmodule Firebird.WasmRunner do if String.printable?(source) do # File path — read it path = Firebird.Runtime.resolve_wasm_path(source) + case File.read(path) do {:ok, bytes} -> bytes {:error, _} -> nil @@ -201,10 +202,22 @@ defmodule Firebird.WasmRunner do @doc """ Call a function and return a single result value (unwrapped from list). + + ## Options + + - `:timeout` — Per-call timeout in milliseconds (default: 5000ms from Runtime). + If the WASM function doesn't return within this time, the call returns + `{:error, {:call_error, function, :timeout}}`. + + ## Examples + + {:ok, 8} = Firebird.WasmRunner.call_single(pid, :add, [5, 3]) + {:ok, 8} = Firebird.WasmRunner.call_single(pid, :add, [5, 3], timeout: 1000) """ - @spec call_single(pid(), atom() | String.t(), list()) :: {:ok, integer()} | {:error, term()} - def call_single(instance, function, args) do - case Firebird.call(instance, function, args) do + @spec call_single(pid(), atom() | String.t(), list(), keyword()) :: + {:ok, integer()} | {:error, term()} + def call_single(instance, function, args, opts \\ []) do + case Firebird.Runtime.call(instance, function, args, opts) do {:ok, [result]} -> {:ok, result} {:ok, results} -> {:ok, results} error -> error @@ -213,10 +226,19 @@ defmodule Firebird.WasmRunner do @doc """ Call a function and return a single result, raising on error. + + ## Options + + - `:timeout` — Per-call timeout in milliseconds (default: 5000ms from Runtime). + + ## Examples + + 8 = Firebird.WasmRunner.call_single!(pid, :add, [5, 3]) + 8 = Firebird.WasmRunner.call_single!(pid, :add, [5, 3], timeout: 1000) """ - @spec call_single!(pid(), atom() | String.t(), list()) :: integer() - def call_single!(instance, function, args) do - case call_single(instance, function, args) do + @spec call_single!(pid(), atom() | String.t(), list(), keyword()) :: integer() + def call_single!(instance, function, args, opts \\ []) do + case call_single(instance, function, args, opts) do {:ok, result} -> result {:error, reason} -> raise "WASM call failed: #{inspect(reason)}" end @@ -241,13 +263,17 @@ defmodule Firebird.WasmRunner do {:ok, results} = Firebird.WasmRunner.run_batch(compiled, [{:add, [5, 3]}, {:multiply, [4, 7]}]) """ @spec run_batch(binary() | String.t() | map(), [{atom() | String.t(), list()}], keyword()) :: - {:ok, list()} | {:error, term()} + {:ok, list()} | {:error, term()} def run_batch(wasm_source, calls, opts \\ []) do - with {:ok, pid} <- start(wasm_source, opts) do + {call_opts, start_opts} = extract_call_opts(opts) + + with {:ok, pid} <- start(wasm_source, start_opts) do try do - results = Enum.map(calls, fn {func, args} -> - call_single!(pid, func, args) - end) + results = + Enum.map(calls, fn {func, args} -> + call_single!(pid, func, args, call_opts) + end) + {:ok, results} rescue e -> {:error, Exception.message(e)} @@ -294,7 +320,8 @@ defmodule Firebird.WasmRunner do end) """ @spec with_instance(binary() | String.t() | map(), keyword(), (pid() -> result)) :: - {:ok, result} | {:error, term()} when result: var + {:ok, result} | {:error, term()} + when result: var def with_instance(wasm_source, opts \\ [], fun) when is_function(fun, 1) do case start(wasm_source, opts) do {:ok, pid} -> @@ -371,8 +398,10 @@ defmodule Firebird.WasmRunner do else {:error, {:arity_mismatch, func_name, expected: length(params), got: length(args)}} end + _ -> - :ok # Can't verify type, let it through + # Can't verify type, let it through + :ok end end end @@ -383,29 +412,31 @@ defmodule Firebird.WasmRunner do Useful for verifying cross-language consistency. """ @spec compare( - [{binary() | String.t() | map(), keyword()}], - atom() | String.t(), - list() - ) :: {:ok, [{binary() | String.t() | map(), list()}]} | {:error, term()} + [{binary() | String.t() | map(), keyword()}], + atom() | String.t(), + list() + ) :: {:ok, [{binary() | String.t() | map(), list()}]} | {:error, term()} def compare(modules, function, args) do - results = Enum.map(modules, fn - {source, opts} -> - with {:ok, pid} <- start(source, opts), - {:ok, result} <- Firebird.call(pid, function, args) do - Firebird.stop(pid) - {source, {:ok, result}} - else - error -> {source, error} - end - source when is_binary(source) -> - with {:ok, pid} <- start(source), - {:ok, result} <- Firebird.call(pid, function, args) do - Firebird.stop(pid) - {source, {:ok, result}} - else - error -> {source, error} - end - end) + results = + Enum.map(modules, fn + {source, opts} -> + with {:ok, pid} <- start(source, opts), + {:ok, result} <- Firebird.call(pid, function, args) do + Firebird.stop(pid) + {source, {:ok, result}} + else + error -> {source, error} + end + + source when is_binary(source) -> + with {:ok, pid} <- start(source), + {:ok, result} <- Firebird.call(pid, function, args) do + Firebird.stop(pid) + {source, {:ok, result}} + else + error -> {source, error} + end + end) {:ok, results} end @@ -429,11 +460,16 @@ defmodule Firebird.WasmRunner do "fixtures/rust_math.wasm", :fibonacci, args_list, concurrency: 4 ) """ - @spec run_concurrent(binary() | String.t() | map(), atom() | String.t(), [[integer()]], keyword()) :: - {:ok, [term()]} | {:error, term()} + @spec run_concurrent( + binary() | String.t() | map(), + atom() | String.t(), + [[integer()]], + keyword() + ) :: + {:ok, [term()]} | {:error, term()} def run_concurrent(wasm_source, function, args_list, opts \\ []) do concurrency = Keyword.get(opts, :concurrency, System.schedulers_online()) - wasm_opts = Keyword.drop(opts, [:concurrency]) + {call_opts, wasm_opts} = extract_call_opts(Keyword.drop(opts, [:concurrency])) # Precompile once, then create all instances from the compiled module. # This avoids recompiling the same WASM bytes N times. @@ -465,6 +501,7 @@ defmodule Firebird.WasmRunner do {:error, reason} -> throw({:start_error, reason}) end end + {List.to_tuple(pids), pids} end end @@ -482,8 +519,9 @@ defmodule Firebird.WasmRunner do |> Task.async_stream( fn {args, idx} -> instance = elem(instances_tuple, rem(idx, concurrency)) + try do - call_single!(instance, function, args) + call_single!(instance, function, args, call_opts) rescue e -> {:error, Exception.message(e)} end @@ -517,7 +555,8 @@ defmodule Firebird.WasmRunner do {time_us, {:ok, [55]}} = Firebird.WasmRunner.timed_call(instance, :fibonacci, [10]) IO.puts("Took \#{time_us}μs") """ - @spec timed_call(pid(), atom() | String.t(), list()) :: {non_neg_integer(), {:ok, list()} | {:error, term()}} + @spec timed_call(pid(), atom() | String.t(), list()) :: + {non_neg_integer(), {:ok, list()} | {:error, term()}} def timed_call(instance, function, args) do :timer.tc(fn -> Firebird.call(instance, function, args) end) end @@ -536,10 +575,11 @@ defmodule Firebird.WasmRunner do memories = Enum.count(all, fn {_, type} -> type == :memory end) globals = Enum.count(all, fn {_, type} -> type == :global end) - mem_info = case Firebird.memory_size(instance) do - {:ok, size} -> %{memory_bytes: size, memory_pages: div(size, 65536)} - _ -> %{memory_bytes: 0, memory_pages: 0} - end + mem_info = + case Firebird.memory_size(instance) do + {:ok, size} -> %{memory_bytes: size, memory_pages: div(size, 65536)} + _ -> %{memory_bytes: 0, memory_pages: 0} + end Map.merge(mem_info, %{ functions: exports, @@ -601,8 +641,13 @@ defmodule Firebird.WasmRunner do r end) """ - @spec with_memory(binary() | String.t() | map(), keyword(), (pid(), Firebird.Memory.t() -> result)) :: - {:ok, result} | {:error, term()} when result: var + @spec with_memory( + binary() | String.t() | map(), + keyword(), + (pid(), Firebird.Memory.t() -> result) + ) :: + {:ok, result} | {:error, term()} + when result: var def with_memory(wasm_source, opts \\ [], fun) when is_function(fun, 2) do {memory_opts, wasm_opts} = Keyword.pop(opts, :memory, []) @@ -672,11 +717,11 @@ defmodule Firebird.WasmRunner do ) """ @spec call_with_strings( - binary() | String.t() | map(), - atom() | String.t(), - [String.t()], - keyword() - ) :: {:ok, term()} | {:error, term()} + binary() | String.t() | map(), + atom() | String.t(), + [String.t()], + keyword() + ) :: {:ok, term()} | {:error, term()} def call_with_strings(wasm_source, function, strings, opts \\ []) when is_list(strings) do result_mode = Keyword.get(opts, :result, :raw) extra_args = Keyword.get(opts, :extra_args, []) @@ -701,6 +746,7 @@ defmodule Firebird.WasmRunner do [ptr, len] -> {:ok, str} = Firebird.Memory.read_string(mem, ptr, len) str + _ -> raise "Expected [ptr, len] result for :string mode, got: #{inspect(result)}" end @@ -747,10 +793,10 @@ defmodule Firebird.WasmRunner do # results => [hash1, hash2, encrypted_len] """ @spec run_memory_batch( - binary() | String.t() | map(), - [map()], - keyword() - ) :: {:ok, [term()]} | {:error, term()} + binary() | String.t() | map(), + [map()], + keyword() + ) :: {:ok, [term()]} | {:error, term()} def run_memory_batch(wasm_source, calls, opts \\ []) when is_list(calls) do with_memory(wasm_source, opts, fn pid, mem -> Enum.map(calls, fn call_spec -> @@ -761,43 +807,46 @@ defmodule Firebird.WasmRunner do result_mode = Map.get(call_spec, :result, :raw) # Use arena so memory is reclaimed after each call - {result, _mem} = Firebird.Memory.with_arena(mem, fn mem -> - # Write strings as (ptr, len) pairs — prepend + reverse to avoid O(n²) - {mem, string_args_rev} = - Enum.reduce(strings, {mem, []}, fn str, {m, acc} -> - {m, ptr, len} = Firebird.Memory.write_string(m, str) - {m, [len, ptr | acc]} - end) - - # Write typed arrays as (ptr, count) pairs — same pattern - {mem, array_args_rev} = - Enum.reduce(arrays, {mem, []}, fn {type, values}, {m, acc} -> - {m, ptr, count} = write_typed_array(m, type, values) - {m, [count, ptr | acc]} - end) - - all_args = Enum.reverse(string_args_rev) ++ Enum.reverse(array_args_rev) ++ extra_args - - result = - case Firebird.call(pid, function, all_args) do - {:ok, result} -> - case result_mode do - :string -> - [ptr, len] = result - {:ok, str} = Firebird.Memory.read_string(mem, ptr, len) - str - :i32 -> - hd(result) - :raw -> - result - end + {result, _mem} = + Firebird.Memory.with_arena(mem, fn mem -> + # Write strings as (ptr, len) pairs — prepend + reverse to avoid O(n²) + {mem, string_args_rev} = + Enum.reduce(strings, {mem, []}, fn str, {m, acc} -> + {m, ptr, len} = Firebird.Memory.write_string(m, str) + {m, [len, ptr | acc]} + end) + + # Write typed arrays as (ptr, count) pairs — same pattern + {mem, array_args_rev} = + Enum.reduce(arrays, {mem, []}, fn {type, values}, {m, acc} -> + {m, ptr, count} = write_typed_array(m, type, values) + {m, [count, ptr | acc]} + end) + + all_args = Enum.reverse(string_args_rev) ++ Enum.reverse(array_args_rev) ++ extra_args + + result = + case Firebird.call(pid, function, all_args) do + {:ok, result} -> + case result_mode do + :string -> + [ptr, len] = result + {:ok, str} = Firebird.Memory.read_string(mem, ptr, len) + str + + :i32 -> + hd(result) + + :raw -> + result + end - {:error, reason} -> - raise "WASM call failed: #{inspect(reason)}" - end + {:error, reason} -> + raise "WASM call failed: #{inspect(reason)}" + end - {result, mem} - end) + {result, mem} + end) result end) @@ -859,11 +908,11 @@ defmodule Firebird.WasmRunner do ) """ @spec benchmark( - binary() | String.t() | map(), - atom() | String.t(), - [[integer()]], - keyword() - ) :: map() + binary() | String.t() | map(), + atom() | String.t(), + [[integer()]], + keyword() + ) :: map() def benchmark(wasm_source, function, args_list, opts \\ []) do iterations = Keyword.get(opts, :iterations, 100) warmup = Keyword.get(opts, :warmup, 10) @@ -883,11 +932,13 @@ defmodule Firebird.WasmRunner do # Timed iterations times = for _ <- 1..iterations do - {elapsed, _} = :timer.tc(fn -> - Enum.each(args_list, fn args -> - Firebird.call(pid, function, args) + {elapsed, _} = + :timer.tc(fn -> + Enum.each(args_list, fn args -> + Firebird.call(pid, function, args) + end) end) - end) + elapsed end @@ -956,11 +1007,11 @@ defmodule Firebird.WasmRunner do IO.puts(Firebird.WasmRunner.format_benchmark(result)) """ @spec benchmark_compare( - binary() | String.t() | map(), - atom() | String.t(), - [[integer()]], - keyword() - ) :: map() + binary() | String.t() | map(), + atom() | String.t(), + [[integer()]], + keyword() + ) :: map() def benchmark_compare(wasm_source, function, args_list, opts \\ []) do beam_fun = Keyword.fetch!(opts, :beam_fun) iterations = Keyword.get(opts, :iterations, 100) @@ -982,22 +1033,26 @@ defmodule Firebird.WasmRunner do # Time WASM wasm_times = for _ <- 1..iterations do - {elapsed, _} = :timer.tc(fn -> - Enum.each(args_list, fn args -> - Firebird.call(pid, function, args) + {elapsed, _} = + :timer.tc(fn -> + Enum.each(args_list, fn args -> + Firebird.call(pid, function, args) + end) end) - end) + elapsed end # Time BEAM beam_times = for _ <- 1..iterations do - {elapsed, _} = :timer.tc(fn -> - Enum.each(args_list, fn args -> - apply_beam_fun(beam_fun, args) + {elapsed, _} = + :timer.tc(fn -> + Enum.each(args_list, fn args -> + apply_beam_fun(beam_fun, args) + end) end) - end) + elapsed end @@ -1085,7 +1140,9 @@ defmodule Firebird.WasmRunner do end # Pad a numeric value to 8 chars with "μs" suffix - defp pad_num(val) when is_float(val), do: String.pad_leading("#{Float.round(val, 1)}", 6) <> " μs" + defp pad_num(val) when is_float(val), + do: String.pad_leading("#{Float.round(val, 1)}", 6) <> " μs" + defp pad_num(val) when is_integer(val), do: String.pad_leading("#{val}", 6) <> " μs" defp pad_num(val), do: String.pad_leading("#{val}", 6) <> " μs" @@ -1113,8 +1170,9 @@ defmodule Firebird.WasmRunner do end defp bench_percentile([], _p), do: 0 + defp bench_percentile(sorted, p) do - k = (p / 100) * (length(sorted) - 1) + k = p / 100 * (length(sorted) - 1) f = trunc(k) c = Float.ceil(k) |> trunc() @@ -1129,6 +1187,7 @@ defmodule Firebird.WasmRunner do defp bench_stddev(values, mean) do count = length(values) + if count <= 1 do 0.0 else @@ -1142,9 +1201,125 @@ defmodule Firebird.WasmRunner do end end + # ── One-shot run helpers ────────────────────────────────────────────── + + @doc """ + Load a WASM module, call a single function, return one result, and stop. + + Combines `start/2`, `call_single/4`, and `stop/1` into a single call. + Uses WasmRunner's WASI auto-detection and optional module caching, and + unwraps the result from a list so you get a plain value back. + + ## Options + + - `:timeout` — Per-call timeout in milliseconds (default: 5000ms). + - `:cache` — Use `Firebird.ModuleCache` for compilation caching (default: `false`) + - `:wasi` — WASI configuration (auto-detected if not specified) + + ## Examples + + {:ok, 8} = Firebird.WasmRunner.run("fixtures/math.wasm", :add, [5, 3]) + {:ok, 55} = Firebird.WasmRunner.run("fixtures/math.wasm", :fibonacci, [10], cache: true) + {:ok, 8} = Firebird.WasmRunner.run("fixtures/math.wasm", :add, [5, 3], timeout: 1000) + """ + @spec run(binary() | String.t() | map(), atom() | String.t(), list(), keyword()) :: + {:ok, term()} | {:error, term()} + def run(wasm_source, function, args, opts \\ []) do + {call_opts, start_opts} = extract_call_opts(opts) + + with {:ok, pid} <- start(wasm_source, start_opts) do + try do + call_single(pid, function, args, call_opts) + after + Firebird.stop(pid) + end + end + end + + @doc """ + Like `run/4` but raises on error. + + ## Examples + + 8 = Firebird.WasmRunner.run!("fixtures/math.wasm", :add, [5, 3]) + 8 = Firebird.WasmRunner.run!("fixtures/math.wasm", :add, [5, 3], timeout: 2000) + """ + @spec run!(binary() | String.t() | map(), atom() | String.t(), list(), keyword()) :: term() + def run!(wasm_source, function, args, opts \\ []) do + case run(wasm_source, function, args, opts) do + {:ok, result} -> result + {:error, reason} -> raise "WASM run failed: #{inspect(reason)}" + end + end + + @doc """ + Apply a WASM function over a list of argument lists, using a single instance. + + Loads the module once, calls the function for each set of arguments, and + returns all results as a flat list of unwrapped values. + + ## Options + + - `:timeout` — Per-call timeout in milliseconds (default: 5000ms). + - `:concurrency` — When > 1, distributes work across multiple instances. + - All options from `start/2`. + + ## Examples + + {:ok, [3, 7, 11]} = Firebird.WasmRunner.map("fixtures/math.wasm", :add, [ + [1, 2], [3, 4], [5, 6] + ]) + + {:ok, results} = Firebird.WasmRunner.map("fixtures/math.wasm", :add, + [[1, 2], [3, 4]], timeout: 2000 + ) + """ + @spec map(binary() | String.t() | map(), atom() | String.t(), [[integer()]], keyword()) :: + {:ok, [term()]} | {:error, term()} + def map(wasm_source, function, args_list, opts \\ []) when is_list(args_list) do + {concurrency, opts_rest} = Keyword.pop(opts, :concurrency, 1) + {call_opts, start_opts} = extract_call_opts(opts_rest) + + if concurrency > 1 do + run_concurrent(wasm_source, function, args_list, [{:concurrency, concurrency} | opts]) + else + with {:ok, pid} <- start(wasm_source, start_opts) do + try do + results = + Enum.map(args_list, fn args -> + call_single!(pid, function, args, call_opts) + end) + + {:ok, results} + rescue + e -> {:error, Exception.message(e)} + after + Firebird.stop(pid) + end + end + end + end + @doc """ Stop a WASM runner instance. """ @spec stop(pid()) :: :ok defdelegate stop(instance), to: Firebird + + # ── Private helpers ───────────────────────────────────────────────── + + # Extract call-level options (like :timeout) from mixed opts, + # returning {call_opts, remaining_opts}. + defp extract_call_opts(opts) do + {timeout, rest} = Keyword.pop(opts, :timeout) + + call_opts = + if timeout != nil do + [timeout: timeout] + else + [] + end + + {call_opts, rest} + end end diff --git a/test/wasm_runner_timeout_test.exs b/test/wasm_runner_timeout_test.exs new file mode 100644 index 0000000..193b026 --- /dev/null +++ b/test/wasm_runner_timeout_test.exs @@ -0,0 +1,179 @@ +defmodule Firebird.WasmRunnerTimeoutTest do + @moduledoc """ + Tests for WasmRunner timeout support across all convenience functions. + + Verifies that the `:timeout` option is properly threaded through + `call_single/4`, `call_single!/4`, `run/4`, `run!/4`, `run_batch/3`, + `map/4`, and `run_concurrent/4`. + """ + use ExUnit.Case, async: false + + @rust_wasm Path.join(__DIR__, "../fixtures/rust_math.wasm") + @math_wasm Path.join(__DIR__, "../fixtures/math.wasm") + + describe "call_single/4 with timeout" do + test "succeeds with generous timeout" do + {:ok, pid} = Firebird.load(@rust_wasm) + + {:ok, 8} = Firebird.WasmRunner.call_single(pid, :add, [5, 3], timeout: 10_000) + + Firebird.stop(pid) + end + + test "succeeds with default (no timeout option)" do + {:ok, pid} = Firebird.load(@rust_wasm) + + {:ok, 8} = Firebird.WasmRunner.call_single(pid, :add, [5, 3]) + + Firebird.stop(pid) + end + + test "returns error for non-existent function" do + {:ok, pid} = Firebird.load(@rust_wasm) + + {:error, _} = Firebird.WasmRunner.call_single(pid, :nonexistent, [1], timeout: 5_000) + + Firebird.stop(pid) + end + end + + describe "call_single!/4 with timeout" do + test "succeeds with generous timeout" do + {:ok, pid} = Firebird.load(@rust_wasm) + + assert 8 == Firebird.WasmRunner.call_single!(pid, :add, [5, 3], timeout: 10_000) + + Firebird.stop(pid) + end + + test "raises on non-existent function with timeout" do + {:ok, pid} = Firebird.load(@rust_wasm) + + assert_raise RuntimeError, ~r/WASM call failed/, fn -> + Firebird.WasmRunner.call_single!(pid, :nonexistent, [1], timeout: 5_000) + end + + Firebird.stop(pid) + end + end + + describe "run/4 with timeout" do + test "runs with explicit timeout" do + {:ok, 8} = Firebird.WasmRunner.run(@rust_wasm, :add, [5, 3], timeout: 10_000) + end + + test "runs without timeout (defaults)" do + {:ok, 8} = Firebird.WasmRunner.run(@rust_wasm, :add, [5, 3]) + end + + test "runs fibonacci with timeout" do + {:ok, 55} = Firebird.WasmRunner.run(@rust_wasm, :fibonacci, [10], timeout: 10_000) + end + + test "works with WAT-compiled WASM" do + {:ok, 8} = Firebird.WasmRunner.run(@math_wasm, :add, [5, 3], timeout: 5_000) + end + end + + describe "run!/4 with timeout" do + test "returns result with timeout" do + assert 8 == Firebird.WasmRunner.run!(@rust_wasm, :add, [5, 3], timeout: 10_000) + end + + test "raises on bad function" do + assert_raise RuntimeError, ~r/WASM run failed/, fn -> + Firebird.WasmRunner.run!(@rust_wasm, :nonexistent, [1], timeout: 5_000) + end + end + end + + describe "run_batch/3 with timeout" do + test "runs batch with explicit timeout" do + {:ok, results} = + Firebird.WasmRunner.run_batch( + @rust_wasm, + [ + {:add, [5, 3]}, + {:multiply, [4, 7]}, + {:fibonacci, [10]} + ], + timeout: 10_000 + ) + + assert results == [8, 28, 55] + end + + test "runs batch without timeout (defaults)" do + {:ok, results} = + Firebird.WasmRunner.run_batch(@rust_wasm, [ + {:add, [1, 2]}, + {:add, [3, 4]} + ]) + + assert results == [3, 7] + end + end + + describe "map/4 with timeout" do + test "maps with explicit timeout" do + {:ok, results} = + Firebird.WasmRunner.map(@rust_wasm, :add, [[1, 2], [3, 4], [5, 6]], timeout: 10_000) + + assert results == [3, 7, 11] + end + + test "maps without timeout (defaults)" do + {:ok, results} = Firebird.WasmRunner.map(@rust_wasm, :add, [[1, 2], [3, 4]]) + + assert results == [3, 7] + end + + test "maps fibonacci with timeout" do + {:ok, results} = + Firebird.WasmRunner.map(@rust_wasm, :fibonacci, [[5], [10], [15]], timeout: 10_000) + + assert results == [5, 55, 610] + end + end + + describe "run_concurrent/4 with timeout" do + test "runs concurrently with timeout" do + args_list = for n <- 1..10, do: [n, n] + + {:ok, results} = + Firebird.WasmRunner.run_concurrent(@rust_wasm, :add, args_list, + concurrency: 2, + timeout: 10_000 + ) + + expected = for n <- 1..10, do: n * 2 + assert results == expected + end + + test "runs concurrently without timeout (defaults)" do + args_list = [[1, 2], [3, 4], [5, 6]] + + {:ok, results} = + Firebird.WasmRunner.run_concurrent(@rust_wasm, :add, args_list, concurrency: 2) + + assert results == [3, 7, 11] + end + end + + describe "timeout integration" do + test "timeout option doesn't interfere with WASI detection" do + # The timeout option should be extracted before being passed to start/2 + {:ok, 8} = Firebird.WasmRunner.run(@math_wasm, :add, [5, 3], timeout: 5_000) + end + + test "timeout option doesn't interfere with cache option" do + {:ok, 8} = Firebird.WasmRunner.run(@rust_wasm, :add, [5, 3], timeout: 5_000, cache: false) + end + + test "multiple sequential runs with timeout" do + for _ <- 1..5 do + {:ok, 8} = Firebird.WasmRunner.run(@rust_wasm, :add, [5, 3], timeout: 5_000) + end + end + end +end