diff --git a/.formatter.exs b/.formatter.exs index 682d585db..ad151d470 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -1,4 +1,23 @@ [ inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs,zig}"], - plugins: [Zig.Formatter] + plugins: [Zig.Formatter], + locals_without_parens: [ + builtin: 2, + builtin: 3, + static: 1, + static: 2, + method: 1, + method: 2, + prototype: 1, + prototype: 2, + static_value: 2, + static_value: 3, + prototype_value: 2, + prototype_value: 3, + constant: 2, + getter: 1, + getter: 2, + accessor: 2, + static_accessor: 2 + ] ] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 374e4a1de..278cf3dd8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - otp: ['27.0'] + otp: ['27.3'] elixir: ['1.18'] env: MIX_ENV: test @@ -61,6 +61,17 @@ jobs: if [ $rc -ne 0 ] && grep -q "0 failures" /tmp/ci.log; then exit 0; fi exit $rc + - name: Checkout pinned Test262 corpus + run: | + git clone --filter=blob:none --sparse https://github.com/tc39/test262.git /tmp/quickbeam-test262 + git -C /tmp/quickbeam-test262 checkout d1d583db95a521218f3eb8341a887fd63eda8ff1 + git -C /tmp/quickbeam-test262 sparse-checkout set harness test + + - name: BEAM VM copied and pinned conformance gates + env: + TEST262_PATH: /tmp/quickbeam-test262 + run: mix test test/vm/test262_test.exs --include test262 + ubsan: name: UBSan + Zig Debug runs-on: ubuntu-latest @@ -74,7 +85,7 @@ jobs: - uses: erlef/setup-beam@v1 with: - otp-version: '27.0' + otp-version: '27.3' elixir-version: '1.18' - uses: goto-bus-stop/setup-zig@v2 @@ -87,8 +98,8 @@ jobs: path: | deps _build - key: ${{ runner.os }}-ubsan-27.0-1.18-${{ hashFiles('mix.lock') }}-${{ hashFiles('lib/quickbeam/*.zig') }} - restore-keys: ${{ runner.os }}-ubsan-27.0-1.18- + key: ${{ runner.os }}-ubsan-27.3-1.18-${{ hashFiles('mix.lock') }}-${{ hashFiles('lib/quickbeam/*.zig') }} + restore-keys: ${{ runner.os }}-ubsan-27.3-1.18- - run: mix deps.get - run: mix npm.get diff --git a/.github/workflows/vm-fuzz.yml b/.github/workflows/vm-fuzz.yml new file mode 100644 index 000000000..7c5a3ca0d --- /dev/null +++ b/.github/workflows/vm-fuzz.yml @@ -0,0 +1,34 @@ +name: VM mutation corpus + +on: + workflow_dispatch: + schedule: + - cron: '17 3 * * 1' + +jobs: + decoder-verifier: + runs-on: ubuntu-latest + timeout-minutes: 20 + env: + MIX_ENV: test + + steps: + - uses: actions/checkout@v4 + + - uses: erlef/setup-beam@v1 + with: + otp-version: '27.3' + elixir-version: '1.18' + + - name: Cache dependencies + uses: actions/cache@v4 + with: + path: | + deps + _build + key: ${{ runner.os }}-vm-fuzz-27.3-1.18-${{ hashFiles('mix.lock') }} + restore-keys: ${{ runner.os }}-vm-fuzz-27.3-1.18- + + - run: mix deps.get + - run: mix compile --warnings-as-errors + - run: mix quickbeam.vm.fuzz --iterations 100000 --seed 5325389 diff --git a/CHANGELOG.md b/CHANGELOG.md index 23a368833..58b05dd9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +- Add `QuickBEAM.VM`, an isolated BEAM interpreter for verified QuickJS v26 bytecode with async/await, asynchronous `Beam.call` handlers, JavaScript errors, explicit resource limits, deterministic measurements, and bounded Test262 and SSR coverage. `QuickBEAM.VM.call/4` and `measure_call/4` initialize a fresh heap and invoke a named global with a shape matching the native call API. +- Add explicit immutable program pinning through `QuickBEAM.VM.pin/1` and `QuickBEAM.VM.unpin/1`, with fixed slots, single-flight admission, monitored evaluation leases, restart restoration, and decoded-residency limits. +- Add an internal bounded BEAM compiler benchmark tier with fixed generated-module slots and explicit deoptimization. Compiler execution is not exposed through the public VM facade and remains release-quarantined. +- Compact default object and array descriptors while preserving property ordering, reflection, and deterministic memory accounting. +- Serialize native addon initialization and reject unsafe cross-runtime or post-reset reuse unless `allow_reinitialization: true` is explicitly selected. + ## 0.10.20 - Add grapheme-only `Intl.Segmenter` support through `unicode-segmenter` 0.17.0. diff --git a/README.md b/README.md index 6ca963fdb..51f64e746 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,64 @@ QuickBEAM.eval(rt, "function greet(name) { return 'hi ' + name }") QuickBEAM.stop(rt) ``` +## Isolated BEAM execution + +`QuickBEAM.VM` decodes QuickJS bytecode and evaluates it with an isolated BEAM +interpreter. Each evaluation owns its JavaScript heap, globals, jobs, handlers, +and resource accounting. Long-lived SSR bundles can be pinned explicitly so +request processes copy only a lightweight handle: + +```elixir +{:ok, source} = File.read("priv/server.js") +{:ok, program} = QuickBEAM.VM.compile(source, filename: "server.js") +{:ok, pinned} = QuickBEAM.VM.pin(program) + +try do + QuickBEAM.VM.call(pinned, "render", [%{title: "Catalog"}], + profile: :ssr, + handlers: %{"load_props" => fn [] -> %{title: "Catalog"} end}, + max_steps: 20_000_000, + memory_limit: 64 * 1024 * 1024, + timeout: 2_000 + ) +after + QuickBEAM.VM.unpin(pinned) +end +``` + +The program store is fixed-capacity and has no implicit eviction or fallback. +Applications should have one supervised lifecycle owner pin each bundle during +startup and unpin it during replacement or normal shutdown. + +`QuickBEAM.VM.call/4` deliberately mirrors `QuickBEAM.call/4`, but their +lifecycles differ: native calls reuse persistent JavaScript state, while every +VM call initializes a fresh heap from the immutable program before invoking the +global function. + +### Isolated VM limitations + +- Programs and serialized bytecode are locked to the exact vendored QuickJS + bytecode version and ABI fingerprint. Recompile programs when upgrading. +- Source compilation uses a short-lived native QuickJS compiler; execution is + performed by the BEAM interpreter. +- Each `eval` or `call` owns fresh globals, heap objects, jobs, and handlers. + Mutations never persist into another evaluation. +- The BEAM interpreter implements a bounded, tested JavaScript subset rather + than every native QuickJS feature. Unsupported bytecode and semantics fail + explicitly without falling back to native execution. +- `memory_limit` bounds deterministic logical VM allocation. It is distinct + from endpoint BEAM process memory, which is reported by `measure/2` and + `measure_call/4`. Each evaluation permits at most 64 concurrently outstanding + asynchronous `Beam.call` handler operations. +- `:core` and `:ssr` select fixed builtin profiles. They do not expose the full + browser, Node.js, DOM, WASM, or native-addon surfaces of a native runtime. +- Pinned storage has fixed capacity and residency budgets, no implicit eviction, + and no stale-handle fallback. + +The bounded BEAM compiler remains an internal, release-quarantined benchmark +and conformance tier. It is not selectable through the public `QuickBEAM.VM` +evaluation options; production evaluation always uses the interpreter. + ## BEAM integration JS can call Elixir functions and access OTP libraries: diff --git a/bench/README.md b/bench/README.md index fd40a318f..643c9d6b7 100644 --- a/bench/README.md +++ b/bench/README.md @@ -19,8 +19,31 @@ MIX_ENV=bench mix run bench/call_with_data.exs MIX_ENV=bench mix run bench/beam_call.exs MIX_ENV=bench mix run bench/startup.exs MIX_ENV=bench mix run bench/concurrent.exs + +# Isolated BEAM VM SSR and resource gates +MIX_ENV=bench mix run bench/vm_ssr.exs --pinned-programs + +# Native QuickJS versus pinned interpreter/compiler SSR latency +MIX_ENV=bench mix run bench/vm_ssr_compare.exs --samples 50 --warmup 10 + +# Single-scheduler fairness and timeout containment +ERL_FLAGS='+S 1:1' MIX_ENV=bench \ + mix run bench/vm_scheduler_probe.exs --pinned-programs + +# Interpreter sampling and bounded compiler performance +ERL_FLAGS='+S 1:1 +JPperf true' MIX_ENV=bench \ + mix run bench/vm_interpreter_perf.exs --mode pinned +MIX_ENV=bench mix run bench/vm_compiler_perf.exs ``` +The VM runners accept explicit interpreter or compiler profiles. Compiler +execution remains release-quarantined; the interpreter is the default. The SSR +comparison reports both a persistent native runtime and a bare native runtime +created per request, because only the latter shares the BEAM VM's isolated-heap +lifecycle. The interpreter runner uses the public `QuickBEAM.VM.call/4` shape; +internal compiler runners use an equivalent request wrapper. Compilation and +service initialization are excluded from warm latency. + ## Results Apple M1 Pro, Elixir 1.18.4, OTP 27, Zig 0.15.2 (ReleaseFast). diff --git a/bench/vm_compiler_perf.exs b/bench/vm_compiler_perf.exs new file mode 100644 index 000000000..71ca89e41 --- /dev/null +++ b/bench/vm_compiler_perf.exs @@ -0,0 +1,85 @@ +Mix.Task.run("app.start") + +alias QuickBEAM.VM.Compiler +alias QuickBEAM.VM.Runtime.Engine + +iterations = + case Integer.parse(System.get_env("COMPILER_PERF_ITERATIONS", "500")) do + {value, ""} when value > 0 -> value + _invalid -> raise "COMPILER_PERF_ITERATIONS must be a positive integer" + end + +workloads = [ + {"arithmetic_loop", "(function(n){let s=0; for(let i=0;i + started = System.monotonic_time() + Enum.each(1..iterations, fn _iteration -> fun.() end) + elapsed = System.monotonic_time() - started + System.convert_time_unit(elapsed, :native, :microsecond) / iterations +end + +{:ok, compiler} = Compiler.start_link(capacity: 32) +{:ok, initialization_program} = QuickBEAM.VM.compile("0") + +{runtime_init_us, {:ok, 0}} = + :timer.tc(fn -> + Engine.eval(initialization_program, isolation: :caller, max_steps: 1_000_000) + end) + +IO.puts("COMPILER_PERF runtime_initialization_us=#{runtime_init_us} profile=core") + +try do + Enum.each(workloads, fn {name, source} -> + {:ok, program} = QuickBEAM.VM.compile(source) + interpreter_opts = [isolation: :caller, max_steps: 1_000_000] + + compiler_opts = [ + engine: :compiler, + compiler_profile: :scalar_v1, + isolation: :caller, + max_steps: 1_000_000 + ] + + {cold_us, compiled_result} = :timer.tc(fn -> Engine.eval(program, compiler_opts) end) + interpreted_result = Engine.eval(program, interpreter_opts) + + {:ok, _raw_value, raw_execution} = + Compiler.start(program, compiler_profile: :scalar_v1, max_steps: 1_000_000) + + compiled_functions = + Enum.count(raw_execution.compiler_context.decisions, fn {_id, decision} -> + match?({:compile, _, _}, decision) or match?({:cached, _}, decision) + end) + + if compiled_result != interpreted_result do + raise "#{name} mismatch: compiler=#{inspect(compiled_result)} interpreter=#{inspect(interpreted_result)}" + end + + compiler_us = + average_us.(fn -> ^compiled_result = Engine.eval(program, compiler_opts) end) + + interpreter_us = + average_us.(fn -> ^interpreted_result = Engine.eval(program, interpreter_opts) end) + + speedup = interpreter_us / compiler_us + + IO.puts( + "COMPILER_PERF workload=#{name} compiled_functions=#{compiled_functions} " <> + "cold_us=#{cold_us} " <> + "compiler_us=#{Float.round(compiler_us, 2)} " <> + "interpreter_us=#{Float.round(interpreter_us, 2)} speedup=#{Float.round(speedup, 3)}" + ) + end) +after + GenServer.stop(compiler) +end diff --git a/bench/vm_interpreter_perf.exs b/bench/vm_interpreter_perf.exs new file mode 100644 index 000000000..aae7d6577 --- /dev/null +++ b/bench/vm_interpreter_perf.exs @@ -0,0 +1,94 @@ +defmodule QuickBEAM.Bench.VMInterpreterPerf do + @moduledoc "Workload driver for non-instrumented BeamAsm/perf sampling." + + def run(args) do + {opts, positional, invalid} = + OptionParser.parse(args, + strict: [mode: :string, iterations: :integer, warmup: :integer] + ) + + if positional != [] or invalid != [], + do: raise(ArgumentError, "invalid arguments: #{inspect(positional ++ invalid)}") + + mode = mode!(Keyword.get(opts, :mode, "pinned")) + iterations = positive!(Keyword.get(opts, :iterations, 500), :iterations) + warmup = non_negative!(Keyword.get(opts, :warmup, 10), :warmup) + {:ok, source} = bundle() + {:ok, decoded_program} = QuickBEAM.VM.compile(source, filename: fixture()) + + program = + if mode == :pinned do + {:ok, pinned_program} = QuickBEAM.VM.pin(decoded_program) + pinned_program + else + decoded_program + end + + options = [ + profile: :ssr, + handlers: %{"load_props" => fn [] -> props() end}, + max_steps: 50_000_000, + memory_limit: 256_000_000, + timeout: 5_000 + ] + + run = operation(mode, program, options) + Enum.each(1..warmup//1, fn _iteration -> successful!(run.()) end) + + started = System.monotonic_time() + Enum.each(1..iterations, fn _iteration -> successful!(run.()) end) + elapsed = System.monotonic_time() - started + elapsed_ms = System.convert_time_unit(elapsed, :native, :millisecond) + + IO.puts("mode=#{mode} iterations=#{iterations} elapsed_ms=#{elapsed_ms}") + + if mode == :pinned, do: QuickBEAM.VM.unpin(program) + end + + defp operation(:caller, program, options), + do: fn -> QuickBEAM.VM.eval(program, [isolation: :caller] ++ options) end + + defp operation(_isolated, program, options), + do: fn -> QuickBEAM.VM.eval(program, options) end + + defp successful!({:ok, _result}), do: :ok + defp successful!(result), do: raise("unexpected evaluation result: #{inspect(result)}") + + defp fixture, do: "test/fixtures/vm/vue_ssr.js" + + defp bundle do + QuickBEAM.JS.bundle_file(fixture(), + format: :esm, + minify: true, + define: %{ + "__VUE_OPTIONS_API__" => "true", + "__VUE_PROD_DEVTOOLS__" => "false", + "__VUE_PROD_HYDRATION_MISMATCH_DETAILS__" => "false", + "process.env.NODE_ENV" => ~s("production") + } + ) + end + + defp props do + %{ + "title" => "Profile", + "products" => [ + %{"id" => 1, "name" => "Product 1", "inStock" => true, "priceCents" => 1299} + ] + } + end + + defp mode!("caller"), do: :caller + defp mode!("copied"), do: :copied + defp mode!("pinned"), do: :pinned + defp mode!(mode), do: raise(ArgumentError, "invalid mode: #{inspect(mode)}") + + defp positive!(value, _name) when is_integer(value) and value > 0, do: value + defp positive!(value, name), do: raise(ArgumentError, "invalid #{name}: #{inspect(value)}") + defp non_negative!(value, _name) when is_integer(value) and value >= 0, do: value + + defp non_negative!(value, name), + do: raise(ArgumentError, "invalid #{name}: #{inspect(value)}") +end + +QuickBEAM.Bench.VMInterpreterPerf.run(System.argv()) diff --git a/bench/vm_scheduler_probe.exs b/bench/vm_scheduler_probe.exs new file mode 100644 index 000000000..314ae4398 --- /dev/null +++ b/bench/vm_scheduler_probe.exs @@ -0,0 +1,361 @@ +defmodule QuickBEAM.Bench.VMSchedulerProbe do + @moduledoc "Single-scheduler fairness and timeout probe for the BEAM VM." + + alias QuickBEAM.VM.Compiler + alias QuickBEAM.VM.Runtime.Engine + + @fixture "test/fixtures/vm/vue_ssr.js" + @bundle_opts [ + format: :esm, + minify: true, + define: %{ + "__VUE_OPTIONS_API__" => "true", + "__VUE_PROD_DEVTOOLS__" => "false", + "__VUE_PROD_HYDRATION_MISMATCH_DETAILS__" => "false", + "process.env.NODE_ENV" => ~s("production") + } + ] + @max_ticker_gap_us 75_000 + @max_timeout_wall_us 60_000 + + @eval_opts [ + profile: :ssr, + max_steps: 50_000_000, + memory_limit: 512_000_000, + timeout: 60_000 + ] + + def run(args) do + {opts, positional, invalid} = + OptionParser.parse(args, + strict: [ + engine: :string, + compiler_profile: :string, + pinned_programs: :boolean, + samples: :integer, + output: :string + ] + ) + + if positional != [] or invalid != [], + do: raise(ArgumentError, "invalid arguments: #{inspect(positional ++ invalid)}") + + if System.schedulers_online() != 1 do + raise "run with ERL_FLAGS='+S 1:1'; got #{System.schedulers_online()} online schedulers" + end + + samples = positive!(Keyword.get(opts, :samples, 10), :samples) + engine = engine!(Keyword.get(opts, :engine, "interpreter")) + compiler_profile = compiler_profile!(Keyword.get(opts, :compiler_profile, "pure_v1")) + pinned_programs = Keyword.get(opts, :pinned_programs, false) + maybe_start_compiler!(engine) + fixture = compile_fixture!(pinned_programs) + + Enum.each(1..2, fn _iteration -> render!(fixture, engine, compiler_profile) end) + + render_observations = + Enum.map(1..samples, fn _iteration -> + observe_ticker(fn -> render!(fixture, engine, compiler_profile) end) + end) + + render_wall = render_observations |> Enum.map(& &1.wall_time_us) |> Enum.sort() + baseline_ms = max(round(percentile(render_wall, 0.50) / 1_000), 1) + + baseline_observations = + Enum.map(1..samples, fn _iteration -> + observe_ticker(fn -> Process.sleep(baseline_ms) end) + end) + + timeout_program = compile_timeout_program!() + + timeout_wall = + Enum.map(1..samples, fn _iteration -> + {:ok, measurement} = + Engine.measure(timeout_program, + engine: engine, + compiler_profile: compiler_profile, + max_steps: 1_000_000_000, + timeout: 50 + ) + + unless measurement.result == {:error, {:limit_exceeded, :timeout, 50}}, + do: raise("unexpected timeout result: #{inspect(measurement.result)}") + + measurement.wall_time_us + end) + + render_summary = summarize_observations(render_observations) + baseline_summary = summarize_observations(baseline_observations) + timeout_summary = summarize(timeout_wall) + enforce_gates!(render_summary, timeout_summary) + + report = + report( + engine, + compiler_profile, + pinned_programs, + samples, + baseline_ms, + render_summary, + baseline_summary, + timeout_summary + ) + + IO.write(report) + + if output = opts[:output] do + File.mkdir_p!(Path.dirname(output)) + File.write!(output, report) + end + end + + defp compile_fixture!(pinned_programs) do + {:ok, source} = QuickBEAM.JS.bundle_file(@fixture, @bundle_opts) + {:ok, decoded_program} = QuickBEAM.VM.compile(source, filename: @fixture) + + program = + if pinned_programs do + {:ok, pinned_program} = QuickBEAM.VM.pin(decoded_program) + pinned_program + else + decoded_program + end + + %{program: program, props: props()} + end + + defp compile_timeout_program! do + {:ok, program} = QuickBEAM.VM.compile("while (true) {}", filename: "scheduler-timeout.js") + program + end + + defp render!(fixture, engine, compiler_profile) do + handler = fn [] -> fixture.props end + + {:ok, measurement} = + Engine.measure( + fixture.program, + [ + engine: engine, + compiler_profile: compiler_profile, + handlers: %{"load_props" => handler} + ] ++ @eval_opts + ) + + unless match?({:ok, _rendered}, measurement.result), + do: raise("Vue scheduler probe failed: #{inspect(measurement.result)}") + + measurement + end + + defp observe_ticker(operation) do + owner = self() + ref = make_ref() + ticker = spawn_link(fn -> ticker(owner, ref) end) + started = now_us() + result = operation.() + ended = now_us() + send(ticker, {:stop, ref}) + timestamps = collect_ticks(ref, []) + points = [started | timestamps] ++ [ended] + + gaps = + points |> Enum.chunk_every(2, 1, :discard) |> Enum.map(fn [left, right] -> right - left end) + + %{ + wall_time_us: ended - started, + ticker_gaps: gaps, + ticker_count: length(timestamps), + result: result + } + end + + defp ticker(owner, ref) do + receive do + {:stop, ^ref} -> send(owner, {:ticker_stopped, ref}) + after + 1 -> + send(owner, {:ticker_tick, ref, now_us()}) + ticker(owner, ref) + end + end + + defp collect_ticks(ref, timestamps) do + receive do + {:ticker_tick, ^ref, timestamp} -> collect_ticks(ref, [timestamp | timestamps]) + {:ticker_stopped, ^ref} -> Enum.reverse(timestamps) + after + 1_000 -> raise "ticker did not stop" + end + end + + defp summarize_observations(observations) do + gaps = Enum.flat_map(observations, & &1.ticker_gaps) + + %{ + wall: observations |> Enum.map(& &1.wall_time_us) |> summarize(), + gap: summarize(gaps), + ticks: observations |> Enum.map(& &1.ticker_count) |> summarize() + } + end + + defp summarize(values) do + values = Enum.sort(values) + %{median: percentile(values, 0.50), p95: percentile(values, 0.95), max: List.last(values)} + end + + defp percentile(values, fraction) do + index = (fraction * (length(values) - 1)) |> Float.ceil() |> trunc() + Enum.at(values, index) + end + + defp enforce_gates!(render, timeout) do + if render.gap.max > @max_ticker_gap_us, + do: raise("ticker gap #{render.gap.max} µs exceeded #{@max_ticker_gap_us} µs") + + if timeout.p95 > @max_timeout_wall_us, + do: raise("timeout p95 #{timeout.p95} µs exceeded #{@max_timeout_wall_us} µs") + end + + defp report( + engine, + compiler_profile, + pinned_programs, + samples, + baseline_ms, + render, + baseline, + timeout + ) do + title = + if engine == :compiler and compiler_profile == :scalar_v1, + do: "BEAM scalar compiler single-scheduler probe", + else: "BEAM VM single-scheduler probe" + + """ + # #{title} + + Run with `ERL_FLAGS="+S 1:1"`. The pinned Vue SSR fixture and a periodic BEAM + ticker share one scheduler. The baseline sleeps for the median render wall + time, allowing the same ticker to run without #{engine} work. + + - Engine: #{engine} + - Compiler profile: #{compiler_profile} + - Pinned program handles: #{pinned_programs} + - Git base: `#{command("git", ["rev-parse", "--short", "HEAD"])}` + - Working tree at measurement: #{tree_state()} + - Generated: #{DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601()} + - Elixir: #{System.version()} + - OTP: #{System.otp_release()} + - ERTS: #{:erlang.system_info(:version)} + - OS: #{command("uname", ["-sr"])} + - Architecture: #{:erlang.system_info(:system_architecture)} + - CPU: #{cpu_model()} + - Online schedulers: #{System.schedulers_online()} + - Vue probe memory limit: 512 MB + - Samples: #{samples} + + | workload | wall median | wall p95 | ticker gap median | ticker gap p95 | ticker gap max | ticks median | + |---|---:|---:|---:|---:|---:|---:| + | Vue SSR | #{duration(render.wall.median)} | #{duration(render.wall.p95)} | #{duration(render.gap.median)} | #{duration(render.gap.p95)} | #{duration(render.gap.max)} | #{render.ticks.median} | + | sleep baseline (#{baseline_ms} ms target) | #{duration(baseline.wall.median)} | #{duration(baseline.wall.p95)} | #{duration(baseline.gap.median)} | #{duration(baseline.gap.p95)} | #{duration(baseline.gap.max)} | #{baseline.ticks.median} | + + Acceptance bound: Vue SSR ticker gap ≤ #{duration(@max_ticker_gap_us)}. + + ## Timeout containment + + An infinite JavaScript loop was evaluated with a 50 ms outer timeout. + + | timeout | wall median | wall p95 | wall max | median overshoot | + |---:|---:|---:|---:|---:| + | 50 ms | #{duration(timeout.median)} | #{duration(timeout.p95)} | #{duration(timeout.max)} | #{duration(max(timeout.median - 50_000, 0))} | + + Acceptance bound: timeout p95 ≤ #{duration(@max_timeout_wall_us)}. + """ + end + + defp now_us, do: System.monotonic_time(:microsecond) + + defp duration(microseconds) when microseconds >= 1_000, + do: "#{Float.round(microseconds / 1_000, 2)} ms" + + defp duration(microseconds), do: "#{microseconds} µs" + + defp cpu_model do + case File.read("/proc/cpuinfo") do + {:ok, contents} -> + contents + |> String.split("\n") + |> Enum.find_value("unknown", &cpu_model_line/1) + + {:error, _reason} -> + command("sysctl", ["-n", "machdep.cpu.brand_string"]) + end + end + + defp cpu_model_line(line) do + case String.split(line, ":", parts: 2) do + [key, model] -> if(String.trim(key) == "model name", do: String.trim(model)) + _line -> nil + end + end + + defp tree_state do + case command("git", ["status", "--porcelain"]) do + "" -> "clean" + _changes -> "modified" + end + end + + defp command(executable, args) do + case System.cmd(executable, args, stderr_to_stdout: true) do + {output, 0} -> String.trim(output) + {_output, _status} -> "unknown" + end + end + + defp engine!("interpreter"), do: :interpreter + defp engine!("compiler"), do: :compiler + + defp engine!(engine), + do: raise(ArgumentError, "engine must be interpreter or compiler, got: #{inspect(engine)}") + + defp compiler_profile!("pure_v1"), do: :pure_v1 + defp compiler_profile!("scalar_v1"), do: :scalar_v1 + + defp compiler_profile!(profile) do + raise ArgumentError, + "compiler profile must be pure_v1 or scalar_v1, got: #{inspect(profile)}" + end + + defp maybe_start_compiler!(:interpreter), do: :ok + + defp maybe_start_compiler!(:compiler) do + case Compiler.start_link(capacity: 32) do + {:ok, _pid} -> :ok + {:error, {:already_started, _pid}} -> :ok + {:error, reason} -> raise "compiler start failed: #{inspect(reason)}" + end + end + + defp positive!(value, _name) when is_integer(value) and value > 0, do: value + + defp positive!(value, name), + do: raise(ArgumentError, "#{name} must be positive, got: #{inspect(value)}") + + defp props do + %{ + "title" => "Scheduler fairness", + "products" => [ + %{ + "id" => 1, + "name" => "Product 1", + "inStock" => true, + "priceCents" => 1_299 + } + ] + } + end +end + +QuickBEAM.Bench.VMSchedulerProbe.run(System.argv()) diff --git a/bench/vm_ssr.exs b/bench/vm_ssr.exs new file mode 100644 index 000000000..fed60c5bf --- /dev/null +++ b/bench/vm_ssr.exs @@ -0,0 +1,712 @@ +defmodule QuickBEAM.Bench.VMSSR do + @moduledoc """ + Reproducible fixture-specific measurements for the isolated BEAM VM SSR path. + """ + + alias QuickBEAM.VM.Compiler + alias QuickBEAM.VM.Runtime.Engine + + @default_samples 30 + @default_warmup 3 + @default_concurrency [1, 4, 8] + + def run(args) do + {opts, positional, invalid} = + OptionParser.parse(args, + strict: [ + engine: :string, + compiler_profile: :string, + compiler_regions: :boolean, + pinned_programs: :boolean, + samples: :integer, + warmup: :integer, + concurrency: :string, + output: :string + ] + ) + + if positional != [] or invalid != [], + do: raise(ArgumentError, "invalid arguments: #{inspect(positional ++ invalid)}") + + engine = engine!(Keyword.get(opts, :engine, "interpreter")) + compiler_profile = compiler_profile!(Keyword.get(opts, :compiler_profile, "pure_v1")) + compiler_regions = Keyword.get(opts, :compiler_regions, false) + pinned_programs = Keyword.get(opts, :pinned_programs, false) + maybe_start_compiler!(engine) + samples = positive!(Keyword.get(opts, :samples, @default_samples), :samples) + warmup = non_negative!(Keyword.get(opts, :warmup, @default_warmup), :warmup) + + concurrency = + concurrency!(Keyword.get(opts, :concurrency, Enum.join(@default_concurrency, ","))) + + fixtures = + Enum.map( + fixture_specs(), + &compile_fixture!(&1, engine, compiler_profile, compiler_regions, pinned_programs) + ) + + results = + Enum.map(fixtures, fn fixture -> + warm(fixture, warmup) + + %{ + name: fixture.name, + sequential: sequential(fixture, samples), + concurrency: Enum.map(concurrency, &concurrent(fixture, samples, &1)), + limits: limits(fixture) + } + end) + + isolation = isolation_probe(hd(fixtures)) + + report = + markdown_report( + engine, + compiler_profile, + compiler_regions, + pinned_programs, + results, + isolation, + samples, + warmup, + concurrency + ) + + IO.write(report) + + if output = opts[:output] do + File.mkdir_p!(Path.dirname(output)) + File.write!(output, report) + end + end + + defp fixture_specs do + [ + %{ + name: "Preact 10.29.7", + fixture: "test/fixtures/vm/preact_ssr.js", + bundle_opts: [format: :esm, minify: false], + eval_opts: [ + profile: :ssr, + max_steps: 20_000_000, + memory_limit: 64_000_000, + timeout: 2_000 + ] + }, + %{ + name: "Vue 3.5.39", + fixture: "test/fixtures/vm/vue_ssr.js", + bundle_opts: [ + format: :esm, + minify: true, + define: %{ + "__VUE_OPTIONS_API__" => "true", + "__VUE_PROD_DEVTOOLS__" => "false", + "__VUE_PROD_HYDRATION_MISMATCH_DETAILS__" => "false", + "process.env.NODE_ENV" => ~s("production") + } + ], + eval_opts: [ + profile: :ssr, + max_steps: 50_000_000, + memory_limit: 256_000_000, + timeout: 5_000 + ] + }, + %{ + name: "Svelte 5.56.4", + fixture: "test/fixtures/vm/svelte_ssr.js", + bundle_opts: [format: :esm, minify: true], + eval_opts: [ + profile: :ssr, + max_steps: 20_000_000, + memory_limit: 64_000_000, + timeout: 5_000 + ] + } + ] + end + + defp compile_fixture!(spec, engine, compiler_profile, compiler_regions, pinned_programs) do + {:ok, source} = QuickBEAM.JS.bundle_file(spec.fixture, spec.bundle_opts) + {:ok, decoded_program} = QuickBEAM.VM.compile(source, filename: spec.fixture) + + program = + if pinned_programs do + {:ok, pinned_program} = QuickBEAM.VM.pin(decoded_program) + pinned_program + else + decoded_program + end + + eval_opts = + spec.eval_opts + |> Keyword.put(:engine, engine) + |> Keyword.put(:compiler_profile, compiler_profile) + |> Keyword.put(:compiler_regions, compiler_regions) + + spec + |> Map.put(:program, program) + |> Map.put(:eval_opts, eval_opts) + end + + defp warm(_fixture, 0), do: :ok + + defp warm(fixture, count) do + Enum.each(1..count, fn _iteration -> + measurement = measure!(fixture, 1) + ensure_success!(measurement) + end) + end + + defp sequential(fixture, samples) do + measurements = + Enum.map(1..samples, fn _iteration -> + measurement = measure!(fixture, 1) + ensure_success!(measurement) + measurement + end) + + %{ + wall: summarize(measurements, & &1.wall_time_us), + process_memory: summarize(measurements, & &1.process_memory_bytes), + reductions: summarize(measurements, & &1.reductions), + steps: stable_value!(measurements, & &1.steps, :steps), + logical_memory: stable_value!(measurements, & &1.logical_memory_bytes, :logical_memory), + compiler_counters: summarize_counters(measurements) + } + end + + defp concurrent(fixture, samples, concurrency) do + total = max(samples, concurrency * 3) + started = System.monotonic_time() + + measurements = + 1..total + |> Task.async_stream( + fn _iteration -> measure!(fixture, 1) end, + max_concurrency: concurrency, + ordered: false, + timeout: Keyword.fetch!(fixture.eval_opts, :timeout) + 2_000 + ) + |> Enum.map(fn {:ok, measurement} -> + ensure_success!(measurement) + measurement + end) + + elapsed = System.monotonic_time() - started + elapsed_us = System.convert_time_unit(elapsed, :native, :microsecond) + + %{ + level: concurrency, + renders: total, + throughput: total * 1_000_000 / max(elapsed_us, 1), + wall: summarize(measurements, & &1.wall_time_us) + } + end + + defp isolation_probe(fixture) do + :erlang.garbage_collect() + owner_memory_before = process_memory(self()) + process_count_before = :erlang.system_info(:process_count) + started = System.monotonic_time() + + successful = + 1..100 + |> Task.async_stream( + fn id -> + measurement = measure!(fixture, id) + + case measurement.result do + {:ok, html} when is_binary(html) -> + String.contains?(html, ~s(data-id="#{id}")) + + _result -> + false + end + end, + max_concurrency: 100, + ordered: false, + timeout: Keyword.fetch!(fixture.eval_opts, :timeout) + 5_000 + ) + |> Enum.count(&match?({:ok, true}, &1)) + + elapsed = System.monotonic_time() - started + :erlang.garbage_collect() + Process.sleep(20) + :erlang.garbage_collect() + + %{ + successful: successful, + throughput: + 100 * 1_000_000 / max(System.convert_time_unit(elapsed, :native, :microsecond), 1), + owner_memory_delta: process_memory(self()) - owner_memory_before, + process_count_delta: :erlang.system_info(:process_count) - process_count_before + } + end + + defp limits(fixture) do + baseline = measure!(fixture, 1, handler_delay: 0) + ensure_success!(baseline) + + step_limit = max(baseline.steps - 1, 1) + step_measurement = measure!(fixture, 1, handler_delay: 0, max_steps: step_limit) + + memory_limit = max(div(baseline.logical_memory_bytes, 2), 1_024) + memory_measurement = measure!(fixture, 1, handler_delay: 0, memory_limit: memory_limit) + + timeout = timeout_and_cancellation(fixture) + + %{ + step_limit: step_limit, + step_result: result_label(step_measurement.result), + memory_limit: memory_limit, + memory_result: result_label(memory_measurement.result), + timeout_ms: timeout.timeout_ms, + timeout_wall_us: timeout.measurement.wall_time_us, + timeout_result: result_label(timeout.measurement.result), + cancellation_us: timeout.cancellation_us + } + end + + defp timeout_and_cancellation(fixture) do + parent = self() + timeout_ms = 200 + + handler = fn [] -> + send(parent, {:vm_ssr_handler_started, self()}) + Process.sleep(:infinity) + end + + opts = + fixture.eval_opts + |> Keyword.put(:handlers, %{"load_props" => handler}) + |> Keyword.put(:timeout, timeout_ms) + + {:ok, measurement} = Engine.measure(fixture.program, opts) + + handler_pid = + receive do + {:vm_ssr_handler_started, pid} -> pid + after + timeout_ms + 1_000 -> raise "#{fixture.name} did not start its asynchronous handler" + end + + started = System.monotonic_time() + monitor = Process.monitor(handler_pid) + + receive do + {:DOWN, ^monitor, :process, ^handler_pid, _reason} -> :ok + after + 1_000 -> raise "#{fixture.name} did not cancel its asynchronous handler" + end + + elapsed = System.monotonic_time() - started + + %{ + timeout_ms: timeout_ms, + measurement: measurement, + cancellation_us: System.convert_time_unit(elapsed, :native, :microsecond) + } + end + + defp measure!(fixture, id, overrides \\ []) do + delay = Keyword.get(overrides, :handler_delay, 5) + props = props("Catalog #{id}", id) + + handler = fn [] -> + if delay > 0, do: Process.sleep(delay) + props + end + + eval_opts = + fixture.eval_opts + |> Keyword.merge(Keyword.drop(overrides, [:handler_delay])) + |> Keyword.put(:handlers, %{"load_props" => handler}) + + {:ok, measurement} = Engine.measure(fixture.program, eval_opts) + measurement + end + + defp ensure_success!(%{result: {:ok, _value}}), do: :ok + + defp ensure_success!(measurement), + do: raise("SSR measurement failed: #{inspect(measurement.result)}") + + defp stable_value!(measurements, getter, label) do + values = measurements |> Enum.map(getter) |> Enum.uniq() + + case values do + [value] -> value + _values -> raise "#{label} was not deterministic: #{inspect(values)}" + end + end + + defp summarize_counters(measurements) do + counters = measurements |> Enum.map(& &1.compiler_counters) |> Enum.reject(&is_nil/1) + + case counters do + [] -> + nil + + counters -> + counters + |> hd() + |> Map.keys() + |> Enum.reject(&(&1 in [:deopt_opcodes, :interpreted_opcodes, :profile])) + |> Map.new(fn key -> + values = counters |> Enum.map(&Map.fetch!(&1, key)) |> Enum.sort() + {key, percentile(values, 0.50)} + end) + |> Map.put(:deopt_opcodes, summarize_opcode_counts(counters, :deopt_opcodes)) + |> Map.put(:interpreted_opcodes, summarize_opcode_counts(counters, :interpreted_opcodes)) + |> Map.put(:profile, stable_value!(counters, & &1.profile, :compiler_profile)) + end + end + + defp summarize_opcode_counts(counters, field) do + names = counters |> Enum.flat_map(&Map.keys(&1[field])) |> Enum.uniq() + + Map.new(names, fn name -> + values = counters |> Enum.map(&Map.get(&1[field], name, 0)) |> Enum.sort() + {name, percentile(values, 0.50)} + end) + end + + defp summarize(measurements, getter) do + values = measurements |> Enum.map(getter) |> Enum.reject(&is_nil/1) |> Enum.sort() + + %{ + median: percentile(values, 0.50), + p95: percentile(values, 0.95), + min: hd(values), + max: List.last(values) + } + end + + defp percentile(values, fraction) do + index = (fraction * (length(values) - 1)) |> Float.ceil() |> trunc() + Enum.at(values, index) + end + + defp result_label({:error, {:limit_exceeded, kind, _limit}}), do: "limit:#{kind}" + defp result_label({:error, reason}), do: "error:#{inspect(reason)}" + defp result_label({:ok, _value}), do: "ok" + + defp markdown_report( + engine, + compiler_profile, + compiler_regions, + pinned_programs, + results, + isolation, + samples, + warmup, + concurrency + ) do + metadata = metadata() + + scheduler_report = + case {engine, compiler_profile} do + {:compiler, :scalar_v1} -> "beam-compiler-scalar-scheduler-measurements.md" + {:compiler, _profile} -> "beam-compiler-scheduler-measurements.md" + {:interpreter, _profile} -> "beam-scheduler-measurements.md" + end + + title = + case {engine, compiler_profile, compiler_regions} do + {:compiler, _profile, true} -> "Bounded region compiler SSR measurements" + {:compiler, :scalar_v1, false} -> "BEAM scalar compiler SSR measurements" + {:compiler, _profile, false} -> "BEAM compiler SSR measurements" + {:interpreter, _profile, _regions} -> "BEAM VM SSR measurements" + end + + scheduler_note = + if compiler_regions do + "The region experiment has no scheduler-gate claim because it fails the SSR latency gate." + else + "The single-scheduler fairness and timeout gate is published separately in " <> + "[`#{scheduler_report}`](#{scheduler_report})." + end + + """ + # #{title} + + These results cover only the pinned, non-streaming fixtures listed below. They + are not browser, DOM, or general framework compatibility claims. Each render + performs one asynchronous `Beam.call` with a fixed 5 ms handler delay. + #{scheduler_note} + + ## Environment + + - Engine: #{engine} + - Compiler profile: #{compiler_profile} + - Compiler regions: #{compiler_regions} + - Pinned program handles: #{pinned_programs} + - Git base: `#{metadata.git}` + - Working tree at measurement: #{metadata.tree_state} + - Generated: #{metadata.generated} + - Elixir: #{metadata.elixir} + - OTP: #{metadata.otp} + - ERTS: #{metadata.erts} + - OS: #{metadata.os} + - Architecture: #{metadata.architecture} + - CPU: #{metadata.cpu} + - Logical schedulers: #{metadata.schedulers} + - Mix environment: `#{metadata.mix_env}` + - Samples per fixture: #{samples} after #{warmup} warmups + - Concurrency levels: #{Enum.join(concurrency, ", ")} + + ## Sequential isolated renders + + | Fixture | wall median | wall p95 | VM steps | logical memory | endpoint process memory | reductions median | + |---|---:|---:|---:|---:|---:|---:| + #{Enum.map_join(results, "\n", &sequential_row/1)} + + `VM steps` and `logical memory` are deterministic counters. Endpoint process + memory and reductions are observed once after result conversion; they are not + sampled peaks. Wall time includes process startup, the 5 ms host wait, + rendering, conversion, and reply delivery. + + #{compiler_counter_report(engine, results)} + ## Concurrent isolated renders + + | Fixture | concurrency | renders | throughput | per-render wall median | per-render wall p95 | + |---|---:|---:|---:|---:|---:| + #{Enum.map_join(results, "\n", &concurrency_rows/1)} + + ## 100-render isolation and reclamation probe + + The Preact fixture was rendered 100 times concurrently with unique request + data and one shared immutable program. + + | successful isolated renders | throughput | caller memory delta after GC | process-count delta | + |---:|---:|---:|---:| + | #{isolation.successful}/100 | #{Float.round(isolation.throughput, 1)} renders/s | #{signed_bytes(isolation.owner_memory_delta)} | #{signed_integer(isolation.process_count_delta)} | + + Request-specific IDs were checked in every result. Memory and process deltas + are endpoint observations after explicit caller GC, not operating-system RSS + measurements. + + ## Resource-limit and cancellation checks + + | Fixture | step rejection | memory rejection | timeout | observed timeout wall | handler cancellation after return | + |---|---:|---:|---:|---:|---:| + #{Enum.map_join(results, "\n", &limits_row/1)} + + Memory rejection uses half the fixture's successful logical allocation. + Timeout uses a non-returning asynchronous handler and verifies that its BEAM + process terminates. Cancellation time is measured from `measure/2` returning + to observation of the handler's `:DOWN` message. + """ + end + + defp compiler_counter_report(:interpreter, _results), do: "" + + defp compiler_counter_report(:compiler, results) do + """ + ## Compiler execution counters + + These fixed-key counters are captured from the evaluation owner. Generated + steps, entries, deoptimizations, invocation actions, and re-entries describe + execution; compile/cache/skip fields remain module-pool lifecycle observations. + + | Fixture | frame attempts | skipped frames | decisions C/H/S | generated steps | step coverage | entries | deopts | invocation actions | re-entries | leading deopt opcodes | hot interpreted opcodes | + |---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---|---| + #{Enum.map_join(results, "\n", &compiler_counter_row/1)} + + """ + end + + defp compiler_counter_row(result) do + counters = result.sequential.compiler_counters + coverage = 100 * counters.generated_steps / max(result.sequential.steps, 1) + + deopt_opcodes = format_opcode_counts(counters.deopt_opcodes) + interpreted_opcodes = format_opcode_counts(counters.interpreted_opcodes) + + decisions = + "#{counters.compiled_functions}/#{counters.cached_functions}/#{counters.skipped_functions}" + + "| #{result.name} | #{integer(counters.frame_attempts)} | " <> + "#{integer(counters.skipped_frames)} | #{decisions} | " <> + "#{integer(counters.generated_steps)} | " <> + "#{Float.round(coverage, 1)}% | #{integer(counters.generated_entries)} | " <> + "#{integer(counters.deoptimizations)} | " <> + "#{integer(counters.invocation_actions)} | #{integer(counters.reentries)} | " <> + "#{deopt_opcodes} | #{interpreted_opcodes} |" + end + + defp format_opcode_counts(counts) do + counts + |> Enum.sort_by(fn {name, count} -> {-count, name} end) + |> Enum.take(8) + |> Enum.map_join(", ", fn {name, count} -> "`#{name}`=#{count}" end) + end + + defp sequential_row(result) do + sequential = result.sequential + + "| #{result.name} | #{duration(sequential.wall.median)} | #{duration(sequential.wall.p95)} | " <> + "#{integer(sequential.steps)} | #{bytes(sequential.logical_memory)} | " <> + "#{bytes(sequential.process_memory.median)} | #{integer(sequential.reductions.median)} |" + end + + defp concurrency_rows(result) do + Enum.map_join(result.concurrency, "\n", fn measurement -> + "| #{result.name} | #{measurement.level} | #{measurement.renders} | " <> + "#{Float.round(measurement.throughput, 1)} renders/s | " <> + "#{duration(measurement.wall.median)} | #{duration(measurement.wall.p95)} |" + end) + end + + defp limits_row(result) do + limits = result.limits + + "| #{result.name} | #{limits.step_result} at #{integer(limits.step_limit)} | " <> + "#{limits.memory_result} at #{bytes(limits.memory_limit)} | " <> + "#{limits.timeout_result} at #{limits.timeout_ms} ms | " <> + "#{duration(limits.timeout_wall_us)} | #{duration(limits.cancellation_us)} |" + end + + defp metadata do + %{ + git: command("git", ["rev-parse", "--short", "HEAD"]), + tree_state: tree_state(), + generated: DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601(), + elixir: System.version(), + otp: System.otp_release(), + erts: :erlang.system_info(:version) |> to_string(), + os: command("uname", ["-sr"]), + architecture: :erlang.system_info(:system_architecture) |> to_string(), + cpu: cpu_model(), + schedulers: System.schedulers_online(), + mix_env: Mix.env() + } + end + + defp cpu_model do + case File.read("/proc/cpuinfo") do + {:ok, contents} -> + contents + |> String.split("\n") + |> Enum.find_value("unknown", &cpu_model_line/1) + + {:error, _reason} -> + command("sysctl", ["-n", "machdep.cpu.brand_string"]) + end + end + + defp cpu_model_line(line) do + case String.split(line, ":", parts: 2) do + [key, model] -> if(String.trim(key) == "model name", do: String.trim(model)) + _line -> nil + end + end + + defp tree_state do + case command("git", ["status", "--porcelain"]) do + "" -> "clean" + _changes -> "modified" + end + end + + defp command(executable, args) do + case System.cmd(executable, args, stderr_to_stdout: true) do + {output, 0} -> String.trim(output) + {_output, _status} -> "unknown" + end + end + + defp duration(microseconds) when microseconds >= 1_000, + do: "#{Float.round(microseconds / 1_000, 2)} ms" + + defp duration(microseconds), do: "#{microseconds} µs" + + defp process_memory(pid) do + case Process.info(pid, :memory) do + {:memory, bytes} -> bytes + nil -> 0 + end + end + + defp signed_bytes(value) when value < 0, do: "-#{bytes(abs(value))}" + defp signed_bytes(value), do: "+#{bytes(value)}" + + defp signed_integer(value) when value > 0, do: "+#{value}" + defp signed_integer(value), do: Integer.to_string(value) + + defp bytes(value) when value >= 1024 * 1024, + do: "#{Float.round(value / (1024 * 1024), 2)} MiB" + + defp bytes(value) when value >= 1024, do: "#{Float.round(value / 1024, 1)} KiB" + defp bytes(value), do: "#{value} B" + + defp integer(value), do: Integer.to_string(value) + + defp engine!("interpreter"), do: :interpreter + defp engine!("compiler"), do: :compiler + + defp engine!(engine), + do: raise(ArgumentError, "engine must be interpreter or compiler, got: #{inspect(engine)}") + + defp compiler_profile!("pure_v1"), do: :pure_v1 + defp compiler_profile!("scalar_v1"), do: :scalar_v1 + + defp compiler_profile!(profile) do + raise ArgumentError, + "compiler profile must be pure_v1 or scalar_v1, got: #{inspect(profile)}" + end + + defp maybe_start_compiler!(:interpreter), do: :ok + + defp maybe_start_compiler!(:compiler) do + case Compiler.start_link(capacity: 32) do + {:ok, _pid} -> :ok + {:error, {:already_started, _pid}} -> :ok + {:error, reason} -> raise "compiler start failed: #{inspect(reason)}" + end + end + + defp positive!(value, _name) when is_integer(value) and value > 0, do: value + + defp positive!(value, name), + do: raise(ArgumentError, "#{name} must be positive, got: #{inspect(value)}") + + defp non_negative!(value, _name) when is_integer(value) and value >= 0, do: value + + defp non_negative!(value, name), + do: raise(ArgumentError, "#{name} must be non-negative, got: #{inspect(value)}") + + defp concurrency!(value) do + levels = + value + |> String.split(",", trim: true) + |> Enum.map(fn level -> + case Integer.parse(level) do + {integer, ""} when integer > 0 -> integer + _ -> raise ArgumentError, "invalid concurrency level: #{inspect(level)}" + end + end) + |> Enum.uniq() + + if levels == [], do: raise(ArgumentError, "at least one concurrency level is required") + levels + end + + defp props(title, id) do + %{ + "title" => title, + "products" => [ + %{ + "id" => id, + "name" => "Product #{id}", + "inStock" => rem(id, 2) == 1, + "priceCents" => 1_299 + (id - 1) * 100 + } + ] + } + end +end + +QuickBEAM.Bench.VMSSR.run(System.argv()) diff --git a/bench/vm_ssr_compare.exs b/bench/vm_ssr_compare.exs new file mode 100644 index 000000000..ac4e42b0c --- /dev/null +++ b/bench/vm_ssr_compare.exs @@ -0,0 +1,310 @@ +defmodule QuickBEAM.Bench.VMSSRCompare do + @moduledoc """ + Compares native QuickJS and the pinned BEAM VM on identical SSR fixtures. + + Native warm mode loads the framework bundle once and calls its render function + repeatedly. Native isolated mode starts a bare runtime, loads precompiled + request bytecode, renders, and stops the runtime for every sample. The public + interpreter initializes pinned setup bytecode and calls the same named render + function in a fresh owner-local heap. Internal compiler modes execute the + equivalent precompiled request wrapper. Source compilation and compiler-service + startup are excluded; all returned values must match exactly. + """ + + alias QuickBEAM.VM.Compiler + alias QuickBEAM.VM.Compiler.Pool + alias QuickBEAM.VM.Runtime.Engine + + @default_samples 50 + @default_warmup 10 + + def run(args) do + {opts, positional, invalid} = + OptionParser.parse(args, strict: [samples: :integer, warmup: :integer]) + + if positional != [] or invalid != [], + do: raise(ArgumentError, "invalid arguments: #{inspect(positional ++ invalid)}") + + samples = positive!(Keyword.get(opts, :samples, @default_samples), :samples) + warmup = non_negative!(Keyword.get(opts, :warmup, @default_warmup), :warmup) + ensure_compiler!() + + IO.puts( + "OTP=#{System.otp_release()} schedulers=#{System.schedulers_online()} " <> + "samples=#{samples} warmup=#{warmup}" + ) + + IO.puts("Source compilation, bundle setup, and compiler-service startup are excluded.") + + IO.puts( + "native_warm reuses an initialized runtime; native_isolated includes bare " <> + "runtime start/load/render/stop." + ) + + Enum.each(fixtures(), &compare_fixture(&1, samples, warmup)) + end + + defp compare_fixture(spec, sample_count, warmup) do + setup_source = setup_source!(spec) + + request_source = + setup_source <> + "\nglobalThis.__quickbeamSSRResult = globalThis.__quickbeamRender();\n" <> + "globalThis.__quickbeamGetSSRResult = async function(){ " <> + "return await globalThis.__quickbeamSSRResult; };\n" <> + "globalThis.__quickbeamSSRResult;\n" + + handlers = %{"load_props" => fn [] -> props() end} + + {:ok, compile_runtime} = QuickBEAM.start(apis: false) + {:ok, setup_bytecode} = QuickBEAM.compile(compile_runtime, setup_source) + {:ok, request_bytecode} = QuickBEAM.compile(compile_runtime, request_source) + :ok = QuickBEAM.stop(compile_runtime) + + {:ok, call_program} = QuickBEAM.VM.decode(setup_bytecode) + {:ok, request_program} = QuickBEAM.VM.decode(request_bytecode) + {:ok, pinned_call} = QuickBEAM.VM.pin(call_program) + {:ok, pinned_request} = QuickBEAM.VM.pin(request_program) + + vm_opts = [ + handlers: handlers, + profile: :ssr, + max_steps: spec.max_steps, + memory_limit: spec.memory_limit, + timeout: 10_000 + ] + + {:ok, warm_runtime} = QuickBEAM.start(handlers: handlers, apis: false) + {:ok, _namespace} = QuickBEAM.load_bytecode(warm_runtime, setup_bytecode) + + runners = [ + native_warm: fn -> + QuickBEAM.call(warm_runtime, "__quickbeamRender", [], timeout: 10_000) + end, + native_isolated: fn -> native_isolated(request_bytecode, handlers) end, + interpreter: fn -> + QuickBEAM.VM.call(pinned_call, "__quickbeamRender", [], vm_opts) + end, + compiler_pure: fn -> compiler_eval(pinned_request, :pure_v1, vm_opts) end, + compiler_scalar: fn -> compiler_eval(pinned_request, :scalar_v1, vm_opts) end + ] + + try do + expected = successful!(runners[:native_warm].()) + warm!(runners, expected, warmup) + samples = sample!(runners, expected, sample_count) + report(spec.name, samples) + after + QuickBEAM.stop(warm_runtime) + QuickBEAM.VM.unpin(pinned_call) + QuickBEAM.VM.unpin(pinned_request) + end + end + + defp compiler_eval(program, profile, opts), + do: Engine.eval(program, [engine: :compiler, compiler_profile: profile] ++ opts) + + defp native_isolated(bytecode, handlers) do + case QuickBEAM.start(handlers: handlers, apis: false) do + {:ok, runtime} -> + try do + case QuickBEAM.load_bytecode(runtime, bytecode) do + {:ok, _namespace} -> + QuickBEAM.call(runtime, "__quickbeamGetSSRResult", [], timeout: 10_000) + + {:error, _reason} = error -> + error + end + after + QuickBEAM.stop(runtime) + end + + {:error, _reason} = error -> + error + end + end + + defp warm!(_runners, _expected, 0), do: :ok + + defp warm!(runners, expected, count) do + Enum.each(runners, fn {_name, runner} -> + Enum.each(1..count, fn _iteration -> assert_same!(runner.(), expected) end) + end) + end + + defp sample!(runners, expected, count) do + initial = Map.new(runners, fn {name, _runner} -> {name, []} end) + runner_count = length(runners) + + samples = + Enum.reduce(0..(count - 1), initial, fn iteration, samples -> + runners + |> rotate(rem(iteration, runner_count)) + |> Enum.reduce(samples, fn {name, runner}, samples -> + {elapsed_us, result} = :timer.tc(runner) + assert_same!(result, expected) + Map.update!(samples, name, &[elapsed_us | &1]) + end) + end) + + Map.new(samples, fn {name, values} -> {name, summarize(values)} end) + end + + defp rotate(values, 0), do: values + + defp rotate(values, count) do + {left, right} = Enum.split(values, count) + right ++ left + end + + defp report(name, samples) do + native_warm = samples.native_warm.median + native_isolated = samples.native_isolated.median + + IO.puts("\n#{name}") + + IO.puts(" mode median p95 vs warm vs isolated") + + Enum.each( + [:native_warm, :native_isolated, :interpreter, :compiler_pure, :compiler_scalar], + fn mode -> + result = Map.fetch!(samples, mode) + + IO.puts( + " #{mode |> Atom.to_string() |> String.pad_trailing(17)} " <> + "#{format_ms(result.median) |> String.pad_leading(7)}ms " <> + "#{format_ms(result.p95) |> String.pad_leading(7)}ms " <> + "#{format_ratio(result.median, native_warm) |> String.pad_leading(9)} " <> + "#{format_ratio(result.median, native_isolated) |> String.pad_leading(12)}" + ) + end + ) + end + + defp summarize(samples) do + sorted = Enum.sort(samples) + + %{ + min: hd(sorted), + median: percentile(sorted, 0.50), + p95: percentile(sorted, 0.95) + } + end + + defp percentile(sorted, fraction) do + index = max(ceil(length(sorted) * fraction) - 1, 0) + Enum.at(sorted, index) + end + + defp successful!({:ok, value}), do: value + defp successful!(other), do: raise("unexpected result: #{inspect(other)}") + + defp assert_same!({:ok, value}, value), do: :ok + + defp assert_same!(actual, expected), + do: raise("result mismatch: #{inspect(actual)} != #{inspect(expected)}") + + defp format_ms(microseconds), + do: :erlang.float_to_binary(microseconds / 1_000, decimals: 3) + + defp format_ratio(value, baseline), + do: :erlang.float_to_binary(value / baseline, decimals: 2) <> "x" + + defp positive!(value, _name) when is_integer(value) and value > 0, do: value + + defp positive!(value, name), + do: raise(ArgumentError, "#{name} must be positive, got: #{inspect(value)}") + + defp non_negative!(value, _name) when is_integer(value) and value >= 0, do: value + + defp non_negative!(value, name), + do: raise(ArgumentError, "#{name} must be non-negative, got: #{inspect(value)}") + + defp setup_source!(spec) do + source = File.read!(spec.fixture) + + setup_entry = + source + |> String.replace( + "globalThis.__quickbeamSSRResult = (async function", + "globalThis.__quickbeamRender = async function" + ) + |> String.replace("})();\n\nglobalThis.__quickbeamSSRResult;", "};") + + if setup_entry == source, + do: raise("failed to rewrite SSR entry #{spec.fixture}") + + temporary = Path.join(Path.dirname(spec.fixture), ".native-vm-#{Path.basename(spec.fixture)}") + File.write!(temporary, setup_entry) + + try do + {:ok, bundled} = QuickBEAM.JS.bundle_file(temporary, spec.bundle_opts) + bundled + after + File.rm!(temporary) + end + end + + defp props do + %{ + "title" => "Native versus BEAM", + "products" => + Enum.map(1..8, fn id -> + %{ + "id" => id, + "name" => "Product #{id}", + "inStock" => rem(id, 2) == 1, + "priceCents" => 1_199 + id * 100 + } + end) + } + end + + defp fixtures do + [ + %{ + name: "Preact 10.29.7", + fixture: "test/fixtures/vm/preact_ssr.js", + bundle_opts: [format: :esm, minify: false], + max_steps: 20_000_000, + memory_limit: 64_000_000 + }, + %{ + name: "Vue 3.5.39", + fixture: "test/fixtures/vm/vue_ssr.js", + bundle_opts: [ + format: :esm, + minify: true, + define: %{ + "__VUE_OPTIONS_API__" => "true", + "__VUE_PROD_DEVTOOLS__" => "false", + "__VUE_PROD_HYDRATION_MISMATCH_DETAILS__" => "false", + "process.env.NODE_ENV" => ~s("production") + } + ], + max_steps: 50_000_000, + memory_limit: 256_000_000 + }, + %{ + name: "Svelte 5.56.4", + fixture: "test/fixtures/vm/svelte_ssr.js", + bundle_opts: [format: :esm, minify: true], + max_steps: 20_000_000, + memory_limit: 64_000_000 + } + ] + end + + defp ensure_compiler! do + case Process.whereis(Pool) do + nil -> + {:ok, _pid} = Compiler.start_link(capacity: 32) + :ok + + _pid -> + :ok + end + end +end + +QuickBEAM.Bench.VMSSRCompare.run(System.argv()) diff --git a/lib/mix/tasks/quickbeam.vm.fuzz.ex b/lib/mix/tasks/quickbeam.vm.fuzz.ex new file mode 100644 index 000000000..1c3948e84 --- /dev/null +++ b/lib/mix/tasks/quickbeam.vm.fuzz.ex @@ -0,0 +1,87 @@ +defmodule Mix.Tasks.Quickbeam.Vm.Fuzz do + @shortdoc "Runs bounded QuickBEAM decoder and verifier mutation fuzzing" + @moduledoc """ + Runs the deterministic bytecode decoder and verifier mutation corpora. + + mix quickbeam.vm.fuzz --iterations 10000 --seed 5325389 + + Every mutation is evaluated twice in a heap-limited monitored process. The + command exits unsuccessfully on a crash, timeout, nondeterministic result, or + accepted deliberately-invalid verifier mutation. + """ + + use Mix.Task + + alias QuickBEAM.VM.Fuzz + + @switches [iterations: :integer, seed: :integer, timeout: :integer, max_heap_bytes: :integer] + + @impl Mix.Task + def run(args) do + Mix.Task.run("app.start") + {options, positional, invalid} = OptionParser.parse(args, strict: @switches) + + if positional != [] or invalid != [] do + Mix.raise("invalid arguments: #{inspect(positional ++ invalid)}") + end + + fuzz_options = [ + iterations: Keyword.get(options, :iterations, 10_000), + seed: Keyword.get(options, :seed, 0x51424D), + timeout: Keyword.get(options, :timeout, 100), + max_heap_bytes: Keyword.get(options, :max_heap_bytes, 16 * 1024 * 1024) + ] + + bytecode_corpus = compile_corpus!() + + program_corpus = + Enum.map(bytecode_corpus, fn {name, bytecode} -> {name, decode!(bytecode)} end) + + {:ok, decoder} = Fuzz.run_bytecode(bytecode_corpus, fuzz_options) + {:ok, verifier} = Fuzz.run_verifier(program_corpus, fuzz_options) + + print_summary(decoder) + print_summary(verifier) + + findings = decoder.findings ++ verifier.findings + + if findings != [] do + Enum.each(findings, &Mix.shell().error(format_finding(&1))) + Mix.raise("VM mutation fuzzing found #{length(findings)} failure(s)") + end + end + + defp compile_corpus! do + {:ok, runtime} = QuickBEAM.start(apis: false) + + try do + Enum.map(Fuzz.default_sources(), fn {name, source} -> + {:ok, bytecode} = QuickBEAM.compile(runtime, source) + {name, bytecode} + end) + after + QuickBEAM.stop(runtime) + end + end + + defp decode!(bytecode) do + case QuickBEAM.VM.decode(bytecode) do + {:ok, program} -> program + {:error, reason} -> Mix.raise("valid corpus bytecode failed decoding: #{inspect(reason)}") + end + end + + defp print_summary(summary) do + Mix.shell().info( + "#{summary.domain}: #{summary.iterations} mutations, " <> + "outcomes=#{inspect(summary.counts)}, operations=#{inspect(summary.operation_counts)}" + ) + end + + defp format_finding(finding) do + mutation = finding.mutation + + "#{mutation.domain} finding corpus=#{mutation.corpus} seed=#{mutation.seed} " <> + "iteration=#{mutation.iteration} operation=#{mutation.operation}: #{inspect(finding.outcome)}" + end +end diff --git a/lib/quickbeam.ex b/lib/quickbeam.ex index 6d8c4315f..c5dc9ca76 100644 --- a/lib/quickbeam.ex +++ b/lib/quickbeam.ex @@ -239,10 +239,20 @@ defmodule QuickBEAM do `napi_module_register`) entry point is called. Returns the addon's exports as an Elixir term. + Addon initialization is process-global native state. QuickBEAM initializes a + canonical addon path once per BEAM instance. Loading that path again in the + same runtime reuses its cached exports, so multiple global aliases are safe. + Loading it in another runtime, or after `reset/1`, returns + `{:error, {:addon_already_initialized, canonical_path}}` by default. + ## Options * `:as` - set the addon's exports as a global JS variable with this name, making the functions callable from `eval/3` and `call/3` + * `:allow_reinitialization` - opt into calling the native initializer again + in a different JavaScript context. Defaults to `false`. Only enable this + when the addon explicitly documents multi-environment initialization as + safe; incompatible addons can terminate the BEAM VM. ## Examples diff --git a/lib/quickbeam/application.ex b/lib/quickbeam/application.ex index f961ae4bf..18ebf60d1 100644 --- a/lib/quickbeam/application.ex +++ b/lib/quickbeam/application.ex @@ -1,5 +1,10 @@ defmodule QuickBEAM.Application do - @moduledoc false + @moduledoc """ + Starts QuickBEAM's shared OTP services. + + This includes the bounded immutable VM program store and the task supervisor + used by asynchronous host operations. + """ use Application @@ -11,7 +16,9 @@ defmodule QuickBEAM.Application do start: {:pg, :start_link, [QuickBEAM.BroadcastChannel]} }, QuickBEAM.LockManager, - QuickBEAM.WasmAPI + QuickBEAM.WasmAPI, + QuickBEAM.VM.Program.Store, + {Task.Supervisor, name: QuickBEAM.VM.TaskSupervisor} ] QuickBEAM.Storage.init() diff --git a/lib/quickbeam/context_types.zig b/lib/quickbeam/context_types.zig index 011c8d03e..be1e251c5 100644 --- a/lib/quickbeam/context_types.zig +++ b/lib/quickbeam/context_types.zig @@ -139,6 +139,9 @@ pub const PoolData = struct { queue_tail: ?*PoolMessageNode, stopped: bool, thread: ?std.Thread, + lifecycle_mutex: std.Thread.Mutex = .{}, + lifecycle_cond: std.Thread.Condition = .{}, + lifecycle: types.LifecycleState = .running, memory_limit: usize = 256 * 1024 * 1024, max_stack_size: usize = 8 * 1024 * 1024, // WASM operand stack / heap for the JS `WebAssembly.instantiate` path diff --git a/lib/quickbeam/context_worker.zig b/lib/quickbeam/context_worker.zig index f3613f043..ce4004d5c 100644 --- a/lib/quickbeam/context_worker.zig +++ b/lib/quickbeam/context_worker.zig @@ -102,6 +102,12 @@ fn pool_drain_callback(state: *worker.WorkerState) void { } } +fn destroy_context_entry(entry: *ct.ContextEntry) void { + entry.state.deinit(); + entry.rd.sync_slots.deinit(gpa); + gpa.destroy(entry); +} + pub fn pool_worker_main(pd: *ct.PoolData) void { const rt = qjs.JS_NewRuntime() orelse return; defer qjs.JS_FreeRuntime(rt); @@ -121,10 +127,15 @@ pub fn pool_worker_main(pd: *ct.PoolData) void { var contexts = std.AutoHashMap(ct.ContextId, *ct.ContextEntry).init(gpa); defer { + // Remove externally visible RuntimeData pointers before freeing entries. + // Resolver NIFs hold this mutex through sync-slot publication. + pd.rd_map_mutex.lock(); + pd.rd_map.clearRetainingCapacity(); + pd.rd_map_mutex.unlock(); + var it = contexts.valueIterator(); while (it.next()) |entry| { - entry.*.state.deinit(); - gpa.destroy(entry.*); + destroy_context_entry(entry.*); } contexts.deinit(); } @@ -221,6 +232,7 @@ fn handle_create_context( .rd = &entry.rd, .pending_calls = std.AutoHashMap(u64, worker.PendingCall).init(gpa), .timers = std.AutoHashMap(u64, worker.TimerEntry).init(gpa), + .addon_exports = std.StringHashMap(qjs.JSValue).init(gpa), .start_time = std.time.nanoTimestamp(), .max_reductions = p.max_reductions, }; @@ -238,8 +250,7 @@ fn handle_create_context( pd.rd_map_mutex.lock(); pd.rd_map.put(gpa, p.context_id, &entry.rd) catch |err| { pd.rd_map_mutex.unlock(); - entry.state.deinit(); - gpa.destroy(entry); + destroy_context_entry(entry); const reason = @errorName(err); types.send_reply(p.caller_pid, p.ref_env, p.ref_term, false, null, null, reason); return; @@ -247,8 +258,10 @@ fn handle_create_context( pd.rd_map_mutex.unlock(); contexts.put(p.context_id, entry) catch { - entry.state.deinit(); - gpa.destroy(entry); + pd.rd_map_mutex.lock(); + _ = pd.rd_map.remove(p.context_id); + pd.rd_map_mutex.unlock(); + destroy_context_entry(entry); types.send_reply(p.caller_pid, p.ref_env, p.ref_term, false, null, null, "Out of memory"); return; }; @@ -268,9 +281,8 @@ fn handle_destroy_context( pd.rd_map_mutex.unlock(); if (contexts.fetchRemove(p.context_id)) |kv| { - var entry = kv.value; - entry.state.deinit(); - gpa.destroy(entry); + const entry = kv.value; + destroy_context_entry(entry); } } diff --git a/lib/quickbeam/js/package_resolver.ex b/lib/quickbeam/js/package_resolver.ex index c945bbcd2..832104bd5 100644 --- a/lib/quickbeam/js/package_resolver.ex +++ b/lib/quickbeam/js/package_resolver.ex @@ -94,8 +94,8 @@ defmodule QuickBEAM.JS.PackageResolver do end @spec relative_import_path(String.t(), String.t(), String.t()) :: String.t() - def relative_import_path(importer, target, project_root) do - importer_dir = importer |> Path.relative_to(project_root) |> Path.dirname() + def relative_import_path(importer_dir, target, project_root) do + importer_dir = Path.relative_to(importer_dir, project_root) target_label = Path.relative_to(target, project_root) target_label @@ -120,15 +120,22 @@ defmodule QuickBEAM.JS.PackageResolver do end end + @doc "Finds the nearest matching package, following Node's ancestor lookup rules." @spec package_root(String.t(), String.t()) :: {:ok, String.t()} | :error def package_root(package_name, from_dir) do - case find_node_modules(from_dir) do - nil -> - :error + from_dir + |> Path.expand() + |> find_package_root(package_name) + end - node_modules -> - package_dir = Path.join(node_modules, package_name) - if File.dir?(package_dir), do: {:ok, package_dir}, else: :error + defp find_package_root(dir, package_name) do + package_dir = Path.join([dir, "node_modules", package_name]) + parent = Path.dirname(dir) + + cond do + File.dir?(package_dir) -> {:ok, package_dir} + parent == dir -> :error + true -> find_package_root(parent, package_name) end end diff --git a/lib/quickbeam/js_error.ex b/lib/quickbeam/js_error.ex index 6e3533542..d4dc22808 100644 --- a/lib/quickbeam/js_error.ex +++ b/lib/quickbeam/js_error.ex @@ -1,10 +1,28 @@ defmodule QuickBEAM.JSError do - defexception [:message, :name, :stack] + @moduledoc """ + Represents an uncaught JavaScript exception at a QuickBEAM API boundary. + + Errors include stable JavaScript source locations and structured JavaScript + frames without exposing Elixir handler stack traces. + """ + + defexception [:message, :name, :stack, :filename, :line, :column, frames: []] + + @type frame :: %{ + function: String.t(), + filename: String.t() | nil, + line: pos_integer() | nil, + column: pos_integer() | nil + } @type t :: %__MODULE__{ message: String.t(), name: String.t(), - stack: String.t() | nil + stack: String.t() | nil, + filename: String.t() | nil, + line: pos_integer() | nil, + column: pos_integer() | nil, + frames: [frame()] } @impl true @@ -12,12 +30,16 @@ defmodule QuickBEAM.JSError do "#{name}: #{msg}" end - @doc false + @doc "Converts a JavaScript error value returned by the native runtime." def from_js_value(value) when is_map(value) do %__MODULE__{ - message: to_string(value[:message] || value["message"] || inspect(value)), - name: to_string(value[:name] || value["name"] || "Error"), - stack: get_stack(value) + message: error_text(value[:message] || value["message"] || inspect(value)), + name: error_text(value[:name] || value["name"] || "Error"), + stack: get_stack(value), + filename: value[:filename] || value["filename"], + line: value[:line] || value["line"], + column: value[:column] || value["column"], + frames: normalize_frames(value[:frames] || value["frames"]) } end @@ -29,10 +51,118 @@ defmodule QuickBEAM.JSError do %__MODULE__{message: inspect(value), name: "Error", stack: nil} end + @doc "Converts a VM-generated exception reason into a catchable JavaScript value." + def vm_exception_value(reason) + when is_tuple(reason) and + elem(reason, 0) in [ + :handler_exception, + :not_callable, + :range_error, + :reference_error, + :type_error, + :unknown_handler + ] do + {name, message} = vm_name_and_message(reason) + %{"name" => name, "message" => message} + end + + def vm_exception_value(reason), do: reason + + @doc "Builds an exception from an uncaught VM value and JavaScript stack frames." + @spec from_vm(term(), [frame()]) :: t() + def from_vm(%QuickBEAM.VM.Runtime.Thrown{value: value, frames: async_frames}, frames), + do: from_vm(value, async_frames ++ frames) + + def from_vm(reason, frames) do + {name, message} = vm_name_and_message(reason) + frames = normalize_frames(frames) + first = List.first(frames) + + %__MODULE__{ + name: name, + message: message, + filename: frame_value(first, :filename), + line: frame_value(first, :line), + column: frame_value(first, :column), + frames: frames, + stack: format_stack(name, message, frames) + } + end + + defp vm_name_and_message(%__MODULE__{} = error), do: {error.name, error.message} + + defp vm_name_and_message(%{} = value) when not is_struct(value) do + { + error_text(value[:name] || value["name"] || "Error"), + error_text(value[:message] || value["message"] || inspect(value)) + } + end + + defp vm_name_and_message({:type_error, reason}), do: {"TypeError", format_reason(reason)} + defp vm_name_and_message({:range_error, reason}), do: {"RangeError", format_reason(reason)} + + defp vm_name_and_message({:reference_error, name}) when is_binary(name), + do: {"ReferenceError", "#{name} is not defined"} + + defp vm_name_and_message({:reference_error, binding}), + do: + {"ReferenceError", + "Cannot access lexical binding #{inspect(binding)} before initialization"} + + defp vm_name_and_message({:not_callable, value}), + do: {"TypeError", "#{inspect(value)} is not a function"} + + defp vm_name_and_message({:unknown_handler, name}), + do: {"Error", "Unknown BEAM handler #{inspect(name)}"} + + defp vm_name_and_message({:handler_exception, exception, _stacktrace}), + do: {"Error", handler_exception_message(exception)} + + defp vm_name_and_message(reason), do: {"Error", format_reason(reason)} + + defp format_reason(reason) when is_binary(reason), do: reason + + defp format_reason(reason) when is_atom(reason), + do: reason |> Atom.to_string() |> String.replace("_", " ") + + defp format_reason(reason), do: inspect(reason) + + defp format_stack(name, message, []), do: "#{name}: #{message}" + + defp format_stack(name, message, frames) do + rendered = + Enum.map_join(frames, "\n", fn frame -> + function = frame_value(frame, :function) || "" + filename = frame_value(frame, :filename) || "" + line = frame_value(frame, :line) || 1 + column = frame_value(frame, :column) || 1 + " at #{function} (#{filename}:#{line}:#{column})" + end) + + "#{name}: #{message}\n#{rendered}" + end + + defp handler_exception_message(%{__exception__: true} = exception), + do: Exception.message(exception) + + defp handler_exception_message(reason), do: format_reason(reason) + + defp error_text(value) when is_binary(value), do: value + defp error_text(value) when is_atom(value) or is_number(value), do: to_string(value) + defp error_text(value), do: inspect(value) + + defp normalize_frames(frames) when is_list(frames), do: frames + defp normalize_frames(_frames), do: [] + + defp frame_value(frame, key) when is_map(frame), + do: Map.get(frame, key) || Map.get(frame, Atom.to_string(key)) + + defp frame_value(_frame, _key), do: nil + defp get_stack(value) do case value[:stack] || value["stack"] do nil -> nil - s -> to_string(s) + stack -> error_text(stack) end end end diff --git a/lib/quickbeam/native.ex b/lib/quickbeam/native.ex index 13b97597d..fa68db6b9 100644 --- a/lib/quickbeam/native.ex +++ b/lib/quickbeam/native.ex @@ -204,7 +204,7 @@ defmodule QuickBEAM.Native do pool_dom_text: 3, pool_dom_html: 2, disasm_bytecode: 1, - load_addon: 3, + load_addon: 4, wasm_compile: 1, wasm_start: 3, wasm_start_with_imports: 5, diff --git a/lib/quickbeam/quickbeam.zig b/lib/quickbeam/quickbeam.zig index b0bd813cd..16ab7f5c4 100644 --- a/lib/quickbeam/quickbeam.zig +++ b/lib/quickbeam/quickbeam.zig @@ -38,23 +38,47 @@ const pool_enqueue = ct.pool_enqueue; // ──────────────────── Resource ──────────────────── +fn shutdown_runtime(data: *RuntimeData) void { + data.lifecycle_mutex.lock(); + while (data.lifecycle == .stopping) { + data.lifecycle_cond.wait(&data.lifecycle_mutex); + } + if (data.lifecycle == .joined) { + data.lifecycle_mutex.unlock(); + return; + } + + data.lifecycle = .stopping; + data.shutting_down.store(true, .release); + + data.sync_slots_mutex.lock(); + var it = data.sync_slots.valueIterator(); + while (it.next()) |slot| { + slot.*.ok = false; + slot.*.result_json = "runtime shutting down"; + slot.*.done.set(); + } + data.sync_slots_mutex.unlock(); + + enqueue(data, .{ .stop = {} }); + const thread = data.thread; + data.thread = null; + data.lifecycle_mutex.unlock(); + + if (thread) |worker_thread| worker_thread.join(); + + data.lifecycle_mutex.lock(); + data.lifecycle = .joined; + data.lifecycle_cond.broadcast(); + data.lifecycle_mutex.unlock(); +} + pub const RuntimeResource = beam.Resource(*RuntimeData, @import("root"), .{ .Callbacks = struct { pub fn dtor(ptr: **RuntimeData) void { const data = ptr.*; - data.shutting_down.store(true, .release); - - data.sync_slots_mutex.lock(); - var it = data.sync_slots.valueIterator(); - while (it.next()) |slot| { - slot.*.ok = false; - slot.*.result_json = "runtime shutting down"; - slot.*.done.set(); - } - data.sync_slots_mutex.unlock(); - - enqueue(data, .{ .stop = {} }); - if (data.thread) |t_| t_.join(); + shutdown_runtime(data); + data.sync_slots.deinit(gpa); gpa.destroy(data); } }, @@ -104,8 +128,6 @@ pub fn start_runtime(owner_pid: beam.pid, opts: beam.term) !RuntimeResource { data.max_convert_nodes = @intCast(v); } - const res = try RuntimeResource.create(data, .{}); - const min_thread_stack = 2 * 1024 * 1024; const thread_stack = @max(data.max_stack_size + min_thread_stack, min_thread_stack); data.thread = std.Thread.spawn(.{ .stack_size = thread_stack }, worker.worker_main, .{ data, owner_pid }) catch { @@ -113,7 +135,12 @@ pub fn start_runtime(owner_pid: beam.pid, opts: beam.term) !RuntimeResource { return error.ThreadSpawn; }; - return res; + return RuntimeResource.create(data, .{}) catch |err| { + shutdown_runtime(data); + data.sync_slots.deinit(gpa); + gpa.destroy(data); + return err; + }; } pub fn eval(resource: RuntimeResource, code: []const u8, timeout_ms: u64, filename: []const u8) beam.term { @@ -251,7 +278,7 @@ pub fn reset_runtime(resource: RuntimeResource) beam.term { return beam.term{ .v = e.enif_make_copy(env, ref_term) }; } -pub fn load_addon(resource: RuntimeResource, path: []const u8, global_name: []const u8) beam.term { +pub fn load_addon(resource: RuntimeResource, path: []const u8, global_name: []const u8, allow_reinitialization: bool) beam.term { const data = resource.unpack(); const env = beam.context.env orelse return beam.make(.{ .@"error", "no env" }, .{}); @@ -271,6 +298,7 @@ pub fn load_addon(resource: RuntimeResource, path: []const u8, global_name: []co enqueue(data, .{ .load_addon = .{ .path = path_copy, .global_name = name_copy, + .allow_reinitialization = allow_reinitialization, .caller_pid = caller_pid, .ref_env = ref_env, .ref_term = ref_term, @@ -280,24 +308,7 @@ pub fn load_addon(resource: RuntimeResource, path: []const u8, global_name: []co } pub fn stop_runtime(resource: RuntimeResource) beam.term { - const data = resource.unpack(); - - data.shutting_down.store(true, .release); - - data.sync_slots_mutex.lock(); - var it = data.sync_slots.valueIterator(); - while (it.next()) |slot| { - slot.*.ok = false; - slot.*.result_json = "runtime shutting down"; - slot.*.done.set(); - } - data.sync_slots_mutex.unlock(); - - enqueue(data, .{ .stop = {} }); - if (data.thread) |th| { - th.join(); - data.thread = null; - } + shutdown_runtime(resource.unpack()); return beam.make(.ok, .{}); } @@ -305,15 +316,14 @@ pub fn resolve_call(resource: RuntimeResource, call_id: u64, value_json: []const const data = resource.unpack(); data.sync_slots_mutex.lock(); - const slot = data.sync_slots.get(call_id); - data.sync_slots_mutex.unlock(); - - if (slot) |s| { - s.result_json = gpa.dupe(u8, value_json) catch ""; - s.ok = true; - s.done.set(); + if (data.sync_slots.get(call_id)) |slot| { + slot.result_json = gpa.dupe(u8, value_json) catch ""; + slot.ok = true; + slot.done.set(); + data.sync_slots_mutex.unlock(); return beam.make(.ok, .{}); } + data.sync_slots_mutex.unlock(); const json_copy = gpa.dupe(u8, value_json) catch return beam.make(.ok, .{}); enqueue(data, .{ .resolve_call = .{ .id = call_id, .json = json_copy } }); @@ -324,15 +334,14 @@ pub fn reject_call(resource: RuntimeResource, call_id: u64, reason: []const u8) const data = resource.unpack(); data.sync_slots_mutex.lock(); - const slot = data.sync_slots.get(call_id); - data.sync_slots_mutex.unlock(); - - if (slot) |s| { - s.result_json = gpa.dupe(u8, reason) catch ""; - s.ok = false; - s.done.set(); + if (data.sync_slots.get(call_id)) |slot| { + slot.result_json = gpa.dupe(u8, reason) catch ""; + slot.ok = false; + slot.done.set(); + data.sync_slots_mutex.unlock(); return beam.make(.ok, .{}); } + data.sync_slots_mutex.unlock(); const reason_copy = gpa.dupe(u8, reason) catch return beam.make(.ok, .{}); enqueue(data, .{ .reject_call = .{ .id = call_id, .json = reason_copy } }); @@ -343,17 +352,16 @@ pub fn resolve_call_term(resource: RuntimeResource, call_id: u64, value: beam.te const data = resource.unpack(); data.sync_slots_mutex.lock(); - const slot = data.sync_slots.get(call_id); - data.sync_slots_mutex.unlock(); - - if (slot) |s| { + if (data.sync_slots.get(call_id)) |slot| { const term_env = beam.alloc_env(); - s.result_env = term_env; - s.result_term = e.enif_make_copy(term_env, value.v); - s.ok = true; - s.done.set(); + slot.result_env = term_env; + slot.result_term = e.enif_make_copy(term_env, value.v); + slot.ok = true; + slot.done.set(); + data.sync_slots_mutex.unlock(); return beam.make(.ok, .{}); } + data.sync_slots_mutex.unlock(); const msg_env = beam.alloc_env(); const copied = e.enif_make_copy(msg_env, value.v); @@ -365,17 +373,16 @@ pub fn reject_call_term(resource: RuntimeResource, call_id: u64, reason: []const const data = resource.unpack(); data.sync_slots_mutex.lock(); - const slot = data.sync_slots.get(call_id); - data.sync_slots_mutex.unlock(); - - if (slot) |s| { + if (data.sync_slots.get(call_id)) |slot| { const term_env = beam.alloc_env(); - s.result_env = term_env; - s.result_term = beam.make(reason, .{ .env = term_env }).v; - s.ok = false; - s.done.set(); + slot.result_env = term_env; + slot.result_term = beam.make(reason, .{ .env = term_env }).v; + slot.ok = false; + slot.done.set(); + data.sync_slots_mutex.unlock(); return beam.make(.ok, .{}); } + data.sync_slots_mutex.unlock(); const reason_copy = gpa.dupe(u8, reason) catch return beam.make(.ok, .{}); enqueue(data, .{ .reject_call = .{ .id = call_id, .json = reason_copy } }); @@ -555,13 +562,37 @@ pub fn get_global(resource: RuntimeResource, name: []const u8) beam.term { // ──────────────────── Context Pool ──────────────────── +fn shutdown_pool(data: *ct.PoolData) void { + data.lifecycle_mutex.lock(); + while (data.lifecycle == .stopping) { + data.lifecycle_cond.wait(&data.lifecycle_mutex); + } + if (data.lifecycle == .joined) { + data.lifecycle_mutex.unlock(); + return; + } + + data.lifecycle = .stopping; + data.shutting_down.store(true, .release); + pool_enqueue(data, .{ .stop = {} }); + const thread = data.thread; + data.thread = null; + data.lifecycle_mutex.unlock(); + + if (thread) |worker_thread| worker_thread.join(); + + data.lifecycle_mutex.lock(); + data.lifecycle = .joined; + data.lifecycle_cond.broadcast(); + data.lifecycle_mutex.unlock(); +} + pub const PoolResource = beam.Resource(*ct.PoolData, @import("root"), .{ .Callbacks = struct { pub fn dtor(ptr: **ct.PoolData) void { const data = ptr.*; - data.shutting_down.store(true, .release); - pool_enqueue(data, .{ .stop = {} }); - if (data.thread) |t_| t_.join(); + shutdown_pool(data); + data.rd_map.deinit(gpa); gpa.destroy(data); } }, @@ -604,8 +635,6 @@ pub fn pool_start(opts: beam.term) !PoolResource { data.max_convert_nodes = @intCast(v); } - const res = try PoolResource.create(data, .{}); - const min_thread_stack = 2 * 1024 * 1024; const thread_stack = @max(data.max_stack_size + min_thread_stack, min_thread_stack); data.thread = std.Thread.spawn(.{ .stack_size = thread_stack }, context_worker.pool_worker_main, .{data}) catch { @@ -613,17 +642,16 @@ pub fn pool_start(opts: beam.term) !PoolResource { return error.ThreadSpawn; }; - return res; + return PoolResource.create(data, .{}) catch |err| { + shutdown_pool(data); + data.rd_map.deinit(gpa); + gpa.destroy(data); + return err; + }; } pub fn pool_stop(resource: PoolResource) beam.term { - const data = resource.unpack(); - data.shutting_down.store(true, .release); - pool_enqueue(data, .{ .stop = {} }); - if (data.thread) |th| { - th.join(); - data.thread = null; - } + shutdown_pool(resource.unpack()); return beam.make(.ok, .{}); } @@ -838,31 +866,25 @@ fn pool_dom_op(resource: PoolResource, context_id: u64, op: types.DomOp, selecto return beam.term{ .v = e.enif_make_copy(env, ref_term) }; } -fn pool_lookup_sync_slot(data: *ct.PoolData, context_id: u64, call_id: u64) ?*types.SyncCallSlot { - data.rd_map_mutex.lock(); - const rd = data.rd_map.get(context_id); - data.rd_map_mutex.unlock(); - - if (rd) |r| { - r.sync_slots_mutex.lock(); - const slot = r.sync_slots.get(call_id); - r.sync_slots_mutex.unlock(); - return slot; - } - return null; -} - pub fn pool_resolve_call_term(resource: PoolResource, context_id: u64, call_id: u64, value: beam.term) beam.term { const data = resource.unpack(); - if (pool_lookup_sync_slot(data, context_id, call_id)) |s| { - const term_env = beam.alloc_env(); - s.result_env = term_env; - s.result_term = e.enif_make_copy(term_env, value.v); - s.ok = true; - s.done.set(); - return beam.make(.ok, .{}); + data.rd_map_mutex.lock(); + if (data.rd_map.get(context_id)) |rd| { + rd.sync_slots_mutex.lock(); + if (rd.sync_slots.get(call_id)) |slot| { + const term_env = beam.alloc_env(); + slot.result_env = term_env; + slot.result_term = e.enif_make_copy(term_env, value.v); + slot.ok = true; + slot.done.set(); + rd.sync_slots_mutex.unlock(); + data.rd_map_mutex.unlock(); + return beam.make(.ok, .{}); + } + rd.sync_slots_mutex.unlock(); } + data.rd_map_mutex.unlock(); const msg_env = beam.alloc_env(); const copied = e.enif_make_copy(msg_env, value.v); @@ -879,14 +901,22 @@ pub fn pool_resolve_call_term(resource: PoolResource, context_id: u64, call_id: pub fn pool_reject_call_term(resource: PoolResource, context_id: u64, call_id: u64, reason: []const u8) beam.term { const data = resource.unpack(); - if (pool_lookup_sync_slot(data, context_id, call_id)) |s| { - const term_env = beam.alloc_env(); - s.result_env = term_env; - s.result_term = beam.make(reason, .{ .env = term_env }).v; - s.ok = false; - s.done.set(); - return beam.make(.ok, .{}); + data.rd_map_mutex.lock(); + if (data.rd_map.get(context_id)) |rd| { + rd.sync_slots_mutex.lock(); + if (rd.sync_slots.get(call_id)) |slot| { + const term_env = beam.alloc_env(); + slot.result_env = term_env; + slot.result_term = beam.make(reason, .{ .env = term_env }).v; + slot.ok = false; + slot.done.set(); + rd.sync_slots_mutex.unlock(); + data.rd_map_mutex.unlock(); + return beam.make(.ok, .{}); + } + rd.sync_slots_mutex.unlock(); } + data.rd_map_mutex.unlock(); const reason_copy = gpa.dupe(u8, reason) catch return beam.make(.ok, .{}); pool_enqueue(data, .{ .ctx_reject_call = .{ diff --git a/lib/quickbeam/runtime.ex b/lib/quickbeam/runtime.ex index 294c8838b..229176780 100644 --- a/lib/quickbeam/runtime.ex +++ b/lib/quickbeam/runtime.ex @@ -95,11 +95,43 @@ defmodule QuickBEAM.Runtime do end @spec load_addon(GenServer.server(), String.t(), keyword()) :: {:ok, term()} | {:error, term()} - def load_addon(server, path, opts \\ []) when is_binary(path) do - global_name = Keyword.get(opts, :as, "") - GenServer.call(server, {:load_addon, path, global_name}, :infinity) + def load_addon(server, path, opts \\ []) + + def load_addon(server, path, opts) when is_binary(path) and is_list(opts) do + with true <- Keyword.keyword?(opts), + :ok <- validate_addon_options(opts), + global_name = Keyword.get(opts, :as, ""), + :ok <- validate_addon_global_name(global_name), + allow_reinitialization = Keyword.get(opts, :allow_reinitialization, false), + :ok <- validate_addon_reinitialization(allow_reinitialization) do + GenServer.call( + server, + {:load_addon, path, global_name, allow_reinitialization}, + :infinity + ) + else + false -> {:error, {:invalid_option, :options, opts}} + {:error, _reason} = error -> error + end + end + + def load_addon(_server, path, opts), do: {:error, {:invalid_addon_arguments, path, opts}} + + defp validate_addon_options(opts) do + case Keyword.keys(opts) -- [:allow_reinitialization, :as] do + [] -> :ok + [key | _rest] -> {:error, {:unknown_option, key}} + end end + defp validate_addon_global_name(name) when is_binary(name), do: :ok + defp validate_addon_global_name(name), do: {:error, {:invalid_option, :as, name}} + + defp validate_addon_reinitialization(value) when is_boolean(value), do: :ok + + defp validate_addon_reinitialization(value), + do: {:error, {:invalid_option, :allow_reinitialization, value}} + @spec reset(GenServer.server()) :: :ok | {:error, String.t()} def reset(server) do GenServer.call(server, :reset, :infinity) @@ -513,8 +545,15 @@ defmodule QuickBEAM.Runtime do {:noreply, put_pending(state, ref, from, transform)} end - def handle_call({:load_addon, path, global_name}, from, state) do - ref = QuickBEAM.Native.load_addon(state.resource, path, global_name) + def handle_call({:load_addon, path, global_name, allow_reinitialization}, from, state) do + ref = + QuickBEAM.Native.load_addon( + state.resource, + path, + global_name, + allow_reinitialization + ) + {:noreply, put_pending(state, ref, from)} end diff --git a/lib/quickbeam/types.zig b/lib/quickbeam/types.zig index 3aad9ab43..a48253d14 100644 --- a/lib/quickbeam/types.zig +++ b/lib/quickbeam/types.zig @@ -18,6 +18,12 @@ pub fn reserveClassID(rt: *qjs.JSRuntime, class_id: *qjs.JSClassID) void { if (reserved != class_id.*) @panic("QuickJS class ID allocation order mismatch"); } +pub const LifecycleState = enum { + running, + stopping, + joined, +}; + pub const SyncCallSlot = struct { result_json: []const u8 = "", result_env: ?*e.ErlNifEnv = null, @@ -33,6 +39,9 @@ pub const RuntimeData = struct { queue_tail: ?*MessageNode, stopped: bool, thread: ?std.Thread, + lifecycle_mutex: std.Thread.Mutex = .{}, + lifecycle_cond: std.Thread.Condition = .{}, + lifecycle: LifecycleState = .running, memory_limit: usize = 256 * 1024 * 1024, max_stack_size: usize = 8 * 1024 * 1024, // WASM operand stack / heap for the JS `WebAssembly.instantiate` path @@ -176,6 +185,7 @@ pub const NapiTsfnCallPayload = struct { pub const AsyncAddonPayload = struct { path: [:0]const u8, global_name: ?[:0]const u8 = null, + allow_reinitialization: bool = false, caller_pid: beam.pid, ref_env: ?*e.ErlNifEnv, ref_term: e.ErlNifTerm, diff --git a/lib/quickbeam/vm.ex b/lib/quickbeam/vm.ex new file mode 100644 index 000000000..707b8f098 --- /dev/null +++ b/lib/quickbeam/vm.ex @@ -0,0 +1,363 @@ +defmodule QuickBEAM.VM do + @moduledoc """ + Compiles and executes verified QuickJS bytecode with an isolated BEAM interpreter. + + Programs are immutable and version-locked. Each evaluation owns its frames, + heap, Promise state, host operations, and resource limits. The optional BEAM + compiler is an internal, release-quarantined subsystem and is not selected + through this public facade. + + ## Execution model and limitations + + `eval/2` and `call/4` create fresh mutable JavaScript state for every request. + `call/4` runs program initialization and then invokes its named global in that + same fresh state; unlike `QuickBEAM.call/4`, mutations do not persist across + calls. Programs and bytecode are locked to the exact vendored QuickJS ABI and + must be recompiled after an incompatible upgrade. + + The interpreter implements bounded, explicitly tested bytecode and builtin + profiles rather than every native QuickJS, browser, Node.js, DOM, WASM, or + addon feature. Unsupported behavior fails without native fallback. + `:memory_limit` governs deterministic logical VM allocation, while endpoint + BEAM process memory is reported separately by measurement APIs. An + evaluation may have at most 64 asynchronous BEAM handler operations + outstanding at once. Pinned storage is fixed-capacity, explicitly retired, + and never implicitly evicted. + """ + + alias QuickBEAM.VM.ABI + alias QuickBEAM.VM.Bytecode.Decoder + alias QuickBEAM.VM.Bytecode.Verifier + alias QuickBEAM.VM.Measurement + alias QuickBEAM.VM.Options + alias QuickBEAM.VM.Program + alias QuickBEAM.VM.Program.Function + alias QuickBEAM.VM.Program.Identity + alias QuickBEAM.VM.Program.Pinned + alias QuickBEAM.VM.Program.Store + alias QuickBEAM.VM.Runtime.Engine + + @type program :: Program.t() + @type pinned_program :: Pinned.t() + @type verifier_option :: + {:max_atoms, pos_integer()} + | {:max_constants_per_function, pos_integer()} + | {:max_function_depth, pos_integer()} + | {:max_functions, pos_integer()} + | {:max_instructions, pos_integer()} + | {:max_stack_size, pos_integer()} + @type compile_option :: + {:filename, String.t()} | {:max_bytecode_bytes, pos_integer()} | verifier_option() + @type decode_option :: {:max_bytecode_bytes, pos_integer()} | verifier_option() + @type evaluation_option :: + {:vars, map()} + | {:handlers, %{optional(String.t()) => ([term()] -> term())}} + | {:profile, :core | :ssr} + | {:timeout, pos_integer() | :infinity} + | {:max_steps, pos_integer()} + | {:max_stack_depth, pos_integer()} + | {:memory_limit, pos_integer() | :infinity} + | {:isolation, :process | :caller} + @type error_reason :: + :invalid_program + | :invalid_source + | :invalid_bytecode + | :invalid_function_name + | :invalid_arguments + | :invalid_pinned_program + | :pinned_program_capacity + | :pinned_program_unavailable + | :program_too_large + | :residency_budget + | {:invalid_options, term()} + | {:unknown_option, atom()} + | {:invalid_option, atom(), term()} + | {:limit_exceeded, atom(), term()} + | {:unsupported, term()} + | {:evaluation_process_exit, term()} + | tuple() + | atom() + @type result(value) :: {:ok, value} | {:error, QuickBEAM.JSError.t() | error_reason()} + + @max_bytecode_bytes 16 * 1024 * 1024 + @verifier_options [ + :max_atoms, + :max_constants_per_function, + :max_function_depth, + :max_functions, + :max_instructions, + :max_stack_size + ] + @decode_options [:max_bytecode_bytes | @verifier_options] + @compile_options [:filename | @decode_options] + @evaluation_options [ + :handlers, + :isolation, + :max_stack_depth, + :max_steps, + :memory_limit, + :profile, + :timeout, + :vars + ] + + @doc """ + Compiles JavaScript with the vendored QuickJS compiler and returns a verified + immutable program. + + Compilation uses a short-lived bare native runtime. Supported options are + `:filename`, `:max_bytecode_bytes`, and the bounded verifier limit options. + Use `decode/2` when bytecode has already been compiled by this exact QuickJS + build. + """ + @spec compile(String.t(), [compile_option()]) :: result(program()) + def compile(source, opts \\ []) + + def compile(source, opts) when is_binary(source) and is_list(opts) do + with :ok <- Options.validate(opts, @compile_options), + {filename, decode_options} = Keyword.pop(opts, :filename), + :ok <- validate_filename(filename), + {:ok, runtime} <- QuickBEAM.start(apis: false) do + try do + with {:ok, bytecode} <- QuickBEAM.compile(runtime, source), + {:ok, program} <- decode(bytecode, decode_options) do + program = + program + |> maybe_put_filename(filename) + |> Map.put(:source_digest, :crypto.hash(:sha256, source)) + |> Identity.put() + + {:ok, program} + end + after + QuickBEAM.stop(runtime) + end + end + end + + def compile(source, _opts) when not is_binary(source), do: {:error, :invalid_source} + def compile(_source, opts), do: {:error, {:invalid_options, opts}} + + @doc """ + Decodes and verifies bytecode from this exact QuickJS build. + + The `:max_bytecode_bytes` option and bounded verifier limits can only reduce + the built-in maximums; unknown options fail explicitly. + """ + @spec decode(binary(), [decode_option()]) :: result(program()) + def decode(bytecode, opts \\ []) + + def decode(bytecode, opts) when is_binary(bytecode) and is_list(opts) do + with :ok <- Options.validate(opts, @decode_options), + {max_bytecode_bytes, verifier_options} = + Keyword.pop(opts, :max_bytecode_bytes, @max_bytecode_bytes), + :ok <- validate_max_bytecode_bytes(max_bytecode_bytes), + :ok <- within_bytecode_limit(bytecode, max_bytecode_bytes), + {:ok, program} <- Decoder.decode(bytecode), + :ok <- Verifier.verify(program, verifier_options) do + {:ok, Identity.put(program)} + end + end + + def decode(bytecode, _opts) when not is_binary(bytecode), do: {:error, :invalid_bytecode} + def decode(_bytecode, opts), do: {:error, {:invalid_options, opts}} + + @doc """ + Pins a verified program in bounded immutable storage. + + The default store has eight fixed slots. Serialized bytecode is limited to + 2 MiB, decoded external-term residency to 32 MiB per program, and total + residency to 128 MiB. Concurrent pins of the same program identity are + idempotent and return the same lightweight handle. The application-supervised + store restores valid slots after its own restart; call `unpin/1` explicitly + when the lifecycle owner no longer needs the program. + """ + @spec pin(Program.t()) :: result(Pinned.t()) + def pin(%Program{} = program) do + program = Identity.put(program) + + with :ok <- Verifier.verify(program) do + case Store.pin(program) do + {:ok, pinned} -> {:ok, pinned} + {:error, reason} -> {:error, reason} + :retiring -> {:error, :pinned_program_unavailable} + :unavailable -> {:error, :pinned_program_capacity} + end + end + end + + def pin(_program), do: {:error, :invalid_program} + + @doc """ + Evaluates a verified program with the isolated BEAM interpreter. + + Supported options are `:vars`, asynchronous `:handlers`, builtin `:profile`, + `:timeout`, `:max_steps`, `:max_stack_depth`, `:memory_limit`, and + `:isolation`. The default `isolation: :process` provides timeout, process-heap, + and failure containment. `isolation: :caller` is only for trusted diagnostics. + """ + @spec eval(Program.t() | Pinned.t(), [evaluation_option()]) :: result(term()) + def eval(program, opts \\ []) + + def eval(program, opts) when is_list(opts) do + with :ok <- Options.validate(opts, @evaluation_options) do + Engine.eval(program, Keyword.put(opts, :engine, :interpreter)) + end + end + + def eval(_program, opts), do: {:error, {:invalid_options, opts}} + + @doc """ + Initializes a fresh isolated heap and calls a named global JavaScript function. + + Program initialization runs before every call, so globals mutated by one call + are not visible to the next. Arguments and Promise results use the same value + conversion, asynchronous handlers, isolation, and resource limits as `eval/2`. + Missing globals produce `ReferenceError`; non-callable globals produce + `TypeError`. + """ + @spec call(Program.t() | Pinned.t(), String.t(), [term()], [evaluation_option()]) :: + result(term()) + def call(program, name, arguments \\ [], opts \\ []) + + def call(program, name, arguments, opts) + when is_binary(name) and is_list(arguments) and is_list(opts) do + with :ok <- Options.validate(opts, @evaluation_options) do + Engine.call(program, name, arguments, Keyword.put(opts, :engine, :interpreter)) + end + end + + def call(_program, name, _arguments, _opts) when not is_binary(name), + do: {:error, :invalid_function_name} + + def call(_program, _name, arguments, _opts) when not is_list(arguments), + do: {:error, :invalid_arguments} + + def call(_program, _name, _arguments, opts), do: {:error, {:invalid_options, opts}} + + @doc """ + Evaluates with the same isolation and limits as `eval/2` and returns resource + observations in a `QuickBEAM.VM.Measurement`. + + Evaluation failures are stored in `measurement.result`. Invalid programs or + options return directly as `{:error, reason}` because no evaluation started. + """ + @spec measure(Program.t() | Pinned.t(), [evaluation_option()]) :: result(Measurement.t()) + def measure(program, opts \\ []) + + def measure(program, opts) when is_list(opts) do + with :ok <- Options.validate(opts, @evaluation_options), + {:ok, measurement} <- + Engine.measure(program, Keyword.put(opts, :engine, :interpreter)) do + {:ok, public_measurement(measurement)} + end + end + + def measure(_program, opts), do: {:error, {:invalid_options, opts}} + + @doc """ + Calls a named global with the same fresh initialization and limits as `call/4` + and returns resource observations in a `QuickBEAM.VM.Measurement`. + + Call failures are stored in `measurement.result`. Invalid names, arguments, + programs, or options return directly because no evaluation started. + """ + @spec measure_call( + Program.t() | Pinned.t(), + String.t(), + [term()], + [evaluation_option()] + ) :: result(Measurement.t()) + def measure_call(program, name, arguments \\ [], opts \\ []) + + def measure_call(program, name, arguments, opts) + when is_binary(name) and is_list(arguments) and is_list(opts) do + with :ok <- Options.validate(opts, @evaluation_options), + {:ok, measurement} <- + Engine.measure_call( + program, + name, + arguments, + Keyword.put(opts, :engine, :interpreter) + ) do + {:ok, public_measurement(measurement)} + end + end + + def measure_call(_program, name, _arguments, _opts) when not is_binary(name), + do: {:error, :invalid_function_name} + + def measure_call(_program, _name, arguments, _opts) when not is_list(arguments), + do: {:error, :invalid_arguments} + + def measure_call(_program, _name, _arguments, opts), + do: {:error, {:invalid_options, opts}} + + @doc """ + Unpins a handle after its current evaluations finish. + + Returns `{:error, :pinned_program_unavailable}` when the handle is stale. + Because pins are idempotent by program identity rather than ownership-counted, + one lifecycle owner should coordinate pinning and unpinning. + """ + @spec unpin(Pinned.t()) :: + :ok | {:error, :pinned_program_unavailable | :invalid_pinned_program} + def unpin(%Pinned{} = pinned) do + case Store.unpin(pinned) do + :ok -> :ok + :not_pinned -> {:error, :pinned_program_unavailable} + end + end + + def unpin(_pinned), do: {:error, :invalid_pinned_program} + + @doc "Returns the exact vendored QuickJS bytecode ABI fingerprint." + @spec fingerprint() :: String.t() + defdelegate fingerprint(), to: ABI + + @doc "Returns the vendored QuickJS bytecode version." + @spec bytecode_version() :: non_neg_integer() + defdelegate bytecode_version(), to: ABI + + defp public_measurement(measurement) do + %Measurement{ + result: measurement.result, + wall_time_us: measurement.wall_time_us, + steps: measurement.steps, + logical_memory_bytes: measurement.logical_memory_bytes, + process_memory_bytes: measurement.process_memory_bytes, + reductions: measurement.reductions + } + end + + defp validate_max_bytecode_bytes(limit) + when is_integer(limit) and limit > 0 and limit <= @max_bytecode_bytes, + do: :ok + + defp validate_max_bytecode_bytes(limit), + do: {:error, {:invalid_option, :max_bytecode_bytes, limit}} + + defp within_bytecode_limit(bytecode, limit) when byte_size(bytecode) <= limit, do: :ok + + defp within_bytecode_limit(bytecode, _limit), + do: {:error, {:limit_exceeded, :bytecode_bytes, byte_size(bytecode)}} + + defp validate_filename(nil), do: :ok + defp validate_filename(filename) when is_binary(filename), do: :ok + defp validate_filename(filename), do: {:error, {:invalid_option, :filename, filename}} + + defp maybe_put_filename(program, nil), do: program + + defp maybe_put_filename(program, filename) when is_binary(filename), + do: update_filename(program, filename) + + defp update_filename(%Function{} = function, filename) do + constants = Enum.map(function.constants, &update_filename(&1, filename)) + %{function | filename: filename, constants: constants} + end + + defp update_filename(%{root: root} = program, filename), + do: %{program | root: update_filename(root, filename)} + + defp update_filename(value, _filename), do: value +end diff --git a/lib/quickbeam/vm/abi.ex b/lib/quickbeam/vm/abi.ex new file mode 100644 index 000000000..8c0a92f96 --- /dev/null +++ b/lib/quickbeam/vm/abi.ex @@ -0,0 +1,53 @@ +defmodule QuickBEAM.VM.ABI do + @moduledoc """ + Build fingerprint and generated metadata for the vendored QuickJS bytecode ABI. + + QuickJS bytecode is private to one engine build. These values are generated + from the same vendored C sources used by the native library so header changes + cannot silently leave a hand-maintained decoder table behind. + """ + + alias QuickBEAM.VM.ABI.Generator + + @decoder_format_version 1 + @c_src_dir Application.app_dir(:quickbeam, "priv/c_src") + @quickjs_path Path.join(@c_src_dir, "quickjs.c") + @opcode_path Path.join(@c_src_dir, "quickjs-opcode.h") + @atom_path Path.join(@c_src_dir, "quickjs-atom.h") + + @external_resource @quickjs_path + @external_resource @opcode_path + @external_resource @atom_path + + @quickjs_source File.read!(@quickjs_path) + @opcode_source File.read!(@opcode_path) + @atom_source File.read!(@atom_path) + + @bytecode_version Generator.version!(@quickjs_source) + @tags Generator.tags!(@quickjs_source) + @opcodes Generator.opcodes!(@opcode_source) + @atoms Generator.atoms!(@atom_source) + @fingerprint Generator.fingerprint( + @bytecode_version, + @decoder_format_version, + [@quickjs_source, @opcode_source, @atom_source] + ) + + @doc "Returns QuickBEAM's decoded-program format version." + def decoder_format_version, do: @decoder_format_version + + @doc "Returns the bytecode version from the vendored QuickJS source." + def bytecode_version, do: @bytecode_version + + @doc "Returns a fingerprint for the exact vendored QuickJS bytecode ABI." + def fingerprint, do: @fingerprint + + @doc "Returns serialized-value tags generated from the vendored QuickJS source." + def tags, do: @tags + + @doc "Returns opcode metadata generated from the vendored QuickJS header." + def opcodes, do: @opcodes + + @doc "Returns predefined atom metadata generated from the vendored QuickJS header." + def predefined_atoms, do: @atoms +end diff --git a/lib/quickbeam/vm/abi/generator.ex b/lib/quickbeam/vm/abi/generator.ex new file mode 100644 index 000000000..f84f15f4a --- /dev/null +++ b/lib/quickbeam/vm/abi/generator.ex @@ -0,0 +1,144 @@ +defmodule QuickBEAM.VM.ABI.Generator do + @moduledoc """ + Extracts bytecode ABI metadata from the vendored QuickJS C sources. + + The generated version, tags, opcodes, atoms, and fingerprint keep decoding + coupled to the exact engine build that produced serialized bytecode. + """ + + alias QuickBEAM.VM.ABI.Source + alias QuickBEAM.VM.ABI.Vocabulary + + require Vocabulary + Vocabulary.ensure_loaded() + + @doc "Returns the vendored QuickJS bytecode version." + @spec version!(String.t()) :: pos_integer() + def version!(source) do + source + |> Source.define!("BC_VERSION") + |> unsigned_integer!("BC_VERSION") + end + + @doc "Returns the bytecode tag table from the vendored QuickJS source." + @spec tags!(String.t()) :: %{atom() => non_neg_integer()} + def tags!(source) do + source + |> Source.enum_entries!("BCTagEnum") + |> Enum.reduce({%{}, 0}, fn entry, {tags, previous} -> + {name, value} = tag!(entry, previous) + {Map.put(tags, name, value), value} + end) + |> elem(0) + end + + @doc "Returns the opcode table from the vendored QuickJS opcode header." + @spec opcodes!(String.t()) :: %{non_neg_integer() => tuple()} + def opcodes!(header) do + rows = + header + |> Source.macro_arguments("DEF") + |> Enum.with_index() + |> Map.new(fn {arguments, opcode} -> {opcode, opcode!(arguments)} end) + + if map_size(rows) == 0 or map_size(rows) > 256 do + raise ArgumentError, "invalid opcode table size: #{map_size(rows)}" + end + + rows + end + + @doc "Returns the predefined atom table from the vendored QuickJS atom header." + @spec atoms!(String.t()) :: %{pos_integer() => String.t()} + def atoms!(header) do + header + |> Source.macro_arguments("DEF") + |> Enum.with_index(1) + |> Map.new(fn {arguments, index} -> {index, atom_value!(arguments)} end) + end + + @doc "Returns a digest binding generated metadata to its exact source inputs." + @spec fingerprint(non_neg_integer(), non_neg_integer(), [binary()]) :: String.t() + def fingerprint(version, decoder_version, sources) do + source_digests = Enum.map(sources, &:crypto.hash(:sha256, &1)) + payload = :erlang.term_to_binary({decoder_version, version, source_digests}) + payload |> then(&:crypto.hash(:sha256, &1)) |> Base.encode16(case: :lower) + end + + defp tag!(entry, previous) do + case String.split(entry, "=", parts: 2) do + [name] -> {tag_name!(name), previous + 1} + [name, value] -> {tag_name!(name), unsigned_integer!(String.trim(value), entry)} + end + end + + defp tag_name!(name) do + case String.trim(name) do + "BC_TAG_" <> suffix -> + suffix |> identifier!(:tag) |> String.downcase() |> Vocabulary.fetch!(:tag) + + _other -> + raise ArgumentError, "unsupported bytecode tag definition: #{inspect(name)}" + end + end + + defp opcode!([name, size, pops, pushes, format]) do + { + name |> identifier!(:opcode) |> Vocabulary.fetch!(:opcode), + unsigned_integer!(size, name), + unsigned_integer!(pops, name), + unsigned_integer!(pushes, name), + format |> identifier!(:opcode_format) |> Vocabulary.fetch!(:opcode_format) + } + end + + defp opcode!(arguments), + do: raise(ArgumentError, "unsupported opcode definition: #{inspect(arguments)}") + + defp atom_value!([name, value]) do + identifier!(name, :atom) + quoted_string!(value, name) + end + + defp atom_value!(arguments), + do: raise(ArgumentError, "unsupported atom definition: #{inspect(arguments)}") + + defp quoted_string!(value, context) do + value = String.trim(value) + + case value do + <> when byte_size(rest) > 0 -> + if String.ends_with?(rest, "\"") do + binary_part(rest, 0, byte_size(rest) - 1) + else + raise ArgumentError, "unterminated string for #{context}" + end + + _other -> + raise ArgumentError, "expected string for #{context}, got: #{inspect(value)}" + end + end + + defp unsigned_integer!(value, context) do + case Integer.parse(String.trim(value)) do + {integer, ""} when integer >= 0 -> integer + _other -> raise ArgumentError, "expected unsigned integer for #{context}: #{inspect(value)}" + end + end + + defp identifier!(value, context) do + value = String.trim(value) + + if value != "" and Enum.all?(String.to_charlist(value), &identifier_character?/1) do + value + else + raise ArgumentError, "invalid #{context} identifier: #{inspect(value)}" + end + end + + defp identifier_character?(character) when character in ?a..?z, do: true + defp identifier_character?(character) when character in ?A..?Z, do: true + defp identifier_character?(character) when character in ?0..?9, do: true + defp identifier_character?(?_), do: true + defp identifier_character?(_character), do: false +end diff --git a/lib/quickbeam/vm/abi/source.ex b/lib/quickbeam/vm/abi/source.ex new file mode 100644 index 000000000..145ae4d1d --- /dev/null +++ b/lib/quickbeam/vm/abi/source.ex @@ -0,0 +1,114 @@ +defmodule QuickBEAM.VM.ABI.Source do + @moduledoc """ + Parses the small, fixed C declaration forms used by the vendored QuickJS ABI. + + This is deliberately not a general C parser. Unsupported declarations fail + explicitly instead of being accepted through permissive text matching. + """ + + @doc "Returns the value of an exact preprocessor definition." + @spec define!(String.t(), String.t()) :: String.t() + def define!(source, name) when is_binary(source) and is_binary(name) do + source + |> lines() + |> Enum.find_value(fn line -> definition_value(line, name) end) + |> case do + nil -> raise ArgumentError, "#{name} not found in vendored source" + value -> value + end + end + + @doc "Returns comma-separated entries from one exact typedef enum declaration." + @spec enum_entries!(String.t(), String.t()) :: [String.t()] + def enum_entries!(source, name) when is_binary(source) and is_binary(name) do + opening = "typedef enum #{name} {" + closing = "} #{name};" + + case Enum.split_while(lines(source), &(String.trim(&1) != opening)) do + {_before, []} -> + raise ArgumentError, "#{name} not found in vendored source" + + {_before, [_opening | body]} -> + {entries, remainder} = Enum.split_while(body, &(String.trim(&1) != closing)) + + if remainder == [] do + raise ArgumentError, "unterminated #{name} in vendored source" + end + + entries + |> Enum.flat_map(&String.split(&1, ",")) + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + end + end + + @doc "Returns argument lists from exact invocations of a C-style macro." + @spec macro_arguments(String.t(), String.t()) :: [[String.t()]] + def macro_arguments(source, name) when is_binary(source) and is_binary(name) do + prefix = name <> "(" + + source + |> lines() + |> Enum.flat_map(fn line -> + case String.trim_leading(line) do + ^prefix <> rest -> [parse_macro_arguments!(rest, name)] + _other -> [] + end + end) + end + + defp lines(source), do: String.split(source, ["\r\n", "\n"]) + + defp definition_value(line, name) do + case String.split(String.trim(line), [" ", "\t"], trim: true) do + ["#define", ^name, value] -> value + _other -> nil + end + end + + defp parse_macro_arguments!(rest, name) do + rest + |> String.to_charlist() + |> take_macro_body([], false, false, name) + |> split_arguments([], [], false, false) + |> Enum.map(&(&1 |> Enum.reverse() |> to_string() |> String.trim())) + end + + defp take_macro_body([], _body, _quoted?, _escaped?, name), + do: raise(ArgumentError, "unterminated #{name} invocation in vendored source") + + defp take_macro_body([?) | _rest], body, false, false, _name), do: Enum.reverse(body) + + defp take_macro_body([?\\ = char | rest], body, true, false, name), + do: take_macro_body(rest, [char | body], true, true, name) + + defp take_macro_body([char | rest], body, quoted?, true, name), + do: take_macro_body(rest, [char | body], quoted?, false, name) + + defp take_macro_body([?" = char | rest], body, quoted?, false, name), + do: take_macro_body(rest, [char | body], not quoted?, false, name) + + defp take_macro_body([char | rest], body, quoted?, false, name), + do: take_macro_body(rest, [char | body], quoted?, false, name) + + defp split_arguments([], argument, arguments, false, false), + do: Enum.reverse([argument | arguments]) + + defp split_arguments([], _argument, _arguments, true, _escaped?), + do: raise(ArgumentError, "unterminated string in vendored macro invocation") + + defp split_arguments([?, | rest], argument, arguments, false, false), + do: split_arguments(rest, [], [argument | arguments], false, false) + + defp split_arguments([?\\ = char | rest], argument, arguments, true, false), + do: split_arguments(rest, [char | argument], arguments, true, true) + + defp split_arguments([char | rest], argument, arguments, quoted?, true), + do: split_arguments(rest, [char | argument], arguments, quoted?, false) + + defp split_arguments([?" = char | rest], argument, arguments, quoted?, false), + do: split_arguments(rest, [char | argument], arguments, not quoted?, false) + + defp split_arguments([char | rest], argument, arguments, quoted?, false), + do: split_arguments(rest, [char | argument], arguments, quoted?, false) +end diff --git a/lib/quickbeam/vm/abi/vocabulary.ex b/lib/quickbeam/vm/abi/vocabulary.ex new file mode 100644 index 000000000..b546793c4 --- /dev/null +++ b/lib/quickbeam/vm/abi/vocabulary.ex @@ -0,0 +1,316 @@ +defmodule QuickBEAM.VM.ABI.Vocabulary do + @moduledoc "Defines the fixed atom vocabulary accepted from vendored QuickJS ABI sources." + + @atoms [ + :add, + :add_brand, + :add_loc, + :and, + :append, + :apply, + :apply_eval, + :arg, + :array, + :array_buffer, + :array_from, + :async_yield_star, + :atom, + :atom_label_u8, + :atom_u16, + :atom_u8, + :await, + :big_int, + :bool_false, + :bool_true, + :call, + :call0, + :call1, + :call2, + :call3, + :call_constructor, + :call_method, + :catch, + :check_brand, + :check_ctor, + :check_ctor_return, + :check_define_var, + :check_object, + :close_loc, + :const, + :const8, + :copy_data_properties, + :date, + :dec, + :dec_loc, + :define_array_el, + :define_class, + :define_class_computed, + :define_field, + :define_func, + :define_method, + :define_method_computed, + :define_private_field, + :define_var, + :delete, + :delete_var, + :div, + :drop, + :dup, + :dup1, + :dup2, + :dup3, + :eq, + :eval, + :fclosure, + :fclosure8, + :float64, + :for_await_of_start, + :for_in_next, + :for_in_start, + :for_of_next, + :for_of_start, + :function_bytecode, + :get_arg, + :get_arg0, + :get_arg1, + :get_arg2, + :get_arg3, + :get_array_el, + :get_array_el2, + :get_field, + :get_field2, + :get_length, + :get_loc, + :get_loc0, + :get_loc0_loc1, + :get_loc1, + :get_loc2, + :get_loc3, + :get_loc8, + :get_loc_check, + :get_private_field, + :get_ref_value, + :get_super, + :get_super_value, + :get_var, + :get_var_ref, + :get_var_ref0, + :get_var_ref1, + :get_var_ref2, + :get_var_ref3, + :get_var_ref_check, + :get_var_undef, + :gosub, + :goto, + :goto16, + :goto8, + :gt, + :gte, + :i16, + :i32, + :i8, + :if_false, + :if_false8, + :if_true, + :if_true8, + :import, + :in, + :inc, + :inc_loc, + :init_ctor, + :initial_yield, + :insert2, + :insert3, + :insert4, + :instanceof, + :int32, + :invalid, + :is_null, + :is_undefined, + :is_undefined_or_null, + :iterator_call, + :iterator_close, + :iterator_get_value_done, + :iterator_next, + :label, + :label16, + :label8, + :lnot, + :loc, + :loc8, + :lt, + :lte, + :make_arg_ref, + :make_loc_ref, + :make_var_ref, + :make_var_ref_ref, + :map, + :mod, + :module, + :mul, + :neg, + :neq, + :nip, + :nip1, + :nip_catch, + :none, + :none_arg, + :none_int, + :none_loc, + :none_var_ref, + :nop, + :not, + :npop, + :npop_u16, + :npopx, + :null, + :object, + :object_reference, + :object_value, + :or, + :perm3, + :perm4, + :perm5, + :plus, + :post_dec, + :post_inc, + :pow, + :private_in, + :private_symbol, + :push_0, + :push_1, + :push_2, + :push_3, + :push_4, + :push_5, + :push_6, + :push_7, + :push_atom_value, + :push_bigint_i32, + :push_const, + :push_const8, + :push_empty_string, + :push_false, + :push_i16, + :push_i32, + :push_i8, + :push_minus1, + :push_this, + :push_true, + :put_arg, + :put_arg0, + :put_arg1, + :put_arg2, + :put_arg3, + :put_array_el, + :put_field, + :put_loc, + :put_loc0, + :put_loc1, + :put_loc2, + :put_loc3, + :put_loc8, + :put_loc_check, + :put_loc_check_init, + :put_private_field, + :put_ref_value, + :put_super_value, + :put_var, + :put_var_init, + :put_var_ref, + :put_var_ref0, + :put_var_ref1, + :put_var_ref2, + :put_var_ref3, + :put_var_ref_check, + :put_var_ref_check_init, + :regexp, + :rest, + :ret, + :return, + :return_async, + :return_undef, + :rot3l, + :rot3r, + :rot4l, + :rot5l, + :sar, + :set, + :set_arg, + :set_arg0, + :set_arg1, + :set_arg2, + :set_arg3, + :set_home_object, + :set_loc, + :set_loc0, + :set_loc1, + :set_loc2, + :set_loc3, + :set_loc8, + :set_loc_uninitialized, + :set_name, + :set_name_computed, + :set_proto, + :set_var_ref, + :set_var_ref0, + :set_var_ref1, + :set_var_ref2, + :set_var_ref3, + :shared_array_buffer, + :shl, + :shr, + :special_object, + :strict_eq, + :strict_neq, + :string, + :sub, + :swap, + :swap2, + :symbol, + :tail_call, + :tail_call_method, + :template_object, + :throw, + :throw_error, + :to_object, + :to_propkey, + :to_propkey2, + :typed_array, + :typeof, + :typeof_is_function, + :typeof_is_undefined, + :u16, + :u8, + :undefined, + :using_check, + :using_dispose, + :using_dispose_async, + :using_dispose_end, + :using_dispose_init, + :using_dispose_merge, + :var_ref, + :with_delete_var, + :with_get_ref, + :with_get_ref_undef, + :with_get_var, + :with_make_ref, + :with_put_var, + :xor, + :yield, + :yield_star + ] + @by_name Map.new(@atoms, &{Atom.to_string(&1), &1}) + + @doc "Establishes the compile-time dependency required by ABI generation." + defmacro ensure_loaded, do: :ok + + @doc "Returns an existing ABI atom for a validated vendored identifier." + @spec fetch!(String.t(), atom()) :: atom() + def fetch!(name, context) do + case Map.fetch(@by_name, name) do + {:ok, atom} -> atom + :error -> raise ArgumentError, "unknown #{context} identifier: #{inspect(name)}" + end + end +end diff --git a/lib/quickbeam/vm/builtin.ex b/lib/quickbeam/vm/builtin.ex new file mode 100644 index 000000000..01be73610 --- /dev/null +++ b/lib/quickbeam/vm/builtin.ex @@ -0,0 +1,52 @@ +defmodule QuickBEAM.VM.Builtin do + @moduledoc """ + Provides the runtime contract and `use` entry point for declarative builtins. + + `QuickBEAM.VM.Builtin.DSL` compiles declarations into immutable specs; + `QuickBEAM.VM.Builtin.Validator` validates them; and + `QuickBEAM.VM.Builtin.Installer` creates owner-local intrinsic objects. + Runtime handlers receive an explicit `QuickBEAM.VM.Builtin.Call` and return an + immediate result, JavaScript error, or typed resumable action. + """ + + alias QuickBEAM.VM.Builtin.Action + alias QuickBEAM.VM.Builtin.Call + alias QuickBEAM.VM.Builtin.Contract.Error, as: ContractError + alias QuickBEAM.VM.Runtime.State + + @type handler_result :: + {:ok, term(), State.t()} + | {:error, term(), State.t()} + | Action.t() + + @doc "Installs the declarative builtin DSL in a module." + defmacro __using__(opts) do + quote do + use QuickBEAM.VM.Builtin.DSL, unquote(opts) + end + end + + @doc "Wraps a canonical resumable invocation action in the builtin result contract." + @spec action(term()) :: Action.t() + def action(value), do: %Action{value: value} + + @doc "Returns whether a declarative builtin token is its spec's constructor." + @spec constructable?({:declared_builtin, module(), atom()}) :: boolean() + def constructable?({:declared_builtin, module, handler}) do + spec = module.builtin_spec() + spec.kind == :constructor and spec.constructor == handler + end + + @doc "Dispatches a stable token and validates the handler's runtime result." + @spec invoke({:declared_builtin, module(), atom()}, Call.t()) :: handler_result() + def invoke({:declared_builtin, module, handler}, %Call{} = call) do + result = apply(module, handler, [call]) + + case result do + {:ok, _value, %State{}} -> result + {:error, _reason, %State{}} -> result + %Action{} -> result + invalid -> raise ContractError, module: module, handler: handler, result: invalid + end + end +end diff --git a/lib/quickbeam/vm/builtin/action.ex b/lib/quickbeam/vm/builtin/action.ex new file mode 100644 index 000000000..dd2ce9a46 --- /dev/null +++ b/lib/quickbeam/vm/builtin/action.ex @@ -0,0 +1,8 @@ +defmodule QuickBEAM.VM.Builtin.Action do + @moduledoc "Wraps a validated resumable action returned by a builtin handler." + + @enforce_keys [:value] + defstruct [:value] + + @type t :: %__MODULE__{value: term()} +end diff --git a/lib/quickbeam/vm/builtin/array.ex b/lib/quickbeam/vm/builtin/array.ex new file mode 100644 index 000000000..46e1a7d0a --- /dev/null +++ b/lib/quickbeam/vm/builtin/array.ex @@ -0,0 +1,279 @@ +defmodule QuickBEAM.VM.Builtin.Array do + @moduledoc "Defines declarative additions to the core `Array` constructor." + + use QuickBEAM.VM.Builtin + + alias QuickBEAM.VM.Builtin + alias QuickBEAM.VM.Builtin.Call + alias QuickBEAM.VM.Runtime.Heap + alias QuickBEAM.VM.Runtime.Object + alias QuickBEAM.VM.Runtime.Property + alias QuickBEAM.VM.Runtime.Reference + alias QuickBEAM.VM.Runtime.Value + + builtin "Array", + kind: :constructor, + constructor: :construct, + length: 1, + depends_on: ["Object", "Function"] do + static :array?, js: "isArray", length: 1 + + prototype kind: :array, extends: "Object", default_for: :array do + method :concat, length: 1 + method :filter, length: 1 + method :for_each, js: "forEach", length: 1 + method :includes, length: 1 + method :join, length: 1 + method :map, length: 1 + method :push, length: 1 + method :reduce, length: 1 + method :slice, length: 2 + method :some, length: 1 + method :sort, length: 1 + end + end + + @doc "Constructs an Array from a length or argument list." + def construct(%Call{arguments: [length], execution: execution}) + when is_integer(length) and length in 0..4_294_967_295 do + {array, execution} = Heap.allocate(execution, :array, length: length) + {:ok, array, execution} + end + + def construct(%Call{arguments: [length], execution: execution}) + when is_float(length) and length >= 0 and length <= 4_294_967_295 and + trunc(length) == length do + {array, execution} = Heap.allocate(execution, :array, length: trunc(length)) + {:ok, array, execution} + end + + def construct(%Call{arguments: [length], execution: execution} = call) + when is_number(length) or length in [:nan, :infinity, :neg_infinity] do + Builtin.action({:error, {:range_error, :invalid_array_length}, call.caller, execution}) + end + + def construct(%Call{arguments: arguments, execution: execution}) do + entries = Enum.map(arguments, &{:present, &1}) + {array, execution} = from_entries(entries, execution) + {:ok, array, execution} + end + + @doc "Implements `Array.isArray`." + def array?(%Call{arguments: arguments, execution: execution}) do + value = List.first(arguments, :undefined) + + result = + case value do + %Reference{} = reference -> Property.kind(reference, execution) == :array + value -> is_list(value) + end + + {:ok, result, execution} + end + + @doc "Implements sparse `Array.prototype.concat`." + def concat(%Call{this: receiver, arguments: arguments, execution: execution}) do + case array_entries(receiver, execution) do + {:ok, entries} -> + entries = Enum.reduce(arguments, entries, &(&2 ++ concat_entries(&1, execution))) + {array, execution} = from_entries(entries, execution) + {:ok, array, execution} + + {:error, reason} -> + {:error, reason, execution} + end + end + + @doc "Implements `Array.prototype.includes` with SameValueZero comparison." + def includes(%Call{this: receiver, arguments: arguments, execution: execution}) do + searched = List.first(arguments, :undefined) + + case array_entries(receiver, execution) do + {:ok, entries} -> + included? = + Enum.any?(entries, fn + :hole -> searched == :undefined + {:present, :nan} -> searched == :nan + {:present, value} -> Value.strict_equal?(value, searched) + end) + + {:ok, included?, execution} + + {:error, reason} -> + {:error, reason, execution} + end + end + + @doc "Implements `Array.prototype.join`, treating holes and nullish values as empty strings." + def join(%Call{this: receiver, arguments: arguments, execution: execution}) do + separator = + case arguments do + [] -> "," + [:undefined | _] -> "," + [value | _] -> Value.to_string_value(value) + end + + case array_entries(receiver, execution) do + {:ok, entries} -> + value = + Enum.map_join(entries, separator, fn + :hole -> "" + {:present, value} when value in [nil, :undefined] -> "" + {:present, value} -> Value.to_string_value(value) + end) + + {:ok, value, execution} + + {:error, reason} -> + {:error, reason, execution} + end + end + + @doc "Implements `Array.prototype.push`." + def push(%Call{this: %Reference{} = array, arguments: values, execution: execution}) do + case Heap.fetch_object(execution, array) do + {:ok, %Object{kind: :array, length: length}} -> + execution = + values + |> Enum.with_index(length) + |> Enum.reduce(execution, fn {value, index}, execution -> + {:ok, execution} = Property.put(array, index, value, execution) + execution + end) + + {:ok, length + Kernel.length(values), execution} + + _not_array -> + {:error, :not_an_array, execution} + end + end + + def push(%Call{execution: execution}), do: {:error, :not_an_array, execution} + + @doc "Implements sparse `Array.prototype.slice`." + def slice(%Call{this: receiver, arguments: arguments, execution: execution}) do + case array_entries(receiver, execution) do + {:ok, entries} -> + {start, length} = Value.slice_range(length(entries), arguments) + {array, execution} = from_entries(Enum.slice(entries, start, length), execution) + {:ok, array, execution} + + {:error, reason} -> + {:error, reason, execution} + end + end + + @doc "Plans resumable `Array.prototype.filter` iteration." + def filter(%Call{} = call), do: iteration_action("filter", call) + + @doc "Plans resumable `Array.prototype.forEach` iteration." + def for_each(%Call{} = call), do: iteration_action("forEach", call) + + @doc "Plans resumable `Array.prototype.map` iteration." + def map(%Call{} = call), do: iteration_action("map", call) + + @doc "Plans resumable `Array.prototype.reduce` iteration." + def reduce(%Call{} = call), do: iteration_action("reduce", call) + + @doc "Plans resumable `Array.prototype.some` iteration." + def some(%Call{} = call), do: iteration_action("some", call) + + @doc "Sorts present array values lexicographically when no comparator is supplied." + def sort(%Call{this: %Reference{} = array, arguments: arguments, execution: execution}) do + case List.first(arguments, :undefined) do + :undefined -> sort_default(array, execution) + _comparator -> {:error, :unsupported_array_sort_comparator, execution} + end + end + + def sort(%Call{execution: execution}), do: {:error, :not_an_array, execution} + + @doc "Allocates an owner-local array containing the supplied present values." + @spec from_values([term()], QuickBEAM.VM.Runtime.State.t()) :: + {Reference.t(), QuickBEAM.VM.Runtime.State.t()} + def from_values(values, execution) do + from_entries(Enum.map(values, &{:present, &1}), execution) + end + + defp sort_default(array, execution) do + case array_entries(array, execution) do + {:ok, entries} -> + values = + entries + |> Enum.flat_map(fn + {:present, value} -> [value] + :hole -> [] + end) + |> Enum.sort_by(&Value.to_string_value/1) + + {:ok, execution} = Heap.update_object(execution, array, &Heap.clear_array/1) + + execution = + values + |> Enum.with_index() + |> Enum.reduce(execution, fn {value, index}, execution -> + {:ok, execution} = Property.define(array, index, value, execution) + execution + end) + + {:ok, array, execution} + + {:error, reason} -> + {:error, reason, execution} + end + end + + defp array_entries(value, _execution) when is_list(value), + do: {:ok, Enum.map(value, &{:present, &1})} + + defp array_entries(%Reference{} = reference, execution) do + case Heap.fetch_object(execution, reference) do + {:ok, %Object{kind: :array} = object} -> + {:ok, Heap.array_entries(object)} + + _not_array -> + {:error, :not_an_array} + end + end + + defp array_entries(_value, _execution), do: {:error, :not_an_array} + + defp concat_entries(value, execution) do + case array_entries(value, execution) do + {:ok, entries} -> entries + {:error, _reason} -> [{:present, value}] + end + end + + @doc "Allocates an owner-local sparse array from present-value and hole entries." + @spec from_entries([{:present, term()} | :hole], QuickBEAM.VM.Runtime.State.t()) :: + {Reference.t(), QuickBEAM.VM.Runtime.State.t()} + def from_entries(entries, execution) do + {array, execution} = Heap.allocate(execution, :array) + + execution = + entries + |> Enum.with_index() + |> Enum.reduce(execution, fn + {{:present, value}, index}, execution -> + {:ok, execution} = Property.define(array, index, value, execution) + execution + + {:hole, _index}, execution -> + execution + end) + + {:ok, execution} = Property.define(array, "length", length(entries), execution) + {array, execution} + end + + defp iteration_action(method, %Call{ + arguments: arguments, + this: receiver, + caller: caller, + tail?: tail?, + execution: execution + }) do + Builtin.action({:array_iteration, method, receiver, arguments, caller, execution, tail?}) + end +end diff --git a/lib/quickbeam/vm/builtin/boolean.ex b/lib/quickbeam/vm/builtin/boolean.ex new file mode 100644 index 000000000..2e03eab46 --- /dev/null +++ b/lib/quickbeam/vm/builtin/boolean.ex @@ -0,0 +1,41 @@ +defmodule QuickBEAM.VM.Builtin.Boolean do + @moduledoc "Defines declarative Boolean call and construction semantics." + + use QuickBEAM.VM.Builtin + + alias QuickBEAM.VM.Builtin.Call + alias QuickBEAM.VM.Runtime.Boundary + alias QuickBEAM.VM.Runtime.Heap + alias QuickBEAM.VM.Runtime.Reference + alias QuickBEAM.VM.Runtime.Value + + builtin "Boolean", + kind: :constructor, + constructor: :construct, + length: 1, + depends_on: ["Object", "Function"] do + prototype extends: "Object", primitive: {:boolean, false} + end + + @doc "Converts a value to boolean or initializes a boxed Boolean." + def construct(%Call{ + this: %Reference{} = receiver, + caller: %Boundary.Constructor{}, + arguments: arguments, + execution: execution + }) do + value = arguments |> List.first(:undefined) |> Value.truthy?() + + {:ok, execution} = + Heap.update_object(execution, receiver, fn object -> + %{object | internal: {:primitive, :boolean, value}} + end) + + {:ok, receiver, execution} + end + + def construct(%Call{arguments: arguments, execution: execution}) do + value = arguments |> List.first(:undefined) |> Value.truthy?() + {:ok, value, execution} + end +end diff --git a/lib/quickbeam/vm/builtin/call.ex b/lib/quickbeam/vm/builtin/call.ex new file mode 100644 index 000000000..1e38a89d8 --- /dev/null +++ b/lib/quickbeam/vm/builtin/call.ex @@ -0,0 +1,21 @@ +defmodule QuickBEAM.VM.Builtin.Call do + @moduledoc """ + Carries one builtin invocation through the canonical invocation boundary. + + Handlers receive explicit arguments, receiver, continuation, tail-call mode, + and owner-local execution state. They must not fetch mutable state elsewhere. + """ + + alias QuickBEAM.VM.Runtime.State + + @enforce_keys [:arguments, :this, :caller, :tail?, :execution] + defstruct [:arguments, :this, :caller, :tail?, :execution] + + @type t :: %__MODULE__{ + arguments: [term()], + this: term(), + caller: term(), + tail?: boolean(), + execution: State.t() + } +end diff --git a/lib/quickbeam/vm/builtin/console.ex b/lib/quickbeam/vm/builtin/console.ex new file mode 100644 index 000000000..7eabb0244 --- /dev/null +++ b/lib/quickbeam/vm/builtin/console.ex @@ -0,0 +1,29 @@ +defmodule QuickBEAM.VM.Builtin.Console do + @moduledoc "Defines the minimal SSR console namespace." + + use QuickBEAM.VM.Builtin + + alias QuickBEAM.VM.Builtin.Call + + builtin "console", + kind: :namespace, + profiles: [:ssr], + depends_on: ["Object", "Function"] do + static :error, length: 1 + static :info, length: 1 + static :log, length: 1 + static :warn, length: 1 + end + + @doc "Accepts a console error message without writing outside the evaluation." + def error(%Call{execution: execution}), do: {:ok, :undefined, execution} + + @doc "Accepts a console informational message without writing outside the evaluation." + def info(%Call{execution: execution}), do: {:ok, :undefined, execution} + + @doc "Accepts a console log message without writing outside the evaluation." + def log(%Call{execution: execution}), do: {:ok, :undefined, execution} + + @doc "Accepts a console warning without writing outside the evaluation." + def warn(%Call{execution: execution}), do: {:ok, :undefined, execution} +end diff --git a/lib/quickbeam/vm/builtin/contract/error.ex b/lib/quickbeam/vm/builtin/contract/error.ex new file mode 100644 index 000000000..13bc1d19b --- /dev/null +++ b/lib/quickbeam/vm/builtin/contract/error.ex @@ -0,0 +1,11 @@ +defmodule QuickBEAM.VM.Builtin.Contract.Error do + @moduledoc "Raised when a builtin handler violates the runtime result contract." + + defexception [:module, :handler, :result] + + @doc "Formats the invalid builtin handler result." + @impl true + def message(%__MODULE__{module: module, handler: handler, result: result}) do + "builtin #{inspect(module)}.#{handler}/1 returned an invalid result: #{inspect(result)}" + end +end diff --git a/lib/quickbeam/vm/builtin/dsl.ex b/lib/quickbeam/vm/builtin/dsl.ex new file mode 100644 index 000000000..fc0eca7fd --- /dev/null +++ b/lib/quickbeam/vm/builtin/dsl.ex @@ -0,0 +1,230 @@ +defmodule QuickBEAM.VM.Builtin.DSL do + @moduledoc "Compiles the declarative builtin syntax into immutable validated specs." + + alias QuickBEAM.VM.Builtin.Spec + alias QuickBEAM.VM.Builtin.Spec.Accessor, as: AccessorSpec + alias QuickBEAM.VM.Builtin.Spec.Function, as: FunctionSpec + alias QuickBEAM.VM.Builtin.Spec.Prototype, as: PrototypeSpec + alias QuickBEAM.VM.Builtin.Validator + + @doc "Installs builtin declaration attributes and macros." + defmacro __using__(_opts) do + quote do + import QuickBEAM.VM.Builtin.DSL + + Module.register_attribute(__MODULE__, :quickbeam_builtin_name, persist: false) + Module.register_attribute(__MODULE__, :quickbeam_builtin_options, persist: false) + Module.register_attribute(__MODULE__, :quickbeam_builtin_count, persist: false) + Module.register_attribute(__MODULE__, :quickbeam_builtin_statics, accumulate: true) + Module.register_attribute(__MODULE__, :quickbeam_builtin_prototype, accumulate: true) + Module.register_attribute(__MODULE__, :quickbeam_builtin_prototype_options, persist: false) + @quickbeam_builtin_count 0 + @quickbeam_builtin_prototype_options [] + @before_compile QuickBEAM.VM.Builtin.DSL + end + end + + @doc "Declares one builtin namespace, intrinsic fragment, or constructor." + defmacro builtin(name, opts \\ [], do: block) do + quote do + @quickbeam_builtin_count @quickbeam_builtin_count + 1 + @quickbeam_builtin_name unquote(name) + @quickbeam_builtin_options unquote(opts) + unquote(block) + end + end + + @doc "Declares prototype topology and optionally groups its properties." + defmacro prototype(do: block), do: block + + defmacro prototype(opts) do + quote do + @quickbeam_builtin_prototype_options unquote(opts) + end + end + + @doc "Declares semantic prototype topology and groups its properties." + defmacro prototype(opts, do: block) do + quote do + @quickbeam_builtin_prototype_options unquote(opts) + unquote(block) + end + end + + @doc "Declares a static function using its handler as the default JavaScript name." + defmacro static(handler, opts \\ []) do + spec = function_spec(handler, opts) + + quote do + @quickbeam_builtin_statics unquote(Macro.escape(spec)) + end + end + + @doc "Declares a prototype method using its handler as the default JavaScript name." + defmacro method(handler, opts \\ []) do + spec = function_spec(handler, opts) + + quote do + @quickbeam_builtin_prototype unquote(Macro.escape(spec)) + end + end + + @doc "Declares a static data property and evaluates its value in the declaring module." + defmacro static_value(key, value, opts \\ []) do + quote bind_quoted: [key: key, value: value, opts: opts] do + @quickbeam_builtin_statics QuickBEAM.VM.Builtin.DSL.property_spec(key, value, opts) + end + end + + @doc "Declares an immutable static constant." + defmacro constant(key, value) do + quote bind_quoted: [key: key, value: value] do + @quickbeam_builtin_statics QuickBEAM.VM.Builtin.DSL.property_spec(key, value, []) + end + end + + @doc "Declares a prototype data property and evaluates its value in the declaring module." + defmacro prototype_value(key, value, opts \\ []) do + quote bind_quoted: [key: key, value: value, opts: opts] do + @quickbeam_builtin_prototype QuickBEAM.VM.Builtin.DSL.property_spec(key, value, opts) + end + end + + @doc "Declares a prototype property alias with an evaluated property key." + defmacro prototype_alias(key, opts) do + quote bind_quoted: [key: key, opts: opts] do + @quickbeam_builtin_prototype QuickBEAM.VM.Builtin.DSL.alias_spec(key, opts) + end + end + + @doc "Declares a static property alias with an evaluated property key." + defmacro static_alias(key, opts) do + quote bind_quoted: [key: key, opts: opts] do + @quickbeam_builtin_statics QuickBEAM.VM.Builtin.DSL.alias_spec(key, opts) + end + end + + @doc "Declares a prototype getter." + defmacro getter(handler, opts \\ []) do + spec = accessor_spec(handler, Keyword.put(opts, :get, handler)) + + quote do + @quickbeam_builtin_prototype unquote(Macro.escape(spec)) + end + end + + @doc "Declares a prototype getter/setter pair." + defmacro accessor(name, opts) do + spec = accessor_spec(name, opts) + + quote do + @quickbeam_builtin_prototype unquote(Macro.escape(spec)) + end + end + + @doc "Declares a static getter/setter pair." + defmacro static_accessor(name, opts) do + spec = accessor_spec(name, opts) + + quote do + @quickbeam_builtin_statics unquote(Macro.escape(spec)) + end + end + + @doc "Emits one validated immutable builtin specification." + defmacro __before_compile__(env) do + count = Module.get_attribute(env.module, :quickbeam_builtin_count) + + if count != 1 do + raise CompileError, + file: env.file, + line: env.line, + description: "builtin modules must contain exactly one builtin declaration" + end + + name = Module.get_attribute(env.module, :quickbeam_builtin_name) + opts = Module.get_attribute(env.module, :quickbeam_builtin_options) || [] + statics = env.module |> Module.get_attribute(:quickbeam_builtin_statics) |> Enum.reverse() + prototype = env.module |> Module.get_attribute(:quickbeam_builtin_prototype) |> Enum.reverse() + prototype_opts = Module.get_attribute(env.module, :quickbeam_builtin_prototype_options) || [] + + prototype_spec = %PrototypeSpec{ + kind: Keyword.get(prototype_opts, :kind, :ordinary), + extends: + if(Keyword.has_key?(prototype_opts, :extends), + do: Keyword.get(prototype_opts, :extends), + else: :default + ), + default_for: Keyword.get(prototype_opts, :default_for), + callable: Keyword.get(prototype_opts, :callable), + primitive: Keyword.get(prototype_opts, :primitive), + error_type: Keyword.get(prototype_opts, :error_type) + } + + spec = %Spec{ + name: name, + module: env.module, + kind: Keyword.get(opts, :kind, :namespace), + constructor: Keyword.get(opts, :constructor), + prototype_spec: prototype_spec, + profiles: Keyword.get(opts, :profiles, [:core]), + depends_on: Keyword.get(opts, :depends_on, []), + length: Keyword.get(opts, :length, 0), + statics: statics, + prototype: prototype + } + + Validator.validate!(spec, env) + + quote do + @doc "Returns this module's compile-time-validated builtin specification." + @spec builtin_spec() :: QuickBEAM.VM.Builtin.Spec.t() + def builtin_spec, do: unquote(Macro.escape(spec)) + end + end + + @doc "Builds an immutable builtin data-property declaration." + @spec property_spec(term(), term(), keyword()) :: QuickBEAM.VM.Builtin.Spec.Property.t() + def property_spec(key, value, opts) do + %QuickBEAM.VM.Builtin.Spec.Property{ + key: key, + value: value, + writable: Keyword.get(opts, :writable, false), + enumerable: Keyword.get(opts, :enumerable, false), + configurable: Keyword.get(opts, :configurable, false) + } + end + + @doc "Builds an immutable builtin property-alias declaration." + @spec alias_spec(term(), keyword()) :: QuickBEAM.VM.Builtin.Spec.Alias.t() + def alias_spec(key, opts) do + %QuickBEAM.VM.Builtin.Spec.Alias{ + key: key, + target: Keyword.fetch!(opts, :to), + writable: Keyword.get(opts, :writable, true), + enumerable: Keyword.get(opts, :enumerable, false), + configurable: Keyword.get(opts, :configurable, true) + } + end + + defp function_spec(handler, opts) when is_atom(handler) and is_list(opts) do + %FunctionSpec{ + key: Keyword.get(opts, :js, Atom.to_string(handler)), + handler: handler, + length: Keyword.get(opts, :length, 0), + writable: Keyword.get(opts, :writable, true), + enumerable: Keyword.get(opts, :enumerable, false), + configurable: Keyword.get(opts, :configurable, true) + } + end + + defp accessor_spec(name, opts) when is_atom(name) and is_list(opts) do + %AccessorSpec{ + key: Keyword.get(opts, :js, Atom.to_string(name)), + getter: Keyword.get(opts, :get), + setter: Keyword.get(opts, :set), + enumerable: Keyword.get(opts, :enumerable, false), + configurable: Keyword.get(opts, :configurable, true) + } + end +end diff --git a/lib/quickbeam/vm/builtin/error.ex b/lib/quickbeam/vm/builtin/error.ex new file mode 100644 index 000000000..797a8c74a --- /dev/null +++ b/lib/quickbeam/vm/builtin/error.ex @@ -0,0 +1,26 @@ +defmodule QuickBEAM.VM.Builtin.Error do + @moduledoc "Defines the declarative JavaScript Error constructor and prototype." + + use QuickBEAM.VM.Builtin + + alias QuickBEAM.VM.Builtin.Call + alias QuickBEAM.VM.Builtin.Error.Support + + builtin "Error", + kind: :constructor, + constructor: :construct, + length: 1, + depends_on: ["Object", "Function"] do + prototype extends: "Object", error_type: "Error" do + prototype_value "name", "Error", writable: true, configurable: true + prototype_value "message", "", writable: true, configurable: true + method :to_string_method, js: "toString", length: 0 + end + end + + @doc "Constructs an Error value." + def construct(%Call{} = call), do: Support.construct("Error", call) + + @doc "Formats an Error value." + def to_string_method(%Call{} = call), do: Support.to_string(call) +end diff --git a/lib/quickbeam/vm/builtin/error/eval.ex b/lib/quickbeam/vm/builtin/error/eval.ex new file mode 100644 index 000000000..51bf21626 --- /dev/null +++ b/lib/quickbeam/vm/builtin/error/eval.ex @@ -0,0 +1,4 @@ +defmodule QuickBEAM.VM.Builtin.Error.Eval do + @moduledoc "Defines the declarative EvalError constructor." + use QuickBEAM.VM.Builtin.Error.Subclass, name: "EvalError" +end diff --git a/lib/quickbeam/vm/builtin/error/range.ex b/lib/quickbeam/vm/builtin/error/range.ex new file mode 100644 index 000000000..367a6f4d8 --- /dev/null +++ b/lib/quickbeam/vm/builtin/error/range.ex @@ -0,0 +1,4 @@ +defmodule QuickBEAM.VM.Builtin.Error.Range do + @moduledoc "Defines the declarative RangeError constructor." + use QuickBEAM.VM.Builtin.Error.Subclass, name: "RangeError" +end diff --git a/lib/quickbeam/vm/builtin/error/reference.ex b/lib/quickbeam/vm/builtin/error/reference.ex new file mode 100644 index 000000000..f57e0b734 --- /dev/null +++ b/lib/quickbeam/vm/builtin/error/reference.ex @@ -0,0 +1,4 @@ +defmodule QuickBEAM.VM.Builtin.Error.Reference do + @moduledoc "Defines the declarative ReferenceError constructor." + use QuickBEAM.VM.Builtin.Error.Subclass, name: "ReferenceError" +end diff --git a/lib/quickbeam/vm/builtin/error/subclass.ex b/lib/quickbeam/vm/builtin/error/subclass.ex new file mode 100644 index 000000000..3afa98584 --- /dev/null +++ b/lib/quickbeam/vm/builtin/error/subclass.ex @@ -0,0 +1,31 @@ +defmodule QuickBEAM.VM.Builtin.Error.Subclass do + @moduledoc "Generates one declarative native Error subclass builtin." + + @doc "Defines one declarative native Error subclass." + defmacro __using__(opts) do + name = Keyword.fetch!(opts, :name) + + quote do + use QuickBEAM.VM.Builtin + + alias QuickBEAM.VM.Builtin.Call + alias QuickBEAM.VM.Builtin.Error.Support + + @error_name unquote(name) + + builtin unquote(name), + kind: :constructor, + constructor: :construct, + length: 1, + depends_on: ["Error", "Function"] do + prototype extends: "Error", error_type: unquote(name) do + prototype_value "name", unquote(name), writable: true, configurable: true + prototype_value "message", "", writable: true, configurable: true + end + end + + @doc "Constructs this native Error subclass." + def construct(%Call{} = call), do: Support.construct(@error_name, call) + end + end +end diff --git a/lib/quickbeam/vm/builtin/error/support.ex b/lib/quickbeam/vm/builtin/error/support.ex new file mode 100644 index 000000000..b4d3d7aa1 --- /dev/null +++ b/lib/quickbeam/vm/builtin/error/support.ex @@ -0,0 +1,75 @@ +defmodule QuickBEAM.VM.Builtin.Error.Support do + @moduledoc "Provides shared declarative Error constructor and formatting semantics." + + alias QuickBEAM.VM.Builtin.{Call, Runtime} + alias QuickBEAM.VM.Runtime.{Heap, Object, Property, Reference, Value} + + @doc "Constructs or initializes one Error hierarchy instance." + def construct(name, %Call{this: this, arguments: arguments, execution: execution}) do + message = + case arguments do + [value | _] -> Value.to_string_value(value) + [] -> nil + end + + if constructor_instance?(this, execution) do + prototype = Map.fetch!(execution.error_prototypes, name) + + {:ok, execution} = + Heap.update_object(execution, this, fn object -> + %{object | prototype: prototype, internal: {:error, name}} + end) + + execution = define_message(this, message, execution) + {:ok, this, execution} + else + {error, execution} = Runtime.new_error(execution, name, message) + {:ok, error, execution} + end + end + + @doc "Formats an Error receiver according to `Error.prototype.toString`." + def to_string(%Call{this: %Reference{} = error, execution: execution}) do + with {:ok, name} <- Property.get(error, "name", execution), + {:ok, message} <- Property.get(error, "message", execution) do + name = if name in [:undefined, nil], do: "Error", else: Value.to_string_value(name) + message = if message in [:undefined, nil], do: "", else: Value.to_string_value(message) + + result = + cond do + name == "" -> message + message == "" -> name + true -> name <> ": " <> message + end + + {:ok, result, execution} + else + {:error, reason} -> {:error, reason, execution} + end + end + + def to_string(%Call{execution: execution}), + do: {:error, :incompatible_error_receiver, execution} + + defp constructor_instance?(%Reference{} = receiver, execution) do + match?( + {:ok, %Object{internal: :constructor_instance}}, + Heap.fetch_object(execution, receiver) + ) + end + + defp constructor_instance?(_receiver, _execution), do: false + + defp define_message(_error, nil, execution), do: execution + + defp define_message(error, message, execution) do + {:ok, execution} = + Property.define(error, "message", message, execution, + enumerable: false, + configurable: true, + writable: true + ) + + execution + end +end diff --git a/lib/quickbeam/vm/builtin/error/syntax.ex b/lib/quickbeam/vm/builtin/error/syntax.ex new file mode 100644 index 000000000..2eb893ffe --- /dev/null +++ b/lib/quickbeam/vm/builtin/error/syntax.ex @@ -0,0 +1,4 @@ +defmodule QuickBEAM.VM.Builtin.Error.Syntax do + @moduledoc "Defines the declarative SyntaxError constructor." + use QuickBEAM.VM.Builtin.Error.Subclass, name: "SyntaxError" +end diff --git a/lib/quickbeam/vm/builtin/error/type.ex b/lib/quickbeam/vm/builtin/error/type.ex new file mode 100644 index 000000000..e7b210ab9 --- /dev/null +++ b/lib/quickbeam/vm/builtin/error/type.ex @@ -0,0 +1,4 @@ +defmodule QuickBEAM.VM.Builtin.Error.Type do + @moduledoc "Defines the declarative TypeError constructor." + use QuickBEAM.VM.Builtin.Error.Subclass, name: "TypeError" +end diff --git a/lib/quickbeam/vm/builtin/error/uri.ex b/lib/quickbeam/vm/builtin/error/uri.ex new file mode 100644 index 000000000..155849c1e --- /dev/null +++ b/lib/quickbeam/vm/builtin/error/uri.ex @@ -0,0 +1,4 @@ +defmodule QuickBEAM.VM.Builtin.Error.URI do + @moduledoc "Defines the declarative URIError constructor." + use QuickBEAM.VM.Builtin.Error.Subclass, name: "URIError" +end diff --git a/lib/quickbeam/vm/builtin/function.ex b/lib/quickbeam/vm/builtin/function.ex new file mode 100644 index 000000000..28b9519af --- /dev/null +++ b/lib/quickbeam/vm/builtin/function.ex @@ -0,0 +1,68 @@ +defmodule QuickBEAM.VM.Builtin.Function do + @moduledoc "Defines declarative methods shared by JavaScript function objects." + + use QuickBEAM.VM.Builtin + + alias QuickBEAM.VM.Builtin + alias QuickBEAM.VM.Builtin.Call + alias QuickBEAM.VM.Runtime.Invocation + + builtin "Function", + kind: :constructor, + constructor: :construct, + length: 1, + depends_on: ["Object"] do + prototype kind: :function, + extends: "Object", + default_for: :function, + callable: :prototype_call do + prototype_value "name", "", writable: false, configurable: true + prototype_value "length", 0, writable: false, configurable: true + method :bind, length: 1 + method :call, length: 1 + end + end + + @doc "Rejects unsupported dynamic Function construction explicitly." + def construct(%Call{execution: execution}), + do: {:error, :dynamic_function_unsupported, execution} + + @doc "Implements the callable empty Function prototype object." + def prototype_call(%Call{execution: execution}), do: {:ok, :undefined, execution} + + @doc "Creates a represented bound function." + def bind(%Call{this: target, arguments: arguments, execution: execution}) do + if Invocation.callable?(target, execution) do + {bound_this, bound_arguments} = + case arguments do + [bound_this | rest] -> {bound_this, rest} + [] -> {:undefined, []} + end + + {:ok, {:bound_function, target, bound_this, bound_arguments}, execution} + else + {:error, :incompatible_function_receiver, execution} + end + end + + @doc "Invokes a function with an explicit receiver." + def call(%Call{ + this: target, + arguments: arguments, + caller: caller, + tail?: tail?, + execution: execution + }) do + if Invocation.callable?(target, execution) do + {this, arguments} = + case arguments do + [this | rest] -> {this, rest} + [] -> {:undefined, []} + end + + Builtin.action({:dispatch, target, arguments, this, caller, execution, tail?}) + else + {:error, :incompatible_function_receiver, execution} + end + end +end diff --git a/lib/quickbeam/vm/builtin/installer.ex b/lib/quickbeam/vm/builtin/installer.ex new file mode 100644 index 000000000..707735fc6 --- /dev/null +++ b/lib/quickbeam/vm/builtin/installer.ex @@ -0,0 +1,289 @@ +defmodule QuickBEAM.VM.Builtin.Installer do + @moduledoc """ + Installs declarative builtin specs into one owner-local VM execution. + + Installation is deterministic and threads `%QuickBEAM.VM.Runtime.State{}` through + the canonical heap and property layers. Installed function objects carry + stable module/handler tokens rather than captured closures. + """ + + alias QuickBEAM.VM.Builtin.Spec + alias QuickBEAM.VM.Builtin.Spec.Accessor, as: AccessorSpec + alias QuickBEAM.VM.Builtin.Spec.Alias, as: AliasSpec + alias QuickBEAM.VM.Builtin.Spec.Function, as: FunctionSpec + alias QuickBEAM.VM.Builtin.Spec.Property, as: PropertySpec + alias QuickBEAM.VM.Builtin.Spec.Prototype, as: PrototypeSpec + alias QuickBEAM.VM.Runtime.Heap + alias QuickBEAM.VM.Runtime.Property + alias QuickBEAM.VM.Runtime.Reference + alias QuickBEAM.VM.Runtime.State + + @doc "Installs registered builtin modules for the selected profile." + @spec install_all(State.t(), [module()], atom()) :: State.t() + def install_all(execution, modules, profile \\ :core) do + specs = + modules + |> Enum.map(& &1.builtin_spec()) + |> Enum.filter(&(:core in &1.profiles or profile in &1.profiles)) + + validate_registry!(specs, execution) + Enum.reduce(specs, execution, &install(&2, &1)) + end + + @doc "Installs one immutable builtin specification." + @spec install(State.t(), Spec.t()) :: State.t() + def install(execution, %Spec{kind: :namespace} = spec) do + {target, execution} = Heap.allocate(execution) + execution = install_entries(execution, target, spec.module, spec.statics) + put_global(execution, spec.name, target) + end + + def install(execution, %Spec{kind: :function} = spec) do + token = {:declared_builtin, spec.module, :call} + {target, execution} = allocate_function(execution, spec.name, spec.length, token) + execution = install_entries(execution, target, spec.module, spec.statics) + put_global(execution, spec.name, target) + end + + def install(execution, %Spec{kind: :intrinsic} = spec) do + target = Map.fetch!(execution.globals, spec.name) + execution = install_entries(execution, target, spec.module, spec.statics) + install_prototype_entries(execution, target, spec) + end + + def install(execution, %Spec{kind: :constructor} = spec) do + token = {:declared_builtin, spec.module, spec.constructor} + {constructor, execution} = allocate_function(execution, spec.name, spec.length, token) + topology = spec.prototype_spec + parent = resolve_prototype_parent(execution, topology.extends) + + prototype_callable = + if topology.callable, + do: {:declared_builtin, spec.module, topology.callable}, + else: nil + + prototype_internal = + case topology.primitive do + {kind, value} -> {:primitive, kind, value} + nil -> nil + end + + {prototype, execution} = + Heap.allocate(execution, topology.kind, + prototype: parent, + callable: prototype_callable, + internal: prototype_internal + ) + + {:ok, execution} = + Property.define(prototype, "constructor", constructor, execution, + writable: true, + enumerable: false, + configurable: true + ) + + {:ok, execution} = + Property.define(constructor, "prototype", prototype, execution, + writable: false, + enumerable: false, + configurable: false + ) + + execution = install_entries(execution, constructor, spec.module, spec.statics) + execution = install_entries(execution, prototype, spec.module, spec.prototype) + execution = put_global(execution, spec.name, constructor) + register_prototype(execution, prototype, topology) + end + + defp install_prototype_entries(execution, _target, %Spec{prototype: []}), do: execution + + defp install_prototype_entries(execution, %Reference{} = constructor, spec) do + case Property.get(constructor, "prototype", execution) do + {:ok, %Reference{} = prototype} -> + install_entries(execution, prototype, spec.module, spec.prototype) + + _missing -> + raise ArgumentError, "builtin extension #{spec.name} has no prototype object" + end + end + + defp install_entries(execution, target, module, entries) do + Enum.reduce(entries, execution, fn entry, execution -> + install_entry(execution, target, module, entry) + end) + end + + defp install_entry(execution, target, module, %FunctionSpec{} = spec) do + token = {:declared_builtin, module, spec.handler} + {function, execution} = allocate_function(execution, to_string(spec.key), spec.length, token) + + {:ok, execution} = + Property.define(target, spec.key, function, execution, + writable: spec.writable, + enumerable: spec.enumerable, + configurable: spec.configurable + ) + + execution + end + + defp install_entry(execution, target, module, %AccessorSpec{} = spec) do + {getter, execution} = allocate_optional_function(execution, module, spec.getter, spec.key) + {setter, execution} = allocate_optional_function(execution, module, spec.setter, spec.key) + + {:ok, execution} = + Property.define_accessor(target, spec.key, :getter, getter, execution, + enumerable: spec.enumerable, + configurable: spec.configurable + ) + + {:ok, execution} = + Property.define_accessor(target, spec.key, :setter, setter, execution, + enumerable: spec.enumerable, + configurable: spec.configurable + ) + + execution + end + + defp install_entry(execution, target, _module, %AliasSpec{} = spec) do + {:ok, value} = Property.get(target, spec.target, execution) + + if value == :undefined do + raise ArgumentError, + "builtin alias #{inspect(spec.key)} has missing target #{inspect(spec.target)}" + end + + {:ok, execution} = + Property.define(target, spec.key, value, execution, + writable: spec.writable, + enumerable: spec.enumerable, + configurable: spec.configurable + ) + + execution + end + + defp install_entry(execution, target, _module, %PropertySpec{} = spec) do + {:ok, execution} = + Property.define(target, spec.key, spec.value, execution, + writable: spec.writable, + enumerable: spec.enumerable, + configurable: spec.configurable + ) + + execution + end + + defp allocate_function(execution, name, length, callable) do + {function, execution} = Heap.allocate(execution, :function, callable: callable) + + {:ok, execution} = + Property.define(function, "name", name, execution, + writable: false, + enumerable: false, + configurable: true + ) + + {:ok, execution} = + Property.define(function, "length", length, execution, + writable: false, + enumerable: false, + configurable: true + ) + + {function, execution} + end + + defp allocate_optional_function(execution, _module, nil, _key), + do: {nil, execution} + + defp allocate_optional_function(execution, module, handler, key) do + token = {:declared_builtin, module, handler} + allocate_function(execution, to_string(key), 0, token) + end + + defp resolve_prototype_parent(execution, :default), + do: Map.get(execution.default_prototypes, :ordinary) + + defp resolve_prototype_parent(_execution, nil), do: nil + + defp resolve_prototype_parent(execution, parent_name) do + constructor = Map.fetch!(execution.globals, parent_name) + {:ok, %Reference{} = prototype} = Property.get(constructor, "prototype", execution) + prototype + end + + defp register_prototype(execution, prototype, %PrototypeSpec{} = topology) do + execution = register_default_prototype(execution, prototype, topology.default_for) + + if topology.error_type, + do: %{ + execution + | error_prototypes: Map.put(execution.error_prototypes, topology.error_type, prototype) + }, + else: execution + end + + defp register_default_prototype(execution, _prototype, nil), do: execution + + defp register_default_prototype(execution, prototype, :function) do + execution = %{ + execution + | default_prototypes: Map.put(execution.default_prototypes, :function, prototype) + } + + Enum.reduce(execution.heap, execution, fn + {id, %{kind: :function, prototype: nil}}, execution -> + {:ok, execution} = + Property.set_prototype(%Reference{id: id}, prototype, execution) + + execution + + {_id, _object}, execution -> + execution + end) + end + + defp register_default_prototype(execution, prototype, kind), + do: %{execution | default_prototypes: Map.put(execution.default_prototypes, kind, prototype)} + + defp put_global(execution, name, value), + do: %{execution | globals: Map.put(execution.globals, name, value)} + + defp validate_registry!(specs, execution) do + names = Enum.map(specs, & &1.name) + + case names -- Enum.uniq(names) do + [] -> + :ok + + duplicates -> + raise ArgumentError, "duplicate builtin specs: #{inspect(Enum.uniq(duplicates))}" + end + + validate_dependencies!(specs, MapSet.new(Map.keys(execution.globals))) + end + + defp validate_dependencies!([], _available), do: :ok + + defp validate_dependencies!([spec | specs], available) do + missing = Enum.reject(spec.depends_on, &MapSet.member?(available, &1)) + + if missing != [] do + raise ArgumentError, + "builtin #{spec.name} has unavailable dependencies: #{inspect(missing)}" + end + + if spec.kind == :intrinsic and not MapSet.member?(available, spec.name) do + raise ArgumentError, "builtin intrinsic #{spec.name} is not installed" + end + + if spec.kind in [:namespace, :function, :constructor] and + MapSet.member?(available, spec.name) do + raise ArgumentError, "builtin #{spec.name} conflicts with an installed global" + end + + validate_dependencies!(specs, MapSet.put(available, spec.name)) + end +end diff --git a/lib/quickbeam/vm/builtin/map.ex b/lib/quickbeam/vm/builtin/map.ex new file mode 100644 index 000000000..869338ebe --- /dev/null +++ b/lib/quickbeam/vm/builtin/map.ex @@ -0,0 +1,307 @@ +defmodule QuickBEAM.VM.Builtin.Map do + @moduledoc "Defines the declarative Map constructor and core methods." + + use QuickBEAM.VM.Builtin + + alias QuickBEAM.VM.Builtin.Call + + alias QuickBEAM.VM.Runtime.Boundary + alias QuickBEAM.VM.Runtime.Heap + alias QuickBEAM.VM.Runtime.Iterator + alias QuickBEAM.VM.Runtime.Object + alias QuickBEAM.VM.Runtime.Property + alias QuickBEAM.VM.Runtime.Reference + alias QuickBEAM.VM.Runtime.Symbol + alias QuickBEAM.VM.Runtime.Value + + builtin "Map", + kind: :constructor, + constructor: :construct, + length: 0, + depends_on: ["Object", "Function", "Symbol"] do + prototype do + method :clear, length: 0 + method :delete, length: 1 + method :entries, length: 0 + method :get, length: 1 + method :has, length: 1 + method :keys, length: 0 + method :set, length: 2 + getter :size + method :values, length: 0 + prototype_alias(Symbol.iterator(), to: "entries") + end + end + + @doc "Constructs a Map from an array-like iterable of key-value pairs." + def construct(%Call{ + this: %Reference{} = receiver, + caller: %Boundary.Constructor{}, + arguments: arguments, + execution: execution + }) do + iterable = List.first(arguments, :undefined) + + with {:ok, entries} <- constructor_entries(iterable, execution), + {:ok, execution} <- initialize(receiver, entries, execution) do + {:ok, receiver, execution} + else + {:error, reason} -> {:error, reason, execution} + {:resumable} -> {:error, :unsupported_map_iterable, execution} + end + end + + def construct(%Call{execution: execution}), + do: {:error, :map_constructor_requires_new, execution} + + @doc "Removes every entry from a Map." + def clear(%Call{this: %Reference{} = map, execution: execution}) do + update_map(map, execution, fn _entries -> [] end, :undefined) + end + + def clear(%Call{execution: execution}), do: incompatible(execution) + + @doc "Removes an entry and reports whether it existed." + def delete(%Call{this: %Reference{} = map, arguments: arguments, execution: execution}) do + key = List.first(arguments, :undefined) + + case entries(map, execution) do + {:ok, entries} -> + found? = Enum.any?(entries, fn {candidate, _value} -> same_key?(candidate, key) end) + retained = Enum.reject(entries, fn {candidate, _value} -> same_key?(candidate, key) end) + update_map(map, execution, fn _entries -> retained end, found?) + + :error -> + incompatible(execution) + end + end + + def delete(%Call{execution: execution}), do: incompatible(execution) + + @doc "Creates an insertion-ordered Map entry iterator." + def entries(%Call{} = call), do: map_iterator(call, :entries) + + @doc "Returns a Map value or undefined when the key is absent." + def get(%Call{this: %Reference{} = map, arguments: arguments, execution: execution}) do + key = List.first(arguments, :undefined) + + case entries(map, execution) do + {:ok, entries} -> + {:ok, find_entry(entries, key), execution} + + :error -> + incompatible(execution) + end + end + + def get(%Call{execution: execution}), do: incompatible(execution) + + @doc "Tests whether a Map contains a key." + def has(%Call{this: %Reference{} = map, arguments: arguments, execution: execution}) do + key = List.first(arguments, :undefined) + + case entries(map, execution) do + {:ok, entries} -> + {:ok, Enum.any?(entries, fn {candidate, _value} -> same_key?(candidate, key) end), + execution} + + :error -> + incompatible(execution) + end + end + + def has(%Call{execution: execution}), do: incompatible(execution) + + @doc "Creates an insertion-ordered Map key iterator." + def keys(%Call{} = call), do: map_iterator(call, :keys) + + @doc "Adds or replaces a Map entry while preserving insertion order." + def set(%Call{this: %Reference{} = map, arguments: arguments, execution: execution}) do + key = Enum.at(arguments, 0, :undefined) + value = Enum.at(arguments, 1, :undefined) + + update_map( + map, + execution, + fn entries -> put_entry(entries, key, value) end, + map + ) + end + + def set(%Call{execution: execution}), do: incompatible(execution) + + @doc "Returns the next result from an internal Map iterator." + def next(%Call{this: %Reference{} = iterator, execution: execution}) do + case Heap.fetch_object(execution, iterator) do + {:ok, %Object{internal: {:map_iterator, entries, index, kind}}} -> + advance_iterator(iterator, entries, index, kind, execution) + + _other -> + {:error, :incompatible_map_iterator_receiver, execution} + end + end + + def next(%Call{execution: execution}), + do: {:error, :incompatible_map_iterator_receiver, execution} + + @doc "Returns the number of entries in a Map." + def size(%Call{this: %Reference{} = map, execution: execution}) do + case entries(map, execution) do + {:ok, entries} -> {:ok, length(entries), execution} + :error -> incompatible(execution) + end + end + + def size(%Call{execution: execution}), do: incompatible(execution) + + @doc "Creates an insertion-ordered Map value iterator." + def values(%Call{} = call), do: map_iterator(call, :values) + + @doc "Initializes a Map receiver from insertion-ordered entries." + def initialize(receiver, entries, execution) do + entries = + entries + |> Enum.reduce([], fn {key, value}, acc -> put_entry_prepend(acc, key, value) end) + |> Enum.reverse() + + Heap.update_object(execution, receiver, fn object -> + %{object | kind: :map, internal: %{entries: entries}} + end) + end + + defp constructor_entries(iterable, _execution) when iterable in [:undefined, nil], do: {:ok, []} + + defp constructor_entries(%Reference{} = iterable, execution) do + case entries(iterable, execution) do + {:ok, entries} -> {:ok, entries} + :error -> iterable |> Iterator.values(execution) |> pair_entries(execution) + end + end + + defp constructor_entries(iterable, execution), + do: iterable |> Iterator.values(execution) |> pair_entries(execution) + + defp pair_entries({:ok, values}, execution) do + case Enum.reduce_while(values, [], &read_pair(&1, &2, execution)) do + :invalid -> {:error, :invalid_map_entry} + entries -> {:ok, Enum.reverse(entries)} + end + end + + defp pair_entries(other, _execution), do: other + + defp read_pair(pair, entries, execution) do + with {:ok, key} <- Property.get(pair, 0, execution), + {:ok, value} <- Property.get(pair, 1, execution) do + {:cont, [{key, value} | entries]} + else + _error -> {:halt, :invalid} + end + end + + defp map_iterator(%Call{this: %Reference{} = map, execution: execution}, kind) do + case entries(map, execution) do + {:ok, entries} -> + {iterator, execution} = + Heap.allocate(execution, :ordinary, internal: {:map_iterator, entries, 0, kind}) + + {next, execution} = Heap.allocate(execution, :function, callable: declared(:next)) + {:ok, execution} = Property.define(iterator, "next", next, execution) + {:ok, iterator, execution} + + :error -> + incompatible(execution) + end + end + + defp map_iterator(%Call{execution: execution}, _kind), do: incompatible(execution) + + defp map_entry_array(key, value, execution) do + {entry, execution} = Heap.allocate(execution, :array) + {:ok, execution} = Property.define(entry, 0, key, execution) + {:ok, execution} = Property.define(entry, 1, value, execution) + {entry, execution} + end + + defp entries(map, execution) do + case Heap.fetch_object(execution, map) do + {:ok, %Object{kind: :map, internal: %{entries: entries}}} -> {:ok, entries} + _other -> :error + end + end + + defp update_map(map, execution, update, result) do + case Heap.update_object(execution, map, fn + %Object{kind: :map, internal: %{entries: entries}} = object -> + %{object | internal: %{entries: update.(entries)}} + + object -> + object + end) do + {:ok, execution} -> + case entries(map, execution) do + {:ok, _entries} -> {:ok, result, execution} + :error -> incompatible(execution) + end + + {:error, _reason} -> + incompatible(execution) + end + end + + defp advance_iterator(iterator, entries, index, kind, execution) do + done? = index >= length(entries) + {value, execution} = iterator_value(entries, index, kind, done?, execution) + next_index = if done?, do: index, else: index + 1 + + {:ok, execution} = + Heap.update_object(execution, iterator, fn object -> + %{object | internal: {:map_iterator, entries, next_index, kind}} + end) + + {result, execution} = Heap.allocate(execution) + {:ok, execution} = Property.define(result, "value", value, execution) + {:ok, execution} = Property.define(result, "done", done?, execution) + {:ok, result, execution} + end + + defp iterator_value(_entries, _index, _kind, true, execution), + do: {:undefined, execution} + + defp iterator_value(entries, index, kind, false, execution) do + {key, value} = Enum.at(entries, index) + + case kind do + :keys -> {key, execution} + :values -> {value, execution} + :entries -> map_entry_array(key, value, execution) + end + end + + defp find_entry(entries, key) do + case Enum.find(entries, fn {candidate, _value} -> same_key?(candidate, key) end) do + {_candidate, value} -> value + nil -> :undefined + end + end + + defp put_entry(entries, key, value) do + case Enum.find_index(entries, fn {candidate, _value} -> same_key?(candidate, key) end) do + nil -> entries ++ [{key, value}] + index -> List.replace_at(entries, index, {key, value}) + end + end + + defp put_entry_prepend(entries, key, value) do + case Enum.find_index(entries, fn {candidate, _value} -> same_key?(candidate, key) end) do + nil -> [{key, value} | entries] + index -> List.replace_at(entries, index, {key, value}) + end + end + + defp same_key?(:nan, :nan), do: true + defp same_key?(left, right), do: Value.strict_equal?(left, right) + + defp declared(handler), do: {:declared_builtin, __MODULE__, handler} + defp incompatible(execution), do: {:error, :incompatible_map_receiver, execution} +end diff --git a/lib/quickbeam/vm/builtin/math.ex b/lib/quickbeam/vm/builtin/math.ex new file mode 100644 index 000000000..6527a39cd --- /dev/null +++ b/lib/quickbeam/vm/builtin/math.ex @@ -0,0 +1,75 @@ +defmodule QuickBEAM.VM.Builtin.Math do + @moduledoc "Defines the declarative core `Math` builtin object." + + use QuickBEAM.VM.Builtin + + alias QuickBEAM.VM.Builtin.Call + alias QuickBEAM.VM.Runtime.Value + + builtin "Math", kind: :namespace, depends_on: ["Object", "Function"] do + constant "E", :math.exp(1) + constant "PI", :math.pi() + + static :floor_value, js: "floor", length: 1 + static :max_value, js: "max", length: 2 + static :min_value, js: "min", length: 2 + static :pow_value, js: "pow", length: 2 + static :random_value, js: "random", length: 0 + static :round_value, js: "round", length: 1 + end + + @doc "Implements `Math.floor`." + def floor_value(%Call{arguments: arguments, execution: execution}) do + value = arguments |> List.first(:undefined) |> Value.to_number() + value = if is_number(value), do: floor(value), else: value + {:ok, value, execution} + end + + @doc "Implements `Math.round`." + def round_value(%Call{arguments: arguments, execution: execution}) do + value = arguments |> List.first(:undefined) |> Value.to_number() + value = if is_number(value), do: round(value), else: value + {:ok, value, execution} + end + + @doc "Implements deterministic `Math.random` for the current VM profile." + def random_value(%Call{execution: execution}), do: {:ok, 0.5, execution} + + @doc "Implements `Math.min`." + def min_value(%Call{arguments: values, execution: execution}), + do: {:ok, numeric_extreme(values, :min), execution} + + @doc "Implements `Math.max`." + def max_value(%Call{arguments: values, execution: execution}), + do: {:ok, numeric_extreme(values, :max), execution} + + @doc "Implements `Math.pow`." + def pow_value(%Call{arguments: arguments, execution: execution}) do + base = Enum.at(arguments, 0, :undefined) + exponent = Enum.at(arguments, 1, :undefined) + {:ok, Value.power(base, exponent), execution} + end + + defp numeric_extreme(values, kind) do + initial = if kind == :min, do: :infinity, else: :neg_infinity + + Enum.reduce_while(values, initial, fn value, result -> + case Value.to_number(value) do + :nan -> {:halt, :nan} + number -> {:cont, extreme(kind, result, number)} + end + end) + end + + defp extreme(:min, :infinity, number), do: number + defp extreme(:min, :neg_infinity, _number), do: :neg_infinity + defp extreme(:min, _result, :neg_infinity), do: :neg_infinity + defp extreme(:min, result, :infinity), do: result + defp extreme(:min, result, number), do: min(result, number) + + defp extreme(:max, :neg_infinity, number), do: number + defp extreme(:max, :infinity, _number), do: :infinity + defp extreme(:max, _result, :infinity), do: :infinity + defp extreme(:max, result, :neg_infinity), do: result + defp extreme(:max, result, number), do: max(result, number) +end diff --git a/lib/quickbeam/vm/builtin/number.ex b/lib/quickbeam/vm/builtin/number.ex new file mode 100644 index 000000000..7e1af28ab --- /dev/null +++ b/lib/quickbeam/vm/builtin/number.ex @@ -0,0 +1,105 @@ +defmodule QuickBEAM.VM.Builtin.Number do + @moduledoc "Defines declarative `Number` prototype methods." + + use QuickBEAM.VM.Builtin + + alias QuickBEAM.VM.Builtin.Call + alias QuickBEAM.VM.Runtime.Boundary + alias QuickBEAM.VM.Runtime.Heap + alias QuickBEAM.VM.Runtime.Object + alias QuickBEAM.VM.Runtime.Reference + alias QuickBEAM.VM.Runtime.Value + + builtin "Number", + kind: :constructor, + constructor: :construct, + length: 1, + depends_on: ["Object", "Function"] do + constant "MAX_VALUE", 1.797_693_134_862_315_7e308 + constant "MIN_VALUE", 5.0e-324 + constant "POSITIVE_INFINITY", :infinity + constant "NEGATIVE_INFINITY", :neg_infinity + + prototype extends: "Object", primitive: {:number, 0} do + method :to_fixed, js: "toFixed", length: 1 + method :to_string_method, js: "toString", length: 1 + end + end + + @doc "Implements Number call and boxed-construction semantics." + def construct(%Call{ + this: %Reference{} = receiver, + caller: %Boundary.Constructor{}, + arguments: arguments, + execution: execution + }) do + value = arguments |> List.first(0) |> Value.to_number() + + {:ok, execution} = + Heap.update_object(execution, receiver, fn object -> + %{object | internal: {:primitive, :number, value}} + end) + + {:ok, receiver, execution} + end + + def construct(%Call{arguments: arguments, execution: execution}) do + value = arguments |> List.first(0) |> Value.to_number() + {:ok, value, execution} + end + + @doc "Implements `Number.prototype.toFixed`." + def to_fixed(%Call{} = call) do + with_number(call, fn value, arguments, execution -> + digits = arguments |> List.first(0) |> Value.to_int32() + + result = + if is_number(value) and digits in 0..100, + do: :erlang.float_to_binary(value / 1, decimals: digits), + else: Value.to_string_value(value) + + {:ok, result, execution} + end) + end + + @doc "Implements `Number.prototype.toString`." + def to_string_method(%Call{} = call) do + with_number(call, fn value, arguments, execution -> + radix = + case arguments do + [] -> 10 + [:undefined | _] -> 10 + [radix | _] -> Value.to_int32(radix) + end + + {:ok, number_to_string(value, radix), execution} + end) + end + + defp with_number(%Call{this: receiver, arguments: arguments, execution: execution}, callback) do + case number_value(receiver, execution) do + {:ok, value} -> callback.(value, arguments, execution) + :error -> {:error, :incompatible_number_receiver, execution} + end + end + + defp number_value(value, _execution) + when is_number(value) or value in [:nan, :infinity, :neg_infinity], + do: {:ok, value} + + defp number_value(%Reference{} = reference, execution) do + case Heap.fetch_object(execution, reference) do + {:ok, %Object{internal: {:primitive, :number, value}}} -> {:ok, value} + _other -> :error + end + end + + defp number_value(_value, _execution), do: :error + + defp number_to_string(value, 10), do: Value.to_string_value(value) + + defp number_to_string(value, radix) when is_integer(value) and radix in 2..36, + do: value |> Integer.to_string(radix) |> String.downcase() + + defp number_to_string(value, _radix), do: Value.to_string_value(value) +end diff --git a/lib/quickbeam/vm/builtin/object.ex b/lib/quickbeam/vm/builtin/object.ex new file mode 100644 index 000000000..8344d3839 --- /dev/null +++ b/lib/quickbeam/vm/builtin/object.ex @@ -0,0 +1,422 @@ +defmodule QuickBEAM.VM.Builtin.Object do + @moduledoc "Defines declarative low-risk and resumable `Object` static methods." + + use QuickBEAM.VM.Builtin + + alias QuickBEAM.VM.Builtin + alias QuickBEAM.VM.Builtin.Array, as: ArrayBuiltin + alias QuickBEAM.VM.Builtin.Call + + alias QuickBEAM.VM.Runtime.Boundary + alias QuickBEAM.VM.Runtime.Heap + alias QuickBEAM.VM.Runtime.Invocation + alias QuickBEAM.VM.Runtime.Property + alias QuickBEAM.VM.Runtime.Property.Descriptor + alias QuickBEAM.VM.Runtime.Reference + alias QuickBEAM.VM.Runtime.Value + + builtin "Object", kind: :constructor, constructor: :construct, length: 1 do + static :assign, length: 2 + static :create, length: 2 + static :define_property, js: "defineProperty", length: 3 + static :define_properties, js: "defineProperties", length: 2 + static :freeze, length: 1 + static :get_own_property_descriptor, js: "getOwnPropertyDescriptor", length: 2 + static :get_own_property_names, js: "getOwnPropertyNames", length: 1 + static :get_prototype_of, js: "getPrototypeOf", length: 1 + static :keys, length: 1 + static :set_prototype_of, js: "setPrototypeOf", length: 2 + + prototype extends: nil, default_for: :ordinary do + method :has_own_property, js: "hasOwnProperty", length: 1 + method :property_is_enumerable, js: "propertyIsEnumerable", length: 1 + method :to_string_method, js: "toString", length: 0 + end + end + + @doc "Implements Object call and construction semantics." + def construct(%Call{arguments: [%Reference{} = value | _], execution: execution}), + do: {:ok, value, execution} + + def construct(%Call{arguments: [value | _], execution: execution}) + when is_map(value) or is_list(value), + do: {:ok, value, execution} + + def construct(%Call{arguments: [value | _], execution: execution}) + when is_boolean(value) or is_number(value) or is_binary(value) do + {kind, constructor_name} = + cond do + is_boolean(value) -> {:boolean, "Boolean"} + is_number(value) -> {:number, "Number"} + true -> {:string, "String"} + end + + constructor = Map.fetch!(execution.globals, constructor_name) + {:ok, prototype} = Property.get(constructor, "prototype", execution) + + {boxed, execution} = + Heap.allocate(execution, :ordinary, + prototype: prototype, + internal: {:primitive, kind, value} + ) + + {:ok, boxed, execution} + end + + def construct(%Call{ + this: %Reference{} = receiver, + caller: %Boundary.Constructor{}, + execution: execution + }), + do: {:ok, receiver, execution} + + def construct(%Call{execution: execution}) do + {object, execution} = Heap.allocate(execution) + {:ok, object, execution} + end + + @doc "Implements `Object.prototype.hasOwnProperty`." + def has_own_property(%Call{ + this: %Reference{} = object, + arguments: arguments, + execution: execution + }) do + key = List.first(arguments, :undefined) + + case Property.own_property(object, key, execution) do + {:ok, property} -> {:ok, not is_nil(property), execution} + {:error, reason} -> {:error, reason, execution} + end + end + + def has_own_property(%Call{execution: execution}), + do: {:error, :incompatible_object_receiver, execution} + + @doc "Implements `Object.prototype.propertyIsEnumerable`." + def property_is_enumerable(%Call{ + this: %Reference{} = object, + arguments: arguments, + execution: execution + }) do + key = List.first(arguments, :undefined) + + case Property.own_property(object, key, execution) do + {:ok, %Descriptor{enumerable: enumerable}} -> {:ok, enumerable, execution} + {:ok, nil} -> {:ok, false, execution} + {:error, reason} -> {:error, reason, execution} + end + end + + def property_is_enumerable(%Call{execution: execution}), + do: {:error, :incompatible_object_receiver, execution} + + @doc "Implements the core `Object.prototype.toString` result." + def to_string_method(%Call{execution: execution}), + do: {:ok, "[object Object]", execution} + + @doc "Plans resumable `Object.assign` property reads and writes." + def assign(%Call{ + arguments: [%Reference{} = target | sources], + caller: caller, + tail?: tail?, + execution: execution + }), + do: Builtin.action({:object_assign, target, sources, caller, execution, tail?}) + + def assign(%Call{execution: execution}), do: {:error, :not_an_object, execution} + + @doc "Implements `Object.create` for null or owner-local prototypes." + def create(%Call{arguments: [prototype | _], execution: execution}) + when is_nil(prototype) or is_struct(prototype, Reference) do + {object, execution} = Heap.allocate(execution, :ordinary, prototype: prototype) + {:ok, object, execution} + end + + def create(%Call{execution: execution}), do: {:error, :invalid_prototype, execution} + + @doc "Implements `Object.defineProperty` with canonical descriptor validation." + def define_property(%Call{ + arguments: [%Reference{} = target, key, descriptor | _], + execution: execution + }) do + with {:ok, current} <- Property.own_property(target, key, execution), + {:ok, definition} <- descriptor_definition(descriptor, current, execution), + {:ok, execution} <- apply_property_definition(execution, target, key, definition) do + {:ok, target, execution} + else + {:error, reason} -> {:error, reason, execution} + end + end + + def define_property(%Call{execution: execution}), + do: {:error, :invalid_property_target, execution} + + @doc "Implements `Object.defineProperties` for ordinary descriptor objects." + def define_properties(%Call{ + arguments: [%Reference{} = target, descriptors | _], + execution: execution + }) do + with {:ok, keys} <- Property.enumerable_keys(descriptors, execution), + {:ok, execution} <- define_properties(keys, target, descriptors, execution) do + {:ok, target, execution} + else + {:error, reason} -> {:error, reason, execution} + end + end + + def define_properties(%Call{execution: execution}), + do: {:error, :invalid_property_target, execution} + + @doc "Marks an object as non-extensible and returns it." + def freeze(%Call{arguments: [%Reference{} = target | _], execution: execution}) do + {:ok, execution} = + Heap.update_object(execution, target, fn object -> %{object | extensible: false} end) + + {:ok, target, execution} + end + + def freeze(%Call{arguments: [target | _], execution: execution}), + do: {:ok, target, execution} + + def freeze(%Call{execution: execution}), do: {:ok, :undefined, execution} + + @doc "Implements `Object.getOwnPropertyDescriptor`." + def get_own_property_descriptor(%Call{ + arguments: [%Reference{} = target, key | _], + execution: execution + }) do + case Property.own_property(target, key, execution) do + {:ok, nil} -> + {:ok, :undefined, execution} + + {:ok, property} -> + {descriptor, execution} = descriptor_object(property, execution) + {:ok, descriptor, execution} + + {:error, reason} -> + {:error, reason, execution} + end + end + + def get_own_property_descriptor(%Call{execution: execution}), + do: {:error, :invalid_property_target, execution} + + @doc "Implements `Object.getOwnPropertyNames`." + def get_own_property_names(%Call{ + arguments: [%Reference{} = target | _], + execution: execution + }) do + case Property.own_property_names(target, execution) do + {:ok, keys} -> + {array, execution} = ArrayBuiltin.from_values(keys, execution) + {:ok, array, execution} + + {:error, reason} -> + {:error, reason, execution} + end + end + + def get_own_property_names(%Call{execution: execution}), + do: {:error, :not_an_object, execution} + + @doc "Implements `Object.getPrototypeOf`." + def get_prototype_of(%Call{arguments: [%Reference{} = target | _], execution: execution}) do + case Property.prototype(target, execution) do + {:ok, prototype} -> {:ok, prototype, execution} + {:error, reason} -> {:error, reason, execution} + end + end + + def get_prototype_of(%Call{arguments: [target | _], execution: execution}) + when is_map(target) or is_list(target) do + kind = if is_list(target), do: :array, else: :ordinary + {:ok, Map.get(execution.default_prototypes, kind), execution} + end + + def get_prototype_of(%Call{execution: execution}), do: {:error, :not_an_object, execution} + + @doc "Implements `Object.keys` with canonical enumerable-key ordering." + def keys(%Call{arguments: [value | _], execution: execution}) do + case own_keys(value, execution) do + {:ok, keys} -> + keys = Enum.map(keys, &Value.to_string_value/1) + {array, execution} = ArrayBuiltin.from_values(keys, execution) + {:ok, array, execution} + + {:error, reason} -> + {:error, reason, execution} + end + end + + def keys(%Call{execution: execution}), do: {:error, :missing_argument, execution} + + @doc "Implements `Object.setPrototypeOf` with owner-local cycle validation." + def set_prototype_of(%Call{ + arguments: [%Reference{} = target, prototype | _], + execution: execution + }) + when is_nil(prototype) or is_struct(prototype, Reference) do + case Property.set_prototype(target, prototype, execution) do + {:ok, execution} -> {:ok, target, execution} + {:error, reason} -> {:error, reason, execution} + end + end + + def set_prototype_of(%Call{execution: execution}), + do: {:error, :invalid_prototype, execution} + + defp define_properties(keys, target, descriptors, execution) do + Enum.reduce_while(keys, {:ok, execution}, fn key, {:ok, execution} -> + with {:ok, descriptor} when not is_tuple(descriptor) <- + Property.get(descriptors, key, execution), + {:ok, current} <- Property.own_property(target, key, execution), + {:ok, definition} <- descriptor_definition(descriptor, current, execution), + {:ok, execution} <- apply_property_definition(execution, target, key, definition) do + {:cont, {:ok, execution}} + else + {:error, reason} -> {:halt, {:error, reason}} + _accessor -> {:halt, {:error, :accessor_property_descriptor}} + end + end) + end + + defp descriptor_definition(descriptor, current, execution) do + with {:ok, getter, getter?} <- descriptor_field(descriptor, "get", execution), + {:ok, setter, setter?} <- descriptor_field(descriptor, "set", execution), + {:ok, value, value?} <- descriptor_field(descriptor, "value", execution), + {:ok, writable, writable?} <- descriptor_field(descriptor, "writable", execution), + {:ok, enumerable, enumerable?} <- descriptor_field(descriptor, "enumerable", execution), + {:ok, configurable, configurable?} <- + descriptor_field(descriptor, "configurable", execution), + :ok <- compatible_descriptor_kinds(getter? or setter?, value? or writable?), + {:ok, getter} <- accessor_function(getter, getter?, execution), + {:ok, setter} <- accessor_function(setter, setter?, execution) do + current = current || %Descriptor{writable: false, enumerable: false, configurable: false} + accessor? = getter? or setter? or (not value? and not writable? and accessor?(current)) + + {:ok, + build_descriptor(accessor?, current, + getter: {getter?, getter}, + setter: {setter?, setter}, + value: {value?, value}, + writable: {writable?, writable}, + enumerable: {enumerable?, enumerable}, + configurable: {configurable?, configurable} + )} + end + end + + defp build_descriptor(true, current, fields) do + %Descriptor{ + kind: :accessor, + value: :undefined, + writable: false, + enumerable: descriptor_boolean(fields[:enumerable], current.enumerable), + configurable: descriptor_boolean(fields[:configurable], current.configurable), + getter: descriptor_value(fields[:getter], current.getter), + setter: descriptor_value(fields[:setter], current.setter) + } + end + + defp build_descriptor(false, current, fields) do + %Descriptor{ + value: descriptor_value(fields[:value], current.value), + writable: descriptor_boolean(fields[:writable], current.writable), + enumerable: descriptor_boolean(fields[:enumerable], current.enumerable), + configurable: descriptor_boolean(fields[:configurable], current.configurable) + } + end + + defp descriptor_value({true, value}, _default), do: value + defp descriptor_value({false, _value}, default), do: default + + defp descriptor_boolean({true, value}, _default), do: Value.truthy?(value) + defp descriptor_boolean({false, _value}, default), do: default + + defp compatible_descriptor_kinds(true, true), do: {:error, :invalid_property_descriptor} + defp compatible_descriptor_kinds(_accessor?, _data?), do: :ok + + defp accessor_function(_value, false, _execution), do: {:ok, nil} + defp accessor_function(:undefined, true, _execution), do: {:ok, nil} + + defp accessor_function(value, true, execution) do + if Invocation.callable?(value, execution), + do: {:ok, value}, + else: {:error, :accessor_not_callable} + end + + defp accessor?(%Descriptor{kind: :accessor}), do: true + defp accessor?(_property), do: false + + defp apply_property_definition(execution, target, key, %Descriptor{} = property) do + if accessor?(property) do + Property.define_descriptor(target, key, property, execution) + else + Property.define(target, key, property.value, execution, + writable: property.writable, + enumerable: property.enumerable, + configurable: property.configurable + ) + end + end + + defp descriptor_field(%Reference{} = descriptor, key, execution) do + if Property.has_property?(descriptor, key, execution) do + case Property.get(descriptor, key, execution) do + {:ok, {:accessor, _getter, _receiver}} -> {:error, :accessor_descriptor_field} + {:ok, value} -> {:ok, value, true} + {:error, reason} -> {:error, reason} + end + else + {:ok, :undefined, false} + end + end + + defp descriptor_field(descriptor, key, _execution) when is_map(descriptor) do + if Map.has_key?(descriptor, key), + do: {:ok, Map.fetch!(descriptor, key), true}, + else: {:ok, :undefined, false} + end + + defp descriptor_field(_descriptor, _key, _execution), do: {:error, :invalid_descriptor} + + defp descriptor_object(property, execution) do + {descriptor, execution} = Heap.allocate(execution) + + fields = + if accessor?(property) do + [ + {"get", property.getter || :undefined}, + {"set", property.setter || :undefined}, + {"enumerable", property.enumerable}, + {"configurable", property.configurable} + ] + else + [ + {"value", property.value}, + {"writable", property.writable}, + {"enumerable", property.enumerable}, + {"configurable", property.configurable} + ] + end + + execution = + Enum.reduce(fields, execution, fn {key, value}, execution -> + {:ok, execution} = Property.define(descriptor, key, value, execution) + execution + end) + + {descriptor, execution} + end + + defp own_keys(%Reference{} = reference, execution), + do: Property.enumerable_keys(reference, execution) + + defp own_keys(value, _execution) when is_map(value), do: {:ok, Map.keys(value)} + defp own_keys([], _execution), do: {:ok, []} + + defp own_keys(value, _execution) when is_list(value), + do: {:ok, Enum.to_list(0..(length(value) - 1))} + + defp own_keys(_value, _execution), do: {:ok, []} +end diff --git a/lib/quickbeam/vm/builtin/promise.ex b/lib/quickbeam/vm/builtin/promise.ex new file mode 100644 index 000000000..962348b1d --- /dev/null +++ b/lib/quickbeam/vm/builtin/promise.ex @@ -0,0 +1,154 @@ +defmodule QuickBEAM.VM.Builtin.Promise do + @moduledoc "Defines the declarative Promise constructor, statics, and reactions." + + use QuickBEAM.VM.Builtin + + alias QuickBEAM.VM.Builtin + alias QuickBEAM.VM.Builtin.Call + + alias QuickBEAM.VM.Runtime.Boundary + alias QuickBEAM.VM.Runtime.Exception + alias QuickBEAM.VM.Runtime.Invocation + alias QuickBEAM.VM.Runtime.Iterator + alias QuickBEAM.VM.Runtime.Promise + + alias QuickBEAM.VM.Runtime.Promise.Reference, as: PromiseReference + + builtin "Promise", + kind: :constructor, + constructor: :construct, + length: 1, + depends_on: ["Object", "Function", "Symbol"] do + static :all, length: 1 + static :all_settled, js: "allSettled", length: 1 + static :any, length: 1 + static :race, length: 1 + static :reject, length: 1 + static :resolve, length: 1 + + prototype do + method :catch_method, js: "catch", length: 1 + method :finally_method, js: "finally", length: 1 + method :then, length: 2 + end + end + + @doc "Constructs a Promise and invokes its executor synchronously." + def construct(%Call{ + arguments: [executor | _], + caller: %Boundary.Constructor{} = caller, + execution: execution + }) do + if Invocation.callable?(executor, execution) do + {promise, execution} = Promise.new(execution) + + boundary = %Boundary.PromiseExecutor{ + promise: promise, + caller: caller, + depth: execution.depth, + tail?: false + } + + resolve = {:promise_resolver, promise, :resolve} + reject = {:promise_resolver, promise, :reject} + + Builtin.action( + {:dispatch, executor, [resolve, reject], :undefined, boundary, execution, false} + ) + else + {:error, :promise_executor_not_callable, execution} + end + end + + def construct(%Call{caller: %Boundary.Constructor{}, execution: execution}), + do: {:error, :missing_promise_executor, execution} + + def construct(%Call{execution: execution}), + do: {:error, :promise_constructor_requires_new, execution} + + @doc "Implements `Promise.resolve`." + def resolve(%Call{arguments: [%PromiseReference{} = promise | _], execution: execution}), + do: {:ok, promise, execution} + + def resolve(%Call{arguments: arguments, execution: execution}) do + {promise, execution} = Promise.new(execution) + value = List.first(arguments, :undefined) + {:ok, promise, Promise.settle(execution, promise, {:ok, value})} + end + + @doc "Implements `Promise.reject`." + def reject(%Call{arguments: arguments, execution: execution}) do + {promise, execution} = Promise.new(execution) + reason = List.first(arguments, :undefined) + {:ok, promise, Promise.settle(execution, promise, {:error, reason})} + end + + @doc "Implements `Promise.all` over the canonical iterable boundary." + def all(%Call{} = call), do: aggregate(:all, call) + + @doc "Implements `Promise.allSettled` over the canonical iterable boundary." + def all_settled(%Call{} = call), do: aggregate(:all_settled, call) + + @doc "Implements `Promise.any` over the canonical iterable boundary." + def any(%Call{} = call), do: aggregate(:any, call) + + @doc "Implements `Promise.race` over the canonical iterable boundary." + def race(%Call{} = call), do: aggregate(:race, call) + + @doc "Implements `Promise.prototype.then`." + def then(%Call{this: %PromiseReference{} = source, arguments: arguments, execution: execution}) do + on_fulfilled = Enum.at(arguments, 0, :undefined) + on_rejected = Enum.at(arguments, 1, :undefined) + {result, execution} = Promise.react(execution, source, on_fulfilled, on_rejected) + {:ok, result, execution} + end + + def then(%Call{execution: execution}), do: {:error, :incompatible_promise_receiver, execution} + + @doc "Implements `Promise.prototype.catch`." + def catch_method(%Call{ + this: %PromiseReference{} = source, + arguments: arguments, + execution: execution + }) do + on_rejected = Enum.at(arguments, 0, :undefined) + {result, execution} = Promise.react(execution, source, :undefined, on_rejected) + {:ok, result, execution} + end + + def catch_method(%Call{execution: execution}), + do: {:error, :incompatible_promise_receiver, execution} + + @doc "Implements `Promise.prototype.finally`." + def finally_method(%Call{ + this: %PromiseReference{} = source, + arguments: arguments, + execution: execution + }) do + callback = Enum.at(arguments, 0, :undefined) + {result, execution} = Promise.finally(execution, source, callback) + {:ok, result, execution} + end + + def finally_method(%Call{execution: execution}), + do: {:error, :incompatible_promise_receiver, execution} + + defp aggregate(kind, %Call{arguments: arguments, execution: execution} = call) do + iterable = List.first(arguments, :undefined) + + case Iterator.values(iterable, execution) do + {:ok, values} -> + {promise, execution} = Promise.aggregate(execution, kind, values) + {:ok, promise, execution} + + {:resumable} -> + Builtin.action({:promise_iterate, kind, iterable, call.caller, execution, call.tail?}) + + {:error, :not_iterable} -> + {reason, execution} = Exception.materialize({:type_error, :not_iterable}, execution) + {promise, execution} = Promise.new(execution) + execution = Promise.settle(execution, promise, {:error, reason}) + {:ok, promise, execution} + end + end +end diff --git a/lib/quickbeam/vm/builtin/registry.ex b/lib/quickbeam/vm/builtin/registry.ex new file mode 100644 index 000000000..ced5df1fe --- /dev/null +++ b/lib/quickbeam/vm/builtin/registry.ex @@ -0,0 +1,120 @@ +defmodule QuickBEAM.VM.Builtin.Registry do + @moduledoc """ + Discovers builtin modules from QuickBEAM's compiled application manifest. + + Mix writes the complete module inventory into the `:quickbeam` application + specification at compile time. Discovery filters that inventory for modules + exporting `builtin_spec/0`, then orders their immutable specs by declared + JavaScript dependencies. It does not depend on module loading order. + + The resulting profile registry is cached in `:persistent_term` and versions + the immutable host templates shared structurally by isolated evaluations. + """ + + alias QuickBEAM.VM.Builtin.Spec + + @application :quickbeam + @cache_key {__MODULE__, :profiles} + @generation_key {__MODULE__, :generation} + @profiles [:core, :ssr] + + @doc "Returns discovered builtin modules in deterministic dependency order." + @spec modules(:core | :ssr) :: [module()] + def modules(profile) when profile in @profiles do + registry() + |> Map.fetch!(profile) + |> Enum.map(& &1.module) + end + + @doc "Returns the cache generation for the current discovered registry." + @spec generation() :: non_neg_integer() + def generation do + _registry = registry() + :persistent_term.get(@generation_key, 0) + end + + @doc "Refreshes and returns the builtin registry from the compiled application manifest." + @spec refresh() :: %{required(:core) => [Spec.t()], required(:ssr) => [Spec.t()]} + def refresh do + specs = discover_specs() + validate_unique_names!(specs) + + registry = + Map.new(@profiles, fn profile -> + selected = Enum.filter(specs, &profile_enabled?(&1, profile)) + {profile, dependency_order(selected, profile)} + end) + + generation = :persistent_term.get(@generation_key, 0) + 1 + :persistent_term.put(@cache_key, registry) + :persistent_term.put(@generation_key, generation) + registry + end + + defp registry do + case :persistent_term.get(@cache_key, :missing) do + :missing -> refresh() + registry -> registry + end + end + + defp discover_specs do + @application + |> Application.spec(:modules) + |> List.wrap() + |> Enum.filter(&builtin_module?/1) + |> Enum.map(& &1.builtin_spec()) + |> Enum.sort_by(&{&1.name, &1.module}) + end + + defp validate_unique_names!(specs) do + duplicates = + specs + |> Enum.group_by(& &1.name) + |> Enum.filter(fn {_name, definitions} -> length(definitions) > 1 end) + |> Map.new(fn {name, definitions} -> {name, Enum.map(definitions, & &1.module)} end) + + if map_size(duplicates) > 0 do + raise ArgumentError, "duplicate discovered builtin specs: #{inspect(duplicates)}" + end + end + + defp builtin_module?(module) do + Code.ensure_loaded?(module) and function_exported?(module, :builtin_spec, 0) + end + + defp profile_enabled?(%Spec{profiles: profiles}, :core), do: :core in profiles + + defp profile_enabled?(%Spec{profiles: profiles}, profile), + do: :core in profiles or profile in profiles + + defp dependency_order(specs, profile), + do: dependency_order(specs, profile, MapSet.new(), []) + + defp dependency_order([], _profile, _available, ordered), do: Enum.reverse(ordered) + + defp dependency_order(specs, profile, available, ordered) do + {ready, blocked} = + Enum.split_with(specs, fn spec -> + Enum.all?(spec.depends_on, &MapSet.member?(available, &1)) + end) + + case ready do + [] -> + unresolved = + Map.new(blocked, fn spec -> + missing = Enum.reject(spec.depends_on, &MapSet.member?(available, &1)) + {spec.name, missing} + end) + + raise ArgumentError, + "cannot order #{inspect(profile)} builtin profile; " <> + "missing or cyclic dependencies: #{inspect(unresolved)}" + + ready -> + ready = Enum.sort_by(ready, &{&1.name, &1.module}) + available = Enum.reduce(ready, available, &MapSet.put(&2, &1.name)) + dependency_order(blocked, profile, available, Enum.reverse(ready, ordered)) + end + end +end diff --git a/lib/quickbeam/vm/builtin/runtime.ex b/lib/quickbeam/vm/builtin/runtime.ex new file mode 100644 index 000000000..aa69c9bcd --- /dev/null +++ b/lib/quickbeam/vm/builtin/runtime.ex @@ -0,0 +1,162 @@ +defmodule QuickBEAM.VM.Builtin.Runtime do + @moduledoc """ + Installs and dispatches the JavaScript built-ins supported by the VM profile. + """ + + alias QuickBEAM.VM.Runtime.Heap + alias QuickBEAM.VM.Runtime.Object + alias QuickBEAM.VM.Runtime.Property + alias QuickBEAM.VM.Runtime.Reference + alias QuickBEAM.VM.Runtime.RegExp + alias QuickBEAM.VM.Runtime.State + alias QuickBEAM.VM.Runtime.Value + + alias QuickBEAM.VM.Builtin.Installer + alias QuickBEAM.VM.Builtin.Registry + + @doc "Installs all builtins enabled by an immutable runtime profile." + @spec install(State.t(), :core | :ssr) :: State.t() + def install(execution, profile \\ :core), + do: Installer.install_all(execution, Registry.modules(profile), profile) + + @doc "Returns the callable token stored on an object reference, if present." + @spec callable(State.t(), Reference.t()) :: term() | nil + def callable(execution, %Reference{} = reference) do + case Heap.fetch_object(execution, reference) do + {:ok, %Object{callable: callable}} -> callable + :error -> nil + end + end + + @doc "Allocates a catchable JavaScript error object in the current evaluation heap." + @spec new_error(State.t(), String.t(), String.t() | nil) :: {Reference.t(), State.t()} + def new_error(execution, name, message) do + prototype = + Map.get(execution.error_prototypes, name) || Map.get(execution.error_prototypes, "Error") + + {error, execution} = + Heap.allocate(execution, :ordinary, prototype: prototype, internal: {:error, name}) + + execution = + if is_nil(message) do + execution + else + {:ok, execution} = + Property.define(error, "message", message, execution, + enumerable: false, + configurable: true, + writable: true + ) + + execution + end + + {error, execution} + end + + @doc "Dispatches a primitive builtin method through canonical runtime semantics." + @spec call(term(), term(), [term()], State.t()) :: + {:ok, term(), State.t()} | {:error, term(), State.t()} + def call( + {:primitive_method, :regexp, "exec"}, + %Reference{} = reference, + [value | _], + execution + ) do + with {:ok, %Object{kind: :regexp, internal: %RegExp{} = regexp}} <- + Heap.fetch_object(execution, reference), + {:ok, regex} <- compile_regexp(regexp) do + case Regex.run(regex, Value.to_string_value(value), return: :index) do + [{index, length} | _captures] -> + matched = value |> Value.to_string_value() |> binary_part(index, length) + {result, execution} = Heap.allocate(execution, :array) + {:ok, execution} = Property.define(result, 0, matched, execution) + {:ok, execution} = Property.define(result, "index", index, execution) + {:ok, result, execution} + + nil -> + {:ok, nil, execution} + end + else + _other -> {:error, :incompatible_regexp_receiver, execution} + end + end + + def call( + {:primitive_method, :regexp, "test"}, + %Reference{} = reference, + [value | _], + execution + ) do + with {:ok, %Object{kind: :regexp, internal: %RegExp{} = regexp}} <- + Heap.fetch_object(execution, reference), + {:ok, last_index} <- Property.get(reference, "lastIndex", execution) do + {matched?, next_index} = + regex_match_from(regexp, Value.to_string_value(value), last_index) + + {:ok, execution} = Property.put(reference, "lastIndex", next_index, execution) + {:ok, matched?, execution} + else + _other -> {:error, :incompatible_regexp_receiver, execution} + end + end + + def call({:primitive_method, :regexp, "test"}, %RegExp{} = regexp, [value | _], execution), + do: {:ok, regex_match?(regexp, Value.to_string_value(value)), execution} + + def call(callable, _this, _arguments, execution), + do: {:error, {:unsupported_builtin, callable}, execution} + + defp regex_match_from(%RegExp{} = regexp, value, last_index) do + stateful? = regexp_flag?(regexp, 1) or regexp_flag?(regexp, 32) + current_index = normalized_last_index(last_index) + start = if stateful?, do: current_index, else: 0 + + case compile_regexp(regexp) do + {:ok, regex} -> run_regexp(regex, value, start, current_index, stateful?) + {:error, _reason} -> {false, reset_index(current_index, stateful?)} + end + end + + defp run_regexp(regex, value, start, current_index, stateful?) do + case Regex.run(regex, value, offset: start, return: :index) do + [{index, length} | _captures] -> + next_index = if stateful?, do: index + max(length, 1), else: current_index + {true, next_index} + + nil -> + {false, reset_index(current_index, stateful?)} + end + end + + defp normalized_last_index(index) when is_integer(index) and index >= 0, do: index + defp normalized_last_index(_index), do: 0 + + defp reset_index(_current_index, true), do: 0 + defp reset_index(current_index, false), do: current_index + + defp regex_match?(%RegExp{} = regexp, value) do + case compile_regexp(regexp) do + {:ok, regex} -> Regex.match?(regex, value) + {:error, _} -> false + end + end + + defp compile_regexp(%RegExp{source: source} = regexp) do + options = + "" + |> maybe_regex_option(regexp_flag?(regexp, 2), "i") + |> maybe_regex_option(regexp_flag?(regexp, 4), "m") + |> maybe_regex_option(regexp_flag?(regexp, 8), "s") + + Regex.compile(source, options) + end + + defp regexp_flag?(%RegExp{bytecode: <>}, flag), + do: Bitwise.band(flags, flag) != 0 + + defp regexp_flag?(_regexp, _flag), do: false + + defp maybe_regex_option(options, true, option), do: options <> option + defp maybe_regex_option(options, false, _option), do: options +end diff --git a/lib/quickbeam/vm/builtin/set.ex b/lib/quickbeam/vm/builtin/set.ex new file mode 100644 index 000000000..2fd1cf8a6 --- /dev/null +++ b/lib/quickbeam/vm/builtin/set.ex @@ -0,0 +1,245 @@ +defmodule QuickBEAM.VM.Builtin.Set do + @moduledoc "Defines the declarative Set constructor, methods, and iterator alias." + + use QuickBEAM.VM.Builtin + + alias QuickBEAM.VM.Builtin + alias QuickBEAM.VM.Builtin.Call + + alias QuickBEAM.VM.Runtime.Boundary + alias QuickBEAM.VM.Runtime.Heap + alias QuickBEAM.VM.Runtime.Iterator + alias QuickBEAM.VM.Runtime.Object + alias QuickBEAM.VM.Runtime.Property + alias QuickBEAM.VM.Runtime.Reference + alias QuickBEAM.VM.Runtime.Symbol + alias QuickBEAM.VM.Runtime.Value + + builtin "Set", + kind: :constructor, + constructor: :construct, + length: 0, + depends_on: ["Object", "Function", "Symbol"] do + prototype do + method :add, length: 1 + method :delete, length: 1 + method :has, length: 1 + method :values, length: 0 + getter :size + prototype_alias(Symbol.iterator(), to: "values") + end + end + + @doc "Constructs an insertion-ordered Set from a supported iterable." + def construct( + %Call{ + this: %Reference{} = receiver, + caller: %Boundary.Constructor{}, + arguments: arguments, + execution: execution + } = call + ) do + iterable = List.first(arguments, :undefined) + + case constructor_values(iterable, execution) do + {:ok, values} -> + case initialize(receiver, values, execution) do + {:ok, execution} -> {:ok, receiver, execution} + {:error, reason} -> {:error, reason, execution} + end + + {:resumable} -> + Builtin.action({:set_iterate, receiver, iterable, call.caller, execution, call.tail?}) + + {:error, reason} -> + {:error, reason, execution} + end + end + + def construct(%Call{execution: execution}), + do: {:error, :set_constructor_requires_new, execution} + + @doc "Adds a value while preserving Set insertion order." + def add(%Call{this: %Reference{} = set, arguments: arguments, execution: execution}) do + value = List.first(arguments, :undefined) + + case Heap.update_object(execution, set, &add_value(&1, value)) do + {:ok, execution} -> + case Heap.fetch_object(execution, set) do + {:ok, %Object{kind: :set}} -> {:ok, set, execution} + _other -> {:error, :incompatible_set_receiver, execution} + end + + {:error, reason} -> + {:error, reason, execution} + end + end + + def add(%Call{execution: execution}), do: {:error, :incompatible_set_receiver, execution} + + @doc "Removes a value and reports whether it was present." + def delete(%Call{this: %Reference{} = set, arguments: arguments, execution: execution}) do + value = List.first(arguments, :undefined) + + case Heap.fetch_object(execution, set) do + {:ok, %Object{kind: :set, internal: %{values: values, index: index}}} -> + key = set_key(value) + found? = MapSet.member?(index, key) + + {:ok, execution} = + Heap.update_object(execution, set, fn object -> + %{ + object + | internal: %{ + values: Enum.reject(values, &same_value_zero?(&1, value)), + index: MapSet.delete(index, key) + } + } + end) + + {:ok, found?, execution} + + _other -> + {:error, :incompatible_set_receiver, execution} + end + end + + def delete(%Call{execution: execution}), do: {:error, :incompatible_set_receiver, execution} + + @doc "Tests membership in a Set." + def has(%Call{this: %Reference{} = set, arguments: arguments, execution: execution}) do + value = List.first(arguments, :undefined) + + case Heap.fetch_object(execution, set) do + {:ok, %Object{kind: :set, internal: %{index: entries}}} -> + {:ok, MapSet.member?(entries, set_key(value)), execution} + + _other -> + {:error, :incompatible_set_receiver, execution} + end + end + + def has(%Call{execution: execution}), do: {:error, :incompatible_set_receiver, execution} + + @doc "Returns the number of unique Set entries." + def size(%Call{this: %Reference{} = set, execution: execution}) do + case Heap.fetch_object(execution, set) do + {:ok, %Object{kind: :set, internal: %{values: values}}} -> + {:ok, length(values), execution} + + _other -> + {:error, :incompatible_set_receiver, execution} + end + end + + def size(%Call{execution: execution}), do: {:error, :incompatible_set_receiver, execution} + + @doc "Creates an insertion-ordered Set value iterator." + def values(%Call{this: %Reference{} = set, execution: execution}) do + case Heap.fetch_object(execution, set) do + {:ok, %Object{kind: :set, internal: %{values: values}}} -> + {iterator, execution} = + Heap.allocate(execution, :ordinary, internal: {:set_iterator, values, 0}) + + {next, execution} = Heap.allocate(execution, :function, callable: declared(:next)) + + {:ok, execution} = + Property.define(next, "name", "next", execution, + writable: false, + enumerable: false, + configurable: true + ) + + {:ok, execution} = + Property.define(next, "length", 0, execution, + writable: false, + enumerable: false, + configurable: true + ) + + {:ok, execution} = + Property.define(iterator, "next", next, execution, + writable: true, + enumerable: false, + configurable: true + ) + + {:ok, iterator, execution} + + _other -> + {:error, :incompatible_set_receiver, execution} + end + end + + def values(%Call{execution: execution}), do: {:error, :incompatible_set_receiver, execution} + + @doc "Advances one Set value iterator." + def next(%Call{this: %Reference{} = iterator, execution: execution}) do + case Heap.fetch_object(execution, iterator) do + {:ok, %Object{internal: {:set_iterator, values, index}}} -> + done? = index >= length(values) + value = if done?, do: :undefined, else: Enum.at(values, index) + + {:ok, execution} = + Heap.update_object(execution, iterator, fn object -> + %{object | internal: {:set_iterator, values, if(done?, do: index, else: index + 1)}} + end) + + {result, execution} = Heap.allocate(execution) + {:ok, execution} = Property.define(result, "value", value, execution) + {:ok, execution} = Property.define(result, "done", done?, execution) + {:ok, result, execution} + + _other -> + {:error, :incompatible_set_iterator_receiver, execution} + end + end + + def next(%Call{execution: execution}), + do: {:error, :incompatible_set_iterator_receiver, execution} + + defp constructor_values(iterable, _execution) when iterable in [:undefined, nil], do: {:ok, []} + + defp constructor_values(iterable, execution), do: Iterator.values(iterable, execution) + + @doc "Initializes a Set receiver from values in iteration order." + def initialize(receiver, values, execution) do + {reversed_values, index} = + Enum.reduce(values, {[], MapSet.new()}, fn value, {values, index} -> + key = set_key(value) + + if MapSet.member?(index, key), + do: {values, index}, + else: {[value | values], MapSet.put(index, key)} + end) + + Heap.update_object(execution, receiver, fn object -> + %{object | kind: :set, internal: %{values: Enum.reverse(reversed_values), index: index}} + end) + end + + defp add_value(%Object{kind: :set, internal: %{values: values, index: index}} = object, value) do + key = set_key(value) + + if MapSet.member?(index, key), + do: object, + else: %{object | internal: %{values: values ++ [value], index: MapSet.put(index, key)}} + end + + defp add_value(object, _value), do: object + + defp set_key(:nan), do: {:number, :nan} + defp set_key(value) when is_integer(value), do: {:number, value} + defp set_key(value) when is_float(value) and value == 0.0, do: {:number, 0} + + defp set_key(value) when is_float(value) and trunc(value) == value, + do: {:number, trunc(value)} + + defp set_key(value) when is_float(value), do: {:number, value} + defp set_key(value), do: value + + defp same_value_zero?(:nan, :nan), do: true + defp same_value_zero?(left, right), do: Value.strict_equal?(left, right) + + defp declared(handler), do: {:declared_builtin, __MODULE__, handler} +end diff --git a/lib/quickbeam/vm/builtin/spec.ex b/lib/quickbeam/vm/builtin/spec.ex new file mode 100644 index 000000000..b67627c1b --- /dev/null +++ b/lib/quickbeam/vm/builtin/spec.ex @@ -0,0 +1,38 @@ +defmodule QuickBEAM.VM.Builtin.Spec do + @moduledoc """ + Defines immutable, compile-time-validated metadata for one JavaScript builtin. + + Specs are installed deterministically into each owner-local execution. They do + not contain heap references or captured functions. + """ + + alias QuickBEAM.VM.Builtin.Spec.{Accessor, Alias, Function, Property, Prototype} + + @enforce_keys [:name, :module, :kind] + defstruct [ + :name, + :module, + :constructor, + prototype_spec: %Prototype{}, + profiles: [:core], + depends_on: [], + kind: :namespace, + length: 0, + statics: [], + prototype: [] + ] + + @type kind :: :namespace | :function | :constructor | :intrinsic + @type t :: %__MODULE__{ + name: String.t(), + module: module(), + kind: kind(), + constructor: atom() | nil, + prototype_spec: Prototype.t(), + profiles: [atom()], + depends_on: [String.t()], + length: non_neg_integer(), + statics: [Function.t() | Property.t() | Accessor.t() | Alias.t()], + prototype: [Function.t() | Property.t() | Accessor.t() | Alias.t()] + } +end diff --git a/lib/quickbeam/vm/builtin/spec/accessor.ex b/lib/quickbeam/vm/builtin/spec/accessor.ex new file mode 100644 index 000000000..dd37c1672 --- /dev/null +++ b/lib/quickbeam/vm/builtin/spec/accessor.ex @@ -0,0 +1,14 @@ +defmodule QuickBEAM.VM.Builtin.Spec.Accessor do + @moduledoc "Defines a declarative JavaScript builtin accessor property." + + @enforce_keys [:key] + defstruct [:key, :getter, :setter, enumerable: false, configurable: true] + + @type t :: %__MODULE__{ + key: term(), + getter: atom() | nil, + setter: atom() | nil, + enumerable: boolean(), + configurable: boolean() + } +end diff --git a/lib/quickbeam/vm/builtin/spec/alias.ex b/lib/quickbeam/vm/builtin/spec/alias.ex new file mode 100644 index 000000000..363f3b26f --- /dev/null +++ b/lib/quickbeam/vm/builtin/spec/alias.ex @@ -0,0 +1,14 @@ +defmodule QuickBEAM.VM.Builtin.Spec.Alias do + @moduledoc "Defines a builtin property that aliases another property on the same object." + + @enforce_keys [:key, :target] + defstruct [:key, :target, writable: true, enumerable: false, configurable: true] + + @type t :: %__MODULE__{ + key: term(), + target: term(), + writable: boolean(), + enumerable: boolean(), + configurable: boolean() + } +end diff --git a/lib/quickbeam/vm/builtin/spec/function.ex b/lib/quickbeam/vm/builtin/spec/function.ex new file mode 100644 index 000000000..f78a0eb93 --- /dev/null +++ b/lib/quickbeam/vm/builtin/spec/function.ex @@ -0,0 +1,15 @@ +defmodule QuickBEAM.VM.Builtin.Spec.Function do + @moduledoc "Defines a declarative JavaScript builtin function property." + + @enforce_keys [:key, :handler] + defstruct [:key, :handler, length: 0, writable: true, enumerable: false, configurable: true] + + @type t :: %__MODULE__{ + key: term(), + handler: atom(), + length: non_neg_integer(), + writable: boolean(), + enumerable: boolean(), + configurable: boolean() + } +end diff --git a/lib/quickbeam/vm/builtin/spec/property.ex b/lib/quickbeam/vm/builtin/spec/property.ex new file mode 100644 index 000000000..01593f790 --- /dev/null +++ b/lib/quickbeam/vm/builtin/spec/property.ex @@ -0,0 +1,14 @@ +defmodule QuickBEAM.VM.Builtin.Spec.Property do + @moduledoc "Defines a declarative JavaScript builtin data property." + + @enforce_keys [:key, :value] + defstruct [:key, :value, writable: false, enumerable: false, configurable: false] + + @type t :: %__MODULE__{ + key: term(), + value: term(), + writable: boolean(), + enumerable: boolean(), + configurable: boolean() + } +end diff --git a/lib/quickbeam/vm/builtin/spec/prototype.ex b/lib/quickbeam/vm/builtin/spec/prototype.ex new file mode 100644 index 000000000..18666d5de --- /dev/null +++ b/lib/quickbeam/vm/builtin/spec/prototype.ex @@ -0,0 +1,19 @@ +defmodule QuickBEAM.VM.Builtin.Spec.Prototype do + @moduledoc "Defines semantic topology for one JavaScript intrinsic prototype." + + defstruct kind: :ordinary, + extends: :default, + default_for: nil, + callable: nil, + primitive: nil, + error_type: nil + + @type t :: %__MODULE__{ + kind: :ordinary | :array | :function, + extends: :default | nil | String.t(), + default_for: atom() | nil, + callable: atom() | nil, + primitive: {atom(), term()} | nil, + error_type: String.t() | nil + } +end diff --git a/lib/quickbeam/vm/builtin/string.ex b/lib/quickbeam/vm/builtin/string.ex new file mode 100644 index 000000000..03aff2f5f --- /dev/null +++ b/lib/quickbeam/vm/builtin/string.ex @@ -0,0 +1,232 @@ +defmodule QuickBEAM.VM.Builtin.String do + @moduledoc "Defines the declarative core `String` static and prototype methods." + + use QuickBEAM.VM.Builtin + + alias QuickBEAM.VM.Builtin.Array, as: ArrayBuiltin + alias QuickBEAM.VM.Builtin.Call + alias QuickBEAM.VM.Runtime.Boundary + alias QuickBEAM.VM.Runtime.Heap + alias QuickBEAM.VM.Runtime.Object + alias QuickBEAM.VM.Runtime.Reference + alias QuickBEAM.VM.Runtime.RegExp + alias QuickBEAM.VM.Runtime.Value + + builtin "String", + kind: :constructor, + constructor: :construct, + length: 1, + depends_on: ["Object", "Function"] do + static :from_char_code, js: "fromCharCode", length: 1 + + prototype extends: "Object", primitive: {:string, ""} do + method :char_at, js: "charAt", length: 1 + method :char_code_at, js: "charCodeAt", length: 1 + method :concat, length: 1 + method :ends_with, js: "endsWith", length: 1 + method :includes, length: 1 + method :index_of, js: "indexOf", length: 1 + method :replace, length: 2 + method :slice, length: 2 + method :split, length: 2 + method :starts_with, js: "startsWith", length: 1 + method :substring, length: 2 + method :to_lower_case, js: "toLowerCase", length: 0 + method :to_string_method, js: "toString", length: 0 + method :trim, length: 0 + end + end + + @doc "Implements String call and boxed-construction semantics." + def construct(%Call{ + this: %Reference{} = receiver, + caller: %Boundary.Constructor{}, + arguments: arguments, + execution: execution + }) do + value = arguments |> List.first("") |> Value.to_string_value() + + {:ok, execution} = + Heap.update_object(execution, receiver, fn object -> + %{object | internal: {:primitive, :string, value}} + end) + + {:ok, receiver, execution} + end + + def construct(%Call{arguments: arguments, execution: execution}) do + value = arguments |> List.first("") |> Value.to_string_value() + {:ok, value, execution} + end + + @doc "Implements `String.fromCharCode`." + def from_char_code(%Call{arguments: values, execution: execution}), + do: {:ok, Value.string_from_char_codes(values), execution} + + @doc "Implements UTF-16 `String.prototype.charAt`." + def char_at(%Call{} = call) do + with_string(call, fn value, arguments, execution -> + index = arguments |> List.first(0) |> Value.to_int32() + + result = + case Value.string_at(value, index) do + :undefined -> "" + character -> character + end + + {:ok, result, execution} + end) + end + + @doc "Implements UTF-16 `String.prototype.charCodeAt`." + def char_code_at(%Call{} = call) do + with_string(call, fn value, arguments, execution -> + index = arguments |> List.first(0) |> Value.to_int32() + {:ok, Value.string_char_code_at(value, index), execution} + end) + end + + @doc "Implements `String.prototype.concat`." + def concat(%Call{} = call) do + with_string(call, fn value, arguments, execution -> + suffix = Enum.map_join(arguments, &Value.to_string_value/1) + {:ok, value <> suffix, execution} + end) + end + + @doc "Implements `String.prototype.endsWith`." + def ends_with(%Call{} = call) do + with_string(call, fn value, arguments, execution -> + suffix = arguments |> List.first(:undefined) |> Value.to_string_value() + {:ok, String.ends_with?(value, suffix), execution} + end) + end + + @doc "Implements `String.prototype.includes`." + def includes(%Call{} = call) do + with_string(call, fn value, arguments, execution -> + part = arguments |> List.first(:undefined) |> Value.to_string_value() + {:ok, String.contains?(value, part), execution} + end) + end + + @doc "Implements `String.prototype.indexOf`." + def index_of(%Call{} = call) do + with_string(call, fn value, arguments, execution -> + searched = arguments |> List.first(:undefined) |> Value.to_string_value() + + index = + case :binary.match(value, searched) do + {index, _length} -> index + :nomatch -> -1 + end + + {:ok, index, execution} + end) + end + + @doc "Implements `String.prototype.replace` for string and RegExp patterns." + def replace(%Call{} = call) do + with_string(call, fn value, arguments, execution -> + pattern = Enum.at(arguments, 0, :undefined) + replacement = arguments |> Enum.at(1, :undefined) |> Value.to_string_value() + {:ok, replace_string(value, pattern, replacement), execution} + end) + end + + @doc "Implements UTF-16 `String.prototype.slice`." + def slice(%Call{} = call) do + with_string(call, fn value, arguments, execution -> + {start, length} = Value.slice_range(Value.string_length(value), arguments) + {:ok, Value.string_slice(value, start, length), execution} + end) + end + + @doc "Implements `String.prototype.split`." + def split(%Call{} = call) do + with_string(call, fn value, arguments, execution -> + parts = + case arguments do + [] -> [value] + [:undefined | _] -> [value] + [separator | _] -> String.split(value, Value.to_string_value(separator)) + end + + {array, execution} = ArrayBuiltin.from_values(parts, execution) + {:ok, array, execution} + end) + end + + @doc "Implements `String.prototype.startsWith`." + def starts_with(%Call{} = call) do + with_string(call, fn value, arguments, execution -> + prefix = arguments |> List.first(:undefined) |> Value.to_string_value() + {:ok, String.starts_with?(value, prefix), execution} + end) + end + + @doc "Implements UTF-16 `String.prototype.substring`." + def substring(%Call{} = call) do + with_string(call, fn value, arguments, execution -> + size = Value.string_length(value) + start = arguments |> Enum.at(0, 0) |> substring_index(size) + finish = arguments |> Enum.at(1, size) |> substring_index(size) + {start, finish} = if start <= finish, do: {start, finish}, else: {finish, start} + {:ok, Value.string_slice(value, start, finish - start), execution} + end) + end + + @doc "Implements `String.prototype.toLowerCase`." + def to_lower_case(%Call{} = call), + do: + with_string(call, fn value, _arguments, execution -> + {:ok, String.downcase(value), execution} + end) + + @doc "Implements `String.prototype.trim`." + def trim(%Call{} = call), + do: + with_string(call, fn value, _arguments, execution -> + {:ok, String.trim(value), execution} + end) + + @doc "Implements `String.prototype.toString` with receiver validation." + def to_string_method(%Call{} = call), + do: with_string(call, fn value, _arguments, execution -> {:ok, value, execution} end) + + defp with_string(%Call{this: receiver, arguments: arguments, execution: execution}, callback) do + case string_value(receiver, execution) do + {:ok, value} -> callback.(value, arguments, execution) + :error -> {:error, :incompatible_string_receiver, execution} + end + end + + defp string_value(value, _execution) when is_binary(value), do: {:ok, value} + + defp string_value(%Reference{} = reference, execution) do + case Heap.fetch_object(execution, reference) do + {:ok, %Object{internal: {:primitive, :string, value}}} -> {:ok, value} + _other -> :error + end + end + + defp string_value(_value, _execution), do: :error + + defp substring_index(value, size) do + case Value.to_number(value) do + :infinity -> size + number when is_number(number) -> number |> trunc() |> max(0) |> min(size) + _other -> 0 + end + end + + defp replace_string(value, %RegExp{source: source}, replacement) do + case Regex.compile(source) do + {:ok, regex} -> Regex.replace(regex, value, replacement) + {:error, _reason} -> value + end + end + + defp replace_string(value, pattern, replacement), + do: String.replace(value, Value.to_string_value(pattern), replacement, global: false) +end diff --git a/lib/quickbeam/vm/builtin/symbol.ex b/lib/quickbeam/vm/builtin/symbol.ex new file mode 100644 index 000000000..83598faca --- /dev/null +++ b/lib/quickbeam/vm/builtin/symbol.ex @@ -0,0 +1,34 @@ +defmodule QuickBEAM.VM.Builtin.Symbol do + @moduledoc "Defines the well-known symbols exposed by the core VM profile." + + use QuickBEAM.VM.Builtin + + alias QuickBEAM.VM.Builtin.Call + alias QuickBEAM.VM.Runtime.Symbol + alias QuickBEAM.VM.Runtime.Value + + builtin "Symbol", kind: :function, length: 0, depends_on: ["Object", "Function"] do + static :for_symbol, js: "for", length: 1 + constant "iterator", Symbol.iterator() + end + + @doc "Returns the evaluation-stable global symbol for a string key." + def for_symbol(%Call{arguments: arguments, execution: execution}) do + key = arguments |> List.first(:undefined) |> Value.to_string_value() + {:ok, %Symbol{id: {:global, key}, description: key}, execution} + end + + @doc "Creates a fresh owner-local Symbol value." + def call(%Call{arguments: arguments, execution: execution}) do + id = execution.next_symbol_id + + description = + case arguments do + [value | _] when value != :undefined -> Value.to_string_value(value) + _arguments -> "" + end + + symbol = %Symbol{id: {:local, id}, description: description} + {:ok, symbol, %{execution | next_symbol_id: id + 1}} + end +end diff --git a/lib/quickbeam/vm/builtin/uint8_array.ex b/lib/quickbeam/vm/builtin/uint8_array.ex new file mode 100644 index 000000000..71a06c9fc --- /dev/null +++ b/lib/quickbeam/vm/builtin/uint8_array.ex @@ -0,0 +1,42 @@ +defmodule QuickBEAM.VM.Builtin.Uint8Array do + @moduledoc "Defines the minimal Uint8Array constructor required by server bundles." + + use QuickBEAM.VM.Builtin + + alias QuickBEAM.VM.Builtin.Call + alias QuickBEAM.VM.Runtime.Heap + alias QuickBEAM.VM.Runtime.Property + + builtin "Uint8Array", + kind: :constructor, + constructor: :construct, + length: 3, + depends_on: ["Object", "Function"] do + prototype extends: "Object" + end + + @doc "Constructs a byte array from a length or a list of numeric values." + def construct(%Call{arguments: arguments, execution: execution}) do + values = + case arguments do + [length | _] when is_integer(length) and length >= 0 -> List.duplicate(0, length) + [values | _] when is_list(values) -> Enum.map(values, &normalize_byte/1) + _arguments -> [] + end + + {array, execution} = Heap.allocate(execution, :array) + + execution = + values + |> Enum.with_index() + |> Enum.reduce(execution, fn {value, index}, execution -> + {:ok, execution} = Property.define(array, index, value, execution) + execution + end) + + {:ok, array, execution} + end + + defp normalize_byte(value) when is_number(value), do: trunc(value) |> Integer.mod(256) + defp normalize_byte(_value), do: 0 +end diff --git a/lib/quickbeam/vm/builtin/validator.ex b/lib/quickbeam/vm/builtin/validator.ex new file mode 100644 index 000000000..c816f6443 --- /dev/null +++ b/lib/quickbeam/vm/builtin/validator.ex @@ -0,0 +1,172 @@ +defmodule QuickBEAM.VM.Builtin.Validator do + @moduledoc "Validates builtin declarations and handler contracts at compile time." + + alias QuickBEAM.VM.Builtin.Spec + alias QuickBEAM.VM.Builtin.Spec.Accessor, as: AccessorSpec + alias QuickBEAM.VM.Builtin.Spec.Alias, as: AliasSpec + alias QuickBEAM.VM.Builtin.Spec.Function, as: FunctionSpec + alias QuickBEAM.VM.Builtin.Spec.Property, as: PropertySpec + alias QuickBEAM.VM.Builtin.Spec.Prototype, as: PrototypeSpec + + @doc "Validates a compiled builtin spec against its declaring module." + @spec validate!(Spec.t(), Macro.Env.t()) :: :ok + def validate!(%Spec{} = spec, env) do + validate_spec!(spec, env) + %PrototypeSpec{} = prototype = spec.prototype_spec + validate_prototype!(prototype, spec, env) + + entries = spec.statics ++ spec.prototype + duplicate_keys!(spec.statics, :static, env) + duplicate_keys!(spec.prototype, :prototype, env) + validate_handlers!(entries, prototype, spec, env) + Enum.each(entries, &validate_entry!(&1, env)) + end + + defp validate_spec!(spec, env) do + validate!( + is_binary(spec.name) and spec.name != "", + env, + "builtin name must be a non-empty string" + ) + + validate!(spec.kind in [:namespace, :function, :constructor, :intrinsic], env, fn -> + "unsupported builtin kind: #{inspect(spec.kind)}" + end) + + validate!( + is_integer(spec.length) and spec.length >= 0, + env, + "builtin length must be a non-negative integer" + ) + + validate!( + spec.profiles != [] and Enum.all?(spec.profiles, &is_atom/1), + env, + "builtin profiles must be a non-empty atom list" + ) + + validate!( + Enum.all?(spec.depends_on, &(is_binary(&1) and &1 != "")), + env, + "builtin dependencies must be non-empty strings" + ) + + validate!( + spec.kind != :constructor or is_atom(spec.constructor), + env, + "constructor builtins require a :constructor handler" + ) + end + + defp validate_prototype!(prototype, spec, env) do + valid_parent? = + prototype.extends in [:default, nil] or + (is_binary(prototype.extends) and prototype.extends in spec.depends_on) + + validate!(valid_parent?, env, "prototype :extends must name a declared dependency or be nil") + + validate!(prototype.kind in [:ordinary, :array, :function], env, fn -> + "unsupported prototype kind: #{inspect(prototype.kind)}" + end) + + validate!( + is_nil(prototype.default_for) or is_atom(prototype.default_for), + env, + "prototype :default_for must be an atom" + ) + + validate!( + is_nil(prototype.error_type) or is_binary(prototype.error_type), + env, + "prototype :error_type must be a string" + ) + + valid_primitive? = + is_nil(prototype.primitive) or + match?({kind, _value} when is_atom(kind), prototype.primitive) + + validate!(valid_primitive?, env, "prototype :primitive must be a {kind, value} pair") + + validate!( + prototype.kind != :function or is_atom(prototype.callable), + env, + "function prototypes require a :callable handler" + ) + end + + defp validate_handlers!(entries, prototype, spec, env) do + handlers = + entries + |> Enum.flat_map(&entry_handlers/1) + |> maybe_prepend(spec.constructor) + |> maybe_prepend(if(spec.kind == :function, do: :call)) + |> maybe_prepend(prototype.callable) + |> Enum.uniq() + + Enum.each(handlers, fn handler -> + validate!(is_atom(handler) and Module.defines?(env.module, {handler, 1}, :def), env, fn -> + "builtin handler #{inspect(handler)}/1 must be a public function" + end) + end) + end + + defp maybe_prepend(values, nil), do: values + defp maybe_prepend(values, value), do: [value | values] + + defp entry_handlers(%FunctionSpec{handler: handler}), do: [handler] + + defp entry_handlers(%AccessorSpec{getter: getter, setter: setter}), + do: Enum.reject([getter, setter], &is_nil/1) + + defp entry_handlers(%PropertySpec{}), do: [] + defp entry_handlers(%AliasSpec{}), do: [] + + defp validate_entry!(%FunctionSpec{key: key, length: length} = spec, env) do + unless is_integer(length) and length >= 0 do + compile_error!(env, "invalid function length for #{inspect(key)}") + end + + validate_flags!(spec, env) + end + + defp validate_entry!(%AccessorSpec{getter: nil, setter: nil, key: key}, env), + do: compile_error!(env, "accessor #{inspect(key)} requires a getter or setter") + + defp validate_entry!(%AccessorSpec{} = spec, env), do: validate_flags!(spec, env) + defp validate_entry!(%PropertySpec{} = spec, env), do: validate_flags!(spec, env) + defp validate_entry!(%AliasSpec{} = spec, env), do: validate_flags!(spec, env) + + defp validate_flags!(spec, env) do + flags = + spec + |> Map.from_struct() + |> Map.take([:writable, :enumerable, :configurable]) + + unless Enum.all?(flags, fn {_key, value} -> is_boolean(value) end) do + compile_error!(env, "property descriptor flags must be boolean: #{inspect(spec.key)}") + end + end + + defp duplicate_keys!(entries, namespace, env) do + keys = Enum.map(entries, & &1.key) + + case keys -- Enum.uniq(keys) do + [] -> + :ok + + duplicates -> + compile_error!(env, "duplicate #{namespace} keys: #{inspect(Enum.uniq(duplicates))}") + end + end + + defp validate!(true, _env, _description), do: :ok + + defp validate!(false, env, description) when is_binary(description), + do: compile_error!(env, description) + + defp validate!(false, env, description), do: compile_error!(env, description.()) + + defp compile_error!(env, description) do + raise CompileError, file: env.file, line: env.line, description: description + end +end diff --git a/lib/quickbeam/vm/builtin/weak_map.ex b/lib/quickbeam/vm/builtin/weak_map.ex new file mode 100644 index 000000000..9bd8e26e5 --- /dev/null +++ b/lib/quickbeam/vm/builtin/weak_map.ex @@ -0,0 +1,62 @@ +defmodule QuickBEAM.VM.Builtin.WeakMap do + @moduledoc "Defines evaluation-local WeakMap semantics for object keys." + + use QuickBEAM.VM.Builtin + + alias QuickBEAM.VM.Builtin.Call + alias QuickBEAM.VM.Builtin.Map, as: MapBuiltin + alias QuickBEAM.VM.Runtime.Heap + alias QuickBEAM.VM.Runtime.Object + alias QuickBEAM.VM.Runtime.Reference + + builtin "WeakMap", + kind: :constructor, + constructor: :construct, + length: 0, + depends_on: ["Object", "Function"] do + prototype do + method :delete, length: 1 + method :get, length: 1 + method :has, length: 1 + method :set, length: 2 + end + end + + @doc "Constructs an evaluation-local weak-key map." + def construct(%Call{} = call) do + case MapBuiltin.construct(call) do + {:ok, receiver, execution} = result -> validate_keys(result, receiver, execution) + action -> action + end + end + + @doc "Removes a WeakMap entry." + def delete(%Call{} = call), do: MapBuiltin.delete(call) + + @doc "Returns a WeakMap value." + def get(%Call{} = call), do: MapBuiltin.get(call) + + @doc "Tests whether a WeakMap contains a key." + def has(%Call{} = call), do: MapBuiltin.has(call) + + @doc "Adds or replaces a WeakMap entry." + def set(%Call{arguments: [%Reference{} | _]} = call), do: MapBuiltin.set(call) + + def set(%Call{execution: execution}), + do: {:error, {:type_error, :invalid_weak_map_key}, execution} + + defp validate_keys(result, receiver, execution) do + case Heap.fetch_object(execution, receiver) do + {:ok, %Object{internal: %{entries: entries}}} -> + if valid_keys?(entries), + do: result, + else: {:error, {:type_error, :invalid_weak_map_key}, execution} + + _invalid_receiver -> + {:error, {:type_error, :invalid_weak_map_receiver}, execution} + end + end + + defp valid_keys?(entries), + do: Enum.all?(entries, fn {key, _value} -> is_struct(key, Reference) end) +end diff --git a/lib/quickbeam/vm/builtin/weak_set.ex b/lib/quickbeam/vm/builtin/weak_set.ex new file mode 100644 index 000000000..0aa4a83c7 --- /dev/null +++ b/lib/quickbeam/vm/builtin/weak_set.ex @@ -0,0 +1,55 @@ +defmodule QuickBEAM.VM.Builtin.WeakSet do + @moduledoc "Defines evaluation-local WeakSet semantics for object values." + + use QuickBEAM.VM.Builtin + + alias QuickBEAM.VM.Builtin.Call + alias QuickBEAM.VM.Builtin.Set, as: SetBuiltin + alias QuickBEAM.VM.Runtime.Heap + alias QuickBEAM.VM.Runtime.Object + alias QuickBEAM.VM.Runtime.Reference + + builtin "WeakSet", + kind: :constructor, + constructor: :construct, + length: 0, + depends_on: ["Object", "Function", "Symbol"] do + prototype do + method :add, length: 1 + method :delete, length: 1 + method :has, length: 1 + end + end + + @doc "Constructs an evaluation-local weak-value set." + def construct(%Call{} = call) do + case SetBuiltin.construct(call) do + {:ok, receiver, execution} = result -> validate_values(result, receiver, execution) + action -> action + end + end + + @doc "Adds a WeakSet value." + def add(%Call{arguments: [%Reference{} | _]} = call), do: SetBuiltin.add(call) + + def add(%Call{execution: execution}), + do: {:error, {:type_error, :invalid_weak_set_value}, execution} + + @doc "Removes a WeakSet value." + def delete(%Call{} = call), do: SetBuiltin.delete(call) + + @doc "Tests whether a WeakSet contains a value." + def has(%Call{} = call), do: SetBuiltin.has(call) + + defp validate_values(result, receiver, execution) do + case Heap.fetch_object(execution, receiver) do + {:ok, %Object{internal: %{values: values}}} -> + if Enum.all?(values, &is_struct(&1, Reference)), + do: result, + else: {:error, {:type_error, :invalid_weak_set_value}, execution} + + _invalid_receiver -> + {:error, {:type_error, :invalid_weak_set_receiver}, execution} + end + end +end diff --git a/lib/quickbeam/vm/bytecode/atom.ex b/lib/quickbeam/vm/bytecode/atom.ex new file mode 100644 index 000000000..6b46fc7df --- /dev/null +++ b/lib/quickbeam/vm/bytecode/atom.ex @@ -0,0 +1,12 @@ +defmodule QuickBEAM.VM.Bytecode.Atom do + @moduledoc "QuickJS predefined atoms generated from the vendored atom header." + + @table QuickBEAM.VM.ABI.predefined_atoms() + + @doc "Looks up a predefined atom by its QuickJS index." + def lookup(index) when is_map_key(@table, index), do: Map.fetch!(@table, index) + def lookup(_index), do: nil + + @doc "Returns the number of predefined QuickJS atoms." + def count, do: map_size(@table) +end diff --git a/lib/quickbeam/vm/bytecode/checksum.ex b/lib/quickbeam/vm/bytecode/checksum.ex new file mode 100644 index 000000000..874ff0d3e --- /dev/null +++ b/lib/quickbeam/vm/bytecode/checksum.ex @@ -0,0 +1,38 @@ +defmodule QuickBEAM.VM.Bytecode.Checksum do + @moduledoc "Computes stable checksums for decoded VM program artifacts." + + import Bitwise + + @factor 0x9E370001 + @mask 0xFFFFFFFF + + @doc "Verifies the checksum embedded in a serialized QuickJS bytecode envelope." + @spec verify(binary()) :: :ok | {:error, :unexpected_end | :checksum_mismatch} + def verify(<<_version, expected::little-unsigned-32, payload::binary>>) do + if calculate(payload) == expected, do: :ok, else: {:error, :checksum_mismatch} + end + + def verify(_binary), do: {:error, :unexpected_end} + + @doc "Calculates QuickJS's stable 32-bit bytecode payload checksum." + @spec calculate(binary()) :: non_neg_integer() + def calculate(payload) when is_binary(payload), do: checksum_words(payload, 0) + + defp checksum_words(<>, checksum) + when byte_size(rest) > 0 do + checksum = band(checksum + word, @mask) + checksum_words(rest, band(checksum * @factor, @mask)) + end + + defp checksum_words(rest, checksum) do + tail = + case rest do + <> -> a + <> -> a ||| b <<< 8 + <> -> a ||| b <<< 8 ||| c <<< 16 + _ -> 0 + end + + checksum |> then(&band(&1 + tail, @mask)) |> then(&band(&1 * @factor, @mask)) + end +end diff --git a/lib/quickbeam/vm/bytecode/decoder.ex b/lib/quickbeam/vm/bytecode/decoder.ex new file mode 100644 index 000000000..e93487381 --- /dev/null +++ b/lib/quickbeam/vm/bytecode/decoder.ex @@ -0,0 +1,760 @@ +defmodule QuickBEAM.VM.Bytecode.Decoder do + @moduledoc """ + Parses QuickJS bytecode binaries into VM program/function instruction IR. + + Binary format matches JS_WriteObjectAtoms / JS_ReadObjectAtoms / JS_ReadFunctionTag + in priv/c_src/quickjs.c exactly. + """ + + alias QuickBEAM.VM.ABI + alias QuickBEAM.VM.Bytecode.Atom, as: AtomTable + alias QuickBEAM.VM.Bytecode.Checksum + alias QuickBEAM.VM.Bytecode.Instruction + alias QuickBEAM.VM.Bytecode.Opcode + alias QuickBEAM.VM.Program + alias QuickBEAM.VM.Program.Function + + alias QuickBEAM.VM.Bytecode.Varint, as: LEB128 + import Bitwise + + # JS_ATOM_NULL=0, plus 228 DEF entries from quickjs-atom.h + @js_atom_end Opcode.js_atom_end() + + # Pre-compute tag constants for use in match clauses + @tag_null Opcode.bc_tag_null() + @tag_undefined Opcode.bc_tag_undefined() + @tag_bool_false Opcode.bc_tag_bool_false() + @tag_bool_true Opcode.bc_tag_bool_true() + @tag_int32 Opcode.bc_tag_int32() + @tag_float64 Opcode.bc_tag_float64() + @tag_string Opcode.bc_tag_string() + @tag_function_bytecode Opcode.bc_tag_function_bytecode() + @tag_object Opcode.bc_tag_object() + @tag_array Opcode.bc_tag_array() + @tag_big_int Opcode.bc_tag_big_int() + @tag_template_object Opcode.bc_tag_template_object() + @tag_regexp Opcode.bc_tag_regexp() + + @pc2line_base -1 + @pc2line_range 5 + @pc2line_op_first 1 + + @max_input_bytes 16 * 1024 * 1024 + @max_nesting_depth 128 + @max_entries 100_000 + @max_function_bytecode_bytes 4 * 1024 * 1024 + + @doc "Decodes a QuickJS bytecode binary into a `%QuickBEAM.VM.Program{}`." + @spec decode(binary()) :: {:ok, Program.t()} | {:error, term()} + def decode(data) when is_binary(data) and byte_size(data) > @max_input_bytes, + do: {:error, {:limit_exceeded, :bytecode_bytes, byte_size(data)}} + + def decode(data) when is_binary(data) do + with {:ok, version, rest} <- LEB128.read_u8(data), + :ok <- validate_version(version), + :ok <- Checksum.verify(data), + <<_checksum::little-unsigned-32, rest2::binary>> <- rest, + {:ok, atoms, rest3} <- read_atoms(rest2), + {:ok, value, rest4} <- read_object(rest3, atoms, 0), + :ok <- ensure_consumed(rest4) do + {:ok, + %Program{ + version: version, + fingerprint: ABI.fingerprint(), + bytecode_digest: :crypto.hash(:sha256, data), + bytecode_size: byte_size(data), + atoms: atoms, + root: attach_atoms(value, atoms) + }} + else + {:error, _} = err -> err + _ -> {:error, :unexpected_end} + end + end + + defp attach_atoms(value, atoms) do + {value, _next_id} = attach_atoms(value, atoms, 0) + value + end + + defp attach_atoms(%Function{} = function, atoms, id) do + {constants, next_id} = + Enum.map_reduce(function.constants, id + 1, fn + %Function{} = nested, nested_id -> + attach_atoms(nested, atoms, nested_id) + + other, nested_id -> + {other, nested_id} + end) + + {%{function | id: id, atoms: atoms, constants: constants}, next_id} + end + + defp attach_atoms(other, _atoms, id), do: {other, id} + + # ── Atom table ── + # Matches JS_ReadObjectAtoms: reads idx_to_atom_count entries. + # Each entry: type=0 → const atom (u32), type≠0 → string atom. + + defp read_atoms(data) do + with {:ok, count, rest} <- LEB128.read_unsigned(data), + :ok <- validate_count(count, :atoms) do + read_atom_list(rest, count, []) + end + end + + defp read_atom_list(data, 0, acc), do: {:ok, List.to_tuple(Enum.reverse(acc)), data} + + defp read_atom_list(data, count, acc) do + with {:ok, type, rest} <- LEB128.read_u8(data) do + read_atom(type, rest, count, acc) + end + end + + defp read_atom(0, data, count, acc) do + with {:ok, atom_id, rest} <- LEB128.read_fixed_u32(data), + {:ok, atom} <- predefined_atom(atom_id) do + read_atom_list(rest, count - 1, [atom | acc]) + end + end + + defp read_atom(_type, data, count, acc) do + with {:ok, string, rest} <- read_string_raw(data) do + read_atom_list(rest, count - 1, [string | acc]) + end + end + + defp predefined_atom(atom_id) do + case AtomTable.lookup(atom_id) do + atom when is_binary(atom) -> {:ok, atom} + nil -> {:error, {:unknown_predefined_atom, atom_id}} + end + end + + # bc_get_atom: reads LEB128 value v. + # If v & 1 → tagged int (v >> 1). + # If v even → idx = v >> 1: + # idx < JS_ATOM_END → predefined runtime atom (return as {:predefined, idx}) + # idx >= JS_ATOM_END → atom table at idx - JS_ATOM_END + defp read_atom_ref(data, atoms) do + with {:ok, value, rest} <- LEB128.read_unsigned(data) do + decode_atom_ref(value, atoms, rest) + end + end + + defp decode_atom_ref(value, _atoms, rest) when band(value, 1) == 1, + do: {:ok, {:tagged_int, bsr(value, 1)}, rest} + + defp decode_atom_ref(value, atoms, rest) do + case atom_name(bsr(value, 1), atoms) do + {:error, reason} -> {:error, reason} + name -> {:ok, name, rest} + end + end + + defp atom_name(0, _atoms), do: "" + defp atom_name(index, _atoms) when index < @js_atom_end, do: {:predefined, index} + + defp atom_name(index, atoms) do + local_index = index - @js_atom_end + + if local_index < tuple_size(atoms), + do: elem(atoms, local_index), + else: {:error, {:unknown_atom, index}} + end + + # ── String reading ── + # bc_get_leb128 for len (where bit0=is_wide, bits1+=actual_len), then raw bytes. + + defp read_binary_raw(data) do + with {:ok, length, rest} <- LEB128.read_unsigned(data) do + take_binary(rest, bsr(length, 1)) + end + end + + defp read_string_raw(data) do + with {:ok, encoded_length, rest} <- LEB128.read_unsigned(data), + wide? = band(encoded_length, 1) == 1, + byte_length = string_byte_length(encoded_length, wide?), + {:ok, string, rest} <- take_binary(rest, byte_length) do + {:ok, decode_string(string, wide?), rest} + end + end + + defp string_byte_length(encoded_length, true), do: bsr(encoded_length, 1) * 2 + defp string_byte_length(encoded_length, false), do: bsr(encoded_length, 1) + + defp decode_string(string, true), do: wide_to_utf8(string) + defp decode_string(string, false), do: latin1_to_utf8(string) + + defp take_binary(data, length) when byte_size(data) >= length do + <> = data + {:ok, value, rest} + end + + defp take_binary(_data, _length), do: {:error, :unexpected_end} + + defp latin1_to_utf8(data) do + for <>, into: <<>>, do: <> + end + + defp wide_to_utf8(data) do + data + |> decode_utf16_le() + |> codepoints_to_binary() + end + + defp codepoints_to_binary(codepoints) do + IO.iodata_to_binary( + for cp <- codepoints do + if cp >= 0xD800 and cp <= 0xDFFF do + # Lone surrogate: encode as CESU-8 (3-byte UTF-8-like encoding) + <<0xE0 ||| cp >>> 12, 0x80 ||| (cp >>> 6 &&& 0x3F), 0x80 ||| (cp &&& 0x3F)>> + else + <> + end + end + ) + end + + defp decode_utf16_le(data, acc \\ []) + defp decode_utf16_le(<<>>, acc), do: Enum.reverse(acc) + + defp decode_utf16_le(<>, acc) + when hi >= 0xD800 and hi <= 0xDBFF and lo >= 0xDC00 and lo <= 0xDFFF do + cp = (hi - 0xD800) * 0x400 + (lo - 0xDC00) + 0x10000 + decode_utf16_le(rest, [cp | acc]) + end + + defp decode_utf16_le(<>, acc) do + decode_utf16_le(rest, [c | acc]) + end + + defp decode_float64(bits) do + sign = bits >>> 63 + exponent = bits >>> 52 &&& 0x7FF + fraction = bits &&& 0xFFFFFFFFFFFFF + + cond do + exponent == 0x7FF and fraction == 0 and sign == 0 -> :infinity + exponent == 0x7FF and fraction == 0 -> :neg_infinity + exponent == 0x7FF -> :nan + true -> decode_finite_float64(bits) + end + end + + defp decode_finite_float64(bits) do + <> = <> + value + end + + # ── Object deserialization ── + # Matches JS_ReadObjectRec switch(tag). + + defp read_object(_data, _atoms, depth) when depth > @max_nesting_depth, + do: {:error, {:limit_exceeded, :nesting_depth, depth}} + + defp read_object(<<@tag_null, rest::binary>>, _atoms, _depth), do: {:ok, nil, rest} + + defp read_object(<<@tag_undefined, rest::binary>>, _atoms, _depth), + do: {:ok, :undefined, rest} + + defp read_object(<<@tag_bool_false, rest::binary>>, _atoms, _depth), + do: {:ok, false, rest} + + defp read_object(<<@tag_bool_true, rest::binary>>, _atoms, _depth), + do: {:ok, true, rest} + + defp read_object(<<@tag_int32, rest::binary>>, _atoms, _depth), + do: LEB128.read_signed(rest) + + defp read_object(<<@tag_float64, rest::binary>>, _atoms, _depth) do + case rest do + <> -> {:ok, decode_float64(bits), rest2} + _ -> {:error, :unexpected_end} + end + end + + defp read_object(<<@tag_string, rest::binary>>, _atoms, _depth), do: read_string_raw(rest) + + defp read_object(<<@tag_function_bytecode, rest::binary>>, atoms, depth), + do: read_function(rest, atoms, depth + 1) + + defp read_object(<<@tag_object, rest::binary>>, atoms, depth), + do: read_plain_object(rest, atoms, depth + 1) + + defp read_object(<<@tag_array, rest::binary>>, atoms, depth), + do: read_array(rest, atoms, depth + 1) + + defp read_object(<<@tag_big_int, rest::binary>>, _atoms, _depth) do + with {:ok, len, rest2} <- LEB128.read_unsigned(rest) do + if byte_size(rest2) < len do + {:error, :unexpected_end} + else + <> = rest2 + value = decode_bigint_twos_complement(bytes) + {:ok, {:bigint, value}, rest3} + end + end + end + + defp read_object(<<@tag_template_object, rest::binary>>, atoms, depth) do + with {:ok, count, rest2} <- LEB128.read_unsigned(rest), + :ok <- validate_count(count, :array_entries), + {:ok, elems, rest3} <- read_array_elems(rest2, count, [], atoms, depth + 1), + {:ok, raw, rest4} <- read_object(rest3, atoms, depth + 1) do + {:ok, {:template_object, elems, raw}, rest4} + end + end + + defp read_object(<<@tag_regexp, rest::binary>>, _atoms, _depth) do + with {:ok, source, rest2} <- read_string_raw(rest), + {:ok, bytecode, rest3} <- read_binary_raw(rest2) do + {:ok, {:regexp, source, bytecode}, rest3} + end + end + + defp read_object(<>, _atoms, _depth), + do: {:error, {:unknown_tag, tag}} + + defp read_object(<<>>, _atoms, _depth), do: {:error, :unexpected_end} + + defp decode_bigint_twos_complement(<<>>), do: 0 + + defp decode_bigint_twos_complement(bytes) do + # QuickJS stores bigint as little-endian two's complement bytes + size = byte_size(bytes) + <> = bytes + # Check sign bit + if band(binary_part(bytes, size - 1, 1) |> :binary.decode_unsigned(), 0x80) != 0 do + value - (1 <<< (size * 8)) + else + value + end + end + + defp read_plain_object(data, atoms, depth) do + with {:ok, count, rest} <- LEB128.read_unsigned(data), + :ok <- validate_count(count, :object_properties) do + read_props(rest, count, %{}, atoms, depth) + end + end + + defp read_array(data, atoms, depth) do + with {:ok, count, rest} <- LEB128.read_unsigned(data), + :ok <- validate_count(count, :array_entries) do + read_array_elems(rest, count, [], atoms, depth) + end + end + + defp read_props(data, 0, acc, _atoms, _depth), do: {:ok, {:object, acc}, data} + + defp read_props(data, count, acc, atoms, depth) do + with {:ok, key, rest} <- read_atom_ref(data, atoms), + {:ok, value, rest2} <- read_object(rest, atoms, depth) do + read_props(rest2, count - 1, Map.put(acc, key, value), atoms, depth) + end + end + + defp read_array_elems(data, 0, acc, _atoms, _depth), + do: {:ok, {:array, Enum.reverse(acc)}, data} + + defp read_array_elems(data, count, acc, atoms, depth) do + with {:ok, value, rest} <- read_object(data, atoms, depth) do + read_array_elems(rest, count - 1, [value | acc], atoms, depth) + end + end + + # ── Function bytecode ── + # Matches JS_ReadFunctionTag exactly. + # + # Layout: + # flags (u16 raw LE) + # is_strict_mode (u8) + # func_name (bc_get_atom → LEB128) + # arg_count (leb128_u16) + # var_count (leb128_u16) + # defined_arg_count (leb128_u16) + # stack_size (leb128_u16) + # var_ref_count (leb128_u16) + # closure_var_count (leb128_u16) + # cpool_count (leb128_int) + # byte_code_len (leb128_int) + # local_count (leb128_int) + # [vardefs × local_count] + # [closure_vars × closure_var_count] + # [cpool × cpool_count] — cpool written BEFORE bytecode + # [bytecode × byte_code_len] + # [debug_info if has_debug_info: filename_atom + line_num] + + defp read_function(data, atoms, depth) do + # flags: raw u16 little-endian (bc_put_u16 / bc_get_u16) + case data do + <> -> + read_function_body(flags, rest, atoms, depth) + + _ -> + {:error, :unexpected_end} + end + end + + defp validate_version(version) do + if version == Opcode.bc_version(), do: :ok, else: {:error, {:bad_version, version}} + end + + defp read_function_body(flags, data, atoms, depth) do + flags_map = decode_func_flags(flags) + + with {:ok, strict, rest} <- LEB128.read_u8(data), + {:ok, func_name, rest} <- read_atom_ref(rest, atoms), + {:ok, arg_count, rest} <- LEB128.read_unsigned(rest), + {:ok, var_count, rest} <- LEB128.read_unsigned(rest), + {:ok, defined_arg_count, rest} <- LEB128.read_unsigned(rest), + {:ok, stack_size, rest} <- LEB128.read_unsigned(rest), + {:ok, var_ref_count, rest} <- LEB128.read_unsigned(rest), + {:ok, closure_var_count, rest} <- LEB128.read_unsigned(rest), + {:ok, cpool_count, rest} <- LEB128.read_unsigned(rest), + {:ok, byte_code_len, rest} <- LEB128.read_unsigned(rest), + {:ok, local_count, rest} <- LEB128.read_unsigned(rest), + :ok <- + validate_function_counts(%{ + arguments: arg_count, + variables: var_count, + variable_references: var_ref_count, + closure_variables: closure_var_count, + constants: cpool_count, + locals: local_count, + stack: stack_size, + bytecode_bytes: byte_code_len + }), + :ok <- validate_local_count(local_count, arg_count, var_count), + {:ok, locals, rest} <- read_vardefs(rest, local_count, atoms), + {:ok, closure_vars, rest} <- read_closure_vars(rest, closure_var_count, atoms), + {:ok, cpool, rest} <- read_cpool(rest, cpool_count, atoms, depth) do + metadata = %{ + name: func_name, + arg_count: arg_count, + var_count: var_count, + defined_arg_count: defined_arg_count, + stack_size: stack_size, + var_ref_count: var_ref_count, + locals: locals, + closure_vars: closure_vars, + constants: cpool, + strict?: strict > 0, + flags: flags_map + } + + read_function_bytecode(rest, byte_code_len, atoms, metadata) + end + end + + defp read_function_bytecode(data, byte_code_length, atoms, metadata) do + with {:ok, byte_code, rest} <- take_binary(data, byte_code_length), + {:ok, instructions} <- Instruction.decode(byte_code, metadata.arg_count), + {:ok, debug_info, rest} <- read_debug_info(rest, metadata.flags.has_debug_info, atoms) do + {:ok, build_function(metadata, instructions, byte_code, debug_info), rest} + end + end + + defp build_function(metadata, instructions, byte_code, debug_info) do + %Function{ + name: metadata.name, + arg_count: metadata.arg_count, + var_count: metadata.var_count, + defined_arg_count: metadata.defined_arg_count, + stack_size: metadata.stack_size, + var_ref_count: metadata.var_ref_count, + locals: metadata.locals, + closure_vars: metadata.closure_vars, + constants: metadata.constants, + instructions: List.to_tuple(instructions), + filename: debug_info.filename, + line_num: debug_info.line_num, + col_num: debug_info.col_num, + pc2line: debug_info.pc2line, + source: debug_info.source, + source_positions: source_positions(byte_code, debug_info), + is_strict_mode: metadata.strict?, + has_prototype: metadata.flags.has_prototype, + has_simple_parameter_list: metadata.flags.has_simple_parameter_list, + is_derived_class_constructor: metadata.flags.is_derived_class_constructor, + need_home_object: metadata.flags.need_home_object, + func_kind: metadata.flags.func_kind, + new_target_allowed: metadata.flags.new_target_allowed, + super_call_allowed: metadata.flags.super_call_allowed, + super_allowed: metadata.flags.super_allowed, + arguments_allowed: metadata.flags.arguments_allowed, + has_debug_info: metadata.flags.has_debug_info + } + end + + # Must match JS_WriteFunctionTag bit layout: + # bit 0: has_prototype + # bit 1: has_simple_parameter_list + # bit 2: is_derived_class_constructor + # bit 3: need_home_object + # bits 4-5: func_kind (2 bits) + # bit 6: new_target_allowed + # bit 7: super_call_allowed + # bit 8: super_allowed + # bit 9: arguments_allowed + # bit 10: backtrace_barrier + # bit 11: has_debug_info + defp decode_func_flags(v16) do + %{ + has_prototype: band(bsr(v16, 0), 1) == 1, + has_simple_parameter_list: band(bsr(v16, 1), 1) == 1, + is_derived_class_constructor: band(bsr(v16, 2), 1) == 1, + need_home_object: band(bsr(v16, 3), 1) == 1, + func_kind: band(bsr(v16, 4), 0x3), + new_target_allowed: band(bsr(v16, 6), 1) == 1, + super_call_allowed: band(bsr(v16, 7), 1) == 1, + super_allowed: band(bsr(v16, 8), 1) == 1, + arguments_allowed: band(bsr(v16, 9), 1) == 1, + backtrace_barrier: band(bsr(v16, 10), 1) == 1, + has_debug_info: band(bsr(v16, 11), 1) == 1 + } + end + + # ── Vardefs ── + # Matches JS_ReadFunctionTag vardef loop: + # var_name (bc_get_atom), scope_level (leb128), scope_next (leb128, then -1), + # flags (u8): var_kind(4), is_const(1), is_lexical(1), is_captured(1) + # if is_captured: var_ref_idx (leb128_u16) + + defp read_vardefs(data, 0, _atoms), do: {:ok, [], data} + + defp read_vardefs(data, count, atoms) do + read_vardefs_loop(data, count, atoms, []) + end + + defp read_vardefs_loop(data, 0, _atoms, acc), do: {:ok, Enum.reverse(acc), data} + + defp read_vardefs_loop(data, count, atoms, acc) do + with {:ok, name, rest} <- read_atom_ref(data, atoms), + {:ok, scope_level, rest} <- LEB128.read_unsigned(rest), + {:ok, scope_next_raw, rest} <- LEB128.read_unsigned(rest), + {:ok, flags, rest} <- LEB128.read_u8(rest) do + scope_next = scope_next_raw - 1 + var_kind = band(flags, 0xF) + is_const = band(bsr(flags, 4), 1) == 1 + is_lexical = band(bsr(flags, 5), 1) == 1 + is_captured = band(bsr(flags, 6), 1) == 1 + + with {:ok, var_ref_idx, rest} <- read_var_ref_index(rest, is_captured) do + variable = %QuickBEAM.VM.Program.Variable{ + name: name, + scope_level: scope_level, + scope_next: scope_next, + var_kind: var_kind, + is_const: is_const, + is_lexical: is_lexical, + is_captured: is_captured, + var_ref_idx: var_ref_idx + } + + read_vardefs_loop(rest, count - 1, atoms, [variable | acc]) + end + end + end + + defp read_var_ref_index(rest, false), do: {:ok, nil, rest} + defp read_var_ref_index(rest, true), do: LEB128.read_unsigned(rest) + + # ── Closure vars ── + # Matches JS_ReadFunctionTag closure_var loop: + # var_name (bc_get_atom), var_idx (leb128_int), flags (leb128_int): + # closure_type(3), is_const(1), is_lexical(1), var_kind(4) + + defp read_closure_vars(data, 0, _atoms), do: {:ok, [], data} + defp read_closure_vars(data, count, atoms), do: read_closure_vars_loop(data, count, atoms, []) + + defp read_closure_vars_loop(data, 0, _atoms, acc), do: {:ok, Enum.reverse(acc), data} + + defp read_closure_vars_loop(data, count, atoms, acc) do + with {:ok, name, rest} <- read_atom_ref(data, atoms), + {:ok, var_idx, rest} <- LEB128.read_unsigned(rest), + {:ok, flags, rest} <- LEB128.read_unsigned(rest) do + closure_type = band(flags, 0x7) + is_const = band(bsr(flags, 3), 1) == 1 + is_lexical = band(bsr(flags, 4), 1) == 1 + var_kind = band(bsr(flags, 5), 0xF) + + cv = %QuickBEAM.VM.Program.Variable.Closure{ + name: name, + var_idx: var_idx, + closure_type: closure_type, + is_const: is_const, + is_lexical: is_lexical, + var_kind: var_kind + } + + read_closure_vars_loop(rest, count - 1, atoms, [cv | acc]) + end + end + + defp read_cpool(data, 0, _atoms, _depth), do: {:ok, [], data} + + defp read_cpool(data, count, atoms, depth), + do: read_cpool_loop(data, count, atoms, depth, []) + + defp read_cpool_loop(data, 0, _atoms, _depth, acc), + do: {:ok, Enum.reverse(acc), data} + + defp read_cpool_loop(data, count, atoms, depth, acc) do + case read_object(data, atoms, depth) do + {:ok, value, rest} -> read_cpool_loop(rest, count - 1, atoms, depth, [value | acc]) + {:error, _} = error -> error + end + end + + defp validate_count(count, _kind) when count <= @max_entries, do: :ok + + defp validate_count(count, kind), + do: {:error, {:limit_exceeded, kind, count}} + + defp validate_function_counts(counts) do + Enum.reduce_while(counts, :ok, fn + {:bytecode_bytes, count}, :ok when count <= @max_function_bytecode_bytes -> + {:cont, :ok} + + {:bytecode_bytes, count}, :ok -> + {:halt, {:error, {:limit_exceeded, :function_bytecode_bytes, count}}} + + {kind, count}, :ok -> + case validate_count(count, kind) do + :ok -> {:cont, :ok} + {:error, _} = error -> {:halt, error} + end + end) + end + + defp validate_local_count(local_count, arg_count, var_count) + when local_count == arg_count + var_count, + do: :ok + + defp validate_local_count(local_count, arg_count, var_count), + do: {:error, {:invalid_local_count, local_count, arg_count + var_count}} + + defp ensure_consumed(<<>>), do: :ok + defp ensure_consumed(rest), do: {:error, {:trailing_bytes, byte_size(rest)}} + + defp read_debug_info(data, false, _atoms) do + {:ok, %{filename: nil, line_num: 1, col_num: 1, pc2line: <<>>, source: <<>>}, data} + end + + defp read_debug_info(data, true, atoms) do + with {:ok, filename, rest} <- read_atom_ref(data, atoms), + {:ok, line_num, rest} <- LEB128.read_unsigned(rest), + {:ok, col_num, rest} <- LEB128.read_unsigned(rest), + {:ok, pc2line_len, rest} <- LEB128.read_unsigned(rest), + true <- byte_size(rest) >= pc2line_len, + <> <- rest, + {:ok, source_len, rest} <- LEB128.read_unsigned(rest), + true <- byte_size(rest) >= source_len, + <> <- rest do + {:ok, + %{ + filename: filename, + line_num: line_num, + col_num: col_num, + pc2line: pc2line, + source: source + }, rest} + else + {:error, _} = err -> err + false -> {:error, :malformed_debug_info} + _ -> {:error, :malformed_debug_info} + end + end + + defp source_positions( + byte_code, + %{pc2line: pc2line, line_num: line_num, col_num: col_num} + ) do + offsets = instruction_offsets(byte_code) + entries = pc2line_entries(pc2line, line_num, col_num) + + offsets + |> positions_for_offsets(entries) + |> List.to_tuple() + end + + defp instruction_offsets(byte_code), + do: instruction_offsets(byte_code, byte_size(byte_code), 0, []) + + defp instruction_offsets(_byte_code, len, pos, acc) when pos >= len, do: Enum.reverse(acc) + + defp instruction_offsets(byte_code, len, pos, acc) do + op = :binary.at(byte_code, pos) + + case Opcode.info(op) do + {_name, size, _n_pop, _n_push, _fmt} -> + instruction_offsets(byte_code, len, pos + size, [pos | acc]) + + _ -> + Enum.reverse(acc) + end + end + + defp pc2line_entries(<<>>, line_num, col_num), do: [{0, line_num, col_num}] + + defp pc2line_entries(pc2line, line_num, col_num) do + [{0, line_num, col_num} | do_pc2line_entries(pc2line, 0, line_num, col_num, [])] + end + + defp do_pc2line_entries(<<>>, _pc, _line_num, _col_num, acc), do: Enum.reverse(acc) + + defp do_pc2line_entries(data, pc, line_num, col_num, acc) do + <> = data + + case next_pc2line_entry(op_byte, rest, pc, line_num, col_num) do + {:ok, next_pc, next_line, next_col, rest} -> + do_pc2line_entries(rest, next_pc, next_line, next_col, [ + {next_pc, next_line, next_col} | acc + ]) + + :error -> + Enum.reverse(acc) + end + end + + defp next_pc2line_entry(0, rest, pc, line_num, col_num) do + with {:ok, diff_pc, rest} <- LEB128.read_unsigned(rest), + {:ok, diff_line, rest} <- LEB128.read_signed(rest), + {:ok, diff_col, rest} <- LEB128.read_signed(rest) do + {:ok, pc + diff_pc, line_num + diff_line, col_num + diff_col, rest} + else + _ -> :error + end + end + + defp next_pc2line_entry(op_byte, rest, pc, line_num, col_num) do + op = op_byte - @pc2line_op_first + + case LEB128.read_signed(rest) do + {:ok, diff_col, rest} -> + {:ok, pc + div(op, @pc2line_range), line_num + rem(op, @pc2line_range) + @pc2line_base, + col_num + diff_col, rest} + + _ -> + :error + end + end + + defp positions_for_offsets(offsets, [{_pc, line, col} | rest]) do + {positions, _current, _rest} = + Enum.reduce(offsets, {[], {line, col}, rest}, fn offset, {positions, current, entries} -> + {current, entries} = advance_source_position(offset, current, entries) + {[current | positions], current, entries} + end) + + Enum.reverse(positions) + end + + defp advance_source_position(offset, _current, [{pc, line, col} | rest]) when pc <= offset, + do: advance_source_position(offset, {line, col}, rest) + + defp advance_source_position(_offset, current, entries), do: {current, entries} +end diff --git a/lib/quickbeam/vm/bytecode/instruction.ex b/lib/quickbeam/vm/bytecode/instruction.ex new file mode 100644 index 000000000..3d04a7c4c --- /dev/null +++ b/lib/quickbeam/vm/bytecode/instruction.ex @@ -0,0 +1,236 @@ +defmodule QuickBEAM.VM.Bytecode.Instruction do + @compile {:inline, + get_u8: 2, + get_i8: 2, + get_u16: 2, + get_i16: 2, + get_u32: 2, + get_i32: 2, + get_atom_u32: 2, + resolve_label: 2} + @moduledoc """ + Decodes a raw QuickJS function bytecode body into VM instruction tuples. + + Returns a list of `{opcode_integer, args}` indexed by instruction position + (NOT byte offset). Labels are resolved to instruction indices via a + byte-offset-to-index map. Opcodes are raw integer tags for O(1) BEAM-compiler + jump-table dispatch. + """ + + alias QuickBEAM.VM.Bytecode.Opcode + import Bitwise + + @type instruction :: {non_neg_integer(), [term()]} + + @spec decode(binary()) :: {:ok, [instruction()]} | {:error, term()} + @doc "Decodes a QuickJS function bytecode body into structured VM instructions." + def decode(byte_code, arg_count \\ 0) when is_binary(byte_code) do + case build_offset_map(byte_code) do + {:ok, offset_map} -> + decode_pass2(byte_code, byte_size(byte_code), 0, 0, offset_map, [], arg_count) + + {:error, _} = err -> + err + end + end + + defp build_offset_map(bc) do + build_offset_map(bc, byte_size(bc), 0, 0, %{}) + end + + defp build_offset_map(_bc, len, pos, _idx, acc) when pos >= len do + {:ok, acc} + end + + defp build_offset_map(bc, len, pos, idx, acc) do + op = :binary.at(bc, pos) + + case Opcode.info(op) do + nil -> + {:error, {:unknown_opcode, op, pos}} + + {_name, size, _n_pop, _n_push, _fmt} -> + if pos + size > len do + {:error, {:truncated_instruction, op, pos}} + else + build_offset_map(bc, len, pos + size, idx + 1, Map.put(acc, pos, idx)) + end + end + end + + defp decode_pass2(_bc, len, pos, _idx, _offset_map, acc, _ac) when pos >= len do + {:ok, Enum.reverse(acc)} + end + + defp decode_pass2(bc, len, pos, idx, offset_map, acc, ac) do + op = :binary.at(bc, pos) + + case Opcode.info(op) do + nil -> + {:error, {:unknown_opcode, op, pos}} + + {_name, size, _n_pop, _n_push, fmt} -> + state = %{bc: bc, len: len, pos: pos, index: idx, offsets: offset_map, acc: acc, ac: ac} + decode_instruction(state, op, size, fmt) + end + end + + defp decode_instruction(%{len: len, pos: pos}, op, size, _fmt) when pos + size > len, + do: {:error, {:truncated_instruction, op, pos}} + + defp decode_instruction(state, op, size, fmt) do + case operands_for(state.bc, state.pos + 1, op, fmt, state.offsets, state.ac) do + {:ok, operands} -> + decode_pass2( + state.bc, + state.len, + state.pos + size, + state.index + 1, + state.offsets, + [{op, operands} | state.acc], + state.ac + ) + + {:error, _reason} = error -> + error + end + end + + # ── Operand decoding ── + + defp operands_for(_bc, _pos, op, fmt, _offset_map, ac) + when fmt in [:none_loc, :none_arg, :none_var_ref, :none_int, :npopx] do + {:ok, Opcode.short_form_operands(op, ac)} + end + + defp operands_for(bc, pos, op, fmt, offset_map, ac) do + with {:ok, operands} <- decode_operands(bc, pos, fmt, offset_map, ac) do + {:ok, normalize_operands(op, operands, ac)} + end + end + + defp normalize_operands(op, [atom_idx, local_idx], ac) do + if op == Opcode.num(:make_loc_ref) do + [atom_idx, local_idx + ac] + else + [atom_idx, local_idx] + end + end + + defp normalize_operands(_op, operands, _ac), do: operands + + defp decode_operands(bc, pos, :u8, _om, _ac), do: {:ok, [get_u8(bc, pos)]} + defp decode_operands(bc, pos, :i8, _om, _ac), do: {:ok, [get_i8(bc, pos)]} + defp decode_operands(bc, pos, :u16, _om, _ac), do: {:ok, [get_u16(bc, pos)]} + defp decode_operands(bc, pos, :i16, _om, _ac), do: {:ok, [get_i16(bc, pos)]} + defp decode_operands(bc, pos, :i32, _om, _ac), do: {:ok, [get_i32(bc, pos)]} + defp decode_operands(bc, pos, :u32, _om, _ac), do: {:ok, [get_u32(bc, pos)]} + + defp decode_operands(bc, pos, :u32x2, _om, _ac) do + {:ok, [get_u32(bc, pos), get_u32(bc, pos + 4)]} + end + + defp decode_operands(_bc, _pos, :none, _om, _ac), do: {:ok, []} + defp decode_operands(bc, pos, :npop, _om, _ac), do: {:ok, [get_u16(bc, pos)]} + + defp decode_operands(bc, pos, :npop_u16, _om, _ac) do + {:ok, [get_u16(bc, pos), get_u16(bc, pos + 2)]} + end + + defp decode_operands(bc, pos, :loc8, _om, ac), do: {:ok, [get_u8(bc, pos) + ac]} + defp decode_operands(bc, pos, :const8, _om, _ac), do: {:ok, [get_u8(bc, pos)]} + defp decode_operands(bc, pos, :loc, _om, ac), do: {:ok, [get_u16(bc, pos) + ac]} + defp decode_operands(bc, pos, :arg, _om, _ac), do: {:ok, [get_u16(bc, pos)]} + defp decode_operands(bc, pos, :var_ref, _om, _ac), do: {:ok, [get_u16(bc, pos)]} + defp decode_operands(bc, pos, :const, _om, _ac), do: {:ok, [get_u32(bc, pos)]} + + defp decode_operands(bc, pos, :label8, om, _ac) do + target_byte = pos + get_i8(bc, pos) + with {:ok, label} <- resolve_label(target_byte, om), do: {:ok, [label]} + end + + defp decode_operands(bc, pos, :label16, om, _ac) do + target_byte = pos + get_i16(bc, pos) + with {:ok, label} <- resolve_label(target_byte, om), do: {:ok, [label]} + end + + defp decode_operands(bc, pos, :label, om, _ac) do + byte_off = pos + get_i32(bc, pos) + with {:ok, label} <- resolve_label(byte_off, om), do: {:ok, [label]} + end + + defp decode_operands(bc, pos, :label_u16, om, _ac) do + byte_off = pos + get_i32(bc, pos) + with {:ok, label} <- resolve_label(byte_off, om), do: {:ok, [label, get_u16(bc, pos + 4)]} + end + + defp decode_operands(bc, pos, :atom, _om, _ac) do + {:ok, [get_atom_u32(bc, pos)]} + end + + defp decode_operands(bc, pos, :atom_u8, _om, _ac) do + {:ok, [get_atom_u32(bc, pos), get_u8(bc, pos + 4)]} + end + + defp decode_operands(bc, pos, :atom_u16, _om, _ac) do + {:ok, [get_atom_u32(bc, pos), get_u16(bc, pos + 4)]} + end + + defp decode_operands(bc, pos, :atom_label_u8, om, _ac) do + byte_off = pos + 4 + get_i32(bc, pos + 4) + + with {:ok, label} <- resolve_label(byte_off, om) do + {:ok, [get_atom_u32(bc, pos), label, get_u8(bc, pos + 8)]} + end + end + + defp decode_operands(bc, pos, :atom_label_u16, om, _ac) do + byte_off = pos + 4 + get_i32(bc, pos + 4) + + with {:ok, label} <- resolve_label(byte_off, om) do + {:ok, [get_atom_u32(bc, pos), label, get_u16(bc, pos + 8)]} + end + end + + defp resolve_label(byte_off, offset_map) do + case Map.fetch(offset_map, byte_off) do + {:ok, label} -> {:ok, label} + :error -> {:error, {:invalid_label, byte_off}} + end + end + + # ── Byte accessors (little-endian) ── + + defp get_u8(bc, pos), do: :binary.at(bc, pos) + + defp get_i8(bc, pos) do + v = :binary.at(bc, pos) + if v >= 128, do: v - 256, else: v + end + + defp get_u16(bc, pos), do: :binary.decode_unsigned(:binary.part(bc, pos, 2), :little) + + defp get_i16(bc, pos) do + v = get_u16(bc, pos) + if v >= 0x8000, do: v - 0x10000, else: v + end + + defp get_u32(bc, pos), do: :binary.decode_unsigned(:binary.part(bc, pos, 4), :little) + + defp get_i32(bc, pos) do + v = get_u32(bc, pos) + if v >= 0x80000000, do: v - 0x100000000, else: v + end + + @js_atom_end Opcode.js_atom_end() + defp get_atom_u32(bc, pos) do + v = get_u32(bc, pos) + + cond do + band(v, 0x80000000) != 0 -> {:tagged_int, band(v, 0x7FFFFFFF)} + v >= 1 and v < @js_atom_end -> {:predefined, v} + v >= @js_atom_end -> v - @js_atom_end + true -> {:predefined, v} + end + end +end diff --git a/lib/quickbeam/vm/bytecode/opcode.ex b/lib/quickbeam/vm/bytecode/opcode.ex new file mode 100644 index 000000000..3a508417f --- /dev/null +++ b/lib/quickbeam/vm/bytecode/opcode.ex @@ -0,0 +1,173 @@ +defmodule QuickBEAM.VM.Bytecode.Opcode do + @moduledoc """ + QuickJS opcode metadata generated from the vendored `quickjs-opcode.h`. + """ + + alias QuickBEAM.VM.ABI + + @opcodes ABI.opcodes() + @tags ABI.tags() + @name_to_num Map.new(@opcodes, fn {number, {name, _, _, _, _}} -> {name, number} end) + @js_atom_end map_size(ABI.predefined_atoms()) + 1 + + @format_info %{ + none: :zero, + none_int: :zero, + none_loc: :zero, + none_arg: :zero, + none_var_ref: :zero, + u8: {:bytes, 1}, + i8: {:bytes, 1}, + loc8: {:bytes, 1}, + const8: {:bytes, 1}, + label8: {:bytes, 1}, + u16: {:bytes, 2}, + i16: {:bytes, 2}, + label16: {:bytes, 2}, + npop: {:bytes, 2}, + npopx: :zero, + npop_u16: {:bytes, 4}, + loc: {:bytes, 2}, + arg: {:bytes, 2}, + var_ref: {:bytes, 2}, + u32: {:bytes, 4}, + u32x2: {:bytes, 8}, + i32: {:bytes, 4}, + const: {:bytes, 4}, + label: {:bytes, 4}, + atom: {:bytes, 4}, + atom_u8: {:bytes, 5}, + atom_u16: {:bytes, 6}, + atom_label_u8: {:bytes, 9}, + atom_label_u16: {:bytes, 10}, + label_u16: {:bytes, 6} + } + + @missing_formats @opcodes + |> Map.values() + |> Enum.map(&elem(&1, 4)) + |> Enum.uniq() + |> Enum.reject(&Map.has_key?(@format_info, &1)) + + if @missing_formats != [] do + raise "missing QuickJS operand formats: #{inspect(@missing_formats)}" + end + + for {name, value} <- @tags do + @doc "Returns a generated serialized-bytecode tag value." + def unquote(:"bc_tag_#{name}")(), do: unquote(value) + end + + @doc "Returns the vendored QuickJS serialized-bytecode version." + def bc_version, do: ABI.bytecode_version() + + @doc "Returns the first dynamic atom index in serialized bytecode." + def js_atom_end, do: @js_atom_end + + @doc "Returns all final-bytecode opcode metadata indexed by opcode byte." + def table, do: @opcodes + + @doc "Returns metadata for an opcode byte." + def info(number) when is_integer(number), do: Map.get(@opcodes, number) + + @doc "Returns the opcode byte for a name." + def num(name) when is_atom(name), do: Map.get(@name_to_num, name) + + @doc "Returns all opcode names mapped to their bytes." + def all_opcodes, do: @name_to_num + + @doc "Returns operand format width metadata." + def format_info(format), do: Map.get(@format_info, format) + + @short_forms %{ + push_minus1: {:push_i32, [-1]}, + push_0: {:push_i32, [0]}, + push_1: {:push_i32, [1]}, + push_2: {:push_i32, [2]}, + push_3: {:push_i32, [3]}, + push_4: {:push_i32, [4]}, + push_5: {:push_i32, [5]}, + push_6: {:push_i32, [6]}, + push_7: {:push_i32, [7]}, + get_loc0: {:get_loc, [0]}, + get_loc1: {:get_loc, [1]}, + get_loc2: {:get_loc, [2]}, + get_loc3: {:get_loc, [3]}, + put_loc0: {:put_loc, [0]}, + put_loc1: {:put_loc, [1]}, + put_loc2: {:put_loc, [2]}, + put_loc3: {:put_loc, [3]}, + set_loc0: {:set_loc, [0]}, + set_loc1: {:set_loc, [1]}, + set_loc2: {:set_loc, [2]}, + set_loc3: {:set_loc, [3]}, + get_arg0: {:get_arg, [0]}, + get_arg1: {:get_arg, [1]}, + get_arg2: {:get_arg, [2]}, + get_arg3: {:get_arg, [3]}, + put_arg0: {:put_arg, [0]}, + put_arg1: {:put_arg, [1]}, + put_arg2: {:put_arg, [2]}, + put_arg3: {:put_arg, [3]}, + set_arg0: {:set_arg, [0]}, + set_arg1: {:set_arg, [1]}, + set_arg2: {:set_arg, [2]}, + set_arg3: {:set_arg, [3]}, + get_var_ref0: {:get_var_ref, [0]}, + get_var_ref1: {:get_var_ref, [1]}, + get_var_ref2: {:get_var_ref, [2]}, + get_var_ref3: {:get_var_ref, [3]}, + put_var_ref0: {:put_var_ref, [0]}, + put_var_ref1: {:put_var_ref, [1]}, + put_var_ref2: {:put_var_ref, [2]}, + put_var_ref3: {:put_var_ref, [3]}, + set_var_ref0: {:set_var_ref, [0]}, + set_var_ref1: {:set_var_ref, [1]}, + set_var_ref2: {:set_var_ref, [2]}, + set_var_ref3: {:set_var_ref, [3]}, + call0: {:call, [0]}, + call1: {:call, [1]}, + call2: {:call, [2]}, + call3: {:call, [3]}, + push_empty_string: {:push_atom_value, [:empty_string]}, + get_loc0_loc1: {:get_loc0_loc1, [0, 1]} + } + + @passthrough_aliases %{ + get_loc8: :get_loc, + put_loc8: :put_loc, + set_loc8: :set_loc, + get_loc_check8: :get_loc_check, + put_loc_check8: :put_loc_check + } + + @doc "Expands compact opcode encodings into canonical instructions." + def expand_short_form(name, args, arg_count \\ 0) do + case Map.get(@short_forms, name) do + nil -> + case Map.get(@passthrough_aliases, name) do + nil -> {name, args} + canonical -> {canonical, args} + end + + {canonical, constant_args} -> + if canonical in [:get_loc, :put_loc, :set_loc, :get_loc0_loc1] do + {canonical, Enum.map(constant_args, &(&1 + arg_count))} + else + {canonical, constant_args} + end + end + end + + @doc "Returns implicit operands encoded by a compact opcode form." + def short_form_operands(opcode, arg_count) when is_integer(opcode) do + case Map.get(@opcodes, opcode) do + {name, _size, _pops, _pushes, _format} -> + {_canonical, operands} = expand_short_form(name, [], arg_count) + operands + + nil -> + [] + end + end +end diff --git a/lib/quickbeam/vm/bytecode/varint.ex b/lib/quickbeam/vm/bytecode/varint.ex new file mode 100644 index 000000000..47d88da9f --- /dev/null +++ b/lib/quickbeam/vm/bytecode/varint.ex @@ -0,0 +1,77 @@ +defmodule QuickBEAM.VM.Bytecode.Varint do + @moduledoc """ + Bounded QuickJS integer decoding backed by `Varint.LEB128`. + + QuickJS encodes signed values by ZigZag-transforming a 32-bit integer and + then writing ordinary unsigned LEB128; it does not use standard SLEB128. + + QuickJS serializes these fields as 32-bit values, so accepting an unbounded + varint would make malformed bytecode unnecessarily expensive to decode. + """ + + import Bitwise + + @max_encoded_bytes 5 + @max_u32 0xFFFFFFFF + @min_i32 -0x80000000 + @max_i32 0x7FFFFFFF + + @doc "Reads one bounded unsigned 32-bit LEB128 value." + @spec read_unsigned(binary()) :: + {:ok, non_neg_integer(), binary()} | {:error, :bad_leb128 | :integer_overflow} + def read_unsigned(binary) when is_binary(binary) do + with :ok <- terminated_within_limit(binary, @max_encoded_bytes, :bad_leb128), + {:ok, value, rest} <- decode_unsigned(binary), + true <- value <= @max_u32 do + {:ok, value, rest} + else + false -> {:error, :integer_overflow} + {:error, _} = error -> error + end + end + + @doc "Reads one bounded ZigZag-encoded signed 32-bit value." + @spec read_signed(binary()) :: + {:ok, integer(), binary()} | {:error, :bad_sleb128 | :integer_overflow} + def read_signed(binary) when is_binary(binary) do + with :ok <- terminated_within_limit(binary, @max_encoded_bytes, :bad_sleb128), + {:ok, encoded, rest} <- decode_unsigned(binary), + true <- encoded <= @max_u32, + value = bxor(bsr(encoded, 1), -band(encoded, 1)), + true <- value >= @min_i32 and value <= @max_i32 do + {:ok, value, rest} + else + false -> {:error, :integer_overflow} + {:error, :bad_leb128} -> {:error, :bad_sleb128} + {:error, _} = error -> error + end + end + + @doc "Reads one unsigned byte." + @spec read_u8(binary()) :: {:ok, byte(), binary()} | {:error, :unexpected_end} + def read_u8(<>), do: {:ok, value, rest} + def read_u8(_binary), do: {:error, :unexpected_end} + + @doc "Reads one fixed-width little-endian unsigned 32-bit value." + @spec read_fixed_u32(binary()) :: + {:ok, non_neg_integer(), binary()} | {:error, :unexpected_end} + def read_fixed_u32(<>), do: {:ok, value, rest} + def read_fixed_u32(_binary), do: {:error, :unexpected_end} + + defp decode_unsigned(binary) do + {value, rest} = Varint.LEB128.decode(binary) + {:ok, value, rest} + rescue + ArgumentError -> {:error, :bad_leb128} + end + + defp terminated_within_limit(_binary, 0, error), do: {:error, error} + defp terminated_within_limit(<<>>, _remaining, error), do: {:error, error} + + defp terminated_within_limit(<>, _remaining, _error) + when band(byte, 0x80) == 0, + do: :ok + + defp terminated_within_limit(<<_byte, rest::binary>>, remaining, error), + do: terminated_within_limit(rest, remaining - 1, error) +end diff --git a/lib/quickbeam/vm/bytecode/verifier.ex b/lib/quickbeam/vm/bytecode/verifier.ex new file mode 100644 index 000000000..981556476 --- /dev/null +++ b/lib/quickbeam/vm/bytecode/verifier.ex @@ -0,0 +1,449 @@ +defmodule QuickBEAM.VM.Bytecode.Verifier do + @moduledoc """ + Structurally verifies decoded programs before interpreter execution. + + Verification rejects malformed control flow, operands, references, and stack + behavior before untrusted bytecode reaches mutable evaluation state. + """ + + alias QuickBEAM.VM.ABI + alias QuickBEAM.VM.Bytecode.Opcode + alias QuickBEAM.VM.Bytecode.Verifier.Stack + alias QuickBEAM.VM.Program + alias QuickBEAM.VM.Program.Function + alias QuickBEAM.VM.Program.Variable + alias QuickBEAM.VM.Program.Variable.Closure + + @js_atom_end Opcode.js_atom_end() + + @default_limits %{ + max_atoms: 100_000, + max_constants_per_function: 100_000, + max_function_depth: 128, + max_functions: 10_000, + max_instructions: 1_000_000, + max_stack_size: 100_000 + } + + @doc "Verifies a decoded program under bounded structural limits." + @spec verify(Program.t(), keyword()) :: :ok | {:error, term()} + def verify(program, opts \\ []) + + def verify(%Program{} = program, opts) do + with {:ok, limits} <- limits(opts), + :ok <- verify_header(program), + :ok <- verify_root(program.root), + :ok <- within(tuple_size(program.atoms), limits.max_atoms, :atoms), + :ok <- verify_atoms(program.atoms), + {:ok, _counts} <- + verify_value(program.root, program.atoms, limits, 0, %{functions: 0, instructions: 0}) do + :ok + end + end + + def verify(_program, _opts), do: {:error, :invalid_program} + + @doc "Checks the constant-time ABI identity of an already verified pinned program." + @spec verify_identity(Program.t()) :: :ok | {:error, term()} + def verify_identity(%Program{} = program), do: verify_header(program) + def verify_identity(_program), do: {:error, :invalid_program} + + defp limits(opts) when is_list(opts) do + Enum.reduce_while(opts, {:ok, @default_limits}, fn + {key, value}, {:ok, limits} when is_map_key(@default_limits, key) -> + maximum = Map.fetch!(@default_limits, key) + + if is_integer(value) and value > 0 and value <= maximum, + do: {:cont, {:ok, Map.put(limits, key, value)}}, + else: {:halt, {:error, {:invalid_limit, key}}} + + {key, _value}, _acc -> + {:halt, {:error, {:unknown_option, key}}} + + option, _acc -> + {:halt, {:error, {:invalid_option, option}}} + end) + end + + defp limits(opts), do: {:error, {:invalid_options, opts}} + + defp verify_header(%Program{version: version, fingerprint: fingerprint, atoms: atoms}) do + cond do + version != ABI.bytecode_version() -> {:error, {:bad_version, version}} + fingerprint != ABI.fingerprint() -> {:error, {:bad_fingerprint, fingerprint}} + not is_tuple(atoms) -> {:error, :invalid_atom_table} + true -> :ok + end + end + + defp verify_value(%Function{} = function, atoms, limits, depth, counts) do + with :ok <- verify_function_container_shape(function), + :ok <- within(depth, limits.max_function_depth, :function_depth), + :ok <- within(length(function.constants), limits.max_constants_per_function, :constants), + :ok <- within(function.stack_size, limits.max_stack_size, :stack_size), + :ok <- verify_function_shape(function), + :ok <- verify_instructions(function, atoms), + :ok <- verify_stack(function), + {:ok, counts} <- add_counts(function, limits, counts) do + verify_values(function.constants, atoms, limits, depth + 1, counts) + end + end + + defp verify_value({:array, values}, atoms, limits, depth, counts) when is_list(values) do + with :ok <- within(depth, limits.max_function_depth, :constant_depth), + :ok <- within(length(values), limits.max_constants_per_function, :constants) do + verify_values(values, atoms, limits, depth + 1, counts) + end + end + + defp verify_value({:array, _values}, _atoms, _limits, _depth, _counts), + do: {:error, :invalid_array_constant} + + defp verify_value({:object, values}, atoms, limits, depth, counts) when is_map(values) do + with :ok <- within(depth, limits.max_function_depth, :constant_depth), + :ok <- within(map_size(values), limits.max_constants_per_function, :constants) do + verify_values(Map.values(values), atoms, limits, depth + 1, counts) + end + end + + defp verify_value({:object, _values}, _atoms, _limits, _depth, _counts), + do: {:error, :invalid_object_constant} + + defp verify_value({:template_object, {:array, values}, raw}, atoms, limits, depth, counts) + when is_list(values) do + with :ok <- within(depth, limits.max_function_depth, :constant_depth), + :ok <- within(length(values), limits.max_constants_per_function, :constants), + {:ok, counts} <- verify_values(values, atoms, limits, depth + 1, counts) do + verify_value(raw, atoms, limits, depth + 1, counts) + end + end + + defp verify_value({:template_object, _cooked, _raw}, _atoms, _limits, _depth, _counts), + do: {:error, :invalid_template_constant} + + defp verify_value(_value, _atoms, _limits, _depth, counts), do: {:ok, counts} + + defp verify_values(values, atoms, limits, depth, counts) do + Enum.reduce_while(values, {:ok, counts}, fn value, {:ok, counts} -> + case verify_value(value, atoms, limits, depth, counts) do + {:ok, counts} -> {:cont, {:ok, counts}} + {:error, _} = error -> {:halt, error} + end + end) + end + + defp verify_atoms(atoms) do + if atoms |> Tuple.to_list() |> Enum.all?(&is_binary/1), + do: :ok, + else: {:error, :invalid_atom_table} + end + + defp verify_root(%Function{}), do: :ok + defp verify_root(_root), do: {:error, :invalid_root_function} + + defp verify_function_container_shape(%Function{} = function) do + with :ok <- require_type(function.constants, :list, function.id, :constants), + :ok <- require_type(function.locals, :list, function.id, :locals), + :ok <- require_type(function.closure_vars, :list, function.id, :closure_variables), + :ok <- require_type(function.instructions, :tuple, function.id, :instructions), + :ok <- require_type(function.source_positions, :tuple, function.id, :source_positions), + :ok <- require_type(function.pc2line, :binary, function.id, :pc2line), + :ok <- require_type(function.source, :binary, function.id, :source), + :ok <- require_filename(function.filename, function.id), + true <- valid_function_flags?(function) do + :ok + else + false -> {:error, {:invalid_function, function.id, :flags}} + {:error, _reason} = error -> error + end + end + + defp require_type(value, :list, _id, _field) when is_list(value), do: :ok + defp require_type(value, :tuple, _id, _field) when is_tuple(value), do: :ok + defp require_type(value, :binary, _id, _field) when is_binary(value), do: :ok + defp require_type(_value, _type, id, field), do: {:error, {:invalid_function, id, field}} + + defp require_filename(nil, _id), do: :ok + defp require_filename(filename, _id) when is_binary(filename), do: :ok + defp require_filename(_filename, id), do: {:error, {:invalid_function, id, :filename}} + + defp valid_function_flags?(function) do + Enum.all?( + [ + function.has_prototype, + function.has_simple_parameter_list, + function.is_derived_class_constructor, + function.need_home_object, + function.new_target_allowed, + function.super_call_allowed, + function.super_allowed, + function.arguments_allowed, + function.is_strict_mode, + function.has_debug_info + ], + &is_boolean/1 + ) and is_integer(function.func_kind) and function.func_kind >= 0 + end + + defp verify_function_shape(%Function{} = function) do + instruction_count = + if is_tuple(function.instructions), do: tuple_size(function.instructions), else: -1 + + cond do + instruction_count < 0 -> + {:error, {:invalid_function, function.id, :instructions}} + + invalid_non_negative_fields?(function) -> + {:error, {:invalid_function, function.id, :negative_count}} + + length(function.locals) != function.arg_count + function.var_count -> + {:error, {:invalid_function, function.id, :locals}} + + function.defined_arg_count > function.arg_count -> + {:error, {:invalid_function, function.id, :defined_arguments}} + + not is_tuple(function.source_positions) -> + {:error, {:invalid_function, function.id, :source_positions}} + + tuple_size(function.source_positions) != instruction_count -> + {:error, {:invalid_function, function.id, :source_positions}} + + true -> + with :ok <- verify_source_metadata(function), + :ok <- verify_variables(function.locals), + :ok <- verify_closure_variables(function.closure_vars) do + verify_capture_indexes(function) + end + end + end + + defp invalid_non_negative_fields?(function) do + Enum.any?( + [ + function.id, + function.arg_count, + function.var_count, + function.defined_arg_count, + function.stack_size, + function.var_ref_count + ], + &(not is_integer(&1) or &1 < 0) + ) + end + + defp verify_source_metadata(function) do + valid_positions? = + function.source_positions + |> Tuple.to_list() + |> Enum.all?(fn + {line, column} + when is_integer(line) and line >= 0 and is_integer(column) and column >= 0 -> + true + + _position -> + false + end) + + if is_integer(function.line_num) and function.line_num >= 0 and + is_integer(function.col_num) and function.col_num >= 0 and valid_positions?, + do: :ok, + else: {:error, {:invalid_function, function.id, :source_positions}} + end + + defp verify_variables(variables) do + if Enum.all?(variables, &valid_variable?/1), + do: :ok, + else: {:error, :invalid_variable_metadata} + end + + defp valid_variable?(%Variable{} = variable) do + valid_variable_indexes?(variable) and valid_variable_flags?(variable) + end + + defp valid_variable?(_variable), do: false + + defp valid_variable_indexes?(variable) do + is_integer(variable.scope_level) and variable.scope_level >= 0 and + is_integer(variable.scope_next) and is_integer(variable.var_kind) and variable.var_kind >= 0 and + valid_optional_index?(variable.var_ref_idx) + end + + defp valid_variable_flags?(variable) do + is_boolean(variable.is_const) and is_boolean(variable.is_lexical) and + is_boolean(variable.is_captured) + end + + defp valid_optional_index?(nil), do: true + defp valid_optional_index?(index), do: is_integer(index) and index >= 0 + + defp verify_closure_variables(variables) do + if Enum.all?(variables, &valid_closure_variable?/1), + do: :ok, + else: {:error, :invalid_closure_variable_metadata} + end + + defp valid_closure_variable?(%Closure{} = variable) do + is_integer(variable.var_idx) and variable.var_idx >= 0 and + is_integer(variable.closure_type) and variable.closure_type >= 0 and + is_integer(variable.var_kind) and variable.var_kind >= 0 and + is_boolean(variable.is_const) and is_boolean(variable.is_lexical) + end + + defp valid_closure_variable?(_variable), do: false + + defp verify_capture_indexes(function) do + Enum.reduce_while(function.locals, :ok, fn variable, :ok -> + if variable.is_captured and + (not is_integer(variable.var_ref_idx) or variable.var_ref_idx < 0 or + variable.var_ref_idx >= function.var_ref_count) do + {:halt, {:error, {:invalid_var_ref, function.id, variable.var_ref_idx}}} + else + {:cont, :ok} + end + end) + end + + defp verify_instructions(function, atoms) do + function.instructions + |> Tuple.to_list() + |> Enum.with_index() + |> Enum.reduce_while(:ok, fn {instruction, index}, :ok -> + case verify_instruction(instruction, function, atoms) do + :ok -> {:cont, :ok} + {:error, reason} -> {:halt, {:error, {:invalid_instruction, function.id, index, reason}}} + end + end) + end + + defp verify_instruction({opcode, operands}, function, atoms) + when is_integer(opcode) and is_list(operands) do + case Opcode.info(opcode) do + nil -> + {:error, {:unknown_opcode, opcode}} + + {name, _size, _pops, _pushes, format} -> + with :ok <- verify_operand_count(format, operands), + :ok <- verify_operand_types(format, operands) do + verify_operands(name, format, operands, function, atoms) + end + end + end + + defp verify_instruction(instruction, _function, _atoms), + do: {:error, {:invalid_shape, instruction}} + + defp verify_stack(function) do + case Stack.verify(function) do + :ok -> :ok + {:error, reason} -> {:error, {:invalid_stack, function.id, reason}} + end + end + + defp verify_operand_count(format, operands) do + expected = + case format do + :none -> 0 + format when format in [:u32x2, :npop_u16, :atom_u8, :atom_u16, :label_u16] -> 2 + format when format in [:atom_label_u8, :atom_label_u16] -> 3 + :none_loc -> if(length(operands) == 2, do: 2, else: 1) + format when format in [:none_int, :none_arg, :none_var_ref, :npopx] -> 1 + _ -> 1 + end + + if length(operands) == expected, + do: :ok, + else: {:error, {:operand_count, expected, length(operands)}} + end + + defp verify_operand_types(format, [_atom | operands]) + when format in [:atom, :atom_u8, :atom_u16, :atom_label_u8, :atom_label_u16], + do: verify_integer_operands(operands) + + defp verify_operand_types(_format, operands), do: verify_integer_operands(operands) + + defp verify_integer_operands(operands) do + if Enum.all?(operands, &is_integer/1), do: :ok, else: {:error, :invalid_operand_type} + end + + defp verify_operands(_name, format, [index], function, _atoms) + when format in [:loc, :loc8], + do: index_within(index, function.arg_count + function.var_count, :local) + + defp verify_operands(_name, :arg, [index], function, _atoms), + do: index_within(index, function.arg_count, :argument) + + defp verify_operands(_name, :var_ref, [index], function, _atoms), + do: index_within(index, length(function.closure_vars), :closure_variable) + + defp verify_operands(_name, format, [index], function, _atoms) + when format in [:const, :const8], + do: index_within(index, length(function.constants), :constant) + + defp verify_operands(name, format, operands, function, atoms) + when format in [:atom, :atom_u8, :atom_u16, :atom_label_u8, :atom_label_u16] do + with :ok <- verify_atom_operand(List.first(operands), atoms), + :ok <- verify_secondary_operand(name, operands, function) do + verify_embedded_label(format, operands, function) + end + end + + defp verify_operands(_name, format, operands, function, _atoms) + when format in [:label8, :label16, :label, :label_u16], + do: verify_label(List.first(operands), function) + + defp verify_operands(_name, _format, _operands, _function, _atoms), do: :ok + + defp verify_atom_operand(index, atoms) when is_integer(index), + do: index_within(index, tuple_size(atoms), :atom) + + defp verify_atom_operand({:predefined, index}, _atoms) + when is_integer(index) and index >= 0 and index < @js_atom_end, + do: :ok + + defp verify_atom_operand({:tagged_int, value}, _atoms) + when is_integer(value) and value >= 0, + do: :ok + + defp verify_atom_operand(_operand, _atoms), do: {:error, :invalid_atom} + + defp verify_secondary_operand(:make_loc_ref, [_atom, index], function), + do: index_within(index, function.arg_count + function.var_count, :local) + + defp verify_secondary_operand(:make_arg_ref, [_atom, index], function), + do: index_within(index, function.arg_count, :argument) + + defp verify_secondary_operand(:make_var_ref_ref, [_atom, index], function), + do: index_within(index, length(function.closure_vars), :closure_variable) + + defp verify_secondary_operand(_name, _operands, _function), do: :ok + + defp verify_embedded_label(format, [_atom, label | _rest], function) + when format in [:atom_label_u8, :atom_label_u16], + do: verify_label(label, function) + + defp verify_embedded_label(_format, _operands, _function), do: :ok + + defp verify_label(label, function), + do: index_within(label, tuple_size(function.instructions), :label) + + defp index_within(index, count, _kind) + when is_integer(index) and index >= 0 and index < count, + do: :ok + + defp index_within(index, _count, kind), do: {:error, {:invalid_index, kind, index}} + + defp add_counts(function, limits, counts) do + counts = %{ + functions: counts.functions + 1, + instructions: counts.instructions + tuple_size(function.instructions) + } + + with :ok <- within(counts.functions, limits.max_functions, :functions), + :ok <- within(counts.instructions, limits.max_instructions, :instructions) do + {:ok, counts} + end + end + + defp within(value, maximum, _kind) when value <= maximum, do: :ok + defp within(value, _maximum, kind), do: {:error, {:limit_exceeded, kind, value}} +end diff --git a/lib/quickbeam/vm/bytecode/verifier/stack.ex b/lib/quickbeam/vm/bytecode/verifier/stack.ex new file mode 100644 index 000000000..9e931efee --- /dev/null +++ b/lib/quickbeam/vm/bytecode/verifier/stack.ex @@ -0,0 +1,199 @@ +defmodule QuickBEAM.VM.Bytecode.Verifier.Stack do + @moduledoc """ + Verifies QuickJS operand-stack dataflow over decoded instruction indexes. + + This mirrors QuickJS's `compute_stack_size`: every reachable instruction has + one consistent stack depth and active catch target, variable-arity calls add + their encoded pop count, and exceptional/control-flow edges receive their + opcode-specific stack depth. + """ + + alias QuickBEAM.VM.Bytecode.Opcode + alias QuickBEAM.VM.Program.Function + + @terminal_ops [ + :tail_call, + :tail_call_method, + :return, + :return_undef, + :return_async, + :throw, + :throw_error, + :ret + ] + @goto_ops [:goto, :goto8, :goto16] + @conditional_ops [:if_true, :if_true8, :if_false, :if_false8] + @with_one_ops [:with_get_var, :with_delete_var] + @with_two_ops [:with_make_ref, :with_get_ref, :with_get_ref_undef] + @iterator_start_ops [:for_of_start, :for_await_of_start] + + @doc "Checks stack underflow, joins, catch state, and the declared maximum." + @spec verify(Function.t()) :: :ok | {:error, term()} + def verify(%Function{stack_size: declared} = function) do + with {:ok, analysis} <- analyze(function), + true <- analysis.maximum == declared do + :ok + else + false -> {:error, {:stack_size_mismatch, declared}} + {:error, _reason} = error -> error + end + end + + @doc "Returns verified instruction-entry stack and catch levels for compiler analysis." + @spec analyze(Function.t()) :: + {:ok, + %{ + levels: %{non_neg_integer() => {non_neg_integer(), term()}}, + maximum: non_neg_integer() + }} + | {:error, term()} + def analyze(%Function{instructions: instructions} = function) + when is_tuple(instructions) and tuple_size(instructions) > 0 do + initial = %{function: function, levels: %{0 => {0, nil}}, queue: [0], maximum: 0} + + with {:ok, state} <- walk(initial) do + {:ok, %{levels: state.levels, maximum: state.maximum}} + end + end + + def analyze(%Function{}), do: {:error, :empty_instruction_stream} + + defp walk(%{queue: []} = state), do: {:ok, state} + + defp walk(%{queue: [index | queue]} = state) do + {depth, catch_index} = Map.fetch!(state.levels, index) + {opcode, operands} = elem(state.function.instructions, index) + {name, _size, pops, pushes, format} = Opcode.info(opcode) + pops = pops + variable_pops(format, operands) + + if depth < pops do + {:error, {:stack_underflow, index, depth, pops}} + else + next_depth = depth + pushes - pops + state = %{state | queue: queue, maximum: max(state.maximum, next_depth)} + + with {:ok, transitions} <- + transitions(name, operands, index, next_depth, catch_index, state), + {:ok, state} <- enqueue_all(state, transitions) do + walk(state) + end + end + end + + defp variable_pops(format, [count | _rest]) when format in [:npop, :npop_u16, :npopx], + do: count + + defp variable_pops(_format, _operands), do: 0 + + defp transitions(name, _operands, _index, _depth, _catch_index, _state) + when name in @terminal_ops, + do: {:ok, []} + + defp transitions(name, [target | _rest], _index, depth, catch_index, _state) + when name in @goto_ops, + do: {:ok, [{target, depth, catch_index}]} + + defp transitions(name, [target | _rest], index, depth, catch_index, _state) + when name in @conditional_ops, + do: {:ok, [{target, depth, catch_index}, {index + 1, depth, catch_index}]} + + defp transitions(:gosub, [target], index, depth, catch_index, _state), + do: {:ok, [{target, depth + 1, catch_index}, {index + 1, depth, catch_index}]} + + defp transitions(name, [_atom, target | _rest], index, depth, catch_index, _state) + when name in @with_one_ops, + do: {:ok, [{target, depth + 1, catch_index}, {index + 1, depth, catch_index}]} + + defp transitions(name, [_atom, target | _rest], index, depth, catch_index, _state) + when name in @with_two_ops, + do: {:ok, [{target, depth + 2, catch_index}, {index + 1, depth, catch_index}]} + + defp transitions(:with_put_var, [_atom, target | _rest], index, depth, catch_index, _state) + when depth > 0, + do: {:ok, [{target, depth - 1, catch_index}, {index + 1, depth, catch_index}]} + + defp transitions(:with_put_var, _operands, index, depth, _catch_index, _state), + do: {:error, {:stack_underflow, index, depth, depth + 1}} + + defp transitions(:catch, [target], index, depth, catch_index, _state), + do: {:ok, [{target, depth, catch_index}, {index + 1, depth, index}]} + + defp transitions(name, _operands, index, depth, _catch_index, _state) + when name in @iterator_start_ops, + do: {:ok, [{index + 1, depth, index}]} + + defp transitions(name, _operands, index, depth, catch_index, state) + when name in [:drop, :nip, :nip1, :iterator_close] do + catch_level = catch_level(name, depth) + catch_index = maybe_leave_catch(catch_index, catch_level, state) + {:ok, [{index + 1, depth, catch_index}]} + end + + defp transitions(:nip_catch, _operands, index, _depth, nil, _state), + do: {:error, {:missing_catch, index}} + + defp transitions(:nip_catch, _operands, index, _depth, catch_index, state) do + {entry_depth, parent_catch} = Map.fetch!(state.levels, catch_index) + extra = if opcode_name(state, catch_index) == :catch, do: 1, else: 2 + {:ok, [{index + 1, entry_depth + extra, parent_catch}]} + end + + defp transitions(_name, _operands, index, depth, catch_index, _state), + do: {:ok, [{index + 1, depth, catch_index}]} + + defp catch_level(:drop, depth), do: depth + defp catch_level(name, depth) when name in [:nip, :nip1], do: depth - 1 + defp catch_level(:iterator_close, depth), do: depth + 2 + + defp maybe_leave_catch(nil, _catch_level, _state), do: nil + + defp maybe_leave_catch(catch_index, catch_level, state) do + {entry_depth, parent_catch} = Map.fetch!(state.levels, catch_index) + + entry_depth = + if opcode_name(state, catch_index) == :catch, do: entry_depth, else: entry_depth + 1 + + if catch_level == entry_depth, do: parent_catch, else: catch_index + end + + defp opcode_name(state, index) do + {opcode, _operands} = elem(state.function.instructions, index) + {name, _size, _pops, _pushes, _format} = Opcode.info(opcode) + name + end + + defp enqueue_all(state, transitions) do + Enum.reduce_while(transitions, {:ok, state}, fn transition, {:ok, state} -> + case enqueue(state, transition) do + {:ok, state} -> {:cont, {:ok, state}} + {:error, _reason} = error -> {:halt, error} + end + end) + end + + defp enqueue(state, {index, depth, catch_index}) + when index >= 0 and index < tuple_size(state.function.instructions) and depth >= 0 do + case Map.fetch(state.levels, index) do + :error -> + {:ok, + %{ + state + | levels: Map.put(state.levels, index, {depth, catch_index}), + queue: [index | state.queue], + maximum: max(state.maximum, depth) + }} + + {:ok, {^depth, ^catch_index}} -> + {:ok, state} + + {:ok, {existing_depth, existing_catch}} -> + {:error, + {:inconsistent_stack, index, {existing_depth, existing_catch}, {depth, catch_index}}} + end + end + + defp enqueue(_state, {index, depth, _catch_index}) when depth < 0, + do: {:error, {:stack_underflow, index, depth, 0}} + + defp enqueue(_state, {index, _depth, _catch_index}), do: {:error, {:invalid_fallthrough, index}} +end diff --git a/lib/quickbeam/vm/compiler.ex b/lib/quickbeam/vm/compiler.ex new file mode 100644 index 000000000..e90850c72 --- /dev/null +++ b/lib/quickbeam/vm/compiler.ex @@ -0,0 +1,488 @@ +defmodule QuickBEAM.VM.Compiler do + @moduledoc """ + Supervises and orchestrates the optional bounded BEAM compiler tier. + + Internal benchmarks add this module to a supervision tree before selecting + `engine: :compiler` through `QuickBEAM.VM.Runtime.Engine`: + + children = [ + {QuickBEAM.VM.Compiler, capacity: 8} + ] + + Compiler execution remains explicit. Unsupported instructions deopt into the + interpreter; compilation, capacity, loading, and lease failures are returned + as typed compiler errors and never invoke native QuickJS. + """ + + alias QuickBEAM.VM.Compiler.Code + alias QuickBEAM.VM.Compiler.Context + alias QuickBEAM.VM.Compiler.Contract + alias QuickBEAM.VM.Compiler.Counter + alias QuickBEAM.VM.Compiler.Deopt + alias QuickBEAM.VM.Compiler.Instrumentation + alias QuickBEAM.VM.Compiler.Pool + alias QuickBEAM.VM.Compiler.Profile.Pure + alias QuickBEAM.VM.Compiler.Region.Probe + alias QuickBEAM.VM.Program + alias QuickBEAM.VM.Program.Function + alias QuickBEAM.VM.Runtime + alias QuickBEAM.VM.Runtime.Frame + alias QuickBEAM.VM.Runtime.Interpreter + alias QuickBEAM.VM.Runtime.State + + @type result :: {:ok, term()} | {:error, term()} | {:suspended, term()} + @type frame_action :: + {:deopt, term()} + | {:invoke, term(), [term()], term(), struct(), struct(), false} + | {:skip, struct(), struct()} + | {:error, term()} + + @doc "Returns a child specification for the singleton generated-module pool." + @spec child_spec(keyword()) :: Supervisor.child_spec() + def child_spec(opts) do + %{ + id: Pool, + start: {__MODULE__, :start_link, [opts]}, + type: :worker, + restart: :permanent, + shutdown: 5_000 + } + end + + @doc "Starts the singleton compiler pool with the production generated-module backend." + @spec start_link(keyword()) :: GenServer.on_start() + def start_link(opts \\ []) when is_list(opts) do + opts = + opts + |> Keyword.put_new(:backend, Code) + |> Keyword.put_new(:task_supervisor, QuickBEAM.VM.TaskSupervisor) + + Pool.start_link(opts) + end + + @doc "Evaluates a verified program through the selected bounded compiler profile." + @spec eval(Program.t(), keyword()) :: result() + def eval(%Program{} = program, opts \\ []) when is_list(opts) do + program + |> start(opts) + |> Runtime.drive() + end + + @doc "Evaluates through the compiler while collecting the standard deterministic counters." + @spec eval_with_metrics(Program.t(), keyword()) :: {result(), map() | nil} + def eval_with_metrics(%Program{} = program, opts \\ []) when is_list(opts) do + reference = make_ref() + result = eval(program, Keyword.put(opts, :measurement_target, {self(), reference})) + + receive do + {:quickbeam_vm_measurement, ^reference, metrics} -> {result, metrics} + after + 0 -> {result, nil} + end + end + + @doc "Starts compiled execution and returns a raw owner-local machine result." + @spec start(Program.t(), keyword()) :: term() + def start(%Program{root: %Function{}} = program, opts \\ []) when is_list(opts) do + {frame, execution} = Interpreter.initialize(program, opts) + pool = Keyword.get(opts, :compiler_pool, Pool) + {:ok, artifact_namespace} = Contract.program_identity(program) + + counters = if execution.measurement_target, do: Counter.new() + region_probe = if Keyword.get(opts, :compiler_region_probe) == true, do: Probe.new() + profile = Keyword.get(opts, :compiler_profile, :pure_v1) + + context = %Context{ + artifact_namespace: artifact_namespace, + counters: counters, + deopt_module: Deopt, + executor: __MODULE__, + instrumentation: if(counters || region_probe, do: Instrumentation), + pool: pool, + profile: profile, + program: program, + region_probe: region_probe, + regions: Keyword.get(opts, :compiler_regions, false) + } + + execution = %{execution | compiler_context: context} + + frame + |> Map.put(:compiler_entered, true) + |> execute_frame(execution) + |> resume_action(execution) + end + + @doc "Compiles and invokes one entry block for a canonical bytecode frame." + @spec execute_frame(struct(), struct()) :: term() + def execute_frame( + %Frame{function: %Function{id: function_id} = function} = frame, + %State{compiler_context: %Context{pool: pool}} = execution + ) do + execution = Counter.increment(execution, :frame_attempts) + context = execution.compiler_context + + action = + with :ok <- ensure_pool_available(pool) do + case Map.fetch(context.decisions, function_id) do + {:ok, :skip} -> + prepare_region_frame(function, frame, execution) + + {:ok, {:compile, key, template}} -> + invoke_frame(pool, key, template, frame, execution) + + {:ok, {:cached, key}} -> + invoke_cached(pool, key, function, frame, execution) + + :error -> + prepare_frame(function, frame, execution) + end + end + + observe_action(action) + end + + defp prepare_frame( + %Function{} = function, + frame, + %State{ + compiler_context: %Context{ + program: %Program{root: %Function{}} = program, + min_function_instructions: minimum, + profile: profile + } + } = execution + ) do + if Pure.candidate?(function, minimum, profile) do + prepare_keyed_frame(program, function, minimum, profile, frame, execution) + else + execution = Counter.increment(execution, :skipped_functions) + execution = cache_decision(execution, function.id, :skip) + prepare_region_frame(function, frame, execution) + end + end + + defp prepare_keyed_frame(program, function, minimum, profile, frame, execution) do + with {:ok, key} <- artifact_key(execution.compiler_context, program, function, profile) do + case Pool.checkout_cached(execution.compiler_context.pool, key) do + {:ok, lease} -> + execution = Counter.increment(execution, :cached_functions) + execution = cache_decision(execution, function.id, {:cached, key}) + invoke_lease(execution.compiler_context.pool, lease, frame, execution) + + :skip -> + execution = Counter.increment(execution, :skipped_functions) + execution = cache_decision(execution, function.id, :skip) + prepare_region_frame(function, frame, execution) + + :miss -> + prepare_uncached_frame(function, minimum, profile, key, frame, execution) + + {:error, reason} -> + {:error, reason} + end + end + end + + defp artifact_key( + %Context{artifact_namespace: namespace} = context, + _program, + function, + profile + ) + when is_binary(namespace), + do: + Contract.artifact_key_from_identity(namespace, function, + profile: profile, + region_preferred: context.regions and profile == :scalar_v1 + ) + + defp artifact_key(context, program, function, profile), + do: + Contract.artifact_key(program, function, + profile: profile, + region_preferred: context.regions and profile == :scalar_v1 + ) + + defp prepare_uncached_frame(function, minimum, profile, key, frame, execution) do + case Pure.prepare(function, minimum, profile) do + {:ok, template, _count} -> + prepare_template(function, profile, key, template, frame, execution) + + {:skip, _count} -> + skip_uncached_frame(function, key, frame, execution) + + {:error, reason} -> + {:error, reason} + end + end + + defp prepare_template(function, :scalar_v1, key, template, frame, execution) do + if Pure.scalar_template?(template) or not execution.compiler_context.regions do + prepare_compiled_template(function, key, template, frame, execution) + else + skip_uncached_frame(function, key, frame, execution) + end + end + + defp prepare_template(function, _profile, key, template, frame, execution), + do: prepare_compiled_template(function, key, template, frame, execution) + + defp prepare_compiled_template(function, key, template, frame, execution) do + execution = Counter.increment(execution, :compiled_functions) + execution = cache_decision(execution, function.id, {:compile, key, template}) + + case invoke_frame(execution.compiler_context.pool, key, template, frame, execution) do + {:error, reason} -> {:error, {:compiler_function_failed, function.id, reason}} + action -> action + end + end + + defp skip_uncached_frame(function, key, frame, execution) do + with :ok <- Pool.remember_skip(execution.compiler_context.pool, key) do + execution = Counter.increment(execution, :skipped_functions) + execution = cache_decision(execution, function.id, :skip) + prepare_region_frame(function, frame, execution) + end + end + + defp invoke_cached(pool, key, function, frame, execution) do + case Pool.checkout_cached(pool, key) do + {:ok, lease} -> + invoke_lease(pool, lease, frame, execution) + + :skip -> + execution = Counter.increment(execution, :skipped_functions) + execution = cache_decision(execution, function.id, :skip) + prepare_region_frame(function, frame, execution) + + :miss -> + prepare_frame(function, frame, execution) + + {:error, reason} -> + {:error, reason} + end + end + + defp prepare_region_frame( + %Function{id: function_id} = function, + %Frame{pc: 0} = frame, + %State{compiler_context: %Context{profile: :scalar_v1, regions: true} = context} = + execution + ) do + decision_key = {:region, function_id, 0} + execution = Counter.increment(execution, :region_attempts) + + case Map.fetch(context.decisions, decision_key) do + {:ok, :skip} -> + {:skip, frame, execution} + + {:ok, {:compile, key, template}} -> + invoke_frame(context.pool, key, template, frame, execution) + + {:ok, {:cached, key}} -> + invoke_cached_region(key, decision_key, function, frame, execution) + + :error -> + admit_region(decision_key, function, frame, execution) + end + end + + defp prepare_region_frame(_function, frame, execution), do: {:skip, frame, execution} + + defp admit_region( + decision_key, + %Function{id: function_id} = function, + frame, + %State{ + compiler_context: %Context{ + artifact_namespace: namespace, + pool: pool, + profile: profile + } + } = execution + ) do + with {:ok, admission_key} <- + Contract.region_admission_key(namespace, function_id, frame.pc, profile) do + case Pool.admit_region(pool, admission_key) do + :cold -> + {:skip, frame, Counter.increment(execution, :region_cold)} + + :hot -> + execution = Counter.increment(execution, :region_hot) + prepare_region_keyed(decision_key, function, frame, execution) + + {:error, reason} -> + {:error, reason} + end + end + end + + defp prepare_region_keyed( + decision_key, + function, + frame, + %State{ + compiler_context: %Context{ + artifact_namespace: namespace, + pool: pool, + profile: profile + } + } = execution + ) do + with {:ok, key} <- + Contract.artifact_key_from_identity(namespace, function, + profile: profile, + region_entry: frame.pc + ) do + case Pool.checkout_cached(pool, key) do + {:ok, lease} -> + execution = Counter.increment(execution, :cached_functions) + execution = cache_decision(execution, decision_key, {:cached, key}) + invoke_lease(pool, lease, frame, execution) + + :skip -> + execution = cache_decision(execution, decision_key, :skip) + {:skip, frame, execution} + + :miss -> + prepare_uncached_region(decision_key, key, function, frame, execution) + + {:error, reason} -> + {:error, reason} + end + end + end + + defp prepare_uncached_region(decision_key, key, function, frame, execution) do + profile = execution.compiler_context.profile + + case Pure.prepare_region(function, frame.pc, profile) do + {:ok, template, _count} -> + execution = Counter.increment(execution, :compiled_functions) + execution = Counter.increment(execution, :region_compiled) + execution = cache_decision(execution, decision_key, {:compile, key, template}) + + case invoke_frame(execution.compiler_context.pool, key, template, frame, execution) do + {:error, reason} -> {:error, {:region_compile_failed, function.id, frame.pc, reason}} + action -> action + end + + {:skip, _count} -> + with :ok <- Pool.remember_skip(execution.compiler_context.pool, key) do + {:skip, frame, cache_decision(execution, decision_key, :skip)} + end + + {:error, reason} -> + {:error, reason} + end + end + + defp invoke_cached_region(key, decision_key, function, frame, execution) do + pool = execution.compiler_context.pool + + case Pool.checkout_cached(pool, key) do + {:ok, lease} -> invoke_lease(pool, lease, frame, execution) + :skip -> {:skip, frame, cache_decision(execution, decision_key, :skip)} + :miss -> prepare_region_keyed(decision_key, function, frame, execution) + {:error, reason} -> {:error, reason} + end + end + + defp invoke_frame(pool, key, template, frame, execution) do + with {:ok, lease} <- Pool.checkout(pool, key, template), + do: invoke_lease(pool, lease, frame, execution) + end + + defp invoke_lease(pool, lease, frame, execution) do + execution = Counter.increment(execution, :generated_entries) + remaining_steps = execution.remaining_steps + + pool + |> Code.invoke(lease, frame, execution) + |> add_generated_steps(remaining_steps) + after + safe_checkin(pool, lease) + end + + defp cache_decision(%State{compiler_context: context} = execution, function_id, decision) do + if map_size(context.decisions) < context.max_decisions do + context = %{context | decisions: Map.put(context.decisions, function_id, decision)} + %{execution | compiler_context: context} + else + execution + end + end + + defp observe_action({:deopt, %Deopt{} = deopt}) do + execution = Counter.deopt(deopt.execution, deopt.reason, deopt.frame) + {:deopt, %{deopt | execution: execution}} + end + + defp observe_action({:invoke, callable, arguments, this, caller, execution, false}) do + execution = Counter.increment(execution, :invocation_actions) + {:invoke, callable, arguments, this, caller, execution, false} + end + + defp observe_action({:skip, frame, execution}) do + {:skip, frame, Counter.increment(execution, :skipped_frames)} + end + + defp observe_action(action), do: action + + defp add_generated_steps(action, before) do + update_action_execution(action, fn execution -> + Counter.add_generated_steps(execution, max(before - execution.remaining_steps, 0)) + end) + end + + defp update_action_execution({:deopt, %Deopt{} = deopt}, update) do + {:deopt, %{deopt | execution: update.(deopt.execution)}} + end + + defp update_action_execution( + {:invoke, callable, arguments, this, caller, execution, false}, + update + ) do + {:invoke, callable, arguments, this, caller, update.(execution), false} + end + + defp update_action_execution({status, value, %State{} = execution}, update) + when status in [:ok, :error], + do: {status, value, update.(execution)} + + defp update_action_execution(action, _update), do: action + + defp resume_action({:deopt, deopt}, _execution), do: Interpreter.resume_deopt_raw(deopt) + + defp resume_action( + {:invoke, callable, arguments, this, caller, execution, false}, + _initial + ), + do: Interpreter.resume_compiler_invoke(callable, arguments, this, caller, execution) + + defp resume_action({:skip, frame, execution}, _initial), + do: Interpreter.run_frame(frame, execution) + + defp resume_action({:error, reason}, execution), + do: {:error, {:compiler_error, reason}, execution} + + defp resume_action(action, execution), + do: {:error, {:compiler_error, {:invalid_generated_action, action}}, execution} + + defp ensure_pool_available(pool) when is_pid(pool) do + if Process.alive?(pool), do: :ok, else: {:error, {:compiler_pool_unavailable, pool}} + end + + defp ensure_pool_available(pool) when is_atom(pool) do + if is_pid(Process.whereis(pool)), do: :ok, else: {:error, {:compiler_pool_unavailable, pool}} + end + + defp ensure_pool_available(pool), do: {:error, {:compiler_pool_unavailable, pool}} + + defp safe_checkin(pool, lease) do + Pool.checkin_active(pool, lease) + catch + :exit, _reason -> :ok + end +end diff --git a/lib/quickbeam/vm/compiler/analysis/block.ex b/lib/quickbeam/vm/compiler/analysis/block.ex new file mode 100644 index 000000000..a6b4f5ef3 --- /dev/null +++ b/lib/quickbeam/vm/compiler/analysis/block.ex @@ -0,0 +1,21 @@ +defmodule QuickBEAM.VM.Compiler.Analysis.Block do + @moduledoc """ + Represents one verified QuickJS basic block for compiler lowering. + + Instruction entries contain canonical opcode names and instruction-index + targets, never serialized byte offsets. + """ + + @enforce_keys [:start_pc, :end_pc, :instructions, :successors, :predecessors] + defstruct [:start_pc, :end_pc, :instructions, :successors, :predecessors] + + @type instruction :: {non_neg_integer(), atom(), [term()]} + + @type t :: %__MODULE__{ + start_pc: non_neg_integer(), + end_pc: non_neg_integer(), + instructions: [instruction()], + successors: [non_neg_integer()], + predecessors: [non_neg_integer()] + } +end diff --git a/lib/quickbeam/vm/compiler/analysis/control_flow.ex b/lib/quickbeam/vm/compiler/analysis/control_flow.ex new file mode 100644 index 000000000..1fd894d04 --- /dev/null +++ b/lib/quickbeam/vm/compiler/analysis/control_flow.ex @@ -0,0 +1,172 @@ +defmodule QuickBEAM.VM.Compiler.Analysis.ControlFlow do + @moduledoc """ + Builds bounded basic blocks from verified QuickJS instruction tuples. + + The analysis adapts the prototype's graph algorithm but consumes only current + v26 canonical instruction indexes and does not carry prototype runtime state. + """ + + alias QuickBEAM.VM.Bytecode.Opcode + alias QuickBEAM.VM.Compiler.Analysis.Block + alias QuickBEAM.VM.Program.Function + + @conditional_branches [:if_false, :if_false8, :if_true, :if_true8] + @unconditional_branches [:goto, :goto8, :goto16] + @exception_branches [:catch, :gosub] + @with_branches [ + :with_get_var, + :with_put_var, + :with_delete_var, + :with_make_ref, + :with_get_ref, + :with_get_ref_undef + ] + @terminators [ + :return, + :return_undef, + :return_async, + :throw, + :throw_error, + :tail_call, + :tail_call_method, + :ret + ] + @suspensions [:await, :initial_yield, :yield, :yield_star, :async_yield_star] + @target_branches @conditional_branches ++ @unconditional_branches ++ @exception_branches + @block_terminators @target_branches ++ @with_branches ++ @terminators ++ @suspensions + + @doc "Returns ordered basic blocks for one verified function." + @spec analyze(Function.t()) :: {:ok, [Block.t()]} | {:error, term()} + def analyze(%Function{instructions: instructions} = function) when is_tuple(instructions) do + with {:ok, canonical} <- canonical_instructions(function), + {:ok, entries} <- block_entries(canonical), + {:ok, blocks} <- build_blocks(canonical, entries) do + {:ok, attach_predecessors(blocks)} + end + end + + def analyze(%Function{}), do: {:error, :missing_instructions} + def analyze(value), do: {:error, {:invalid_compiler_function, value}} + + @doc "Converts numeric and compact opcodes into canonical names and operands." + @spec canonical_instructions(Function.t()) :: + {:ok, [Block.instruction()]} | {:error, term()} + def canonical_instructions(%Function{instructions: instructions} = function) + when is_tuple(instructions) do + instructions + |> Tuple.to_list() + |> Enum.with_index() + |> Enum.reduce_while({:ok, []}, fn {{opcode, operands}, pc}, {:ok, acc} -> + case Opcode.info(opcode) do + {name, _size, _pops, _pushes, _format} when is_list(operands) -> + {name, operands} = Opcode.expand_short_form(name, operands, function.arg_count) + {:cont, {:ok, [{pc, name, operands} | acc]}} + + _invalid -> + {:halt, {:error, {:invalid_compiler_instruction, pc, opcode, operands}}} + end + end) + |> case do + {:ok, canonical} -> {:ok, Enum.reverse(canonical)} + {:error, _reason} = error -> error + end + end + + def canonical_instructions(%Function{}), do: {:error, :missing_instructions} + + defp block_entries([]), do: {:error, :empty_instruction_stream} + + defp block_entries(instructions) do + size = length(instructions) + + entries = + Enum.reduce(instructions, MapSet.new([0]), fn {pc, name, operands}, entries -> + entries + |> add_targets(name, operands) + |> add_post_terminal(pc, name, size) + end) + + invalid = Enum.find(entries, &(not is_integer(&1) or &1 < 0 or &1 >= size)) + + if invalid == nil, + do: {:ok, entries |> MapSet.to_list() |> Enum.sort()}, + else: {:error, {:invalid_compiler_target, invalid}} + end + + defp add_targets(entries, name, [target]) when name in @target_branches, + do: MapSet.put(entries, target) + + defp add_targets(entries, name, [_atom, target | _rest]) when name in @with_branches, + do: MapSet.put(entries, target) + + defp add_targets(entries, _name, _operands), do: entries + + defp add_post_terminal(entries, pc, name, size) when name in @block_terminators do + if pc + 1 < size, do: MapSet.put(entries, pc + 1), else: entries + end + + defp add_post_terminal(entries, _pc, _name, _size), do: entries + + defp build_blocks(instructions, entries) do + tuple = List.to_tuple(instructions) + size = tuple_size(tuple) + + blocks = + entries + |> Enum.with_index() + |> Enum.map(fn {start_pc, index} -> + next_entry = Enum.at(entries, index + 1, size) + block_instructions = for pc <- start_pc..(next_entry - 1), do: elem(tuple, pc) + end_pc = next_entry - 1 + + %Block{ + start_pc: start_pc, + end_pc: end_pc, + instructions: block_instructions, + successors: successors(List.last(block_instructions), next_entry, size), + predecessors: [] + } + end) + + {:ok, blocks} + end + + defp successors({_pc, name, [target]}, next_entry, size) when name in @conditional_branches, + do: valid_successors([target, next_entry], size) + + defp successors({_pc, name, [target]}, _next_entry, size) + when name in @unconditional_branches, + do: valid_successors([target], size) + + defp successors({_pc, name, [target]}, next_entry, size) when name in @exception_branches, + do: valid_successors([target, next_entry], size) + + defp successors({_pc, name, [_atom, target | _rest]}, next_entry, size) + when name in @with_branches, + do: valid_successors([target, next_entry], size) + + defp successors({_pc, name, _operands}, _next_entry, _size) when name in @terminators, + do: [] + + defp successors({_pc, name, _operands}, next_entry, size) when name in @suspensions, + do: valid_successors([next_entry], size) + + defp successors(_instruction, next_entry, size), do: valid_successors([next_entry], size) + + defp valid_successors(successors, size), + do: successors |> Enum.filter(&(&1 >= 0 and &1 < size)) |> Enum.uniq() + + defp attach_predecessors(blocks) do + predecessors = + Enum.reduce(blocks, %{}, fn block, predecessors -> + Enum.reduce(block.successors, predecessors, fn successor, predecessors -> + Map.update(predecessors, successor, [block.start_pc], &[block.start_pc | &1]) + end) + end) + + Enum.map(blocks, fn block -> + predecessors = predecessors |> Map.get(block.start_pc, []) |> Enum.sort() + %{block | predecessors: predecessors} + end) + end +end diff --git a/lib/quickbeam/vm/compiler/code.ex b/lib/quickbeam/vm/compiler/code.ex new file mode 100644 index 000000000..392697931 --- /dev/null +++ b/lib/quickbeam/vm/compiler/code.ex @@ -0,0 +1,38 @@ +defmodule QuickBEAM.VM.Compiler.Code do + @moduledoc """ + Provides the production generated-code backend for the bounded module pool. + + Emission and code-server lifecycle are separate components behind this small + backend facade, keeping cache and lease ownership independent from generated + form validation and code loading. + """ + + @behaviour QuickBEAM.VM.Compiler.Pool.Backend + + alias QuickBEAM.VM.Compiler.Code.Emitter + alias QuickBEAM.VM.Compiler.Code.Lifecycle + alias QuickBEAM.VM.Compiler.Pool + alias QuickBEAM.VM.Compiler.Pool.Lease + alias QuickBEAM.VM.Runtime.Frame + alias QuickBEAM.VM.Runtime.State + + @doc "Compiles a validated template for one fixed generated-module slot." + @impl true + def compile(key, module, template), do: Emitter.emit(key, module, template) + + @doc "Installs a validated artifact into its fixed module slot." + @impl true + def install(module, artifact), do: Lifecycle.install(module, artifact) + + @doc "Soft-purges a generated module slot without deleting live code." + @impl true + def retire(module), do: Lifecycle.retire(module) + + @doc "Invokes generated code after validating its owner-local active lease." + @spec invoke(Pool.server(), Lease.t(), Frame.t(), State.t()) :: term() + def invoke(pool, %Lease{} = lease, %Frame{} = frame, %State{} = execution) do + with :ok <- Pool.validate_lease(pool, lease) do + lease.module.run(lease, frame, execution) + end + end +end diff --git a/lib/quickbeam/vm/compiler/code/artifact.ex b/lib/quickbeam/vm/compiler/code/artifact.ex new file mode 100644 index 000000000..4232a1108 --- /dev/null +++ b/lib/quickbeam/vm/compiler/code/artifact.ex @@ -0,0 +1,46 @@ +defmodule QuickBEAM.VM.Compiler.Code.Artifact do + @moduledoc """ + Represents a validated slot-specific generated module binary. + + The digest is checked again immediately before installation so an artifact + cannot be substituted between emission and code loading. + """ + + @max_binary_bytes 8 * 1024 * 1024 + + @enforce_keys [:module, :binary, :digest] + defstruct [:module, :binary, :digest] + + @type t :: %__MODULE__{module: module(), binary: binary(), digest: binary()} + + @doc "Creates an artifact after enforcing the generated binary size limit." + @spec new(module(), binary()) :: {:ok, t()} | {:error, term()} + def new(module, binary) when is_atom(module) and is_binary(binary) do + if byte_size(binary) <= @max_binary_bytes do + {:ok, %__MODULE__{module: module, binary: binary, digest: digest(binary)}} + else + {:error, {:compiler_resource_limit, :module_bytes, byte_size(binary), @max_binary_bytes}} + end + end + + def new(module, binary), do: {:error, {:invalid_generated_module_binary, module, binary}} + + @doc "Revalidates an artifact's binary size and digest before installation." + @spec validate(t()) :: :ok | {:error, term()} + def validate(%__MODULE__{binary: binary, digest: expected}) when is_binary(binary) do + cond do + byte_size(binary) > @max_binary_bytes -> + {:error, {:compiler_resource_limit, :module_bytes, byte_size(binary), @max_binary_bytes}} + + expected != digest(binary) -> + {:error, :artifact_digest_mismatch} + + true -> + :ok + end + end + + def validate(artifact), do: {:error, {:invalid_generated_module_artifact, artifact}} + + defp digest(binary), do: :crypto.hash(:sha256, binary) +end diff --git a/lib/quickbeam/vm/compiler/code/emitter.ex b/lib/quickbeam/vm/compiler/code/emitter.ex new file mode 100644 index 000000000..b9cb27057 --- /dev/null +++ b/lib/quickbeam/vm/compiler/code/emitter.ex @@ -0,0 +1,135 @@ +defmodule QuickBEAM.VM.Compiler.Code.Emitter do + @moduledoc """ + Emits bounded slot-specific module binaries from Erlang abstract forms. + + The emitter validates the template envelope, replaces its fixed placeholder + module, invokes the Erlang forms compiler, and applies the generated import + policy before returning an artifact. SSA optimization is intentionally + disabled because affected OTP releases can emit invalid register-liveness + metadata for the bounded tuple and branch forms used by this backend; the + ordinary BEAM validator still checks every emitted module. + """ + + alias QuickBEAM.VM.Compiler.Code.Artifact + alias QuickBEAM.VM.Compiler.Code.Import + alias QuickBEAM.VM.Compiler.Code.Template + alias QuickBEAM.VM.Compiler.Contract + + @max_form_count 5_000 + @max_form_bytes 8 * 1024 * 1024 + @entry_export {:run, 3} + @placeholder_module Template.placeholder_module() + @artifact_key_bytes Contract.artifact_key_bytes() + + @doc "Emits one validated artifact for an assigned static module slot." + @spec emit(binary(), module(), Template.t()) :: {:ok, Artifact.t()} | {:error, term()} + def emit(key, module, %Template{forms: forms}) do + with :ok <- validate_key(key), + :ok <- validate_module(module), + {:ok, forms} <- prepare_forms(forms, module), + {:ok, binary} <- compile_forms(forms, module), + :ok <- Import.validate(binary) do + Artifact.new(module, binary) + end + end + + def emit(_key, _module, input), do: {:error, {:invalid_compiler_template, input}} + + defp prepare_forms(forms, module) when is_list(forms) do + with :ok <- validate_form_count(forms), + :ok <- validate_form_bytes(forms), + :ok <- validate_top_level_forms(forms), + :ok <- validate_module_attribute(forms), + :ok <- validate_exports(forms) do + {:ok, Enum.map(forms, &replace_module_attribute(&1, module))} + end + end + + defp prepare_forms(forms, _module), do: {:error, {:invalid_compiler_forms, forms}} + + defp validate_form_count(forms) when length(forms) <= @max_form_count, do: :ok + + defp validate_form_count(forms), + do: {:error, {:compiler_resource_limit, :forms, length(forms), @max_form_count}} + + defp validate_form_bytes(forms) do + size = :erlang.external_size(forms) + + if size <= @max_form_bytes, + do: :ok, + else: {:error, {:compiler_resource_limit, :form_bytes, size, @max_form_bytes}} + end + + defp validate_top_level_forms(forms) do + case Enum.find(forms, &(not allowed_top_level_form?(&1))) do + nil -> :ok + form -> {:error, {:unsupported_compiler_form, form}} + end + end + + defp allowed_top_level_form?({:attribute, _line, name, _value}) + when name in [:module, :export, :file], + do: true + + defp allowed_top_level_form?({:function, _line, name, arity, clauses}), + do: is_atom(name) and is_integer(arity) and arity >= 0 and is_list(clauses) + + defp allowed_top_level_form?({:eof, _line}), do: true + defp allowed_top_level_form?(_form), do: false + + defp validate_module_attribute(forms) do + modules = for {:attribute, _line, :module, module} <- forms, do: module + + case modules do + [@placeholder_module] -> :ok + _modules -> {:error, {:invalid_compiler_module_attributes, modules}} + end + end + + defp validate_exports(forms) do + exports = for {:attribute, _line, :export, exports} <- forms, do: exports + + case exports do + [[@entry_export]] -> :ok + _exports -> {:error, {:invalid_compiler_exports, exports}} + end + end + + defp replace_module_attribute({:attribute, line, :module, _placeholder}, module), + do: {:attribute, line, :module, module} + + defp replace_module_attribute(form, _module), do: form + + defp compile_forms(forms, module) do + options = [:binary, :deterministic, :no_ssa_opt, :return_errors, :return_warnings] + + case :compile.forms(forms, options) do + {:ok, ^module, binary} -> + {:ok, binary} + + {:ok, ^module, binary, []} -> + {:ok, binary} + + {:ok, ^module, _binary, warnings} -> + {:error, {:generated_module_warnings, warnings}} + + {:error, errors, warnings} -> + {:error, {:generated_module_compile_failed, errors, warnings}} + + other -> + {:error, {:generated_module_compile_failed, other}} + end + end + + defp validate_key(key) + when is_binary(key) and byte_size(key) == @artifact_key_bytes, + do: :ok + + defp validate_key(key), do: {:error, {:invalid_artifact_key, key}} + + defp validate_module(module) do + if module in Contract.pool_modules(), + do: :ok, + else: {:error, {:invalid_compiler_module, module}} + end +end diff --git a/lib/quickbeam/vm/compiler/code/import.ex b/lib/quickbeam/vm/compiler/code/import.ex new file mode 100644 index 000000000..31a5cc6c9 --- /dev/null +++ b/lib/quickbeam/vm/compiler/code/import.ex @@ -0,0 +1,88 @@ +defmodule QuickBEAM.VM.Compiler.Code.Import do + @moduledoc """ + Enforces the generated-module external-call allowlist. + + Initial generated modules may call only the versioned compiler runtime ABI. + Any new runtime helper or direct Erlang BIF must be added deliberately with + differential and disassembly coverage. + """ + + alias QuickBEAM.VM.Compiler.Runtime + + @allowed [ + {:erlang, :*, 2}, + {:erlang, :+, 2}, + {:erlang, :-, 1}, + {:erlang, :-, 2}, + {:erlang, :<, 2}, + {:erlang, :"=<", 2}, + {:erlang, :==, 2}, + {:erlang, :>, 2}, + {:erlang, :>=, 2}, + {:erlang, :"/=", 2}, + {:erlang, :element, 2}, + {:erlang, :get_module_info, 1}, + {:erlang, :get_module_info, 2}, + {:erlang, :is_integer, 1}, + {:erlang, :is_number, 1}, + {:erlang, :rem, 2}, + {:erlang, :setelement, 3}, + {Runtime, :version, 0}, + {Runtime, :charge_block, 4}, + {Runtime, :charge_state, 4}, + {Runtime, :deopt, 4}, + {Runtime, :deopt_state, 4}, + {Runtime, :execute_plan, 4}, + {Runtime, :execute_fast_block, 4}, + {Runtime, :execute_stack, 4}, + {Runtime, :execute_local, 4}, + {Runtime, :execute_value, 4}, + {Runtime, :execute_branch, 4}, + {Runtime, :frame_constant, 2}, + {Runtime, :frame_pc, 1}, + {Runtime, :frame_state, 1}, + {Runtime, :frame_this, 1}, + {Runtime, :global_get, 3}, + {Runtime, :global_put, 3}, + {Runtime, :invoke_state, 5}, + {Runtime, :property_get, 3}, + {Runtime, :resolve_atom, 2}, + {Runtime, :truthy?, 1}, + {Runtime, :tuple_put, 3}, + {Runtime, :unary, 2}, + {Runtime, :binary, 3} + ] + @allowed_set MapSet.new(@allowed) + + @doc "Returns the closed initial external-call allowlist." + @spec allowed() :: [{module(), atom(), non_neg_integer()}] + def allowed, do: @allowed + + @doc "Returns the imports recorded in a generated module binary." + @spec imports(binary()) :: {:ok, [tuple()]} | {:error, term()} + def imports(binary) when is_binary(binary) do + case :beam_lib.chunks(binary, [:imports]) do + {:ok, {_module, [{:imports, imports}]}} when is_list(imports) -> + {:ok, Enum.sort(imports)} + + {:error, _module, reason} -> + {:error, {:invalid_generated_beam, reason}} + + other -> + {:error, {:invalid_generated_beam_imports, other}} + end + end + + @doc "Rejects every generated external call outside the closed allowlist." + @spec validate(binary()) :: :ok | {:error, term()} + def validate(binary) do + with {:ok, imports} <- imports(binary) do + rejected = imports |> Enum.reject(&MapSet.member?(@allowed_set, &1)) |> Enum.sort() + + case rejected do + [] -> :ok + _calls -> {:error, {:disallowed_generated_calls, rejected}} + end + end + end +end diff --git a/lib/quickbeam/vm/compiler/code/lifecycle.ex b/lib/quickbeam/vm/compiler/code/lifecycle.ex new file mode 100644 index 000000000..b537ef3df --- /dev/null +++ b/lib/quickbeam/vm/compiler/code/lifecycle.ex @@ -0,0 +1,74 @@ +defmodule QuickBEAM.VM.Compiler.Code.Lifecycle do + @moduledoc """ + Installs and safely retires generated modules in the Erlang code server. + + Retirement uses only `:code.soft_purge/1`. Live references return an error so + the module pool can quarantine their slot instead of killing a process with + hard purge. + """ + + alias QuickBEAM.VM.Compiler.Code.Artifact + alias QuickBEAM.VM.Compiler.Code.Import + alias QuickBEAM.VM.Compiler.Contract + + @source ~c"quickbeam_compiler" + + @doc "Installs a revalidated artifact under its assigned static module name." + @spec install(module(), Artifact.t()) :: :ok | {:error, term()} + def install(module, %Artifact{module: module, binary: binary} = artifact) do + with :ok <- validate_module(module), + :ok <- Artifact.validate(artifact), + :ok <- Import.validate(binary), + :ok <- ensure_slot_available(module), + {:module, ^module} <- :code.load_binary(module, @source, binary) do + :ok + else + {:error, _reason} = error -> error + other -> {:error, {:generated_module_load_failed, other}} + end + end + + def install(module, artifact), + do: {:error, {:invalid_generated_module_artifact, module, artifact}} + + @doc "Deletes current code and soft-purges old code for one reserved module." + @spec retire(module()) :: :ok | {:error, term()} + def retire(module) do + with :ok <- validate_module(module), + :ok <- soft_purge(module, :old), + :ok <- delete_current(module) do + soft_purge(module, :current) + end + end + + defp validate_module(module) do + if module in Contract.pool_modules(), + do: :ok, + else: {:error, {:invalid_compiler_module, module}} + end + + defp ensure_slot_available(module) do + case :code.is_loaded(module) do + false -> soft_purge(module, :install) + _loaded -> {:error, {:compiler_slot_not_retired, module}} + end + end + + defp soft_purge(module, phase) do + if :code.soft_purge(module), + do: :ok, + else: {:error, {:live_generated_code, module, phase}} + end + + defp delete_current(module) do + case :code.is_loaded(module) do + false -> + :ok + + _loaded -> + if :code.delete(module), + do: :ok, + else: {:error, {:generated_module_delete_failed, module}} + end + end +end diff --git a/lib/quickbeam/vm/compiler/code/template.ex b/lib/quickbeam/vm/compiler/code/template.ex new file mode 100644 index 000000000..6ac290f29 --- /dev/null +++ b/lib/quickbeam/vm/compiler/code/template.ex @@ -0,0 +1,20 @@ +defmodule QuickBEAM.VM.Compiler.Code.Template do + @moduledoc """ + Holds bounded Erlang abstract forms before assignment to a static module slot. + + Templates use the reserved `QuickBEAM.VM.Compiler.Code.Placeholder` + atom as their module attribute. The emitter replaces only that attribute with + the leased static module name. + """ + + @placeholder_module QuickBEAM.VM.Compiler.Code.Placeholder + + @enforce_keys [:forms] + defstruct [:forms] + + @type t :: %__MODULE__{forms: [tuple()]} + + @doc "Returns the sole module atom accepted in unassigned compiler forms." + @spec placeholder_module() :: module() + def placeholder_module, do: @placeholder_module +end diff --git a/lib/quickbeam/vm/compiler/context.ex b/lib/quickbeam/vm/compiler/context.ex new file mode 100644 index 000000000..545667127 --- /dev/null +++ b/lib/quickbeam/vm/compiler/context.ex @@ -0,0 +1,47 @@ +defmodule QuickBEAM.VM.Compiler.Context do + @moduledoc """ + Carries immutable compiler identity through one owner-local evaluation. + + The shared program remains immutable. Mutable frames, heaps, jobs, and + continuations stay in the owning `%QuickBEAM.VM.Runtime.State{}`. + """ + + alias QuickBEAM.VM.Compiler.Counter + alias QuickBEAM.VM.Compiler.Region.Probe + + @enforce_keys [:pool, :program] + defstruct [ + :deopt_module, + :executor, + :instrumentation, + :pool, + :program, + artifact_namespace: nil, + decisions: %{}, + max_decisions: 256, + min_function_instructions: 32, + profile: :pure_v1, + counters: nil, + region_probe: nil, + regions: false + ] + + @type t :: %__MODULE__{ + deopt_module: module() | nil, + executor: module() | nil, + instrumentation: module() | nil, + pool: QuickBEAM.VM.Compiler.Pool.server(), + program: QuickBEAM.VM.Program.t(), + artifact_namespace: binary() | nil, + decisions: %{ + optional(non_neg_integer() | {:region, non_neg_integer(), non_neg_integer()}) => + :skip | {:cached, binary()} | {:compile, binary(), term()} + }, + max_decisions: pos_integer(), + min_function_instructions: non_neg_integer(), + profile: :pure_v1 | :scalar_v1, + counters: Counter.t() | nil, + region_probe: Probe.t() | nil, + regions: boolean() + } +end diff --git a/lib/quickbeam/vm/compiler/contract.ex b/lib/quickbeam/vm/compiler/contract.ex new file mode 100644 index 000000000..1a0923e12 --- /dev/null +++ b/lib/quickbeam/vm/compiler/contract.ex @@ -0,0 +1,207 @@ +defmodule QuickBEAM.VM.Compiler.Contract do + @moduledoc """ + Defines bounded identities for the optional BEAM compiler tier. + + Artifact keys are binaries and module slots are a fixed compile-time atom set. + Calling this module for unbounded user programs therefore cannot grow the atom + table. Compiler implementations must not construct additional module names. + """ + + alias QuickBEAM.VM.Program + alias QuickBEAM.VM.Program.Function + + @contract_version 1 + @runtime_abi_version 6 + @artifact_key_bytes 32 + @profiles [:pure_v1, :scalar_v1] + + @pool_modules [ + QuickBEAM.VM.Compiler.Slot00, + QuickBEAM.VM.Compiler.Slot01, + QuickBEAM.VM.Compiler.Slot02, + QuickBEAM.VM.Compiler.Slot03, + QuickBEAM.VM.Compiler.Slot04, + QuickBEAM.VM.Compiler.Slot05, + QuickBEAM.VM.Compiler.Slot06, + QuickBEAM.VM.Compiler.Slot07, + QuickBEAM.VM.Compiler.Slot08, + QuickBEAM.VM.Compiler.Slot09, + QuickBEAM.VM.Compiler.Slot10, + QuickBEAM.VM.Compiler.Slot11, + QuickBEAM.VM.Compiler.Slot12, + QuickBEAM.VM.Compiler.Slot13, + QuickBEAM.VM.Compiler.Slot14, + QuickBEAM.VM.Compiler.Slot15, + QuickBEAM.VM.Compiler.Slot16, + QuickBEAM.VM.Compiler.Slot17, + QuickBEAM.VM.Compiler.Slot18, + QuickBEAM.VM.Compiler.Slot19, + QuickBEAM.VM.Compiler.Slot20, + QuickBEAM.VM.Compiler.Slot21, + QuickBEAM.VM.Compiler.Slot22, + QuickBEAM.VM.Compiler.Slot23, + QuickBEAM.VM.Compiler.Slot24, + QuickBEAM.VM.Compiler.Slot25, + QuickBEAM.VM.Compiler.Slot26, + QuickBEAM.VM.Compiler.Slot27, + QuickBEAM.VM.Compiler.Slot28, + QuickBEAM.VM.Compiler.Slot29, + QuickBEAM.VM.Compiler.Slot30, + QuickBEAM.VM.Compiler.Slot31 + ] + + @doc "Returns the compiler contract version included in every artifact key." + @spec version() :: pos_integer() + def version, do: @contract_version + + @doc "Returns the generated-code runtime ABI version." + @spec runtime_abi_version() :: pos_integer() + def runtime_abi_version, do: @runtime_abi_version + + @doc "Returns the exact byte width of compiler artifact keys." + @spec artifact_key_bytes() :: pos_integer() + def artifact_key_bytes, do: @artifact_key_bytes + + @doc "Returns the immutable, bounded module atom pool." + @spec pool_modules() :: [module()] + def pool_modules, do: @pool_modules + + @doc "Returns the maximum number of compiler modules that may be loaded." + @spec pool_capacity() :: pos_integer() + def pool_capacity, do: length(@pool_modules) + + @doc "Builds a deterministic binary namespace for one immutable verified program." + @spec program_identity(Program.t()) :: {:ok, binary()} | {:error, term()} + def program_identity(%Program{} = program) do + payload = { + @contract_version, + @runtime_abi_version, + program.version, + program.fingerprint, + program.bytecode_digest, + program.source_digest, + program.atoms + } + + {:ok, digest(payload)} + end + + def program_identity(program), do: {:error, {:invalid_artifact_program, program}} + + @doc "Builds a deterministic binary identity for one verified function artifact." + @spec artifact_key(Program.t(), Function.t(), keyword()) :: + {:ok, binary()} | {:error, term()} + def artifact_key(program, function, opts \\ []) + + def artifact_key(%Program{} = program, %Function{} = function, opts) + when is_list(opts) do + with :ok <- validate_options(opts), + profile = Keyword.get(opts, :profile, :pure_v1), + region_entry = Keyword.get(opts, :region_entry), + region_preferred = Keyword.get(opts, :region_preferred, false), + :ok <- validate_profile(profile), + :ok <- validate_region_entry(region_entry), + :ok <- validate_region_preferred(region_preferred), + {:ok, program_identity} <- program_identity(program) do + artifact_key_from_identity(program_identity, function, + profile: profile, + region_entry: region_entry, + region_preferred: region_preferred + ) + end + end + + def artifact_key(program, function, _opts), + do: {:error, {:invalid_artifact_input, program, function}} + + @doc "Builds a cheap binary admission identity for one bounded function region." + @spec region_admission_key(binary(), non_neg_integer(), non_neg_integer(), atom()) :: + {:ok, binary()} | {:error, term()} + def region_admission_key(program_identity, function_id, entry_pc, profile) + when is_binary(program_identity) and byte_size(program_identity) == @artifact_key_bytes and + is_integer(function_id) and function_id >= 0 and is_integer(entry_pc) and + entry_pc >= 0 do + with :ok <- validate_profile(profile) do + {:ok, digest({program_identity, function_id, entry_pc, profile, :region_admission})} + end + end + + def region_admission_key(program_identity, function_id, entry_pc, profile), + do: {:error, {:invalid_region_admission, program_identity, function_id, entry_pc, profile}} + + @doc "Builds an artifact key from a previously validated program namespace." + @spec artifact_key_from_identity(binary(), Function.t(), keyword()) :: + {:ok, binary()} | {:error, term()} + def artifact_key_from_identity(program_identity, function, opts \\ []) + + def artifact_key_from_identity(program_identity, %Function{} = function, opts) + when is_binary(program_identity) and byte_size(program_identity) == @artifact_key_bytes and + is_list(opts) do + with :ok <- validate_options(opts), + profile = Keyword.get(opts, :profile, :pure_v1), + region_entry = Keyword.get(opts, :region_entry), + region_preferred = Keyword.get(opts, :region_preferred, false), + :ok <- validate_profile(profile), + :ok <- validate_region_entry(region_entry), + :ok <- validate_region_preferred(region_preferred) do + payload = { + program_identity, + artifact_function_identity(function), + profile, + region_entry, + region_preferred + } + + {:ok, digest(payload)} + end + end + + def artifact_key_from_identity(program_identity, function, _opts), + do: {:error, {:invalid_artifact_identity, program_identity, function}} + + defp artifact_function_identity(%Function{} = function) do + constants = + Enum.map(function.constants, fn + %Function{id: id} -> {:function_constant, id} + value -> value + end) + + %{ + function + | atoms: nil, + constants: constants, + filename: nil, + line_num: 1, + col_num: 1, + pc2line: <<>>, + source: <<>>, + source_positions: nil + } + end + + defp digest(value) do + binary = :erlang.term_to_binary(value, [:deterministic]) + :crypto.hash(:sha256, binary) + end + + defp validate_options(opts) do + if Keyword.keyword?(opts) do + case Keyword.keys(opts) -- [:profile, :region_entry, :region_preferred] do + [] -> :ok + [key | _rest] -> {:error, {:unknown_option, key}} + end + else + {:error, {:invalid_option, :options, opts}} + end + end + + defp validate_region_entry(nil), do: :ok + defp validate_region_entry(entry) when is_integer(entry) and entry >= 0, do: :ok + defp validate_region_entry(entry), do: {:error, {:invalid_region_entry, entry}} + + defp validate_region_preferred(value) when is_boolean(value), do: :ok + defp validate_region_preferred(value), do: {:error, {:invalid_region_preferred, value}} + + defp validate_profile(profile) when profile in @profiles, do: :ok + defp validate_profile(profile), do: {:error, {:unsupported_compiler_profile, profile}} +end diff --git a/lib/quickbeam/vm/compiler/counter.ex b/lib/quickbeam/vm/compiler/counter.ex new file mode 100644 index 000000000..9cea0d5ac --- /dev/null +++ b/lib/quickbeam/vm/compiler/counter.ex @@ -0,0 +1,158 @@ +defmodule QuickBEAM.VM.Compiler.Counter do + @moduledoc """ + Owns bounded compiler counters for one evaluation through OTP `:counters`. + + The reference is created with the evaluation's compiler context, remains in + that evaluation owner, and is read into a fixed-key map only at the measurement + boundary. Generated-step, entry, deoptimization, invocation, and re-entry + values describe execution through a fixed compiler profile. Compilation, + cache, and skip-decision values are lifecycle observations and can differ with + module-pool state. + """ + + alias QuickBEAM.VM.Bytecode.Opcode + alias QuickBEAM.VM.Runtime.Frame + alias QuickBEAM.VM.Runtime.State + + @indexes %{ + frame_attempts: 1, + generated_entries: 2, + generated_steps: 3, + compiled_functions: 4, + cached_functions: 5, + skipped_functions: 6, + skipped_frames: 7, + deoptimizations: 8, + unsupported_opcode_deopts: 9, + unsupported_semantics_deopts: 10, + step_boundary_deopts: 11, + suspension_boundary_deopts: 12, + guard_failed_deopts: 13, + invocation_actions: 14, + reentries: 15, + region_attempts: 16, + region_cold: 17, + region_hot: 18, + region_compiled: 19 + } + @event_count map_size(@indexes) + @opcode_slots 256 + @deopt_opcode_offset @event_count + @interpreted_opcode_offset @event_count + @opcode_slots + @counter_count @event_count + 2 * @opcode_slots + @events Map.keys(@indexes) + + @enforce_keys [:owner, :reference] + defstruct [:owner, :reference] + + @type t :: %__MODULE__{owner: pid(), reference: term()} + + @doc "Creates one fixed-size counter set without cross-process write concurrency." + @spec new() :: t() + def new, + do: %__MODULE__{owner: self(), reference: :counters.new(@counter_count, [])} + + @doc "Increments one fixed compiler event in an evaluation-owned context." + @spec increment(State.t(), atom()) :: State.t() + def increment( + %State{ + compiler_context: %{counters: %__MODULE__{owner: owner, reference: reference}} + } = execution, + event + ) + when owner == self() and event in @events do + :counters.add(reference, Map.fetch!(@indexes, event), 1) + execution + end + + def increment(%State{} = execution, _event), do: execution + + @doc "Adds an exact generated instruction count to owner-local counters." + @spec add_generated_steps(State.t(), non_neg_integer()) :: State.t() + def add_generated_steps( + %State{ + compiler_context: %{counters: %__MODULE__{owner: owner, reference: reference}} + } = execution, + count + ) + when owner == self() and is_integer(count) and count > 0 do + :counters.add(reference, Map.fetch!(@indexes, :generated_steps), count) + execution + end + + def add_generated_steps(%State{} = execution, _count), do: execution + + @doc "Records one interpreted opcode while compiler measurement is enabled." + @spec interpreted_opcode(State.t(), non_neg_integer()) :: State.t() + def interpreted_opcode( + %State{ + compiler_context: %{counters: %__MODULE__{owner: owner, reference: reference}} + } = execution, + opcode + ) + when owner == self() and is_integer(opcode) and opcode in 0..255 do + :counters.add(reference, @interpreted_opcode_offset + opcode + 1, 1) + execution + end + + def interpreted_opcode(%State{} = execution, _opcode), do: execution + + @doc "Records one validated compiler deoptimization reason and opcode." + @spec deopt(State.t(), term(), Frame.t()) :: State.t() + def deopt(%State{} = execution, reason, %Frame{} = frame) do + execution + |> increment(:deoptimizations) + |> increment(deopt_event(reason)) + |> increment_deopt_opcode(frame) + end + + @doc "Returns a fixed-key compiler counter map for endpoint measurement." + @spec snapshot(State.t()) :: map() | nil + def snapshot(%State{ + compiler_context: %{ + profile: profile, + counters: %__MODULE__{owner: owner, reference: reference} + } + }) + when owner == self() do + Map.new(@indexes, fn {name, index} -> {name, :counters.get(reference, index)} end) + |> Map.put(:deopt_opcodes, opcode_counts(reference, @deopt_opcode_offset)) + |> Map.put(:interpreted_opcodes, opcode_counts(reference, @interpreted_opcode_offset)) + |> Map.put(:profile, profile) + end + + def snapshot(%State{}), do: nil + + defp increment_deopt_opcode( + %State{ + compiler_context: %{counters: %__MODULE__{owner: owner, reference: reference}} + } = execution, + %Frame{pc: pc, function: %{instructions: instructions}} + ) + when owner == self() and is_integer(pc) and pc >= 0 and is_tuple(instructions) and + pc < tuple_size(instructions) do + {opcode, _operands} = elem(instructions, pc) + + if is_integer(opcode) and opcode in 0..255, + do: :counters.add(reference, @deopt_opcode_offset + opcode + 1, 1) + + execution + end + + defp increment_deopt_opcode(%State{} = execution, _frame), do: execution + + defp opcode_counts(reference, offset) do + Opcode.table() + |> Enum.reduce(%{}, fn {opcode, {name, _size, _pops, _pushes, _format}}, counts -> + count = :counters.get(reference, offset + opcode + 1) + if count == 0, do: counts, else: Map.put(counts, name, count) + end) + end + + defp deopt_event(:unsupported_opcode), do: :unsupported_opcode_deopts + defp deopt_event(:unsupported_semantics), do: :unsupported_semantics_deopts + defp deopt_event(:step_boundary), do: :step_boundary_deopts + defp deopt_event(:suspension_boundary), do: :suspension_boundary_deopts + defp deopt_event({:guard_failed, _guard}), do: :guard_failed_deopts + defp deopt_event(_reason), do: :unsupported_semantics_deopts +end diff --git a/lib/quickbeam/vm/compiler/deopt.ex b/lib/quickbeam/vm/compiler/deopt.ex new file mode 100644 index 000000000..707982a1a --- /dev/null +++ b/lib/quickbeam/vm/compiler/deopt.ex @@ -0,0 +1,145 @@ +defmodule QuickBEAM.VM.Compiler.Deopt do + @moduledoc """ + Represents a validated before-instruction transition to the interpreter. + + Deoptimization state is owner-local. Its frame points at the next unexecuted + verified instruction, and that instruction has neither consumed a step nor + performed an observable effect. + """ + + alias QuickBEAM.VM.Compiler.Contract + alias QuickBEAM.VM.Runtime.Frame + alias QuickBEAM.VM.Runtime.State + + @contract_version Contract.version() + @artifact_key_bytes Contract.artifact_key_bytes() + + @enforce_keys [ + :artifact_key, + :pool_epoch, + :generation, + :reason, + :owner, + :frame, + :execution + ] + defstruct [ + :artifact_key, + :pool_epoch, + :generation, + :reason, + :owner, + :frame, + :execution, + contract_version: Contract.version(), + phase: :before_instruction + ] + + @type reason :: + :unsupported_opcode + | :unsupported_semantics + | :step_boundary + | :suspension_boundary + | {:guard_failed, atom()} + + @type t :: %__MODULE__{ + artifact_key: binary(), + pool_epoch: non_neg_integer(), + generation: non_neg_integer(), + reason: reason(), + owner: pid(), + frame: Frame.t(), + execution: State.t(), + contract_version: pos_integer(), + phase: :before_instruction + } + + @doc "Builds owner-local deoptimization state and validates its boundary." + @spec new(reason(), binary(), non_neg_integer(), non_neg_integer(), Frame.t(), State.t()) :: + {:ok, t()} | {:error, term()} + def new( + reason, + artifact_key, + pool_epoch, + generation, + %Frame{} = frame, + %State{} = execution + ) do + deopt = %__MODULE__{ + artifact_key: artifact_key, + pool_epoch: pool_epoch, + generation: generation, + reason: reason, + owner: self(), + frame: frame, + execution: execution + } + + case validate(deopt) do + :ok -> {:ok, deopt} + {:error, _reason} = error -> error + end + end + + def new(reason, artifact_key, pool_epoch, generation, frame, execution), + do: + {:error, + {:invalid_deopt_state, reason, artifact_key, pool_epoch, generation, frame, execution}} + + @doc "Validates the owner, contract identity, reason, and instruction boundary." + @spec validate(t()) :: :ok | {:error, term()} + def validate(%__MODULE__{} = deopt) do + with :ok <- validate_contract(deopt.contract_version), + :ok <- validate_key(deopt.artifact_key), + :ok <- validate_counter(:pool_epoch, deopt.pool_epoch), + :ok <- validate_counter(:generation, deopt.generation), + :ok <- validate_owner(deopt.owner), + :ok <- validate_phase(deopt.phase), + :ok <- validate_reason(deopt.reason), + :ok <- validate_boundary(deopt.frame) do + validate_execution(deopt.execution) + end + end + + def validate(value), do: {:error, {:invalid_deopt, value}} + + defp validate_contract(@contract_version), do: :ok + defp validate_contract(version), do: {:error, {:stale_compiler_contract, version}} + + defp validate_key(key) + when is_binary(key) and byte_size(key) == @artifact_key_bytes, + do: :ok + + defp validate_key(key), do: {:error, {:invalid_artifact_key, key}} + + defp validate_counter(_name, value) when is_integer(value) and value >= 0, do: :ok + defp validate_counter(name, value), do: {:error, {:invalid_deopt_counter, name, value}} + + defp validate_owner(owner) when owner == self(), do: :ok + defp validate_owner(owner), do: {:error, {:deopt_owner_mismatch, owner, self()}} + + defp validate_phase(:before_instruction), do: :ok + defp validate_phase(phase), do: {:error, {:unsupported_deopt_phase, phase}} + + defp validate_reason(reason) + when reason in [ + :unsupported_opcode, + :unsupported_semantics, + :step_boundary, + :suspension_boundary + ], + do: :ok + + defp validate_reason({:guard_failed, guard}) when is_atom(guard), do: :ok + defp validate_reason(reason), do: {:error, {:invalid_deopt_reason, reason}} + + defp validate_boundary(%Frame{pc: pc, function: %{instructions: instructions}}) + when is_integer(pc) and pc >= 0 and is_tuple(instructions) and + pc < tuple_size(instructions), + do: :ok + + defp validate_boundary(frame), do: {:error, {:invalid_deopt_boundary, frame}} + + defp validate_execution(%State{}), do: :ok + defp validate_execution(execution), do: {:error, {:invalid_deopt_execution, execution}} +end diff --git a/lib/quickbeam/vm/compiler/instrumentation.ex b/lib/quickbeam/vm/compiler/instrumentation.ex new file mode 100644 index 000000000..1197b87a6 --- /dev/null +++ b/lib/quickbeam/vm/compiler/instrumentation.ex @@ -0,0 +1,32 @@ +defmodule QuickBEAM.VM.Compiler.Instrumentation do + @moduledoc """ + Adapts compiler counters and region probes to the runtime's optional dynamic + optimization-hook contract. + """ + + alias QuickBEAM.VM.Compiler.Counter + alias QuickBEAM.VM.Compiler.Region.Probe + alias QuickBEAM.VM.Runtime.Frame + alias QuickBEAM.VM.Runtime.State + + @doc "Increments a fixed compiler event." + @spec increment(State.t(), atom()) :: State.t() + defdelegate increment(execution, event), to: Counter + + @doc "Records one interpreted opcode for compiler coverage." + @spec interpreted_opcode(State.t(), non_neg_integer()) :: State.t() + defdelegate interpreted_opcode(execution, opcode), to: Counter + + @doc "Samples one canonical interpreted frame for bounded region diagnostics." + @spec observe(State.t(), Frame.t()) :: State.t() + defdelegate observe(execution, frame), to: Probe + + @doc "Returns compiler counter and region snapshots at evaluation completion." + @spec snapshot(State.t()) :: map() + def snapshot(%State{} = execution) do + %{ + compiler_counters: Counter.snapshot(execution), + compiler_regions: Probe.snapshot(execution) + } + end +end diff --git a/lib/quickbeam/vm/compiler/pool.ex b/lib/quickbeam/vm/compiler/pool.ex new file mode 100644 index 000000000..f3fc1d3ed --- /dev/null +++ b/lib/quickbeam/vm/compiler/pool.ex @@ -0,0 +1,828 @@ +defmodule QuickBEAM.VM.Compiler.Pool do + @moduledoc """ + Owns a bounded cache of generated BEAM modules. + + The pool leases fixed module atoms to evaluation processes, compiles each + cache miss once, monitors lease owners, and reuses only idle slots. Backend + installation and retirement are serialized in the pool process. + """ + + use GenServer + + alias QuickBEAM.VM.Compiler.Contract + alias QuickBEAM.VM.Compiler.Pool.Lease + + @default_compile_timeout 5_000 + @default_compile_max_heap_bytes 64 * 1024 * 1024 + @max_capacity Contract.pool_capacity() + @max_skip_entries @max_capacity * 8 + @max_region_admissions @max_capacity * 8 + @region_admission_threshold 3 + + @type server :: GenServer.server() + + @doc "Starts the singleton pool with a required `:backend` module." + @spec start_link(keyword()) :: GenServer.on_start() + def start_link(opts) when is_list(opts) do + case Keyword.pop(opts, :name, __MODULE__) do + {__MODULE__, opts} -> GenServer.start_link(__MODULE__, opts, name: __MODULE__) + {name, _opts} -> {:error, {:invalid_option, :name, name}} + end + end + + @doc "Checks out a compiled artifact, joining an existing compilation on a cache hit." + @spec checkout(server(), binary(), term()) :: {:ok, Lease.t()} | {:error, term()} + def checkout(server, key, input \\ nil) do + if valid_key?(key) do + GenServer.call(server, {:checkout, key, input}, :infinity) + else + {:error, {:invalid_artifact_key, key}} + end + end + + @doc "Checks out an already-ready artifact without starting compilation on a miss." + @spec checkout_cached(server(), binary()) :: + {:ok, Lease.t()} | :skip | :miss | {:error, term()} + def checkout_cached(server, key) do + if valid_key?(key) do + GenServer.call(server, {:checkout_cached, key}, :infinity) + else + {:error, {:invalid_artifact_key, key}} + end + end + + @doc "Remembers a bounded negative lowering decision for a verified artifact key." + @spec remember_skip(server(), binary()) :: :ok | {:error, term()} + def remember_skip(server, key) do + if valid_key?(key) do + GenServer.call(server, {:remember_skip, key}) + else + {:error, {:invalid_artifact_key, key}} + end + end + + @doc "Admits a repeated binary region identity into the stable half-pool region budget." + @spec admit_region(server(), binary()) :: :cold | :hot | {:error, term()} + def admit_region(server, key) do + if valid_key?(key) do + GenServer.call(server, {:admit_region, key}) + else + {:error, {:invalid_artifact_key, key}} + end + end + + @doc "Returns a lease after execution. Repeated or stale returns are rejected." + @spec checkin(server(), Lease.t()) :: :ok | {:error, term()} + def checkin(server, %Lease{} = lease), + do: GenServer.call(server, {:checkin, lease, self()}) + + @doc "Returns a freshly checked-out lease asynchronously after generated execution." + @spec checkin_active(server(), Lease.t()) :: :ok | {:error, term()} + def checkin_active(server, %Lease{owner: owner} = lease) when owner == self() do + GenServer.cast(server, {:checkin_active, lease, self()}) + :ok + end + + def checkin_active(_server, %Lease{}), do: {:error, :compiler_lease_owner_mismatch} + + @doc "Checks whether a lease is currently active for the calling process." + @spec validate_lease(server(), Lease.t()) :: :ok | {:error, term()} + def validate_lease(server, %Lease{} = lease), + do: GenServer.call(server, {:validate_lease, lease, self()}) + + @doc "Rejects new work, cancels compilation, and soft-retires slots after leases drain." + @spec drain(server(), pos_integer()) :: :ok | {:error, term()} + def drain(server, timeout \\ 5_000) + + def drain(server, timeout) when is_integer(timeout) and timeout > 0, + do: GenServer.call(server, {:drain, timeout}, :infinity) + + def drain(_server, timeout), do: {:error, {:invalid_option, :drain_timeout, timeout}} + + @doc "Returns bounded diagnostic state for tests and operational inspection." + @spec stats(server()) :: map() + def stats(server), do: GenServer.call(server, :stats) + + @impl true + def init(opts) do + with {:ok, backend} <- fetch_backend(opts), + {:ok, task_supervisor} <- fetch_task_supervisor(opts), + {:ok, capacity} <- fetch_capacity(opts), + {:ok, compile_timeout} <- fetch_compile_timeout(opts), + {:ok, compile_max_heap_words} <- fetch_compile_max_heap_words(opts) do + all_modules = Contract.pool_modules() + initialized_slots = Map.new(all_modules, &{&1, initialize_slot(backend, &1)}) + modules = Enum.take(all_modules, capacity) + slots = Map.take(initialized_slots, modules) + + {:ok, + %{ + backend: backend, + task_supervisor: task_supervisor, + compile_timeout: compile_timeout, + compile_max_heap_words: compile_max_heap_words, + modules: modules, + slots: slots, + key_index: %{}, + skip_index: %{}, + region_admissions: %{}, + region_hot: MapSet.new(), + region_hot_capacity: max(div(capacity, 2), 1), + leases: %{}, + monitor_index: %{}, + tasks: %{}, + epoch: System.unique_integer([:positive, :monotonic]), + clock: 0, + mode: :running, + drain: nil + }} + else + {:error, reason} -> {:stop, reason} + end + end + + @impl true + def handle_call({:checkout, _key, _input}, _from, %{mode: mode} = state) + when mode != :running, + do: {:reply, {:error, :compiler_pool_stopping}, state} + + def handle_call({:checkout, key, input}, from, state) do + case Map.fetch(state.key_index, key) do + {:ok, module} -> checkout_indexed(module, key, input, from, state) + :error -> checkout_miss(key, input, from, state) + end + end + + def handle_call({:checkout_cached, _key}, _from, %{mode: mode} = state) + when mode != :running, + do: {:reply, {:error, :compiler_pool_stopping}, state} + + def handle_call({:checkout_cached, key}, from, state) do + case Map.fetch(state.key_index, key) do + {:ok, module} -> + checkout_cached_indexed(module, key, from, state) + + :error -> + if Map.has_key?(state.skip_index, key), + do: {:reply, :skip, touch_skip(state, key)}, + else: {:reply, :miss, state} + end + end + + def handle_call({:remember_skip, _key}, _from, %{mode: mode} = state) + when mode != :running, + do: {:reply, {:error, :compiler_pool_stopping}, state} + + def handle_call({:remember_skip, key}, _from, state) do + state = state |> put_skip(key) |> trim_skips() + {:reply, :ok, state} + end + + def handle_call({:admit_region, _key}, _from, %{mode: mode} = state) + when mode != :running, + do: {:reply, {:error, :compiler_pool_stopping}, state} + + def handle_call({:admit_region, key}, _from, state) do + state = tick(state) + current = Map.get(state.region_admissions, key, %{count: 0, clock: state.clock}) + count = min(current.count + 1, @region_admission_threshold) + entry = %{count: count, clock: state.clock} + + state = + state + |> put_in([:region_admissions, key], entry) + |> trim_region_admissions() + + {reply, state} = admit_hot_region(key, count, state) + {:reply, reply, state} + end + + def handle_call({:checkin, lease, caller}, _from, state) do + case fetch_lease(lease, caller, state) do + {:ok, record} -> + state = release_lease(lease.token, record, state, true) + {:reply, :ok, maybe_complete_drain(state)} + + {:error, reason} -> + {:reply, {:error, reason}, state} + end + end + + def handle_call({:validate_lease, lease, caller}, _from, state) do + reply = + case fetch_lease(lease, caller, state) do + {:ok, _record} -> :ok + {:error, reason} -> {:error, reason} + end + + {:reply, reply, state} + end + + def handle_call({:drain, _timeout}, _from, %{mode: mode} = state) + when mode != :running, + do: {:reply, {:error, :compiler_pool_stopping}, state} + + def handle_call({:drain, timeout}, from, state) do + state = cancel_compilations(state) + reference = make_ref() + timer = Process.send_after(self(), {:drain_timeout, reference}, timeout) + state = %{state | mode: :draining, drain: %{from: from, reference: reference, timer: timer}} + {:noreply, maybe_complete_drain(state)} + end + + def handle_call(:stats, _from, state) do + counts = Enum.frequencies_by(state.slots, fn {_module, slot} -> slot.status end) + + slots = + Enum.map(state.modules, fn module -> + slot = Map.fetch!(state.slots, module) + + %{ + module: module, + status: slot.status, + key: Map.get(slot, :key), + generation: slot.generation, + lease_count: Map.get(slot, :lease_count, 0), + reason: Map.get(slot, :reason) + } + end) + + {:reply, + %{ + capacity: length(state.modules), + epoch: state.epoch, + counts: counts, + leases: map_size(state.leases), + compilations: map_size(state.tasks), + skips: map_size(state.skip_index), + region_admissions: map_size(state.region_admissions), + region_hot: MapSet.size(state.region_hot), + region_hot_capacity: state.region_hot_capacity, + mode: state.mode, + slots: slots + }, state} + end + + @impl true + def handle_cast({:checkin_active, lease, caller}, state) do + state = + case fetch_lease(lease, caller, state) do + {:ok, record} -> release_lease(lease.token, record, state, true) + {:error, _reason} -> state + end + + {:noreply, maybe_complete_drain(state)} + end + + @impl true + def handle_info({reference, result}, state) when is_reference(reference) do + case Map.fetch(state.tasks, reference) do + {:ok, task_record} -> + Process.demonitor(reference, [:flush]) + state = drop_task(reference, task_record, state) + {:noreply, finish_compilation(task_record.module, result, state)} + + :error -> + {:noreply, state} + end + end + + def handle_info({:drain_timeout, reference}, %{drain: %{reference: reference}} = state) do + GenServer.reply( + state.drain.from, + {:error, {:compiler_pool_shutdown_timeout, map_size(state.leases)}} + ) + + {:noreply, %{state | drain: nil}} + end + + def handle_info({:drain_timeout, _reference}, state), do: {:noreply, state} + + def handle_info({:compile_timeout, reference}, state) do + case Map.fetch(state.tasks, reference) do + {:ok, task_record} -> + Task.Supervisor.terminate_child(state.task_supervisor, task_record.pid) + Process.demonitor(reference, [:flush]) + state = drop_task(reference, task_record, state) + result = {:error, {:compile_timeout, state.compile_timeout}} + {:noreply, finish_compilation(task_record.module, result, state)} + + :error -> + {:noreply, state} + end + end + + def handle_info({:DOWN, reference, :process, _pid, reason}, state) do + cond do + Map.has_key?(state.tasks, reference) -> + task_record = Map.fetch!(state.tasks, reference) + state = drop_task(reference, task_record, state) + result = {:error, {:compile_task_exit, reason}} + {:noreply, finish_compilation(task_record.module, result, state)} + + Map.has_key?(state.monitor_index, reference) -> + state = reference |> release_monitored_owner(state) |> maybe_complete_drain() + {:noreply, state} + + true -> + {:noreply, state} + end + end + + @impl true + def terminate(_reason, state) do + Enum.each(state.tasks, fn {_reference, task} -> + Task.Supervisor.terminate_child(state.task_supervisor, task.pid) + end) + + Enum.each(state.slots, fn {module, slot} -> + if slot.status == :ready and slot.lease_count == 0 do + safe_backend_call(state.backend, :retire, [module]) + end + end) + + :ok + end + + defp checkout_cached_indexed(module, key, from, state) do + case Map.fetch!(state.slots, module) do + %{status: :ready, key: ^key} -> + {lease, state} = issue_lease(module, from, state) + {:reply, {:ok, lease}, state} + + %{status: :compiling, key: ^key} -> + state = add_waiter(module, from, state) + {:noreply, state} + + _stale -> + state = %{state | key_index: Map.delete(state.key_index, key)} + {:reply, :miss, state} + end + end + + defp checkout_indexed(module, key, input, from, state) do + case Map.fetch!(state.slots, module) do + %{status: :ready, key: ^key} -> + {lease, state} = issue_lease(module, from, state) + {:reply, {:ok, lease}, state} + + %{status: :compiling, key: ^key} -> + state = add_waiter(module, from, state) + {:noreply, state} + + _stale -> + state = %{state | key_index: Map.delete(state.key_index, key)} + checkout_miss(key, input, from, state) + end + end + + defp checkout_miss(key, input, from, state) do + case select_slot(state) do + {:ok, module, :free} -> + {:noreply, start_compilation(module, key, input, from, state)} + + {:ok, module, :evict} -> + case retire_slot(module, state) do + {:ok, state} -> + {:noreply, start_compilation(module, key, input, from, state)} + + {:error, state} -> + checkout_miss(key, input, from, state) + end + + :error -> + {:reply, {:error, :compiler_pool_busy}, state} + end + end + + defp select_slot(state) do + case Enum.find(state.modules, &(Map.fetch!(state.slots, &1).status == :free)) do + nil -> select_eviction(state) + module -> {:ok, module, :free} + end + end + + defp select_eviction(state) do + state.modules + |> Enum.map(&Map.fetch!(state.slots, &1)) + |> Enum.filter(&(&1.status == :ready and &1.lease_count == 0)) + |> Enum.min_by(& &1.last_used, fn -> nil end) + |> case do + nil -> :error + slot -> {:ok, slot.module, :evict} + end + end + + defp retire_slot(module, state) do + slot = Map.fetch!(state.slots, module) + state = %{state | key_index: Map.delete(state.key_index, slot.key)} + + case safe_backend_call(state.backend, :retire, [module]) do + :ok -> + {:ok, put_slot(state, free_slot(module, slot.generation))} + + result -> + reason = backend_error(result) + + slot = %{ + module: module, + status: :quarantined, + generation: slot.generation, + reason: reason + } + + {:error, put_slot(state, slot)} + end + end + + defp start_compilation(module, key, input, from, state) do + state = %{state | skip_index: Map.delete(state.skip_index, key)} + {waiter, state} = monitor_waiter(module, from, state) + backend = state.backend + max_heap_words = state.compile_max_heap_words + + task = + Task.Supervisor.async_nolink(state.task_supervisor, fn -> + Process.flag(:max_heap_size, %{ + size: max_heap_words, + kill: true, + error_logger: false + }) + + backend.compile(key, module, input) + end) + + timer = Process.send_after(self(), {:compile_timeout, task.ref}, state.compile_timeout) + previous = Map.fetch!(state.slots, module) + + slot = %{ + module: module, + status: :compiling, + key: key, + generation: previous.generation, + waiters: [waiter], + task_ref: task.ref + } + + task_record = %{module: module, pid: task.pid, timer: timer} + + state + |> put_slot(slot) + |> put_in([:key_index, key], module) + |> put_in([:tasks, task.ref], task_record) + end + + defp add_waiter(module, from, state) do + {waiter, state} = monitor_waiter(module, from, state) + update_slot(state, module, &Map.update!(&1, :waiters, fn waiters -> [waiter | waiters] end)) + end + + defp monitor_waiter(module, {owner, _tag} = from, state) do + monitor = Process.monitor(owner) + waiter = %{from: from, owner: owner, monitor: monitor} + state = put_in(state, [:monitor_index, monitor], {:waiter, module}) + {waiter, state} + end + + defp finish_compilation(module, {:ok, artifact}, state) do + slot = Map.fetch!(state.slots, module) + + case safe_backend_call(state.backend, :install, [module, artifact]) do + :ok -> compilation_ready(slot, state) + result -> compilation_failed(slot, {:install_failed, backend_error(result)}, state, true) + end + end + + defp finish_compilation(module, {:error, reason}, state) do + slot = Map.fetch!(state.slots, module) + compilation_failed(slot, reason, state, false) + end + + defp finish_compilation(module, result, state) do + slot = Map.fetch!(state.slots, module) + compilation_failed(slot, {:invalid_backend_result, result}, state, false) + end + + defp compilation_ready(slot, state) do + state = tick(state) + + ready = %{ + module: slot.module, + status: :ready, + key: slot.key, + generation: slot.generation + 1, + lease_count: 0, + last_used: state.clock + } + + state = put_slot(state, ready) + + Enum.reduce(Enum.reverse(slot.waiters), state, fn waiter, state -> + state = demonitor_waiter(waiter, state) + {lease, state} = issue_lease(slot.module, waiter.from, state) + GenServer.reply(waiter.from, {:ok, lease}) + state + end) + end + + defp compilation_failed(slot, reason, state, quarantine?) do + Enum.each( + slot.waiters, + &GenServer.reply(&1.from, {:error, {:compiler_compile_failed, reason}}) + ) + + state = Enum.reduce(slot.waiters, state, &demonitor_waiter/2) + state = %{state | key_index: Map.delete(state.key_index, slot.key)} + + replacement = + if quarantine? do + %{module: slot.module, status: :quarantined, generation: slot.generation, reason: reason} + else + free_slot(slot.module, slot.generation) + end + + put_slot(state, replacement) + end + + defp issue_lease(module, {owner, _tag}, state) do + slot = Map.fetch!(state.slots, module) + token = make_ref() + monitor = Process.monitor(owner) + + lease = %Lease{ + pool: self(), + module: module, + key: slot.key, + epoch: state.epoch, + generation: slot.generation, + token: token, + owner: owner + } + + record = %{module: module, owner: owner, monitor: monitor, lease: lease} + + state = + state + |> put_in([:leases, token], record) + |> put_in([:monitor_index, monitor], {:lease, token}) + |> update_slot(module, &Map.update!(&1, :lease_count, fn count -> count + 1 end)) + |> touch_slot(module) + + {lease, state} + end + + defp fetch_lease(%Lease{} = lease, caller, state) do + with true <- lease.pool == self(), + true <- lease.owner == caller, + {:ok, record} <- Map.fetch(state.leases, lease.token), + true <- record.lease == lease do + {:ok, record} + else + false when lease.owner != caller -> {:error, :compiler_lease_owner_mismatch} + _other -> {:error, :stale_compiler_lease} + end + end + + defp release_lease(token, record, state, demonitor?) do + if demonitor?, do: Process.demonitor(record.monitor, [:flush]) + + state + |> update_in([:leases], &Map.delete(&1, token)) + |> update_in([:monitor_index], &Map.delete(&1, record.monitor)) + |> update_slot(record.module, &Map.update!(&1, :lease_count, fn count -> count - 1 end)) + |> touch_slot(record.module) + end + + defp release_monitored_owner(reference, state) do + case Map.fetch!(state.monitor_index, reference) do + {:lease, token} -> + record = Map.fetch!(state.leases, token) + release_lease(token, record, state, false) + + {:waiter, module} -> + state = update_in(state, [:monitor_index], &Map.delete(&1, reference)) + update_slot(state, module, &remove_waiter(&1, reference)) + end + end + + defp remove_waiter(slot, reference) do + waiters = Enum.reject(slot.waiters, &(&1.monitor == reference)) + %{slot | waiters: waiters} + end + + defp demonitor_waiter(waiter, state) do + Process.demonitor(waiter.monitor, [:flush]) + update_in(state, [:monitor_index], &Map.delete(&1, waiter.monitor)) + end + + defp drop_task(reference, task_record, state) do + Process.cancel_timer(task_record.timer) + update_in(state, [:tasks], &Map.delete(&1, reference)) + end + + defp touch_slot(state, module) do + state = tick(state) + update_slot(state, module, &Map.put(&1, :last_used, state.clock)) + end + + defp put_skip(state, key) do + state = tick(state) + put_in(state, [:skip_index, key], state.clock) + end + + defp touch_skip(state, key), do: put_skip(state, key) + + defp trim_skips(state) when map_size(state.skip_index) <= @max_skip_entries, do: state + + defp trim_skips(state) do + {key, _clock} = Enum.min_by(state.skip_index, fn {_key, clock} -> clock end) + %{state | skip_index: Map.delete(state.skip_index, key)} + end + + defp admit_hot_region(key, count, state) do + cond do + MapSet.member?(state.region_hot, key) -> + {:hot, state} + + count < @region_admission_threshold -> + {:cold, state} + + MapSet.size(state.region_hot) < state.region_hot_capacity -> + {:hot, %{state | region_hot: MapSet.put(state.region_hot, key)}} + + true -> + {:cold, state} + end + end + + defp trim_region_admissions(state) + when map_size(state.region_admissions) <= @max_region_admissions, + do: state + + defp trim_region_admissions(state) do + {key, _entry} = + Enum.min_by(state.region_admissions, fn {candidate, entry} -> {entry.clock, candidate} end) + + %{state | region_admissions: Map.delete(state.region_admissions, key)} + end + + defp tick(state), do: %{state | clock: state.clock + 1} + defp put_slot(state, slot), do: put_in(state, [:slots, slot.module], slot) + defp update_slot(state, module, function), do: update_in(state, [:slots, module], function) + + defp free_slot(module, generation), + do: %{module: module, status: :free, generation: generation} + + defp initialize_slot(backend, module) do + case safe_backend_call(backend, :retire, [module]) do + :ok -> + free_slot(module, 0) + + result -> + %{ + module: module, + status: :quarantined, + generation: 0, + reason: backend_error(result) + } + end + end + + defp safe_backend_call(backend, function, args) do + apply(backend, function, args) + catch + kind, reason -> {:error, {kind, reason}} + end + + defp backend_error({:error, reason}), do: reason + defp backend_error(result), do: {:invalid_backend_result, result} + + defp cancel_compilations(state) do + Enum.reduce(state.tasks, state, fn {reference, task_record}, state -> + Task.Supervisor.terminate_child(state.task_supervisor, task_record.pid) + Process.demonitor(reference, [:flush]) + state = drop_task(reference, task_record, state) + finish_compilation(task_record.module, {:error, :compiler_pool_stopping}, state) + end) + end + + defp maybe_complete_drain(%{mode: :running} = state), do: state + + defp maybe_complete_drain(state) when map_size(state.leases) > 0 or map_size(state.tasks) > 0, + do: state + + defp maybe_complete_drain(state) do + {state, quarantined} = retire_ready_slots(state) + + response = + if quarantined == [], do: :ok, else: {:error, {:compiler_slots_quarantined, quarantined}} + + case state.drain do + nil -> + %{state | mode: :drained} + + drain -> + Process.cancel_timer(drain.timer) + GenServer.reply(drain.from, response) + %{state | mode: :drained, drain: nil} + end + end + + defp retire_ready_slots(state) do + Enum.reduce(state.modules, {state, []}, fn module, {state, quarantined} -> + slot = Map.fetch!(state.slots, module) + retire_ready_slot(module, slot, state, quarantined) + end) + end + + defp retire_ready_slot(module, %{status: :ready, lease_count: 0} = slot, state, quarantined) do + state = %{state | key_index: Map.delete(state.key_index, slot.key)} + + case safe_backend_call(state.backend, :retire, [module]) do + :ok -> + {put_slot(state, free_slot(module, slot.generation)), quarantined} + + result -> + reason = backend_error(result) + + replacement = %{ + module: module, + status: :quarantined, + generation: slot.generation, + reason: reason + } + + {put_slot(state, replacement), [{module, reason} | quarantined]} + end + end + + defp retire_ready_slot(_module, _slot, state, quarantined), do: {state, quarantined} + + defp fetch_backend(opts) do + case Keyword.fetch(opts, :backend) do + {:ok, backend} when is_atom(backend) -> + if Code.ensure_loaded?(backend) and + function_exported?(backend, :compile, 3) and + function_exported?(backend, :install, 2) and + function_exported?(backend, :retire, 1) do + {:ok, backend} + else + {:error, {:invalid_compiler_backend, backend}} + end + + {:ok, backend} -> + {:error, {:invalid_compiler_backend, backend}} + + :error -> + {:error, {:missing_option, :backend}} + end + end + + defp fetch_task_supervisor(opts) do + supervisor = Keyword.get(opts, :task_supervisor, QuickBEAM.VM.TaskSupervisor) + + cond do + is_pid(supervisor) and Process.alive?(supervisor) -> + {:ok, supervisor} + + is_atom(supervisor) and is_pid(Process.whereis(supervisor)) -> + {:ok, supervisor} + + is_atom(supervisor) or is_pid(supervisor) -> + {:error, {:compiler_task_supervisor_unavailable, supervisor}} + + true -> + {:error, {:invalid_option, :task_supervisor, supervisor}} + end + end + + defp fetch_capacity(opts) do + case Keyword.get(opts, :capacity, Contract.pool_capacity()) do + capacity when is_integer(capacity) and capacity > 0 and capacity <= @max_capacity -> + {:ok, capacity} + + capacity -> + {:error, {:invalid_option, :capacity, capacity}} + end + end + + defp fetch_compile_timeout(opts) do + case Keyword.get(opts, :compile_timeout, @default_compile_timeout) do + timeout when is_integer(timeout) and timeout > 0 -> {:ok, timeout} + timeout -> {:error, {:invalid_option, :compile_timeout, timeout}} + end + end + + defp fetch_compile_max_heap_words(opts) do + bytes = Keyword.get(opts, :compile_max_heap_bytes, @default_compile_max_heap_bytes) + + if is_integer(bytes) and bytes > 0 do + {:ok, max(div(bytes, :erlang.system_info(:wordsize)), 1)} + else + {:error, {:invalid_option, :compile_max_heap_bytes, bytes}} + end + end + + defp valid_key?(key), + do: is_binary(key) and byte_size(key) == Contract.artifact_key_bytes() +end diff --git a/lib/quickbeam/vm/compiler/pool/backend.ex b/lib/quickbeam/vm/compiler/pool/backend.ex new file mode 100644 index 000000000..e226f2a9c --- /dev/null +++ b/lib/quickbeam/vm/compiler/pool/backend.ex @@ -0,0 +1,20 @@ +defmodule QuickBEAM.VM.Compiler.Pool.Backend do + @moduledoc """ + Defines the generated-module boundary used by the bounded module pool. + + Preparation runs in a supervised task. Installation and retirement are + serialized by the module pool. Retirement must use soft purge semantics and + return an error rather than hard-purging live code. + """ + + @type artifact :: term() + + @doc "Builds a slot-specific artifact for a binary cache key." + @callback compile(binary(), module(), term()) :: {:ok, artifact()} | {:error, term()} + + @doc "Installs a compiled artifact under its assigned static module." + @callback install(module(), artifact()) :: :ok | {:error, term()} + + @doc "Safely retires code currently installed in a static module slot." + @callback retire(module()) :: :ok | {:error, term()} +end diff --git a/lib/quickbeam/vm/compiler/pool/lease.ex b/lib/quickbeam/vm/compiler/pool/lease.ex new file mode 100644 index 000000000..7a4f4e508 --- /dev/null +++ b/lib/quickbeam/vm/compiler/pool/lease.ex @@ -0,0 +1,21 @@ +defmodule QuickBEAM.VM.Compiler.Pool.Lease do + @moduledoc """ + Grants one evaluation process temporary access to a compiled module slot. + + Leases are owner-local and opaque. A module may be invoked only while its + lease is valid; callers must not retain the module or construct leases. + """ + + @enforce_keys [:pool, :module, :key, :epoch, :generation, :token, :owner] + defstruct [:pool, :module, :key, :epoch, :generation, :token, :owner] + + @type t :: %__MODULE__{ + pool: pid(), + module: module(), + key: binary(), + epoch: pos_integer(), + generation: pos_integer(), + token: reference(), + owner: pid() + } +end diff --git a/lib/quickbeam/vm/compiler/profile/pure.ex b/lib/quickbeam/vm/compiler/profile/pure.ex new file mode 100644 index 000000000..fc82cce3b --- /dev/null +++ b/lib/quickbeam/vm/compiler/profile/pure.ex @@ -0,0 +1,563 @@ +defmodule QuickBEAM.VM.Compiler.Profile.Pure do + @moduledoc """ + Lowers verified v26 basic blocks to the first bounded compiler profile. + + Generated modules contain specialized block and instruction clauses but route + every operation through the canonical runtime ABI. Unsupported or resumable + instructions remain explicit before-instruction deopt boundaries. + """ + + alias QuickBEAM.VM.Bytecode.Opcode + alias QuickBEAM.VM.Bytecode.Verifier.Stack + alias QuickBEAM.VM.Compiler.Analysis.ControlFlow, as: CFG + alias QuickBEAM.VM.Compiler.Code.Template + alias QuickBEAM.VM.Compiler.Profile.Scalar + alias QuickBEAM.VM.Compiler.Runtime + alias QuickBEAM.VM.Program.Function + alias QuickBEAM.VM.Runtime.Opcode.Invocation + + @max_block_instruction_count 256 + @max_block_count 4_096 + @max_lowered_instruction_count 4_096 + @max_region_operations 32 + @suspension_operations [:await] ++ Invocation.opcodes() + @core_scalar_operations %{ + get_loc_check: :local, + post_dec: :value, + post_inc: :value + } + @extended_scalar_operations %{ + check_define_var: :global, + define_func: :global, + define_var: :global, + get_var: :global, + get_var_undef: :global, + push_atom_value: :global, + put_var: :global, + put_var_init: :global, + call: :invocation, + call_method: :invocation, + get_array_el: :object, + get_field: :object, + get_field2: :object, + get_length: :object + } + @scalar_operations Map.merge(@core_scalar_operations, @extended_scalar_operations) + @line 1 + + @doc "Rejects obviously too-small non-loop candidates before artifact hashing." + @spec candidate?(Function.t(), non_neg_integer(), :pure_v1 | :scalar_v1) :: boolean() + def candidate?(%Function{instructions: instructions}, minimum, profile) + when is_tuple(instructions) and is_integer(minimum) and minimum >= 0 and + profile in [:pure_v1, :scalar_v1] do + minimum <= 1 or tuple_size(instructions) >= minimum or backward_branch?(instructions) + end + + @doc "Emits one bounded scalar entry region from an oversized function." + @spec prepare_region(Function.t(), non_neg_integer(), :pure_v1 | :scalar_v1) :: + {:ok, Template.t(), non_neg_integer()} | {:skip, non_neg_integer()} | {:error, term()} + def prepare_region(%Function{} = function, entry_pc, profile) + when is_integer(entry_pc) and entry_pc >= 0 and profile in [:pure_v1, :scalar_v1] do + with {:ok, plan, levels} <- analyze_plan(function, profile) do + region = select_region(plan, entry_pc) + count = lowered_operation_count(region) + + case Scalar.lower_region(function, region, levels) do + {:ok, template} when count > 0 -> {:ok, template, count} + _not_eligible -> {:skip, count} + end + end + end + + @doc "Returns the deterministic pure-block execution plan for one function." + @spec plan(Function.t()) :: {:ok, Runtime.plan()} | {:error, term()} + def plan(%Function{} = function) do + with {:ok, plan, _levels} <- analyze_plan(function, :pure_v1), do: {:ok, plan} + end + + @doc "Returns bounded scalar eligibility for a verified function and profile." + @spec scalar_eligibility(Function.t(), :pure_v1 | :scalar_v1) :: + :eligible | {:ineligible, atom()} | {:error, term()} + def scalar_eligibility(%Function{} = function, profile) + when profile in [:pure_v1, :scalar_v1] do + with {:ok, plan, levels} <- analyze_plan(function, profile) do + Scalar.eligibility(function, plan, levels) + end + end + + @doc "Emits specialized generated-module forms for the bounded pure profile." + @spec lower(Function.t()) :: {:ok, Template.t()} | {:error, term()} + def lower(%Function{} = function) do + with {:ok, plan, levels} <- analyze_plan(function, :pure_v1), + do: lower_plan(function, plan, levels, :pure_v1) + end + + @doc "Selects functions with a useful entry prefix and emits their validated plan." + @spec prepare(Function.t(), non_neg_integer(), :pure_v1 | :scalar_v1) :: + {:ok, Template.t(), non_neg_integer()} | {:skip, non_neg_integer()} | {:error, term()} + def prepare(function, minimum, profile \\ :pure_v1) + + def prepare(%Function{} = function, minimum, profile) + when is_integer(minimum) and minimum >= 0 and profile in [:pure_v1, :scalar_v1] do + with {:ok, analyzed_plan, levels} <- analyze_plan(function, profile) do + template = template(function, analyzed_plan, levels, profile) + plan = if scalar_template?(template), do: analyzed_plan, else: generic_plan(analyzed_plan) + count = lowered_operation_count(plan) + entry_count = entry_operation_count(plan) + + if eligible_template?(template, plan, count, entry_count, minimum), + do: {:ok, template, count}, + else: {:skip, count} + end + end + + defp analyze_plan(function, profile) do + with {:ok, analysis} <- Stack.analyze(function), + true <- analysis.maximum == function.stack_size, + {:ok, blocks} <- CFG.analyze(function), + plan <- build_plan(blocks, profile), + :ok <- validate_plan_size(plan) do + {:ok, plan, analysis.levels} + else + false -> {:error, {:stack_size_mismatch, function.stack_size}} + {:error, _reason} = error -> error + end + end + + @spec lower_plan(Function.t(), Runtime.plan(), map(), :pure_v1 | :scalar_v1) :: + {:ok, Template.t()} + defp lower_plan(function, plan, levels, profile), + do: {:ok, template(function, plan, levels, profile)} + + defp entry_operation_count(plan) do + case Map.get(plan, 0) do + {operations, _reason} -> length(operations) + nil -> 0 + end + end + + @doc "Reports whether a template uses bounded scalar block forms." + @spec scalar_template?(Template.t()) :: boolean() + def scalar_template?(%Template{forms: forms}), + do: Enum.any?(forms, &match?({:function, _, :block, 3, _}, &1)) + + defp lowered_operation_count(plan) do + Enum.reduce(plan, 0, fn {_pc, {operations, _reason}}, count -> count + length(operations) end) + end + + defp eligible_template?(template, plan, count, entry_count, minimum) do + cond do + scalar_template?(template) and extended_scalar_plan?(plan) -> loop_plan?(plan) + scalar_template?(template) -> count >= minimum or (minimum > 0 and loop_plan?(plan)) + true -> entry_count >= minimum + end + end + + defp extended_scalar_plan?(plan) do + Enum.any?(plan, fn {_pc, {operations, _reason}} -> + Enum.any?(operations, fn {family, _name, _operands} -> family in [:object, :invocation] end) + end) + end + + defp backward_branch?(instructions) do + instructions + |> Tuple.to_list() + |> Enum.with_index() + |> Enum.any?(fn {{opcode, operands}, pc} -> + case Opcode.info(opcode) do + {name, _size, _pops, _pushes, _format} + when name in [:goto, :goto8, :goto16, :if_false, :if_false8, :if_true, :if_true8] -> + match?([target | _] when is_integer(target) and target <= pc, operands) + + _info -> + false + end + end) + end + + defp loop_plan?(plan) do + Enum.any?(plan, fn {pc, {operations, _reason}} -> + Enum.any?(operations, fn + {:branch, _name, [target]} -> target <= pc + _operation -> false + end) + end) + end + + defp select_region(plan, entry_pc) do + case Map.get(plan, entry_pc) do + {operations, reason} when operations != [] -> + selected = operations |> Enum.take(@max_region_operations) |> drop_terminal_branch() + + selected_reason = + if length(selected) < length(operations) or reason == :continue, + do: :unsupported_semantics, + else: reason + + if selected == [], do: %{}, else: %{entry_pc => {selected, selected_reason}} + + _missing_or_unsupported_entry -> + %{} + end + end + + defp drop_terminal_branch(operations) do + case List.last(operations) do + {:branch, _name, _operands} -> Enum.drop(operations, -1) + _operation -> operations + end + end + + defp build_plan(blocks, :pure_v1), do: Map.new(blocks, &plan_block(&1, :pure_v1)) + + defp build_plan(blocks, :scalar_v1), + do: blocks |> Enum.flat_map(&plan_block_segments/1) |> Map.new() + + defp plan_block_segments(block) do + block.instructions + |> split_scalar_segments([], []) + |> Enum.map(fn instructions -> + first_pc = instructions |> hd() |> elem(0) + plan_block(%{block | start_pc: first_pc, instructions: instructions}, :scalar_v1) + end) + end + + defp split_scalar_segments([], [], segments), do: Enum.reverse(segments) + + defp split_scalar_segments([], current, segments), + do: Enum.reverse([Enum.reverse(current) | segments]) + + defp split_scalar_segments([instruction | instructions], current, segments) do + cond do + not supported_instruction?(instruction, :scalar_v1) -> + segments = if current == [], do: segments, else: [Enum.reverse(current) | segments] + split_scalar_segments(instructions, [], [[instruction] | segments]) + + preflight_instruction?(instruction) -> + segments = if current == [], do: segments, else: [Enum.reverse(current) | segments] + split_scalar_segments(instructions, [], [[instruction] | segments]) + + invocation_instruction?(instruction) -> + current = [instruction | current] + split_scalar_segments(instructions, [], [Enum.reverse(current) | segments]) + + true -> + split_scalar_segments(instructions, [instruction | current], segments) + end + end + + defp preflight_instruction?({_pc, name, _operands}), + do: name in [:get_array_el, :get_field, :get_field2, :get_length, :get_var] + + defp invocation_instruction?({_pc, name, _operands}), do: name in [:call, :call_method] + + defp plan_block(block, profile) do + {supported, remainder} = + Enum.split_while(block.instructions, &supported_instruction?(&1, profile)) + + capped? = length(supported) > @max_block_instruction_count + supported = Enum.take(supported, @max_block_instruction_count) + operations = Enum.map(supported, &operation(&1, profile)) + next_instruction = Enum.at(block.instructions, length(supported)) + reason = boundary_reason(operations, remainder, next_instruction, capped?) + {block.start_pc, {operations, reason}} + end + + @doc "Reports whether one canonical instruction belongs to a bounded profile." + @spec supported_instruction?({non_neg_integer(), atom(), [term()]}, :pure_v1 | :scalar_v1) :: + boolean() + def supported_instruction?({_pc, name, _operands}, profile) + when profile in [:pure_v1, :scalar_v1] do + scalar_operations = + if profile == :scalar_v1, do: @scalar_operations, else: @core_scalar_operations + + Map.has_key?(scalar_operations, name) or + match?({:ok, _family}, Runtime.operation_family(name)) + end + + defp operation({_pc, name, operands}, profile) do + scalar_operations = + if profile == :scalar_v1, do: @scalar_operations, else: @core_scalar_operations + + family = + case Map.fetch(scalar_operations, name) do + {:ok, family} -> family + :error -> name |> Runtime.operation_family() |> elem(1) + end + + {family, name, operands} + end + + defp boundary_reason(_operations, _remainder, _next_instruction, true), + do: :unsupported_semantics + + defp boundary_reason([], _remainder, {_pc, name, _operands}, false), + do: deopt_reason(name) + + defp boundary_reason(_operations, [_unsupported | _], {_pc, name, _operands}, false), + do: deopt_reason(name) + + defp boundary_reason(_operations, [], nil, false), do: :continue + + defp boundary_reason(_operations, _remainder, _next_instruction, false), + do: :unsupported_semantics + + defp deopt_reason(name) when name in @suspension_operations, do: :suspension_boundary + defp deopt_reason(_name), do: :unsupported_opcode + + defp validate_plan_size(plan) when map_size(plan) > @max_block_count, + do: {:error, {:compiler_resource_limit, :blocks, map_size(plan), @max_block_count}} + + defp validate_plan_size(plan) do + count = + Enum.reduce(plan, 0, fn {_pc, {operations, _reason}}, count -> + count + length(operations) + end) + + if count <= @max_lowered_instruction_count, + do: :ok, + else: + {:error, + {:compiler_resource_limit, :lowered_instructions, count, @max_lowered_instruction_count}} + end + + defp template(function, plan, levels, _profile) do + case Scalar.lower(function, plan, levels) do + {:ok, template} -> template + :not_eligible -> generic_template(generic_plan(plan)) + end + end + + defp generic_plan(plan) do + Map.new(plan, fn {pc, {operations, reason}} -> + {supported, remainder} = + Enum.split_while(operations, fn {_family, name, _operands} -> + not Map.has_key?(@scalar_operations, name) + end) + + reason = + cond do + remainder != [] -> :unsupported_opcode + reason == :continue -> :unsupported_semantics + true -> reason + end + + {pc, {supported, reason}} + end) + end + + defp generic_template(plan) do + step_forms = if has_slow_operations?(plan), do: [step_form(plan)], else: [] + + %Template{ + forms: + [ + {:attribute, @line, :module, Template.placeholder_module()}, + {:attribute, @line, :export, [run: 3]}, + run_form(), + block_form(plan) + ] ++ + step_forms ++ [{:eof, @line}] + } + end + + defp has_slow_operations?(plan), + do: + Enum.any?(plan, fn {_pc, {operations, _reason}} -> + operations != [] and not fast_operations?(operations) + end) + + defp run_form do + lease = variable(:Lease) + frame = variable(:Frame) + execution = variable(:Execution) + pc = remote_call(Runtime, :frame_pc, [frame]) + body = local_call(:block, [pc, lease, frame, execution]) + function(:run, 3, [clause([lease, frame, execution], [body])]) + end + + defp block_form(plan) do + clauses = + plan + |> Enum.sort_by(fn {pc, _block} -> pc end) + |> Enum.map(fn {pc, block} -> block_clause(pc, block) end) + + fallback = + clause( + [variable(:_PC), variable(:Lease), variable(:Frame), variable(:Execution)], + [deopt_call(:unsupported_semantics)] + ) + + function(:block, 4, clauses ++ [fallback]) + end + + defp block_clause(pc, {[], reason}) do + clause( + [integer(pc), variable(:Lease), variable(:Frame), variable(:Execution)], + [deopt_call(reason)] + ) + end + + defp block_clause(pc, {operations, reason}) do + if fast_operations?(operations) do + fast_block_clause(pc, operations, reason) + else + stepped_block_clause(pc, operations) + end + end + + defp fast_block_clause(pc, operations, reason) do + lease = variable(:Lease) + frame = variable(:Frame) + execution = variable(:Execution) + next_frame = variable(:NextFrame) + next_execution = variable(:NextExecution) + action = variable(:Action) + + execute = + remote_call(Runtime, :execute_fast_block, [ + lease, + frame, + execution, + :erl_parse.abstract(operations) + ]) + + body = + case_expression(execute, [ + clause( + [tuple([atom(:ok), next_frame, next_execution])], + [boundary_call(reason, lease, next_frame, next_execution)] + ), + clause([action], [action]) + ]) + + clause([integer(pc), lease, frame, execution], [body]) + end + + defp stepped_block_clause(pc, operations) do + lease = variable(:Lease) + frame = variable(:Frame) + execution = variable(:Execution) + next_frame = variable(:NextFrame) + next_execution = variable(:NextExecution) + action = variable(:Action) + + charge = + remote_call(Runtime, :charge_block, [lease, frame, execution, integer(length(operations))]) + + body = + case_expression(charge, [ + clause( + [tuple([atom(:ok), next_frame, next_execution])], + [local_call(:step, [step_id(pc, 0), lease, next_frame, next_execution])] + ), + clause([action], [action]) + ]) + + clause([integer(pc), lease, frame, execution], [body]) + end + + defp step_form(plan) do + clauses = + plan + |> Enum.sort_by(fn {pc, _block} -> pc end) + |> Enum.reject(fn {_pc, {operations, _reason}} -> fast_operations?(operations) end) + |> Enum.flat_map(fn {pc, {operations, reason}} -> step_clauses(pc, operations, reason) end) + + fallback = + clause( + [variable(:_Step), variable(:_Lease), variable(:_Frame), variable(:_Execution)], + [tuple([atom(:error), atom(:invalid_compiler_step)])] + ) + + function(:step, 4, clauses ++ [fallback]) + end + + defp step_clauses(pc, operations, reason) do + operation_clauses = + operations + |> Enum.with_index() + |> Enum.map(fn {operation, index} -> operation_clause(pc, index, operation) end) + + final = + clause( + [ + step_id(pc, length(operations)), + variable(:Lease), + variable(:Frame), + variable(:Execution) + ], + [boundary_call(reason)] + ) + + operation_clauses ++ [final] + end + + defp operation_clause(pc, index, {family, name, operands}) do + lease = variable(:Lease) + frame = variable(:Frame) + execution = variable(:Execution) + next_frame = variable(:NextFrame) + next_execution = variable(:NextExecution) + action = variable(:Action) + + execute = + remote_call(Runtime, family_function(family), [ + atom(name), + :erl_parse.abstract(operands), + frame, + execution + ]) + + body = + case_expression(execute, [ + clause( + [tuple([atom(:ok), next_frame, next_execution])], + [local_call(:step, [step_id(pc, index + 1), lease, next_frame, next_execution])] + ), + clause([action], [action]) + ]) + + clause([step_id(pc, index), lease, frame, execution], [body]) + end + + defp family_function(:stack), do: :execute_stack + defp family_function(:local), do: :execute_local + defp family_function(:value), do: :execute_value + defp family_function(:branch), do: :execute_branch + + defp fast_operations?(operations), do: operations != [] + + defp boundary_call(reason), + do: boundary_call(reason, variable(:Lease), variable(:Frame), variable(:Execution)) + + defp boundary_call(:continue, lease, frame, execution) do + pc = remote_call(Runtime, :frame_pc, [frame]) + local_call(:block, [pc, lease, frame, execution]) + end + + defp boundary_call(reason, lease, frame, execution), + do: deopt_call(reason, lease, frame, execution) + + defp deopt_call(reason), + do: deopt_call(reason, variable(:Lease), variable(:Frame), variable(:Execution)) + + defp deopt_call(reason, lease, frame, execution) do + remote_call(Runtime, :deopt, [atom(reason), lease, frame, execution]) + end + + defp step_id(pc, index), do: tuple([integer(pc), integer(index)]) + defp function(name, arity, clauses), do: {:function, @line, name, arity, clauses} + defp clause(patterns, body), do: {:clause, @line, patterns, [], body} + defp case_expression(expression, clauses), do: {:case, @line, expression, clauses} + defp local_call(name, arguments), do: {:call, @line, atom(name), arguments} + + defp remote_call(module, name, arguments) do + {:call, @line, {:remote, @line, atom(module), atom(name)}, arguments} + end + + defp variable(name), do: {:var, @line, name} + defp tuple(elements), do: {:tuple, @line, elements} + defp integer(value), do: {:integer, @line, value} + defp atom(value), do: {:atom, @line, value} +end diff --git a/lib/quickbeam/vm/compiler/profile/scalar.ex b/lib/quickbeam/vm/compiler/profile/scalar.ex new file mode 100644 index 000000000..dbcaa96df --- /dev/null +++ b/lib/quickbeam/vm/compiler/profile/scalar.ex @@ -0,0 +1,1011 @@ +defmodule QuickBEAM.VM.Compiler.Profile.Scalar do + @moduledoc """ + Emits bounded scalar block forms for functions whose locals cannot become cells. + + Operand-stack values remain generated BEAM expressions within a block, locals + and arguments remain tuples, and canonical value helpers are called directly. + Canonical frames are rebuilt only at explicit deoptimization boundaries. + """ + + alias QuickBEAM.VM.Compiler.Code.Template + alias QuickBEAM.VM.Compiler.Runtime + alias QuickBEAM.VM.Program.Function + + @line 1 + @max_stack_depth 64 + @max_argument_count 8 + @max_variable_count 8 + @max_scalar_operations 64 + @max_scalar_blocks 16 + @stack_variables List.to_tuple( + for index <- 0..(@max_stack_depth - 1), do: :"_CompilerStack#{index}" + ) + @charged_stack_variables List.to_tuple( + for index <- 0..(@max_stack_depth - 1), + do: :"_CompilerChargedStack#{index}" + ) + @preflight_stack_variables List.to_tuple( + for index <- 0..(@max_stack_depth - 1), + do: :"_CompilerPreflightStack#{index}" + ) + @left_variables List.to_tuple(for index <- 0..255, do: :"_CompilerLeft#{index}") + @right_variables List.to_tuple(for index <- 0..255, do: :"_CompilerRight#{index}") + @value_variables List.to_tuple(for index <- 0..255, do: :"_CompilerValue#{index}") + @property_variables List.to_tuple(for index <- 0..255, do: :"_CompilerProperty#{index}") + @global_variables List.to_tuple(for index <- 0..255, do: :"_CompilerGlobal#{index}") + @execution_variables List.to_tuple(for index <- 0..255, do: :"_CompilerExecution#{index}") + @invocation_variables List.to_tuple( + for index <- 0..(@max_stack_depth + 1), + do: :"_CompilerInvoke#{index}" + ) + @argument_tuple_variables List.to_tuple(for index <- 0..255, do: :"CompilerArgs#{index}") + @local_tuple_variables List.to_tuple(for index <- 0..255, do: :"CompilerLocals#{index}") + @materialized_variables List.to_tuple(for index <- 0..511, do: :"CompilerMaterialized#{index}") + + @type plan :: Runtime.plan() + + @doc "Emits scalar forms when stack depth and capture ownership are statically bounded." + @spec lower(Function.t(), plan(), map()) :: {:ok, Template.t()} | :not_eligible + def lower(%Function{} = function, plan, levels) when is_map(plan) and is_map(levels), + do: lower(function, plan, levels, :beam) + + @doc "Emits a region using runtime tuple updates that remain valid at early boundaries." + @spec lower_region(Function.t(), plan(), map()) :: {:ok, Template.t()} | :not_eligible + def lower_region(%Function{} = function, plan, levels) when is_map(plan) and is_map(levels), + do: lower(function, plan, levels, :runtime) + + defp lower(function, plan, levels, tuple_mode) do + case eligibility(function, plan, levels) do + :eligible -> + {:ok, + %Template{ + forms: [ + {:attribute, @line, :module, Template.placeholder_module()}, + {:attribute, @line, :export, [run: 3]}, + run_form(), + block_form(plan, levels, tuple_mode), + {:eof, @line} + ] + }} + + {:ineligible, _reason} -> + :not_eligible + end + end + + @doc "Returns the first bounded scalar-lowering eligibility rejection." + @spec eligibility(Function.t(), plan(), map()) :: :eligible | {:ineligible, atom()} + def eligibility(%Function{} = function, plan, levels) + when is_map(plan) and is_map(levels) do + with :ok <- within_limit(function.stack_size, @max_stack_depth, :stack_depth), + :ok <- within_limit(function.arg_count, @max_argument_count, :argument_count), + :ok <- within_limit(function.var_count, @max_variable_count, :variable_count), + :ok <- within_limit(map_size(plan), @max_scalar_blocks, :block_count), + :ok <- + within_limit(scalar_operation_count(plan), @max_scalar_operations, :operation_count), + :ok <- require_eligibility(bounded_blocks?(plan), :block_operation_count), + :ok <- require_eligibility(bounded_levels?(levels), :analyzed_stack_depth), + :ok <- + require_eligibility( + not captured_frame_slots?(function.constants), + :captured_frame_slots + ), + :ok <- + require_eligibility(checked_locals_initialized?(function, plan), :uninitialized_local) do + :eligible + end + end + + defp within_limit(value, maximum, _reason) when value <= maximum, do: :ok + defp within_limit(_value, _maximum, reason), do: {:ineligible, reason} + + defp require_eligibility(true, _reason), do: :ok + defp require_eligibility(false, reason), do: {:ineligible, reason} + + defp bounded_blocks?(plan), + do: Enum.all?(plan, fn {_pc, {operations, _reason}} -> length(operations) <= 32 end) + + defp bounded_levels?(levels), + do: Enum.all?(levels, fn {_pc, {depth, _catch}} -> depth <= @max_stack_depth end) + + defp checked_locals_initialized?(function, plan) do + count = max(function.arg_count + function.var_count, 1) + initialized = MapSet.new(0..(count - 1)) + walk_initialization(plan, %{0 => initialized}, [0]) + end + + defp walk_initialization(_plan, _entries, []), do: true + + defp walk_initialization(plan, entries, [pc | queue]) do + case Map.fetch(plan, pc) do + :error -> + walk_initialization(plan, entries, queue) + + {:ok, {operations, reason}} -> + initialized = Map.fetch!(entries, pc) + + case apply_initialization(operations, initialized) do + :unsafe -> + false + + initialized -> + successors = initialization_successors(pc, operations, reason) + {entries, queue} = merge_initialization(successors, initialized, entries, queue) + walk_initialization(plan, entries, queue) + end + end + end + + defp apply_initialization([], initialized), do: initialized + + defp apply_initialization([{:local, :get_loc_check, [index]} | operations], initialized) do + if MapSet.member?(initialized, index), + do: apply_initialization(operations, initialized), + else: :unsafe + end + + defp apply_initialization( + [{:local, :set_loc_uninitialized, [index]} | operations], + initialized + ), + do: apply_initialization(operations, MapSet.delete(initialized, index)) + + defp apply_initialization([{:local, name, [index]} | operations], initialized) + when name in [:put_loc, :set_loc, :put_loc_check, :put_loc_check_init], + do: apply_initialization(operations, MapSet.put(initialized, index)) + + defp apply_initialization([_operation | operations], initialized), + do: apply_initialization(operations, initialized) + + defp initialization_successors(pc, operations, reason) do + case List.last(operations) do + {:branch, name, [target]} when name in [:if_false, :if_false8, :if_true, :if_true8] -> + [target, pc + length(operations)] + + {:branch, name, [target]} when name in [:goto, :goto8, :goto16] -> + [target] + + _operation when reason == :continue -> + [pc + length(operations)] + + _operation -> + [] + end + end + + defp merge_initialization(successors, initialized, entries, queue) do + Enum.reduce(successors, {entries, queue}, fn successor, accumulator -> + merge_initialization_successor(successor, initialized, accumulator) + end) + end + + defp merge_initialization_successor(successor, initialized, {entries, queue}) do + case Map.fetch(entries, successor) do + :error -> + {Map.put(entries, successor, initialized), [successor | queue]} + + {:ok, existing} -> + merged = MapSet.intersection(existing, initialized) + + if merged == existing, + do: {entries, queue}, + else: {Map.put(entries, successor, merged), [successor | queue]} + end + end + + defp scalar_operation_count(plan), + do: + Enum.reduce(plan, 0, fn {_pc, {operations, _reason}}, count -> + count + length(operations) + end) + + defp captured_frame_slots?(constants) do + Enum.any?(constants, fn + %Function{closure_vars: closure_vars} -> + Enum.any?(closure_vars, &(Map.get(&1, :closure_type) in [0, 1])) + + _constant -> + false + end) + end + + defp run_form do + lease = variable(:Lease) + frame = variable(:Frame) + execution = variable(:Execution) + pc = variable(:PC) + args = variable(:Args) + locals = variable(:Locals) + stack = variable(:Stack) + + state = remote_call(Runtime, :frame_state, [frame]) + + body = + case_expression(state, [ + clause( + [tuple([pc, args, locals, stack])], + [local_call(:block, [pc, lease, tuple([frame, args, locals, stack, execution])])] + ) + ]) + + function(:run, 3, [clause([lease, frame, execution], [body])]) + end + + defp block_form(plan, levels, tuple_mode) do + clauses = + plan + |> Enum.sort_by(fn {pc, _block} -> pc end) + |> Enum.map(fn {pc, block} -> block_clause(pc, block, levels, tuple_mode) end) + + fallback = + clause( + [ + variable(:_PC), + variable(:Lease), + tuple([ + variable(:Frame), + variable(:Args), + variable(:Locals), + variable(:Stack), + variable(:Execution) + ]) + ], + [deopt_from_arguments(:unsupported_semantics, variable(:_PC), variable(:Stack))] + ) + + function(:block, 3, clauses ++ [fallback]) + end + + defp block_clause(pc, {[], reason}, levels, _tuple_mode) do + depth = stack_depth!(levels, pc) + + clause( + block_arguments(pc, depth), + [deopt_from_arguments(reason, integer(pc), stack_expression(depth))] + ) + end + + defp block_clause(pc, {[{family, name, _operands}] = operations, reason}, levels, tuple_mode) + when family == :object or (family == :global and name == :get_var) do + depth = stack_depth!(levels, pc) + + state = %{ + pc: pc, + lease: variable(:Lease), + frame: variable(:Frame), + args: variable(:Args), + locals: variable(:Locals), + stack: stack_values(depth), + execution: variable(:Execution), + tuple_mode: tuple_mode, + bindings: [], + materialization_counts: %{} + } + + clause(block_arguments(pc, depth), [lower_operations(operations, reason, state)]) + end + + defp block_clause(pc, {operations, reason}, levels, tuple_mode) do + depth = stack_depth!(levels, pc) + lease = variable(:Lease) + frame = variable(:Frame) + args = variable(:Args) + locals = variable(:Locals) + execution = variable(:Execution) + charged_lease = variable(:_ChargedLease) + charged_frame = variable(:ChargedFrame) + charged_args = variable(:ChargedArgs) + charged_locals = variable(:ChargedLocals) + charged_stack = charged_stack_values(depth) + charged_execution = variable(:ChargedExecution) + charged_state = variable(:ChargedState) + charge_result = variable(:ChargeResult) + action = variable(:Action) + + charged_bindings = [ + match_expression( + charged_lease, + remote_call(:erlang, :element, [integer(1), charged_state]) + ), + match_expression( + charged_frame, + remote_call(:erlang, :element, [integer(2), charged_state]) + ), + match_expression(charged_args, remote_call(:erlang, :element, [integer(3), charged_state])), + match_expression( + charged_locals, + remote_call(:erlang, :element, [integer(4), charged_state]) + ), + match_expression( + list(charged_stack), + remote_call(:erlang, :element, [integer(5), charged_state]) + ), + match_expression( + charged_execution, + remote_call(:erlang, :element, [integer(6), charged_state]) + ) + ] + + state = %{ + pc: pc, + lease: charged_lease, + frame: charged_frame, + args: charged_args, + locals: charged_locals, + stack: charged_stack, + execution: charged_execution, + tuple_mode: tuple_mode, + bindings: charged_bindings, + materialization_counts: %{} + } + + lowered = lower_operations(operations, reason, state) + + compact = tuple([frame, integer(pc), args, locals, stack_expression(depth)]) + + charge = + remote_call(Runtime, :charge_state, [lease, compact, execution, integer(length(operations))]) + + charged_body = + case_expression(charge_result, [ + clause([tuple([atom(:ok), charged_state])], [lowered]), + clause([action], [action]) + ]) + + body = anonymous_call([clause([charge_result], [charged_body])], [charge]) + + clause(block_arguments(pc, depth), [body]) + end + + defp block_arguments(pc, depth) do + [ + integer(pc), + variable(:Lease), + tuple([ + variable(:Frame), + variable(:Args), + variable(:Locals), + stack_expression(depth), + variable(:Execution) + ]) + ] + end + + defp lower_operations([], reason, state), + do: with_bindings(state, boundary_expression(reason, %{state | bindings: []})) + + defp lower_operations([{:global, name, operands} | operations], reason, state) do + with_bindings( + state, + lower_global(name, operands, operations, reason, %{state | bindings: []}) + ) + end + + defp lower_operations([{:object, name, operands} | operations], reason, state) do + with_bindings( + state, + lower_property(name, operands, operations, reason, %{state | bindings: []}) + ) + end + + defp lower_operations([{:invocation, name, operands}], _reason, state), + do: with_bindings(state, lower_invocation(name, operands, %{state | bindings: []})) + + defp lower_operations([operation | operations], reason, state) do + case lower_operation(operation, state) do + {:next, state} -> lower_operations(operations, reason, state) + {:terminal, expression, state} -> with_bindings(state, expression) + end + end + + defp lower_global(name, _operands, operations, reason, state) + when name in [:check_define_var, :define_var] do + lower_operations(operations, reason, %{state | pc: state.pc + 1}) + end + + defp lower_global(:push_atom_value, [atom_operand], operations, reason, state) do + value = remote_call(Runtime, :resolve_atom, [literal(atom_operand), state.execution]) + next_state = %{state | pc: state.pc + 1, stack: [value | state.stack]} + lower_operations(operations, reason, next_state) + end + + defp lower_global(name, [atom_operand], operations, reason, state) + when name in [:get_var, :get_var_undef] do + value = variable(elem(@global_variables, rem(state.pc, 256))) + get = remote_call(Runtime, :global_get, [atom(name), literal(atom_operand), state.execution]) + + success = + if name == :get_var do + charge_preflight(state, fn charged_state -> + next_state = %{ + charged_state + | pc: state.pc + 1, + stack: [value | charged_state.stack] + } + + lower_operations(operations, reason, next_state) + end) + else + next_state = %{state | pc: state.pc + 1, stack: [value | state.stack]} + lower_operations(operations, reason, next_state) + end + + case_expression(get, [ + clause([tuple([atom(:ok), value])], [success]), + clause([atom(:deopt)], [deopt_call(:unsupported_semantics, integer(state.pc), state)]) + ]) + end + + defp lower_global(name, [atom_operand | _flags], operations, reason, state) + when name in [:put_var, :put_var_init, :define_func] do + [value | stack] = state.stack + execution = variable(elem(@execution_variables, rem(state.pc, 256))) + put = remote_call(Runtime, :global_put, [literal(atom_operand), value, state.execution]) + next_state = %{state | pc: state.pc + 1, stack: stack, execution: execution} + + case_expression(put, [clause([execution], [lower_operations(operations, reason, next_state)])]) + end + + defp lower_invocation(:call, [argument_count], state) do + {arguments, [callable | stack]} = Enum.split(state.stack, argument_count) + state = %{state | pc: state.pc + 1, stack: stack} + invoke_call(callable, Enum.reverse(arguments), literal(:undefined), state) + end + + defp lower_invocation(:call_method, [argument_count], state) do + {arguments, [callable, this | stack]} = Enum.split(state.stack, argument_count) + state = %{state | pc: state.pc + 1, stack: stack} + invoke_call(callable, Enum.reverse(arguments), this, state) + end + + defp invoke_call(callable, arguments, this, state) do + bind_invocation_values([callable, this | arguments], [], fn [callable, this | arguments] -> + compact = + tuple([state.frame, integer(state.pc), state.args, state.locals, list(state.stack)]) + + remote_call(Runtime, :invoke_state, [ + callable, + list(arguments), + this, + compact, + state.execution + ]) + end) + end + + defp bind_invocation_values([], values, continuation), + do: values |> Enum.reverse() |> continuation.() + + defp bind_invocation_values([expression | expressions], values, continuation) do + value = variable(elem(@invocation_variables, length(values))) + + case_expression(expression, [ + clause([value], [bind_invocation_values(expressions, [value | values], continuation)]) + ]) + end + + defp lower_property(name, operands, operations, reason, state) do + {object, key, _result_stack} = property_operands(name, operands, state) + property_value = variable(elem(@property_variables, rem(state.pc, 256))) + get = remote_call(Runtime, :property_get, [object, key, state.execution]) + + success = + charge_preflight(state, fn charged_state -> + {_object, _key, result_stack} = property_operands(name, operands, charged_state) + + next_state = %{ + charged_state + | pc: state.pc + 1, + stack: [property_value | result_stack] + } + + lower_operations(operations, reason, next_state) + end) + + case_expression(get, [ + clause([tuple([atom(:ok), property_value])], [success]), + clause([atom(:deopt)], [deopt_call(:unsupported_semantics, integer(state.pc), state)]) + ]) + end + + defp charge_preflight(state, continuation) do + lease = variable(:_PreflightLease) + frame = variable(:PreflightFrame) + args = variable(:PreflightArgs) + locals = variable(:PreflightLocals) + stack = preflight_stack_values(length(state.stack)) + execution = variable(elem(@execution_variables, rem(state.pc, 256))) + continuation_state = variable(:PreflightState) + action = variable(:Action) + compact = tuple([state.frame, integer(state.pc), state.args, state.locals, list(state.stack)]) + + charged_bindings = [ + match_expression(lease, remote_call(:erlang, :element, [integer(1), continuation_state])), + match_expression(frame, remote_call(:erlang, :element, [integer(2), continuation_state])), + match_expression(args, remote_call(:erlang, :element, [integer(3), continuation_state])), + match_expression(locals, remote_call(:erlang, :element, [integer(4), continuation_state])), + match_expression( + list(stack), + remote_call(:erlang, :element, [integer(5), continuation_state]) + ), + match_expression( + execution, + remote_call(:erlang, :element, [integer(6), continuation_state]) + ) + ] + + charged_state = %{ + state + | lease: lease, + frame: frame, + args: args, + locals: locals, + stack: stack, + execution: execution, + bindings: charged_bindings + } + + charge = + remote_call(Runtime, :charge_state, [state.lease, compact, state.execution, integer(1)]) + + case_expression(charge, [ + clause([tuple([atom(:ok), continuation_state])], [continuation.(charged_state)]), + clause([action], [action]) + ]) + end + + defp property_operands(:get_field, [atom_operand], %{stack: [object | stack]} = state) do + key = remote_call(Runtime, :resolve_atom, [literal(atom_operand), state.execution]) + {object, key, stack} + end + + defp property_operands(:get_field2, [atom_operand], %{stack: [object | stack]} = state) do + key = remote_call(Runtime, :resolve_atom, [literal(atom_operand), state.execution]) + {object, key, [object | stack]} + end + + defp property_operands(:get_array_el, [], %{stack: [key, object | stack]}), + do: {object, key, stack} + + defp property_operands(:get_length, [], %{stack: [object | stack]}), + do: {object, literal("length"), stack} + + defp lower_operation({:stack, name, operands}, state), + do: {:next, lower_stack(name, operands, state)} + + defp lower_operation({:local, name, operands}, state), + do: {:next, lower_local(name, operands, state)} + + defp lower_operation({:value, name, []}, %{stack: [right, left | stack]} = state) + when name in [ + :add, + :sub, + :mul, + :div, + :mod, + :pow, + :lt, + :lte, + :gt, + :gte, + :eq, + :neq, + :strict_eq, + :strict_neq, + :and, + :or, + :xor, + :shl, + :sar, + :shr + ] do + value = binary_expression(name, left, right, state.pc) + {value, state} = materialize_expression(state, value) + {:next, %{state | pc: state.pc + 1, stack: [value | stack]}} + end + + defp lower_operation({:value, name, []}, %{stack: [value | stack]} = state) + when name in [:post_inc, :post_dec] do + operation = if name == :post_inc, do: :inc, else: :dec + updated = unary_expression(operation, value, state.pc) + {updated, state} = materialize_expression(state, updated) + {:next, %{state | pc: state.pc + 1, stack: [updated, value | stack]}} + end + + defp lower_operation({:value, name, []}, %{stack: [value | stack]} = state) do + value = unary_expression(name, value, state.pc) + {value, state} = materialize_expression(state, value) + {:next, %{state | pc: state.pc + 1, stack: [value | stack]}} + end + + defp lower_operation( + {:branch, name, [target]}, + %{stack: [condition | stack]} = state + ) + when name in [:if_false, :if_false8, :if_true, :if_true8] do + state = %{state | stack: stack} + fallthrough = block_call(%{state | pc: state.pc + 1}) + target = block_call(%{state | pc: target}) + truthy = remote_call(Runtime, :truthy?, [condition]) + + {false_body, true_body} = + if name in [:if_false, :if_false8], do: {target, fallthrough}, else: {fallthrough, target} + + {:terminal, + case_expression(truthy, [ + clause([atom(false)], [false_body]), + clause([atom(true)], [true_body]) + ]), state} + end + + defp lower_operation({:branch, name, [target]}, state) + when name in [:goto, :goto8, :goto16], + do: {:terminal, block_call(%{state | pc: target}), state} + + defp lower_stack(name, operands, state) when name in [:push_i32, :push_i8, :push_i16] do + [value] = operands + %{state | pc: state.pc + 1, stack: [integer(value) | state.stack]} + end + + defp lower_stack(:push_bigint_i32, [value], state), + do: %{state | pc: state.pc + 1, stack: [literal({:bigint, value}) | state.stack]} + + defp lower_stack(:undefined, [], state), do: push_literal(state, :undefined) + defp lower_stack(:null, [], state), do: push_literal(state, nil) + defp lower_stack(:push_false, [], state), do: push_literal(state, false) + defp lower_stack(:push_true, [], state), do: push_literal(state, true) + + defp lower_stack(:push_this, [], state), + do: push_expression(state, remote_call(Runtime, :frame_this, [state.frame])) + + defp lower_stack(name, [index], state) when name in [:push_const, :push_const8], + do: + push_expression(state, remote_call(Runtime, :frame_constant, [state.frame, integer(index)])) + + defp lower_stack(:drop, [], %{stack: [_value | stack]} = state), + do: advance_stack(state, stack) + + defp lower_stack(:dup, [], %{stack: [value | _]} = state), + do: advance_stack(state, [value | state.stack]) + + defp lower_stack(:dup1, [], %{stack: [a, b | stack]} = state), + do: advance_stack(state, [a, b, b | stack]) + + defp lower_stack(:dup2, [], %{stack: [a, b | stack]} = state), + do: advance_stack(state, [a, b, a, b | stack]) + + defp lower_stack(:dup3, [], %{stack: [a, b, c | stack]} = state), + do: advance_stack(state, [a, b, c, a, b, c | stack]) + + defp lower_stack(name, [], %{stack: [a, _b | stack]} = state) + when name in [:nip, :nip_catch], + do: advance_stack(state, [a | stack]) + + defp lower_stack(:nip1, [], %{stack: [a, b, _c | stack]} = state), + do: advance_stack(state, [a, b | stack]) + + defp lower_stack(:swap, [], %{stack: [a, b | stack]} = state), + do: advance_stack(state, [b, a | stack]) + + defp lower_stack(:swap2, [], %{stack: [a, b, c, d | stack]} = state), + do: advance_stack(state, [c, d, a, b | stack]) + + defp lower_stack(:perm3, [], %{stack: [a, b, c | stack]} = state), + do: advance_stack(state, [a, c, b | stack]) + + defp lower_stack(:perm4, [], %{stack: [a, b, c, d | stack]} = state), + do: advance_stack(state, [a, c, d, b | stack]) + + defp lower_stack(:perm5, [], %{stack: [a, b, c, d, e | stack]} = state), + do: advance_stack(state, [a, c, d, e, b | stack]) + + defp lower_stack(:rot3l, [], %{stack: [a, b, c | stack]} = state), + do: advance_stack(state, [c, a, b | stack]) + + defp lower_stack(:rot3r, [], %{stack: [a, b, c | stack]} = state), + do: advance_stack(state, [b, c, a | stack]) + + defp lower_stack(:rot4l, [], %{stack: [a, b, c, d | stack]} = state), + do: advance_stack(state, [d, a, b, c | stack]) + + defp lower_stack(:rot5l, [], %{stack: [a, b, c, d, e | stack]} = state), + do: advance_stack(state, [e, a, b, c, d | stack]) + + defp lower_stack(:insert2, [], %{stack: [a, b | stack]} = state), + do: advance_stack(state, [a, b, a | stack]) + + defp lower_stack(:insert3, [], %{stack: [a, b, c | stack]} = state), + do: advance_stack(state, [a, b, c, a | stack]) + + defp lower_local(:get_arg, [index], state), + do: push_expression(state, tuple_element(state.args, index)) + + defp lower_local(:put_arg, [index], %{stack: [value | stack]} = state) do + state = put_tuple(state, :args, index, value) + %{state | pc: state.pc + 1, stack: stack} + end + + defp lower_local(:set_arg, [index], %{stack: [value | _]} = state) do + state = put_tuple(state, :args, index, value) + %{state | pc: state.pc + 1} + end + + defp lower_local(name, [index], state) when name in [:get_loc, :get_loc_check], + do: push_expression(state, tuple_element(state.locals, index)) + + defp lower_local(:get_loc0_loc1, [first, second], state) do + {first, state} = materialize_expression(state, tuple_element(state.locals, first)) + {second, state} = materialize_expression(state, tuple_element(state.locals, second)) + %{state | pc: state.pc + 1, stack: [first, second | state.stack]} + end + + defp lower_local(name, [index], state) when name in [:inc_loc, :dec_loc] do + operation = if name == :inc_loc, do: :inc, else: :dec + current = tuple_element(state.locals, index) + value = unary_expression(operation, current, state.pc) + {value, state} = materialize_expression(state, value) + state = put_tuple(state, :locals, index, value) + %{state | pc: state.pc + 1} + end + + defp lower_local(:add_loc, [index], %{stack: [value | stack]} = state) do + current = tuple_element(state.locals, index) + value = binary_expression(:add, current, value, state.pc) + {value, state} = materialize_expression(state, value) + state = put_tuple(state, :locals, index, value) + %{state | pc: state.pc + 1, stack: stack} + end + + defp lower_local(name, [index], %{stack: [value | stack]} = state) + when name in [:put_loc, :put_loc_check_init, :put_loc_check] do + state = put_tuple(state, :locals, index, value) + %{state | pc: state.pc + 1, stack: stack} + end + + defp lower_local(:set_loc, [index], %{stack: [value | _]} = state) do + state = put_tuple(state, :locals, index, value) + %{state | pc: state.pc + 1} + end + + defp lower_local(:set_loc_uninitialized, [index], state) do + state = put_tuple(state, :locals, index, atom(:uninitialized)) + %{state | pc: state.pc + 1} + end + + defp boundary_expression(:continue, state), do: block_call(state) + defp boundary_expression(reason, state), do: deopt_call(reason, integer(state.pc), state) + + defp block_call(state) do + local_call(:block, [ + integer(state.pc), + state.lease, + tuple([state.frame, state.args, state.locals, list(state.stack), state.execution]) + ]) + end + + defp deopt_call(reason, pc, state) do + compact = tuple([state.frame, pc, state.args, state.locals, list(state.stack)]) + remote_call(Runtime, :deopt_state, [atom(reason), state.lease, compact, state.execution]) + end + + defp deopt_from_arguments(reason, pc, stack) do + compact = tuple([variable(:Frame), pc, variable(:Args), variable(:Locals), stack]) + + remote_call(Runtime, :deopt_state, [ + atom(reason), + variable(:Lease), + compact, + variable(:Execution) + ]) + end + + defp stack_depth!(levels, pc) do + {depth, _catch} = Map.fetch!(levels, pc) + depth + end + + defp stack_values(0), do: [] + + defp stack_values(depth), + do: for(index <- 0..(depth - 1), do: variable(elem(@stack_variables, index))) + + defp charged_stack_values(0), do: [] + + defp charged_stack_values(depth), + do: for(index <- 0..(depth - 1), do: variable(elem(@charged_stack_variables, index))) + + defp preflight_stack_values(0), do: [] + + defp preflight_stack_values(depth), + do: for(index <- 0..(depth - 1), do: variable(elem(@preflight_stack_variables, index))) + + defp stack_expression(depth), do: list(stack_values(depth)) + + defp push_literal(state, value), do: push_expression(state, literal(value)) + + defp push_expression(state, expression) do + {expression, state} = materialize_expression(state, expression) + %{state | pc: state.pc + 1, stack: [expression | state.stack]} + end + + defp advance_stack(state, stack), do: %{state | pc: state.pc + 1, stack: stack} + + defp binary_expression(:mod, {:integer, _, _} = left, {:integer, _, right} = right_expr, _pc) + when right != 0, + do: remote_call(:erlang, :rem, [left, right_expr]) + + defp binary_expression(name, left, right, pc) + when name in [:add, :sub, :mul, :lt, :lte, :gt, :gte, :eq, :neq, :strict_eq, :strict_neq] do + operator = binary_operator(name) + + if numeric_expression?(left) and numeric_expression?(right) do + operation(operator, left, right) + else + left_var = variable(elem(@left_variables, rem(pc, 256))) + right_var = variable(elem(@right_variables, rem(pc, 256))) + guards = [[guard_call(:is_number, [left_var]), guard_call(:is_number, [right_var])]] + + anonymous_call( + [ + guarded_clause([left_var, right_var], guards, [operation(operator, left_var, right_var)]), + clause( + [left_var, right_var], + [remote_call(Runtime, :binary, [atom(name), left_var, right_var])] + ) + ], + [left, right] + ) + end + end + + defp binary_expression(:mod = name, left, right, pc) do + left_var = variable(elem(@left_variables, rem(pc, 256))) + right_var = variable(elem(@right_variables, rem(pc, 256))) + + guards = [ + [ + guard_call(:is_integer, [left_var]), + guard_call(:is_integer, [right_var]), + operation(:"=/=", right_var, integer(0)) + ] + ] + + anonymous_call( + [ + guarded_clause( + [left_var, right_var], + guards, + [remote_call(:erlang, :rem, [left_var, right_var])] + ), + clause( + [left_var, right_var], + [remote_call(Runtime, :binary, [atom(name), left_var, right_var])] + ) + ], + [left, right] + ) + end + + defp binary_expression(name, left, right, _pc), + do: remote_call(Runtime, :binary, [atom(name), left, right]) + + defp unary_expression(name, {type, _, _} = value, _pc) + when type in [:integer, :float] and name in [:neg, :plus, :inc, :dec], + do: unary_numeric_expression(name, value) + + defp unary_expression(name, value, pc) when name in [:neg, :plus, :inc, :dec] do + value_var = variable(elem(@value_variables, rem(pc, 256))) + guards = [[guard_call(:is_number, [value_var])]] + + anonymous_call( + [ + guarded_clause([value_var], guards, [unary_numeric_expression(name, value_var)]), + clause([value_var], [remote_call(Runtime, :unary, [atom(name), value_var])]) + ], + [value] + ) + end + + defp unary_expression(name, value, _pc), + do: remote_call(Runtime, :unary, [atom(name), value]) + + defp numeric_expression?({type, _, _}) when type in [:integer, :float], do: true + + defp numeric_expression?({:op, _, operator, left, right}) + when operator in [:+, :-, :*], + do: numeric_expression?(left) and numeric_expression?(right) + + defp numeric_expression?(_expression), do: false + + defp binary_operator(:add), do: :+ + defp binary_operator(:sub), do: :- + defp binary_operator(:mul), do: :* + defp binary_operator(:lt), do: :< + defp binary_operator(:lte), do: :"=<" + defp binary_operator(:gt), do: :> + defp binary_operator(:gte), do: :>= + defp binary_operator(name) when name in [:eq, :strict_eq], do: :== + defp binary_operator(name) when name in [:neq, :strict_neq], do: :"/=" + + defp unary_numeric_expression(:neg, value), do: operation(:-, value) + defp unary_numeric_expression(:plus, value), do: value + defp unary_numeric_expression(:inc, value), do: operation(:+, value, integer(1)) + defp unary_numeric_expression(:dec, value), do: operation(:-, value, integer(1)) + + defp tuple_element(tuple, index), + do: remote_call(:erlang, :element, [integer(index + 1), tuple]) + + defp materialize_expression(state, expression) do + if simple_expression?(expression) do + {expression, state} + else + ordinal = Map.get(state.materialization_counts, state.pc, 0) + index = rem(state.pc, 256) + ordinal * 256 + value = variable(elem(@materialized_variables, index)) + + state = + state + |> Map.update!(:bindings, &[match_expression(value, expression) | &1]) + |> put_in([:materialization_counts, state.pc], ordinal + 1) + + {value, state} + end + end + + defp simple_expression?({type, _line, _value}) + when type in [:atom, :char, :float, :integer, :string, :var], + do: true + + defp simple_expression?({nil, _line}), do: true + defp simple_expression?(_expression), do: false + + defp put_tuple(state, field, index, value) when field in [:args, :locals] do + tuple = Map.fetch!(state, field) + result = tuple_variable(field, state.pc) + update = tuple_update(state.tuple_mode, tuple, index, value) + + state + |> Map.put(field, result) + |> Map.update!(:bindings, &[match_expression(result, update) | &1]) + end + + defp tuple_update(:beam, tuple, index, value), + do: remote_call(:erlang, :setelement, [integer(index + 1), tuple, value]) + + defp tuple_update(:runtime, tuple, index, value), + do: remote_call(Runtime, :tuple_put, [tuple, integer(index), value]) + + defp tuple_variable(:args, pc), + do: variable(elem(@argument_tuple_variables, rem(pc, 256))) + + defp tuple_variable(:locals, pc), + do: variable(elem(@local_tuple_variables, rem(pc, 256))) + + defp with_bindings(%{bindings: []}, expression), do: expression + + defp with_bindings(%{bindings: bindings}, expression), + do: {:block, @line, Enum.reverse(bindings) ++ [expression]} + + defp function(name, arity, clauses), do: {:function, @line, name, arity, clauses} + defp clause(arguments, body), do: {:clause, @line, arguments, [], body} + defp guarded_clause(arguments, guards, body), do: {:clause, @line, arguments, guards, body} + defp variable(name), do: {:var, @line, name} + defp atom(value), do: {:atom, @line, value} + defp integer(value), do: {:integer, @line, value} + defp literal(value), do: :erl_parse.abstract(value) + defp tuple(values), do: {:tuple, @line, values} + defp match_expression(pattern, expression), do: {:match, @line, pattern, expression} + defp list(values), do: Enum.reduce(Enum.reverse(values), {nil, @line}, &{:cons, @line, &1, &2}) + defp local_call(name, arguments), do: {:call, @line, atom(name), arguments} + defp guard_call(name, arguments), do: {:call, @line, atom(name), arguments} + defp operation(name, value), do: {:op, @line, name, value} + defp operation(name, left, right), do: {:op, @line, name, left, right} + + defp remote_call(module, name, arguments), + do: {:call, @line, {:remote, @line, atom(module), atom(name)}, arguments} + + defp case_expression(expression, clauses), do: {:case, @line, expression, clauses} + + defp anonymous_call(clauses, arguments), + do: {:call, @line, {:fun, @line, {:clauses, clauses}}, arguments} +end diff --git a/lib/quickbeam/vm/compiler/region/probe.ex b/lib/quickbeam/vm/compiler/region/probe.ex new file mode 100644 index 000000000..127f03e9e --- /dev/null +++ b/lib/quickbeam/vm/compiler/region/probe.ex @@ -0,0 +1,101 @@ +defmodule QuickBEAM.VM.Compiler.Region.Probe do + @moduledoc """ + Samples bounded owner-local instruction windows for compiler diagnostics. + + The probe is opt-in, uses OTP `:counters` for its sampling clock, and retains + at most 64 integer `{function_id, entry_pc}` keys with the Space-Saving + heavy-hitter algorithm. It never creates atoms or shared mutable state and is + not enabled by ordinary evaluation or standard measurements. + """ + + alias QuickBEAM.VM.Runtime.Frame + alias QuickBEAM.VM.Runtime.State + + @sample_interval 16 + @window_size 64 + @max_entries 64 + + @enforce_keys [:owner, :sample_counter] + defstruct [:owner, :sample_counter, entries: %{}] + + @type entry :: %{samples: pos_integer(), error: non_neg_integer()} + @type t :: %__MODULE__{ + owner: pid(), + sample_counter: term(), + entries: %{{non_neg_integer(), non_neg_integer()} => entry()} + } + + @doc "Creates one fixed-capacity probe owned by the current evaluation process." + @spec new() :: t() + def new, + do: %__MODULE__{owner: self(), sample_counter: :counters.new(1, [])} + + @doc "Samples one canonical interpreted frame without changing VM semantics." + @spec observe(State.t(), Frame.t()) :: State.t() + def observe( + %State{compiler_context: %{region_probe: %__MODULE__{owner: owner} = probe} = context} = + execution, + %Frame{function: %{id: function_id}, pc: pc} + ) + when owner == self() and is_integer(function_id) and function_id >= 0 and is_integer(pc) and + pc >= 0 do + :counters.add(probe.sample_counter, 1, 1) + count = :counters.get(probe.sample_counter, 1) + + if rem(count, @sample_interval) == 0 do + key = {function_id, div(pc, @window_size) * @window_size} + probe = %{probe | entries: increment(probe.entries, key)} + %{execution | compiler_context: %{context | region_probe: probe}} + else + execution + end + end + + def observe(%State{} = execution, %Frame{}), do: execution + + @doc "Returns bounded heavy hitters and sampling metadata at evaluation completion." + @spec snapshot(State.t()) :: map() | nil + def snapshot(%State{ + compiler_context: %{region_probe: %__MODULE__{owner: owner} = probe} + }) + when owner == self() do + regions = + probe.entries + |> Enum.map(fn {{function_id, entry_pc}, entry} -> + %{ + function_id: function_id, + entry_pc: entry_pc, + samples: entry.samples, + error: entry.error + } + end) + |> Enum.sort_by(&{-&1.samples, &1.function_id, &1.entry_pc}) + + %{ + sample_interval: @sample_interval, + window_size: @window_size, + total_samples: div(:counters.get(probe.sample_counter, 1), @sample_interval), + regions: regions + } + end + + def snapshot(%State{}), do: nil + + defp increment(entries, key) do + case Map.fetch(entries, key) do + {:ok, entry} -> + Map.put(entries, key, %{entry | samples: entry.samples + 1}) + + :error when map_size(entries) < @max_entries -> + Map.put(entries, key, %{samples: 1, error: 0}) + + :error -> + {victim, entry} = + Enum.min_by(entries, fn {candidate, value} -> {value.samples, candidate} end) + + entries + |> Map.delete(victim) + |> Map.put(key, %{samples: entry.samples + 1, error: entry.samples}) + end + end +end diff --git a/lib/quickbeam/vm/compiler/runtime.ex b/lib/quickbeam/vm/compiler/runtime.ex new file mode 100644 index 000000000..d20230be7 --- /dev/null +++ b/lib/quickbeam/vm/compiler/runtime.ex @@ -0,0 +1,649 @@ +defmodule QuickBEAM.VM.Compiler.Runtime do + @moduledoc """ + Defines the versioned semantic ABI available to generated BEAM modules. + + The ABI supports exact block charging, explicit deoptimization, and verified + stack, local, global, property, value, branch, and invocation operations. Implementations + delegate to canonical VM opcode and value layers rather than duplicating + JavaScript semantics. + """ + + alias QuickBEAM.VM.Compiler.Contract + alias QuickBEAM.VM.Compiler.Deopt + alias QuickBEAM.VM.Compiler.Pool.Lease + alias QuickBEAM.VM.Runtime.Frame + alias QuickBEAM.VM.Runtime.Opcode.Control + alias QuickBEAM.VM.Runtime.Opcode.Local, as: Locals + alias QuickBEAM.VM.Runtime.Opcode.Stack + alias QuickBEAM.VM.Runtime.Opcode.Value, as: Values + alias QuickBEAM.VM.Runtime.Property + alias QuickBEAM.VM.Runtime.Stack, as: OperandStack + alias QuickBEAM.VM.Runtime.State + alias QuickBEAM.VM.Runtime.Value + + @stack_operations Stack.opcodes() + @local_operations [ + :get_arg, + :put_arg, + :set_arg, + :get_loc, + :get_loc0_loc1, + :inc_loc, + :dec_loc, + :add_loc, + :put_loc, + :set_loc, + :set_loc_uninitialized, + :put_loc_check_init, + :put_loc_check + ] + @value_operations [ + :add, + :sub, + :mul, + :div, + :mod, + :pow, + :lt, + :lte, + :gt, + :gte, + :eq, + :neq, + :strict_eq, + :strict_neq, + :and, + :or, + :xor, + :shl, + :sar, + :shr, + :neg, + :plus, + :not, + :lnot, + :inc, + :dec, + :is_undefined_or_null, + :is_undefined, + :is_null + ] + @branch_operations [:if_false, :if_false8, :if_true, :if_true8, :goto, :goto8, :goto16] + @max_block_instruction_count 256 + + @type operation :: {:stack | :local | :value | :branch, atom(), [term()]} + @type block_boundary :: Deopt.reason() | :continue + @type plan :: %{optional(non_neg_integer()) => {[operation()], block_boundary()}} + + @type action :: + {:ok, Frame.t(), State.t()} + | {:deopt, Deopt.t()} + | {:invoke, term(), [term()], term(), Frame.t(), State.t(), false} + | {:error, term(), State.t()} + | {:error, term()} + + @doc "Returns the generated-code runtime ABI version." + @spec version() :: pos_integer() + def version, do: Contract.runtime_abi_version() + + @doc "Returns compact immutable fields used by scalar generated blocks." + @spec frame_state(Frame.t()) :: {non_neg_integer(), tuple(), tuple(), [term()]} + def frame_state(%Frame{} = frame), do: {frame.pc, frame.args, frame.locals, frame.stack} + + @doc "Returns the current frame receiver for scalar literal lowering." + @spec frame_this(Frame.t()) :: term() + def frame_this(%Frame{this: this}), do: this + + @doc "Returns one verified function constant for scalar literal lowering." + @spec frame_constant(Frame.t(), non_neg_integer()) :: term() + def frame_constant(%Frame{function: function}, index), do: Enum.at(function.constants, index) + + @doc "Returns the current verified instruction index from a canonical frame." + @spec frame_pc(Frame.t()) :: non_neg_integer() + def frame_pc(%Frame{pc: pc}), do: pc + + @doc "Returns the pure compiler family for a supported canonical opcode." + @spec operation_family(atom()) :: {:ok, :stack | :local | :value | :branch} | :error + def operation_family(name) when name in @stack_operations, do: {:ok, :stack} + def operation_family(name) when name in @local_operations, do: {:ok, :local} + def operation_family(name) when name in @value_operations, do: {:ok, :value} + def operation_family(name) when name in @branch_operations, do: {:ok, :branch} + def operation_family(_name), do: :error + + @doc "Executes one bounded lowered block and deoptimizes at its next boundary." + @spec execute_plan(Lease.t(), Frame.t(), State.t(), plan()) :: action() + def execute_plan(%Lease{} = lease, %Frame{} = frame, %State{} = execution, plan) + when is_map(plan) do + case Map.fetch(plan, frame.pc) do + {:ok, {[], reason}} -> + deopt(reason, lease, frame, execution) + + {:ok, {operations, reason}} + when is_list(operations) and length(operations) <= @max_block_instruction_count -> + with {:ok, frame, execution} <- + charge_block(lease, frame, execution, length(operations)), + {:ok, frame, execution} <- execute_operations(operations, frame, execution) do + finish_block(reason, lease, frame, execution, plan) + end + + {:ok, invalid} -> + {:error, {:invalid_compiler_block_plan, frame.pc, invalid}} + + :error -> + deopt(:unsupported_semantics, lease, frame, execution) + end + end + + def execute_plan(_lease, _frame, _execution, plan), + do: {:error, {:invalid_compiler_plan, plan}} + + defp finish_block(:continue, lease, frame, execution, plan), + do: execute_plan(lease, frame, execution, plan) + + defp finish_block(reason, lease, frame, execution, _plan), + do: deopt(reason, lease, frame, execution) + + @doc "Charges a scalar block or deoptimizes with its reconstructed before-block state." + @spec charge_state(Lease.t(), tuple(), State.t(), pos_integer()) :: + {:ok, tuple()} | action() + def charge_state(%Lease{owner: owner}, _state, _execution, _count) when owner != self(), + do: {:error, :compiler_lease_owner_mismatch} + + def charge_state(_lease, _state, %State{memory_exceeded: true} = execution, _count), + do: {:error, {:limit_exceeded, :memory_bytes, execution.memory_limit}, execution} + + def charge_state( + %Lease{} = lease, + {%Frame{} = frame, pc, args, locals, stack}, + %State{remaining_steps: remaining} = execution, + count + ) + when is_integer(pc) and pc >= 0 and is_tuple(args) and is_tuple(locals) and is_list(stack) and + is_integer(count) and count > 0 and remaining >= count do + charged_execution = %{execution | remaining_steps: remaining - count} + {:ok, {lease, frame, args, locals, stack, charged_execution}} + end + + def charge_state(%Lease{} = lease, state, %State{} = execution, count) + when is_integer(count) and count > 0, + do: deopt_state(:step_boundary, lease, state, execution) + + def charge_state(_lease, state, _execution, count), + do: {:error, {:invalid_compiler_scalar_charge, count, state}} + + @doc "Charges a guaranteed straight-line block or deoptimizes before it." + @spec charge_block(Lease.t(), Frame.t(), State.t(), pos_integer()) :: action() + def charge_block(%Lease{owner: owner}, _frame, _execution, _count) when owner != self(), + do: {:error, :compiler_lease_owner_mismatch} + + def charge_block(_lease, _frame, %State{memory_exceeded: true} = execution, _count), + do: {:error, {:limit_exceeded, :memory_bytes, execution.memory_limit}, execution} + + def charge_block( + %Lease{}, + %Frame{} = frame, + %State{remaining_steps: remaining} = execution, + count + ) + when is_integer(count) and count > 0 and remaining >= count do + {:ok, frame, %{execution | remaining_steps: remaining - count}} + end + + def charge_block(%Lease{} = lease, %Frame{} = frame, %State{} = execution, count) + when is_integer(count) and count > 0, + do: deopt(:step_boundary, lease, frame, execution) + + def charge_block(_lease, _frame, _execution, count), + do: {:error, {:invalid_compiler_step_charge, count}} + + @doc "Constructs a validated owner-local before-instruction deoptimization action." + @spec deopt(Deopt.reason(), Lease.t(), Frame.t(), State.t()) :: action() + def deopt(_reason, %Lease{owner: owner}, _frame, _execution) when owner != self(), + do: {:error, :compiler_lease_owner_mismatch} + + def deopt(reason, %Lease{} = lease, %Frame{} = frame, %State{} = execution) do + case Deopt.new(reason, lease.key, lease.epoch, lease.generation, frame, execution) do + {:ok, deopt} -> {:deopt, deopt} + {:error, error} -> {:error, {:invalid_compiler_deopt, error}} + end + end + + @doc "Deoptimizes after rebuilding a canonical frame from bounded scalar state." + @spec deopt_state(Deopt.reason(), Lease.t(), tuple(), State.t()) :: action() + def deopt_state( + reason, + %Lease{} = lease, + {%Frame{} = frame, pc, args, locals, stack}, + %State{} = execution + ) + when is_integer(pc) and pc >= 0 and is_tuple(args) and is_tuple(locals) and is_list(stack) do + frame = %{ + frame + | pc: pc, + args: args, + locals: locals, + stack: stack, + compiler_allow_reentry: scalar_profile?(execution) + } + + deopt(reason, lease, frame, execution) + end + + def deopt_state(_reason, _lease, state, _execution), + do: {:error, {:invalid_compiler_scalar_state, state}} + + @doc "Executes one compact stack/value/branch block with a single frame rebuild." + @spec execute_fast_block(Lease.t(), Frame.t(), State.t(), [operation()]) :: action() + def execute_fast_block(%Lease{} = lease, %Frame{} = frame, %State{} = execution, operations) + when is_list(operations) and length(operations) <= @max_block_instruction_count do + with {:ok, frame, execution} <- charge_block(lease, frame, execution, length(operations)), + {:ok, pc, args, locals, stack, execution} <- + execute_fast_operations( + operations, + frame.pc, + frame.args, + frame.locals, + frame.stack, + frame.this, + frame.function, + execution + ) do + {:ok, %{frame | pc: pc, args: args, locals: locals, stack: stack}, execution} + end + end + + def execute_fast_block(_lease, _frame, _execution, operations), + do: {:error, {:invalid_compiler_fast_block, operations}} + + @doc "Executes one verified pure operand-stack instruction and advances the PC." + @spec execute_stack(atom(), [term()], Frame.t(), State.t()) :: action() + def execute_stack(name, operands, %Frame{} = frame, %State{} = execution) + when name in @stack_operations and is_list(operands), + do: name |> Stack.execute(operands, frame, execution) |> advance_action() + + def execute_stack(name, operands, _frame, _execution), + do: {:error, {:unsupported_compiler_stack_operation, name, operands}} + + @doc "Updates a bounded argument or local tuple without generated tuple-update optimization." + @spec tuple_put(tuple(), non_neg_integer(), term()) :: tuple() + def tuple_put(tuple, index, value) when is_tuple(tuple) and is_integer(index) and index >= 0, + do: put_elem(tuple, index, value) + + @doc "Executes one verified pure local or argument instruction and advances the PC." + @spec execute_local(atom(), [term()], Frame.t(), State.t()) :: action() + def execute_local(name, operands, %Frame{} = frame, %State{} = execution) + when name in @local_operations and is_list(operands), + do: name |> Locals.execute(operands, frame, execution) |> advance_action() + + def execute_local(name, operands, _frame, _execution), + do: {:error, {:unsupported_compiler_local_operation, name, operands}} + + @doc "Executes one verified primitive value instruction and advances the PC." + @spec execute_value(atom(), [term()], Frame.t(), State.t()) :: action() + def execute_value(name, operands, %Frame{} = frame, %State{} = execution) + when name in @value_operations and is_list(operands), + do: name |> Values.execute(operands, frame, execution) |> advance_action() + + def execute_value(name, operands, _frame, _execution), + do: {:error, {:unsupported_compiler_value_operation, name, operands}} + + @doc "Executes one verified conditional or unconditional branch instruction." + @spec execute_branch(atom(), [term()], Frame.t(), State.t()) :: action() + def execute_branch(name, operands, %Frame{} = frame, %State{} = execution) + when name in @branch_operations and is_list(operands), + do: name |> Control.execute(operands, frame, execution) |> branch_action() + + def execute_branch(name, operands, _frame, _execution), + do: {:error, {:unsupported_compiler_branch_operation, name, operands}} + + defp execute_fast_operations( + [], + pc, + args, + locals, + stack, + _this, + _function, + execution + ), + do: {:ok, pc, args, locals, stack, execution} + + defp execute_fast_operations( + [{:stack, name, operands} | operations], + pc, + args, + locals, + stack, + this, + function, + execution + ) do + with {:ok, stack} <- OperandStack.execute(name, operands, stack, this, function.constants) do + execute_fast_operations( + operations, + pc + 1, + args, + locals, + stack, + this, + function, + execution + ) + end + end + + defp execute_fast_operations( + [{:local, name, operands} | operations], + pc, + args, + locals, + stack, + this, + function, + execution + ) do + with {:ok, args, locals, stack, execution} <- + Locals.execute_compact(name, operands, args, locals, stack, execution) do + execute_fast_operations( + operations, + pc + 1, + args, + locals, + stack, + this, + function, + execution + ) + end + end + + defp execute_fast_operations( + [{:value, name, []} | operations], + pc, + args, + locals, + [right, left | stack], + this, + function, + execution + ) + when name in [ + :add, + :sub, + :mul, + :div, + :mod, + :pow, + :lt, + :lte, + :gt, + :gte, + :eq, + :neq, + :strict_eq, + :strict_neq, + :and, + :or, + :xor, + :shl, + :sar, + :shr + ] do + value = fast_binary(name, left, right) + + execute_fast_operations( + operations, + pc + 1, + args, + locals, + [value | stack], + this, + function, + execution + ) + end + + defp execute_fast_operations( + [{:value, name, []} | operations], + pc, + args, + locals, + [value | stack], + this, + function, + execution + ) + when name in [ + :neg, + :plus, + :not, + :lnot, + :inc, + :dec, + :is_undefined_or_null, + :is_undefined, + :is_null + ] do + value = fast_unary(name, value) + + execute_fast_operations( + operations, + pc + 1, + args, + locals, + [value | stack], + this, + function, + execution + ) + end + + defp execute_fast_operations( + [{:branch, name, [target]} | operations], + pc, + args, + locals, + [value | stack], + this, + function, + execution + ) + when name in [:if_false, :if_false8, :if_true, :if_true8] do + truthy? = Value.truthy?(value) + + target_pc = + if name in [:if_false, :if_false8] do + if truthy?, do: pc + 1, else: target + else + if truthy?, do: target, else: pc + 1 + end + + execute_fast_operations( + operations, + target_pc, + args, + locals, + stack, + this, + function, + execution + ) + end + + defp execute_fast_operations( + [{:branch, name, [target]} | operations], + _pc, + args, + locals, + stack, + this, + function, + execution + ) + when name in [:goto, :goto8, :goto16], + do: + execute_fast_operations( + operations, + target, + args, + locals, + stack, + this, + function, + execution + ) + + defp execute_fast_operations( + [operation | _], + pc, + _args, + _locals, + stack, + _this, + _function, + _execution + ), + do: {:error, {:invalid_compiler_fast_operation, pc, operation, stack}} + + defp fast_binary(:add, left, right) when is_number(left) and is_number(right), + do: left + right + + defp fast_binary(:sub, left, right) when is_number(left) and is_number(right), + do: left - right + + defp fast_binary(:mul, left, right) when is_number(left) and is_number(right), + do: left * right + + defp fast_binary(:mod, left, right) + when is_integer(left) and is_integer(right) and right != 0, + do: rem(left, right) + + defp fast_binary(:lt, left, right) when is_number(left) and is_number(right), do: left < right + defp fast_binary(:lte, left, right) when is_number(left) and is_number(right), do: left <= right + defp fast_binary(:gt, left, right) when is_number(left) and is_number(right), do: left > right + defp fast_binary(:gte, left, right) when is_number(left) and is_number(right), do: left >= right + defp fast_binary(:eq, left, right) when is_number(left) and is_number(right), do: left == right + defp fast_binary(:neq, left, right) when is_number(left) and is_number(right), do: left != right + + defp fast_binary(:strict_eq, left, right) when is_number(left) and is_number(right), + do: left == right + + defp fast_binary(:strict_neq, left, right) when is_number(left) and is_number(right), + do: left != right + + defp fast_binary(name, left, right), do: Value.binary(name, left, right) + + defp fast_unary(:neg, value) when is_number(value), do: -value + defp fast_unary(:plus, value) when is_number(value), do: value + defp fast_unary(:inc, value) when is_number(value), do: value + 1 + defp fast_unary(:dec, value) when is_number(value), do: value - 1 + defp fast_unary(:lnot, value), do: not Value.truthy?(value) + defp fast_unary(:is_undefined_or_null, value), do: value in [:undefined, nil] + defp fast_unary(:is_undefined, value), do: value == :undefined + defp fast_unary(:is_null, value), do: is_nil(value) + defp fast_unary(name, value), do: Value.unary(name, value) + + defp execute_operations([], frame, execution), do: {:ok, frame, execution} + + defp execute_operations([{family, name, operands} | operations], frame, execution) do + case execute_operation(family, name, operands, frame, execution) do + {:ok, frame, execution} -> execute_operations(operations, frame, execution) + action -> action + end + end + + defp execute_operation(:stack, name, operands, frame, execution), + do: execute_stack(name, operands, frame, execution) + + defp execute_operation(:local, name, operands, frame, execution), + do: execute_local(name, operands, frame, execution) + + defp execute_operation(:value, name, operands, frame, execution), + do: execute_value(name, operands, frame, execution) + + defp execute_operation(:branch, name, operands, frame, execution), + do: execute_branch(name, operands, frame, execution) + + defp execute_operation(family, name, operands, _frame, _execution), + do: {:error, {:unsupported_compiler_operation, family, name, operands}} + + @doc "Reads one global through the canonical local/global layer." + @spec global_get(:get_var | :get_var_undef, term(), State.t()) :: {:ok, term()} | :deopt + def global_get(mode, atom, %State{} = execution) when mode in [:get_var, :get_var_undef] do + name = Locals.resolve_atom(atom, execution) + + case Locals.read_global(execution, name) do + {:ok, value} -> {:ok, value} + :error when mode == :get_var_undef -> {:ok, :undefined} + :error -> :deopt + end + end + + @doc "Writes one global through the canonical local/global layer." + @spec global_put(term(), term(), State.t()) :: State.t() + def global_put(atom, value, %State{} = execution) do + name = Locals.resolve_atom(atom, execution) + Locals.write_global(execution, name, value) + end + + @doc "Resolves one decoded atom operand through the canonical local layer." + @spec resolve_atom(term(), State.t()) :: term() + def resolve_atom(atom, %State{} = execution), do: Locals.resolve_atom(atom, execution) + + @doc "Reads a non-accessor property or requests before-instruction deoptimization." + @spec property_get(term(), term(), State.t()) :: {:ok, term()} | :deopt + def property_get(object, key, %State{} = execution) do + case Property.get(object, key, execution) do + {:ok, {:accessor, _getter, _receiver}} -> :deopt + {:ok, value} -> {:ok, value} + {:error, _reason} -> :deopt + end + end + + @doc "Returns an explicit interpreter-owned invocation from bounded scalar state." + @spec invoke_state(term(), [term()], term(), tuple(), State.t()) :: action() + def invoke_state( + callable, + arguments, + this, + {%Frame{} = frame, pc, args, locals, stack}, + %State{} = execution + ) + when is_list(arguments) and is_integer(pc) and pc >= 0 and is_tuple(args) and + is_tuple(locals) and is_list(stack) do + caller = %{ + frame + | pc: pc, + args: args, + locals: locals, + stack: stack, + compiler_allow_reentry: true, + compiler_entered: false + } + + {:invoke, callable, arguments, this, caller, execution, false} + end + + def invoke_state(_callable, _arguments, _this, state, _execution), + do: {:error, {:invalid_compiler_invocation_state, state}} + + defp scalar_profile?(%State{compiler_context: %{profile: :scalar_v1}}), do: true + defp scalar_profile?(_execution), do: false + + @doc "Returns canonical JavaScript truthiness for a represented value." + @spec truthy?(term()) :: boolean() + def truthy?(value), do: Value.truthy?(value) + + @doc "Applies one canonical primitive unary operation." + @spec unary(atom(), term()) :: term() + def unary(operation, value), do: Value.unary(operation, value) + + @doc "Applies one canonical primitive binary operation." + @spec binary(atom(), term(), term()) :: term() + def binary(operation, left, right), do: Value.binary(operation, left, right) + + defp advance_action({:next, frame, execution}), + do: {:ok, %{frame | pc: frame.pc + 1}, execution} + + defp branch_action({:run, frame, execution}), do: {:ok, frame, execution} +end diff --git a/lib/quickbeam/vm/fuzz.ex b/lib/quickbeam/vm/fuzz.ex new file mode 100644 index 000000000..1981e93b7 --- /dev/null +++ b/lib/quickbeam/vm/fuzz.ex @@ -0,0 +1,813 @@ +defmodule QuickBEAM.VM.Fuzz do + @moduledoc """ + Deterministic, bounded mutation fuzzing for the VM decoder and verifier. + + Every case executes twice in a monitored process with a wall-clock timeout + and BEAM maximum-heap limit. Decoder mutations preserve the QuickJS envelope + checksum unless checksum or version handling is the mutation target. Program + mutations are deliberately invalid and therefore must be rejected by the + verifier. + + A failure is replayed from its corpus name, seed, and iteration. The PRNG is + implemented here rather than delegated to `:rand`, keeping corpus generation + stable across OTP upgrades. + """ + + import Bitwise + + alias QuickBEAM.VM.Bytecode.Checksum + alias QuickBEAM.VM.Bytecode.Opcode + alias QuickBEAM.VM.Bytecode.Verifier + alias QuickBEAM.VM.Fuzz.Finding + alias QuickBEAM.VM.Fuzz.Mutation + alias QuickBEAM.VM.Fuzz.Summary + alias QuickBEAM.VM.Program + + @mask 0xFFFFFFFFFFFFFFFF + @default_iterations 1_000 + @default_timeout 100 + @default_max_heap_bytes 16 * 1024 * 1024 + @default_max_findings 20 + @max_iterations 1_000_000 + @run_options [:iterations, :max_findings, :max_heap_bytes, :seed, :timeout, :verify_options] + + @bytecode_operations [ + :truncate, + :delete, + :insert, + :duplicate, + :flip_bit, + :overwrite, + :unterminated_leb128, + :overflowing_leb128, + :oversized_count, + :unknown_byte, + :trailing_byte, + :bad_checksum, + :bad_version + ] + + @default_sources [ + {"control-flow", "function choose(x) { return x > 2 ? x * 3 : x - 1 } choose(4)"}, + {"closures", "function outer(x) { return y => x + y } outer(20)(22)"}, + {"objects", "const value = {a: [1, , 3], r: /a+/gi, n: 12345678901234567890n}; value.a[2]"}, + {"exceptions", + "function guarded(x) { try { if (x) throw new Error('x'); } catch (e) { return e.message; } } guarded(true)"}, + {"async", + "async function answer() { const x = await Promise.resolve(40); return x + 2 } answer()"} + ] + + @program_operations [ + :bad_version, + :bad_fingerprint, + :invalid_atom_table, + :invalid_source_positions, + :invalid_local_count, + :invalid_defined_args, + :negative_stack_size, + :unknown_opcode, + :invalid_instruction_shape, + :invalid_operand_type, + :invalid_constant, + :invalid_atom, + :invalid_jump, + :invalid_exception_target, + :stack_underflow, + :stack_size_mismatch, + :invalid_capture + ] + + @doc "Returns the representative source corpus used by CI and the Mix task." + @spec default_sources() :: [{String.t(), String.t()}] + def default_sources, do: @default_sources + + @doc "Runs deterministic mutations over named serialized-bytecode seeds." + @spec run_bytecode([{String.t(), binary()}], keyword()) :: + {:ok, Summary.t()} | {:error, term()} + def run_bytecode(corpus, opts \\ []) do + with {:ok, config} <- validate_run(corpus, opts), + :ok <- validate_bytecode_corpus(corpus) do + run(:bytecode, corpus, config, fn {name, bytecode}, iteration -> + bytecode_mutation(name, bytecode, config.seed, iteration) + end) + end + end + + @doc "Runs deliberately invalid mutations over named verified programs." + @spec run_verifier([{String.t(), Program.t()}], keyword()) :: + {:ok, Summary.t()} | {:error, term()} + def run_verifier(corpus, opts \\ []) do + with {:ok, config} <- validate_run(corpus, opts), + :ok <- validate_program_corpus(corpus) do + run(:program, corpus, config, fn {name, program}, iteration -> + program_mutation(name, program, config.seed, iteration) + end) + end + end + + @doc "Builds one reproducible checksum-aware bytecode mutation." + @spec bytecode_mutation(String.t(), binary(), non_neg_integer(), non_neg_integer()) :: + Mutation.t() + def bytecode_mutation(name, bytecode, seed, iteration) + when is_binary(name) and is_binary(bytecode) and is_integer(seed) and seed >= 0 and + is_integer(iteration) and iteration >= 0 do + state = initial_state(seed, iteration) + operation = Enum.at(@bytecode_operations, rem(iteration, length(@bytecode_operations))) + {value, details} = mutate_bytecode(bytecode, operation, state) + + %Mutation{ + domain: :bytecode, + corpus: name, + seed: seed, + iteration: iteration, + operation: operation, + value: value, + details: details + } + end + + @doc "Builds one reproducible invalid program mutation." + @spec program_mutation(String.t(), Program.t(), non_neg_integer(), non_neg_integer()) :: + Mutation.t() + def program_mutation(name, %Program{} = program, seed, iteration) + when is_binary(name) and is_integer(seed) and seed >= 0 and is_integer(iteration) and + iteration >= 0 do + operation = Enum.at(@program_operations, rem(iteration, length(@program_operations))) + value = mutate_program(program, operation, initial_state(seed, iteration)) + + %Mutation{ + domain: :program, + corpus: name, + seed: seed, + iteration: iteration, + operation: operation, + value: value + } + end + + @doc "Runs one serialized input twice under the harness resource bounds." + @spec probe_bytecode(binary(), keyword()) :: {:ok, term()} | {:error, term()} + def probe_bytecode(bytecode, opts \\ []) + + def probe_bytecode(bytecode, opts) when is_binary(bytecode) do + with {:ok, config} <- + validate_run([{"probe", bytecode}], Keyword.put(opts, :iterations, 1)) do + mutation = %Mutation{ + domain: :bytecode, + corpus: "probe", + seed: 0, + iteration: 0, + operation: :replay, + value: bytecode + } + + {:ok, execute(mutation, config)} + end + end + + def probe_bytecode(bytecode, _opts), do: {:error, {:invalid_bytecode, bytecode}} + + @doc "Returns true when a completed run found no safety or verification failures." + @spec safe?(Summary.t()) :: boolean() + def safe?(%Summary{findings: findings}), do: findings == [] + + @doc """ + Minimizes a bytecode safety finding with bounded deletion-based reduction. + + Reduction preserves the failure category rather than an unstable exception + stack. `:max_attempts` defaults to 256 and uses the same timeout and heap + options as a normal run. + """ + @spec minimize(Finding.t(), keyword()) :: {:ok, Finding.t()} | {:error, term()} + def minimize(finding, opts \\ []) + + def minimize(%Finding{mutation: %Mutation{domain: :bytecode} = mutation} = finding, opts) do + with {:ok, config} <- minimizer_config(opts), + target when target in [:crash, :timeout, :nondeterministic] <- + outcome_category(finding.outcome), + :ok <- ensure_reproduced(mutation, target, config) do + {value, _attempts} = + minimize_binary(mutation.value, target, config, 2, 0) + + minimized = %{mutation | value: value, details: Map.put(mutation.details, :minimized, true)} + {:ok, %Finding{finding | mutation: minimized, outcome: execute(minimized, config)}} + else + category when is_atom(category) -> {:error, {:not_a_safety_finding, category}} + {:error, _} = error -> error + end + end + + def minimize(%Finding{}, _opts), do: {:error, :unsupported_finding} + + defp ensure_reproduced(mutation, target, config) do + actual = mutation |> execute(config) |> outcome_category() + if actual == target, do: :ok, else: {:error, {:not_reproducible, target, actual}} + end + + @doc "Persists a minimized bytecode finding and human-readable replay metadata." + @spec persist(Finding.t(), Path.t()) :: {:ok, Path.t()} | {:error, term()} + def persist(%Finding{mutation: %Mutation{domain: :bytecode} = mutation} = finding, directory) + when is_binary(directory) do + digest = :crypto.hash(:sha256, mutation.value) |> Base.encode16(case: :lower) + + basename = + "#{safe_name(mutation.corpus)}-#{mutation.seed}-#{mutation.iteration}-#{String.slice(digest, 0, 12)}" + + binary_path = Path.join(directory, basename <> ".bin") + metadata_path = Path.join(directory, basename <> ".txt") + + metadata = """ + corpus: #{mutation.corpus} + seed: #{mutation.seed} + iteration: #{mutation.iteration} + operation: #{mutation.operation} + outcome: #{inspect(finding.outcome)} + sha256: #{digest} + """ + + with :ok <- File.mkdir_p(directory), + :ok <- File.write(binary_path, mutation.value), + :ok <- File.write(metadata_path, metadata) do + {:ok, binary_path} + end + end + + def persist(%Finding{}, directory), do: {:error, {:invalid_directory, directory}} + + @simple_error_classes %{ + unexpected_end: :truncated, + malformed_debug_info: :truncated, + checksum_mismatch: :checksum, + bad_leb128: :malformed_integer, + bad_sleb128: :malformed_integer, + integer_overflow: :malformed_integer + } + + @doc "Maps decoder and verifier errors to stable, coarse failure classes." + @spec classify_error(term()) :: atom() + def classify_error(reason) when is_atom(reason), + do: Map.get(@simple_error_classes, reason, :other_rejection) + + def classify_error({:bad_version, _version}), do: :version + def classify_error({:limit_exceeded, _kind, _count}), do: :limit + def classify_error({:unknown_tag, _tag}), do: :unknown_tag + def classify_error({:unknown_opcode, _opcode, _offset}), do: :unknown_opcode + def classify_error({:truncated_instruction, _opcode, _offset}), do: :truncated_instruction + def classify_error({:invalid_label, _label}), do: :invalid_jump + def classify_error({:trailing_bytes, _count}), do: :trailing_data + + def classify_error({:invalid_instruction, _function, _index, nested}), + do: classify_error(nested) + + def classify_error({:invalid_stack, _function, nested}), do: classify_error(nested) + def classify_error({:stack_underflow, _index, _depth, _pops}), do: :stack_underflow + def classify_error({:stack_size_mismatch, _declared}), do: :invalid_stack + def classify_error({:inconsistent_stack, _index, _existing, _incoming}), do: :invalid_stack + def classify_error({:invalid_fallthrough, _index}), do: :invalid_stack + def classify_error({:missing_catch, _index}), do: :invalid_stack + def classify_error({:unknown_opcode, _opcode}), do: :unknown_opcode + def classify_error({:invalid_index, :label, _index}), do: :invalid_jump + def classify_error({:invalid_index, _kind, _index}), do: :invalid_reference + def classify_error({:invalid_var_ref, _function, _index}), do: :invalid_reference + def classify_error({:invalid_function, _function, _reason}), do: :invalid_structure + def classify_error({:invalid_program, _reason}), do: :invalid_structure + def classify_error({:invalid_local_count, _actual, _expected}), do: :invalid_structure + def classify_error(_reason), do: :other_rejection + + defp run(domain, corpus, config, mutation_fun) do + initial = %Summary{ + domain: domain, + seed: config.seed, + iterations: config.iterations, + counts: %{}, + operation_counts: %{}, + findings: [] + } + + summary = + Enum.reduce(0..(config.iterations - 1), initial, fn iteration, summary -> + corpus_entry = Enum.at(corpus, rem(iteration, length(corpus))) + mutation = mutation_fun.(corpus_entry, iteration) + outcome = execute(mutation, config) + record(summary, mutation, outcome, config.max_findings) + end) + + {:ok, %{summary | findings: Enum.reverse(summary.findings)}} + end + + defp execute(%Mutation{domain: domain, value: value}, config) do + operation = fn -> normalize_result(domain, value, config.verify_options) end + isolated_repeat(operation, config.timeout, config.max_heap_bytes) + end + + defp normalize_result(:bytecode, bytecode, verify_options) do + case QuickBEAM.VM.decode(bytecode, verify_options) do + {:ok, _program} -> :accepted + {:error, reason} -> {:rejected, classify_error(reason), reason} + end + end + + defp normalize_result(:program, program, verify_options) do + case Verifier.verify(program, verify_options) do + :ok -> :accepted_invalid_program + {:error, reason} -> {:rejected, classify_error(reason), reason} + end + end + + defp isolated_repeat(operation, timeout, max_heap_bytes) do + parent = self() + words = max(div(max_heap_bytes, :erlang.system_info(:wordsize)), 1) + + {pid, monitor} = + :erlang.spawn_opt( + fn -> + first = operation.() + second = operation.() + + send( + parent, + {self(), if(first == second, do: first, else: {:nondeterministic, first, second})} + ) + end, + [:monitor, {:max_heap_size, %{size: words, kill: true, error_logger: false}}] + ) + + receive do + {^pid, outcome} -> + Process.demonitor(monitor, [:flush]) + outcome + + {:DOWN, ^monitor, :process, ^pid, reason} -> + {:crash, reason} + after + timeout -> + Process.exit(pid, :kill) + + receive do + {:DOWN, ^monitor, :process, ^pid, _reason} -> :ok + after + timeout -> :ok + end + + {:timeout, timeout} + end + end + + defp record(summary, mutation, outcome, max_findings) do + category = outcome_category(outcome) + + summary = %{ + summary + | counts: Map.update(summary.counts, category, 1, &(&1 + 1)), + operation_counts: Map.update(summary.operation_counts, mutation.operation, 1, &(&1 + 1)) + } + + if finding?(mutation.domain, outcome) and length(summary.findings) < max_findings do + %{summary | findings: [%Finding{mutation: mutation, outcome: outcome} | summary.findings]} + else + summary + end + end + + defp outcome_category(:accepted), do: :accepted + defp outcome_category(:accepted_invalid_program), do: :accepted_invalid_program + defp outcome_category({:rejected, category, _reason}), do: category + defp outcome_category({:timeout, _}), do: :timeout + defp outcome_category({:crash, _}), do: :crash + defp outcome_category({:nondeterministic, _, _}), do: :nondeterministic + + defp finding?(:bytecode, outcome), + do: + match?({kind, _} when kind in [:timeout, :crash], outcome) or + match?({:nondeterministic, _, _}, outcome) + + defp finding?(:program, :accepted_invalid_program), do: true + defp finding?(:program, outcome), do: finding?(:bytecode, outcome) + + defp mutate_bytecode(<>, operation, state) do + case operation do + :bad_checksum -> + {<>, %{offset: 1}} + + :bad_version -> + {envelope(rem(version + 1, 256), payload), %{offset: 0}} + + _ -> + {mutated, details} = mutate_payload(payload, operation, state) + {envelope(version, mutated), details} + end + end + + defp mutate_bytecode(bytecode, operation, state) do + mutate_payload(bytecode, operation, state) + end + + defp mutate_payload(payload, :truncate, state) do + {length, _state} = choose(state, byte_size(payload) + 1) + {binary_part(payload, 0, length), %{offset: length}} + end + + defp mutate_payload(payload, :delete, state) do + with_nonempty(payload, state, fn offset, state -> + {extra, _state} = choose(state, min(8, byte_size(payload) - offset)) + count = extra + 1 + {remove(payload, offset, count), %{offset: offset, count: count}} + end) + end + + defp mutate_payload(payload, :insert, state) do + {offset, state} = choose(state, byte_size(payload) + 1) + {count0, state} = choose(state, 8) + {bytes, _state} = random_bytes(state, count0 + 1) + {insert(payload, offset, bytes), %{offset: offset, count: byte_size(bytes)}} + end + + defp mutate_payload(payload, :duplicate, state) do + with_nonempty(payload, state, fn offset, state -> + {extra, state} = choose(state, min(8, byte_size(payload) - offset)) + count = extra + 1 + {target, _state} = choose(state, byte_size(payload) + 1) + bytes = binary_part(payload, offset, count) + {insert(payload, target, bytes), %{offset: target, source_offset: offset, count: count}} + end) + end + + defp mutate_payload(payload, :flip_bit, state) do + with_nonempty(payload, state, fn offset, state -> + {bit, _state} = choose(state, 8) + original = :binary.at(payload, offset) + {replace(payload, offset, 1, <>), %{offset: offset, bit: bit}} + end) + end + + defp mutate_payload(payload, :overwrite, state) do + with_nonempty(payload, state, fn offset, state -> + {value, _state} = choose(state, 256) + {replace(payload, offset, 1, <>), %{offset: offset, value: value}} + end) + end + + defp mutate_payload(payload, :unterminated_leb128, state), + do: insert_sequence(payload, state, <<0x80, 0x80, 0x80, 0x80, 0x80>>) + + defp mutate_payload(payload, :overflowing_leb128, state), + do: insert_sequence(payload, state, <<0xFF, 0xFF, 0xFF, 0xFF, 0x1F>>) + + defp mutate_payload(payload, :oversized_count, state), + do: insert_sequence(payload, state, <<0xA1, 0x8D, 0x06>>) + + defp mutate_payload(payload, :unknown_byte, state), + do: insert_sequence(payload, state, <<0xFF>>) + + defp mutate_payload(payload, :trailing_byte, state), + do: {payload <> <>, %{offset: byte_size(payload)}} + + defp mutate_payload(payload, _operation, _state), do: {payload, %{}} + + defp insert_sequence(payload, state, bytes) do + {offset, _state} = choose(state, byte_size(payload) + 1) + {insert(payload, offset, bytes), %{offset: offset, count: byte_size(bytes)}} + end + + defp with_nonempty(<<>>, _state, _fun), do: {<<0>>, %{offset: 0}} + + defp with_nonempty(payload, state, fun) do + {offset, state} = choose(state, byte_size(payload)) + fun.(offset, state) + end + + defp envelope(version, payload) do + checksum = Checksum.calculate(payload) + <> + end + + defp flip_checksum(<>), do: <> + + defp insert(binary, offset, bytes) do + <> = binary + left <> bytes <> right + end + + defp remove(binary, offset, count), do: replace(binary, offset, count, <<>>) + + defp replace(binary, offset, count, replacement) do + <> = binary + left <> replacement <> right + end + + defp mutate_program(program, :bad_version, _state), + do: %{program | version: program.version + 1} + + defp mutate_program(program, :bad_fingerprint, _state), + do: %{program | fingerprint: program.fingerprint <> "-mutated"} + + defp mutate_program(program, :invalid_atom_table, _state), do: %{program | atoms: []} + + defp mutate_program(program, operation, state) do + %{program | root: mutate_function(program.root, operation, program.atoms, state)} + end + + defp mutate_function(function, :invalid_source_positions, _atoms, _state), + do: %{function | source_positions: {}} + + defp mutate_function(function, :invalid_local_count, _atoms, _state), + do: %{function | var_count: function.var_count + 1} + + defp mutate_function(function, :invalid_defined_args, _atoms, _state), + do: %{function | defined_arg_count: function.arg_count + 1} + + defp mutate_function(function, :negative_stack_size, _atoms, _state), + do: %{function | stack_size: -1} + + defp mutate_function(function, :unknown_opcode, _atoms, _state), + do: replace_instruction(function, {256, []}) + + defp mutate_function(function, :invalid_instruction_shape, _atoms, _state), + do: replace_instruction(function, :not_an_instruction) + + defp mutate_function(function, :invalid_operand_type, _atoms, _state), + do: replace_instruction(function, {Opcode.num(:push_i32), [:not_an_integer]}) + + defp mutate_function(function, :invalid_constant, _atoms, _state) do + instruction = {Opcode.num(:push_const), [length(function.constants)]} + replace_instruction(function, instruction) + end + + defp mutate_function(function, :invalid_atom, atoms, _state) do + instruction = {Opcode.num(:get_var), [tuple_size(atoms)]} + replace_instruction(function, instruction) + end + + defp mutate_function(function, :invalid_jump, _atoms, _state) do + instruction = {Opcode.num(:goto), [tuple_size(function.instructions)]} + replace_instruction(function, instruction) + end + + defp mutate_function(function, :invalid_exception_target, _atoms, _state) do + instruction = {Opcode.num(:catch), [tuple_size(function.instructions)]} + replace_instruction(function, instruction) + end + + defp mutate_function(function, :stack_underflow, _atoms, _state), + do: replace_instruction(function, {Opcode.num(:drop), []}) + + defp mutate_function(function, :stack_size_mismatch, _atoms, _state), + do: %{function | stack_size: function.stack_size + 1} + + defp mutate_function(function, :invalid_capture, _atoms, _state) do + case function.locals do + [local | rest] -> + local = %{local | is_captured: true, var_ref_idx: function.var_ref_count} + %{function | locals: [local | rest]} + + [] -> + replace_instruction( + function, + {Opcode.num(:get_var_ref), [length(function.closure_vars)]} + ) + end + end + + defp mutate_function(function, _operation, _atoms, _state), do: function + + defp replace_instruction(function, instruction) do + instructions = + case tuple_size(function.instructions) do + 0 -> {instruction} + _ -> put_elem(function.instructions, 0, instruction) + end + + %{function | instructions: instructions} + end + + defp minimizer_config(opts) when is_list(opts) do + {max_attempts, run_options} = Keyword.pop(opts, :max_attempts, 256) + + with true <- is_integer(max_attempts) and max_attempts > 0 and max_attempts <= 10_000, + {:ok, config} <- + validate_run([{"minimizer", <<>>}], Keyword.put(run_options, :iterations, 1)) do + {:ok, Map.put(config, :max_attempts, max_attempts)} + else + false -> invalid_option(:max_attempts, max_attempts) + {:error, _} = error -> error + end + end + + defp minimizer_config(opts), do: invalid_option(:options, opts) + + defp minimize_binary(binary, _target, config, _granularity, attempts) + when attempts >= config.max_attempts or byte_size(binary) <= 6, + do: {binary, attempts} + + defp minimize_binary( + <> = binary, + target, + config, + granularity, + attempts + ) do + payload_size = byte_size(payload) + granularity = min(granularity, max(payload_size, 1)) + chunk_size = max(div(payload_size + granularity - 1, granularity), 1) + + result = + 0..(granularity - 1) + |> Enum.reduce_while({:none, attempts}, fn index, accumulator -> + attempt_reduction( + index, + accumulator, + version, + payload, + payload_size, + chunk_size, + target, + config + ) + end) + + case result do + {{:reduced, candidate}, attempts} -> + minimize_binary(candidate, target, config, max(granularity - 1, 2), attempts) + + {:none, attempts} when granularity < payload_size -> + minimize_binary(binary, target, config, min(granularity * 2, payload_size), attempts) + + {:none, attempts} -> + {binary, attempts} + end + end + + defp minimize_binary(binary, _target, _config, _granularity, attempts), do: {binary, attempts} + + defp attempt_reduction( + index, + {:none, attempts}, + version, + payload, + payload_size, + chunk_size, + target, + config + ) do + offset = index * chunk_size + count = min(chunk_size, max(payload_size - offset, 0)) + + if count == 0 or attempts >= config.max_attempts do + {:halt, {:none, attempts}} + else + reduction_outcome(version, payload, offset, count, attempts, target, config) + end + end + + defp reduction_outcome(version, payload, offset, count, attempts, target, config) do + candidate = envelope(version, remove(payload, offset, count)) + + mutation = %Mutation{ + domain: :bytecode, + corpus: "minimizer", + seed: 0, + iteration: attempts, + operation: :delete, + value: candidate + } + + if mutation |> execute(config) |> outcome_category() == target, + do: {:halt, {{:reduced, candidate}, attempts + 1}}, + else: {:cont, {:none, attempts + 1}} + end + + defp safe_name(name) do + name + |> String.to_charlist() + |> Enum.reduce({[], false}, fn character, {characters, separator?} -> + cond do + safe_name_character?(character) -> {[character | characters], false} + separator? -> {characters, true} + true -> {[?- | characters], true} + end + end) + |> elem(0) + |> Enum.reverse() + |> to_string() + |> String.trim("-") + |> case do + "" -> "corpus" + safe -> safe + end + end + + defp safe_name_character?(character) when character in ?a..?z, do: true + defp safe_name_character?(character) when character in ?A..?Z, do: true + defp safe_name_character?(character) when character in ?0..?9, do: true + defp safe_name_character?(character) when character in [?_, ?-], do: true + defp safe_name_character?(_character), do: false + + defp validate_run(corpus, opts) when is_list(corpus) and is_list(opts) do + if Keyword.keyword?(opts), + do: do_validate_run(corpus, opts), + else: {:error, :invalid_arguments} + end + + defp validate_run(_corpus, _opts), do: {:error, :invalid_arguments} + + defp do_validate_run(corpus, opts) do + config = %{ + seed: Keyword.get(opts, :seed, 0x51424D), + iterations: Keyword.get(opts, :iterations, @default_iterations), + timeout: Keyword.get(opts, :timeout, @default_timeout), + max_heap_bytes: Keyword.get(opts, :max_heap_bytes, @default_max_heap_bytes), + max_findings: Keyword.get(opts, :max_findings, @default_max_findings), + verify_options: Keyword.get(opts, :verify_options, []) + } + + with :ok <- validate_corpus_names(corpus), + :ok <- validate_option_keys(opts), + :ok <- validate_seed(config.seed), + :ok <- validate_iterations(config.iterations), + :ok <- validate_timeout(config.timeout), + :ok <- validate_max_heap_bytes(config.max_heap_bytes), + :ok <- validate_max_findings(config.max_findings), + :ok <- validate_verify_options(config.verify_options) do + {:ok, config} + end + end + + defp validate_corpus_names([]), do: {:error, :empty_corpus} + + defp validate_corpus_names(corpus) do + if Enum.all?(corpus, &valid_corpus_entry?/1), + do: :ok, + else: {:error, :invalid_corpus} + end + + defp validate_option_keys(opts) do + case Keyword.keys(opts) -- @run_options do + [] -> :ok + [key | _rest] -> {:error, {:unknown_option, key}} + end + end + + defp validate_seed(value) when is_integer(value) and value >= 0, do: :ok + defp validate_seed(value), do: invalid_option(:seed, value) + + defp validate_iterations(value) + when is_integer(value) and value > 0 and value <= @max_iterations, + do: :ok + + defp validate_iterations(value), do: invalid_option(:iterations, value) + + defp validate_timeout(value) when is_integer(value) and value > 0 and value <= 5_000, do: :ok + defp validate_timeout(value), do: invalid_option(:timeout, value) + + defp validate_max_heap_bytes(value) when is_integer(value) and value >= 1024 * 1024, + do: :ok + + defp validate_max_heap_bytes(value), do: invalid_option(:max_heap_bytes, value) + + defp validate_max_findings(value) when is_integer(value) and value > 0, do: :ok + defp validate_max_findings(value), do: invalid_option(:max_findings, value) + + defp validate_verify_options(value) when is_list(value), do: :ok + defp validate_verify_options(value), do: invalid_option(:verify_options, value) + + defp valid_corpus_entry?({name, _value}), do: is_binary(name) + defp valid_corpus_entry?(_entry), do: false + + defp validate_bytecode_corpus(corpus) do + if Enum.all?(corpus, fn {_name, value} -> is_binary(value) end), + do: :ok, + else: {:error, :invalid_corpus} + end + + defp validate_program_corpus(corpus) do + if Enum.all?(corpus, fn {_name, value} -> match?(%Program{}, value) end), + do: :ok, + else: {:error, :invalid_corpus} + end + + defp invalid_option(name, value), do: {:error, {:invalid_option, name, value}} + + defp initial_state(seed, iteration) do + mixed = band(seed + iteration * 0x9E3779B97F4A7C15, @mask) + if mixed == 0, do: 0xA0761D6478BD642F, else: mixed + end + + defp next_state(state) do + state = bxor(state, state <<< 13) |> band(@mask) + state = bxor(state, state >>> 7) + bxor(state, state <<< 17) |> band(@mask) + end + + defp choose(state, upper_bound) when upper_bound > 0 do + state = next_state(state) + {rem(state, upper_bound), state} + end + + defp random_bytes(state, count), do: random_bytes(state, count, []) + defp random_bytes(state, 0, bytes), do: {:erlang.list_to_binary(Enum.reverse(bytes)), state} + + defp random_bytes(state, count, bytes) do + {byte, state} = choose(state, 256) + random_bytes(state, count - 1, [byte | bytes]) + end +end diff --git a/lib/quickbeam/vm/fuzz/finding.ex b/lib/quickbeam/vm/fuzz/finding.ex new file mode 100644 index 000000000..a09287197 --- /dev/null +++ b/lib/quickbeam/vm/fuzz/finding.ex @@ -0,0 +1,11 @@ +defmodule QuickBEAM.VM.Fuzz.Finding do + @moduledoc "A reproducible crash, timeout, nondeterminism, or verifier acceptance." + + @enforce_keys [:mutation, :outcome] + defstruct [:mutation, :outcome] + + @type t :: %__MODULE__{ + mutation: QuickBEAM.VM.Fuzz.Mutation.t(), + outcome: term() + } +end diff --git a/lib/quickbeam/vm/fuzz/mutation.ex b/lib/quickbeam/vm/fuzz/mutation.ex new file mode 100644 index 000000000..87339a9dc --- /dev/null +++ b/lib/quickbeam/vm/fuzz/mutation.ex @@ -0,0 +1,21 @@ +defmodule QuickBEAM.VM.Fuzz.Mutation do + @moduledoc """ + Describes one reproducible decoder or verifier mutation. + + The original corpus value is intentionally not retained. Replaying the same + corpus entry with `seed` and `iteration` reconstructs `value` exactly. + """ + + @enforce_keys [:domain, :corpus, :seed, :iteration, :operation, :value] + defstruct [:domain, :corpus, :seed, :iteration, :operation, :value, details: %{}] + + @type t :: %__MODULE__{ + domain: :bytecode | :program, + corpus: String.t(), + seed: non_neg_integer(), + iteration: non_neg_integer(), + operation: atom(), + value: binary() | QuickBEAM.VM.Program.t(), + details: map() + } +end diff --git a/lib/quickbeam/vm/fuzz/summary.ex b/lib/quickbeam/vm/fuzz/summary.ex new file mode 100644 index 000000000..64b1ae490 --- /dev/null +++ b/lib/quickbeam/vm/fuzz/summary.ex @@ -0,0 +1,15 @@ +defmodule QuickBEAM.VM.Fuzz.Summary do + @moduledoc "Aggregated result of a bounded mutation-fuzzing run." + + @enforce_keys [:domain, :seed, :iterations, :counts, :operation_counts, :findings] + defstruct [:domain, :seed, :iterations, :counts, :operation_counts, :findings] + + @type t :: %__MODULE__{ + domain: :bytecode | :program, + seed: non_neg_integer(), + iterations: non_neg_integer(), + counts: %{optional(atom()) => non_neg_integer()}, + operation_counts: %{optional(atom()) => non_neg_integer()}, + findings: [QuickBEAM.VM.Fuzz.Finding.t()] + } +end diff --git a/lib/quickbeam/vm/measurement.ex b/lib/quickbeam/vm/measurement.ex new file mode 100644 index 000000000..cd2054d07 --- /dev/null +++ b/lib/quickbeam/vm/measurement.ex @@ -0,0 +1,29 @@ +defmodule QuickBEAM.VM.Measurement do + @moduledoc """ + Resource and timing observations for one isolated VM evaluation. + + `steps` and `logical_memory_bytes` come from deterministic VM accounting. + `process_memory_bytes` and `reductions` are endpoint observations from the + evaluation process, not sampled peaks. `wall_time_us` includes isolated + process startup, host waits, result conversion, and reply delivery. + """ + + @enforce_keys [:result, :wall_time_us] + defstruct [ + :result, + :wall_time_us, + :steps, + :logical_memory_bytes, + :process_memory_bytes, + :reductions + ] + + @type t :: %__MODULE__{ + result: {:ok, term()} | {:error, term()}, + wall_time_us: non_neg_integer(), + steps: non_neg_integer() | nil, + logical_memory_bytes: non_neg_integer() | nil, + process_memory_bytes: non_neg_integer() | nil, + reductions: non_neg_integer() | nil + } +end diff --git a/lib/quickbeam/vm/options.ex b/lib/quickbeam/vm/options.ex new file mode 100644 index 000000000..dd6c0c698 --- /dev/null +++ b/lib/quickbeam/vm/options.ex @@ -0,0 +1,26 @@ +defmodule QuickBEAM.VM.Options do + @moduledoc """ + Validates strict keyword options at VM API and engine boundaries. + + Unknown keys and non-keyword inputs fail explicitly so public and internal + callers share one deterministic option contract. + """ + + @doc "Validates that an input is a keyword list containing only allowed keys." + @spec validate(term(), [atom()]) :: + :ok | {:error, {:invalid_options, term()} | {:unknown_option, atom()}} + def validate(options, allowed) do + if Keyword.keyword?(options) do + validate_keys(Keyword.keys(options), allowed) + else + {:error, {:invalid_options, options}} + end + end + + defp validate_keys(keys, allowed) do + case keys -- allowed do + [] -> :ok + [unknown | _rest] -> {:error, {:unknown_option, unknown}} + end + end +end diff --git a/lib/quickbeam/vm/program.ex b/lib/quickbeam/vm/program.ex new file mode 100644 index 000000000..91885fca1 --- /dev/null +++ b/lib/quickbeam/vm/program.ex @@ -0,0 +1,37 @@ +defmodule QuickBEAM.VM.Program do + @moduledoc """ + Defines an immutable decoded JavaScript program. + + `bytecode_digest` identifies the serialized QuickJS input. `source_digest` is + also present when the public source compiler produced the program. The + binary `pin_key` includes version, digest, and filename identity so bounded + pinned storage never derives atoms from input. + + The fields are an advanced, version-locked decoded representation, not a + construction API. Obtain valid values through `QuickBEAM.VM.compile/2` or + `QuickBEAM.VM.decode/2`; changing fields invalidates verification guarantees. + """ + + @enforce_keys [:version, :fingerprint, :atoms, :root] + defstruct [ + :version, + :fingerprint, + :atoms, + :root, + :bytecode_digest, + :bytecode_size, + :source_digest, + :pin_key + ] + + @type t :: %__MODULE__{ + version: non_neg_integer(), + fingerprint: String.t(), + atoms: tuple(), + root: QuickBEAM.VM.Program.Function.t(), + bytecode_digest: binary() | nil, + bytecode_size: non_neg_integer() | nil, + source_digest: binary() | nil, + pin_key: binary() | nil + } +end diff --git a/lib/quickbeam/vm/program/function.ex b/lib/quickbeam/vm/program/function.ex new file mode 100644 index 000000000..239a480cb --- /dev/null +++ b/lib/quickbeam/vm/program/function.ex @@ -0,0 +1,67 @@ +defmodule QuickBEAM.VM.Program.Function do + @moduledoc "JavaScript function metadata and pre-resolved VM instructions used by the interpreter and BEAM compiler." + + defstruct [ + :id, + :name, + :filename, + line_num: 1, + col_num: 1, + pc2line: <<>>, + source: <<>>, + source_positions: nil, + arg_count: 0, + var_count: 0, + defined_arg_count: 0, + stack_size: 0, + var_ref_count: 0, + locals: [], + closure_vars: [], + constants: [], + atoms: nil, + instructions: nil, + has_prototype: false, + has_simple_parameter_list: false, + is_derived_class_constructor: false, + need_home_object: false, + func_kind: 0, + new_target_allowed: false, + super_call_allowed: false, + super_allowed: false, + arguments_allowed: false, + is_strict_mode: false, + has_debug_info: false + ] + + @type t :: %__MODULE__{ + id: non_neg_integer(), + name: String.t(), + filename: String.t() | nil, + line_num: pos_integer(), + col_num: pos_integer(), + pc2line: binary(), + source: binary(), + source_positions: tuple(), + arg_count: non_neg_integer(), + var_count: non_neg_integer(), + defined_arg_count: non_neg_integer(), + stack_size: non_neg_integer(), + var_ref_count: non_neg_integer(), + locals: [QuickBEAM.VM.Program.Variable.t()], + closure_vars: [QuickBEAM.VM.Program.Variable.Closure.t()], + constants: [term()], + atoms: tuple(), + instructions: tuple(), + has_prototype: boolean(), + has_simple_parameter_list: boolean(), + is_derived_class_constructor: boolean(), + need_home_object: boolean(), + func_kind: non_neg_integer(), + new_target_allowed: boolean(), + super_call_allowed: boolean(), + super_allowed: boolean(), + arguments_allowed: boolean(), + is_strict_mode: boolean(), + has_debug_info: boolean() + } +end diff --git a/lib/quickbeam/vm/program/identity.ex b/lib/quickbeam/vm/program/identity.ex new file mode 100644 index 000000000..ceeb099b0 --- /dev/null +++ b/lib/quickbeam/vm/program/identity.ex @@ -0,0 +1,22 @@ +defmodule QuickBEAM.VM.Program.Identity do + @moduledoc """ + Derives the stable binary identity used by bounded program pinning. + + Identities include the bytecode ABI, serialized and source digests, and the + recursively assigned filename. They never create atoms from program input. + """ + + alias QuickBEAM.VM.Program + + @doc "Adds the stable pin identity to a decoded program." + @spec put(Program.t()) :: Program.t() + def put(%Program{} = program) do + filename = if is_map(program.root), do: Map.get(program.root, :filename), else: nil + + identity = + {:quickbeam_vm_program_v1, program.version, program.fingerprint, program.bytecode_digest, + program.source_digest, filename} + + %{program | pin_key: :crypto.hash(:sha256, :erlang.term_to_binary(identity))} + end +end diff --git a/lib/quickbeam/vm/program/pinned.ex b/lib/quickbeam/vm/program/pinned.ex new file mode 100644 index 000000000..80d3fbae8 --- /dev/null +++ b/lib/quickbeam/vm/program/pinned.ex @@ -0,0 +1,16 @@ +defmodule QuickBEAM.VM.Program.Pinned do + @moduledoc """ + A lightweight handle to a verified immutable program pinned in bounded storage. + + Handles can be copied cheaply between request processes. The underlying + decoded program remains in a fixed `QuickBEAM.VM.Program.Store` slot until + explicitly removed with `QuickBEAM.VM.unpin/1`. A supervised store restart + restores valid slots. Once unpinning begins, new evaluations and replacement + pins are rejected while existing evaluation leases finish. + """ + + @enforce_keys [:key] + defstruct [:key] + + @type t :: %__MODULE__{key: binary()} +end diff --git a/lib/quickbeam/vm/program/source.ex b/lib/quickbeam/vm/program/source.ex new file mode 100644 index 000000000..c79caf145 --- /dev/null +++ b/lib/quickbeam/vm/program/source.ex @@ -0,0 +1,15 @@ +defmodule QuickBEAM.VM.Program.Source do + @moduledoc "Resolves VM instruction positions to source line and column metadata." + + @type position :: {pos_integer(), pos_integer()} + + @doc "Resolves a VM function instruction index to source line and column information." + @spec source_position(QuickBEAM.VM.Program.Function.t(), non_neg_integer()) :: position() + def source_position(%QuickBEAM.VM.Program.Function{source_positions: positions}, insn_index) + when is_tuple(positions) and is_integer(insn_index) and insn_index >= 0 and + insn_index < tuple_size(positions), + do: elem(positions, insn_index) + + def source_position(%QuickBEAM.VM.Program.Function{} = fun, _insn_index), + do: {fun.line_num, fun.col_num} +end diff --git a/lib/quickbeam/vm/program/store.ex b/lib/quickbeam/vm/program/store.ex new file mode 100644 index 000000000..9f7d43547 --- /dev/null +++ b/lib/quickbeam/vm/program/store.ex @@ -0,0 +1,474 @@ +defmodule QuickBEAM.VM.Program.Store do + @moduledoc """ + Keeps a bounded set of large immutable VM programs in fixed persistent slots. + + The store exists to avoid copying a decoded program into every isolated + evaluation process. Programs enter only through explicit `pin/1` + calls. The store never derives atoms from input, holds at most `capacity` + persistent terms, bounds both per-program and total external-term residency, + and does not evict programs implicitly. Programs can be + explicitly released; active leases defer erasure until their workers finish. + """ + + use GenServer + + alias QuickBEAM.VM.Bytecode.Verifier + alias QuickBEAM.VM.Program + alias QuickBEAM.VM.Program.Pinned + alias QuickBEAM.VM.Program.Store.Lease + + @default_capacity 8 + @maximum_capacity 32 + @maximum_pinned_bytecode_bytes 2 * 1024 * 1024 + @maximum_program_residency_bytes 32 * 1024 * 1024 + @maximum_total_residency_bytes 128 * 1024 * 1024 + @type checkout_result :: {:ok, Lease.t()} | :unavailable + + @doc "Starts the bounded pinned-program store." + @spec start_link(keyword()) :: GenServer.on_start() + def start_link(opts \\ []) do + {name, opts} = Keyword.pop(opts, :name, __MODULE__) + GenServer.start_link(__MODULE__, opts, name: name) + end + + @doc "Pins a verified program explicitly and returns its lightweight handle." + @spec pin(Program.t(), GenServer.server()) :: + {:ok, Pinned.t()} + | :unavailable + | :retiring + | {:error, :program_too_large | :residency_budget} + def pin(program, server \\ __MODULE__) + + def pin( + %Program{pin_key: key, bytecode_size: size} = program, + server + ) + when is_binary(key) and is_integer(size) and size <= @maximum_pinned_bytecode_bytes do + case program_residency(program) do + {:ok, residency_bytes} when residency_bytes <= @maximum_program_residency_bytes -> + case reserve_and_install(key, program, residency_bytes, server) do + {:ok, lease} -> + checkin(lease, server) + {:ok, %Pinned{key: key}} + + result -> + result + end + + _oversized_or_invalid -> + {:error, :program_too_large} + end + end + + def pin(%Program{bytecode_size: size}, _server) + when is_integer(size) and size > @maximum_pinned_bytecode_bytes, + do: {:error, :program_too_large} + + def pin(%Program{}, _server), do: :unavailable + + @doc "Checks out an explicitly pinned immutable program." + @spec checkout(Pinned.t(), GenServer.server()) :: checkout_result() + def checkout(%Pinned{key: key}, server \\ __MODULE__) do + if GenServer.whereis(server), + do: safe_store_call(server, {:checkout_existing, key}), + else: :unavailable + end + + @doc "Fetches the immutable program covered by an active lease." + @spec fetch(Lease.t()) :: {:ok, Program.t()} | {:error, :stale_lease} + def fetch(%Lease{key: key, slot: slot, token: token}) do + case :persistent_term.get(storage_key(slot), :missing) do + {^key, ^token, %Program{} = program} -> {:ok, program} + _other -> {:error, :stale_lease} + end + end + + @doc "Returns a lease after its isolated evaluation has terminated." + @spec checkin(Lease.t(), GenServer.server()) :: :ok + def checkin(lease, server \\ __MODULE__) do + if GenServer.whereis(server), do: GenServer.cast(server, {:checkin, lease}) + :ok + end + + @doc "Unpins a program slot, deferring erasure while leases remain active." + @spec unpin(Program.t() | Pinned.t(), GenServer.server()) :: :ok | :not_pinned + def unpin(program, server \\ __MODULE__) + + def unpin(%Pinned{key: key}, server), do: unpin_key(key, server) + + def unpin(%Program{pin_key: key}, server) when is_binary(key), + do: unpin_key(key, server) + + def unpin(%Program{}, _server), do: :not_pinned + + defp unpin_key(key, server) do + case GenServer.whereis(server) && safe_store_call(server, {:unpin, key}) do + :ok -> :ok + _unavailable -> :not_pinned + end + end + + @impl true + def init(opts) do + capacity = Keyword.get(opts, :capacity, @default_capacity) + + if not is_integer(capacity) or capacity <= 0 or capacity > @maximum_capacity do + {:stop, {:invalid_capacity, capacity}} + else + {entries, slots, residency_bytes} = restore_slots(capacity) + + {:ok, + %{ + capacity: capacity, + entries: entries, + slots: slots, + pending: %{}, + residency_bytes: residency_bytes + }} + end + end + + @impl true + def handle_call({:checkout_existing, key}, from, state) do + case state.entries do + %{^key => %{unpin?: true}} -> + {:reply, :unavailable, state} + + %{^key => entry} -> + {lease, entry} = grant_lease(key, entry, from) + {:reply, {:ok, lease}, put_in(state.entries[key], entry)} + + _entries -> + {:reply, :unavailable, state} + end + end + + def handle_call({:reserve, key, residency_bytes}, from, state) do + case state.entries do + %{^key => %{unpin?: true}} -> + {:reply, :retiring, state} + + %{^key => entry} -> + {lease, entry} = grant_lease(key, entry, from) + {:reply, {:ok, lease}, put_in(state.entries[key], entry)} + + _entries -> + reserve_missing(key, residency_bytes, from, state) + end + end + + def handle_call({:commit, key, token}, {owner, _tag}, state) do + case state.pending do + %{^key => %{owner: ^owner, token: ^token} = pending} -> + if persisted?(pending.slot, key, token), + do: complete_install(key, token, owner, pending, state), + else: cancel_install(key, pending, state) + + _pending -> + {:reply, :unavailable, state} + end + end + + def handle_call({:cancel, key, token}, {owner, _tag}, state) do + case state.pending do + %{^key => %{owner: ^owner, token: ^token} = pending} -> + cancel_install(key, pending, state) + + _pending -> + {:reply, :unavailable, state} + end + end + + def handle_call({:unpin, key}, _from, state) do + case state.entries do + %{^key => %{leases: leases} = entry} when map_size(leases) == 0 -> + :persistent_term.erase(storage_key(entry.slot)) + {:reply, :ok, remove_entry(state, key, entry.slot)} + + %{^key => entry} -> + {:reply, :ok, put_in(state.entries[key], %{entry | unpin?: true})} + + _entries -> + {:reply, :not_pinned, state} + end + end + + @impl true + def handle_cast( + {:checkin, %Lease{id: id, key: key, slot: slot, token: token}}, + state + ) do + case state.entries do + %{^key => %{slot: ^slot, token: ^token, leases: %{^id => monitor}} = entry} -> + Process.demonitor(monitor, [:flush]) + entry = %{entry | leases: Map.delete(entry.leases, id)} + {:noreply, maybe_release_entry(state, key, entry)} + + _entries -> + {:noreply, state} + end + end + + @impl true + def handle_info({:DOWN, monitor, :process, owner, _reason}, state) do + case Enum.find(state.pending, fn {_key, pending} -> + pending.monitor == monitor and pending.owner == owner + end) do + {key, pending} -> + :persistent_term.erase(storage_key(pending.slot)) + Enum.each(pending.waiters, &GenServer.reply(&1, :unavailable)) + {:noreply, %{state | pending: Map.delete(state.pending, key)}} + + nil -> + {:noreply, drop_owner_lease(state, monitor)} + end + end + + defp reserve_and_install(key, program, residency_bytes, server) do + if GenServer.whereis(server) do + case safe_store_call(server, {:reserve, key, residency_bytes}) do + {:install, token, slot} -> install_reserved(server, key, token, slot, program) + result -> result + end + else + :unavailable + end + end + + defp reserve_missing(key, residency_bytes, from, state) do + case state.pending do + %{^key => pending} -> + pending = %{pending | waiters: [from | pending.waiters]} + {:noreply, put_in(state.pending[key], pending)} + + _pending -> + case {free_slot(state), residency_available?(state, residency_bytes)} do + {nil, _available?} -> + {:reply, :unavailable, state} + + {_slot, false} -> + {:reply, {:error, :residency_budget}, state} + + {slot, true} -> + owner = elem(from, 0) + token = make_ref() + + pending = %{ + slot: slot, + token: token, + owner: owner, + monitor: Process.monitor(owner), + waiters: [], + residency_bytes: residency_bytes + } + + {:reply, {:install, token, slot}, put_in(state.pending[key], pending)} + end + end + end + + defp free_slot(state) do + pending_slots = MapSet.new(state.pending, fn {_key, pending} -> pending.slot end) + + Enum.find(0..(state.capacity - 1), fn slot -> + not Map.has_key?(state.slots, slot) and not MapSet.member?(pending_slots, slot) + end) + end + + defp program_residency(program) do + {:ok, :erlang.external_size(program)} + rescue + _exception -> :error + end + + defp residency_available?(state, residency_bytes) do + pending_bytes = + Enum.reduce(state.pending, 0, fn {_key, pending}, total -> + total + pending.residency_bytes + end) + + state.residency_bytes + pending_bytes + residency_bytes <= + @maximum_total_residency_bytes + end + + defp restore_slots(capacity) do + Enum.reduce(0..(capacity - 1), {%{}, %{}, 0}, fn slot, {entries, slots, residency_bytes} -> + case :persistent_term.get(storage_key(slot), :missing) do + {key, token, %Program{} = program} when is_binary(key) and is_reference(token) -> + restore_program_slot( + program, + key, + token, + slot, + entries, + slots, + residency_bytes + ) + + :missing -> + {entries, slots, residency_bytes} + + _other -> + :persistent_term.erase(storage_key(slot)) + {entries, slots, residency_bytes} + end + end) + end + + defp restore_program_slot(program, key, token, slot, entries, slots, total_bytes) do + with :ok <- Verifier.verify_identity(program), + {:ok, program_bytes} <- program_residency(program), + true <- program_bytes <= @maximum_program_residency_bytes, + true <- total_bytes + program_bytes <= @maximum_total_residency_bytes do + entry = %{ + key: key, + slot: slot, + token: token, + leases: %{}, + unpin?: false, + residency_bytes: program_bytes + } + + {Map.put(entries, key, entry), Map.put(slots, slot, key), total_bytes + program_bytes} + else + _invalid_or_oversized -> + :persistent_term.erase(storage_key(slot)) + {entries, slots, total_bytes} + end + end + + defp complete_install(key, token, owner, pending, state) do + Process.demonitor(pending.monitor, [:flush]) + + entry = %{ + key: key, + slot: pending.slot, + token: token, + leases: %{}, + unpin?: false, + residency_bytes: pending.residency_bytes + } + + {lease, entry} = grant_lease(key, entry, {owner, nil}) + + entry = + Enum.reduce(pending.waiters, entry, fn waiter, entry -> + {waiter_lease, entry} = grant_lease(key, entry, waiter) + GenServer.reply(waiter, {:ok, waiter_lease}) + entry + end) + + state = %{ + state + | entries: Map.put(state.entries, key, entry), + slots: Map.put(state.slots, pending.slot, key), + pending: Map.delete(state.pending, key), + residency_bytes: state.residency_bytes + pending.residency_bytes + } + + {:reply, {:ok, lease}, state} + end + + defp cancel_install(key, pending, state) do + Process.demonitor(pending.monitor, [:flush]) + :persistent_term.erase(storage_key(pending.slot)) + Enum.each(pending.waiters, &GenServer.reply(&1, :unavailable)) + {:reply, :unavailable, %{state | pending: Map.delete(state.pending, key)}} + end + + defp install_reserved(server, key, token, slot, program) do + result = + case persist_program(slot, key, token, program) do + :ok -> safe_store_call(server, {:commit, key, token}) + :error -> safe_store_call(server, {:cancel, key, token}) + end + + case result do + :unavailable -> recover_or_erase_install(server, slot, key, token) + installed -> installed + end + end + + defp recover_or_erase_install(server, slot, key, token) do + case safe_store_call(server, {:checkout_existing, key}) do + {:ok, lease} -> + {:ok, lease} + + :unavailable -> + erase_if_owned(slot, key, token) + :unavailable + end + end + + defp safe_store_call(server, message) do + GenServer.call(server, message, :infinity) + catch + :exit, _reason -> :unavailable + end + + defp erase_if_owned(slot, key, token) do + if persisted?(slot, key, token), do: :persistent_term.erase(storage_key(slot)) + end + + defp grant_lease(key, entry, {owner, _tag}) do + id = make_ref() + monitor = Process.monitor(owner) + lease = %Lease{id: id, key: key, slot: entry.slot, token: entry.token} + {lease, %{entry | leases: Map.put(entry.leases, id, monitor)}} + end + + defp drop_owner_lease(state, monitor) do + case owner_lease(state.entries, monitor) do + {key, id, entry} -> + entry = %{entry | leases: Map.delete(entry.leases, id)} + maybe_release_entry(state, key, entry) + + nil -> + state + end + end + + defp owner_lease(entries, monitor) do + entries + |> Enum.flat_map(fn {key, entry} -> + Enum.map(entry.leases, fn {id, lease_monitor} -> {key, id, entry, lease_monitor} end) + end) + |> Enum.find_value(fn + {key, id, entry, ^monitor} -> {key, id, entry} + _lease -> nil + end) + end + + defp maybe_release_entry(state, key, entry) do + if map_size(entry.leases) == 0 and entry.unpin? do + :persistent_term.erase(storage_key(entry.slot)) + remove_entry(state, key, entry.slot) + else + put_in(state.entries[key], entry) + end + end + + defp remove_entry(state, key, slot) do + entry = Map.fetch!(state.entries, key) + + %{ + state + | entries: Map.delete(state.entries, key), + slots: Map.delete(state.slots, slot), + residency_bytes: state.residency_bytes - entry.residency_bytes + } + end + + defp persisted?(slot, key, token) do + match?({^key, ^token, %Program{}}, :persistent_term.get(storage_key(slot), :missing)) + end + + defp persist_program(slot, key, token, program) do + :persistent_term.put(storage_key(slot), {key, token, program}) + :ok + rescue + _exception -> :error + end + + defp storage_key(slot), do: {__MODULE__, slot} +end diff --git a/lib/quickbeam/vm/program/store/lease.ex b/lib/quickbeam/vm/program/store/lease.ex new file mode 100644 index 000000000..0dde6d0d9 --- /dev/null +++ b/lib/quickbeam/vm/program/store/lease.ex @@ -0,0 +1,19 @@ +defmodule QuickBEAM.VM.Program.Store.Lease do + @moduledoc """ + Identifies one bounded lease on an immutable pinned VM program. + + Leases contain only fixed-slot metadata. Program terms remain in + `:persistent_term`; evaluation callers fetch a shared literal reference before + spawning their workers. + """ + + @enforce_keys [:id, :key, :slot, :token] + defstruct [:id, :key, :slot, :token] + + @type t :: %__MODULE__{ + id: reference(), + key: binary(), + slot: non_neg_integer(), + token: reference() + } +end diff --git a/lib/quickbeam/vm/program/variable.ex b/lib/quickbeam/vm/program/variable.ex new file mode 100644 index 000000000..ad59a56d5 --- /dev/null +++ b/lib/quickbeam/vm/program/variable.ex @@ -0,0 +1,25 @@ +defmodule QuickBEAM.VM.Program.Variable do + @moduledoc "JavaScript local variable definition metadata." + + defstruct [ + :name, + :scope_level, + :scope_next, + :var_kind, + :is_const, + :is_lexical, + :is_captured, + :var_ref_idx + ] + + @type t :: %__MODULE__{ + name: String.t(), + scope_level: non_neg_integer(), + scope_next: integer(), + var_kind: non_neg_integer(), + is_const: boolean(), + is_lexical: boolean(), + is_captured: boolean(), + var_ref_idx: non_neg_integer() | nil + } +end diff --git a/lib/quickbeam/vm/program/variable/closure.ex b/lib/quickbeam/vm/program/variable/closure.ex new file mode 100644 index 000000000..6d9052891 --- /dev/null +++ b/lib/quickbeam/vm/program/variable/closure.ex @@ -0,0 +1,14 @@ +defmodule QuickBEAM.VM.Program.Variable.Closure do + @moduledoc "JavaScript closure capture metadata." + + defstruct [:name, :var_idx, :closure_type, :is_const, :is_lexical, :var_kind] + + @type t :: %__MODULE__{ + name: String.t(), + var_idx: non_neg_integer(), + closure_type: non_neg_integer(), + is_const: boolean(), + is_lexical: boolean(), + var_kind: non_neg_integer() + } +end diff --git a/lib/quickbeam/vm/runtime.ex b/lib/quickbeam/vm/runtime.ex new file mode 100644 index 000000000..849320cea --- /dev/null +++ b/lib/quickbeam/vm/runtime.ex @@ -0,0 +1,302 @@ +defmodule QuickBEAM.VM.Runtime do + @moduledoc """ + Drives an interpreter evaluation and its owner-local event loop. + + The evaluator drains microtasks, correlates asynchronous host replies, resumes + coroutines, and waits for the final Promise without polling. + """ + + alias QuickBEAM.VM.Program + alias QuickBEAM.VM.Runtime.Async + alias QuickBEAM.VM.Runtime.Continuation + alias QuickBEAM.VM.Runtime.Coroutine + alias QuickBEAM.VM.Runtime.Exception + alias QuickBEAM.VM.Runtime.Interpreter + alias QuickBEAM.VM.Runtime.Optimization + alias QuickBEAM.VM.Runtime.Promise + alias QuickBEAM.VM.Runtime.Promise.Reaction + alias QuickBEAM.VM.Runtime.Promise.Reference, as: PromiseReference + alias QuickBEAM.VM.Runtime.State + + @doc "Evaluates a verified program and drains its owner-local event loop." + @spec eval(Program.t(), keyword()) :: Interpreter.result() + def eval(%Program{} = program, opts \\ []) do + program + |> Interpreter.start(opts) + |> drive() + end + + @doc "Calls a named global after initialization and drains the owner-local event loop." + @spec call(Program.t(), String.t(), [term()], keyword()) :: Interpreter.result() + def call(%Program{} = program, name, arguments \\ [], opts \\ []) do + finish_initialization = fn + {:ok, _value, execution} -> + program + |> Interpreter.invoke_global(name, arguments, execution) + |> drive() + + result -> + finish_final(result) + end + + program + |> Interpreter.start(opts) + |> drive_with(finish_initialization) + end + + @doc "Evaluates a program and returns deterministic counters plus endpoint process observations." + @spec eval_with_metrics(Program.t(), keyword()) :: {Interpreter.result(), map() | nil} + def eval_with_metrics(%Program{} = program, opts \\ []) do + measured(fn measured_opts -> eval(program, measured_opts) end, opts) + end + + @doc "Calls a global and returns deterministic counters plus endpoint process observations." + @spec call_with_metrics(Program.t(), String.t(), [term()], keyword()) :: + {Interpreter.result(), map() | nil} + def call_with_metrics(%Program{} = program, name, arguments \\ [], opts \\ []) do + measured(fn measured_opts -> call(program, name, arguments, measured_opts) end, opts) + end + + @doc "Drives a raw interpreter or compiler machine result through the owner-local event loop." + @spec drive(term()) :: Interpreter.result() + def drive(result), do: drive_with(result, &finish_final/1) + + defp drive_with({:ok, %PromiseReference{} = promise, execution}, finish), + do: await_final_promise(promise, execution, finish) + + defp drive_with({:suspended, %Continuation{awaiting: :microtask} = continuation}, finish) do + case :queue.out(continuation.execution.jobs) do + {{:value, result}, jobs} when elem(result, 0) in [:ok, :error] -> + continuation = %{continuation | execution: %{continuation.execution | jobs: jobs}} + then_resume(result, continuation, finish) + + {:empty, _jobs} -> + finish.({:error, :missing_microtask, continuation.execution}) + end + end + + defp drive_with( + {:suspended, %Continuation{awaiting: %PromiseReference{}} = continuation}, + finish + ) do + await_legacy_promise(continuation, finish) + end + + defp drive_with({:suspended, %Continuation{} = continuation} = suspended, _finish), + do: finish_suspended(suspended, continuation.execution) + + defp drive_with({status, _value, _execution} = result, finish) when status in [:ok, :error], + do: finish.(result) + + defp drive_with({:idle, execution}, finish), + do: finish.({:error, :idle_evaluation, execution}) + + defp await_final_promise(%PromiseReference{} = promise, execution, finish) do + if :queue.is_empty(execution.sync_jobs) do + await_settled_promise(promise, execution, finish) + else + execution + |> Interpreter.run_synchronous_job() + |> continue_final(promise, finish) + end + end + + defp await_settled_promise(promise, execution, finish) do + case Promise.state(execution, promise) do + {:fulfilled, value} -> + finish.({:ok, value, execution}) + + {:rejected, %QuickBEAM.JSError{} = error} -> + finish.({:error, error, execution}) + + {:rejected, reason} -> + error = Exception.to_js_error(reason, execution, []) + finish.({:error, error, execution}) + + :pending -> + drive_event_loop(promise, execution, finish) + end + end + + defp drive_event_loop(final_promise, execution, finish) do + case :queue.out(execution.jobs) do + {{:value, job}, jobs} -> + execution = %{execution | jobs: jobs} + run_job(job, final_promise, execution, finish) + + {:empty, _jobs} when map_size(execution.operations) > 0 -> + receive_host_reply(final_promise, execution, finish) + + {:empty, _jobs} -> + finish.({:error, {:promise_deadlock, final_promise.id}, execution}) + end + end + + defp run_job( + {:resume_coroutine, %Coroutine{} = coroutine, result}, + final_promise, + execution, + finish + ) do + coroutine + |> Interpreter.resume_coroutine(result, execution) + |> continue_final(final_promise, finish) + end + + defp run_job({:read_thenable, promise, thenable, getter}, final_promise, execution, finish) do + promise + |> Interpreter.read_thenable(thenable, getter, execution) + |> continue_final(final_promise, finish) + end + + defp run_job( + {:assimilate_thenable, promise, thenable, callable}, + final_promise, + execution, + finish + ) do + promise + |> Interpreter.assimilate_thenable(thenable, callable, execution) + |> continue_final(final_promise, finish) + end + + defp run_job( + {:run_reaction, %Reaction{} = reaction, result}, + final_promise, + execution, + finish + ) do + reaction + |> Interpreter.run_reaction(result, execution) + |> continue_final(final_promise, finish) + end + + defp run_job({:aggregate_settle, id, index, result}, final_promise, execution, finish) do + execution = Promise.settle_aggregate(execution, id, index, result) + await_final_promise(final_promise, execution, finish) + end + + defp run_job({:settle_assimilated, promise, result}, final_promise, execution, finish) do + execution = Promise.settle_assimilated(execution, promise, result) + await_final_promise(final_promise, execution, finish) + end + + defp run_job({:settle_promise, promise, result}, final_promise, execution, finish) do + execution = Promise.settle(execution, promise, result) + await_final_promise(final_promise, execution, finish) + end + + defp continue_final({:idle, execution}, final_promise, finish), + do: await_final_promise(final_promise, execution, finish) + + defp continue_final({:ok, _value, execution}, final_promise, finish), + do: await_final_promise(final_promise, execution, finish) + + defp continue_final({:error, _reason, _execution} = error, _final_promise, finish), + do: finish.(error) + + defp continue_final({:suspended, continuation}, _final_promise, finish), + do: drive_with({:suspended, continuation}, finish) + + defp receive_host_reply(final_promise, execution, finish) do + receive do + {:quickbeam_vm_host_reply, operation, result} -> + case Async.settle_host_reply(execution, operation, result) do + {:ok, execution} -> await_final_promise(final_promise, execution, finish) + :stale -> receive_host_reply(final_promise, execution, finish) + end + end + end + + defp await_legacy_promise(%Continuation{} = continuation, finish) do + receive do + {:quickbeam_vm_host_reply, operation, result} -> + case Async.settle_host_reply(continuation.execution, operation, result) do + {:ok, execution} -> + continuation = %{continuation | execution: execution} + + if Promise.state(execution, continuation.awaiting) == :pending do + await_legacy_promise(continuation, finish) + else + result = settled_result(continuation.awaiting, execution) + execution = %{execution | jobs: :queue.in(result, execution.jobs)} + + drive_with( + {:suspended, %{continuation | execution: execution, awaiting: :microtask}}, + finish + ) + end + + :stale -> + await_legacy_promise(continuation, finish) + end + end + end + + defp settled_result(promise, execution) do + case Promise.state(execution, promise) do + {:fulfilled, value} -> {:ok, value} + {:rejected, reason} -> {:error, reason} + end + end + + defp then_resume(result, continuation, finish) do + continuation + |> Interpreter.resume_raw(result) + |> drive_with(finish) + end + + defp measured(fun, opts) do + ref = make_ref() + result = fun.(Keyword.put(opts, :measurement_target, {self(), ref})) + + receive do + {:quickbeam_vm_measurement, ^ref, metrics} -> {result, metrics} + after + 0 -> {result, nil} + end + end + + defp finish_final({status, _value, %State{} = execution} = result) + when status in [:ok, :error] do + Async.cancel_operations(execution) + finished = Interpreter.finish(result) + report_measurement(execution) + finished + end + + defp finish_suspended(result, execution) do + finished = Interpreter.finish(result) + report_measurement(execution) + finished + end + + defp report_measurement(%State{measurement_target: nil}), do: :ok + + defp report_measurement(%State{measurement_target: {pid, ref}} = execution) do + process_memory = process_stat(:memory) + reductions = process_stat(:reductions) + + metrics = + Map.merge( + %{ + steps: execution.step_limit - execution.remaining_steps, + logical_memory_bytes: execution.memory_used, + process_memory_bytes: process_memory, + reductions: reductions + }, + Optimization.snapshot(execution) + ) + + send(pid, {:quickbeam_vm_measurement, ref, metrics}) + + :ok + end + + defp process_stat(key) do + case Process.info(self(), key) do + {^key, value} -> value + nil -> nil + end + end +end diff --git a/lib/quickbeam/vm/runtime/async.ex b/lib/quickbeam/vm/runtime/async.ex new file mode 100644 index 000000000..e8d1a4800 --- /dev/null +++ b/lib/quickbeam/vm/runtime/async.ex @@ -0,0 +1,366 @@ +defmodule QuickBEAM.VM.Runtime.Async do + @moduledoc """ + Defines canonical state transitions for async functions, Promises, and host tasks. + + The module owns coroutine detachment, Promise reaction planning, async frame + boundaries, thenable boundaries, microtask suspension, and asynchronous BEAM + handler startup. It returns explicit actions for the interpreter to execute; + it never recursively runs interpreter frames. + """ + + alias QuickBEAM.VM.Runtime.Boundary + alias QuickBEAM.VM.Runtime.Continuation + alias QuickBEAM.VM.Runtime.Coroutine + alias QuickBEAM.VM.Runtime.Frame + alias QuickBEAM.VM.Runtime.Invocation + alias QuickBEAM.VM.Runtime.Memory + alias QuickBEAM.VM.Runtime.Promise + alias QuickBEAM.VM.Runtime.Promise.Reaction + alias QuickBEAM.VM.Runtime.Promise.Reference, as: PromiseReference + alias QuickBEAM.VM.Runtime.State + alias QuickBEAM.VM.Runtime.Thrown + + @max_host_operations 64 + + @type result :: + {:run, Frame.t(), State.t()} + | {:raise, term(), Frame.t(), State.t()} + | {:invoke, term(), [term()], term(), term(), State.t(), boolean()} + | {:complete, term(), term(), State.t(), boolean()} + | {:return, term(), State.t()} + | {:continue_iterator, Boundary.Iterator.t(), State.t()} + | {:idle, State.t()} + | {:suspended, Continuation.t()} + | {:error, term(), State.t()} + + @doc "Enters an async bytecode function behind an explicit Promise boundary." + @spec enter( + QuickBEAM.VM.Program.Function.t(), + term(), + tuple(), + [term()], + term(), + term(), + State.t(), + boolean() + ) :: result() + def enter(function, callable, closure_refs, arguments, this, caller, execution, tail?) do + depth = if tail?, do: execution.depth, else: execution.depth + 1 + + if depth > execution.max_stack_depth do + {:error, {:limit_exceeded, :stack_depth, depth}, execution} + else + {promise, execution} = Promise.new(execution) + + mode = + cond do + match?(%Boundary.Reaction{}, caller) -> :reaction + match?(%Boundary.PromiseExecutor{}, caller) -> :executor + match?(%Boundary.Thenable{}, caller) -> :thenable + tail? -> :return + true -> :push + end + + boundary = %Boundary.Async{ + promise: promise, + caller: if(tail?, do: nil, else: caller), + depth: execution.depth, + mode: mode + } + + execution = %{execution | callers: [boundary | execution.callers], depth: depth} + frame = Invocation.new_frame(function, callable, arguments, this, closure_refs) + {:run, frame, execution} + end + end + + @doc "Restores a detached coroutine and classifies its settlement continuation." + @spec resume_coroutine(Coroutine.t(), {:ok, term()} | {:error, term()}, State.t()) :: + result() + def resume_coroutine(%Coroutine{} = coroutine, result, %State{} = execution) do + callers = coroutine.callers ++ [coroutine.boundary] + frame_depth = Enum.count(coroutine.callers, &match?(%Frame{}, &1)) + execution = %{execution | callers: callers, depth: coroutine.boundary.depth + frame_depth + 1} + + case result do + {:ok, value} -> + {:run, %{coroutine.frame | stack: [value | coroutine.frame.stack]}, execution} + + {:error, reason} -> + {:raise, reason, coroutine.frame, execution} + end + end + + @doc "Detaches the nearest async boundary while awaiting a Promise." + @spec detach_await(Frame.t(), State.t(), PromiseReference.t()) :: + {:ok, result()} | :no_async_boundary + def detach_await(resume_frame, execution, awaited_promise) do + detach(resume_frame, execution, fn execution, coroutine -> + Promise.await(execution, awaited_promise, coroutine) + end) + end + + @doc "Detaches the nearest async boundary and queues an immediate await result." + @spec detach_immediate(Frame.t(), State.t(), {:ok, term()} | {:error, term()}) :: + {:ok, result()} | :no_async_boundary + def detach_immediate(resume_frame, execution, result) do + detach(resume_frame, execution, fn execution, coroutine -> + Promise.enqueue_coroutine(execution, coroutine, result) + end) + end + + @doc "Creates a legacy Promise continuation when no async boundary exists." + @spec suspend_promise(Frame.t(), State.t(), PromiseReference.t()) :: result() + def suspend_promise(resume_frame, execution, promise) do + case Promise.state(execution, promise) do + :pending -> + {:suspended, %Continuation{frame: resume_frame, execution: execution, awaiting: promise}} + + {:fulfilled, value} -> + suspend_microtask(resume_frame, execution, {:ok, value}) + + {:rejected, reason} -> + suspend_microtask(resume_frame, execution, {:error, reason}) + end + end + + @doc "Queues an immediate await result and suspends until its microtask turn." + @spec suspend_microtask(Frame.t(), State.t(), {:ok, term()} | {:error, term()}) :: result() + def suspend_microtask(resume_frame, execution, result) do + execution = %{execution | jobs: :queue.in(result, execution.jobs)} + + {:suspended, %Continuation{frame: resume_frame, execution: execution, awaiting: :microtask}} + end + + @doc "Plans invocation of an accessor-backed thenable property." + @spec read_thenable( + PromiseReference.t(), + term(), + term(), + Frame.t() | Boundary.Iterator.t() | nil, + State.t() + ) :: result() + def read_thenable(promise, thenable, getter, continuation, execution) do + boundary = %Boundary.ThenGetter{ + promise: promise, + thenable: thenable, + depth: execution.depth, + continuation: continuation + } + + {:invoke, getter, [], thenable, boundary, execution, false} + end + + @doc "Plans invocation of a callable thenable with idempotent resolver functions." + @spec assimilate_thenable(PromiseReference.t(), term(), term(), State.t()) :: result() + def assimilate_thenable(promise, thenable, callable, execution) do + boundary = %Boundary.Thenable{promise: promise, depth: execution.depth} + resolve = {:promise_resolver, promise, :resolve_assimilated} + reject = {:promise_resolver, promise, :reject_assimilated} + {:invoke, callable, [resolve, reject], thenable, boundary, execution, false} + end + + @doc "Plans one FIFO Promise reaction or propagates a missing callback." + @spec run_reaction(Reaction.t(), {:ok, term()} | {:error, term()}, State.t()) :: result() + def run_reaction(%Reaction{} = reaction, result, %State{} = execution) do + callback = + case result do + {:ok, _value} -> reaction.on_fulfilled + {:error, _reason} -> reaction.on_rejected + end + + if Invocation.callable?(callback, execution) do + boundary = %Boundary.Reaction{ + promise: reaction.result_promise, + depth: execution.depth, + mode: reaction.kind, + original_result: result + } + + arguments = if reaction.kind == :finally, do: [], else: [reaction_argument(result)] + {:invoke, callback, arguments, :undefined, boundary, execution, false} + else + {:idle, Promise.settle(execution, reaction.result_promise, result)} + end + end + + @doc "Completes an accessor-backed thenable read and resumes its continuation." + @spec complete_then_getter(term(), Boundary.ThenGetter.t(), State.t()) :: result() + def complete_then_getter(value, boundary, execution) do + execution = + if Invocation.callable?(value, execution) do + Promise.enqueue_assimilation(execution, boundary.promise, boundary.thenable, value) + else + Promise.fulfill_assimilated(execution, boundary.promise, boundary.thenable) + end + + case boundary.continuation do + %Frame{} = frame -> {:run, frame, execution} + %Boundary.Iterator{} = iterator -> {:continue_iterator, iterator, execution} + nil -> {:idle, execution} + end + end + + @doc "Completes a Promise reaction boundary." + @spec complete_reaction(Boundary.Reaction.t(), term(), State.t()) :: result() + def complete_reaction(%Boundary.Reaction{mode: :then} = boundary, value, execution), + do: {:idle, Promise.settle(execution, boundary.promise, {:ok, value})} + + def complete_reaction(%Boundary.Reaction{mode: :finally} = boundary, value, execution) do + {completion, execution} = + case value do + %PromiseReference{} = promise -> + {promise, execution} + + value -> + {promise, execution} = Promise.new(execution) + {promise, Promise.settle(execution, promise, {:ok, value})} + end + + execution = + Promise.settle_after_finally( + execution, + completion, + boundary.promise, + boundary.original_result + ) + + {:idle, execution} + end + + @doc "Settles an async function Promise and returns its boundary-delivery action." + @spec complete(Boundary.Async.t(), {:ok, term()} | {:error, term()}, State.t()) :: result() + def complete(%Boundary.Async{} = boundary, result, execution) do + execution = Promise.settle(execution, boundary.promise, result) + deliver(boundary, execution) + end + + @doc "Settles a correlated asynchronous host reply, ignoring stale operations." + @spec settle_host_reply(State.t(), reference(), {:ok, term()} | {:error, term()}) :: + {:ok, State.t()} | :stale + def settle_host_reply(execution, operation, result) do + case Map.pop(execution.operations, operation) do + {nil, _operations} -> + :stale + + {{promise, _pid}, operations} -> + execution = %{execution | operations: operations} + execution = Memory.charge(execution, Memory.estimate(elem(result, 1))) + {:ok, Promise.settle(execution, promise, result)} + end + end + + @doc "Cancels every outstanding handler task owned by an evaluation." + @spec cancel_operations(State.t() | map()) :: :ok + def cancel_operations(%State{operations: operations}), do: cancel_operations(operations) + + def cancel_operations(operations) when is_map(operations) do + Enum.each(operations, fn {_operation, {_promise, pid}} -> + if Process.alive?(pid), do: Process.exit(pid, :kill) + end) + end + + @doc "Starts an asynchronous BEAM handler and returns its owner-local Promise." + @spec start_host_call([term()], State.t()) :: + {:ok, PromiseReference.t(), State.t()} | {:error, term(), State.t()} + def start_host_call([name | arguments], execution) when is_binary(name) do + if map_size(execution.operations) < @max_host_operations do + {promise, execution} = Promise.new(execution) + + execution = + case Map.fetch(execution.handlers, name) do + {:ok, handler} -> start_handler_task(handler, arguments, promise, execution) + :error -> Promise.settle(execution, promise, {:error, {:unknown_handler, name}}) + end + + {:ok, promise, execution} + else + {:error, {:limit_exceeded, :host_operations, @max_host_operations}, execution} + end + end + + def start_host_call(_arguments, execution), + do: {:error, {:type_error, :invalid_beam_call}, execution} + + defp detach(resume_frame, execution, enqueue_resume) do + case Enum.split_while(execution.callers, &(!match?(%Boundary.Async{}, &1))) do + {inner_callers, [%Boundary.Async{} = boundary | outer_callers]} -> + coroutine = %Coroutine{ + frame: resume_frame, + callers: inner_callers, + boundary: %{boundary | caller: nil, depth: 0, mode: :detached} + } + + execution = %{execution | callers: outer_callers, depth: boundary.depth} + execution = enqueue_resume.(execution, coroutine) + {:ok, deliver(boundary, execution)} + + {_callers, []} -> + :no_async_boundary + end + end + + defp deliver(%Boundary.Async{mode: :push, caller: caller, promise: promise}, execution), + do: {:complete, promise, caller, execution, false} + + defp deliver(%Boundary.Async{mode: :return, promise: promise}, execution), + do: {:return, promise, execution} + + defp deliver( + %Boundary.Async{mode: :reaction, caller: boundary, promise: promise}, + execution + ), + do: complete_reaction(boundary, promise, execution) + + defp deliver(%Boundary.Async{mode: :executor, caller: boundary}, execution), + do: {:complete, boundary.promise, boundary.caller, execution, boundary.tail?} + + defp deliver(%Boundary.Async{mode: mode}, execution) when mode in [:thenable, :detached], + do: {:idle, execution} + + defp reaction_argument({:ok, value}), do: value + defp reaction_argument({:error, %Thrown{value: value}}), do: value + defp reaction_argument({:error, reason}), do: reason + + defp start_handler_task(handler, arguments, promise, execution) do + operation = make_ref() + owner = self() + + case Task.Supervisor.start_child(QuickBEAM.VM.TaskSupervisor, fn -> + coordinate_handler(owner, operation, handler, arguments) + end) do + {:ok, pid} -> + %{execution | operations: Map.put(execution.operations, operation, {promise, pid})} + + {:error, reason} -> + Promise.settle(execution, promise, {:error, {:handler_start_failed, reason}}) + end + end + + defp coordinate_handler(owner, operation, handler, arguments) do + owner_monitor = Process.monitor(owner) + coordinator = self() + + handler_pid = + spawn_link(fn -> + send(coordinator, {operation, invoke_handler(handler, arguments)}) + end) + + receive do + {^operation, result} -> + Process.demonitor(owner_monitor, [:flush]) + send(owner, {:quickbeam_vm_host_reply, operation, result}) + + {:DOWN, ^owner_monitor, :process, ^owner, _reason} -> + Process.exit(handler_pid, :kill) + end + end + + defp invoke_handler(handler, arguments) do + {:ok, handler.(arguments)} + rescue + exception -> {:error, {:handler_exception, exception, __STACKTRACE__}} + catch + kind, reason -> {:error, {:handler_exception, {kind, reason}, __STACKTRACE__}} + end +end diff --git a/lib/quickbeam/vm/runtime/boundary/accessor.ex b/lib/quickbeam/vm/runtime/boundary/accessor.ex new file mode 100644 index 000000000..cb357beb4 --- /dev/null +++ b/lib/quickbeam/vm/runtime/boundary/accessor.ex @@ -0,0 +1,17 @@ +defmodule QuickBEAM.VM.Runtime.Boundary.Accessor do + @moduledoc """ + Tracks completion of a JavaScript property accessor invocation. + + Getter completion pushes its value onto the saved caller frame. Setter + completion ignores the accessor return value and resumes the caller unchanged. + """ + + @enforce_keys [:mode, :caller, :depth] + defstruct [:mode, :caller, :depth] + + @type t :: %__MODULE__{ + mode: :get | :set, + caller: QuickBEAM.VM.Runtime.Frame.t(), + depth: non_neg_integer() + } +end diff --git a/lib/quickbeam/vm/runtime/boundary/async.ex b/lib/quickbeam/vm/runtime/boundary/async.ex new file mode 100644 index 000000000..834142849 --- /dev/null +++ b/lib/quickbeam/vm/runtime/boundary/async.ex @@ -0,0 +1,27 @@ +defmodule QuickBEAM.VM.Runtime.Boundary.Async do + @moduledoc """ + Describes the Promise and caller boundary owned by an async invocation. + + The interpreter uses this boundary to settle the async function's result and + resume, return to, or detach from its caller without using the BEAM stack. + """ + + @enforce_keys [:promise, :depth] + defstruct [:promise, :caller, :depth, mode: :push] + + @type t :: %__MODULE__{ + promise: QuickBEAM.VM.Runtime.Promise.Reference.t(), + caller: + QuickBEAM.VM.Runtime.Boundary.Accessor.t() + | QuickBEAM.VM.Runtime.Frame.t() + | QuickBEAM.VM.Runtime.Boundary.ObjectAssign.t() + | QuickBEAM.VM.Runtime.Frame.Native.t() + | QuickBEAM.VM.Runtime.Boundary.Reaction.t() + | QuickBEAM.VM.Runtime.Boundary.PromiseExecutor.t() + | QuickBEAM.VM.Runtime.Boundary.Thenable.t() + | QuickBEAM.VM.Runtime.Boundary.ThenGetter.t() + | nil, + depth: pos_integer(), + mode: :push | :return | :reaction | :executor | :thenable | :detached + } +end diff --git a/lib/quickbeam/vm/runtime/boundary/constructor.ex b/lib/quickbeam/vm/runtime/boundary/constructor.ex new file mode 100644 index 000000000..755da1484 --- /dev/null +++ b/lib/quickbeam/vm/runtime/boundary/constructor.ex @@ -0,0 +1,17 @@ +defmodule QuickBEAM.VM.Runtime.Boundary.Constructor do + @moduledoc """ + Tracks completion of a JavaScript constructor invocation. + + Primitive constructor returns are replaced with the allocated instance, while + object returns become the result of the `new` expression. + """ + + @enforce_keys [:instance, :caller, :depth] + defstruct [:instance, :caller, :depth] + + @type t :: %__MODULE__{ + instance: QuickBEAM.VM.Runtime.Reference.t(), + caller: QuickBEAM.VM.Runtime.Frame.t() | QuickBEAM.VM.Runtime.Frame.Native.t(), + depth: non_neg_integer() + } +end diff --git a/lib/quickbeam/vm/runtime/boundary/iterator.ex b/lib/quickbeam/vm/runtime/boundary/iterator.ex new file mode 100644 index 000000000..50bcec1e3 --- /dev/null +++ b/lib/quickbeam/vm/runtime/boundary/iterator.ex @@ -0,0 +1,44 @@ +defmodule QuickBEAM.VM.Runtime.Boundary.Iterator do + @moduledoc "Defines resumable state while a Promise combinator consumes an iterator." + + @enforce_keys [:consumer, :iterable, :caller, :depth] + defstruct [ + :consumer, + :kind, + :promise, + :target, + :iterable, + :iterator, + :next, + :result, + :phase, + :caller, + :depth, + values: [], + tail?: false + ] + + @type phase :: + :iterator_getter + | :iterator_factory + | :next_getter + | :next_call + | :done_getter + | :value_getter + + @type t :: %__MODULE__{ + consumer: :promise | :set, + kind: :all | :all_settled | :any | :race | nil, + promise: QuickBEAM.VM.Runtime.Promise.Reference.t() | nil, + target: QuickBEAM.VM.Runtime.Reference.t() | nil, + iterable: term(), + iterator: term() | nil, + next: term() | nil, + result: term() | nil, + phase: phase() | nil, + caller: term(), + depth: non_neg_integer(), + values: [term()], + tail?: boolean() + } +end diff --git a/lib/quickbeam/vm/runtime/boundary/object_assign.ex b/lib/quickbeam/vm/runtime/boundary/object_assign.ex new file mode 100644 index 000000000..67e2cc765 --- /dev/null +++ b/lib/quickbeam/vm/runtime/boundary/object_assign.ex @@ -0,0 +1,33 @@ +defmodule QuickBEAM.VM.Runtime.Boundary.ObjectAssign do + @moduledoc """ + Tracks resumable `Object.assign` property reads and writes. + + Source getters and target setters may execute arbitrary JavaScript, so the + operation stores its remaining sources and keys in explicit machine state. + """ + + @enforce_keys [:target, :sources, :caller, :depth] + defstruct [ + :target, + :sources, + :source, + :caller, + :depth, + :phase, + :key, + keys: [], + tail?: false + ] + + @type t :: %__MODULE__{ + target: QuickBEAM.VM.Runtime.Reference.t(), + sources: [term()], + source: term() | nil, + caller: QuickBEAM.VM.Runtime.Frame.t() | QuickBEAM.VM.Runtime.Frame.Native.t(), + depth: non_neg_integer(), + phase: :get | :set | nil, + key: term() | nil, + keys: [term()], + tail?: boolean() + } +end diff --git a/lib/quickbeam/vm/runtime/boundary/promise_executor.ex b/lib/quickbeam/vm/runtime/boundary/promise_executor.ex new file mode 100644 index 000000000..7837cbb5e --- /dev/null +++ b/lib/quickbeam/vm/runtime/boundary/promise_executor.ex @@ -0,0 +1,18 @@ +defmodule QuickBEAM.VM.Runtime.Boundary.PromiseExecutor do + @moduledoc """ + Records the caller waiting for synchronous Promise executor completion. + + Executor return values are ignored; this boundary returns the constructed + Promise and converts a synchronous executor throw into rejection. + """ + + @enforce_keys [:promise, :caller, :depth] + defstruct [:promise, :caller, :depth, tail?: false] + + @type t :: %__MODULE__{ + promise: QuickBEAM.VM.Runtime.Promise.Reference.t(), + caller: QuickBEAM.VM.Runtime.Frame.t() | QuickBEAM.VM.Runtime.Frame.Native.t(), + depth: non_neg_integer(), + tail?: boolean() + } +end diff --git a/lib/quickbeam/vm/runtime/boundary/reaction.ex b/lib/quickbeam/vm/runtime/boundary/reaction.ex new file mode 100644 index 000000000..7f7cb398d --- /dev/null +++ b/lib/quickbeam/vm/runtime/boundary/reaction.ex @@ -0,0 +1,18 @@ +defmodule QuickBEAM.VM.Runtime.Boundary.Reaction do + @moduledoc """ + Tracks completion of a running Promise reaction callback. + + It preserves the original settlement for `finally` and identifies the result + Promise that receives callback completion. + """ + + @enforce_keys [:promise, :depth] + defstruct [:promise, :depth, mode: :then, original_result: nil] + + @type t :: %__MODULE__{ + promise: QuickBEAM.VM.Runtime.Promise.Reference.t(), + depth: non_neg_integer(), + mode: :then | :finally, + original_result: {:ok, term()} | {:error, term()} | nil + } +end diff --git a/lib/quickbeam/vm/runtime/boundary/then_getter.ex b/lib/quickbeam/vm/runtime/boundary/then_getter.ex new file mode 100644 index 000000000..915546017 --- /dev/null +++ b/lib/quickbeam/vm/runtime/boundary/then_getter.ex @@ -0,0 +1,19 @@ +defmodule QuickBEAM.VM.Runtime.Boundary.ThenGetter do + @moduledoc """ + Tracks the JavaScript `then` property getter used during Promise resolution. + + Getter completion either invokes the returned callable as a thenable or + fulfills the target Promise directly when the property is not callable. + """ + + @enforce_keys [:promise, :thenable, :depth] + defstruct [:promise, :thenable, :depth, :continuation] + + @type t :: %__MODULE__{ + promise: QuickBEAM.VM.Runtime.Promise.Reference.t(), + thenable: QuickBEAM.VM.Runtime.Reference.t(), + depth: non_neg_integer(), + continuation: + QuickBEAM.VM.Runtime.Frame.t() | QuickBEAM.VM.Runtime.Boundary.Iterator.t() | nil + } +end diff --git a/lib/quickbeam/vm/runtime/boundary/thenable.ex b/lib/quickbeam/vm/runtime/boundary/thenable.ex new file mode 100644 index 000000000..a54d1928c --- /dev/null +++ b/lib/quickbeam/vm/runtime/boundary/thenable.ex @@ -0,0 +1,13 @@ +defmodule QuickBEAM.VM.Runtime.Boundary.Thenable do + @moduledoc """ + Tracks invocation of a foreign thenable's `then` method during assimilation. + """ + + @enforce_keys [:promise, :depth] + defstruct [:promise, :depth] + + @type t :: %__MODULE__{ + promise: QuickBEAM.VM.Runtime.Promise.Reference.t(), + depth: non_neg_integer() + } +end diff --git a/lib/quickbeam/vm/runtime/callable.ex b/lib/quickbeam/vm/runtime/callable.ex new file mode 100644 index 000000000..21b4fcb16 --- /dev/null +++ b/lib/quickbeam/vm/runtime/callable.ex @@ -0,0 +1,36 @@ +defmodule QuickBEAM.VM.Runtime.Callable do + @moduledoc """ + Classifies represented JavaScript callable values without planning or + executing an invocation. + """ + + alias QuickBEAM.VM.Builtin.Runtime, as: BuiltinRuntime + alias QuickBEAM.VM.Runtime.Reference + alias QuickBEAM.VM.Runtime.State + alias QuickBEAM.VM.Runtime.Value + + @callable_tags [ + :builtin, + :declared_builtin, + :bound_function, + :host_function, + :primitive_method, + :promise_resolver + ] + + @doc "Returns whether a VM value is callable by JavaScript." + @spec callable?(term(), State.t()) :: boolean() + def callable?(value, execution), do: typeof(value, execution) == "function" + + @doc "Returns the JavaScript `typeof` classification for a represented value." + @spec typeof(term(), State.t()) :: String.t() + def typeof(%Reference{} = reference, execution) do + if BuiltinRuntime.callable(execution, reference), do: "function", else: "object" + end + + def typeof(value, _execution) + when is_tuple(value) and elem(value, 0) in @callable_tags, + do: "function" + + def typeof(value, _execution), do: Value.typeof(value) +end diff --git a/lib/quickbeam/vm/runtime/continuation.ex b/lib/quickbeam/vm/runtime/continuation.ex new file mode 100644 index 000000000..b184f6744 --- /dev/null +++ b/lib/quickbeam/vm/runtime/continuation.ex @@ -0,0 +1,12 @@ +defmodule QuickBEAM.VM.Runtime.Continuation do + @moduledoc "Captures a legacy suspended frame and its owning execution state." + + @enforce_keys [:frame, :execution] + defstruct [:frame, :execution, :awaiting] + + @type t :: %__MODULE__{ + frame: QuickBEAM.VM.Runtime.Frame.t(), + execution: QuickBEAM.VM.Runtime.State.t(), + awaiting: QuickBEAM.VM.Runtime.Promise.Reference.t() | term() | nil + } +end diff --git a/lib/quickbeam/vm/runtime/coroutine.ex b/lib/quickbeam/vm/runtime/coroutine.ex new file mode 100644 index 000000000..349b03d92 --- /dev/null +++ b/lib/quickbeam/vm/runtime/coroutine.ex @@ -0,0 +1,17 @@ +defmodule QuickBEAM.VM.Runtime.Coroutine do + @moduledoc """ + Captures a detached async frame and its explicit JavaScript caller stack. + + Coroutines remain local to one evaluation process and are resumed by that + process's FIFO microtask queue. + """ + + @enforce_keys [:frame, :callers, :boundary] + defstruct [:frame, :callers, :boundary] + + @type t :: %__MODULE__{ + frame: QuickBEAM.VM.Runtime.Frame.t(), + callers: [QuickBEAM.VM.Runtime.Frame.t() | QuickBEAM.VM.Runtime.Frame.Native.t()], + boundary: QuickBEAM.VM.Runtime.Boundary.Async.t() + } +end diff --git a/lib/quickbeam/vm/runtime/engine.ex b/lib/quickbeam/vm/runtime/engine.ex new file mode 100644 index 000000000..55c8dcccb --- /dev/null +++ b/lib/quickbeam/vm/runtime/engine.ex @@ -0,0 +1,452 @@ +defmodule QuickBEAM.VM.Runtime.Engine do + @moduledoc """ + Contains internal interpreter/compiler selection and evaluation containment. + + The public `QuickBEAM.VM` facade selects only the interpreter. Compiler tests + and benchmarks use this module explicitly while the compiler remains + release-quarantined and filtered from public ExDoc output. + """ + + alias QuickBEAM.VM.Bytecode.Verifier + alias QuickBEAM.VM.Compiler + alias QuickBEAM.VM.Options + alias QuickBEAM.VM.Program + alias QuickBEAM.VM.Program.Pinned + alias QuickBEAM.VM.Program.Store + alias QuickBEAM.VM.Runtime + alias QuickBEAM.VM.Runtime.Engine.Measurement + + @default_timeout 5_000 + @default_memory_limit 64 * 1024 * 1024 + @worker_heap_overhead 4 * 1024 * 1024 + + @type option :: + QuickBEAM.VM.evaluation_option() + | {:engine, :interpreter | :compiler} + | {:compiler_pool, GenServer.server()} + | {:compiler_profile, :pure_v1 | :scalar_v1} + | {:compiler_region_probe, boolean()} + | {:compiler_regions, boolean()} + + @doc "Evaluates through an explicitly selected internal engine." + @spec eval(Program.t() | Pinned.t(), [option()]) :: QuickBEAM.VM.result(term()) + def eval(program, opts \\ []), do: execute(program, opts, :eval) + + @doc "Calls a named global through an explicitly selected internal engine." + @spec call(Program.t() | Pinned.t(), String.t(), [term()], [option()]) :: + QuickBEAM.VM.result(term()) + def call(program, name, arguments \\ [], opts \\ []) + + def call(program, name, arguments, opts) + when is_binary(name) and is_list(arguments), + do: execute(program, opts, {:call, name, arguments}) + + def call(_program, name, _arguments, _opts) when not is_binary(name), + do: {:error, :invalid_function_name} + + def call(_program, _name, arguments, _opts) when not is_list(arguments), + do: {:error, :invalid_arguments} + + @doc "Measures an explicitly selected internal engine evaluation." + @spec measure(Program.t() | Pinned.t(), [option()]) :: QuickBEAM.VM.result(Measurement.t()) + def measure(program, opts \\ []), do: measure_request(program, opts, :eval) + + @doc "Measures a named global call through an explicitly selected internal engine." + @spec measure_call(Program.t() | Pinned.t(), String.t(), [term()], [option()]) :: + QuickBEAM.VM.result(Measurement.t()) + def measure_call(program, name, arguments \\ [], opts \\ []) + + def measure_call(program, name, arguments, opts) + when is_binary(name) and is_list(arguments), + do: measure_request(program, opts, {:call, name, arguments}) + + def measure_call(_program, name, _arguments, _opts) when not is_binary(name), + do: {:error, :invalid_function_name} + + def measure_call(_program, _name, arguments, _opts) when not is_list(arguments), + do: {:error, :invalid_arguments} + + defp execute(%Pinned{} = pinned, opts, request) when is_list(opts) do + with {:ok, options} <- evaluation_options(opts), + {:ok, lease} <- pinned_lease(pinned) do + options = Map.put(options, :request, request) + + try do + case options.isolation do + :caller -> evaluate_pinned_caller(lease, options) + :process -> eval_isolated_pinned(lease, options) + end + after + Store.checkin(lease) + end + end + end + + defp execute(%Program{} = program, opts, request) when is_list(opts) do + with :ok <- Verifier.verify(program), + {:ok, options} <- evaluation_options(opts) do + options = Map.put(options, :request, request) + + case options.isolation do + :caller -> evaluate(program, options) + :process -> eval_isolated(program, options) + end + end + end + + defp execute(program, _opts, _request) + when not is_struct(program, Program) and not is_struct(program, Pinned), + do: {:error, :invalid_program} + + defp execute(_program, opts, _request), do: {:error, {:invalid_options, opts}} + + defp measure_request(%Pinned{} = pinned, opts, request) when is_list(opts) do + with {:ok, options} <- evaluation_options(opts), + {:ok, lease} <- pinned_lease(pinned) do + options = Map.put(options, :request, request) + started = System.monotonic_time() + + payload = + try do + case options.isolation do + :caller -> measure_pinned_caller(lease, options) + :process -> measure_isolated_pinned(lease, options) + end + after + Store.checkin(lease) + end + + elapsed = System.monotonic_time() - started + wall_time_us = System.convert_time_unit(elapsed, :native, :microsecond) + {:ok, measurement(payload, wall_time_us)} + end + end + + defp measure_request(%Program{} = program, opts, request) when is_list(opts) do + with :ok <- Verifier.verify(program), + {:ok, options} <- evaluation_options(opts) do + options = Map.put(options, :request, request) + started = System.monotonic_time() + + payload = + case options.isolation do + :caller -> safe_measure(program, options) + :process -> measure_isolated(program, options) + end + + elapsed = System.monotonic_time() - started + wall_time_us = System.convert_time_unit(elapsed, :native, :microsecond) + {:ok, measurement(payload, wall_time_us)} + end + end + + defp measure_request(program, _opts, _request) + when not is_struct(program, Program) and not is_struct(program, Pinned), + do: {:error, :invalid_program} + + defp measure_request(_program, opts, _request), do: {:error, {:invalid_options, opts}} + + defp evaluation_options(opts) do + allowed = [ + :compiler_pool, + :compiler_profile, + :compiler_region_probe, + :compiler_regions, + :engine, + :handlers, + :isolation, + :max_stack_depth, + :max_steps, + :memory_limit, + :profile, + :timeout, + :vars + ] + + with :ok <- Options.validate(opts, allowed) do + validate_evaluation_options(opts) + end + end + + defp validate_evaluation_options(opts) do + isolation = Keyword.get(opts, :isolation, :process) + engine = Keyword.get(opts, :engine, :interpreter) + compiler_pool = Keyword.get(opts, :compiler_pool, QuickBEAM.VM.Compiler.Pool) + compiler_profile = Keyword.get(opts, :compiler_profile, :pure_v1) + compiler_region_probe = Keyword.get(opts, :compiler_region_probe, false) + compiler_regions = Keyword.get(opts, :compiler_regions, false) + timeout = Keyword.get(opts, :timeout, @default_timeout) + max_steps = Keyword.get(opts, :max_steps, 5_000_000) + max_stack_depth = Keyword.get(opts, :max_stack_depth, 1_000) + memory_limit = Keyword.get(opts, :memory_limit, @default_memory_limit) + profile = Keyword.get(opts, :profile, :core) + vars = Keyword.get(opts, :vars, %{}) + handlers = Keyword.get(opts, :handlers, %{}) + + with :ok <- validate_member(:isolation, isolation, [:caller, :process]), + :ok <- validate_member(:engine, engine, [:interpreter, :compiler]), + :ok <- validate_server(compiler_pool), + :ok <- validate_member(:compiler_profile, compiler_profile, [:pure_v1, :scalar_v1]), + :ok <- validate_boolean(:compiler_region_probe, compiler_region_probe), + :ok <- validate_boolean(:compiler_regions, compiler_regions), + :ok <- validate_limit(:timeout, timeout), + :ok <- validate_positive(:max_steps, max_steps), + :ok <- validate_positive(:max_stack_depth, max_stack_depth), + :ok <- validate_limit(:memory_limit, memory_limit), + :ok <- validate_member(:profile, profile, [:core, :ssr]), + :ok <- validate_map(:vars, vars), + :ok <- validate_handlers(handlers) do + {:ok, + %{ + isolation: isolation, + engine: engine, + memory_limit: memory_limit, + timeout: timeout, + interpreter: %{ + compiler_pool: compiler_pool, + compiler_profile: compiler_profile, + compiler_region_probe: compiler_region_probe, + compiler_regions: compiler_regions, + handlers: handlers, + max_steps: max_steps, + max_stack_depth: max_stack_depth, + memory_limit: memory_limit, + profile: profile, + vars: vars + } + }} + end + end + + defp validate_member(name, value, allowed) do + if value in allowed, do: :ok, else: {:error, {:invalid_option, name, value}} + end + + defp validate_server(server) when is_atom(server) or is_pid(server), do: :ok + defp validate_server(server), do: {:error, {:invalid_option, :compiler_pool, server}} + + defp validate_boolean(_name, value) when is_boolean(value), do: :ok + defp validate_boolean(name, value), do: {:error, {:invalid_option, name, value}} + + defp validate_limit(_name, :infinity), do: :ok + defp validate_limit(name, value), do: validate_positive(name, value) + + defp validate_positive(_name, value) when is_integer(value) and value > 0, do: :ok + defp validate_positive(name, value), do: {:error, {:invalid_option, name, value}} + + defp validate_map(_name, value) when is_map(value), do: :ok + defp validate_map(name, value), do: {:error, {:invalid_option, name, value}} + + defp validate_handlers(handlers) when is_map(handlers) do + if Enum.all?(handlers, fn {name, handler} -> + is_binary(name) and is_function(handler, 1) + end), + do: :ok, + else: {:error, {:invalid_option, :handlers, handlers}} + end + + defp validate_handlers(handlers), do: {:error, {:invalid_option, :handlers, handlers}} + + defp pinned_lease(pinned) do + case Store.checkout(pinned) do + {:ok, lease} -> {:ok, lease} + :unavailable -> {:error, :pinned_program_unavailable} + end + end + + defp evaluate_pinned_caller(lease, options) do + with {:ok, program} <- Store.fetch(lease), + :ok <- Verifier.verify_identity(program) do + evaluate(program, options) + end + end + + defp measure_pinned_caller(lease, options) do + case fetch_verified_pinned(lease) do + {:ok, program} -> safe_measure(program, options) + {:error, reason} -> {:measured, {:error, reason}, nil} + end + end + + defp eval_isolated(program, options), do: eval_isolated_program(program, options) + + defp eval_isolated_pinned(lease, options) do + with {:ok, program} <- Store.fetch(lease), + :ok <- Verifier.verify_identity(program) do + eval_isolated_program(program, options) + end + end + + defp eval_isolated_program(program, options) do + caller = self() + reply_ref = make_ref() + + worker = fn -> send(caller, {reply_ref, safe_evaluate(program, options)}) end + {pid, monitor_ref} = :erlang.spawn_opt(worker, worker_spawn_options(options.memory_limit)) + await_evaluation(pid, monitor_ref, reply_ref, options.timeout, options.memory_limit) + end + + defp evaluate(program, %{engine: :interpreter, interpreter: options, request: :eval}), + do: Runtime.eval(program, Map.to_list(options)) + + defp evaluate( + program, + %{engine: :interpreter, interpreter: options, request: {:call, name, arguments}} + ), + do: Runtime.call(program, name, arguments, Map.to_list(options)) + + defp evaluate(program, %{engine: :compiler, interpreter: options, request: :eval}), + do: Compiler.eval(program, Map.to_list(options)) + + defp evaluate(_program, %{engine: :compiler, request: {:call, _name, _arguments}}), + do: {:error, {:unsupported, :compiler_call}} + + defp safe_evaluate(program, options) do + case evaluate(program, options) do + {:suspended, _continuation} -> {:error, {:unsupported, :async_wait}} + result -> result + end + rescue + exception -> {:error, {engine_crash(options.engine), exception, __STACKTRACE__}} + catch + kind, reason -> {:error, {engine_crash(options.engine), {kind, reason}, __STACKTRACE__}} + end + + defp measure_isolated(program, options), do: measure_isolated_program(program, options) + + defp measure_isolated_pinned(lease, options) do + case fetch_verified_pinned(lease) do + {:ok, program} -> measure_isolated_program(program, options) + {:error, reason} -> {:measured, {:error, reason}, nil} + end + end + + defp fetch_verified_pinned(lease) do + with {:ok, program} <- Store.fetch(lease), + :ok <- Verifier.verify_identity(program), + do: {:ok, program} + end + + defp measure_isolated_program(program, options) do + caller = self() + reply_ref = make_ref() + worker = fn -> send(caller, {reply_ref, safe_measure(program, options)}) end + {pid, monitor_ref} = :erlang.spawn_opt(worker, worker_spawn_options(options.memory_limit)) + await_measurement(pid, monitor_ref, reply_ref, options) + end + + defp await_measurement(pid, monitor_ref, reply_ref, options) do + case await_evaluation(pid, monitor_ref, reply_ref, options.timeout, options.memory_limit) do + {:measured, _result, _metrics} = measured -> measured + {:error, _reason} = error -> {:measured, error, nil} + end + end + + defp safe_measure(program, %{engine: engine, interpreter: options, request: request}) do + {result, metrics} = measure_engine(engine, program, Map.to_list(options), request) + + result = + if match?({:suspended, _continuation}, result), + do: {:error, {:unsupported, :async_wait}}, + else: result + + {:measured, result, metrics} + rescue + exception -> {:measured, {:error, {engine_crash(engine), exception, __STACKTRACE__}}, nil} + catch + kind, reason -> + {:measured, {:error, {engine_crash(engine), {kind, reason}, __STACKTRACE__}}, nil} + end + + defp measure_engine(:interpreter, program, options, :eval), + do: Runtime.eval_with_metrics(program, options) + + defp measure_engine(:interpreter, program, options, {:call, name, arguments}), + do: Runtime.call_with_metrics(program, name, arguments, options) + + defp measure_engine(:compiler, program, options, :eval), + do: Compiler.eval_with_metrics(program, options) + + defp measure_engine(:compiler, _program, _options, {:call, _name, _arguments}), + do: {{:error, {:unsupported, :compiler_call}}, nil} + + defp engine_crash(:interpreter), do: :interpreter_crash + defp engine_crash(:compiler), do: :compiler_crash + + defp measurement({:measured, result, metrics}, wall_time_us) do + metrics = metrics || %{} + + %Measurement{ + result: result, + wall_time_us: wall_time_us, + steps: Map.get(metrics, :steps), + logical_memory_bytes: Map.get(metrics, :logical_memory_bytes), + compiler_counters: Map.get(metrics, :compiler_counters), + compiler_regions: Map.get(metrics, :compiler_regions), + process_memory_bytes: Map.get(metrics, :process_memory_bytes), + reductions: Map.get(metrics, :reductions) + } + end + + defp worker_spawn_options(:infinity), do: [:monitor] + + defp worker_spawn_options(memory_limit) do + word_size = :erlang.system_info(:wordsize) + max_heap_words = div(memory_limit + @worker_heap_overhead + word_size - 1, word_size) + + [ + :monitor, + {:max_heap_size, %{size: max_heap_words, kill: true, error_logger: false}} + ] + end + + defp await_evaluation(pid, monitor_ref, reply_ref, :infinity, memory_limit) do + receive do + {^reply_ref, result} -> + Process.demonitor(monitor_ref, [:flush]) + result + + {:DOWN, ^monitor_ref, :process, ^pid, reason} -> + evaluation_exit(reason, memory_limit) + end + end + + defp await_evaluation(pid, monitor_ref, reply_ref, timeout, memory_limit) do + receive do + {^reply_ref, result} -> + Process.demonitor(monitor_ref, [:flush]) + result + + {:DOWN, ^monitor_ref, :process, ^pid, reason} -> + evaluation_exit(reason, memory_limit) + after + timeout -> + Process.exit(pid, :kill) + await_down(monitor_ref, pid) + flush_reply(reply_ref) + {:error, {:limit_exceeded, :timeout, timeout}} + end + end + + defp evaluation_exit(:killed, memory_limit) when is_integer(memory_limit), + do: {:error, {:limit_exceeded, :memory_bytes, memory_limit}} + + defp evaluation_exit(reason, _memory_limit), + do: {:error, {:evaluation_process_exit, reason}} + + defp await_down(monitor_ref, pid) do + receive do + {:DOWN, ^monitor_ref, :process, ^pid, _reason} -> :ok + after + 1_000 -> Process.demonitor(monitor_ref, [:flush]) + end + end + + defp flush_reply(reply_ref) do + receive do + {^reply_ref, _result} -> :ok + after + 0 -> :ok + end + end +end diff --git a/lib/quickbeam/vm/runtime/engine/measurement.ex b/lib/quickbeam/vm/runtime/engine/measurement.ex new file mode 100644 index 000000000..5343a9393 --- /dev/null +++ b/lib/quickbeam/vm/runtime/engine/measurement.ex @@ -0,0 +1,29 @@ +defmodule QuickBEAM.VM.Runtime.Engine.Measurement do + @moduledoc """ + Carries interpreter and release-quarantined compiler observations for internal + tests and benchmarks. + """ + + @enforce_keys [:result, :wall_time_us] + defstruct [ + :result, + :wall_time_us, + :steps, + :logical_memory_bytes, + :compiler_counters, + :compiler_regions, + :process_memory_bytes, + :reductions + ] + + @type t :: %__MODULE__{ + result: {:ok, term()} | {:error, term()}, + wall_time_us: non_neg_integer(), + steps: non_neg_integer() | nil, + logical_memory_bytes: non_neg_integer() | nil, + compiler_counters: map() | nil, + compiler_regions: map() | nil, + process_memory_bytes: non_neg_integer() | nil, + reductions: non_neg_integer() | nil + } +end diff --git a/lib/quickbeam/vm/runtime/exception.ex b/lib/quickbeam/vm/runtime/exception.ex new file mode 100644 index 000000000..f8de87040 --- /dev/null +++ b/lib/quickbeam/vm/runtime/exception.ex @@ -0,0 +1,328 @@ +defmodule QuickBEAM.VM.Runtime.Exception do + @moduledoc """ + Converts VM-generated failures into catchable owner-local JavaScript errors. + + JavaScript values remain heap references while code can catch them. Conversion + to `QuickBEAM.JSError` happens only after a value escapes the evaluation. + """ + + alias QuickBEAM.VM.Builtin.Runtime, as: BuiltinRuntime + alias QuickBEAM.VM.Bytecode.Atom, as: AtomTable + alias QuickBEAM.VM.Program.Function + alias QuickBEAM.VM.Runtime.Async + alias QuickBEAM.VM.Runtime.Boundary + alias QuickBEAM.VM.Runtime.Frame + alias QuickBEAM.VM.Runtime.Frame.Native + alias QuickBEAM.VM.Runtime.Heap + alias QuickBEAM.VM.Runtime.Object + alias QuickBEAM.VM.Runtime.Promise + alias QuickBEAM.VM.Runtime.Property + alias QuickBEAM.VM.Runtime.Reference + alias QuickBEAM.VM.Runtime.State + alias QuickBEAM.VM.Runtime.Value + + alias QuickBEAM.VM.Runtime.Thrown + + @type action :: + {:run, Frame.t(), State.t()} + | {:resume_then_getter, Boundary.ThenGetter.t(), State.t()} + | {:complete, term(), term(), State.t(), boolean()} + | {:async, Async.result()} + | {:idle, State.t()} + | {:error, QuickBEAM.JSError.t(), State.t()} + + @doc "Raises a value at the current frame and plans catch or unwind execution." + @spec throw_at(term(), Frame.t() | Native.t(), State.t()) :: action() + def throw_at(reason, %Native{caller: caller}, execution) do + {reason, trace, execution} = throw_state(reason, execution) + do_throw(reason, caller, execution, trace, true) + end + + def throw_at(reason, %Frame{} = frame, execution) do + {reason, trace, execution} = throw_state(reason, execution) + do_throw(reason, frame, execution, trace, false) + end + + @doc "Raises a value from an invocation boundary and plans its continuation." + @spec throw_from(term(), term(), State.t()) :: action() + def throw_from(reason, boundary, execution) do + {reason, trace, execution} = throw_state(reason, execution) + throw_from_boundary(reason, boundary, execution, trace) + end + + @doc "Materializes a generated VM exception as a JavaScript heap value." + @spec materialize(term(), State.t()) :: {term(), State.t()} + def materialize(reason, execution) do + case QuickBEAM.JSError.vm_exception_value(reason) do + %{} = value when not is_struct(value) -> + name = to_string(value[:name] || value["name"] || "Error") + message = to_string(value[:message] || value["message"] || "") + BuiltinRuntime.new_error(execution, name, message) + + value -> + {value, execution} + end + end + + @doc "Converts an uncaught JavaScript value into the stable public error struct." + @spec to_js_error(term(), State.t(), [QuickBEAM.JSError.frame()]) :: QuickBEAM.JSError.t() + def to_js_error(%Thrown{value: value, frames: async_frames}, execution, frames), + do: to_js_error(value, execution, async_frames ++ frames) + + def to_js_error(%Reference{} = reference, execution, frames) do + case details(reference, execution) do + {:ok, value} -> QuickBEAM.JSError.from_vm(value, frames) + :error -> QuickBEAM.JSError.from_vm(reference, frames) + end + end + + def to_js_error(reason, _execution, frames), do: QuickBEAM.JSError.from_vm(reason, frames) + + @doc "Returns the public name and message of an owner-local JavaScript error object." + @spec details(Reference.t(), State.t()) :: {:ok, map()} | :error + def details(%Reference{} = reference, execution) do + case Heap.fetch_object(execution, reference) do + {:ok, %Object{internal: {:error, default_name}}} -> + name = + case Property.get(reference, "name", execution) do + {:ok, value} when value not in [:undefined, nil] -> to_string_value(value) + _missing -> default_name + end + + message = + case Property.get(reference, "message", execution) do + {:ok, :undefined} -> "" + {:ok, value} -> to_string_value(value) + {:error, _reason} -> "" + end + + {:ok, %{"name" => name, "message" => message}} + + _not_error -> + :error + end + end + + defp throw_state(%Thrown{value: value, frames: frames}, execution) do + {value, execution} = materialize(value, execution) + {value, Enum.reverse(frames), execution} + end + + defp throw_state(reason, execution) do + {value, execution} = materialize(reason, execution) + {value, [], execution} + end + + defp throw_from_boundary(reason, %Boundary.ObjectAssign{} = boundary, execution, trace), + do: do_throw(reason, boundary.caller, execution, trace, true) + + defp throw_from_boundary(reason, %Boundary.ThenGetter{} = boundary, execution, trace) do + thrown = thrown(reason, trace) + execution = Promise.settle_assimilated(execution, boundary.promise, {:error, thrown}) + {:resume_then_getter, boundary, execution} + end + + defp throw_from_boundary(reason, %Boundary.Accessor{} = boundary, execution, trace), + do: do_throw(reason, boundary.caller, execution, trace, true) + + defp throw_from_boundary(reason, %Boundary.Constructor{} = boundary, execution, trace), + do: do_throw(reason, boundary.caller, execution, trace, true) + + defp throw_from_boundary(reason, %Boundary.Thenable{} = boundary, execution, trace) do + execution = + Promise.settle_assimilated(execution, boundary.promise, {:error, thrown(reason, trace)}) + + {:idle, execution} + end + + defp throw_from_boundary(reason, %Boundary.PromiseExecutor{} = boundary, execution, trace) do + execution = Promise.settle(execution, boundary.promise, {:error, thrown(reason, trace)}) + {:complete, boundary.promise, boundary.caller, execution, boundary.tail?} + end + + defp throw_from_boundary( + reason, + %Boundary.Iterator{consumer: :promise} = boundary, + execution, + trace + ) do + {reason, execution} = materialize(thrown(reason, trace), execution) + execution = Promise.settle(execution, boundary.promise, {:error, reason}) + {:complete, boundary.promise, boundary.caller, execution, boundary.tail?} + end + + defp throw_from_boundary( + reason, + %Boundary.Iterator{consumer: :set, caller: %Boundary.Constructor{} = constructor}, + execution, + trace + ), + do: do_throw(reason, constructor.caller, execution, trace, true) + + defp throw_from_boundary(reason, %Boundary.Reaction{} = boundary, execution, trace) do + execution = Promise.settle(execution, boundary.promise, {:error, thrown(reason, trace)}) + {:idle, execution} + end + + defp throw_from_boundary(reason, %Native{caller: caller}, execution, trace), + do: do_throw(reason, caller, execution, trace, true) + + defp throw_from_boundary(reason, %Frame{} = frame, execution, trace), + do: do_throw(reason, frame, execution, trace, true) + + defp do_throw(reason, frame, execution, trace, caller?) do + case split_at_catch(frame.stack) do + {:caught, target, stack_below_catch} -> + {:run, %{frame | pc: target, stack: [reason | stack_below_catch]}, execution} + + :uncaught -> + trace = [stack_frame(frame, caller?) | trace] + unwind_caller(reason, execution, trace) + end + end + + defp unwind_caller(reason, %State{callers: []} = execution, trace) do + error = to_js_error(reason, execution, Enum.reverse(trace)) + {:error, error, execution} + end + + defp unwind_caller( + reason, + %State{callers: [%Boundary.ObjectAssign{} = boundary | callers]} = execution, + trace + ) do + execution = %{execution | callers: callers, depth: boundary.depth} + do_throw(reason, boundary.caller, execution, trace, true) + end + + defp unwind_caller( + reason, + %State{callers: [%Boundary.ThenGetter{} = boundary | callers]} = execution, + trace + ) do + execution = %{execution | callers: callers, depth: boundary.depth} + throw_from_boundary(reason, boundary, execution, trace) + end + + defp unwind_caller( + reason, + %State{callers: [%Boundary.Accessor{} = boundary | callers]} = execution, + trace + ) do + execution = %{execution | callers: callers, depth: boundary.depth} + do_throw(reason, boundary.caller, execution, trace, true) + end + + defp unwind_caller( + reason, + %State{callers: [%Boundary.Constructor{} = boundary | callers]} = execution, + trace + ) do + execution = %{execution | callers: callers, depth: boundary.depth} + do_throw(reason, boundary.caller, execution, trace, true) + end + + defp unwind_caller( + reason, + %State{callers: [%Boundary.Thenable{} = boundary | callers]} = execution, + trace + ) do + execution = %{execution | callers: callers, depth: boundary.depth} + throw_from_boundary(reason, boundary, execution, trace) + end + + defp unwind_caller( + reason, + %State{callers: [%Boundary.PromiseExecutor{} = boundary | callers]} = execution, + trace + ) do + execution = %{execution | callers: callers, depth: boundary.depth} + throw_from_boundary(reason, boundary, execution, trace) + end + + defp unwind_caller( + reason, + %State{callers: [%Boundary.Iterator{} = boundary | callers]} = execution, + trace + ) do + execution = %{execution | callers: callers, depth: boundary.depth} + throw_from_boundary(reason, boundary, execution, trace) + end + + defp unwind_caller( + reason, + %State{callers: [%Boundary.Reaction{} = boundary | callers]} = execution, + trace + ) do + execution = %{execution | callers: callers, depth: boundary.depth} + throw_from_boundary(reason, boundary, execution, trace) + end + + defp unwind_caller( + reason, + %State{callers: [%Boundary.Async{} = boundary | callers]} = execution, + trace + ) do + execution = %{execution | callers: callers, depth: boundary.depth} + {:async, Async.complete(boundary, {:error, thrown(reason, trace)}, execution)} + end + + defp unwind_caller( + reason, + %State{callers: [%Native{} = native | callers]} = execution, + trace + ) do + execution = %{execution | callers: callers, depth: execution.depth - 1} + do_throw(reason, native.caller, execution, trace, true) + end + + defp unwind_caller( + reason, + %State{callers: [%Frame{} = caller | callers]} = execution, + trace + ) do + execution = %{execution | callers: callers, depth: execution.depth - 1} + do_throw(reason, caller, execution, trace, true) + end + + defp stack_frame(frame, caller?) do + instruction_count = tuple_size(frame.function.instructions) + pc = if caller?, do: frame.pc - 1, else: frame.pc + pc = pc |> max(0) |> min(max(instruction_count - 1, 0)) + {line, column} = source_position(frame.function, pc) + + %{ + function: normalize_function_name(frame.function.name), + filename: frame.function.filename, + line: line, + column: column + } + end + + defp source_position(%Function{source_positions: positions}, pc) + when is_tuple(positions) and pc < tuple_size(positions), + do: elem(positions, pc) + + defp source_position(%Function{line_num: line, col_num: column}, _pc), + do: {line, column} + + defp normalize_function_name(name) when name in [nil, ""], do: "" + + defp normalize_function_name({:predefined, index}), + do: AtomTable.lookup(index) || "" + + defp normalize_function_name(name) when is_binary(name), do: name + defp normalize_function_name(name), do: inspect(name) + + defp split_at_catch(stack) do + case Enum.split_while(stack, &(!match?({:catch, _target}, &1))) do + {_discarded, [{:catch, target} | stack]} -> {:caught, target, stack} + {_discarded, []} -> :uncaught + end + end + + defp thrown(reason, trace), do: %Thrown{value: reason, frames: Enum.reverse(trace)} + + defp to_string_value(value) when is_binary(value), do: value + defp to_string_value(value), do: Value.to_string_value(value) +end diff --git a/lib/quickbeam/vm/runtime/frame.ex b/lib/quickbeam/vm/runtime/frame.ex new file mode 100644 index 000000000..c737fc19b --- /dev/null +++ b/lib/quickbeam/vm/runtime/frame.ex @@ -0,0 +1,34 @@ +defmodule QuickBEAM.VM.Runtime.Frame do + @moduledoc "Defines an explicit JavaScript bytecode call frame." + + @enforce_keys [:function, :callable, :locals, :args] + defstruct [ + :function, + :callable, + :locals, + :args, + :this, + actual_arg_count: 0, + closure_refs: {}, + compiler_allow_reentry: false, + compiler_entered: false, + compiler_reentry_after_instruction: false, + pc: 0, + stack: [] + ] + + @type t :: %__MODULE__{ + function: QuickBEAM.VM.Program.Function.t(), + callable: term(), + closure_refs: tuple(), + compiler_allow_reentry: boolean(), + compiler_entered: boolean(), + compiler_reentry_after_instruction: boolean(), + locals: tuple(), + args: tuple(), + actual_arg_count: non_neg_integer(), + this: term(), + pc: non_neg_integer(), + stack: [term()] + } +end diff --git a/lib/quickbeam/vm/runtime/frame/native.ex b/lib/quickbeam/vm/runtime/frame/native.ex new file mode 100644 index 000000000..234fc5841 --- /dev/null +++ b/lib/quickbeam/vm/runtime/frame/native.ex @@ -0,0 +1,28 @@ +defmodule QuickBEAM.VM.Runtime.Frame.Native do + @moduledoc "Defines resumable state for a VM-implemented native callback." + + @enforce_keys [:operation, :values, :callback, :receiver, :caller] + defstruct [ + :operation, + :values, + :callback, + :receiver, + :caller, + :accumulator, + index: 0, + results: [], + tail?: false + ] + + @type t :: %__MODULE__{ + operation: :map | :filter | :for_each | :some | :reduce, + values: tuple(), + callback: term(), + receiver: term(), + caller: QuickBEAM.VM.Runtime.Frame.t(), + accumulator: term(), + index: non_neg_integer(), + results: [term()], + tail?: boolean() + } +end diff --git a/lib/quickbeam/vm/runtime/heap.ex b/lib/quickbeam/vm/runtime/heap.ex new file mode 100644 index 000000000..f36514eee --- /dev/null +++ b/lib/quickbeam/vm/runtime/heap.ex @@ -0,0 +1,846 @@ +defmodule QuickBEAM.VM.Runtime.Heap do + @moduledoc """ + Manages objects and properties in an evaluation's process-owned heap. + + Live references are valid only with the `QuickBEAM.VM.Runtime.State` that owns + them and are exported to ordinary BEAM values at the evaluation boundary. + """ + + alias QuickBEAM.VM.Runtime.Memory + alias QuickBEAM.VM.Runtime.Object + alias QuickBEAM.VM.Runtime.Property.Descriptor + alias QuickBEAM.VM.Runtime.Reference + alias QuickBEAM.VM.Runtime.State + + @max_prototype_depth 1_000 + + @doc "Allocates one object in the owner-local heap." + @spec allocate(State.t(), Object.kind(), keyword()) :: {Reference.t(), State.t()} + def allocate(execution, kind \\ :ordinary, opts \\ []) + + def allocate(%State{} = execution, kind, []) do + object = %Object{kind: kind, prototype: Map.get(execution.default_prototypes, kind)} + store_new_object(execution, object) + end + + def allocate(%State{} = execution, kind, opts) do + prototype = + case Keyword.fetch(opts, :prototype) do + {:ok, prototype} -> prototype + :error -> Map.get(execution.default_prototypes, kind) + end + + object = %Object{ + kind: kind, + prototype: prototype, + length: Keyword.get(opts, :length, 0), + callable: Keyword.get(opts, :callable), + internal: Keyword.get(opts, :internal) + } + + store_new_object(execution, object) + end + + @doc "Allocates a dense array in one heap update with canonical default descriptors." + @spec allocate_array(State.t(), [term()]) :: {Reference.t(), State.t()} + def allocate_array(%State{} = execution, values) when is_list(values) do + object = %Object{ + kind: :array, + prototype: Map.get(execution.default_prototypes, :array), + length: length(values), + properties: + values + |> Enum.with_index() + |> Map.new(fn {value, index} -> {index, {value}} end) + } + + empty_object = %{object | length: 0, properties: %{}} + + execution = + Enum.reduce(Enum.with_index(values), Memory.charge_object(execution, empty_object), fn + {value, index}, execution -> Memory.charge_property(execution, index, value) + end) + + store_precharged_object(execution, object) + end + + defp store_new_object(execution, object) do + execution + |> Memory.charge_object(object) + |> store_precharged_object(object) + end + + defp store_precharged_object(execution, object) do + id = execution.next_object_id + reference = %Reference{id: id} + execution = %{execution | heap: Map.put(execution.heap, id, object), next_object_id: id + 1} + {reference, execution} + end + + @doc "Returns enumerable own string and Symbol keys in ECMAScript order." + @spec assignable_keys(State.t(), Reference.t()) :: {:ok, [term()]} | {:error, term()} + def assignable_keys(execution, %Reference{id: id} = reference) do + case fetch_object(execution, reference) do + {:ok, object} -> + integer_keys = + object.properties + |> Enum.filter(fn {key, property} -> + is_integer(key) and property_enumerable?(property) + end) + |> Enum.map(&elem(&1, 0)) + |> Enum.sort() + + other_keys = + Enum.filter(object.property_order, fn key -> + not is_integer(key) and property_enumerable?(object.properties[key]) + end) + + {:ok, integer_keys ++ other_keys} + + :error -> + {:error, {:invalid_reference, id}} + end + end + + @doc "Fetches an object from the owner-local heap." + @spec fetch_object(State.t(), Reference.t()) :: {:ok, Object.t()} | :error + def fetch_object(%State{} = execution, %Reference{id: id}), + do: Map.fetch(execution.heap, id) + + @doc "Returns sparse entries for one canonical array object." + @spec array_entries(Object.t()) :: [:hole | {:present, term()}] + def array_entries(%Object{kind: :array, length: 0}), do: [] + + def array_entries(%Object{kind: :array, length: length, properties: properties}) do + Enum.map(0..(length - 1), fn index -> + case Map.get(properties, index) do + {value} -> {:present, value} + %Descriptor{value: value} -> {:present, value} + nil -> :hole + end + end) + end + + @doc "Clears indexed array elements while retaining non-index properties." + @spec clear_array(Object.t()) :: Object.t() + def clear_array(%Object{kind: :array} = object) do + properties = Map.reject(object.properties, fn {key, _property} -> is_integer(key) end) + %{object | properties: properties, length: 0} + end + + @doc "Reads a property while following the validated prototype chain." + @spec get(State.t(), Reference.t(), term()) :: {:ok, term()} | {:error, term()} + def get(%State{} = execution, %Reference{} = reference, key) do + get_with_depth(execution, reference, normalize_key(key), reference, 0) + end + + @doc "Returns whether a property exists on an object or its prototype chain." + @spec has_property?(State.t(), Reference.t(), term()) :: boolean() + def has_property?(execution, %Reference{} = reference, key) do + key = normalize_key(key) + + case fetch_object(execution, reference) do + {:ok, %Object{kind: :array}} when key == "length" -> + true + + {:ok, object} -> + Map.has_key?(object.properties, key) or + (is_struct(object.prototype, Reference) and + has_property?(execution, object.prototype, key)) + + :error -> + false + end + end + + @doc "Writes a property while preserving its current descriptor semantics." + @spec put(State.t(), Reference.t(), term(), term()) :: + {:ok, State.t()} | {:error, term()} + def put(%State{} = execution, %Reference{id: id} = reference, key, value) do + key = normalize_key(key) + + case fetch_object(execution, reference) do + {:ok, %Object{kind: :array} = object} when is_integer(key) -> + case Map.get(object.properties, key) do + {_old_value} -> store_default_array_index(execution, id, object, key, value, true) + nil -> put_new_default_array_index(execution, id, object, key, value) + %Descriptor{} -> put_object(execution, id, object, key, value) + end + + {:ok, object} -> + put_object(execution, id, object, key, value) + + :error -> + {:error, {:invalid_reference, id}} + end + end + + @doc "Defines a data property with explicit descriptor options." + @spec define(State.t(), Reference.t(), term(), term(), keyword()) :: + {:ok, State.t()} | {:error, term()} + def define(%State{} = execution, %Reference{id: id} = reference, key, value, opts \\ []) do + key = normalize_key(key) + + case fetch_object(execution, reference) do + {:ok, %Object{kind: :array} = object} when is_integer(key) -> + case {Map.get(object.properties, key), default_property_options?(opts)} do + {{_old_value}, true} -> + store_default_array_index(execution, id, object, key, value, true) + + {nil, true} -> + define_new_default_array_index(execution, id, object, key, value) + + {_property, _default?} -> + define_object(execution, id, object, key, value, opts) + end + + {:ok, object} -> + define_object(execution, id, object, key, value, opts) + + :error -> + {:error, {:invalid_reference, id}} + end + end + + @doc "Returns an object's own property descriptor without traversing its prototype." + @spec own_property(State.t(), Reference.t(), term()) :: + {:ok, Descriptor.t() | nil} | {:error, term()} + def own_property(execution, %Reference{id: id} = reference, key) do + key = normalize_key(key) + + case fetch_object(execution, reference) do + {:ok, %Object{kind: :array} = object} when key == "length" -> + {:ok, + %Descriptor{ + value: object.length, + writable: object.length_writable, + enumerable: false, + configurable: false + }} + + {:ok, object} -> + {:ok, object |> own_property_value(key) |> Object.property_descriptor()} + + :error -> + {:error, {:invalid_reference, id}} + end + end + + @doc "Replaces an object's prototype after validating the owner-local chain." + @spec set_prototype(State.t(), Reference.t(), Reference.t() | nil) :: + {:ok, State.t()} | {:error, term()} + def set_prototype(execution, %Reference{id: id} = reference, prototype) + when is_nil(prototype) or is_struct(prototype, Reference) do + with {:ok, object} <- fetch_object(execution, reference), + :ok <- valid_prototype?(execution, reference, prototype) do + object = %{object | prototype: prototype} + {:ok, %{execution | heap: Map.put(execution.heap, id, object)}} + else + :error -> {:error, {:invalid_reference, id}} + {:error, reason} -> {:error, reason} + end + end + + @doc "Tests whether a reference appears in an object's prototype chain." + @spec prototype_chain_contains?(State.t(), Reference.t(), Reference.t()) :: boolean() + def prototype_chain_contains?(execution, %Reference{} = object, %Reference{} = prototype) do + case fetch_object(execution, object) do + {:ok, object} -> prototype_contains?(execution, object.prototype, prototype, 0) + :error -> false + end + end + + @doc "Returns an object's direct prototype." + @spec prototype(State.t(), Reference.t()) :: + {:ok, Reference.t() | nil} | {:error, term()} + def prototype(execution, %Reference{id: id} = reference) do + case fetch_object(execution, reference) do + {:ok, object} -> {:ok, object.prototype} + :error -> {:error, {:invalid_reference, id}} + end + end + + @doc "Defines or updates an accessor property on an owner-local object." + @spec define_accessor( + State.t(), + Reference.t(), + term(), + :getter | :setter, + term(), + keyword() + ) :: {:ok, State.t()} | {:error, term()} + def define_accessor(execution, %Reference{id: id} = reference, key, kind, callable, opts \\ []) + when kind in [:getter, :setter] do + key = normalize_key(key) + + case fetch_object(execution, reference) do + {:ok, object} -> + current = own_property_value(object, key) + + property = %Descriptor{ + kind: :accessor, + value: :undefined, + writable: false, + enumerable: Keyword.get(opts, :enumerable, current_flag(current, :enumerable, true)), + configurable: + Keyword.get(opts, :configurable, current_flag(current, :configurable, true)), + getter: if(kind == :getter, do: callable, else: current_accessor(current, :getter)), + setter: if(kind == :setter, do: callable, else: current_accessor(current, :setter)) + } + + store_descriptor(execution, id, object, key, property) + + :error -> + {:error, {:invalid_reference, id}} + end + end + + @doc "Defines a complete data or accessor descriptor on an owner-local object." + @spec define_descriptor(State.t(), Reference.t(), term(), Descriptor.t()) :: + {:ok, State.t()} | {:error, term()} + def define_descriptor(execution, %Reference{id: id} = reference, key, %Descriptor{} = property) do + key = normalize_key(key) + + case fetch_object(execution, reference) do + {:ok, %Object{kind: :array}} when key == "length" -> + {:error, {:property_not_configurable, "length"}} + + {:ok, object} -> + store_descriptor(execution, id, object, key, property) + + :error -> + {:error, {:invalid_reference, id}} + end + end + + @doc "Deletes a configurable own property." + @spec delete(State.t(), Reference.t(), term()) :: + {:ok, boolean(), State.t()} | {:error, term()} + def delete(%State{} = execution, %Reference{id: id} = reference, key) do + key = normalize_key(key) + + case fetch_object(execution, reference) do + {:ok, object} -> + case Map.get(object.properties, key) do + %Descriptor{configurable: false} -> + {:ok, false, execution} + + _property -> + object = %{ + object + | properties: Map.delete(object.properties, key), + property_order: List.delete(object.property_order, key) + } + + {:ok, true, %{execution | heap: Map.put(execution.heap, id, object)}} + end + + :error -> + {:error, {:invalid_reference, id}} + end + end + + @doc "Applies an owner-local object update." + @spec update_object(State.t(), Reference.t(), (Object.t() -> Object.t())) :: + {:ok, State.t()} | {:error, term()} + def update_object(%State{} = execution, %Reference{id: id} = reference, update) do + case fetch_object(execution, reference) do + {:ok, object} -> {:ok, %{execution | heap: Map.put(execution.heap, id, update.(object))}} + :error -> {:error, {:invalid_reference, id}} + end + end + + @doc "Returns all own string property names in ECMAScript key order." + @spec own_property_names(State.t(), Reference.t()) :: + {:ok, [String.t()]} | {:error, term()} + def own_property_names(execution, %Reference{id: id} = reference) do + case fetch_object(execution, reference) do + {:ok, object} -> + integer_keys = + object.properties + |> Map.keys() + |> Enum.filter(&is_integer/1) + |> Enum.sort() + |> Enum.map(&Integer.to_string/1) + + string_keys = Enum.filter(object.property_order, &is_binary/1) + builtins = if object.kind == :array, do: ["length"], else: [] + {:ok, integer_keys ++ builtins ++ string_keys} + + :error -> + {:error, {:invalid_reference, id}} + end + end + + @doc "Returns enumerable own keys in ECMAScript order." + @spec own_keys(State.t(), Reference.t()) :: {:ok, [term()]} | {:error, term()} + def own_keys(execution, %Reference{id: id} = reference) do + case fetch_object(execution, reference) do + {:ok, object} -> + integer_keys = + object.properties + |> Enum.filter(fn {key, property} -> + is_integer(key) and property_enumerable?(property) + end) + |> Enum.map(&elem(&1, 0)) + |> Enum.sort() + + string_keys = + Enum.filter(object.property_order, fn key -> + is_binary(key) and property_enumerable?(object.properties[key]) + end) + + {:ok, integer_keys ++ string_keys} + + :error -> + {:error, {:invalid_reference, id}} + end + end + + defp valid_prototype?(_execution, _reference, nil), do: :ok + + defp valid_prototype?(execution, reference, prototype) do + if prototype_contains?(execution, prototype, reference, 0), + do: {:error, :cyclic_prototype}, + else: :ok + end + + defp prototype_contains?(_execution, nil, _reference, _depth), do: false + + defp prototype_contains?(_execution, _prototype, _reference, depth) + when depth > @max_prototype_depth, + do: true + + defp prototype_contains?(_execution, prototype, reference, _depth) + when prototype.id == reference.id, + do: true + + defp prototype_contains?(execution, prototype, reference, depth) do + case fetch_object(execution, prototype) do + {:ok, object} -> prototype_contains?(execution, object.prototype, reference, depth + 1) + :error -> false + end + end + + defp get_with_depth(_execution, _reference, _key, _receiver, depth) + when depth > @max_prototype_depth, + do: {:error, :prototype_chain_too_deep} + + defp get_with_depth(execution, %Reference{id: id} = reference, key, receiver, depth) do + case fetch_object(execution, reference) do + {:ok, object} -> get_from_object(execution, object, key, receiver, depth) + :error -> {:error, {:invalid_reference, id}} + end + end + + defp get_from_object( + _execution, + %Object{kind: :array, length: length}, + "length", + _receiver, + _depth + ), + do: {:ok, length} + + defp get_from_object(execution, object, key, receiver, depth) do + case Map.fetch(object.properties, key) do + {:ok, {value}} -> + {:ok, value} + + {:ok, %Descriptor{getter: getter}} when not is_nil(getter) -> + {:ok, {:accessor, getter, receiver}} + + {:ok, %Descriptor{setter: setter}} when not is_nil(setter) -> + {:ok, :undefined} + + {:ok, %Descriptor{value: value}} -> + {:ok, value} + + :error -> + get_from_prototype(execution, object.prototype, key, receiver, depth) + end + end + + defp get_from_prototype(execution, %Reference{} = prototype, key, receiver, depth), + do: get_with_depth(execution, prototype, key, receiver, depth + 1) + + defp get_from_prototype(_execution, _prototype, _key, _receiver, _depth), + do: {:ok, :undefined} + + defp put_object(execution, id, object, key, value) do + case put_value(execution, object, key, value) do + {:ok, object} -> + execution = maybe_charge_property(execution, object, key, value) + updated = put_property(object, key, value) + {:ok, %{execution | heap: Map.put(execution.heap, id, updated)}} + + {:error, reason} -> + {:error, reason} + + {:array_length, object} -> + {:ok, %{execution | heap: Map.put(execution.heap, id, object)}} + end + end + + defp define_object(execution, id, object, key, value, opts) do + case define_property(object, key, value, opts) do + {:ok, object, property} -> + execution = maybe_charge_property(execution, object, key, value) + object = put_property_struct(object, key, property) + {:ok, %{execution | heap: Map.put(execution.heap, id, object)}} + + {:error, reason} -> + {:error, reason} + + {:array_length, object} -> + {:ok, %{execution | heap: Map.put(execution.heap, id, object)}} + end + end + + defp put_new_default_array_index(execution, id, object, key, value) do + inherited = inherited_property(execution, object.prototype, key, 0) + + cond do + match?(%Descriptor{setter: setter} when not is_nil(setter), inherited) -> + {:error, {:invoke_setter, inherited.setter}} + + accessor?(inherited) or match?(%Descriptor{writable: false}, inherited) -> + {:error, {:property_not_writable, key}} + + key >= object.length and not object.length_writable -> + {:error, {:property_not_writable, "length"}} + + not object.extensible -> + {:error, {:object_not_extensible, key}} + + true -> + store_default_array_index(execution, id, object, key, value, false) + end + end + + defp define_new_default_array_index(execution, id, object, key, value) do + cond do + not object.extensible -> + {:error, {:object_not_extensible, key}} + + key >= object.length and not object.length_writable -> + {:error, {:property_not_writable, "length"}} + + true -> + store_default_array_index(execution, id, object, key, value, false) + end + end + + defp store_default_array_index(execution, id, object, key, value, present?) do + execution = if present?, do: execution, else: Memory.charge_property(execution, key, value) + + object = %{ + object + | properties: Map.put(object.properties, key, {value}), + length: max(object.length, key + 1) + } + + {:ok, %{execution | heap: Map.put(execution.heap, id, object)}} + end + + defp default_property_options?(opts) do + Keyword.get(opts, :writable, true) and Keyword.get(opts, :enumerable, true) and + Keyword.get(opts, :configurable, true) + end + + defp maybe_charge_property(execution, object, key, value) do + if Map.has_key?(object.properties, key), + do: execution, + else: Memory.charge_property(execution, key, value) + end + + defp put_value(_execution, %Object{kind: :array} = object, "length", value) do + with :ok <- array_length_writable(object), + {:ok, length} <- array_length(value), + {:ok, object} <- resize_array(object, length) do + {:array_length, object} + end + end + + defp put_value(execution, object, key, _value) do + descriptor = + own_property_value(object, key) || inherited_property(execution, object.prototype, key, 0) + + with :ok <- writable_descriptor(descriptor, key) do + put_new_or_existing_value(object, key) + end + end + + defp writable_descriptor(%Descriptor{setter: setter}, _key) when not is_nil(setter), + do: {:error, {:invoke_setter, setter}} + + defp writable_descriptor(%Descriptor{kind: :accessor}, key), + do: {:error, {:property_not_writable, key}} + + defp writable_descriptor(%Descriptor{writable: false}, key), + do: {:error, {:property_not_writable, key}} + + defp writable_descriptor(_descriptor, _key), do: :ok + + defp put_new_or_existing_value(object, key) do + if Map.has_key?(object.properties, key), + do: {:ok, object}, + else: put_new_value(object, key) + end + + defp put_new_value( + %Object{kind: :array, length: length, length_writable: false}, + key + ) + when is_integer(key) and key >= length, + do: {:error, {:property_not_writable, "length"}} + + defp put_new_value(%Object{extensible: true} = object, _key), do: {:ok, object} + defp put_new_value(_object, key), do: {:error, {:object_not_extensible, key}} + + defp define_property(%Object{kind: :array} = object, "length", value, opts) do + cond do + Keyword.get(opts, :configurable, false) -> + {:error, {:property_not_configurable, "length"}} + + Keyword.get(opts, :enumerable, false) -> + {:error, {:property_not_configurable, "length"}} + + true -> + with :ok <- array_length_writable(object), + {:ok, length} <- array_length(value), + {:ok, object} <- resize_array(object, length) do + writable = Keyword.get(opts, :writable, object.length_writable) + {:array_length, %{object | length_writable: writable}} + end + end + end + + defp define_property(object, key, value, opts) do + property = property(value, opts) + + case validate_definition(object, key, property) do + {:ok, object} -> {:ok, object, property} + {:error, reason} -> {:error, reason} + end + end + + defp validate_definition(object, key, candidate) do + case own_property_value(object, key) do + %Descriptor{configurable: false} = current -> + validate_fixed_definition(object, key, current, candidate) + + nil when not object.extensible -> + {:error, {:object_not_extensible, key}} + + _current -> + validate_array_growth(object, key) + end + end + + defp validate_fixed_definition(object, key, current, candidate) do + with :ok <- keep_nonconfigurable(candidate, key), + :ok <- keep_enumerability(current, candidate, key), + :ok <- keep_descriptor_kind(current, candidate, key), + :ok <- keep_accessor_functions(current, candidate, key), + :ok <- keep_readonly_value(current, candidate, key) do + {:ok, object} + end + end + + defp keep_nonconfigurable(%Descriptor{configurable: true}, key), + do: {:error, {:property_not_configurable, key}} + + defp keep_nonconfigurable(_candidate, _key), do: :ok + + defp keep_enumerability(current, candidate, key) do + if property_enumerable?(candidate) == property_enumerable?(current), + do: :ok, + else: {:error, {:property_not_configurable, key}} + end + + defp keep_descriptor_kind(current, candidate, key) do + if accessor?(candidate) == accessor?(current), + do: :ok, + else: {:error, {:property_not_configurable, key}} + end + + defp keep_accessor_functions(%Descriptor{kind: :accessor} = current, candidate, key) do + if candidate.getter == current.getter and candidate.setter == current.setter, + do: :ok, + else: {:error, {:property_not_configurable, key}} + end + + defp keep_accessor_functions(_current, _candidate, _key), do: :ok + + defp keep_readonly_value(%Descriptor{kind: :data, writable: false} = current, candidate, key) do + if not candidate.writable and candidate.value == current.value, + do: :ok, + else: {:error, {:property_not_writable, key}} + end + + defp keep_readonly_value(_current, _candidate, _key), do: :ok + + defp validate_array_growth( + %Object{kind: :array, length: length, length_writable: false}, + key + ) + when is_integer(key) and key >= length, + do: {:error, {:property_not_writable, "length"}} + + defp validate_array_growth(object, _key), do: {:ok, object} + + defp store_descriptor(execution, id, object, key, property) do + with {:ok, object} <- validate_definition(object, key, property) do + execution = maybe_charge_property(execution, object, key, property.value) + object = put_property_struct(object, key, property) + {:ok, %{execution | heap: Map.put(execution.heap, id, object)}} + end + end + + defp accessor?(%Descriptor{kind: :accessor}), do: true + defp accessor?(_property), do: false + + defp property(value, opts) do + %Descriptor{ + value: value, + writable: Keyword.get(opts, :writable, true), + enumerable: Keyword.get(opts, :enumerable, true), + configurable: Keyword.get(opts, :configurable, true) + } + end + + defp put_property(object, key, value) do + case Map.get(object.properties, key) do + {_old_value} -> put_default_property(object, key, value) + %Descriptor{} = property -> put_property_struct(object, key, %{property | value: value}) + nil -> put_default_property(object, key, value) + end + end + + defp put_property_struct(%Object{} = object, key, property) do + if default_data_property?(property) do + put_default_property(object, key, property.value) + else + object = remember_property(object, key) + %{object | properties: Map.put(object.properties, key, property)} + end + end + + defp put_default_property(object, key, value) do + object = remember_property(object, key) + object = %{object | properties: Map.put(object.properties, key, {value})} + + if object.kind == :array and is_integer(key) and key >= 0, + do: %{object | length: max(object.length, key + 1)}, + else: object + end + + defp remember_property(object, key) when is_integer(key), do: object + + defp remember_property(object, key) do + if Map.has_key?(object.properties, key), + do: object, + else: %{object | property_order: object.property_order ++ [key]} + end + + defp inherited_property(_execution, nil, _key, _depth), do: nil + + defp inherited_property(_execution, _prototype, _key, depth) + when depth > @max_prototype_depth, + do: %Descriptor{writable: false} + + defp inherited_property(execution, %Reference{} = prototype, key, depth) do + case fetch_object(execution, prototype) do + {:ok, object} -> + own_property_value(object, key) || + inherited_property(execution, object.prototype, key, depth + 1) + + :error -> + nil + end + end + + defp default_data_property?(%Descriptor{ + kind: :data, + writable: true, + enumerable: true, + configurable: true, + getter: nil, + setter: nil + }), + do: true + + defp default_data_property?(_property), do: false + + defp own_property_value(object, key), do: Map.get(object.properties, key) + + defp property_enumerable?({_value}), do: true + defp property_enumerable?(%Descriptor{enumerable: enumerable}), do: enumerable + + defp current_accessor(%Descriptor{} = property, field), do: Map.fetch!(property, field) + defp current_accessor({_value}, _field), do: nil + defp current_accessor(nil, _field), do: nil + + defp current_flag(%Descriptor{} = property, field, _default), do: Map.fetch!(property, field) + defp current_flag({_value}, _field, _default), do: true + defp current_flag(nil, _field, default), do: default + + defp array_length_writable(%Object{length_writable: true}), do: :ok + defp array_length_writable(_object), do: {:error, {:property_not_writable, "length"}} + + defp array_length(value) + when is_integer(value) and value >= 0 and value <= 4_294_967_295, + do: {:ok, value} + + defp array_length(value) + when is_float(value) and value >= 0 and value <= 4_294_967_295 and trunc(value) == value, + do: {:ok, trunc(value)} + + defp array_length(_value), do: {:error, :invalid_array_length} + + defp resize_array(object, length) when length >= object.length, + do: {:ok, %{object | length: length}} + + defp resize_array(object, length) do + removed = + object.properties + |> Map.keys() + |> Enum.filter(&(is_integer(&1) and &1 >= length)) + + if Enum.any?(removed, &match?(%Descriptor{configurable: false}, object.properties[&1])) do + {:error, :nonconfigurable_array_element} + else + {:ok, + %{ + object + | length: length, + properties: Map.drop(object.properties, removed), + property_order: object.property_order -- removed + }} + end + end + + defp normalize_key(key) when is_integer(key) and key in 0..4_294_967_294, do: key + defp normalize_key(key) when is_integer(key), do: Integer.to_string(key) + + defp normalize_key(key) + when is_float(key) and key >= 0 and key <= 4_294_967_294 and trunc(key) == key, + do: trunc(key) + + defp normalize_key(key) when is_float(key) and trunc(key) == key, + do: Integer.to_string(trunc(key)) + + defp normalize_key(<> = key) when first in ?0..?9 do + case Integer.parse(key) do + {index, ""} when index in 0..4_294_967_294 -> + if Integer.to_string(index) == key, do: index, else: key + + _ -> + key + end + end + + defp normalize_key(key) when is_binary(key), do: key + defp normalize_key(key), do: key +end diff --git a/lib/quickbeam/vm/runtime/interpreter.ex b/lib/quickbeam/vm/runtime/interpreter.ex new file mode 100644 index 000000000..3e7fe5e0a --- /dev/null +++ b/lib/quickbeam/vm/runtime/interpreter.ex @@ -0,0 +1,1093 @@ +defmodule QuickBEAM.VM.Runtime.Interpreter do + @moduledoc """ + Executes verified QuickJS bytecode with explicit JavaScript machine state. + + Frames, callers, exception unwinding, async boundaries, and native callback + frames are represented as data so execution can suspend without retaining an + Elixir or native call stack. + """ + + alias QuickBEAM.VM.Builtin.Array, as: ArrayBuiltin + alias QuickBEAM.VM.Builtin.Registry + alias QuickBEAM.VM.Builtin.Runtime, as: BuiltinRuntime + alias QuickBEAM.VM.Builtin.Set, as: SetBuiltin + alias QuickBEAM.VM.Bytecode.Opcode + alias QuickBEAM.VM.Program + alias QuickBEAM.VM.Runtime.Async + alias QuickBEAM.VM.Runtime.Boundary + alias QuickBEAM.VM.Runtime.Continuation + alias QuickBEAM.VM.Runtime.Coroutine + alias QuickBEAM.VM.Runtime.Exception + alias QuickBEAM.VM.Runtime.Frame + alias QuickBEAM.VM.Runtime.Frame.Native + alias QuickBEAM.VM.Runtime.Heap + alias QuickBEAM.VM.Runtime.Invocation + alias QuickBEAM.VM.Runtime.Iterator + alias QuickBEAM.VM.Runtime.Memory + alias QuickBEAM.VM.Runtime.Opcode.Control, as: ControlOpcodes + alias QuickBEAM.VM.Runtime.Opcode.Invocation, as: InvocationOpcodes + alias QuickBEAM.VM.Runtime.Opcode.Local, as: LocalOpcodes + alias QuickBEAM.VM.Runtime.Opcode.Object, as: ObjectOpcodes + alias QuickBEAM.VM.Runtime.Opcode.Stack, as: StackOpcodes + alias QuickBEAM.VM.Runtime.Opcode.Value, as: ValueOpcodes + alias QuickBEAM.VM.Runtime.Optimization + alias QuickBEAM.VM.Runtime.Promise + alias QuickBEAM.VM.Runtime.Promise.Reaction + alias QuickBEAM.VM.Runtime.Promise.Reference, as: PromiseReference + alias QuickBEAM.VM.Runtime.Property + alias QuickBEAM.VM.Runtime.Reference + alias QuickBEAM.VM.Runtime.RegExp + alias QuickBEAM.VM.Runtime.State + alias QuickBEAM.VM.Runtime.Value + alias QuickBEAM.VM.Runtime.Value.Export + + @default_max_steps 5_000_000 + @default_max_stack_depth 1_000 + @host_template_cache {__MODULE__, :host_templates} + @host_override_names ["Beam", "globalThis"] + @host_user_names ["Infinity", "NaN", "undefined"] + + @control_opcodes ControlOpcodes.opcodes() + @invocation_opcodes InvocationOpcodes.opcodes() + @local_opcodes LocalOpcodes.opcodes() + @object_opcodes ObjectOpcodes.opcodes() + @stack_opcodes StackOpcodes.opcodes() + @value_opcodes ValueOpcodes.opcodes() + + @type result :: + {:ok, term()} + | {:error, term()} + | {:suspended, Continuation.t()} + + @doc "Evaluates a verified program and exports its final result." + @spec eval(Program.t(), keyword()) :: result() + def eval(%Program{} = program, opts \\ []), do: program |> start(opts) |> finish() + + @doc "Initializes the canonical owner-local frame and execution state." + @spec initialize(Program.t(), keyword()) :: {Frame.t(), State.t()} + def initialize(%Program{} = program, opts \\ []) do + max_steps = Keyword.get(opts, :max_steps, @default_max_steps) + vars = Map.new(Keyword.get(opts, :vars, %{})) + + execution = + State.new( + atoms: program.atoms, + globals: vars, + handlers: Map.new(Keyword.get(opts, :handlers, %{})), + max_stack_depth: Keyword.get(opts, :max_stack_depth, @default_max_stack_depth), + memory_limit: Keyword.get(opts, :memory_limit, :infinity), + measurement_target: Keyword.get(opts, :measurement_target), + remaining_steps: max_steps, + step_limit: max_steps + ) + + execution = Memory.charge(execution, Memory.estimate(vars)) + execution = install_host_globals(execution, Keyword.get(opts, :profile, :core)) + frame = Invocation.new_frame(program.root, program.root, [], :undefined, {}) + {frame, execution} + end + + @doc "Starts interpreting a program and returns its raw machine result." + def start(%Program{} = program, opts \\ []) do + {frame, execution} = initialize(program, opts) + run(frame, execution) + end + + @doc "Invokes a named global after program initialization in the same owner-local state." + @spec invoke_global(Program.t(), String.t(), [term()], State.t()) :: term() + def invoke_global(%Program{} = program, name, arguments, %State{} = execution) + when is_binary(name) and is_list(arguments) do + caller = Invocation.new_frame(program.root, program.root, [], :undefined, {}) + execution = Memory.charge(execution, Memory.estimate(arguments)) + + if execution.memory_exceeded do + {:error, {:limit_exceeded, :memory_bytes, execution.memory_limit}, execution} + else + case LocalOpcodes.read_global(execution, name) do + {:ok, callable} -> + this = Map.get(execution.globals, "globalThis", :undefined) + dispatch_call(callable, arguments, this, caller, execution, true) + + :error -> + raise_js_from_caller({:reference_error, name}, caller, execution) + end + end + end + + @doc "Runs one canonical frame and execution state through the machine loop." + @spec run_frame(Frame.t(), State.t()) :: term() + def run_frame(%Frame{} = frame, %State{} = execution), do: run(frame, execution) + + @doc "Resumes an explicit generated-code invocation through canonical dispatch." + @spec resume_compiler_invoke(term(), [term()], term(), Frame.t(), State.t()) :: term() + def resume_compiler_invoke( + callable, + arguments, + this, + %Frame{} = caller, + %State{} = execution + ), + do: dispatch_call(callable, arguments, this, caller, execution, false) + + @doc "Resumes a suspended continuation and exports its final result." + @spec resume(Continuation.t(), {:ok, term()} | {:error, term()}) :: result() + def resume(%Continuation{} = continuation, result), + do: continuation |> resume_raw(result) |> finish() + + @doc "Resumes a validated owner-local compiler deoptimization." + @spec resume_deopt(struct()) :: result() + def resume_deopt(%{frame: %Frame{}, execution: %State{}} = deopt), + do: deopt |> resume_deopt_raw() |> finish() + + @doc "Resumes compiler deoptimization without exporting the resulting value." + @spec resume_deopt_raw(struct()) :: + {:ok, term(), State.t()} | {:error, term(), State.t()} | {:suspended, term()} + def resume_deopt_raw(%{frame: %Frame{}, execution: %State{}} = deopt) do + case Optimization.validate_deopt(deopt) do + :ok -> + frame = + if deopt.frame.compiler_allow_reentry, + do: %{deopt.frame | compiler_reentry_after_instruction: true}, + else: deopt.frame + + run(frame, deopt.execution) + + {:error, reason} -> + {:error, {:invalid_compiler_deopt, reason}, deopt.execution} + end + end + + @doc "Resumes a legacy continuation without exporting the resulting value." + def resume_raw(%Continuation{} = continuation, {:ok, value}) do + frame = %{continuation.frame | stack: [value | continuation.frame.stack]} + run(frame, continuation.execution) + end + + def resume_raw(%Continuation{} = continuation, {:error, reason}) do + raise_js_from_caller(reason, continuation.frame, continuation.execution) + end + + @doc "Resumes a detached async coroutine with a Promise settlement." + def resume_coroutine(%Coroutine{} = coroutine, result, %State{} = execution), + do: coroutine |> Async.resume_coroutine(result, execution) |> execute_async() + + @doc "Reads a thenable's accessor-backed `then` property during Promise resolution." + def read_thenable(promise, thenable, getter, %State{} = execution), + do: promise |> Async.read_thenable(thenable, getter, nil, execution) |> execute_async() + + @doc "Runs one pending synchronous Promise-resolution job, when present." + def run_synchronous_job(%State{} = execution) do + case :queue.out(execution.sync_jobs) do + {{:value, {:read_thenable, promise, thenable, getter}}, sync_jobs} -> + execution = put_sync_jobs(execution, sync_jobs) + promise |> Async.read_thenable(thenable, getter, nil, execution) |> execute_async() + + {:empty, sync_jobs} -> + {:none, put_sync_jobs(execution, sync_jobs)} + end + end + + @doc "Invokes a thenable and connects its resolver functions to a Promise." + def assimilate_thenable(promise, thenable, callable, %State{} = execution), + do: promise |> Async.assimilate_thenable(thenable, callable, execution) |> execute_async() + + @doc "Runs one queued Promise reaction against a source settlement." + def run_reaction(%Reaction{} = reaction, result, %State{} = execution), + do: reaction |> Async.run_reaction(result, execution) |> execute_async() + + @doc "Converts a raw machine result into the interpreter's public result." + def finish({:ok, value, execution}), do: Export.value(value, execution) + def finish({:error, reason, _execution}), do: {:error, reason} + def finish({:suspended, continuation}), do: {:suspended, continuation} + def finish({:idle, _execution}), do: {:error, :idle_evaluation} + + defp enter_planned_call( + function, + callable, + closure_refs, + arguments, + this, + caller, + execution, + tail? + ) do + if function.func_kind == 2 do + enter_async_call( + function, + callable, + closure_refs, + arguments, + this, + caller, + execution, + tail? + ) + else + enter_sync_call( + function, + callable, + closure_refs, + arguments, + this, + caller, + execution, + tail? + ) + end + end + + defp enter_sync_call(function, callable, closure_refs, args, this, caller, execution, tail?) do + depth = if tail?, do: execution.depth, else: execution.depth + 1 + + if depth > execution.max_stack_depth do + {:error, {:limit_exceeded, :stack_depth, depth}, execution} + else + execution = + if tail?, + do: execution, + else: %{execution | callers: [caller | execution.callers], depth: depth} + + run(Invocation.new_frame(function, callable, args, this, closure_refs), execution) + end + end + + defp enter_async_call(function, callable, closure_refs, args, this, caller, execution, tail?) do + function + |> Async.enter(callable, closure_refs, args, this, caller, execution, tail?) + |> execute_async() + end + + defp execute_async({:run, frame, execution}), do: run(frame, execution) + + defp execute_async({:raise, reason, frame, execution}), + do: raise_js_from_caller(reason, frame, execution) + + defp execute_async({:invoke, callable, arguments, this, caller, execution, tail?}), + do: dispatch_call(callable, arguments, this, caller, execution, tail?) + + defp execute_async({:complete, value, caller, execution, tail?}), + do: complete_call_result(value, caller, execution, tail?) + + defp execute_async({:return, value, execution}), do: return_value(value, execution) + + defp execute_async({:continue_iterator, boundary, execution}), + do: continue_iterator_sync(boundary, execution) + + defp execute_async({:idle, execution}), do: {:idle, execution} + defp execute_async({:suspended, continuation}), do: {:suspended, continuation} + defp execute_async({:error, reason, execution}), do: {:error, reason, execution} + + defp run(frame, %State{sync_jobs_pending?: true} = execution) do + case :queue.out(execution.sync_jobs) do + {{:value, {:read_thenable, promise, thenable, getter}}, sync_jobs} -> + execution = put_sync_jobs(execution, sync_jobs) + promise |> Async.read_thenable(thenable, getter, frame, execution) |> execute_async() + + {:empty, sync_jobs} -> + run(frame, put_sync_jobs(execution, sync_jobs)) + end + end + + defp run(_frame, %State{memory_exceeded: true} = execution), + do: {:error, {:limit_exceeded, :memory_bytes, execution.memory_limit}, execution} + + defp run(_frame, %State{remaining_steps: 0} = execution), + do: {:error, {:limit_exceeded, :steps, execution.step_limit}, execution} + + defp run(%Frame{pc: pc, function: function}, execution) + when pc >= tuple_size(function.instructions), + do: {:error, {:invalid_program_counter, pc}, execution} + + defp run( + %Frame{compiler_reentry_after_instruction: true} = frame, + %State{} = execution + ) do + frame = %{frame | compiler_entered: false, compiler_reentry_after_instruction: false} + execution = Optimization.increment(execution, :reentries) + execute_current(frame, execution) + end + + defp run( + %Frame{compiler_entered: false} = frame, + %State{compiler_context: compiler_context} = execution + ) + when not is_nil(compiler_context) do + frame = %{frame | compiler_entered: true} + + case Optimization.execute_frame(frame, execution) do + {:deopt, deopt} = action -> + if Optimization.deopt?(deopt, execution), + do: resume_deopt_raw(deopt), + else: {:error, {:compiler_error, {:invalid_generated_action, action}}, execution} + + {:invoke, callable, arguments, this, caller, execution, false} -> + dispatch_call(callable, arguments, this, caller, execution, false) + + {:skip, frame, execution} -> + run(frame, execution) + + {:error, reason} -> + {:error, {:compiler_error, reason}, execution} + + action -> + {:error, {:compiler_error, {:invalid_generated_action, action}}, execution} + end + end + + defp run(%Frame{} = frame, %State{} = execution), do: execute_current(frame, execution) + + defp execute_current( + frame, + %State{compiler_context: %{instrumentation: nil}} = execution + ) do + {opcode, operands} = elem(frame.function.instructions, frame.pc) + execute_current_opcode(opcode, operands, frame, execution) + end + + defp execute_current(frame, %State{compiler_context: context} = execution) + when not is_nil(context) do + {opcode, operands} = elem(frame.function.instructions, frame.pc) + + execution = + execution + |> Optimization.observe(frame) + |> Optimization.interpreted_opcode(opcode) + + execute_current_opcode(opcode, operands, frame, execution) + end + + defp execute_current(frame, execution) do + {opcode, operands} = elem(frame.function.instructions, frame.pc) + execute_current_opcode(opcode, operands, frame, execution) + end + + defp execute_current_opcode(opcode, operands, frame, execution) do + {name, _size, _pops, _pushes, _format} = Opcode.info(opcode) + {name, operands} = Opcode.expand_short_form(name, operands, frame.function.arg_count) + execution = %{execution | remaining_steps: execution.remaining_steps - 1} + execute(name, operands, frame, execution) + end + + defp execute(name, operands, frame, execution) when name in @control_opcodes, + do: name |> ControlOpcodes.execute(operands, frame, execution) |> execute_opcode() + + defp execute(name, operands, frame, execution) when name in @invocation_opcodes, + do: name |> InvocationOpcodes.execute(operands, frame, execution) |> execute_opcode() + + defp execute(name, operands, frame, execution) when name in @local_opcodes, + do: name |> LocalOpcodes.execute(operands, frame, execution) |> execute_opcode() + + defp execute(name, operands, frame, execution) when name in @object_opcodes, + do: name |> ObjectOpcodes.execute(operands, frame, execution) |> execute_opcode() + + defp execute(name, operands, frame, execution) when name in @stack_opcodes, + do: name |> StackOpcodes.execute(operands, frame, execution) |> execute_opcode() + + defp execute(name, operands, frame, execution) when name in @value_opcodes, + do: name |> ValueOpcodes.execute(operands, frame, execution) |> execute_opcode() + + defp execute(name, _operands, frame, execution) + when name in [:nop, :set_name, :set_name_computed], + do: continue(frame, execution) + + defp execute(name, operands, _frame, execution), + do: {:error, {:unsupported_opcode, name, operands}, execution} + + defp dispatch_call(callable, arguments, this, caller, execution, tail?) do + callable + |> Invocation.plan(arguments, this, caller, execution, tail?) + |> execute_invocation() + end + + defp execute_invocation({:dispatch, callable, arguments, this, caller, execution, tail?}), + do: dispatch_call(callable, arguments, this, caller, execution, tail?) + + defp execute_invocation( + {:enter, function, callable, closure_refs, arguments, this, caller, execution, tail?} + ), + do: + enter_planned_call( + function, + callable, + closure_refs, + arguments, + this, + caller, + execution, + tail? + ) + + defp execute_invocation({:complete, value, caller, execution, tail?}), + do: complete_call_result(value, caller, execution, tail?) + + defp execute_invocation({:error, reason, caller, execution}), + do: raise_js_from_caller(reason, caller, execution) + + defp execute_invocation({:host_call, arguments, caller, execution, tail?}), + do: start_host_call(arguments, caller, execution, tail?) + + defp execute_invocation({:object_assign, target, sources, caller, execution, tail?}) do + boundary = %Boundary.ObjectAssign{ + target: target, + sources: sources, + caller: caller, + depth: execution.depth, + tail?: tail? + } + + continue_object_assign(boundary, execution) + end + + defp execute_invocation( + {:array_iteration, method, receiver, arguments, caller, execution, tail?} + ), + do: start_array_iteration(method, receiver, arguments, caller, execution, tail?) + + defp execute_invocation({:promise_iterate, kind, iterable, caller, execution, tail?}), + do: kind |> Iterator.start(iterable, caller, execution, tail?) |> execute_invocation() + + defp execute_invocation({:set_iterate, target, iterable, caller, execution, tail?}), + do: target |> Iterator.start_set(iterable, caller, execution, tail?) |> execute_invocation() + + defp execute_invocation({:initialize_set, target, values, caller, execution, tail?}) do + case SetBuiltin.initialize(target, values, execution) do + {:ok, execution} -> + execute_invocation({:complete, target, caller, execution, tail?}) + + {:error, reason} -> + execute_invocation({:error, reason, caller, execution}) + end + end + + defp execute_invocation( + {:iterator_value, value, %Boundary.Iterator{consumer: :promise} = boundary, execution} + ) do + {source, execution} = Promise.from_value(execution, value) + boundary = %{boundary | values: [source | boundary.values]} + continue_iterator_sync(boundary, execution) + end + + defp execute_invocation( + {:iterator_value, value, %Boundary.Iterator{consumer: :set} = boundary, execution} + ) do + boundary = %{boundary | values: [value | boundary.values]} + boundary |> Iterator.continue(execution) |> execute_invocation() + end + + defp complete_call_result(value, %Boundary.Iterator{} = boundary, execution, _tail?), + do: value |> Iterator.resume(boundary, execution) |> execute_invocation() + + defp complete_call_result(value, %Boundary.Reaction{} = boundary, execution, _tail?), + do: complete_reaction(boundary, value, execution) + + defp complete_call_result( + value, + %Boundary.ObjectAssign{phase: :get} = boundary, + execution, + _tail? + ), + do: assign_object_value(boundary, boundary.key, value, execution) + + defp complete_call_result( + _value, + %Boundary.ObjectAssign{phase: :set} = boundary, + execution, + _tail? + ), + do: continue_object_assign(%{boundary | phase: nil, key: nil}, execution) + + defp complete_call_result(value, %Boundary.ThenGetter{} = boundary, execution, _tail?), + do: complete_then_getter(value, boundary, execution) + + defp complete_call_result(value, %Boundary.Accessor{} = boundary, execution, _tail?), + do: complete_accessor(value, boundary, execution) + + defp complete_call_result(value, %Boundary.Constructor{} = boundary, execution, _tail?), + do: complete_constructor(value, boundary, execution) + + defp complete_call_result(_value, %Boundary.PromiseExecutor{} = boundary, execution, _tail?), + do: complete_executor(boundary, execution) + + defp complete_call_result(_value, %Boundary.Thenable{}, execution, _tail?), + do: {:idle, execution} + + defp complete_call_result(value, %Native{} = native, execution, _tail?), + do: resume_native(value, native, execution) + + defp complete_call_result(value, caller, execution, tail?) do + if tail?, + do: return_value(value, execution), + else: run(%{caller | stack: [value | caller.stack]}, execution) + end + + defp complete_then_getter(value, boundary, execution), + do: value |> Async.complete_then_getter(boundary, execution) |> execute_async() + + defp continue_after_then_getter( + %Boundary.ThenGetter{continuation: %Frame{} = frame}, + execution + ), + do: run(frame, execution) + + defp continue_after_then_getter( + %Boundary.ThenGetter{continuation: %Boundary.Iterator{} = boundary}, + execution + ), + do: continue_iterator_sync(boundary, execution) + + defp continue_after_then_getter(%Boundary.ThenGetter{continuation: nil}, execution), + do: {:idle, execution} + + defp continue_iterator_sync(boundary, execution) do + case :queue.out(execution.sync_jobs) do + {{:value, {:read_thenable, promise, thenable, getter}}, sync_jobs} -> + execution = put_sync_jobs(execution, sync_jobs) + + promise + |> Async.read_thenable(thenable, getter, boundary, execution) + |> execute_async() + + {:empty, sync_jobs} -> + boundary |> Iterator.continue(put_sync_jobs(execution, sync_jobs)) |> execute_invocation() + end + end + + defp complete_accessor(value, %Boundary.Accessor{mode: :get} = boundary, execution), + do: run(%{boundary.caller | stack: [value | boundary.caller.stack]}, execution) + + defp complete_accessor(_value, %Boundary.Accessor{mode: :set} = boundary, execution), + do: run(boundary.caller, execution) + + defp complete_constructor(value, boundary, execution) do + result = + if constructor_object?(value), + do: value, + else: boundary.instance + + complete_call_result(result, boundary.caller, execution, false) + end + + defp constructor_object?(value), + do: + is_struct(value, Reference) or is_struct(value, PromiseReference) or + is_struct(value, RegExp) or is_map(value) or is_list(value) + + defp complete_executor(boundary, execution) do + complete_call_result(boundary.promise, boundary.caller, execution, boundary.tail?) + end + + defp complete_reaction(boundary, value, execution), + do: boundary |> Async.complete_reaction(value, execution) |> execute_async() + + defp continue_object_assign(%Boundary.ObjectAssign{keys: [key | keys]} = boundary, execution) do + case Property.get(boundary.source, key, execution) do + {:ok, {:accessor, getter, receiver}} -> + boundary = %{boundary | phase: :get, key: key, keys: keys} + dispatch_call(getter, [], receiver, boundary, execution, false) + + {:ok, value} -> + assign_object_value(%{boundary | keys: keys}, key, value, execution) + + {:error, reason} -> + raise_js_from_caller({:type_error, reason}, boundary, execution) + end + end + + defp continue_object_assign( + %Boundary.ObjectAssign{keys: [], sources: [source | sources]} = boundary, + execution + ) do + case Property.assignable_keys(source, execution) do + {:ok, keys} -> + continue_object_assign( + %{boundary | source: source, sources: sources, keys: keys, phase: nil, key: nil}, + execution + ) + + {:error, reason} -> + raise_js_from_caller({:type_error, reason}, boundary, execution) + end + end + + defp continue_object_assign( + %Boundary.ObjectAssign{keys: [], sources: []} = boundary, + execution + ), + do: complete_call_result(boundary.target, boundary.caller, execution, boundary.tail?) + + defp assign_object_value(boundary, key, value, execution) do + case Property.put(boundary.target, key, value, execution) do + {:ok, execution} -> + continue_object_assign(%{boundary | phase: nil, key: nil}, execution) + + {:error, {:invoke_setter, setter}} -> + boundary = %{boundary | phase: :set, key: key} + dispatch_call(setter, [value], boundary.target, boundary, execution, false) + + {:error, reason} -> + raise_js_from_caller({:type_error, reason}, boundary, execution) + end + end + + defp start_array_iteration(method, receiver, arguments, caller, execution, tail?) do + with {:ok, value_list} <- interpreter_array_values(receiver, execution), + [callback | rest] <- arguments do + values = List.to_tuple(value_list) + + operation = + case method do + "map" -> :map + "filter" -> :filter + "forEach" -> :for_each + "some" -> :some + "reduce" -> :reduce + end + + native = %Native{ + operation: operation, + values: values, + callback: callback, + receiver: receiver, + caller: caller, + tail?: tail? + } + + case initialize_native(native, rest) do + {:ok, native} -> + invoke_native_next(native, execution) + + {:error, :reduce_of_empty_array} -> + raise_js_from_caller({:type_error, :reduce_of_empty_array}, caller, execution) + end + else + {:error, reason} -> raise_js_from_caller({:type_error, reason}, caller, execution) + [] -> raise_js_from_caller({:type_error, :missing_callback}, caller, execution) + end + end + + defp initialize_native(%Native{operation: :reduce} = native, [initial | _]), + do: {:ok, %{native | accumulator: initial}} + + defp initialize_native(%Native{operation: :reduce} = native, []) do + case next_present(native.values, 0) do + {:ok, index, value} -> {:ok, %{native | accumulator: value, index: index + 1}} + :none -> {:error, :reduce_of_empty_array} + end + end + + defp initialize_native(%Native{} = native, _arguments), do: {:ok, native} + + defp next_present(values, index) when index >= tuple_size(values), do: :none + + defp next_present(values, index) do + case elem(values, index) do + :hole -> next_present(values, index + 1) + {:present, value} -> {:ok, index, value} + end + end + + defp invoke_native_next(%Native{} = native, execution) + when native.index >= tuple_size(native.values) do + finish_native(native, native_result(native, execution), execution) + end + + defp invoke_native_next(%Native{} = native, execution) do + case elem(native.values, native.index) do + :hole -> + results = if native.operation == :map, do: [:hole | native.results], else: native.results + invoke_native_next(%{native | index: native.index + 1, results: results}, execution) + + {:present, value} -> + arguments = + if native.operation == :reduce, + do: [native.accumulator, value, native.index, native.receiver], + else: [value, native.index, native.receiver] + + dispatch_call(native.callback, arguments, :undefined, native, execution, false) + end + end + + defp resume_native(value, %Native{} = native, execution) do + {:present, current} = elem(native.values, native.index) + + native = + case native.operation do + :map -> + %{native | index: native.index + 1, results: [{:present, value} | native.results]} + + :filter -> + %{ + native + | index: native.index + 1, + results: + if( + Value.truthy?(value), + do: [{:present, current} | native.results], + else: native.results + ) + } + + :for_each -> + %{native | index: native.index + 1} + + :some -> + %{native | index: native.index + 1, accumulator: Value.truthy?(value)} + + :reduce -> + %{native | index: native.index + 1, accumulator: value} + end + + if native.operation == :some and native.accumulator, + do: finish_native(native, {:value, true, execution}, execution), + else: invoke_native_next(native, execution) + end + + defp native_result(%Native{operation: operation, results: results}, execution) + when operation in [:map, :filter] do + allocate_array(Enum.reverse(results), execution) + end + + defp native_result(%Native{operation: :for_each}, execution), + do: {:value, :undefined, execution} + + defp native_result(%Native{operation: :some}, execution), do: {:value, false, execution} + + defp native_result(%Native{operation: :reduce, accumulator: value}, execution), + do: {:value, value, execution} + + defp finish_native(native, {:value, value, execution}, _old_execution) do + if native.tail?, + do: return_value(value, execution), + else: run(%{native.caller | stack: [value | native.caller.stack]}, execution) + end + + defp allocate_array(entries, execution) do + {array, execution} = ArrayBuiltin.from_entries(entries, execution) + {:value, array, execution} + end + + defp interpreter_array_values(value, _execution) when is_list(value), + do: {:ok, Enum.map(value, &{:present, &1})} + + defp interpreter_array_values(%Reference{} = reference, execution) do + case Heap.fetch_object(execution, reference) do + {:ok, %QuickBEAM.VM.Runtime.Object{kind: :array} = object} -> + {:ok, Heap.array_entries(object)} + + _ -> + {:error, :not_an_array} + end + end + + defp interpreter_array_values(_value, _execution), do: {:error, :not_an_array} + + defp put_sync_jobs(execution, sync_jobs), + do: %{execution | sync_jobs: sync_jobs, sync_jobs_pending?: not :queue.is_empty(sync_jobs)} + + defp continue(frame, execution), do: run(next_frame(frame), execution) + defp next_frame(frame), do: %{frame | pc: frame.pc + 1} + + defp execute_opcode({:next, frame, execution}), do: continue(frame, execution) + defp execute_opcode({:run, frame, execution}), do: run(frame, execution) + defp execute_opcode({:return, value, execution}), do: return_value(value, execution) + defp execute_opcode({:return_async, value, execution}), do: complete_async(value, execution) + + defp execute_opcode({:throw, reason, frame, execution}), + do: raise_js(reason, frame, execution) + + defp execute_opcode({:error, reason, execution}), do: {:error, reason, execution} + + defp execute_opcode({:invoke, callable, arguments, this, caller, execution, tail?}), + do: dispatch_call(callable, arguments, this, next_frame(caller), execution, tail?) + + defp execute_opcode({:invoke_constructor, constructor, arguments, instance, caller, execution}) do + boundary = %Boundary.Constructor{ + instance: instance, + caller: next_frame(caller), + depth: execution.depth + } + + dispatch_call(constructor, arguments, instance, boundary, execution, false) + end + + defp execute_opcode( + {:invoke_super_constructor, constructor, arguments, instance, caller, execution} + ) do + boundary = %Boundary.Constructor{ + instance: instance, + caller: next_frame(caller), + depth: execution.depth + } + + dispatch_call(constructor, arguments, instance, boundary, execution, false) + end + + defp execute_opcode({:await_promise, promise, frame, execution}) do + case detach_async(frame, execution, promise) do + {:ok, result} -> result + :no_async_boundary -> suspend_promise_legacy(frame, execution, promise) + end + end + + defp execute_opcode({:await_legacy, reference, frame, execution}) do + continuation = %Continuation{ + frame: next_frame(frame), + execution: execution, + awaiting: reference + } + + {:suspended, continuation} + end + + defp execute_opcode({:await_immediate, result, frame, execution}), + do: await_immediate(result, frame, execution) + + defp execute_opcode({:invoke_getter, getter, receiver, frame, execution}) do + boundary = %Boundary.Accessor{ + mode: :get, + caller: next_frame(frame), + depth: execution.depth + } + + dispatch_call(getter, [], receiver, boundary, execution, false) + end + + defp execute_opcode({:invoke_setter, setter, value, object, frame, execution}) do + boundary = %Boundary.Accessor{ + mode: :set, + caller: next_frame(frame), + depth: execution.depth + } + + dispatch_call(setter, [value], object, boundary, execution, false) + end + + defp detach_async(frame, execution, awaited_promise) do + case Async.detach_await(next_frame(frame), execution, awaited_promise) do + {:ok, action} -> {:ok, execute_async(action)} + :no_async_boundary -> :no_async_boundary + end + end + + defp await_immediate(result, frame, execution) do + case detach_async_immediate(frame, execution, result) do + {:ok, detached} -> detached + :no_async_boundary -> suspend_microtask(result, frame, execution) + end + end + + defp detach_async_immediate(frame, execution, result) do + case Async.detach_immediate(next_frame(frame), execution, result) do + {:ok, action} -> {:ok, execute_async(action)} + :no_async_boundary -> :no_async_boundary + end + end + + defp suspend_promise_legacy(frame, execution, promise), + do: frame |> next_frame() |> Async.suspend_promise(execution, promise) |> execute_async() + + defp suspend_microtask(result, frame, execution), + do: frame |> next_frame() |> Async.suspend_microtask(execution, result) |> execute_async() + + defp install_host_globals(execution, profile) do + template = host_template(profile) + user_globals = execution.globals + validate_host_global_conflicts!(user_globals, template.globals) + + globals = + template.globals + |> Map.merge(user_globals) + |> Map.put("Beam", Map.fetch!(template.globals, "Beam")) + |> Map.put("globalThis", Map.fetch!(template.globals, "globalThis")) + + execution = %{ + execution + | default_prototypes: template.default_prototypes, + error_prototypes: template.error_prototypes, + globals: globals, + heap: template.heap, + next_cell_id: template.next_cell_id, + next_object_id: template.next_object_id, + next_promise_id: template.next_promise_id, + next_symbol_id: template.next_symbol_id + } + + Memory.charge(execution, template.memory_used) + end + + defp host_template(profile) do + key = {@host_template_cache, profile} + generation = Registry.generation() + + case :persistent_term.get(key, :missing) do + {^generation, template} -> + template + + _missing_or_stale -> + template = build_host_template(profile) + :persistent_term.put(key, {generation, template}) + template + end + end + + defp build_host_template(profile) do + execution = + State.new( + atoms: {}, + max_stack_depth: 1, + remaining_steps: 1, + step_limit: 1 + ) + + build_host_globals(execution, profile) + end + + defp build_host_globals(execution, profile) do + execution = BuiltinRuntime.install(execution, profile) + {beam, execution} = Heap.allocate(execution) + + {:ok, execution} = + Property.define(beam, "call", {:host_function, :beam_call}, execution) + + {global_this, execution} = Heap.allocate(execution, :ordinary, internal: :global_object) + + globals = + execution.globals + |> Map.put_new("Infinity", :infinity) + |> Map.put_new("NaN", :nan) + |> Map.put_new("undefined", :undefined) + |> Map.put("Beam", beam) + |> Map.put("globalThis", global_this) + + %{execution | globals: globals} + end + + defp validate_host_global_conflicts!(user_globals, template_globals) do + protected_names = Map.keys(template_globals) -- (@host_override_names ++ @host_user_names) + + case Enum.find(protected_names, &Map.has_key?(user_globals, &1)) do + nil -> :ok + name -> raise ArgumentError, "builtin #{name} conflicts with an installed global" + end + end + + defp start_host_call(arguments, caller, execution, tail?) do + case Async.start_host_call(arguments, execution) do + {:ok, promise, execution} -> + complete_call_result(promise, caller, execution, tail?) + + {:error, {:limit_exceeded, _kind, _limit} = reason, execution} -> + {:error, reason, execution} + + {:error, reason, execution} -> + raise_js_from_caller(reason, caller, execution) + end + end + + defp raise_js(reason, frame, execution), + do: reason |> Exception.throw_at(frame, execution) |> execute_exception() + + defp raise_js_from_caller(reason, caller, execution), + do: reason |> Exception.throw_from(caller, execution) |> execute_exception() + + defp execute_exception({:run, frame, execution}), do: run(frame, execution) + + defp execute_exception({:resume_then_getter, boundary, execution}), + do: continue_after_then_getter(boundary, execution) + + defp execute_exception({:complete, value, caller, execution, tail?}), + do: complete_call_result(value, caller, execution, tail?) + + defp execute_exception({:async, action}), do: execute_async(action) + defp execute_exception({:idle, execution}), do: {:idle, execution} + defp execute_exception({:error, error, execution}), do: {:error, error, execution} + + defp complete_async( + value, + %State{callers: [%Boundary.Async{} = boundary | callers]} = execution + ) do + execution = %{execution | callers: callers, depth: boundary.depth} + boundary |> Async.complete({:ok, value}, execution) |> execute_async() + end + + defp complete_async(value, execution), do: return_value(value, execution) + + defp return_value(value, %State{callers: []} = execution), + do: {:ok, value, %{execution | depth: 0}} + + defp return_value(value, %State{callers: [%Boundary.Async{} | _]} = execution), + do: complete_async(value, execution) + + defp return_value( + value, + %State{callers: [%Boundary.ObjectAssign{} = boundary | callers]} = execution + ) do + execution = %{execution | callers: callers, depth: boundary.depth} + complete_call_result(value, boundary, execution, false) + end + + defp return_value( + value, + %State{callers: [%Boundary.ThenGetter{} = boundary | callers]} = execution + ) do + execution = %{execution | callers: callers, depth: boundary.depth} + complete_then_getter(value, boundary, execution) + end + + defp return_value( + value, + %State{callers: [%Boundary.Accessor{} = boundary | callers]} = execution + ) do + execution = %{execution | callers: callers, depth: boundary.depth} + complete_accessor(value, boundary, execution) + end + + defp return_value( + value, + %State{callers: [%Boundary.Constructor{} = boundary | callers]} = execution + ) do + execution = %{execution | callers: callers, depth: boundary.depth} + complete_constructor(value, boundary, execution) + end + + defp return_value( + value, + %State{callers: [%Boundary.Iterator{} = boundary | callers]} = execution + ) do + execution = %{execution | callers: callers, depth: boundary.depth} + value |> Iterator.resume(boundary, execution) |> execute_invocation() + end + + defp return_value( + value, + %State{callers: [%Boundary.Reaction{} = boundary | callers]} = execution + ) do + execution = %{execution | callers: callers, depth: boundary.depth} + complete_reaction(boundary, value, execution) + end + + defp return_value( + _value, + %State{callers: [%Boundary.PromiseExecutor{} = boundary | callers]} = execution + ) do + execution = %{execution | callers: callers, depth: boundary.depth} + complete_executor(boundary, execution) + end + + defp return_value( + _value, + %State{callers: [%Boundary.Thenable{} = boundary | callers]} = execution + ) do + {:idle, %{execution | callers: callers, depth: boundary.depth}} + end + + defp return_value(value, %State{callers: [%Native{} = native | callers]} = execution) do + execution = %{execution | callers: callers, depth: execution.depth - 1} + resume_native(value, native, execution) + end + + defp return_value(value, %State{callers: [%Frame{} = caller | callers]} = execution) do + execution = %{execution | callers: callers, depth: execution.depth - 1} + run(%{caller | stack: [value | caller.stack]}, execution) + end +end diff --git a/lib/quickbeam/vm/runtime/invocation.ex b/lib/quickbeam/vm/runtime/invocation.ex new file mode 100644 index 000000000..254307bc7 --- /dev/null +++ b/lib/quickbeam/vm/runtime/invocation.ex @@ -0,0 +1,229 @@ +defmodule QuickBEAM.VM.Runtime.Invocation do + @moduledoc """ + Classifies every JavaScript invocation through one canonical call boundary. + + The module resolves references, bound functions, Promise methods and + resolvers, built-ins, constructors, and ordinary bytecode functions into + explicit actions. `QuickBEAM.VM.Interpreter` executes those actions because it + owns frame scheduling, exception unwinding, and resumable native boundaries. + """ + + alias QuickBEAM.VM.Builtin + alias QuickBEAM.VM.Builtin.Action + alias QuickBEAM.VM.Builtin.Call + alias QuickBEAM.VM.Builtin.Runtime, as: BuiltinRuntime + alias QuickBEAM.VM.Program.Function + alias QuickBEAM.VM.Runtime.Boundary + alias QuickBEAM.VM.Runtime.Callable + alias QuickBEAM.VM.Runtime.Frame + alias QuickBEAM.VM.Runtime.Heap + alias QuickBEAM.VM.Runtime.Promise + alias QuickBEAM.VM.Runtime.Property + alias QuickBEAM.VM.Runtime.Reference + alias QuickBEAM.VM.Runtime.State + + @builtin_tags [:builtin, :declared_builtin, :primitive_method] + + @type action :: + {:dispatch, term(), [term()], term(), term(), State.t(), boolean()} + | {:enter, Function.t(), term(), tuple(), [term()], term(), term(), State.t(), + boolean()} + | {:complete, term(), term(), State.t(), boolean()} + | {:error, term(), term(), State.t()} + | {:host_call, [term()], term(), State.t(), boolean()} + | {:object_assign, Reference.t(), [term()], term(), State.t(), boolean()} + | {:array_iteration, String.t(), term(), [term()], term(), State.t(), boolean()} + | {:promise_iterate, atom(), term(), term(), State.t(), boolean()} + | {:set_iterate, Reference.t(), term(), term(), State.t(), boolean()} + | {:iterator_value, term(), QuickBEAM.VM.Runtime.Boundary.Iterator.t(), State.t()} + + @doc "Plans one invocation without executing interpreter frames." + @spec plan(term(), [term()], term(), term(), State.t(), boolean()) :: action() + def plan(callable, arguments, this, caller, execution, tail? \\ false) + + def plan({:host_function, :beam_call}, arguments, _this, caller, execution, tail?), + do: {:host_call, arguments, caller, execution, tail?} + + def plan( + {:declared_builtin, _module, _handler} = callable, + arguments, + this, + caller, + execution, + tail? + ) do + call = %Call{ + arguments: arguments, + this: this, + caller: caller, + tail?: tail?, + execution: execution + } + + case Builtin.invoke(callable, call) do + {:ok, value, execution} -> {:complete, value, caller, execution, tail?} + {:error, reason, execution} -> {:error, {:type_error, reason}, caller, execution} + %Action{value: action} -> action + end + end + + def plan({:promise_resolver, promise, kind}, arguments, _this, caller, execution, tail?) do + value = Enum.at(arguments, 0, :undefined) + result = if kind in [:resolve, :resolve_assimilated], do: {:ok, value}, else: {:error, value} + + execution = + if kind in [:resolve_assimilated, :reject_assimilated], + do: Promise.settle_assimilated(execution, promise, result), + else: Promise.settle(execution, promise, result) + + {:complete, :undefined, caller, execution, tail?} + end + + def plan( + {:bound_function, target, _bound_this, bound_arguments}, + arguments, + this, + %Boundary.Constructor{} = caller, + execution, + tail? + ), + do: {:dispatch, target, bound_arguments ++ arguments, this, caller, execution, tail?} + + def plan( + {:bound_function, target, bound_this, bound_arguments}, + arguments, + _this, + caller, + execution, + tail? + ), + do: {:dispatch, target, bound_arguments ++ arguments, bound_this, caller, execution, tail?} + + def plan(%Reference{} = reference, arguments, this, caller, execution, tail?) do + case BuiltinRuntime.callable(execution, reference) do + nil -> + {:error, {:not_callable, reference}, caller, execution} + + callable when elem(callable, 0) in @builtin_tags -> + {:dispatch, callable, arguments, this, caller, execution, tail?} + + callable -> + enter_action(callable, arguments, this, caller, execution, tail?, reference) + end + end + + def plan( + {:primitive_method, :array, method}, + arguments, + receiver, + caller, + execution, + tail? + ) + when method in ["filter", "forEach", "map", "reduce", "some"], + do: {:array_iteration, method, receiver, arguments, caller, execution, tail?} + + def plan(callable, arguments, this, caller, execution, tail?) + when is_tuple(callable) and elem(callable, 0) in @builtin_tags do + case BuiltinRuntime.call(callable, this, arguments, execution) do + {:ok, value, execution} -> {:complete, value, caller, execution, tail?} + {:error, reason, execution} -> {:error, {:type_error, reason}, caller, execution} + end + end + + def plan(callable, arguments, this, caller, execution, tail?), + do: enter_action(callable, arguments, this, caller, execution, tail?, nil) + + @doc "Returns whether a value can be used as a JavaScript constructor." + @spec constructable?(term(), State.t()) :: boolean() + def constructable?(%Reference{} = constructor, execution) do + case Heap.fetch_object(execution, constructor) do + {:ok, %{internal: :class_constructor}} -> + true + + _other -> + case BuiltinRuntime.callable(execution, constructor) do + nil -> false + callable -> constructable?(callable, execution) + end + end + end + + def constructable?(%Function{has_prototype: has_prototype}, _execution), do: has_prototype + + def constructable?({:closure, %Function{has_prototype: has_prototype}, _refs}, _execution), + do: has_prototype + + def constructable?({:bound_function, target, _this, _arguments}, execution), + do: constructable?(target, execution) + + def constructable?({:declared_builtin, _module, _handler} = callable, _execution), + do: Builtin.constructable?(callable) + + def constructable?(_constructor, _execution), do: false + + @doc "Returns the object prototype used for a constructor allocation." + @spec constructor_prototype(term(), State.t()) :: Reference.t() | nil + def constructor_prototype({:bound_function, target, _this, _arguments}, execution), + do: constructor_prototype(target, execution) + + def constructor_prototype(constructor, execution) do + case Property.get(constructor, "prototype", execution) do + {:ok, %Reference{} = prototype} -> prototype + _other -> nil + end + end + + @doc "Returns the prototype used by the ordinary `instanceof` algorithm." + @spec instanceof_prototype(term(), State.t()) :: Property.get_result() + def instanceof_prototype({:bound_function, target, _this, _arguments}, execution), + do: instanceof_prototype(target, execution) + + def instanceof_prototype(constructor, execution), + do: Property.get(constructor, "prototype", execution) + + @doc "Returns whether a VM value is callable by JavaScript." + @spec callable?(term(), State.t()) :: boolean() + defdelegate callable?(value, execution), to: Callable + + @doc "Returns the JavaScript `typeof` classification for a VM value." + @spec typeof(term(), State.t()) :: String.t() + defdelegate typeof(value, execution), to: Callable + + @doc "Builds a fresh explicit frame for an ordinary bytecode function call." + @spec new_frame(Function.t(), term(), [term()], term(), tuple()) :: Frame.t() + def new_frame(function, callable, arguments, this, closure_refs) do + local_count = max(function.arg_count + function.var_count, 1) + actual_arg_count = length(arguments) + missing_arguments = max(function.arg_count - actual_arg_count, 0) + arguments = arguments ++ List.duplicate(:undefined, missing_arguments) + + %Frame{ + function: function, + callable: callable, + closure_refs: closure_refs, + locals: :erlang.make_tuple(local_count, :undefined), + args: List.to_tuple(arguments), + actual_arg_count: actual_arg_count, + this: this + } + end + + defp enter_action(callable, arguments, this, caller, execution, tail?, frame_callable) do + case callable_parts(callable) do + {:ok, function, closure_refs} -> + {:enter, function, frame_callable || callable, closure_refs, arguments, this, caller, + execution, tail?} + + {:error, reason} -> + {:error, reason, caller, execution} + end + end + + defp callable_parts(%Function{} = function), do: {:ok, function, {}} + + defp callable_parts({:closure, %Function{} = function, closure_refs}), + do: {:ok, function, closure_refs} + + defp callable_parts(value), do: {:error, {:not_callable, value}} +end diff --git a/lib/quickbeam/vm/runtime/iterator.ex b/lib/quickbeam/vm/runtime/iterator.ex new file mode 100644 index 000000000..a8febbc0c --- /dev/null +++ b/lib/quickbeam/vm/runtime/iterator.ex @@ -0,0 +1,253 @@ +defmodule QuickBEAM.VM.Runtime.Iterator do + @moduledoc """ + Implements the canonical iterable-value boundary used by Promise combinators. + + Arrays, sets, strings, and internal BEAM lists can be collected immediately. + JavaScript objects are consumed through `Symbol.iterator` with explicit + resumable boundaries for getters, the iterator factory, `next`, `done`, and + `value`. No JavaScript call is executed recursively. + """ + + alias QuickBEAM.VM.Runtime.Boundary + alias QuickBEAM.VM.Runtime.Exception + alias QuickBEAM.VM.Runtime.Heap + alias QuickBEAM.VM.Runtime.Invocation + alias QuickBEAM.VM.Runtime.Object + alias QuickBEAM.VM.Runtime.Promise + alias QuickBEAM.VM.Runtime.Property + alias QuickBEAM.VM.Runtime.Reference + alias QuickBEAM.VM.Runtime.State + alias QuickBEAM.VM.Runtime.Symbol + alias QuickBEAM.VM.Runtime.Value + + @type action :: + Invocation.action() + | {:initialize_set, Reference.t(), [term()], term(), State.t(), boolean()} + + @doc "Collects values from an iterable whose protocol requires no JavaScript calls." + @spec values(term(), State.t()) :: {:ok, [term()]} | {:resumable} | {:error, :not_iterable} + def values(value, _execution) when is_list(value), do: {:ok, value} + def values(value, _execution) when is_binary(value), do: {:ok, String.codepoints(value)} + + def values(%Reference{} = reference, execution) do + case Heap.fetch_object(execution, reference) do + {:ok, %Object{kind: :array} = object} -> + values = + Enum.map(Heap.array_entries(object), fn + {:present, value} -> value + :hole -> :undefined + end) + + {:ok, values} + + {:ok, %Object{kind: :map, internal: %{entries: entries}}} -> + {:ok, Enum.map(entries, fn {key, value} -> [key, value] end)} + + {:ok, %Object{kind: :set, internal: %{values: values}}} -> + {:ok, values} + + {:ok, %Object{}} -> + {:resumable} + + _other -> + {:error, :not_iterable} + end + end + + def values(_value, _execution), do: {:error, :not_iterable} + + @doc "Starts resumable consumption of a JavaScript iterator for a Promise combinator." + @spec start(atom(), term(), term(), State.t(), boolean()) :: action() + def start(kind, iterable, caller, execution, tail?) do + {promise, execution} = Promise.new(execution) + + boundary = %Boundary.Iterator{ + consumer: :promise, + kind: kind, + promise: promise, + iterable: iterable, + caller: caller, + depth: execution.depth, + tail?: tail? + } + + read_iterator_method(boundary, execution) + end + + @doc "Starts resumable consumption for a Set constructor." + @spec start_set(Reference.t(), term(), term(), State.t(), boolean()) :: action() + def start_set(target, iterable, caller, execution, tail?) do + boundary = %Boundary.Iterator{ + consumer: :set, + target: target, + iterable: iterable, + caller: caller, + depth: execution.depth, + tail?: tail? + } + + read_iterator_method(boundary, execution) + end + + @doc "Resumes iterator consumption after one getter or method invocation." + @spec resume(term(), Boundary.Iterator.t(), State.t()) :: action() + def resume(value, %Boundary.Iterator{phase: :iterator_getter} = boundary, execution), + do: invoke_iterator_factory(%{boundary | phase: nil}, value, execution) + + def resume(iterator, %Boundary.Iterator{phase: :iterator_factory} = boundary, execution) do + if object?(iterator) do + read_next(%{boundary | iterator: iterator, phase: nil}, execution) + else + reject(boundary, {:type_error, :iterator_result_not_object}, execution) + end + end + + def resume(value, %Boundary.Iterator{phase: :next_getter} = boundary, execution), + do: invoke_next(%{boundary | phase: nil}, value, execution) + + def resume(result, %Boundary.Iterator{phase: :next_call} = boundary, execution) do + if object?(result) do + read_done(%{boundary | result: result, phase: nil}, execution) + else + reject(boundary, {:type_error, :iterator_result_not_object}, execution) + end + end + + def resume(done, %Boundary.Iterator{phase: :done_getter} = boundary, execution), + do: after_done(%{boundary | phase: nil}, done, execution) + + def resume(value, %Boundary.Iterator{phase: :value_getter} = boundary, execution), + do: {:iterator_value, value, %{boundary | phase: nil}, execution} + + @doc "Continues with `next` after synchronous value-resolution work finishes." + @spec continue(Boundary.Iterator.t(), State.t()) :: action() + def continue(%Boundary.Iterator{} = boundary, execution), + do: next_iteration(boundary, execution) + + @doc "Rejects the combinator Promise and returns it to the original caller." + @spec reject(Boundary.Iterator.t(), term(), State.t()) :: action() + def reject(%Boundary.Iterator{consumer: :promise} = boundary, reason, execution), + do: reject_promise(boundary, reason, execution) + + def reject(%Boundary.Iterator{consumer: :set} = boundary, reason, execution), + do: {:error, reason, boundary.caller, execution} + + @doc "Handles a JavaScript throw raised while an iterator boundary is active." + @spec fail(Boundary.Iterator.t(), term(), State.t()) :: action() + def fail(%Boundary.Iterator{} = boundary, reason, execution), + do: reject(boundary, reason, execution) + + defp read_iterator_method(boundary, execution) do + case Property.get(boundary.iterable, Symbol.iterator(), execution) do + {:ok, {:accessor, getter, receiver}} -> + dispatch_getter(boundary, :iterator_getter, getter, receiver, execution) + + {:ok, method} -> + invoke_iterator_factory(boundary, method, execution) + + {:error, reason} -> + reject(boundary, reason, execution) + end + end + + defp invoke_iterator_factory(boundary, method, execution) do + if Invocation.callable?(method, execution) do + dispatch(boundary, :iterator_factory, method, [], boundary.iterable, execution) + else + reject(boundary, {:type_error, :not_iterable}, execution) + end + end + + defp read_next(boundary, execution) do + case Property.get(boundary.iterator, "next", execution) do + {:ok, {:accessor, getter, receiver}} -> + dispatch_getter(boundary, :next_getter, getter, receiver, execution) + + {:ok, next} -> + invoke_next(boundary, next, execution) + + {:error, reason} -> + reject(boundary, reason, execution) + end + end + + defp invoke_next(boundary, next, execution) do + if Invocation.callable?(next, execution) do + dispatch(%{boundary | next: next}, :next_call, next, [], boundary.iterator, execution) + else + reject(boundary, {:type_error, :iterator_next_not_callable}, execution) + end + end + + defp next_iteration(boundary, execution), + do: dispatch(boundary, :next_call, boundary.next, [], boundary.iterator, execution) + + defp read_done(boundary, execution) do + case Property.get(boundary.result, "done", execution) do + {:ok, {:accessor, getter, receiver}} -> + dispatch_getter(boundary, :done_getter, getter, receiver, execution) + + {:ok, done} -> + after_done(boundary, done, execution) + + {:error, reason} -> + reject(boundary, reason, execution) + end + end + + defp after_done(boundary, done, execution) do + if Value.truthy?(done) do + finish(boundary, execution) + else + read_value(boundary, execution) + end + end + + defp finish(%Boundary.Iterator{consumer: :promise} = boundary, execution) do + values = Enum.reverse(boundary.values) + execution = Promise.aggregate_into(execution, boundary.promise, boundary.kind, values) + complete(boundary, execution) + end + + defp finish(%Boundary.Iterator{consumer: :set} = boundary, execution) do + {:initialize_set, boundary.target, Enum.reverse(boundary.values), boundary.caller, execution, + boundary.tail?} + end + + defp read_value(boundary, execution) do + case Property.get(boundary.result, "value", execution) do + {:ok, {:accessor, getter, receiver}} -> + dispatch_getter(boundary, :value_getter, getter, receiver, execution) + + {:ok, value} -> + {:iterator_value, value, boundary, execution} + + {:error, reason} -> + reject(boundary, reason, execution) + end + end + + defp reject_promise(boundary, reason, execution) do + {reason, execution} = Exception.materialize(reason, execution) + execution = Promise.settle(execution, boundary.promise, {:error, reason}) + complete(boundary, execution) + end + + defp dispatch_getter(boundary, phase, getter, receiver, execution) do + if Invocation.callable?(getter, execution) do + dispatch(boundary, phase, getter, [], receiver, execution) + else + reject(boundary, {:type_error, :iterator_accessor_not_callable}, execution) + end + end + + defp dispatch(boundary, phase, callable, arguments, this, execution), + do: {:dispatch, callable, arguments, this, %{boundary | phase: phase}, execution, false} + + defp complete(boundary, execution), + do: {:complete, boundary.promise, boundary.caller, execution, boundary.tail?} + + defp object?(%Reference{}), do: true + defp object?(value) when is_map(value) and not is_struct(value), do: true + defp object?(_value), do: false +end diff --git a/lib/quickbeam/vm/runtime/memory.ex b/lib/quickbeam/vm/runtime/memory.ex new file mode 100644 index 000000000..b1e516bbc --- /dev/null +++ b/lib/quickbeam/vm/runtime/memory.ex @@ -0,0 +1,87 @@ +defmodule QuickBEAM.VM.Runtime.Memory do + @moduledoc """ + Performs conservative logical memory accounting for VM allocations. + + Accounting is monotonic until garbage collection is implemented and provides + controlled limit failures before the worker's process heap ceiling. + """ + + alias QuickBEAM.VM.Runtime.Object + alias QuickBEAM.VM.Runtime.State + + @object_bytes 128 + @property_bytes 64 + @cell_bytes 32 + @promise_bytes 64 + + @doc "Charges an exact number of logical bytes to an evaluation." + @spec charge(State.t(), non_neg_integer()) :: State.t() + def charge(%State{} = execution, bytes) when is_integer(bytes) and bytes >= 0 do + used = execution.memory_used + bytes + + exceeded = + execution.memory_exceeded or + (execution.memory_limit != :infinity and used > execution.memory_limit) + + %{execution | memory_used: used, memory_exceeded: exceeded} + end + + @doc "Charges allocation of one object value." + def charge_object(execution, object), do: charge(execution, @object_bytes + estimate(object)) + + @doc "Charges allocation of one property key and value." + def charge_property(execution, key, value), + do: charge(execution, @property_bytes + estimate(key) + estimate(value)) + + @doc "Charges allocation of one captured mutable cell." + def charge_cell(execution, value), do: charge(execution, @cell_bytes + estimate(value)) + + @doc "Charges allocation of one Promise record." + def charge_promise(execution), do: charge(execution, @promise_bytes) + + @doc "Returns the deterministic logical-size estimate for a VM value." + @spec estimate(term()) :: non_neg_integer() + def estimate(value) when value in [nil, true, false, :undefined], do: 8 + def estimate(value) when is_integer(value) or is_float(value), do: 16 + def estimate(value) when is_binary(value), do: 16 + byte_size(value) + def estimate(value) when is_atom(value), do: 8 + def estimate(value) when is_pid(value) or is_reference(value) or is_function(value), do: 16 + def estimate(%QuickBEAM.VM.Runtime.Reference{}), do: 16 + def estimate(%QuickBEAM.VM.Runtime.Promise.Reference{}), do: 16 + def estimate(%QuickBEAM.VM.Program.Function{}), do: 16 + + def estimate(%Object{} = object) do + 464 + + estimate(object.prototype) + + estimate(object.properties) + + estimate(object.property_order) + + estimate(object.callable) + + estimate(object.internal) + end + + def estimate({first, second}), do: 16 + estimate(first) + estimate(second) + + def estimate({first, second, third}), + do: 16 + estimate(first) + estimate(second) + estimate(third) + + def estimate({first, second, third, fourth}), + do: 16 + estimate(first) + estimate(second) + estimate(third) + estimate(fourth) + + def estimate(value) when is_tuple(value) do + 16 + Enum.reduce(Tuple.to_list(value), 0, &(estimate(&1) + &2)) + end + + def estimate(value) when is_list(value) do + Enum.reduce(value, 0, &(16 + estimate(&1) + &2)) + end + + def estimate(%_module{} = value), do: 32 + estimate(Map.from_struct(value)) + + def estimate(value) when is_map(value) do + Enum.reduce(value, 32, fn {key, child}, total -> + total + 32 + estimate(key) + estimate(child) + end) + end + + def estimate(_value), do: 32 +end diff --git a/lib/quickbeam/vm/runtime/object.ex b/lib/quickbeam/vm/runtime/object.ex new file mode 100644 index 000000000..e89518207 --- /dev/null +++ b/lib/quickbeam/vm/runtime/object.ex @@ -0,0 +1,47 @@ +defmodule QuickBEAM.VM.Runtime.Object do + @moduledoc """ + Defines an object stored in an evaluation-owned VM heap. + + Default data descriptors use the compact `{value}` storage form. Accessors and + non-default data descriptors retain full `QuickBEAM.VM.Runtime.Property.Descriptor` structs. + Callers must use the descriptor helpers instead of depending on either layout. + """ + + defstruct kind: :ordinary, + prototype: nil, + properties: %{}, + property_order: [], + extensible: true, + length: 0, + length_writable: true, + callable: nil, + internal: nil + + @type kind :: :ordinary | :array | :function | :map | :promise | :regexp | :set + @type stored_property :: QuickBEAM.VM.Runtime.Property.Descriptor.t() | {term()} + @type t :: %__MODULE__{ + kind: kind(), + prototype: QuickBEAM.VM.Runtime.Reference.t() | nil, + properties: %{optional(term()) => stored_property()}, + property_order: [term()], + extensible: boolean(), + length: non_neg_integer(), + length_writable: boolean(), + callable: term() | nil, + internal: term() + } + + @doc "Expands a compact default data property into its canonical descriptor." + @spec property_descriptor(stored_property() | nil) :: + QuickBEAM.VM.Runtime.Property.Descriptor.t() | nil + def property_descriptor({value}), do: %QuickBEAM.VM.Runtime.Property.Descriptor{value: value} + def property_descriptor(%QuickBEAM.VM.Runtime.Property.Descriptor{} = property), do: property + def property_descriptor(nil), do: nil + + @doc "Tests whether a stored property is enumerable without forcing callers to know its layout." + @spec property_enumerable?(stored_property()) :: boolean() + def property_enumerable?({_value}), do: true + + def property_enumerable?(%QuickBEAM.VM.Runtime.Property.Descriptor{enumerable: enumerable}), + do: enumerable +end diff --git a/lib/quickbeam/vm/runtime/opcode/control.ex b/lib/quickbeam/vm/runtime/opcode/control.ex new file mode 100644 index 000000000..e857b43df --- /dev/null +++ b/lib/quickbeam/vm/runtime/opcode/control.ex @@ -0,0 +1,103 @@ +defmodule QuickBEAM.VM.Runtime.Opcode.Control do + @moduledoc """ + Executes branch, catch, return, throw, and await opcode families. + + The module chooses explicit control actions but leaves frame execution, + instruction stepping, async scheduling, and exception unwinding to the + interpreter and their canonical semantic layers. + """ + + alias QuickBEAM.VM.Runtime.Frame + alias QuickBEAM.VM.Runtime.Promise.Reference, as: PromiseReference + alias QuickBEAM.VM.Runtime.State + alias QuickBEAM.VM.Runtime.Value + + @opcodes [ + :catch, + :gosub, + :ret, + :if_false, + :if_false8, + :if_true, + :if_true8, + :goto, + :goto8, + :goto16, + :return, + :return_undef, + :return_async, + :throw, + :await + ] + + @type action :: + {:next, Frame.t(), State.t()} + | {:run, Frame.t(), State.t()} + | {:return, term(), State.t()} + | {:return_async, term(), State.t()} + | {:throw, term(), Frame.t(), State.t()} + | {:await_promise, PromiseReference.t(), Frame.t(), State.t()} + | {:await_legacy, reference(), Frame.t(), State.t()} + | {:await_immediate, {:ok, term()} | {:error, term()}, Frame.t(), State.t()} + + @doc "Returns the opcode names handled by this family." + @spec opcodes() :: [atom()] + def opcodes, do: @opcodes + + @doc "Executes one supported control-flow opcode." + @spec execute(atom(), [term()], Frame.t(), State.t()) :: action() + def execute(:catch, [target], frame, execution), + do: next(%{frame | stack: [{:catch, target} | frame.stack]}, execution) + + def execute(:gosub, [target], frame, execution) do + return_address = {:return_address, frame.pc + 1} + {:run, %{frame | pc: target, stack: [return_address | frame.stack]}, execution} + end + + def execute(:ret, [], %{stack: [{:return_address, target} | stack]} = frame, execution), + do: {:run, %{frame | pc: target, stack: stack}, execution} + + def execute(name, [target], %{stack: [value | stack]} = frame, execution) + when name in [:if_false, :if_false8] do + pc = if Value.truthy?(value), do: frame.pc + 1, else: target + {:run, %{frame | pc: pc, stack: stack}, execution} + end + + def execute(name, [target], %{stack: [value | stack]} = frame, execution) + when name in [:if_true, :if_true8] do + pc = if Value.truthy?(value), do: target, else: frame.pc + 1 + {:run, %{frame | pc: pc, stack: stack}, execution} + end + + def execute(name, [target], frame, execution) when name in [:goto, :goto8, :goto16], + do: {:run, %{frame | pc: target}, execution} + + def execute(:return, [], %{stack: [value | _stack]}, execution), + do: {:return, value, execution} + + def execute(:return_undef, [], _frame, execution), + do: {:return, :undefined, execution} + + def execute(:return_async, [], %{stack: [value | _stack]}, execution), + do: {:return_async, value, execution} + + def execute(:throw, [], %{stack: [value | stack]} = frame, execution), + do: {:throw, value, %{frame | stack: stack}, execution} + + def execute(:await, [], %{stack: [%PromiseReference{} = promise | stack]} = frame, execution), + do: {:await_promise, promise, %{frame | stack: stack}, execution} + + def execute(:await, [], %{stack: [{:pending, reference} | stack]} = frame, execution), + do: {:await_legacy, reference, %{frame | stack: stack}, execution} + + def execute(:await, [], %{stack: [{:resolved, value} | stack]} = frame, execution), + do: {:await_immediate, {:ok, value}, %{frame | stack: stack}, execution} + + def execute(:await, [], %{stack: [{:rejected, reason} | stack]} = frame, execution), + do: {:await_immediate, {:error, reason}, %{frame | stack: stack}, execution} + + def execute(:await, [], %{stack: [value | stack]} = frame, execution), + do: {:await_immediate, {:ok, value}, %{frame | stack: stack}, execution} + + defp next(frame, execution), do: {:next, frame, execution} +end diff --git a/lib/quickbeam/vm/runtime/opcode/invocation.ex b/lib/quickbeam/vm/runtime/opcode/invocation.ex new file mode 100644 index 000000000..c2caa1e53 --- /dev/null +++ b/lib/quickbeam/vm/runtime/opcode/invocation.ex @@ -0,0 +1,161 @@ +defmodule QuickBEAM.VM.Runtime.Opcode.Invocation do + @moduledoc """ + Executes ordinary, method, tail, and constructor call opcodes. + + The module decodes call operands from explicit frame stacks and prepares + invocation actions. Callable resolution and call planning remain in + `QuickBEAM.VM.Invocation`; the interpreter remains responsible for advancing + continuation frames and executing invocation actions. + """ + + alias QuickBEAM.VM.Runtime.Frame + alias QuickBEAM.VM.Runtime.Heap + alias QuickBEAM.VM.Runtime.Invocation + alias QuickBEAM.VM.Runtime.Iterator + alias QuickBEAM.VM.Runtime.State + + @opcodes [ + :apply, + :call, + :tail_call, + :call_method, + :tail_call_method, + :call_constructor, + :check_ctor, + :init_ctor + ] + + @type action :: + {:invoke, term(), [term()], term(), Frame.t(), State.t(), boolean()} + | {:invoke_constructor, term(), [term()], term(), Frame.t(), State.t()} + | {:invoke_super_constructor, term(), [term()], term(), Frame.t(), State.t()} + | {:throw, term(), Frame.t(), State.t()} + | {:error, term(), State.t()} + + @doc "Returns the opcode names handled by this family." + @spec opcodes() :: [atom()] + def opcodes, do: @opcodes + + @doc "Executes one supported invocation opcode." + @spec execute(atom(), [term()], Frame.t(), State.t()) :: action() + def execute(:check_ctor, [], %Frame{callable: callable, this: this} = frame, execution) do + with %QuickBEAM.VM.Runtime.Reference{} <- callable, + {:ok, %{internal: :class_constructor}} <- Heap.fetch_object(execution, callable), + %QuickBEAM.VM.Runtime.Reference{} <- this, + {:ok, %{internal: :constructor_instance}} <- Heap.fetch_object(execution, this) do + {:next, frame, execution} + else + _other -> {:throw, {:type_error, :class_constructor_requires_new}, frame, execution} + end + end + + def execute( + :apply, + [constructor?], + %Frame{stack: [argument_list, this, callable | stack]} = frame, + execution + ) do + case Iterator.values(argument_list, execution) do + {:ok, arguments} when constructor? == 0 -> + {:invoke, callable, arguments, this, %{frame | stack: stack}, execution, false} + + {:ok, arguments} when constructor? == 1 -> + if Invocation.constructable?(callable, execution) do + prototype = Invocation.constructor_prototype(callable, execution) + + {instance, execution} = + Heap.allocate(execution, :ordinary, + prototype: prototype, + internal: :constructor_instance + ) + + {:invoke_constructor, callable, arguments, instance, %{frame | stack: stack}, execution} + else + {:throw, {:type_error, :not_a_constructor}, frame, execution} + end + + {:error, reason} -> + {:throw, {:type_error, reason}, frame, execution} + + {:resumable} -> + {:throw, {:type_error, :unsupported_resumable_apply}, frame, execution} + end + end + + def execute(name, [argument_count], %Frame{stack: stack} = frame, execution) + when name in [:call, :tail_call] do + {arguments, callable_and_rest} = Enum.split(stack, argument_count) + + case callable_and_rest do + [callable | rest] -> + tail? = name == :tail_call + caller = %{frame | stack: rest} + {:invoke, callable, Enum.reverse(arguments), :undefined, caller, execution, tail?} + + _ -> + {:error, {:invalid_stack, :call}, execution} + end + end + + def execute(name, [argument_count], %Frame{stack: stack} = frame, execution) + when name in [:call_method, :tail_call_method] do + {arguments, callable_and_this} = Enum.split(stack, argument_count) + + case callable_and_this do + [callable, this | rest] -> + tail? = name == :tail_call_method + caller = %{frame | stack: rest} + {:invoke, callable, Enum.reverse(arguments), this, caller, execution, tail?} + + _ -> + {:error, {:invalid_stack, :call_method}, execution} + end + end + + def execute( + :init_ctor, + [], + %Frame{callable: %QuickBEAM.VM.Runtime.Reference{} = callable} = frame, + execution + ) do + with {:ok, parent} <- Heap.prototype(execution, callable), + true <- Invocation.constructable?(parent, execution) do + prototype = Invocation.constructor_prototype(callable, execution) + + {instance, execution} = + Heap.allocate(execution, :ordinary, + prototype: prototype, + internal: :constructor_instance + ) + + {:invoke_super_constructor, parent, Tuple.to_list(frame.args), instance, frame, execution} + else + _other -> {:throw, {:type_error, :invalid_super_constructor}, frame, execution} + end + end + + def execute(:call_constructor, [argument_count], %Frame{stack: stack} = frame, execution) do + {arguments, constructor_and_new_target} = Enum.split(stack, argument_count) + + case constructor_and_new_target do + [_new_target, constructor | rest] -> + if Invocation.constructable?(constructor, execution) do + prototype = Invocation.constructor_prototype(constructor, execution) + + {instance, execution} = + Heap.allocate(execution, :ordinary, + prototype: prototype, + internal: :constructor_instance + ) + + caller = %{frame | stack: rest} + {:invoke_constructor, constructor, Enum.reverse(arguments), instance, caller, execution} + else + {:throw, {:type_error, :not_a_constructor}, frame, execution} + end + + _ -> + {:error, {:invalid_stack, :call_constructor}, execution} + end + end +end diff --git a/lib/quickbeam/vm/runtime/opcode/local.ex b/lib/quickbeam/vm/runtime/opcode/local.ex new file mode 100644 index 000000000..89a411129 --- /dev/null +++ b/lib/quickbeam/vm/runtime/opcode/local.ex @@ -0,0 +1,424 @@ +defmodule QuickBEAM.VM.Runtime.Opcode.Local do + @moduledoc """ + Executes argument, local, closure-cell, function-closure, and global opcodes. + + Mutable cells and globals remain fields of the owner-local execution state. + The module returns explicit frame actions and never owns interpreter stepping. + """ + + alias QuickBEAM.VM.Bytecode.Atom, as: AtomTable + alias QuickBEAM.VM.Program.Function + alias QuickBEAM.VM.Runtime.Frame + alias QuickBEAM.VM.Runtime.Heap + alias QuickBEAM.VM.Runtime.Memory + alias QuickBEAM.VM.Runtime.Property + alias QuickBEAM.VM.Runtime.Reference + alias QuickBEAM.VM.Runtime.State + alias QuickBEAM.VM.Runtime.Value + + @get_reference_ops [ + :get_var_ref, + :get_var_ref0, + :get_var_ref1, + :get_var_ref2, + :get_var_ref3, + :get_var_ref_check + ] + + @compact_operations [ + :get_arg, + :put_arg, + :set_arg, + :get_loc, + :get_loc0_loc1, + :inc_loc, + :dec_loc, + :add_loc, + :put_loc, + :set_loc, + :set_loc_uninitialized, + :put_loc_check_init, + :put_loc_check + ] + + @put_reference_ops [ + :put_var_ref, + :put_var_ref0, + :put_var_ref1, + :put_var_ref2, + :put_var_ref3, + :put_var_ref_check, + :put_var_ref_check_init + ] + + @opcodes [ + :push_atom_value, + :rest, + :get_arg, + :put_arg, + :set_arg, + :get_loc, + :get_loc0_loc1, + :put_loc, + :set_loc, + :set_loc_uninitialized, + :get_loc_check, + :put_loc_check_init, + :put_loc_check, + :close_loc, + :inc_loc, + :dec_loc, + :add_loc, + :fclosure, + :fclosure8, + :set_var_ref, + :get_var, + :get_var_undef, + :put_var, + :put_var_init, + :define_func, + :define_var, + :check_define_var + ] ++ @get_reference_ops ++ @put_reference_ops + + @type action :: + {:next, Frame.t(), State.t()} + | {:throw, term(), Frame.t(), State.t()} + + @doc "Returns the opcode names handled by this family." + @spec opcodes() :: [atom()] + def opcodes, do: @opcodes + + @doc "Executes one supported local, closure, argument, or global opcode." + @spec execute(atom(), [term()], Frame.t(), State.t()) :: action() + def execute(:push_atom_value, [atom], frame, execution), + do: push(frame, execution, resolve_atom(atom, execution)) + + def execute(:rest, [first], frame, execution) do + values = + frame.args |> Tuple.to_list() |> Enum.take(frame.actual_arg_count) |> Enum.drop(first) + + {array, execution} = Heap.allocate(execution, :array) + + execution = + values + |> Enum.with_index() + |> Enum.reduce(execution, fn {value, index}, execution -> + {:ok, execution} = Property.define(array, index, value, execution) + execution + end) + + push(frame, execution, array) + end + + def execute(name, operands, %Frame{} = frame, %State{} = execution) + when name in @compact_operations do + {:ok, args, locals, stack, execution} = + execute_compact(name, operands, frame.args, frame.locals, frame.stack, execution) + + next(%{frame | args: args, locals: locals, stack: stack}, execution) + end + + def execute(:get_loc_check, [index], frame, execution) do + case read_slot(elem(frame.locals, index), execution) do + :uninitialized -> {:throw, {:reference_error, index}, frame, execution} + value -> push(frame, execution, value) + end + end + + def execute(:close_loc, [_index], frame, execution), do: next(frame, execution) + + def execute(name, [index], frame, execution) when name in @get_reference_ops do + value = read_reference(elem(frame.closure_refs, index), execution) + + if name == :get_var_ref_check and value == :uninitialized, + do: {:throw, {:reference_error, index}, frame, execution}, + else: push(frame, execution, value) + end + + def execute(name, [index], %{stack: [value | stack]} = frame, execution) + when name in @put_reference_ops do + execution = write_reference(elem(frame.closure_refs, index), value, execution) + next(%{frame | stack: stack}, execution) + end + + def execute(:set_var_ref, [index], %{stack: [value | _]} = frame, execution) do + execution = write_reference(elem(frame.closure_refs, index), value, execution) + next(frame, execution) + end + + def execute(:get_var, [atom], frame, execution) do + name = resolve_atom(atom, execution) + + case read_global(execution, name) do + {:ok, value} -> push(frame, execution, value) + :error -> {:throw, {:reference_error, name}, frame, execution} + end + end + + def execute(:get_var_undef, [atom], frame, execution) do + name = resolve_atom(atom, execution) + + value = + case read_global(execution, name) do + {:ok, value} -> value + :error -> :undefined + end + + push(frame, execution, value) + end + + def execute(name, [atom | _flags], %{stack: [value | stack]} = frame, execution) + when name in [:put_var, :put_var_init, :define_func] do + name = resolve_atom(atom, execution) + execution = write_global(execution, name, value) + next(%{frame | stack: stack}, execution) + end + + def execute(name, [_atom | _flags], frame, execution) + when name in [:define_var, :check_define_var], + do: next(frame, execution) + + def execute(name, [index], frame, execution) when name in [:fclosure, :fclosure8] do + function = Enum.at(frame.function.constants, index) + {reference, frame, execution} = instantiate_function(function, frame, execution) + push(frame, execution, reference) + end + + @doc "Instantiates a function constant and captures its owner-local closure cells." + @spec instantiate_function(Function.t(), Frame.t(), State.t(), keyword()) :: + {Reference.t(), Frame.t(), State.t()} + def instantiate_function(%Function{} = function, frame, execution, opts \\ []) do + {callable, frame, execution} = capture_closure(function, frame, execution) + + {reference, execution} = + if Keyword.get(opts, :prototype?, true) do + allocate_function(callable, function, execution) + else + Heap.allocate(execution, :function, callable: callable) + end + + {reference, frame, execution} + end + + @doc "Executes a verified local/argument operation over compact frame fields." + @spec execute_compact(atom(), [term()], tuple(), tuple(), [term()], State.t()) :: + {:ok, tuple(), tuple(), [term()], State.t()} + def execute_compact(:get_arg, [index], args, locals, stack, execution) do + value = read_slot(tuple_get(args, index), execution) + {:ok, args, locals, [value | stack], execution} + end + + def execute_compact(:put_arg, [index], args, locals, [value | stack], execution) do + {args, execution} = write_tuple_slot(args, index, value, execution) + {:ok, args, locals, stack, execution} + end + + def execute_compact(:set_arg, [index], args, locals, [value | _] = stack, execution) do + {args, execution} = write_tuple_slot(args, index, value, execution) + {:ok, args, locals, stack, execution} + end + + def execute_compact(:get_loc, [index], args, locals, stack, execution) do + value = read_slot(elem(locals, index), execution) + {:ok, args, locals, [value | stack], execution} + end + + def execute_compact(:get_loc0_loc1, [first, second], args, locals, stack, execution) do + first = read_slot(elem(locals, first), execution) + second = read_slot(elem(locals, second), execution) + {:ok, args, locals, [first, second | stack], execution} + end + + def execute_compact(name, [index], args, locals, stack, execution) + when name in [:inc_loc, :dec_loc] do + operation = if name == :inc_loc, do: :inc, else: :dec + value = locals |> elem(index) |> read_slot(execution) + value = Value.unary(operation, value) + {locals, execution} = write_tuple_slot(locals, index, value, execution) + {:ok, args, locals, stack, execution} + end + + def execute_compact(:add_loc, [index], args, locals, [value | stack], execution) do + current = read_slot(elem(locals, index), execution) + + {locals, execution} = + write_tuple_slot(locals, index, Value.binary(:add, current, value), execution) + + {:ok, args, locals, stack, execution} + end + + def execute_compact(:put_loc, [index], args, locals, [value | stack], execution) do + {locals, execution} = write_tuple_slot(locals, index, value, execution) + {:ok, args, locals, stack, execution} + end + + def execute_compact(:set_loc, [index], args, locals, [value | _] = stack, execution) do + {locals, execution} = write_tuple_slot(locals, index, value, execution) + {:ok, args, locals, stack, execution} + end + + def execute_compact(:set_loc_uninitialized, [index], args, locals, stack, execution) do + {locals, execution} = write_tuple_slot(locals, index, :uninitialized, execution) + {:ok, args, locals, stack, execution} + end + + def execute_compact(name, [index], args, locals, stack, execution) + when name in [:put_loc_check_init, :put_loc_check], + do: execute_compact(:put_loc, [index], args, locals, stack, execution) + + @doc "Reads a direct value or owner-local cell/global slot." + @spec read_slot(term(), State.t()) :: term() + def read_slot({:cell, _id} = reference, execution), do: read_reference(reference, execution) + def read_slot({:global, _name} = reference, execution), do: read_reference(reference, execution) + def read_slot(value, _execution), do: value + + @doc "Resolves a decoded QuickJS atom operand against an execution's atom table." + @spec resolve_atom(term(), State.t()) :: term() + def resolve_atom(:empty_string, _execution), do: "" + def resolve_atom({:tagged_int, value}, _execution), do: value + + def resolve_atom({:predefined, index}, _execution), + do: AtomTable.lookup(index) || {:predefined, index} + + def resolve_atom(index, execution) when is_integer(index) and index >= 0 do + if index < tuple_size(execution.atoms), + do: elem(execution.atoms, index), + else: {:atom, index} + end + + def resolve_atom(value, _execution), do: value + + @doc "Reads a resolved global name through the canonical global-object fallback." + @spec read_global(State.t(), term()) :: {:ok, term()} | :error + def read_global(execution, name) do + case Map.get(execution.globals, "globalThis") do + %QuickBEAM.VM.Runtime.Reference{} = global_this -> + case Property.get(global_this, name, execution) do + {:ok, :undefined} -> Map.fetch(execution.globals, name) + {:ok, value} -> {:ok, value} + {:error, _reason} -> Map.fetch(execution.globals, name) + end + + _other -> + Map.fetch(execution.globals, name) + end + end + + @doc "Writes a resolved global name and its canonical global-object property." + @spec write_global(State.t(), term(), term()) :: State.t() + def write_global(execution, name, value) do + execution = %{execution | globals: Map.put(execution.globals, name, value)} + + case Map.get(execution.globals, "globalThis") do + %QuickBEAM.VM.Runtime.Reference{} = global_this -> + case Property.put(global_this, name, value, execution) do + {:ok, execution} -> execution + {:error, _reason} -> execution + end + + _other -> + execution + end + end + + defp allocate_function(callable, function, execution) do + {reference, execution} = Heap.allocate(execution, :function, callable: callable) + + if function.has_prototype do + {prototype, execution} = Heap.allocate(execution) + + {:ok, execution} = + Property.define(prototype, "constructor", reference, execution, + enumerable: false, + configurable: true + ) + + {:ok, execution} = + Property.define(reference, "prototype", prototype, execution, + enumerable: false, + configurable: false + ) + + {reference, execution} + else + {reference, execution} + end + end + + defp capture_closure(%Function{closure_vars: []} = function, frame, execution), + do: {function, frame, execution} + + defp capture_closure(%Function{} = function, frame, execution) do + {references, frame, execution} = + Enum.reduce(function.closure_vars, {[], frame, execution}, fn closure_var, + {references, frame, execution} -> + {reference, frame, execution} = capture_reference(closure_var, frame, execution) + {[reference | references], frame, execution} + end) + + {{:closure, function, references |> Enum.reverse() |> List.to_tuple()}, frame, execution} + end + + defp capture_reference(%{closure_type: 0, var_idx: index}, frame, execution) do + index = frame.function.arg_count + index + {reference, locals, execution} = promote_tuple_slot(frame.locals, index, execution) + {reference, %{frame | locals: locals}, execution} + end + + defp capture_reference(%{closure_type: 1, var_idx: index}, frame, execution) do + {reference, args, execution} = promote_tuple_slot(frame.args, index, execution) + {reference, %{frame | args: args}, execution} + end + + defp capture_reference(%{closure_type: 2, var_idx: index}, frame, execution), + do: {elem(frame.closure_refs, index), frame, execution} + + defp capture_reference(%{name: name}, frame, execution), + do: {{:global, name}, frame, execution} + + defp promote_tuple_slot(tuple, index, execution) do + case elem(tuple, index) do + {:cell, _id} = reference -> + {reference, tuple, execution} + + value -> + id = execution.next_cell_id + reference = {:cell, id} + execution = Memory.charge_cell(execution, value) + + execution = %{ + execution + | cells: Map.put(execution.cells, id, value), + next_cell_id: id + 1 + } + + {reference, put_elem(tuple, index, reference), execution} + end + end + + defp read_reference({:cell, id}, execution), do: Map.fetch!(execution.cells, id) + + defp read_reference({:global, name}, execution), + do: Map.get(execution.globals, name, :undefined) + + defp write_reference({:cell, id}, value, execution), + do: %{execution | cells: Map.put(execution.cells, id, value)} + + defp write_reference({:global, name}, value, execution), + do: %{execution | globals: Map.put(execution.globals, name, value)} + + defp write_tuple_slot(tuple, index, value, execution) do + case elem(tuple, index) do + {:cell, _id} = reference -> {tuple, write_reference(reference, value, execution)} + {:global, _name} = reference -> {tuple, write_reference(reference, value, execution)} + _value -> {put_elem(tuple, index, value), execution} + end + end + + defp tuple_get(tuple, index) when index < tuple_size(tuple), do: elem(tuple, index) + defp tuple_get(_tuple, _index), do: :undefined + + defp push(frame, execution, value), do: next(%{frame | stack: [value | frame.stack]}, execution) + defp next(frame, execution), do: {:next, frame, execution} +end diff --git a/lib/quickbeam/vm/runtime/opcode/object.ex b/lib/quickbeam/vm/runtime/opcode/object.ex new file mode 100644 index 000000000..d0596d2dd --- /dev/null +++ b/lib/quickbeam/vm/runtime/opcode/object.ex @@ -0,0 +1,650 @@ +defmodule QuickBEAM.VM.Runtime.Opcode.Object do + @moduledoc """ + Executes object construction, property access, descriptors, and enumeration opcodes. + + Property behavior delegates to `QuickBEAM.VM.Runtime.Property`. Accessor-backed + operations return explicit invocation actions so the interpreter can preserve + resumable frames and JavaScript exception boundaries. + """ + + import Bitwise + + alias QuickBEAM.VM.Program.Function + alias QuickBEAM.VM.Runtime.Frame + alias QuickBEAM.VM.Runtime.Heap + alias QuickBEAM.VM.Runtime.Invocation + alias QuickBEAM.VM.Runtime.Iterator + alias QuickBEAM.VM.Runtime.Property + alias QuickBEAM.VM.Runtime.Property.Descriptor + alias QuickBEAM.VM.Runtime.Reference + alias QuickBEAM.VM.Runtime.RegExp + alias QuickBEAM.VM.Runtime.State + alias QuickBEAM.VM.Runtime.Symbol + alias QuickBEAM.VM.Runtime.Value + + alias QuickBEAM.VM.Runtime.Opcode.Local, as: Locals + + @opcodes [ + :regexp, + :private_symbol, + :get_private_field, + :get_super, + :put_private_field, + :define_private_field, + :add_brand, + :check_brand, + :set_home_object, + :special_object, + :object, + :array_from, + :define_class, + :define_class_computed, + :define_method, + :define_method_computed, + :define_field, + :define_array_el, + :append, + :copy_data_properties, + :get_field, + :get_field2, + :get_array_el, + :get_length, + :put_field, + :put_array_el, + :delete, + :for_in_start, + :for_in_next, + :for_of_start, + :for_of_next, + :iterator_close + ] + + @type action :: + {:next, Frame.t(), State.t()} + | {:throw, term(), Frame.t(), State.t()} + | {:invoke_getter, term(), term(), Frame.t(), State.t()} + | {:invoke_setter, term(), term(), term(), Frame.t(), State.t()} + + @doc "Returns the opcode names handled by this family." + @spec opcodes() :: [atom()] + def opcodes, do: @opcodes + + @doc "Executes one supported object or property opcode." + @spec execute(atom(), [term()], Frame.t(), State.t()) :: action() + def execute(:regexp, [], %{stack: [bytecode, source | stack]} = frame, execution) do + {regexp, execution} = + Heap.allocate(execution, :regexp, internal: %RegExp{source: source, bytecode: bytecode}) + + {:ok, execution} = Property.define(regexp, "lastIndex", 0, execution) + push(%{frame | stack: stack}, execution, regexp) + end + + def execute(:private_symbol, [atom], frame, execution) do + id = execution.next_symbol_id + description = Locals.resolve_atom(atom, execution) |> Value.to_string_value() + symbol = %Symbol{id: {:local, id}, description: description} + push(frame, %{execution | next_symbol_id: id + 1}, symbol) + end + + def execute(:get_super, [], %{stack: [%Reference{} = object | stack]} = frame, execution) do + case Heap.prototype(execution, object) do + {:ok, prototype} -> next(%{frame | stack: [prototype || nil | stack]}, execution) + {:error, reason} -> {:throw, {:type_error, reason}, frame, execution} + end + end + + def execute( + :get_private_field, + [], + %{stack: [%Symbol{} = key, %Reference{} = object | stack]} = frame, + execution + ) do + case Heap.own_property(execution, object, key) do + {:ok, %Descriptor{value: value}} -> next(%{frame | stack: [value | stack]}, execution) + {:ok, nil} -> {:throw, {:type_error, :missing_private_field}, frame, execution} + {:error, reason} -> {:throw, {:type_error, reason}, frame, execution} + end + end + + def execute( + :put_private_field, + [], + %{stack: [%Symbol{} = key, value, %Reference{} = object | stack]} = frame, + execution + ) do + case Heap.own_property(execution, object, key) do + {:ok, %Descriptor{}} -> + case Property.put(object, key, value, execution) do + {:ok, execution} -> next(%{frame | stack: stack}, execution) + {:error, reason} -> {:throw, {:type_error, reason}, frame, execution} + end + + {:ok, nil} -> + {:throw, {:type_error, :missing_private_field}, frame, execution} + + {:error, reason} -> + {:throw, {:type_error, reason}, frame, execution} + end + end + + def execute( + :define_private_field, + [], + %{stack: [value, %Symbol{} = key, %Reference{} = object | stack]} = frame, + execution + ) do + case Heap.own_property(execution, object, key) do + {:ok, nil} -> + continue_definition( + Property.define(object, key, value, execution), + [object | stack], + frame, + execution + ) + + {:ok, %Descriptor{}} -> + {:throw, {:type_error, :duplicate_private_field}, frame, execution} + + {:error, reason} -> + {:throw, {:type_error, reason}, frame, execution} + end + end + + def execute( + :set_home_object, + [], + %{stack: [%Reference{} = function, %Reference{} = home | _stack]} = frame, + execution + ) do + case Property.define(function, :home_object, home, execution) do + {:ok, execution} -> next(frame, execution) + {:error, reason} -> {:throw, {:type_error, reason}, frame, execution} + end + end + + def execute( + :add_brand, + [], + %{stack: [%Reference{} = home, object | stack]} = frame, + execution + ) + when object in [nil, :undefined] or is_struct(object, Reference) do + {brand, execution} = private_brand(home, execution) + + result = + case object do + %Reference{} -> Property.define(object, {:private_brand, brand}, true, execution) + object when object in [nil, :undefined] -> {:ok, execution} + end + + case result do + {:ok, execution} -> next(%{frame | stack: stack}, execution) + {:error, reason} -> {:throw, {:type_error, reason}, frame, execution} + end + end + + def execute( + :check_brand, + [], + %{stack: [%Reference{} = function, %Reference{} = object | _stack]} = frame, + execution + ) do + with {:ok, %Descriptor{value: %Reference{} = home}} <- + Heap.own_property(execution, function, :home_object), + {:ok, %Descriptor{value: %Symbol{} = brand}} <- + Heap.own_property(execution, home, :private_brand_token), + {:ok, %Descriptor{}} <- + Heap.own_property(execution, object, {:private_brand, brand}) do + next(frame, execution) + else + {:ok, nil} -> {:throw, {:type_error, :invalid_private_brand}, frame, execution} + {:error, reason} -> {:throw, {:type_error, reason}, frame, execution} + end + end + + def execute(:special_object, [type], frame, execution) when type in [0, 1] do + values = Tuple.to_list(frame.args) + {arguments, execution} = Heap.allocate(execution, :array) + + execution = + values + |> Enum.with_index() + |> Enum.reduce(execution, fn {value, index}, execution -> + {:ok, execution} = + Property.define(arguments, index, Locals.read_slot(value, execution), execution) + + execution + end) + + push(frame, execution, arguments) + end + + def execute(:special_object, [2], frame, execution), + do: push(frame, execution, frame.callable) + + def execute(:special_object, [3], frame, execution), + do: push(frame, execution, frame.callable) + + def execute(:special_object, [4], frame, execution) do + value = + case frame.callable do + %Reference{} = callable -> + case Heap.own_property(execution, callable, :home_object) do + {:ok, %Descriptor{value: home}} -> home + _other -> :undefined + end + + _other -> + :undefined + end + + push(frame, execution, value) + end + + def execute(:special_object, [type], frame, execution) when type in [5, 7] do + {object, execution} = Heap.allocate(execution, :ordinary, prototype: nil) + push(frame, execution, object) + end + + def execute(:special_object, [_type], frame, execution), + do: push(frame, execution, :undefined) + + def execute(:object, [], frame, execution) do + {reference, execution} = Heap.allocate(execution) + push(frame, execution, reference) + end + + def execute(:array_from, [count], frame, execution) do + {elements, stack} = Enum.split(frame.stack, count) + {reference, execution} = Heap.allocate_array(execution, Enum.reverse(elements)) + push(%{frame | stack: stack}, execution, reference) + end + + def execute( + :define_class, + [atom, _flags], + %{stack: [constructor, parent | stack]} = frame, + execution + ), + do: + define_class( + constructor, + parent, + Locals.resolve_atom(atom, execution), + stack, + frame, + execution + ) + + def execute( + :define_class_computed, + [_atom, _flags], + %{stack: [constructor, parent, name | stack]} = frame, + execution + ), + do: + define_class( + constructor, + parent, + Value.to_string_value(name), + [name | stack], + frame, + execution + ) + + def execute( + :define_method, + [atom, kind], + %{stack: [callable, %Reference{} = object | stack]} = frame, + execution + ) do + key = Locals.resolve_atom(atom, execution) + result = define_method_property(object, key, callable, kind, execution) + + case result do + {:ok, execution} -> next(%{frame | stack: [object | stack]}, execution) + {:error, reason} -> {:throw, {:type_error, reason}, frame, execution} + end + end + + def execute( + :define_method_computed, + [kind], + %{stack: [callable, key, %Reference{} = object | stack]} = frame, + execution + ) do + result = define_method_property(object, key, callable, kind, execution) + + case result do + {:ok, execution} -> next(%{frame | stack: [object | stack]}, execution) + {:error, reason} -> {:throw, {:type_error, reason}, frame, execution} + end + end + + def execute( + :define_field, + [atom], + %{stack: [value, %Reference{} = object | stack]} = frame, + execution + ) do + key = Locals.resolve_atom(atom, execution) + + continue_definition( + Property.define(object, key, value, execution), + [object | stack], + frame, + execution + ) + end + + def execute( + :define_array_el, + [], + %{stack: [value, key, %Reference{} = object | stack]} = frame, + execution + ) do + case Property.define(object, key, value, execution) do + {:ok, execution} -> next(%{frame | stack: [key, object | stack]}, execution) + {:error, reason} -> {:throw, {:type_error, reason}, frame, execution} + end + end + + def execute( + :append, + [], + %{stack: [iterable, index, %Reference{} = array | stack]} = frame, + execution + ) do + case Iterator.values(iterable, execution) do + {:ok, values} -> + index = Value.to_number(index) + + execution = + values + |> Enum.with_index(index) + |> Enum.reduce(execution, fn {value, position}, execution -> + {:ok, execution} = Property.define(array, position, value, execution) + execution + end) + + next(%{frame | stack: [index + length(values), array | stack]}, execution) + + {:error, reason} -> + {:throw, {:type_error, reason}, frame, execution} + + {:resumable} -> + {:throw, {:type_error, :unsupported_resumable_spread}, frame, execution} + end + end + + def execute(:copy_data_properties, [mask], %{stack: stack} = frame, execution) do + target = Enum.at(stack, band(mask, 3)) + source = Enum.at(stack, band(mask >>> 2, 7)) + excluded = Enum.at(stack, band(mask >>> 5, 3)) + + with %Reference{} <- target, + {:ok, keys} <- Property.enumerable_keys(source, execution), + {:ok, excluded_keys} <- copy_excluded_keys(excluded, execution), + {:ok, execution} <- + copy_data_properties(keys -- excluded_keys, target, source, execution) do + next(frame, execution) + else + {:error, reason} -> {:throw, {:type_error, reason}, frame, execution} + _invalid_target -> {:throw, {:type_error, :invalid_copy_target}, frame, execution} + end + end + + def execute(:get_field, [atom], %{stack: [object | stack]} = frame, execution), + do: get(object, Locals.resolve_atom(atom, execution), stack, frame, execution) + + def execute(:get_field2, [atom], %{stack: [object | stack]} = frame, execution), + do: get(object, Locals.resolve_atom(atom, execution), [object | stack], frame, execution) + + def execute(:get_array_el, [], %{stack: [key, object | stack]} = frame, execution), + do: get(object, key, stack, frame, execution) + + def execute(:get_length, [], %{stack: [object | stack]} = frame, execution), + do: get(object, "length", stack, frame, execution) + + def execute(:put_field, [atom], %{stack: [value, object | stack]} = frame, execution), + do: + put( + object, + Locals.resolve_atom(atom, execution), + value, + stack, + frame, + execution + ) + + def execute(:put_array_el, [], %{stack: [value, key, object | stack]} = frame, execution), + do: put(object, key, value, stack, frame, execution) + + def execute(:delete, [], %{stack: [key, %Reference{} = object | stack]} = frame, execution) do + case Property.delete(object, key, execution) do + {:ok, deleted?, execution} -> next(%{frame | stack: [deleted? | stack]}, execution) + {:error, reason} -> {:throw, {:type_error, reason}, frame, execution} + end + end + + def execute(:for_in_start, [], %{stack: [object | stack]} = frame, execution) do + case Property.enumerable_keys(object, execution) do + {:ok, keys} -> next(%{frame | stack: [{:for_in, keys, 0} | stack]}, execution) + {:error, reason} -> {:throw, {:type_error, reason}, %{frame | stack: stack}, execution} + end + end + + def execute(:for_in_next, [], %{stack: [{:for_in, keys, index} | stack]} = frame, execution) do + if index < length(keys) do + iterator = {:for_in, keys, index + 1} + next(%{frame | stack: [false, Enum.at(keys, index), iterator | stack]}, execution) + else + next(%{frame | stack: [true, :undefined, {:for_in, keys, index} | stack]}, execution) + end + end + + def execute(:for_of_start, [], %{stack: [iterable | stack]} = frame, execution) do + case Iterator.values(iterable, execution) do + {:ok, values} -> + iterator = {:for_of, values, 0} + next(%{frame | stack: [0, :direct_iterator, iterator | stack]}, execution) + + {:error, reason} -> + {:throw, {:type_error, reason}, frame, execution} + + {:resumable} -> + {:throw, {:type_error, :unsupported_resumable_for_of}, frame, execution} + end + end + + def execute(:for_of_next, [depth], %{stack: stack} = frame, execution) do + iterator_offset = depth + 2 + + case Enum.at(stack, iterator_offset) do + {:for_of, values, index} -> + done? = index >= length(values) + value = if done?, do: :undefined, else: Enum.at(values, index) + next_index = if done?, do: index, else: index + 1 + stack = List.replace_at(stack, iterator_offset, {:for_of, values, next_index}) + next(%{frame | stack: [done?, value | stack]}, execution) + + _other -> + {:throw, {:type_error, :invalid_for_of_iterator}, frame, execution} + end + end + + def execute( + :iterator_close, + [], + %{stack: [_index, _next, _iterator | stack]} = frame, + execution + ), + do: next(%{frame | stack: stack}, execution) + + defp continue_definition({:ok, execution}, stack, frame, _previous_execution), + do: next(%{frame | stack: stack}, execution) + + defp continue_definition({:error, reason}, _stack, frame, execution), + do: {:throw, {:type_error, reason}, frame, execution} + + defp copy_excluded_keys(value, _execution) when value in [:undefined, nil], do: {:ok, []} + defp copy_excluded_keys(value, execution), do: Property.enumerable_keys(value, execution) + + defp copy_data_properties(keys, target, source, execution) do + Enum.reduce_while(keys, {:ok, execution}, fn key, {:ok, execution} -> + copy_data_property(source, target, key, execution) + end) + end + + defp copy_data_property(source, target, key, execution) do + case Property.get(source, key, execution) do + {:ok, {:accessor, _getter, _receiver}} -> + {:halt, {:error, :unsupported_copy_accessor}} + + {:ok, value} -> + continue_property_copy(Property.put(target, key, value, execution)) + + {:error, reason} -> + {:halt, {:error, reason}} + end + end + + defp continue_property_copy({:ok, execution}), do: {:cont, {:ok, execution}} + defp continue_property_copy({:error, reason}), do: {:halt, {:error, reason}} + + defp private_brand(home, execution) do + case Heap.own_property(execution, home, :private_brand_token) do + {:ok, %Descriptor{value: %Symbol{} = brand}} -> + {brand, execution} + + {:ok, nil} -> + id = execution.next_symbol_id + brand = %Symbol{id: {:local, id}, description: "brand"} + execution = %{execution | next_symbol_id: id + 1} + {:ok, execution} = Property.define(home, :private_brand_token, brand, execution) + {brand, execution} + end + end + + defp define_method_property(object, key, callable, flags, execution) do + opts = [enumerable: band(flags, 4) != 0] + + case band(flags, 3) do + 0 -> Property.define(object, key, callable, execution, opts) + 1 -> Property.define_accessor(object, key, :getter, callable, execution, opts) + 2 -> Property.define_accessor(object, key, :setter, callable, execution, opts) + kind -> {:error, {:unsupported_method_kind, kind}} + end + end + + defp define_class(constructor, parent, name, stack, frame, execution) do + {constructor, frame, execution} = instantiate_class_constructor(constructor, frame, execution) + + with :ok <- validate_class_parent(parent, execution), + {:ok, parent_prototype} <- class_parent_prototype(parent, execution) do + {prototype, execution} = + Heap.allocate(execution, :ordinary, prototype: parent_prototype) + + {:ok, execution} = + Property.define(prototype, "constructor", constructor, execution, + writable: true, + enumerable: false, + configurable: true + ) + + {:ok, execution} = + Property.define(constructor, "prototype", prototype, execution, + writable: false, + enumerable: false, + configurable: false + ) + + {:ok, execution} = + Heap.update_object(execution, constructor, fn object -> + %{object | internal: :class_constructor} + end) + + {:ok, execution} = + Property.define(constructor, "name", name, execution, + writable: false, + enumerable: false, + configurable: true + ) + + execution = set_constructor_parent(execution, constructor, parent) + + next(%{frame | stack: [prototype, constructor | stack]}, execution) + else + {:error, reason} -> {:throw, {:type_error, reason}, frame, execution} + end + end + + defp set_constructor_parent(execution, constructor, %Reference{} = parent) do + case Heap.set_prototype(execution, constructor, parent) do + {:ok, execution} -> execution + {:error, _reason} -> execution + end + end + + defp set_constructor_parent(execution, _constructor, _parent), do: execution + + defp instantiate_class_constructor(%Function{} = function, frame, execution), + do: Locals.instantiate_function(function, frame, execution, prototype?: false) + + defp instantiate_class_constructor(%Reference{} = constructor, frame, execution), + do: {constructor, frame, execution} + + defp validate_class_parent(parent, _execution) when parent in [:undefined, nil], do: :ok + + defp validate_class_parent(parent, execution) do + if Invocation.constructable?(parent, execution), + do: :ok, + else: {:error, :class_parent_not_constructor} + end + + defp class_parent_prototype(:undefined, execution), + do: {:ok, Map.get(execution.default_prototypes, :ordinary)} + + defp class_parent_prototype(nil, _execution), do: {:ok, nil} + + defp class_parent_prototype(parent, execution) do + case Property.get(parent, "prototype", execution) do + {:ok, %Reference{} = prototype} -> {:ok, prototype} + {:ok, nil} -> {:ok, nil} + _other -> {:error, :invalid_class_parent_prototype} + end + end + + defp get(object, key, stack, frame, execution) do + frame = %{frame | stack: stack} + + case Property.get(object, key, execution) do + {:ok, {:accessor, getter, receiver}} -> + {:invoke_getter, getter, receiver, frame, execution} + + {:ok, value} -> + next(%{frame | stack: [value | stack]}, execution) + + {:error, reason} -> + {:throw, {:type_error, reason}, frame, execution} + end + end + + defp put(object, key, value, stack, frame, execution) do + frame = %{frame | stack: stack} + + case Property.put(object, key, value, execution) do + {:ok, execution} -> + next(frame, execution) + + {:error, {:invoke_setter, setter}} -> + {:invoke_setter, setter, value, object, frame, execution} + + {:error, reason} -> + {:throw, {:type_error, reason}, frame, execution} + end + end + + defp push(frame, execution, value), do: next(%{frame | stack: [value | frame.stack]}, execution) + defp next(frame, execution), do: {:next, frame, execution} +end diff --git a/lib/quickbeam/vm/runtime/opcode/stack.ex b/lib/quickbeam/vm/runtime/opcode/stack.ex new file mode 100644 index 000000000..41eb43583 --- /dev/null +++ b/lib/quickbeam/vm/runtime/opcode/stack.ex @@ -0,0 +1,62 @@ +defmodule QuickBEAM.VM.Runtime.Opcode.Stack do + @moduledoc """ + Executes literal and operand-stack QuickJS opcode families. + + The module applies canonical compact stack transformations and returns a + `:next` action so the interpreter remains the sole owner of instruction + stepping and resource accounting. + """ + + alias QuickBEAM.VM.Runtime.Frame + alias QuickBEAM.VM.Runtime.Stack, as: OperandStack + alias QuickBEAM.VM.Runtime.State + + @opcodes [ + :push_i32, + :push_i8, + :push_i16, + :push_bigint_i32, + :undefined, + :null, + :push_false, + :push_true, + :push_this, + :push_const, + :push_const8, + :drop, + :dup, + :dup1, + :dup2, + :dup3, + :nip, + :nip_catch, + :nip1, + :swap, + :swap2, + :perm3, + :perm4, + :perm5, + :rot3l, + :rot3r, + :rot4l, + :rot5l, + :insert2, + :insert3 + ] + + @type action :: {:next, Frame.t(), State.t()} + + @doc "Returns the opcode names handled by this family." + @spec opcodes() :: [atom()] + def opcodes, do: @opcodes + + @doc "Executes one supported literal or stack-manipulation opcode." + @spec execute(atom(), [term()], Frame.t(), State.t()) :: action() + def execute(name, operands, %Frame{} = frame, %State{} = execution) + when name in @opcodes and is_list(operands) do + {:ok, stack} = + OperandStack.execute(name, operands, frame.stack, frame.this, frame.function.constants) + + {:next, %{frame | stack: stack}, execution} + end +end diff --git a/lib/quickbeam/vm/runtime/opcode/value.ex b/lib/quickbeam/vm/runtime/opcode/value.ex new file mode 100644 index 000000000..65ba20815 --- /dev/null +++ b/lib/quickbeam/vm/runtime/opcode/value.ex @@ -0,0 +1,147 @@ +defmodule QuickBEAM.VM.Runtime.Opcode.Value do + @moduledoc """ + Executes coercion, comparison, arithmetic, and value-test opcode families. + + Operations delegate to the canonical value, invocation, and property semantic + layers. Results are explicit `:next` or `:throw` actions consumed by the + interpreter. + """ + + alias QuickBEAM.VM.Runtime.Frame + alias QuickBEAM.VM.Runtime.Invocation + alias QuickBEAM.VM.Runtime.Property + alias QuickBEAM.VM.Runtime.Reference + alias QuickBEAM.VM.Runtime.State + alias QuickBEAM.VM.Runtime.Value + + @binary_operations [ + :add, + :sub, + :mul, + :div, + :mod, + :pow, + :lt, + :lte, + :gt, + :gte, + :eq, + :neq, + :strict_eq, + :strict_neq, + :and, + :or, + :xor, + :shl, + :sar, + :shr + ] + + @unary_operations [ + :neg, + :plus, + :not, + :lnot, + :inc, + :dec, + :is_undefined_or_null, + :is_undefined, + :is_null + ] + + @opcodes @binary_operations ++ + @unary_operations ++ + [ + :post_inc, + :post_dec, + :to_propkey, + :to_propkey2, + :to_object, + :is_function, + :typeof, + :typeof_is_function, + :typeof_is_undefined, + :in, + :instanceof + ] + + @type action :: + {:next, Frame.t(), State.t()} + | {:throw, term(), Frame.t(), State.t()} + + @doc "Returns the opcode names handled by this family." + @spec opcodes() :: [atom()] + def opcodes, do: @opcodes + + @doc "Executes one supported value-semantic opcode." + @spec execute(atom(), [term()], Frame.t(), State.t()) :: action() + def execute(name, [], %{stack: [right, left | stack]} = frame, execution) + when name in @binary_operations do + next(%{frame | stack: [Value.binary(name, left, right) | stack]}, execution) + end + + def execute(name, [], %{stack: [value | stack]} = frame, execution) + when name in @unary_operations do + next(%{frame | stack: [Value.unary(name, value) | stack]}, execution) + end + + def execute(:post_inc, [], %{stack: [value | stack]} = frame, execution), + do: next(%{frame | stack: [Value.unary(:inc, value), value | stack]}, execution) + + def execute(:post_dec, [], %{stack: [value | stack]} = frame, execution), + do: next(%{frame | stack: [Value.unary(:dec, value), value | stack]}, execution) + + def execute(:to_propkey, [], frame, execution), do: next(frame, execution) + + def execute(:to_propkey2, [], %{stack: [_key, object | _]} = frame, execution) + when object in [nil, :undefined], + do: {:throw, {:type_error, :cannot_convert_to_object}, frame, execution} + + def execute(:to_propkey2, [], frame, execution), do: next(frame, execution) + + def execute(:to_object, [], %{stack: [value | _]} = frame, execution) + when value in [nil, :undefined], + do: {:throw, {:type_error, :cannot_convert_to_object}, frame, execution} + + def execute(:to_object, [], frame, execution), do: next(frame, execution) + + def execute(:is_function, [], %{stack: [value | stack]} = frame, execution), + do: next(%{frame | stack: [Invocation.callable?(value, execution) | stack]}, execution) + + def execute(:typeof, [], %{stack: [value | stack]} = frame, execution), + do: next(%{frame | stack: [Invocation.typeof(value, execution) | stack]}, execution) + + def execute(:typeof_is_function, [], %{stack: [value | stack]} = frame, execution), + do: next(%{frame | stack: [Invocation.callable?(value, execution) | stack]}, execution) + + def execute(:typeof_is_undefined, [], %{stack: [value | stack]} = frame, execution), + do: next(%{frame | stack: [Value.unary(:is_undefined, value) | stack]}, execution) + + def execute(:in, [], %{stack: [object, key | stack]} = frame, execution), + do: + next( + %{frame | stack: [Property.has_property?(object, key, execution) | stack]}, + execution + ) + + def execute( + :instanceof, + [], + %{stack: [constructor, object | stack]} = frame, + execution + ) do + with "function" <- Invocation.typeof(constructor, execution), + {:ok, %Reference{} = prototype} <- + Invocation.instanceof_prototype(constructor, execution) do + result = + is_struct(object, Reference) and + Property.prototype_chain_contains?(object, prototype, execution) + + next(%{frame | stack: [result | stack]}, execution) + else + _invalid -> {:throw, {:type_error, :invalid_instanceof_target}, frame, execution} + end + end + + defp next(frame, execution), do: {:next, frame, execution} +end diff --git a/lib/quickbeam/vm/runtime/optimization.ex b/lib/quickbeam/vm/runtime/optimization.ex new file mode 100644 index 000000000..9eff0e1e8 --- /dev/null +++ b/lib/quickbeam/vm/runtime/optimization.ex @@ -0,0 +1,77 @@ +defmodule QuickBEAM.VM.Runtime.Optimization do + @moduledoc """ + Dispatches optional optimization hooks without coupling canonical runtime code + to a specific compiler implementation. + + Hook and executor modules are fixed by the owner-local optimization context; + ordinary interpreter evaluations carry no context and pay only bounded map + checks. + """ + + alias QuickBEAM.VM.Runtime.Frame + alias QuickBEAM.VM.Runtime.State + + @doc "Executes one frame through the configured owner-local optimizer." + @spec execute_frame(Frame.t(), State.t()) :: term() + def execute_frame( + %Frame{} = frame, + %State{compiler_context: %{executor: executor}} = execution + ) + when is_atom(executor), + do: executor.execute_frame(frame, execution) + + @doc "Checks whether an optimizer action contains its configured deoptimization type." + @spec deopt?(term(), State.t()) :: boolean() + def deopt?( + %{__struct__: deopt_module}, + %State{compiler_context: %{deopt_module: deopt_module}} + ), + do: true + + def deopt?(_value, %State{}), do: false + + @doc "Validates an opaque deoptimization through its defining module." + @spec validate_deopt(struct()) :: :ok | {:error, term()} + def validate_deopt(%{__struct__: module} = deopt) when is_atom(module) do + if function_exported?(module, :validate, 1), + do: module.validate(deopt), + else: {:error, {:invalid_deopt_module, module}} + end + + @doc "Increments one optimizer event when instrumentation is configured." + @spec increment(State.t(), atom()) :: State.t() + def increment(%State{} = execution, event), + do: instrument(execution, :increment, [execution, event], execution) + + @doc "Records one interpreted opcode when instrumentation is configured." + @spec interpreted_opcode(State.t(), non_neg_integer()) :: State.t() + def interpreted_opcode(%State{} = execution, opcode), + do: instrument(execution, :interpreted_opcode, [execution, opcode], execution) + + @doc "Samples one interpreted frame when instrumentation is configured." + @spec observe(State.t(), Frame.t()) :: State.t() + def observe(%State{} = execution, %Frame{} = frame), + do: instrument(execution, :observe, [execution, frame], execution) + + @doc "Returns optimizer-specific endpoint observations, or empty observations." + @spec snapshot(State.t()) :: map() + def snapshot(%State{} = execution), + do: + instrument( + execution, + :snapshot, + [execution], + %{compiler_counters: nil, compiler_regions: nil} + ) + + defp instrument( + %State{compiler_context: %{instrumentation: instrumentation}}, + function, + arguments, + _default + ) + when is_atom(instrumentation) and not is_nil(instrumentation), + do: apply(instrumentation, function, arguments) + + defp instrument(%State{}, _function, _arguments, default), do: default +end diff --git a/lib/quickbeam/vm/runtime/promise.ex b/lib/quickbeam/vm/runtime/promise.ex new file mode 100644 index 000000000..a8c1a0ddb --- /dev/null +++ b/lib/quickbeam/vm/runtime/promise.ex @@ -0,0 +1,449 @@ +defmodule QuickBEAM.VM.Runtime.Promise do + @moduledoc """ + Implements owner-local Promise state, reactions, adoption, and combinators. + + Promise state and jobs live in `QuickBEAM.VM.Runtime.State`; this module only + transforms that explicit state and never starts independent processes. + """ + + alias QuickBEAM.VM.Runtime.Callable + alias QuickBEAM.VM.Runtime.Coroutine + alias QuickBEAM.VM.Runtime.Memory + alias QuickBEAM.VM.Runtime.Promise.Reaction + alias QuickBEAM.VM.Runtime.Promise.Reference, as: PromiseReference + alias QuickBEAM.VM.Runtime.Property + alias QuickBEAM.VM.Runtime.Reference + alias QuickBEAM.VM.Runtime.State + + @type state :: :pending | {:fulfilled, term()} | {:rejected, term()} + + @doc "Allocates a pending owner-local Promise." + @spec new(State.t()) :: {PromiseReference.t(), State.t()} + def new(%State{} = execution) do + id = execution.next_promise_id + reference = %PromiseReference{id: id} + execution = Memory.charge_promise(execution) + + execution = %{ + execution + | next_promise_id: id + 1, + promises: Map.put(execution.promises, id, :pending) + } + + {reference, execution} + end + + @doc "Returns the observable state of an owner-local Promise." + @spec state(State.t(), PromiseReference.t()) :: state() + def state(%State{} = execution, %PromiseReference{id: id}) do + case Map.fetch!(execution.promises, id) do + :resolving -> :pending + state -> state + end + end + + @doc "Registers a coroutine to resume when a Promise settles." + @spec await(State.t(), PromiseReference.t(), Coroutine.t()) :: State.t() + def await(execution, %PromiseReference{id: id} = promise, %Coroutine{} = coroutine) do + case state(execution, promise) do + :pending -> add_waiter(execution, id, coroutine) + {:fulfilled, value} -> enqueue(execution, {:resume_coroutine, coroutine, {:ok, value}}) + {:rejected, reason} -> enqueue(execution, {:resume_coroutine, coroutine, {:error, reason}}) + end + end + + @doc "Creates a chained Promise reaction." + @spec react(State.t(), PromiseReference.t(), term(), term()) :: + {PromiseReference.t(), State.t()} + def react(execution, %PromiseReference{id: id} = source, on_fulfilled, on_rejected) do + {result_promise, execution} = new(execution) + + reaction = %Reaction{ + result_promise: result_promise, + on_fulfilled: on_fulfilled, + on_rejected: on_rejected + } + + {result_promise, attach_reaction(execution, source, id, reaction)} + end + + @doc "Creates or reuses the Promise produced by resolving one value." + @spec from_value(State.t(), term()) :: {PromiseReference.t(), State.t()} + def from_value(execution, value), do: promise_from_value(execution, value) + + @doc "Creates a Promise combinator over a bounded value list." + @spec aggregate(State.t(), :all | :all_settled | :any | :race, [term()]) :: + {PromiseReference.t(), State.t()} + def aggregate(execution, kind, values) do + {result_promise, execution} = new(execution) + execution = aggregate_into(execution, result_promise, kind, values) + {result_promise, execution} + end + + @doc "Aggregates iterable values into an existing owner-local Promise." + @spec aggregate_into( + State.t(), + PromiseReference.t(), + :all | :all_settled | :any | :race, + [term()] + ) :: State.t() + def aggregate_into(execution, result_promise, kind, values) do + if values == [] do + result = + case kind do + :all -> {:ok, []} + :all_settled -> {:ok, []} + :any -> {:error, aggregate_error([])} + :race -> nil + end + + if result, do: settle(execution, result_promise, result), else: execution + else + id = make_ref() + + aggregate = %{ + kind: kind, + promise: result_promise, + remaining: length(values), + values: %{} + } + + execution = %{ + execution + | promise_aggregates: Map.put(execution.promise_aggregates, id, aggregate) + } + + execution = + values + |> Enum.with_index() + |> Enum.reduce(execution, fn {value, index}, execution -> + {source, execution} = promise_from_value(execution, value) + add_aggregate_waiter(execution, source, id, index) + end) + + execution + end + end + + @doc "Records one settlement in an active Promise aggregate." + @spec settle_aggregate( + State.t(), + reference(), + non_neg_integer(), + {:ok, term()} | {:error, term()} + ) :: + State.t() + def settle_aggregate(execution, id, index, result) do + case Map.fetch(execution.promise_aggregates, id) do + :error -> + execution + + {:ok, aggregate} -> + update_aggregate(execution, id, aggregate, index, result) + end + end + + @doc "Creates a Promise `finally` reaction." + @spec finally(State.t(), PromiseReference.t(), term()) :: + {PromiseReference.t(), State.t()} + def finally(execution, %PromiseReference{id: id} = source, callback) do + {result_promise, execution} = new(execution) + + reaction = %Reaction{ + result_promise: result_promise, + kind: :finally, + on_fulfilled: callback, + on_rejected: callback + } + + {result_promise, attach_reaction(execution, source, id, reaction)} + end + + defp attach_reaction(execution, source, id, reaction) do + case state(execution, source) do + :pending -> add_waiter(execution, id, reaction) + {:fulfilled, value} -> enqueue(execution, {:run_reaction, reaction, {:ok, value}}) + {:rejected, reason} -> enqueue(execution, {:run_reaction, reaction, {:error, reason}}) + end + end + + @doc "Settles a target Promise after a `finally` callback result." + @spec settle_after_finally( + State.t(), + PromiseReference.t(), + PromiseReference.t(), + {:ok, term()} | {:error, term()} + ) :: State.t() + def settle_after_finally(execution, source, target, original_result) do + case state(execution, source) do + :pending -> add_waiter(execution, source.id, {:finally_adopt, target, original_result}) + {:fulfilled, _value} -> settle(execution, target, original_result) + {:rejected, reason} -> settle(execution, target, {:error, reason}) + end + end + + @doc "Enqueues invocation of a callable `then` result as a Promise microtask." + @spec enqueue_assimilation(State.t(), PromiseReference.t(), Reference.t(), term()) :: + State.t() + def enqueue_assimilation(execution, promise, thenable, callable), + do: enqueue(execution, {:assimilate_thenable, promise, thenable, callable}) + + @doc "Enqueues a coroutine resumption as a Promise job." + @spec enqueue_coroutine(State.t(), Coroutine.t(), {:ok, term()} | {:error, term()}) :: + State.t() + def enqueue_coroutine(execution, %Coroutine{} = coroutine, result), + do: enqueue(execution, {:resume_coroutine, coroutine, result}) + + @doc "Settles a Promise according to the Promise resolution procedure." + @spec settle(State.t(), PromiseReference.t(), {:ok, term()} | {:error, term()}) :: + State.t() + def settle(execution, %PromiseReference{id: id} = promise, {:ok, %PromiseReference{id: id}}), + do: settle(execution, promise, {:error, {:type_error, :promise_self_resolution}}) + + def settle(execution, %PromiseReference{id: id} = promise, {:ok, %PromiseReference{} = source}) do + case Map.fetch!(execution.promises, id) do + :pending -> + case state(execution, source) do + :pending -> + execution = %{execution | promises: Map.put(execution.promises, id, :resolving)} + add_waiter(execution, source.id, {:adopt, promise}) + + {:fulfilled, value} -> + settle(execution, promise, {:ok, value}) + + {:rejected, reason} -> + settle(execution, promise, {:error, reason}) + end + + _settled_or_resolving -> + execution + end + end + + def settle(execution, %PromiseReference{id: id} = promise, {:ok, %Reference{} = value} = result) do + case Map.fetch!(execution.promises, id) do + :pending -> + case then_callable(execution, value) do + {:ok, callable} -> + execution = %{execution | promises: Map.put(execution.promises, id, :resolving)} + enqueue(execution, {:assimilate_thenable, promise, value, callable}) + + {:getter, getter, receiver} -> + execution = %{execution | promises: Map.put(execution.promises, id, :resolving)} + enqueue_sync(execution, {:read_thenable, promise, receiver, getter}) + + :none -> + settle_result(execution, promise, result) + end + + _settled_or_resolving -> + execution + end + end + + def settle(%State{} = execution, %PromiseReference{} = promise, result), + do: settle_result(execution, promise, result) + + @doc """ + Settles a Promise whose resolution was locked while adopting another value. + + The adopted value is recursively resolved according to the Promise resolution + procedure, including Promise and thenable assimilation. + """ + @spec settle_assimilated(State.t(), PromiseReference.t(), {:ok, term()} | {:error, term()}) :: + State.t() + def settle_assimilated(execution, %PromiseReference{id: id} = promise, result) do + case Map.fetch!(execution.promises, id) do + :resolving -> + execution = %{execution | promises: Map.put(execution.promises, id, :pending)} + settle(execution, promise, result) + + _settled -> + execution + end + end + + @doc "Fulfills a resolving Promise after a non-callable `then` getter result." + @spec fulfill_assimilated(State.t(), PromiseReference.t(), term()) :: State.t() + def fulfill_assimilated(execution, %PromiseReference{id: id} = promise, value) do + case Map.fetch!(execution.promises, id) do + :resolving -> + execution = %{execution | promises: Map.put(execution.promises, id, :pending)} + settle_result(execution, promise, {:ok, value}) + + _settled -> + execution + end + end + + defp settle_result(%State{} = execution, %PromiseReference{id: id}, result) do + case Map.fetch!(execution.promises, id) do + :pending -> + state = result_state(result) + {waiters, promise_waiters} = Map.pop(execution.promise_waiters, id, []) + + execution = %{ + execution + | promises: Map.put(execution.promises, id, state), + promise_waiters: promise_waiters + } + + waiters + |> Enum.reverse() + |> Enum.reduce(execution, &enqueue_waiter(&2, &1, result)) + + _settled -> + execution + end + end + + defp then_callable(execution, reference) do + case Property.get(reference, "then", execution) do + {:ok, {:accessor, getter, receiver}} -> + {:getter, getter, receiver} + + {:ok, %Reference{} = callable} -> + if Callable.callable?(callable, execution), do: {:ok, callable}, else: :none + + {:ok, callable} + when is_tuple(callable) and + elem(callable, 0) in [ + :bound_function, + :builtin, + :declared_builtin, + :host_function, + :primitive_method, + :promise_resolver + ] -> + {:ok, callable} + + _ -> + :none + end + end + + defp promise_from_value(execution, %PromiseReference{} = promise), do: {promise, execution} + + defp promise_from_value(execution, value) do + {promise, execution} = new(execution) + {promise, settle(execution, promise, {:ok, value})} + end + + defp add_aggregate_waiter(execution, promise, id, index) do + case state(execution, promise) do + :pending -> add_waiter(execution, promise.id, {:aggregate, id, index}) + {:fulfilled, value} -> enqueue(execution, {:aggregate_settle, id, index, {:ok, value}}) + {:rejected, reason} -> enqueue(execution, {:aggregate_settle, id, index, {:error, reason}}) + end + end + + defp update_aggregate(execution, id, %{kind: :race} = aggregate, _index, result) do + execution = %{execution | promise_aggregates: Map.delete(execution.promise_aggregates, id)} + settle(execution, aggregate.promise, result) + end + + defp update_aggregate(execution, id, %{kind: :all} = aggregate, index, {:ok, value}) do + aggregate = %{ + aggregate + | remaining: aggregate.remaining - 1, + values: Map.put(aggregate.values, index, value) + } + + finish_or_store_aggregate(execution, id, aggregate) + end + + defp update_aggregate(execution, id, %{kind: :all} = aggregate, _index, {:error, reason}) do + execution = %{execution | promise_aggregates: Map.delete(execution.promise_aggregates, id)} + settle(execution, aggregate.promise, {:error, reason}) + end + + defp update_aggregate(execution, id, %{kind: :all_settled} = aggregate, index, result) do + value = + case result do + {:ok, value} -> %{"status" => "fulfilled", "value" => value} + {:error, reason} -> %{"status" => "rejected", "reason" => unwrap_reason(reason)} + end + + aggregate = %{ + aggregate + | remaining: aggregate.remaining - 1, + values: Map.put(aggregate.values, index, value) + } + + finish_or_store_aggregate(execution, id, aggregate) + end + + defp update_aggregate(execution, id, %{kind: :any} = aggregate, _index, {:ok, value}) do + execution = %{execution | promise_aggregates: Map.delete(execution.promise_aggregates, id)} + settle(execution, aggregate.promise, {:ok, value}) + end + + defp update_aggregate(execution, id, %{kind: :any} = aggregate, index, {:error, reason}) do + aggregate = %{ + aggregate + | remaining: aggregate.remaining - 1, + values: Map.put(aggregate.values, index, unwrap_reason(reason)) + } + + if aggregate.remaining == 0 do + execution = %{execution | promise_aggregates: Map.delete(execution.promise_aggregates, id)} + settle(execution, aggregate.promise, {:error, aggregate_error(ordered_values(aggregate))}) + else + %{execution | promise_aggregates: Map.put(execution.promise_aggregates, id, aggregate)} + end + end + + defp finish_or_store_aggregate(execution, id, aggregate) do + if aggregate.remaining == 0 do + execution = %{execution | promise_aggregates: Map.delete(execution.promise_aggregates, id)} + settle(execution, aggregate.promise, {:ok, ordered_values(aggregate)}) + else + %{execution | promise_aggregates: Map.put(execution.promise_aggregates, id, aggregate)} + end + end + + defp ordered_values(aggregate) do + for index <- 0..(map_size(aggregate.values) - 1), do: Map.fetch!(aggregate.values, index) + end + + defp aggregate_error(errors), + do: %{ + "name" => "AggregateError", + "message" => "All promises were rejected", + "errors" => errors + } + + defp unwrap_reason(%QuickBEAM.VM.Runtime.Thrown{value: value}), do: value + defp unwrap_reason(reason), do: reason + + defp result_state({:ok, value}), do: {:fulfilled, value} + defp result_state({:error, reason}), do: {:rejected, reason} + + defp add_waiter(execution, id, waiter) do + waiters = Map.update(execution.promise_waiters, id, [waiter], &[waiter | &1]) + %{execution | promise_waiters: waiters} + end + + defp enqueue_waiter(execution, %Coroutine{} = coroutine, result), + do: enqueue(execution, {:resume_coroutine, coroutine, result}) + + defp enqueue_waiter(execution, {:adopt, target}, result), + do: enqueue(execution, {:settle_assimilated, target, result}) + + defp enqueue_waiter(execution, %Reaction{} = reaction, result), + do: enqueue(execution, {:run_reaction, reaction, result}) + + defp enqueue_waiter(execution, {:finally_adopt, target, original_result}, {:ok, _value}), + do: enqueue(execution, {:settle_promise, target, original_result}) + + defp enqueue_waiter(execution, {:finally_adopt, target, _original_result}, {:error, reason}), + do: enqueue(execution, {:settle_promise, target, {:error, reason}}) + + defp enqueue_waiter(execution, {:aggregate, id, index}, result), + do: enqueue(execution, {:aggregate_settle, id, index, result}) + + defp enqueue(execution, job), do: %{execution | jobs: :queue.in(job, execution.jobs)} + + defp enqueue_sync(execution, job), + do: %{execution | sync_jobs: :queue.in(job, execution.sync_jobs), sync_jobs_pending?: true} +end diff --git a/lib/quickbeam/vm/runtime/promise/reaction.ex b/lib/quickbeam/vm/runtime/promise/reaction.ex new file mode 100644 index 000000000..9496e5dc2 --- /dev/null +++ b/lib/quickbeam/vm/runtime/promise/reaction.ex @@ -0,0 +1,15 @@ +defmodule QuickBEAM.VM.Runtime.Promise.Reaction do + @moduledoc """ + Defines a queued Promise reaction and the Promise produced by that reaction. + """ + + @enforce_keys [:result_promise] + defstruct [:result_promise, kind: :then, on_fulfilled: :undefined, on_rejected: :undefined] + + @type t :: %__MODULE__{ + result_promise: QuickBEAM.VM.Runtime.Promise.Reference.t(), + kind: :then | :finally, + on_fulfilled: term(), + on_rejected: term() + } +end diff --git a/lib/quickbeam/vm/runtime/promise/reference.ex b/lib/quickbeam/vm/runtime/promise/reference.ex new file mode 100644 index 000000000..8d7ffd673 --- /dev/null +++ b/lib/quickbeam/vm/runtime/promise/reference.ex @@ -0,0 +1,8 @@ +defmodule QuickBEAM.VM.Runtime.Promise.Reference do + @moduledoc "Identifies a Promise in one evaluation's Promise store." + + @enforce_keys [:id] + defstruct [:id] + + @type t :: %__MODULE__{id: non_neg_integer()} +end diff --git a/lib/quickbeam/vm/runtime/property.ex b/lib/quickbeam/vm/runtime/property.ex new file mode 100644 index 000000000..757e6e8a6 --- /dev/null +++ b/lib/quickbeam/vm/runtime/property.ex @@ -0,0 +1,234 @@ +defmodule QuickBEAM.VM.Runtime.Property do + @moduledoc """ + Provides the canonical JavaScript property semantic boundary for the VM. + + Heap references, primitive wrappers, callable pseudo-methods, Promise methods, + UTF-16 string indices, enumeration, descriptors, and prototype operations all + pass through this module. Accessor reads return an explicit + `{:ok, {:accessor, getter, receiver}}` action for the interpreter to resume. + """ + + alias QuickBEAM.VM.Runtime.Heap + alias QuickBEAM.VM.Runtime.Promise.Reference, as: PromiseReference + alias QuickBEAM.VM.Runtime.Property.Descriptor + alias QuickBEAM.VM.Runtime.Reference + alias QuickBEAM.VM.Runtime.RegExp + alias QuickBEAM.VM.Runtime.State + alias QuickBEAM.VM.Runtime.Value + + @function_tags [ + :builtin, + :declared_builtin, + :bound_function, + :host_function, + :primitive_method, + :promise_resolver + ] + + @type get_result :: + {:ok, term() | {:accessor, term(), Reference.t()}} + | {:error, term()} + + @doc "Reads a JavaScript property or returns an accessor invocation action." + @spec get(term(), term(), State.t()) :: get_result() + def get(%Reference{} = object, key, execution) do + case Heap.get(execution, object, key) do + {:ok, :undefined} = missing -> missing_reference_property(object, key, execution, missing) + result -> result + end + end + + def get(%PromiseReference{}, key, execution) when is_binary(key), + do: intrinsic_property(execution, "Promise", key) + + def get(%RegExp{}, key, _execution) when is_binary(key), + do: {:ok, {:primitive_method, :regexp, key}} + + def get(object, key, execution) + when is_tuple(object) and is_binary(key) and elem(object, 0) in @function_tags, + do: intrinsic_property(execution, "Function", key) + + def get(object, key, _execution) when is_map(object) and not is_struct(object) do + case Map.fetch(object, key) do + {:ok, value} -> {:ok, value} + :error -> {:ok, map_string_key(object, key)} + end + end + + def get(object, "length", _execution) when is_binary(object), + do: {:ok, Value.string_length(object)} + + def get(object, key, _execution) when is_binary(object) and is_integer(key), + do: {:ok, Value.string_at(object, key)} + + def get(object, key, execution) when is_binary(object) and is_binary(key), + do: intrinsic_property(execution, "String", key) + + def get(object, "length", _execution) when is_list(object), do: {:ok, length(object)} + + def get(object, key, _execution) when is_list(object) and is_integer(key), + do: {:ok, Enum.at(object, key, :undefined)} + + def get(object, key, execution) when is_list(object) and is_binary(key), + do: intrinsic_property(execution, "Array", key) + + def get(object, key, execution) when is_number(object) and is_binary(key), + do: intrinsic_property(execution, "Number", key) + + def get(object, _key, _execution) when object in [nil, :undefined], + do: {:error, :null_or_undefined_property_access} + + def get(_object, _key, _execution), do: {:ok, :undefined} + + defp missing_reference_property(object, key, execution, missing) do + case Heap.fetch_object(execution, object) do + {:ok, %{internal: :global_object}} -> + global_property(execution.globals, key, missing) + + {:ok, %{kind: :regexp}} when key in ["exec", "test"] -> + {:ok, {:primitive_method, :regexp, key}} + + _other -> + missing + end + end + + defp global_property(globals, key, missing) do + case Map.fetch(globals, key) do + {:ok, value} -> {:ok, value} + :error -> missing + end + end + + @doc "Writes a JavaScript property or returns an accessor setter action." + @spec put(term(), term(), term(), State.t()) :: + {:ok, State.t()} | {:error, term()} + def put(%Reference{} = object, key, value, execution), + do: Heap.put(execution, object, key, value) + + def put(object, _key, _value, _execution), do: {:error, {:not_an_object, object}} + + @doc "Defines a data property on an owner-local object." + @spec define(Reference.t(), term(), term(), State.t(), keyword()) :: + {:ok, State.t()} | {:error, term()} + def define(%Reference{} = object, key, value, execution, opts \\ []), + do: Heap.define(execution, object, key, value, opts) + + @doc "Defines one getter or setter on an owner-local object." + @spec define_accessor( + Reference.t(), + term(), + :getter | :setter, + term(), + State.t(), + keyword() + ) :: + {:ok, State.t()} | {:error, term()} + def define_accessor(%Reference{} = object, key, kind, callable, execution, opts \\ []), + do: Heap.define_accessor(execution, object, key, kind, callable, opts) + + @doc "Defines a complete property descriptor on an owner-local object." + @spec define_descriptor(Reference.t(), term(), Descriptor.t(), State.t()) :: + {:ok, State.t()} | {:error, term()} + def define_descriptor(%Reference{} = object, key, %Descriptor{} = property, execution), + do: Heap.define_descriptor(execution, object, key, property) + + @doc "Deletes an own property according to its configurable flag." + @spec delete(Reference.t(), term(), State.t()) :: + {:ok, boolean(), State.t()} | {:error, term()} + def delete(%Reference{} = object, key, execution), do: Heap.delete(execution, object, key) + + @doc "Returns enumerable own keys in ECMAScript order." + @spec enumerable_keys(term(), State.t()) :: {:ok, [term()]} | {:error, term()} + def enumerable_keys(%Reference{} = reference, execution), + do: Heap.own_keys(execution, reference) + + def enumerable_keys(value, _execution) when is_map(value), do: {:ok, Map.keys(value)} + def enumerable_keys([], _execution), do: {:ok, []} + + def enumerable_keys(value, _execution) when is_list(value), + do: {:ok, Enum.to_list(0..(length(value) - 1))} + + def enumerable_keys(value, _execution) when value in [nil, :undefined], do: {:ok, []} + def enumerable_keys(_value, _execution), do: {:ok, []} + + @doc "Returns enumerable own string and Symbol keys copied by `Object.assign`." + @spec assignable_keys(term(), State.t()) :: {:ok, [term()]} | {:error, term()} + def assignable_keys(%Reference{} = reference, execution), + do: Heap.assignable_keys(execution, reference) + + def assignable_keys(value, execution), do: enumerable_keys(value, execution) + + @doc "Tests JavaScript property presence across an object's prototype chain." + @spec has_property?(term(), term(), State.t()) :: boolean() + def has_property?(%Reference{} = reference, key, execution), + do: Heap.has_property?(execution, reference, key) + + def has_property?(value, key, _execution) when is_map(value), do: Map.has_key?(value, key) + + def has_property?(value, key, _execution) when is_list(value) and is_integer(key), + do: key >= 0 and key < length(value) + + def has_property?(value, "length", _execution) when is_list(value) or is_binary(value), + do: true + + def has_property?(_value, _key, _execution), do: false + + @doc "Returns an object's own descriptor without prototype traversal." + @spec own_property(Reference.t(), term(), State.t()) :: + {:ok, Descriptor.t() | nil} | {:error, term()} + def own_property(%Reference{} = reference, key, execution), + do: Heap.own_property(execution, reference, key) + + @doc "Returns all own string property names in ECMAScript order." + @spec own_property_names(Reference.t(), State.t()) :: + {:ok, [String.t()]} | {:error, term()} + def own_property_names(%Reference{} = reference, execution), + do: Heap.own_property_names(execution, reference) + + @doc "Returns an object's direct prototype." + @spec prototype(Reference.t(), State.t()) :: + {:ok, Reference.t() | nil} | {:error, term()} + def prototype(%Reference{} = reference, execution), do: Heap.prototype(execution, reference) + + @doc "Updates an object's direct prototype after cycle validation." + @spec set_prototype(Reference.t(), Reference.t() | nil, State.t()) :: + {:ok, State.t()} | {:error, term()} + def set_prototype(%Reference{} = reference, prototype, execution), + do: Heap.set_prototype(execution, reference, prototype) + + @doc "Tests whether a reference occurs in an object's prototype chain." + @spec prototype_chain_contains?(Reference.t(), Reference.t(), State.t()) :: boolean() + def prototype_chain_contains?(%Reference{} = object, %Reference{} = prototype, execution), + do: Heap.prototype_chain_contains?(execution, object, prototype) + + @doc "Returns the heap kind of an owner-local reference." + @spec kind(Reference.t(), State.t()) :: QuickBEAM.VM.Runtime.Object.kind() | nil + def kind(%Reference{} = reference, execution) do + case Heap.fetch_object(execution, reference) do + {:ok, object} -> object.kind + :error -> nil + end + end + + defp intrinsic_property(execution, constructor_name, key) do + with %Reference{} = constructor <- Map.get(execution.globals, constructor_name), + {:ok, %Reference{} = prototype} <- Heap.get(execution, constructor, "prototype") do + Heap.get(execution, prototype, key) + else + _missing -> {:ok, :undefined} + end + end + + defp map_string_key(map, key) when is_binary(key) do + case Enum.find(map, fn + {map_key, _value} when is_atom(map_key) -> Atom.to_string(map_key) == key + _entry -> false + end) do + {_map_key, value} -> value + nil -> :undefined + end + end + + defp map_string_key(_map, _key), do: :undefined +end diff --git a/lib/quickbeam/vm/runtime/property/descriptor.ex b/lib/quickbeam/vm/runtime/property/descriptor.ex new file mode 100644 index 000000000..3a57bf39e --- /dev/null +++ b/lib/quickbeam/vm/runtime/property/descriptor.ex @@ -0,0 +1,22 @@ +defmodule QuickBEAM.VM.Runtime.Property.Descriptor do + @moduledoc "Defines a JavaScript property value and its descriptor flags." + + defstruct kind: :data, + value: :undefined, + writable: true, + enumerable: true, + configurable: true, + getter: nil, + setter: nil + + @type kind :: :data | :accessor + @type t :: %__MODULE__{ + kind: kind(), + value: term(), + writable: boolean(), + enumerable: boolean(), + configurable: boolean(), + getter: term() | nil, + setter: term() | nil + } +end diff --git a/lib/quickbeam/vm/runtime/reference.ex b/lib/quickbeam/vm/runtime/reference.ex new file mode 100644 index 000000000..9d6bea698 --- /dev/null +++ b/lib/quickbeam/vm/runtime/reference.ex @@ -0,0 +1,8 @@ +defmodule QuickBEAM.VM.Runtime.Reference do + @moduledoc "Identifies an object in one evaluation-owned VM heap." + + @enforce_keys [:id] + defstruct [:id] + + @type t :: %__MODULE__{id: non_neg_integer()} +end diff --git a/lib/quickbeam/vm/runtime/regexp.ex b/lib/quickbeam/vm/runtime/regexp.ex new file mode 100644 index 000000000..e33048ea0 --- /dev/null +++ b/lib/quickbeam/vm/runtime/regexp.ex @@ -0,0 +1,8 @@ +defmodule QuickBEAM.VM.Runtime.RegExp do + @moduledoc "Defines the VM representation of a JavaScript regular expression." + + @enforce_keys [:source, :bytecode] + defstruct [:source, :bytecode] + + @type t :: %__MODULE__{source: String.t(), bytecode: binary()} +end diff --git a/lib/quickbeam/vm/runtime/stack.ex b/lib/quickbeam/vm/runtime/stack.ex new file mode 100644 index 000000000..d60f90cd6 --- /dev/null +++ b/lib/quickbeam/vm/runtime/stack.ex @@ -0,0 +1,79 @@ +defmodule QuickBEAM.VM.Runtime.Stack do + @moduledoc """ + Applies verified literal and operand-stack transformations to compact stack state. + + The interpreter and generated compiler blocks share this module so stack + permutation semantics have one implementation. + """ + + @type result :: {:ok, [term()]} | {:error, term()} + + @doc "Transforms a verified operand stack for one stack-family instruction." + @spec execute(atom(), [term()], [term()], term(), [term()]) :: result() + def execute(name, [value], stack, _this, _constants) + when name in [:push_i32, :push_i8, :push_i16], + do: {:ok, [value | stack]} + + def execute(:push_bigint_i32, [value], stack, _this, _constants), + do: {:ok, [{:bigint, value} | stack]} + + def execute(:undefined, [], stack, _this, _constants), do: {:ok, [:undefined | stack]} + def execute(:null, [], stack, _this, _constants), do: {:ok, [nil | stack]} + def execute(:push_false, [], stack, _this, _constants), do: {:ok, [false | stack]} + def execute(:push_true, [], stack, _this, _constants), do: {:ok, [true | stack]} + def execute(:push_this, [], stack, this, _constants), do: {:ok, [this | stack]} + + def execute(name, [index], stack, _this, constants) when name in [:push_const, :push_const8], + do: {:ok, [Enum.at(constants, index) | stack]} + + def execute(:drop, [], [_value | stack], _this, _constants), do: {:ok, stack} + def execute(:dup, [], [value | _] = stack, _this, _constants), do: {:ok, [value | stack]} + + def execute(:dup1, [], [a, b | stack], _this, _constants), + do: {:ok, [a, b, b | stack]} + + def execute(:dup2, [], [a, b | stack], _this, _constants), + do: {:ok, [a, b, a, b | stack]} + + def execute(:dup3, [], [a, b, c | stack], _this, _constants), + do: {:ok, [a, b, c, a, b, c | stack]} + + def execute(name, [], [a, _b | stack], _this, _constants) when name in [:nip, :nip_catch], + do: {:ok, [a | stack]} + + def execute(:nip1, [], [a, b, _c | stack], _this, _constants), do: {:ok, [a, b | stack]} + def execute(:swap, [], [a, b | stack], _this, _constants), do: {:ok, [b, a | stack]} + + def execute(:swap2, [], [a, b, c, d | stack], _this, _constants), + do: {:ok, [c, d, a, b | stack]} + + def execute(:perm3, [], [a, b, c | stack], _this, _constants), + do: {:ok, [a, c, b | stack]} + + def execute(:perm4, [], [a, b, c, d | stack], _this, _constants), + do: {:ok, [a, c, d, b | stack]} + + def execute(:perm5, [], [a, b, c, d, e | stack], _this, _constants), + do: {:ok, [a, c, d, e, b | stack]} + + def execute(:rot3l, [], [a, b, c | stack], _this, _constants), + do: {:ok, [c, a, b | stack]} + + def execute(:rot3r, [], [a, b, c | stack], _this, _constants), + do: {:ok, [b, c, a | stack]} + + def execute(:rot4l, [], [a, b, c, d | stack], _this, _constants), + do: {:ok, [d, a, b, c | stack]} + + def execute(:rot5l, [], [a, b, c, d, e | stack], _this, _constants), + do: {:ok, [e, a, b, c, d | stack]} + + def execute(:insert2, [], [a, b | stack], _this, _constants), + do: {:ok, [a, b, a | stack]} + + def execute(:insert3, [], [a, b, c | stack], _this, _constants), + do: {:ok, [a, b, c, a | stack]} + + def execute(name, operands, stack, _this, _constants), + do: {:error, {:invalid_stack_operation, name, operands, stack}} +end diff --git a/lib/quickbeam/vm/runtime/state.ex b/lib/quickbeam/vm/runtime/state.ex new file mode 100644 index 000000000..bdd222831 --- /dev/null +++ b/lib/quickbeam/vm/runtime/state.ex @@ -0,0 +1,108 @@ +defmodule QuickBEAM.VM.Runtime.State do + @moduledoc """ + Defines all mutable state owned by one isolated VM evaluation. + + Programs are immutable and shareable; execution heaps, globals, frames, + Promises, jobs, handlers, and resource counters are process-local. + """ + + @empty_queue :queue.new() + + @enforce_keys [:atoms, :max_stack_depth, :remaining_steps, :step_limit] + defstruct [ + :atoms, + :step_limit, + callers: [], + cells: %{}, + compiler_context: nil, + depth: 1, + default_prototypes: %{}, + error_prototypes: %{}, + globals: %{}, + handlers: %{}, + heap: %{}, + jobs: @empty_queue, + sync_jobs: @empty_queue, + sync_jobs_pending?: false, + max_stack_depth: 1_000, + memory_exceeded: false, + memory_limit: :infinity, + memory_used: 0, + measurement_target: nil, + next_cell_id: 0, + next_object_id: 0, + next_promise_id: 0, + next_symbol_id: 0, + operations: %{}, + promise_waiters: %{}, + promise_aggregates: %{}, + promises: %{}, + remaining_steps: 0 + ] + + @type t :: %__MODULE__{ + atoms: tuple(), + callers: [ + QuickBEAM.VM.Runtime.Frame.t() + | QuickBEAM.VM.Runtime.Frame.Native.t() + | QuickBEAM.VM.Runtime.Boundary.ObjectAssign.t() + | QuickBEAM.VM.Runtime.Boundary.Accessor.t() + | QuickBEAM.VM.Runtime.Boundary.Async.t() + | QuickBEAM.VM.Runtime.Boundary.Reaction.t() + | QuickBEAM.VM.Runtime.Boundary.Constructor.t() + | QuickBEAM.VM.Runtime.Boundary.PromiseExecutor.t() + | QuickBEAM.VM.Runtime.Boundary.Thenable.t() + | QuickBEAM.VM.Runtime.Boundary.ThenGetter.t() + ], + cells: %{optional(non_neg_integer()) => term()}, + compiler_context: + %{ + required(:deopt_module) => module(), + required(:executor) => module(), + required(:instrumentation) => module(), + optional(atom()) => term() + } + | nil, + depth: non_neg_integer(), + default_prototypes: %{ + optional(QuickBEAM.VM.Runtime.Object.kind()) => QuickBEAM.VM.Runtime.Reference.t() + }, + error_prototypes: %{optional(String.t()) => QuickBEAM.VM.Runtime.Reference.t()}, + globals: map(), + handlers: %{optional(String.t()) => function()}, + heap: %{optional(non_neg_integer()) => QuickBEAM.VM.Runtime.Object.t()}, + jobs: :queue.queue(term()), + sync_jobs: :queue.queue(term()), + sync_jobs_pending?: boolean(), + max_stack_depth: pos_integer(), + memory_exceeded: boolean(), + memory_limit: pos_integer() | :infinity, + memory_used: non_neg_integer(), + measurement_target: {pid(), reference()} | nil, + next_cell_id: non_neg_integer(), + next_object_id: non_neg_integer(), + next_promise_id: non_neg_integer(), + next_symbol_id: non_neg_integer(), + operations: %{ + optional(reference()) => {QuickBEAM.VM.Runtime.Promise.Reference.t(), pid()} + }, + promise_waiters: %{optional(non_neg_integer()) => [term()]}, + promise_aggregates: %{optional(reference()) => map()}, + promises: %{ + optional(non_neg_integer()) => QuickBEAM.VM.Runtime.Promise.state() | :resolving + }, + remaining_steps: non_neg_integer(), + step_limit: pos_integer() + } + + @doc "Builds evaluation state with fresh owner-local job queues." + @spec new(keyword()) :: t() + def new(attributes) when is_list(attributes) do + attributes = + attributes + |> Keyword.put_new(:jobs, :queue.new()) + |> Keyword.put_new(:sync_jobs, :queue.new()) + + struct!(__MODULE__, attributes) + end +end diff --git a/lib/quickbeam/vm/runtime/string/utf16.ex b/lib/quickbeam/vm/runtime/string/utf16.ex new file mode 100644 index 000000000..b27973f5b --- /dev/null +++ b/lib/quickbeam/vm/runtime/string/utf16.ex @@ -0,0 +1,102 @@ +defmodule QuickBEAM.VM.Runtime.String.UTF16 do + @moduledoc """ + Implements JavaScript string indexing over UTF-16 code units. + + Lone surrogate values are represented as WTF-8 binaries, matching the native + QuickJS conversion boundary while remaining ordinary Elixir binaries. + """ + + import Bitwise + + @doc "Returns the number of UTF-16 code units in a JavaScript string." + @spec length(binary()) :: non_neg_integer() + def length(value), do: value |> units() |> Kernel.length() + + @doc "Returns one UTF-16 code unit encoded as WTF-8, or `:undefined`." + @spec at(binary(), integer()) :: binary() | :undefined + def at(_value, index) when index < 0, do: :undefined + + def at(value, index) do + case Enum.at(units(value), index) do + nil -> :undefined + unit -> encode_unit(unit) + end + end + + @doc "Returns the numeric UTF-16 code unit at an index, or `:nan`." + @spec char_code_at(binary(), integer()) :: non_neg_integer() | :nan + def char_code_at(_value, index) when index < 0, do: :nan + def char_code_at(value, index), do: Enum.at(units(value), index, :nan) + + @doc "Encodes UTF-16 code units as a JavaScript string represented with WTF-8." + @spec from_units([non_neg_integer()]) :: binary() + def from_units(units), do: encode_units(units) + + @doc "Slices a JavaScript string by UTF-16 code-unit offsets." + @spec slice(binary(), non_neg_integer(), non_neg_integer()) :: binary() + def slice(value, start, length) do + value + |> units() + |> Enum.slice(start, length) + |> encode_units() + end + + defp units(value), do: decode(value, []) + + defp decode(<<>>, units), do: Enum.reverse(units) + + defp decode(<>, units) when codepoint < 0x80, + do: decode(rest, [codepoint | units]) + + defp decode(<>, units) when first in 0xC2..0xDF do + codepoint = (first &&& 0x1F) <<< 6 ||| (second &&& 0x3F) + decode(rest, [codepoint | units]) + end + + defp decode(<>, units) when first in 0xE0..0xEF do + codepoint = + (first &&& 0x0F) <<< 12 ||| (second &&& 0x3F) <<< 6 ||| (third &&& 0x3F) + + decode(rest, [codepoint | units]) + end + + defp decode(<>, units) + when first in 0xF0..0xF4 do + codepoint = + (first &&& 0x07) <<< 18 ||| (second &&& 0x3F) <<< 12 ||| + (third &&& 0x3F) <<< 6 ||| (fourth &&& 0x3F) + + scalar = codepoint - 0x10000 + high = 0xD800 + (scalar >>> 10) + low = 0xDC00 + (scalar &&& 0x3FF) + decode(rest, [low, high | units]) + end + + defp decode(<>, units), do: decode(rest, [byte | units]) + + defp encode_units(units), do: encode_units(units, []) |> IO.iodata_to_binary() + + defp encode_units([high, low | rest], encoded) + when high in 0xD800..0xDBFF and low in 0xDC00..0xDFFF do + codepoint = 0x10000 + ((high - 0xD800) <<< 10) + (low - 0xDC00) + encode_units(rest, [encode_scalar(codepoint) | encoded]) + end + + defp encode_units([unit | rest], encoded), + do: encode_units(rest, [encode_unit(unit) | encoded]) + + defp encode_units([], encoded), do: Enum.reverse(encoded) + + defp encode_unit(unit) when unit < 0x80, do: <> + + defp encode_unit(unit) when unit < 0x800, + do: <<0xC0 ||| unit >>> 6, 0x80 ||| (unit &&& 0x3F)>> + + defp encode_unit(unit), + do: <<0xE0 ||| unit >>> 12, 0x80 ||| (unit >>> 6 &&& 0x3F), 0x80 ||| (unit &&& 0x3F)>> + + defp encode_scalar(codepoint) do + <<0xF0 ||| codepoint >>> 18, 0x80 ||| (codepoint >>> 12 &&& 0x3F), + 0x80 ||| (codepoint >>> 6 &&& 0x3F), 0x80 ||| (codepoint &&& 0x3F)>> + end +end diff --git a/lib/quickbeam/vm/runtime/symbol.ex b/lib/quickbeam/vm/runtime/symbol.ex new file mode 100644 index 000000000..af841853e --- /dev/null +++ b/lib/quickbeam/vm/runtime/symbol.ex @@ -0,0 +1,15 @@ +defmodule QuickBEAM.VM.Runtime.Symbol do + @moduledoc "Defines an owner-independent well-known JavaScript Symbol value." + + @enforce_keys [:id, :description] + defstruct [:id, :description] + + @type t :: %__MODULE__{ + id: atom() | {:global, String.t()} | {:local, non_neg_integer()}, + description: String.t() + } + + @doc "Returns the stable `Symbol.iterator` value used as a property key." + @spec iterator() :: t() + def iterator, do: %__MODULE__{id: :iterator, description: "Symbol.iterator"} +end diff --git a/lib/quickbeam/vm/runtime/thrown.ex b/lib/quickbeam/vm/runtime/thrown.ex new file mode 100644 index 000000000..0df2f63e7 --- /dev/null +++ b/lib/quickbeam/vm/runtime/thrown.ex @@ -0,0 +1,13 @@ +defmodule QuickBEAM.VM.Runtime.Thrown do + @moduledoc """ + Carries a raw JavaScript thrown value together with preserved async frames. + + The wrapper stays internal to an evaluation until an uncaught exception is + converted to `QuickBEAM.JSError` at the public boundary. + """ + + @enforce_keys [:value, :frames] + defstruct [:value, :frames] + + @type t :: %__MODULE__{value: term(), frames: [QuickBEAM.JSError.frame()]} +end diff --git a/lib/quickbeam/vm/runtime/value.ex b/lib/quickbeam/vm/runtime/value.ex new file mode 100644 index 000000000..0bbd7a9f3 --- /dev/null +++ b/lib/quickbeam/vm/runtime/value.ex @@ -0,0 +1,441 @@ +defmodule QuickBEAM.VM.Runtime.Value do + @moduledoc """ + Implements canonical JavaScript value coercion and primitive operations. + + Opcode dispatch, built-ins, properties, and future compiled code use this + module for truthiness, equality, arithmetic, comparisons, bitwise conversion, + `typeof`, string conversion, and UTF-16 string operations. + """ + + import Bitwise + + alias QuickBEAM.VM.Runtime.String.UTF16 + + @doc "Returns JavaScript boolean coercion for a VM value." + @spec truthy?(term()) :: boolean() + def truthy?(nil), do: false + def truthy?(:undefined), do: false + def truthy?(:nan), do: false + def truthy?(false), do: false + def truthy?(0), do: false + def truthy?(value) when is_float(value) and value == 0.0, do: false + def truthy?(""), do: false + def truthy?(_value), do: true + + @doc "Implements JavaScript strict equality for represented VM values." + @spec strict_equal?(term(), term()) :: boolean() + def strict_equal?(a, b) when is_number(a) and is_number(b), do: a == b + def strict_equal?(:nan, _value), do: false + def strict_equal?(_value, :nan), do: false + def strict_equal?(a, b), do: a === b + + @doc "Implements the supported JavaScript abstract equality coercions." + @spec abstract_equal?(term(), term()) :: boolean() + def abstract_equal?(nil, :undefined), do: true + def abstract_equal?(:undefined, nil), do: true + def abstract_equal?(a, b) when is_number(a) and is_number(b), do: a == b + def abstract_equal?(a, b) when is_boolean(a), do: abstract_equal?(to_number(a), b) + def abstract_equal?(a, b) when is_boolean(b), do: abstract_equal?(a, to_number(b)) + + def abstract_equal?(a, b) when is_number(a) and is_binary(b), + do: abstract_equal?(a, to_number(b)) + + def abstract_equal?(a, b) when is_binary(a) and is_number(b), + do: abstract_equal?(to_number(a), b) + + def abstract_equal?(a, b), do: strict_equal?(a, b) + + @doc "Applies a canonical unary value operation." + @spec unary(atom(), term()) :: term() + def unary(:neg, value), do: negate(value) + def unary(:plus, value), do: to_number(value) + def unary(:not, value), do: bitwise_not(value) + def unary(:lnot, value), do: not truthy?(value) + def unary(:inc, value), do: add(value, 1) + def unary(:dec, value), do: subtract(value, 1) + def unary(:is_undefined_or_null, value), do: value in [:undefined, nil] + def unary(:is_undefined, value), do: value == :undefined + def unary(:is_null, value), do: is_nil(value) + + @doc "Applies a canonical binary value operation." + @spec binary(atom(), term(), term()) :: term() + def binary(:add, left, right), do: add(left, right) + def binary(:sub, left, right), do: subtract(left, right) + def binary(:mul, left, right), do: multiply(left, right) + def binary(:div, left, right), do: divide(left, right) + def binary(:mod, left, right), do: modulo(left, right) + def binary(:pow, left, right), do: power(left, right) + def binary(:lt, left, right), do: compare(left, right, :lt) + def binary(:lte, left, right), do: compare(left, right, :lte) + def binary(:gt, left, right), do: compare(left, right, :gt) + def binary(:gte, left, right), do: compare(left, right, :gte) + def binary(:eq, left, right), do: abstract_equal?(left, right) + def binary(:neq, left, right), do: not abstract_equal?(left, right) + def binary(:strict_eq, left, right), do: strict_equal?(left, right) + def binary(:strict_neq, left, right), do: not strict_equal?(left, right) + def binary(:and, left, right), do: bitwise(left, right, &band/2) + def binary(:or, left, right), do: bitwise(left, right, &bor/2) + def binary(:xor, left, right), do: bitwise(left, right, &bxor/2) + def binary(:shl, left, right), do: shift_left(left, right) + def binary(:sar, left, right), do: shift_right(left, right) + def binary(:shr, left, right), do: shift_right_unsigned(left, right) + + @doc "Implements JavaScript addition, including string concatenation." + @spec add(term(), term()) :: term() + def add(a, b) when is_binary(a) or is_binary(b), do: to_string_value(a) <> to_string_value(b) + def add(a, b), do: add_numbers(to_number(a), to_number(b)) + + @doc "Normalizes JavaScript slice arguments to a bounded start and length." + @spec slice_range(non_neg_integer(), [term()]) :: {non_neg_integer(), non_neg_integer()} + def slice_range(size, arguments) do + start = arguments |> List.first(0) |> normalize_slice_index(size) + finish = arguments |> Enum.at(1, size) |> normalize_slice_index(size) + {start, max(finish - start, 0)} + end + + @doc "Implements numeric subtraction after JavaScript coercion." + @spec subtract(term(), term()) :: term() + def subtract(a, b), do: subtract_numbers(to_number(a), to_number(b)) + + @doc "Implements numeric multiplication after JavaScript coercion." + @spec multiply(term(), term()) :: term() + def multiply(a, b), do: multiply_numbers(to_number(a), to_number(b)) + + @doc "Implements numeric division with represented JavaScript infinities and NaN." + @spec divide(term(), term()) :: term() + def divide(a, b), do: divide_numbers(to_number(a), to_number(b)) + + defp divide_numbers(:nan, _divisor), do: :nan + defp divide_numbers(_dividend, :nan), do: :nan + + defp divide_numbers(dividend, divisor) + when dividend in [:infinity, :neg_infinity] and divisor in [:infinity, :neg_infinity], + do: :nan + + defp divide_numbers(dividend, divisor) + when dividend in [:infinity, :neg_infinity] and is_number(divisor), + do: signed_infinity(negative?(dividend) != negative?(divisor)) + + defp divide_numbers(dividend, divisor) + when is_number(dividend) and divisor in [:infinity, :neg_infinity], + do: signed_zero(negative?(dividend) != negative?(divisor)) + + defp divide_numbers(dividend, divisor) when dividend == 0 and divisor == 0, do: :nan + + defp divide_numbers(dividend, divisor) when divisor == 0, + do: signed_infinity(negative?(dividend) != negative?(divisor)) + + defp divide_numbers(dividend, divisor), do: dividend / divisor + + @doc "Implements numeric remainder after JavaScript coercion." + @spec modulo(term(), term()) :: term() + def modulo(a, b), do: remainder(to_number(a), to_number(b)) + + defp remainder(:nan, _divisor), do: :nan + defp remainder(_dividend, :nan), do: :nan + defp remainder(dividend, _divisor) when dividend in [:infinity, :neg_infinity], do: :nan + defp remainder(_dividend, divisor) when divisor == 0, do: :nan + defp remainder(dividend, divisor) when divisor in [:infinity, :neg_infinity], do: dividend + + defp remainder(dividend, divisor) when is_integer(dividend) and is_integer(divisor) do + case rem(dividend, divisor) do + 0 when dividend < 0 -> -0.0 + result -> result + end + end + + defp remainder(dividend, divisor), do: :math.fmod(dividend, divisor) + + @doc "Implements numeric exponentiation after JavaScript coercion." + @spec power(term(), term()) :: term() + def power(a, b), do: power_numbers(to_number(a), to_number(b)) + + @doc "Implements unary numeric negation." + @spec negate(term()) :: term() + def negate(value), do: negate_number(to_number(value)) + + @doc "Compares strings lexically or other values numerically." + @spec compare(term(), term(), :lt | :lte | :gt | :gte) :: boolean() + def compare(a, b, operation) when is_binary(a) and is_binary(b), + do: compare_order(operation, ordering(a, b)) + + def compare(a, b, operation) do + case {to_number(a), to_number(b)} do + {:nan, _right} -> false + {_left, :nan} -> false + {left, right} -> compare_order(operation, ordering(left, right)) + end + end + + @doc "Applies a binary operation to JavaScript `Int32` coercions." + @spec bitwise(term(), term(), (integer(), integer() -> integer())) :: integer() + def bitwise(a, b, operation), do: operation.(to_int32(a), to_int32(b)) + + @doc "Implements signed 32-bit left shift." + @spec shift_left(term(), term()) :: integer() + def shift_left(a, b), do: bsl(to_int32(a), band(to_int32(b), 31)) + + @doc "Implements signed 32-bit right shift." + @spec shift_right(term(), term()) :: integer() + def shift_right(a, b), do: bsr(to_int32(a), band(to_int32(b), 31)) + + @doc "Implements unsigned 32-bit right shift." + @spec shift_right_unsigned(term(), term()) :: non_neg_integer() + def shift_right_unsigned(a, b), do: bsr(band(to_int32(a), 0xFFFFFFFF), band(to_int32(b), 31)) + + @doc "Implements 32-bit bitwise complement." + @spec bitwise_not(term()) :: integer() + def bitwise_not(value), do: bnot(to_int32(value)) + + @doc "Returns the primitive JavaScript `typeof` classification." + @spec typeof(term()) :: String.t() + def typeof(:undefined), do: "undefined" + def typeof(nil), do: "object" + def typeof(value) when is_boolean(value), do: "boolean" + + def typeof(value) when is_number(value) or value in [:nan, :infinity, :neg_infinity], + do: "number" + + def typeof(value) when is_binary(value), do: "string" + def typeof(%QuickBEAM.VM.Runtime.Symbol{}), do: "symbol" + def typeof(%QuickBEAM.VM.Program.Function{}), do: "function" + def typeof(%QuickBEAM.VM.Runtime.Reference{}), do: "object" + def typeof(%QuickBEAM.VM.Runtime.Promise.Reference{}), do: "object" + def typeof(%QuickBEAM.VM.Runtime.RegExp{}), do: "object" + def typeof({:closure, %QuickBEAM.VM.Program.Function{}, _captures}), do: "function" + def typeof(_value), do: "object" + + @doc "Coerces a represented JavaScript primitive to a number." + @spec to_number(term()) :: number() | :nan | :infinity | :neg_infinity + def to_number(value) when is_number(value), do: value + def to_number(true), do: 1 + def to_number(false), do: 0 + def to_number(nil), do: 0 + def to_number(:undefined), do: :nan + def to_number(:nan), do: :nan + def to_number(:infinity), do: :infinity + def to_number(:neg_infinity), do: :neg_infinity + def to_number(""), do: 0 + + def to_number(value) when is_binary(value), do: value |> String.trim() |> parse_number() + + def to_number(_value), do: :nan + + @doc "Coerces a represented JavaScript value to signed 32-bit integer form." + @spec to_int32(term()) :: integer() + def to_int32(value) do + case to_number(value) do + number when is_integer(number) -> signed32(number) + number when is_float(number) -> signed32(trunc(number)) + _ -> 0 + end + end + + @doc "Coerces a represented JavaScript value to its string form." + @spec to_string_value(term()) :: String.t() + def to_string_value(:undefined), do: "undefined" + def to_string_value(nil), do: "null" + def to_string_value(true), do: "true" + def to_string_value(false), do: "false" + def to_string_value(:nan), do: "NaN" + def to_string_value(:infinity), do: "Infinity" + def to_string_value(:neg_infinity), do: "-Infinity" + def to_string_value(value) when is_integer(value), do: Integer.to_string(value) + def to_string_value(value) when is_float(value) and value == 0.0, do: "0" + + def to_string_value(value) + when is_float(value) and value > -1.0e21 and value < 1.0e21 and trunc(value) == value, + do: value |> trunc() |> Integer.to_string() + + def to_string_value(value) when is_float(value), do: Float.to_string(value) + def to_string_value(value) when is_binary(value), do: value + def to_string_value(_value), do: "[object Object]" + + @doc "Returns a JavaScript string's UTF-16 code-unit length." + @spec string_length(String.t()) :: non_neg_integer() + def string_length(value), do: UTF16.length(value) + + @doc "Returns one JavaScript string code unit encoded as WTF-8." + @spec string_at(String.t(), integer()) :: String.t() | :undefined + def string_at(value, index), do: UTF16.at(value, index) + + @doc "Slices a JavaScript string by UTF-16 code units." + @spec string_slice(String.t(), integer(), non_neg_integer()) :: String.t() + def string_slice(value, start, length), do: UTF16.slice(value, start, length) + + @doc "Returns a JavaScript string's UTF-16 code unit at an index." + @spec string_char_code_at(String.t(), integer()) :: non_neg_integer() | :nan + def string_char_code_at(value, index), do: UTF16.char_code_at(value, index) + + @doc "Builds a JavaScript string from UTF-16 code units." + @spec string_from_units([integer()]) :: String.t() + def string_from_units(units), do: UTF16.from_units(units) + + @doc "Implements `String.fromCharCode` coercion for represented values." + @spec string_from_char_codes([term()]) :: String.t() + def string_from_char_codes(values) do + values + |> Enum.map(&band(to_int32(&1), 0xFFFF)) + |> string_from_units() + end + + defp parse_number(""), do: 0 + defp parse_number("Infinity"), do: :infinity + defp parse_number("+Infinity"), do: :infinity + defp parse_number("-Infinity"), do: :neg_infinity + defp parse_number("0x" <> digits), do: parse_integer(digits, 16) + defp parse_number("0X" <> digits), do: parse_integer(digits, 16) + defp parse_number("0b" <> digits), do: parse_integer(digits, 2) + defp parse_number("0B" <> digits), do: parse_integer(digits, 2) + defp parse_number("0o" <> digits), do: parse_integer(digits, 8) + defp parse_number("0O" <> digits), do: parse_integer(digits, 8) + + defp parse_number(value) do + case Integer.parse(value) do + {integer, ""} -> integer + _invalid_integer -> parse_float(value) + end + end + + defp parse_integer(value, base) do + case Integer.parse(value, base) do + {integer, ""} -> integer + _invalid_integer -> :nan + end + end + + defp parse_float(value) do + case Float.parse(value) do + {float, ""} -> float + _invalid_float -> :nan + end + end + + defp normalize_slice_index(value, size) do + case to_number(value) do + :infinity -> size + :neg_infinity -> 0 + :nan -> 0 + index when is_number(index) -> normalize_slice_integer(trunc(index), size) + end + end + + defp normalize_slice_integer(index, size) when index < 0, do: max(size + index, 0) + defp normalize_slice_integer(index, size), do: min(index, size) + + defp add_numbers(:nan, _right), do: :nan + defp add_numbers(_left, :nan), do: :nan + defp add_numbers(:infinity, :neg_infinity), do: :nan + defp add_numbers(:neg_infinity, :infinity), do: :nan + defp add_numbers(:infinity, _right), do: :infinity + defp add_numbers(_left, :infinity), do: :infinity + defp add_numbers(:neg_infinity, _right), do: :neg_infinity + defp add_numbers(_left, :neg_infinity), do: :neg_infinity + defp add_numbers(left, right), do: left + right + + defp subtract_numbers(left, right), do: add_numbers(left, negate_number(right)) + + defp multiply_numbers(:nan, _right), do: :nan + defp multiply_numbers(_left, :nan), do: :nan + + defp multiply_numbers(left, right) + when left in [:infinity, :neg_infinity] or right in [:infinity, :neg_infinity] do + if zero?(left) or zero?(right), + do: :nan, + else: signed_infinity(negative?(left) != negative?(right)) + end + + defp multiply_numbers(left, right), do: left * right + + defp power_numbers(_base, exponent) when exponent == 0, do: 1.0 + defp power_numbers(:nan, _exponent), do: :nan + defp power_numbers(_base, :nan), do: :nan + + defp power_numbers(base, exponent) when exponent in [:infinity, :neg_infinity] do + power_infinite_exponent(base, exponent) + end + + defp power_numbers(base, exponent) when base in [:infinity, :neg_infinity] do + power_infinite_base(base, exponent) + end + + defp power_numbers(base, exponent) when base == 0 and exponent < 0, + do: signed_infinity(negative?(base) and odd_integer?(exponent)) + + defp power_numbers(base, exponent) do + :math.pow(base, exponent) + rescue + ArithmeticError -> :nan + end + + defp power_infinite_exponent(base, exponent) do + magnitude = if base in [:infinity, :neg_infinity], do: :infinity, else: abs(base) + + case {magnitude, exponent} do + {magnitude, _exponent} when magnitude == 1 -> :nan + {:infinity, :infinity} -> :infinity + {:infinity, :neg_infinity} -> 0.0 + {magnitude, :infinity} when magnitude > 1 -> :infinity + {_magnitude, :infinity} -> 0.0 + {magnitude, :neg_infinity} when magnitude > 1 -> 0.0 + {_magnitude, :neg_infinity} -> :infinity + end + end + + defp power_infinite_base(base, exponent) when exponent > 0 do + negative_result? = base == :neg_infinity and odd_integer?(exponent) + signed_infinity(negative_result?) + end + + defp power_infinite_base(_base, _exponent), do: 0.0 + + defp negate_number(:nan), do: :nan + defp negate_number(:infinity), do: :neg_infinity + defp negate_number(:neg_infinity), do: :infinity + defp negate_number(number) when number == 0, do: signed_zero(not negative?(number)) + defp negate_number(number), do: -number + + defp ordering(left, right) when left == right, do: :eq + defp ordering(:neg_infinity, _right), do: :lt + defp ordering(_left, :neg_infinity), do: :gt + defp ordering(:infinity, _right), do: :gt + defp ordering(_left, :infinity), do: :lt + defp ordering(left, right) when left < right, do: :lt + defp ordering(_left, _right), do: :gt + + defp compare_order(:lt, :lt), do: true + defp compare_order(:lte, order) when order in [:lt, :eq], do: true + defp compare_order(:gt, :gt), do: true + defp compare_order(:gte, order) when order in [:gt, :eq], do: true + defp compare_order(_operation, _order), do: false + + defp odd_integer?(value) when is_integer(value), do: rem(value, 2) != 0 + + defp odd_integer?(value) when is_float(value) and trunc(value) == value, + do: rem(trunc(value), 2) != 0 + + defp odd_integer?(_value), do: false + + defp signed_infinity(true), do: :neg_infinity + defp signed_infinity(false), do: :infinity + defp signed_zero(true), do: -0.0 + defp signed_zero(false), do: 0.0 + + defp negative?(:neg_infinity), do: true + + defp negative?(value) when is_float(value) and value == 0.0 do + <> = <> + sign == 1 + end + + defp negative?(value) when is_number(value), do: value < 0 + defp negative?(_value), do: false + + defp zero?(value) when is_number(value), do: value == 0 + defp zero?(_value), do: false + + defp signed32(value) do + value = band(value, 0xFFFFFFFF) + if value >= 0x80000000, do: value - 0x100000000, else: value + end +end diff --git a/lib/quickbeam/vm/runtime/value/export.ex b/lib/quickbeam/vm/runtime/value/export.ex new file mode 100644 index 000000000..4005d16c8 --- /dev/null +++ b/lib/quickbeam/vm/runtime/value/export.ex @@ -0,0 +1,107 @@ +defmodule QuickBEAM.VM.Runtime.Value.Export do + @moduledoc """ + Converts owner-local JavaScript values into safe ordinary BEAM values. + + Functions and cyclic object graphs are rejected instead of leaking live VM + references outside their evaluation process. + """ + + alias QuickBEAM.VM.Runtime.Exception + alias QuickBEAM.VM.Runtime.Heap + alias QuickBEAM.VM.Runtime.Object + alias QuickBEAM.VM.Runtime.Promise + alias QuickBEAM.VM.Runtime.Promise.Reference, as: PromiseReference + alias QuickBEAM.VM.Runtime.Property.Descriptor + alias QuickBEAM.VM.Runtime.Reference + alias QuickBEAM.VM.Runtime.State + alias QuickBEAM.VM.Runtime.Symbol + + @doc "Exports one owner-local JavaScript value to a safe BEAM term." + @spec value(term(), State.t()) :: {:ok, term()} | {:error, term()} + def value(value, %State{} = execution), do: convert(value, execution, %{}) + + @spec convert(term(), State.t(), %{optional(non_neg_integer()) => true}) :: + {:ok, term()} | {:error, term()} + defp convert(%PromiseReference{} = promise, execution, seen) do + case Promise.state(execution, promise) do + {:fulfilled, value} -> convert(value, execution, seen) + {:rejected, reason} -> {:error, Exception.to_js_error(reason, execution, [])} + :pending -> {:error, :pending_promise_result} + end + end + + defp convert(%Reference{id: id} = reference, execution, seen) do + if Map.has_key?(seen, id) do + {:error, {:cyclic_result, id}} + else + case Heap.fetch_object(execution, reference) do + {:ok, object} -> convert_object(object, execution, Map.put(seen, id, true)) + :error -> {:error, {:invalid_reference, id}} + end + end + end + + defp convert({:closure, _function, _references}, _execution, _seen), + do: {:error, :function_result} + + defp convert(%QuickBEAM.VM.Program.Function{}, _execution, _seen), + do: {:error, :function_result} + + defp convert(%Symbol{}, _execution, _seen), do: {:error, :symbol_result} + + defp convert(value, execution, seen) when is_list(value) do + convert_list(value, execution, seen, []) + end + + defp convert(value, execution, seen) when is_map(value) and not is_struct(value) do + Enum.reduce_while(value, {:ok, %{}}, fn {key, child}, {:ok, result} -> + case convert(child, execution, seen) do + {:ok, child} -> {:cont, {:ok, Map.put(result, key, child)}} + {:error, reason} -> {:halt, {:error, reason}} + end + end) + end + + defp convert(value, _execution, _seen), do: {:ok, value} + + @spec convert_object(Object.t(), State.t(), %{optional(non_neg_integer()) => true}) :: + {:ok, term()} | {:error, term()} + defp convert_object(%Object{callable: callable}, _execution, _seen) when not is_nil(callable), + do: {:error, :function_result} + + defp convert_object(%Object{kind: :array} = object, execution, seen) do + values = + Enum.map(Heap.array_entries(object), fn + {:present, value} -> value + :hole -> :undefined + end) + + convert_list(values, execution, seen, []) + end + + defp convert_object(%Object{properties: properties}, execution, seen) do + properties + |> Enum.filter(fn {key, property} -> + Object.property_enumerable?(property) and not is_struct(key, Symbol) + end) + |> Enum.reduce_while({:ok, %{}}, fn {key, property}, {:ok, result} -> + %Descriptor{value: value} = Object.property_descriptor(property) + + case convert(value, execution, seen) do + {:ok, value} -> {:cont, {:ok, Map.put(result, key, value)}} + {:error, reason} -> {:halt, {:error, reason}} + end + end) + end + + @spec convert_list([term()], State.t(), %{optional(non_neg_integer()) => true}, [term()]) :: + {:ok, [term()]} | {:error, term()} + defp convert_list([], _execution, _seen, result), do: {:ok, Enum.reverse(result)} + + defp convert_list([value | rest], execution, seen, result) do + case convert(value, execution, seen) do + {:ok, value} -> convert_list(rest, execution, seen, [value | result]) + {:error, reason} -> {:error, reason} + end + end +end diff --git a/lib/quickbeam/worker.zig b/lib/quickbeam/worker.zig index d04326371..6e045ca76 100644 --- a/lib/quickbeam/worker.zig +++ b/lib/quickbeam/worker.zig @@ -19,6 +19,17 @@ const e = types.e; const qjs = types.qjs; const gpa = types.gpa; +const AddonClaim = struct { + library: ?*std.DynLib = null, +}; + +// Native addon code and static state live for the OS process lifetime. Keep one +// bounded, serialized handle per canonical path rather than unloading code that +// may still own process-global callbacks or data. +const max_addon_claims = 256; +var addon_load_mutex: std.Thread.Mutex = .{}; +var addon_claims: std.StringHashMapUnmanaged(AddonClaim) = .{}; + pub const Result = struct { ok: bool = false, json: []const u8 = "", @@ -56,6 +67,7 @@ pub const WorkerState = struct { buf: [4096]u8 = @splat(0), drain_fn: ?DrainFn = null, napi_env: ?*napi_mod.NapiEnv = null, + addon_exports: std.StringHashMap(qjs.JSValue), max_reductions: i64 = 0, pub fn deinit(self: *WorkerState) void { @@ -82,7 +94,9 @@ pub const WorkerState = struct { snap.deinit(); } - // Release napi JS refs, then free context, then free napi Zig memory + // Release cached addon exports and napi JS refs before freeing the context. + self.clearAddonExports(); + self.addon_exports.deinit(); if (self.napi_env) |nenv| nenv.releaseValues(); self.atoms.deinit(self.ctx); @@ -99,6 +113,15 @@ pub const WorkerState = struct { } } + fn clearAddonExports(self: *WorkerState) void { + var iterator = self.addon_exports.iterator(); + while (iterator.next()) |entry| { + qjs.JS_FreeValue(self.ctx, entry.value_ptr.*); + gpa.free(entry.key_ptr.*); + } + self.addon_exports.clearRetainingCapacity(); + } + pub fn drain_jobs(self: *WorkerState) void { var pctx: ?*qjs.JSContext = null; while (true) { @@ -593,6 +616,7 @@ pub const WorkerState = struct { self.message_handler = js.JS_UNDEFINED; } + self.clearAddonExports(); if (self.napi_env) |nenv| { nenv.releaseValues(); nenv.deinit(); @@ -720,14 +744,15 @@ pub const WorkerState = struct { fn complete_sync_call(self: *WorkerState, id: u64, result: *Result) void { self.rd.sync_slots_mutex.lock(); - const slot = self.rd.sync_slots.get(id); - self.rd.sync_slots_mutex.unlock(); + defer self.rd.sync_slots_mutex.unlock(); - if (slot) |sync_slot| { + if (self.rd.sync_slots.get(id)) |sync_slot| { sync_slot.ok = result.ok; sync_slot.result_json = result.json; sync_slot.result_env = result.env; sync_slot.result_term = if (result.env != null) result.term else null; + // Keep the slot mutex held through publication. The waiter removes + // this stack-owned slot only after observing the event. sync_slot.done.set(); } else if (result.env) |term_env| { beam.free_env(term_env); @@ -780,7 +805,61 @@ pub const WorkerState = struct { } } - pub fn do_load_addon(self: *WorkerState, path: [:0]const u8, global_name: ?[:0]const u8, result: *Result) void { + fn setAddonError(result: *Result, reason: [:0]const u8, path: []const u8) void { + const env = beam.alloc_env(); + result.ok = false; + result.env = env; + result.term = e.enif_make_tuple2( + env, + beam.make_into_atom(reason, .{ .env = env }).v, + beam.make(path, .{ .env = env }).v, + ); + } + + fn claimAddon(path: []const u8) enum { claimed, already_claimed, limit, oom } { + if (addon_claims.contains(path)) return .already_claimed; + if (addon_claims.count() >= max_addon_claims) return .limit; + + const key = gpa.dupe(u8, path) catch return .oom; + addon_claims.put(gpa, key, .{}) catch { + gpa.free(key); + return .oom; + }; + return .claimed; + } + + fn releaseAddonClaim(path: []const u8) void { + if (addon_claims.fetchRemove(path)) |entry| gpa.free(entry.key); + } + + fn publishAddonExports(self: *WorkerState, env: *napi_mod.NapiEnv, exports: qjs.JSValue, global_name: ?[:0]const u8, result: *Result) void { + if (global_name) |name| { + const global = qjs.JS_GetGlobalObject(self.ctx); + defer qjs.JS_FreeValue(self.ctx, global); + _ = qjs.JS_SetPropertyStr(self.ctx, global, name.ptr, qjs.JS_DupValue(self.ctx, exports)); + + const atom = qjs.JS_NewAtom(self.ctx, name.ptr); + env.addon_globals.append(gpa, atom) catch { + qjs.JS_FreeAtom(self.ctx, atom); + }; + } + + const result_env = beam.alloc_env(); + result.ok = true; + result.term = js_to_beam.convert_with_limits(self.ctx, exports, result_env, self.convert_limits()); + result.env = result_env; + } + + pub fn do_load_addon(self: *WorkerState, path: [:0]const u8, global_name: ?[:0]const u8, allow_reinitialization: bool, result: *Result) void { + const canonical_path = std.fs.cwd().realpathAlloc(gpa, path) catch { + setAddonError(result, "addon_not_found", path); + return; + }; + defer gpa.free(canonical_path); + + addon_load_mutex.lock(); + defer addon_load_mutex.unlock(); + ensureNapiSymbolsGlobal(); if (self.napi_env == null) { @@ -788,19 +867,54 @@ pub const WorkerState = struct { } const env = self.napi_env.?; - napi_mod.clearPendingModule(); + if (self.addon_exports.get(canonical_path)) |exports| { + self.publishAddonExports(env, exports, global_name, result); + return; + } - const lib = gpa.create(std.DynLib) catch { + const claim = claimAddon(canonical_path); + if (claim == .already_claimed and !allow_reinitialization) { + setAddonError(result, "addon_already_initialized", canonical_path); + return; + } + if (claim == .limit) { + setAddonError(result, "addon_load_limit", canonical_path); + return; + } + if (claim == .oom) { result.ok = false; result.json = "OOM"; return; - }; - lib.* = std.DynLib.openZ(path) catch { - gpa.destroy(lib); + } + const new_claim = claim == .claimed; + + const path_z = gpa.dupeZ(u8, canonical_path) catch { + if (new_claim) releaseAddonClaim(canonical_path); result.ok = false; - result.json = "Failed to dlopen addon"; + result.json = "OOM"; return; }; + defer gpa.free(path_z); + + napi_mod.clearPendingModule(); + + const lib = if (new_claim) blk: { + const opened = gpa.create(std.DynLib) catch { + releaseAddonClaim(canonical_path); + result.ok = false; + result.json = "OOM"; + return; + }; + opened.* = std.DynLib.openZ(path_z) catch { + releaseAddonClaim(canonical_path); + gpa.destroy(opened); + result.ok = false; + result.json = "Failed to dlopen addon"; + return; + }; + addon_claims.getPtr(canonical_path).?.library = opened; + break :blk opened; + } else addon_claims.get(canonical_path).?.library.?; const exports = qjs.JS_NewObject(self.ctx); const exports_slot = gpa.create(qjs.JSValue) catch { @@ -836,29 +950,21 @@ pub const WorkerState = struct { } } - // Set exports as a global JS variable if a name was provided - if (global_name) |gn| { - const g = qjs.JS_GetGlobalObject(self.ctx); - defer qjs.JS_FreeValue(self.ctx, g); - _ = qjs.JS_SetPropertyStr(self.ctx, g, gn.ptr, qjs.JS_DupValue(self.ctx, final_exports)); - // Track the atom so we can delete it during cleanup - const atom = qjs.JS_NewAtom(self.ctx, gn.ptr); - env.addon_globals.append(gpa, atom) catch { - qjs.JS_FreeAtom(self.ctx, atom); + const cache_key = gpa.dupe(u8, canonical_path) catch null; + if (cache_key) |key| { + const cached_exports = qjs.JS_DupValue(self.ctx, final_exports); + self.addon_exports.put(key, cached_exports) catch { + qjs.JS_FreeValue(self.ctx, cached_exports); + gpa.free(key); }; } - const result_env = beam.alloc_env(); - result.ok = true; - result.term = js_to_beam.convert_with_limits(self.ctx, final_exports, result_env, self.convert_limits()); - result.env = result_env; + self.publishAddonExports(env, final_exports, global_name, result); - // Free our reference to exports qjs.JS_FreeValue(self.ctx, exports); if (final_exports.tag != exports.tag or qjs.JS_VALUE_GET_PTR(final_exports) != qjs.JS_VALUE_GET_PTR(exports)) { qjs.JS_FreeValue(self.ctx, final_exports); } - // These are standalone DupValue'd slots that need cleanup env.clearPendingException(); } @@ -938,6 +1044,7 @@ pub fn worker_main(rd: *types.RuntimeData, owner_pid: beam.pid) void { .rd = rd, .pending_calls = std.AutoHashMap(u64, PendingCall).init(gpa), .timers = std.AutoHashMap(u64, TimerEntry).init(gpa), + .addon_exports = std.StringHashMap(qjs.JSValue).init(gpa), .start_time = std.time.nanoTimestamp(), .max_reductions = 0, }; @@ -1106,7 +1213,7 @@ pub fn worker_main(rd: *types.RuntimeData, owner_pid: beam.pid) void { }, .load_addon => |p| { var result = Result{}; - state.do_load_addon(p.path, p.global_name, &result); + state.do_load_addon(p.path, p.global_name, p.allow_reinitialization, &result); gpa.free(p.path); if (p.global_name) |gn| gpa.free(gn); types.send_reply(p.caller_pid, p.ref_env, p.ref_term, result.ok, result.env, result.term, result.json); diff --git a/mix.exs b/mix.exs index 37e3c3889..9b068124c 100644 --- a/mix.exs +++ b/mix.exs @@ -13,7 +13,7 @@ defmodule QuickBEAM.MixProject do start_permanent: Mix.env() == :prod, deps: deps(), aliases: aliases(), - dialyzer: [plt_add_apps: [:crypto, :inets, :ssl, :public_key]], + dialyzer: [plt_add_apps: [:crypto, :inets, :mix, :public_key, :ssl]], name: "QuickBEAM", description: "JavaScript runtime for the BEAM — Web APIs backed by OTP, native DOM, and a built-in TypeScript toolchain.", @@ -70,6 +70,9 @@ defmodule QuickBEAM.MixProject do {:ex_dna, "~> 1.1", only: [:dev, :test], runtime: false}, {:ex_slop, "~> 0.2", only: [:dev, :test], runtime: false}, {:jason, "~> 1.4"}, + {:json_codec, "~> 0.2.2", only: :test}, + {:yaml_elixir, "~> 2.12", only: :test}, + {:varint, "~> 1.6"}, {:oxc, "~> 0.17.2"}, {:npm, "~> 0.7.5", optional: true}, {:mint_web_socket, "~> 1.0"}, @@ -84,6 +87,8 @@ defmodule QuickBEAM.MixProject do defp package do [ + # Zigler regenerates this ignored intermediate during source builds. + exclude_patterns: ["lib/quickbeam/.Elixir.QuickBEAM.Native.zig"], licenses: ["MIT"], links: %{ "GitHub" => @source_url @@ -109,7 +114,43 @@ defmodule QuickBEAM.MixProject do groups_for_extras: [ Guides: ["docs/javascript-api.md", "docs/architecture.md"] ], + filter_modules: &documented_module?/2, + skip_code_autolink_to: &skip_doc_warning?/1, + skip_undefined_reference_warnings_on: &skip_doc_warning?/1, source_ref: "v#{@version}" ] end + + defp skip_doc_warning?(reference) when not is_binary(reference), do: false + + defp skip_doc_warning?(reference) do + internal_vm_modules = [ + "QuickBEAM.VM.ABI", + "QuickBEAM.VM.Builtin", + "QuickBEAM.VM.Bytecode", + "QuickBEAM.VM.Compiler", + "QuickBEAM.VM.Fuzz", + "QuickBEAM.VM.Program.Identity", + "QuickBEAM.VM.Program.Store", + "QuickBEAM.VM.Runtime" + ] + + String.starts_with?(reference, "QuickBEAM.Runtime") or + Enum.any?(internal_vm_modules, &String.starts_with?(reference, &1)) + end + + defp documented_module?(module, _metadata) do + public_vm_modules = [ + QuickBEAM.VM.Program.Variable.Closure, + QuickBEAM.VM.Program.Function, + QuickBEAM.VM.Measurement, + QuickBEAM.VM.Program, + QuickBEAM.VM.Program.Pinned, + QuickBEAM.VM.Program.Source, + QuickBEAM.VM.Program.Variable + ] + + module != QuickBEAM.Application and + (not String.starts_with?(inspect(module), "QuickBEAM.VM.") or module in public_vm_modules) + end end diff --git a/mix.lock b/mix.lock index be257bcab..583b57851 100644 --- a/mix.lock +++ b/mix.lock @@ -16,6 +16,7 @@ "hex_solver": {:hex, :hex_solver, "0.2.3", "0d2ee20fbceb251d573f03ef34852e325529c2874ab66d1b576384021318996c", [:mix], [], "hexpm", "9daeae2ea6b8ad3dc7f51a10484f6bc1d0f705c31063383830a7167ab93b887b"}, "hpax": {:hex, :hpax, "1.0.4", "777de5d433b0fbdc7c418159c8055910faa8047ffdb3d6b31098d2a46cd7685c", [:mix], [], "hexpm", "afc7cb142ebcc2d01ce7816190b98ce5dd49e799111b24249f3443d730f377ca"}, "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"}, + "json_codec": {:hex, :json_codec, "0.2.2", "d8f14252e630ae7677ea554879444903375347097765fbeb12532e5423b88b18", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "cb4199b63633285e8f8fb82d4e21fac65974f7499696b61cefb174bd6579bda4"}, "makeup": {:hex, :makeup, "1.2.2", "882d46dc0905e9ff7abf2aab61a7e6b3dcc555533977d8a23b06019e6c89ac94", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "9a1a24e5b343b8ae16abea0822c10a6f75da27af7fa802ada5251f7579bfccfa"}, "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, "makeup_erlang": {:hex, :makeup_erlang, "1.1.0", "835f7e60792e08824cda445639555d7bf1bbbddb1b60b306e33cb6f6db24dc74", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "1cd6780fb1dd1a03979abaed0fe82712b0625118fd5257d3ebbf73f960c73c3c"}, @@ -39,8 +40,11 @@ "statistex": {:hex, :statistex, "1.1.1", "73612aa7f79e53c30569be065fd121e380f1cf57bc4c2da5b41be9246da18df9", [:mix], [], "hexpm", "310c4b49b34adf683de3103639006bed233ab54c08a4add65a531448e653857c"}, "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"}, "thousand_island": {:hex, :thousand_island, "1.5.0", "f50a213cac97262b6d5ebb85745aa2c00fec1413191e6e66834788d45425cecb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "708923d40523e43cf99041ab37a0d4b0ec426ac6438fa3716ab23d919eaeb412"}, + "varint": {:hex, :varint, "1.6.0", "7bced828599b2eb84491a9f067f8a5a67fc35a946d3b7582278083c7fd434b00", [:mix], [], "hexpm", "2b4f4a20650aeebe993a70b03bb99571bcbddc27c361322c28b586d8a772ce4e"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, "websock_adapter": {:hex, :websock_adapter, "0.6.0", "73db5ab8aaefd1a876a97ce3e6afc96562625de69ef17a4e04426e034849d0b8", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "50021a85bce8f203b086705d9e0c5415e2c7eb05d319111b0428fe71f9934617"}, + "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"}, + "yaml_elixir": {:hex, :yaml_elixir, "2.12.2", "9dd1330fb4cd9a36a7b0f502e5b12486eff632792ee4a5f0eba52a4d4ec32c9c", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "e7c1b10122f973e6558462d51c39026ba0e14afbc6745318e990ea82cfe9e159"}, "zig_get": {:hex, :zig_get, "0.15.2", "a6ccaa894213839ba95615bf9be2b2c9268e37ab9547a2344830202cfd6d7cc0", [:mix], [], "hexpm", "e6b0028f2d5a8da791ff8037deff5b492784017d8d241e598377a24bd765f56f"}, "zig_parser": {:hex, :zig_parser, "0.7.0", "be5972c78387a56833599cf9358c498baf7a8ab71959721e0e9ede224b676f52", [:mix], [{:pegasus, "~> 0.2.4", [hex: :pegasus, repo: "hexpm", optional: false]}], "hexpm", "fd0b2fc343820a202787a78a40feae372386b4303717b099223d5f5c5f21f0bb"}, "zigler": {:hex, :zigler, "0.15.2", "d3e8c7b6d88be2eea72c341376b7e7b0aa36248e26d5798b580cad9815311559", [:mix], [{:protoss, "~> 1.0", [hex: :protoss, repo: "hexpm", optional: false]}, {:zig_get, "0.15.2", [hex: :zig_get, repo: "hexpm", optional: false]}, {:zig_parser, "~> 0.6", [hex: :zig_parser, repo: "hexpm", optional: false]}], "hexpm", "6bf96df41e281a70147e8826e439e24945f0222e08d058fc544da84f5c7ea8dd"}, diff --git a/npm.lock b/npm.lock index 22b7bdfce..f73cdd4f0 100644 --- a/npm.lock +++ b/npm.lock @@ -4,39 +4,39 @@ "@babel/helper-string-parser": { "dependencies": {}, "has_install_script": false, - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "optional_dependencies": {}, - "tarball": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "version": "7.27.1" + "tarball": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "version": "7.29.7" }, "@babel/helper-validator-identifier": { "dependencies": {}, "has_install_script": false, - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "optional_dependencies": {}, - "tarball": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "version": "7.28.5" + "tarball": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "version": "7.29.7" }, "@babel/parser": { "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" }, "has_install_script": false, - "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "optional_dependencies": {}, - "tarball": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", - "version": "7.29.3" + "tarball": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "version": "7.29.7" }, "@babel/types": { "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "has_install_script": false, - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "optional_dependencies": {}, - "tarball": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "version": "7.29.0" + "tarball": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "version": "7.29.7" }, "@colors/colors": { "dependencies": {}, @@ -48,34 +48,34 @@ }, "@emnapi/core": { "dependencies": { - "@emnapi/wasi-threads": "1.2.1", + "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" }, "has_install_script": false, - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==", "optional_dependencies": {}, - "tarball": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "version": "1.10.0" + "tarball": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz", + "version": "1.11.2" }, "@emnapi/runtime": { "dependencies": { "tslib": "^2.4.0" }, "has_install_script": false, - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", "optional_dependencies": {}, - "tarball": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "version": "1.10.0" + "tarball": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", + "version": "1.11.2" }, "@emnapi/wasi-threads": { "dependencies": { "tslib": "^2.4.0" }, "has_install_script": false, - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "optional_dependencies": {}, - "tarball": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "version": "1.2.1" + "tarball": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "version": "1.2.2" }, "@jscpd/badge-reporter": { "dependencies": { @@ -952,10 +952,10 @@ "tslib": "^2.4.0" }, "has_install_script": false, - "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "optional_dependencies": {}, - "tarball": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", - "version": "0.10.2" + "tarball": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "version": "0.10.3" }, "@types/sarif": { "dependencies": {}, @@ -1202,10 +1202,10 @@ "es-errors": "^1.3.0" }, "has_install_script": false, - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "optional_dependencies": {}, - "tarball": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "version": "1.1.1" + "tarball": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "version": "1.1.2" }, "eventemitter3": { "dependencies": {}, @@ -1274,10 +1274,10 @@ "universalify": "^2.0.0" }, "has_install_script": false, - "integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==", + "integrity": "sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA==", "optional_dependencies": {}, - "tarball": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz", - "version": "11.3.5" + "tarball": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.6.tgz", + "version": "11.3.6" }, "function-bind": { "dependencies": {}, @@ -1387,10 +1387,10 @@ "function-bind": "^1.1.2" }, "has_install_script": false, - "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "optional_dependencies": {}, - "tarball": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", - "version": "2.0.3" + "tarball": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "version": "2.0.4" }, "human-signals": { "dependencies": {}, @@ -1752,6 +1752,22 @@ "tarball": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", "version": "2.3.2" }, + "preact": { + "dependencies": {}, + "has_install_script": false, + "integrity": "sha512-DCHYrK/B10yUD3ZjLfhZ3WIE/9Vf9VFUODcRE2dRomTYDpJk6z6L9wecSfhfE6M9ZTHUdyQkoC46arIDhEV84Q==", + "optional_dependencies": {}, + "tarball": "https://registry.npmjs.org/preact/-/preact-10.29.7.tgz", + "version": "10.29.7" + }, + "preact-render-to-string": { + "dependencies": {}, + "has_install_script": false, + "integrity": "sha512-Z4WR8fmLMRpdYqJ9i7vrlXSsSrxVJydwrkEXHapexfARbWfGb7vGcnvNQnIzN0cXciMVOlz/XLoiMCi9gUsy9Q==", + "optional_dependencies": {}, + "tarball": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.7.0.tgz", + "version": "6.7.0" + }, "promise": { "dependencies": { "asap": "~2.0.3" diff --git a/package-lock.json b/package-lock.json index 5f72818fc..693f59d44 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "quickbeam-npm06", + "name": "beam-interpreter-v2", "lockfileVersion": 3, "requires": true, "packages": { @@ -9,7 +9,12 @@ "@node-rs/argon2": "^2.0.2", "@node-rs/bcrypt": "^1.10.7", "@node-rs/crc32": "^1.10.6", - "sqlite-napi": "1.2.0" + "@vue/server-renderer": "3.5.39", + "preact": "10.29.7", + "preact-render-to-string": "6.7.0", + "sqlite-napi": "1.2.0", + "svelte": "5.56.4", + "vue": "3.5.39" }, "devDependencies": { "jscpd": "4.0.9", @@ -19,33 +24,30 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", - "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", - "dev": true, + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -55,14 +57,13 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", - "dev": true, + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -104,6 +105,51 @@ "tslib": "^2.4.0" } }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@jscpd/badge-reporter": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/@jscpd/badge-reporter/-/badge-reporter-4.0.5.tgz", @@ -965,7 +1011,8 @@ "optional": true, "os": [ "darwin" - ] + ], + "peer": true }, "node_modules/@oven/bun-darwin-x64": { "version": "1.3.13", @@ -978,7 +1025,8 @@ "optional": true, "os": [ "darwin" - ] + ], + "peer": true }, "node_modules/@oven/bun-darwin-x64-baseline": { "version": "1.3.13", @@ -991,7 +1039,8 @@ "optional": true, "os": [ "darwin" - ] + ], + "peer": true }, "node_modules/@oven/bun-linux-aarch64": { "version": "1.3.13", @@ -1004,7 +1053,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@oven/bun-linux-aarch64-musl": { "version": "1.3.13", @@ -1017,7 +1067,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@oven/bun-linux-x64": { "version": "1.3.13", @@ -1030,7 +1081,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@oven/bun-linux-x64-baseline": { "version": "1.3.13", @@ -1043,7 +1095,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@oven/bun-linux-x64-musl": { "version": "1.3.13", @@ -1056,7 +1109,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@oven/bun-linux-x64-musl-baseline": { "version": "1.3.13", @@ -1069,7 +1123,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@oven/bun-windows-aarch64": { "version": "1.3.13", @@ -1082,7 +1137,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@oven/bun-windows-x64": { "version": "1.3.13", @@ -1095,7 +1151,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@oven/bun-windows-x64-baseline": { "version": "1.3.13", @@ -1108,7 +1165,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@oxfmt/binding-android-arm-eabi": { "version": "0.48.0", @@ -1848,6 +1906,12 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, "node_modules/@types/sarif": { "version": "2.1.7", "resolved": "https://registry.npmjs.org/@types/sarif/-/sarif-2.1.7.tgz", @@ -1855,6 +1919,112 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT" + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.39.tgz", + "integrity": "sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/shared": "3.5.39", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.39.tgz", + "integrity": "sha512-oQPigALqYbNxTNPvNgSOe+czwVExfbVF02lz8jP0S3AXJiu3jxYDygNUiqSep4ezzW8XgnubqH63My2A7JR/vg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.39.tgz", + "integrity": "sha512-d0ki86iOyN8LoZPBmk5SJWNwHP19CnDDCfuo//+2WJa2g5Ke0Jay983PIBIcSSzldC68I8DrD5GrHV3OSDfodg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/compiler-core": "3.5.39", + "@vue/compiler-dom": "3.5.39", + "@vue/compiler-ssr": "3.5.39", + "@vue/shared": "3.5.39", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.15", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.39.tgz", + "integrity": "sha512-Ce7/wvwMHai74bdszfXExdazFigYnlF9zgCmEQUcM1j0fOymlouZ7XilTYNo8oUjhlnjYOZbGrcYKuqjz89Ucw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.39.tgz", + "integrity": "sha512-TpsuBJ9gGlZa5d23XcM2y8EXanz9dZeVDQBXRwzy46ItgvM+rWpzs+UVM0wcRLxGvcav0HE5jz2gNL53xlRAog==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.39.tgz", + "integrity": "sha512-9GLtNyRvPAUMbX+7ono0RC2j0guo2LXVi8LvcmAooImACUKm0oFf0jjwbX8/H0AE/t1nxhAkn8RSl9PMCzzxZw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.39.tgz", + "integrity": "sha512-7Y6aAGboKcXAZ3ECuUy7RrS5yy2r47dhTp2SKaJmYxjopImaVFaNa5Ne66NwGovsrxVAl5S5rwc7m22UG7Lmww==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.39", + "@vue/runtime-core": "3.5.39", + "@vue/shared": "3.5.39", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.39.tgz", + "integrity": "sha512-yZSakiAGw85rZfG7UM8akMnIF+FmeiNk47uvHf2nVBBSe+dIKUhZuZq9+XgJhbV3nS5Z4ALH23/MpXofW+mbcw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.39", + "@vue/shared": "3.5.39" + }, + "peerDependencies": { + "vue": "3.5.39" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.39.tgz", + "integrity": "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==", + "license": "MIT" + }, "node_modules/acorn": { "version": "7.4.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", @@ -1878,6 +2048,15 @@ "node": ">=8" } }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/asap": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", @@ -1892,6 +2071,15 @@ "dev": true, "license": "MIT" }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/babel-walk": { "version": "3.0.0-canary-5", "resolved": "https://registry.npmjs.org/babel-walk/-/babel-walk-3.0.0-canary-5.tgz", @@ -2039,6 +2227,15 @@ "@colors/colors": "1.5.0" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/colors": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", @@ -2083,6 +2280,18 @@ "node": ">= 8" } }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/devalue": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", + "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", + "license": "MIT" + }, "node_modules/doctypes": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/doctypes/-/doctypes-1.1.0.tgz", @@ -2122,6 +2331,18 @@ "once": "^1.4.0" } }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -2155,6 +2376,35 @@ "node": ">= 0.4" } }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.2.13", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.13.tgz", + "integrity": "sha512-m8jH5hZgJE2RRUK/jjkGPcJEDAV+dYnZYFkosQaPTcE+Yw4xynXHOo6FUdwaWBtdR3b1MMa7wEDTSHeR2VWsGA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, "node_modules/eventemitter3": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", @@ -2454,6 +2704,15 @@ "dev": true, "license": "MIT" }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -2557,6 +2816,21 @@ "promise": "^7.0.1" } }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/markdown-table": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-2.0.0.tgz", @@ -2618,6 +2892,24 @@ "node": ">=6" } }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/node-sarif-builder": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/node-sarif-builder/-/node-sarif-builder-3.4.0.tgz", @@ -2772,7 +3064,6 @@ "integrity": "sha512-YUSGSLUnoolsu8gxISEDio3q1rtsCozwfOzASUn3DT2mR2EeQ93uEEnen7s+6LpF+lyTQFln1pQfqwBh/fsVEg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "tsgolint": "bin/tsgolint.js" }, @@ -2802,6 +3093,12 @@ "dev": true, "license": "MIT" }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, "node_modules/picomatch": { "version": "2.3.2", "dev": true, @@ -2813,6 +3110,61 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/postcss": { + "version": "8.5.19", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", + "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/preact": { + "version": "10.29.7", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.7.tgz", + "integrity": "sha512-DCHYrK/B10yUD3ZjLfhZ3WIE/9Vf9VFUODcRE2dRomTYDpJk6z6L9wecSfhfE6M9ZTHUdyQkoC46arIDhEV84Q==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + }, + "peerDependencies": { + "preact-render-to-string": ">=5" + }, + "peerDependenciesMeta": { + "preact-render-to-string": { + "optional": true + } + } + }, + "node_modules/preact-render-to-string": { + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.7.0.tgz", + "integrity": "sha512-Z4WR8fmLMRpdYqJ9i7vrlXSsSrxVJydwrkEXHapexfARbWfGb7vGcnvNQnIzN0cXciMVOlz/XLoiMCi9gUsy9Q==", + "license": "MIT", + "peerDependencies": { + "preact": ">=10 || >= 11.0.0-0" + } + }, "node_modules/promise": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz", @@ -3089,6 +3441,15 @@ "dev": true, "license": "ISC" }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/spark-md5": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/spark-md5/-/spark-md5-3.0.2.tgz", @@ -3160,6 +3521,54 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svelte": { + "version": "5.56.4", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.4.tgz", + "integrity": "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.10", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.8.1", + "esm-env": "^1.2.1", + "esrap": "^2.2.12", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte/node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.11.tgz", + "integrity": "sha512-LFuZUkjJ9iF7JZye/aG5XM0SFcQ5VyL0oVX4WJ9dc0Va3R3s0OauX1BESVCb+YN/ol8TAfqGDDAQsTG627Y5kw==", + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/svelte/node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/tinypool": { "version": "2.1.0", "dev": true, @@ -3211,6 +3620,27 @@ "node": ">=0.10.0" } }, + "node_modules/vue": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.39.tgz", + "integrity": "sha512-xmZCYabFGcirU8r0fTuvl/LICc1OU620rnqepaJDL/a141ZigkG7AyaxQLdqJ02ZRYzWe6YPaDHeQx7MfknQfA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.39", + "@vue/compiler-sfc": "3.5.39", + "@vue/runtime-dom": "3.5.39", + "@vue/server-renderer": "3.5.39", + "@vue/shared": "3.5.39" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -3249,6 +3679,12 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "dev": true, "license": "ISC" + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "license": "MIT" } } } diff --git a/package.json b/package.json index a85b9f53a..a8bf6a4d6 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,12 @@ "@node-rs/argon2": "^2.0.2", "@node-rs/bcrypt": "^1.10.7", "@node-rs/crc32": "^1.10.6", - "sqlite-napi": "1.2.0" + "@vue/server-renderer": "3.5.39", + "preact": "10.29.7", + "preact-render-to-string": "6.7.0", + "sqlite-napi": "1.2.0", + "svelte": "5.56.4", + "vue": "3.5.39" }, "devDependencies": { "jscpd": "4.0.9", @@ -14,6 +19,7 @@ "private": true, "scripts": { "postinstall": "chmod +x node_modules/@oxlint-tsgolint/*/tsgolint 2>/dev/null || true", + "fixtures:svelte": "node scripts/generate-svelte-ssr-fixture.mjs", "format": "oxfmt --write priv/ts/", "format:check": "oxfmt --check priv/ts/", "lint": "oxlint -c oxlint.json --type-aware --type-check priv/ts/" diff --git a/scripts/generate-svelte-ssr-fixture.mjs b/scripts/generate-svelte-ssr-fixture.mjs new file mode 100644 index 000000000..27a8e76c2 --- /dev/null +++ b/scripts/generate-svelte-ssr-fixture.mjs @@ -0,0 +1,16 @@ +import fs from "node:fs"; +import { compile } from "svelte/compiler"; + +const input = "test/fixtures/vm/svelte_catalog.svelte"; +const output = "test/fixtures/vm/svelte_catalog.server.js"; +const source = fs.readFileSync(input, "utf8"); +const result = compile(source, { + filename: input, + generate: "server", + dev: false, +}); + +fs.writeFileSync( + output, + `// Generated from svelte_catalog.svelte by svelte@5.56.4.\n${result.js.code}\n`, +); diff --git a/test/fixtures/vm/fuzz/regressions/truncated-vardef-flags.bin b/test/fixtures/vm/fuzz/regressions/truncated-vardef-flags.bin new file mode 100644 index 000000000..694eeae63 Binary files /dev/null and b/test/fixtures/vm/fuzz/regressions/truncated-vardef-flags.bin differ diff --git a/test/fixtures/vm/fuzz/regressions/truncated-vardef-flags.txt b/test/fixtures/vm/fuzz/regressions/truncated-vardef-flags.txt new file mode 100644 index 000000000..b0251f28b --- /dev/null +++ b/test/fixtures/vm/fuzz/regressions/truncated-vardef-flags.txt @@ -0,0 +1,3 @@ +classification: truncated vardef flags +expected: stable typed :unexpected_end rejection +sha256: 27a2701c24e5ed0962b4cd18aa6e772306dcd50a04348c99d2ad8cf132998f6a diff --git a/test/fixtures/vm/preact_ssr.js b/test/fixtures/vm/preact_ssr.js new file mode 100644 index 000000000..afcb8e917 --- /dev/null +++ b/test/fixtures/vm/preact_ssr.js @@ -0,0 +1,30 @@ +import { h } from "preact"; +import renderToString from "preact-render-to-string"; + +function App({ title, products }) { + return h( + "main", + { class: "catalog" }, + h("h1", null, title), + h( + "ul", + null, + products.map((product) => + h( + "li", + { class: product.inStock ? "available" : "backorder", "data-id": product.id }, + product.name, + ": $", + (product.priceCents / 100).toFixed(2) + ) + ) + ) + ); +} + +globalThis.__quickbeamSSRResult = (async function render() { + const props = await Beam.call("load_props"); + return renderToString(h(App, props)); +})(); + +globalThis.__quickbeamSSRResult; diff --git a/test/fixtures/vm/svelte_catalog.server.js b/test/fixtures/vm/svelte_catalog.server.js new file mode 100644 index 000000000..b9d72ab05 --- /dev/null +++ b/test/fixtures/vm/svelte_catalog.server.js @@ -0,0 +1,28 @@ +// Generated from svelte_catalog.svelte by svelte@5.56.4. +import * as $ from 'svelte/internal/server'; + +export default function Svelte_catalog($$renderer, $$props) { + $$renderer.component(($$renderer) => { + let title = $$props['title']; + let products = $$props['products']; + + $.head('1vkpw87', $$renderer, ($$renderer) => { + $$renderer.title(($$renderer) => { + $$renderer.push(`${$.escape(title)}`); + }); + }); + + $$renderer.push(`

${$.escape(title)}

    `); + + const each_array = $.ensure_array_like(products); + + for (let $$index = 0, $$length = each_array.length; $$index < $$length; $$index++) { + let product = each_array[$$index]; + + $$renderer.push(`${$.escape(product.name)}: $${$.escape((product.priceCents / 100).toFixed(2))}`); + } + + $$renderer.push(`
`); + $.bind_props($$props, { title, products }); + }); +} diff --git a/test/fixtures/vm/svelte_catalog.svelte b/test/fixtures/vm/svelte_catalog.svelte new file mode 100644 index 000000000..a08dd004c --- /dev/null +++ b/test/fixtures/vm/svelte_catalog.svelte @@ -0,0 +1,20 @@ + + + + {title} + + +
+

{title}

+
    + {#each products as product} +
  • {product.name}: ${(product.priceCents / 100).toFixed(2)}
  • + {/each} +
+
diff --git a/test/fixtures/vm/svelte_ssr.js b/test/fixtures/vm/svelte_ssr.js new file mode 100644 index 000000000..5121a900c --- /dev/null +++ b/test/fixtures/vm/svelte_ssr.js @@ -0,0 +1,37 @@ +import Catalog from "./svelte_catalog.server.js"; + +class FixtureRenderer { + constructor(shared = { body: "", head: "" }, type = "body") { + this.shared = shared; + this.type = type; + } + + component(render) { + render(this); + } + + child(render) { + render(this); + } + + head(render) { + render(new FixtureRenderer(this.shared, "head")); + } + + title(render) { + render(this); + } + + push(fragment) { + this.shared[this.type] += fragment; + } +} + +globalThis.__quickbeamSSRResult = (async function renderCatalog() { + const props = await Beam.call("load_props"); + const renderer = new FixtureRenderer(); + Catalog(renderer, props); + return renderer.shared; +})(); + +globalThis.__quickbeamSSRResult; diff --git a/test/fixtures/vm/vue_ssr.js b/test/fixtures/vm/vue_ssr.js new file mode 100644 index 000000000..cae591727 --- /dev/null +++ b/test/fixtures/vm/vue_ssr.js @@ -0,0 +1,30 @@ +import { createSSRApp, h } from "vue"; +import { renderToString } from "@vue/server-renderer"; + +function Catalog(props) { + return h("main", { class: "catalog" }, [ + h("h1", null, props.title), + h( + "ul", + null, + props.products.map((product) => + h( + "li", + { + class: product.inStock ? "available" : "sold-out", + "data-id": product.id, + }, + `${product.name}: $${(product.priceCents / 100).toFixed(2)}`, + ), + ), + ), + ]); +} + +globalThis.__quickbeamSSRResult = (async function render() { + const props = await Beam.call("load_props"); + const app = createSSRApp(Catalog, props); + return renderToString(app); +})(); + +globalThis.__quickbeamSSRResult; diff --git a/test/napi_test.exs b/test/napi_test.exs index 83156a5c6..33fb202c4 100644 --- a/test/napi_test.exs +++ b/test/napi_test.exs @@ -1,5 +1,7 @@ defmodule QuickBEAM.NapiTest do - use ExUnit.Case, async: true + # Third-party addons may own process-global native state and cannot be loaded + # concurrently into independent runtimes unless the addon documents that as safe. + use ExUnit.Case, async: false @test_addon Path.expand("support/test_addon.node", __DIR__) @node_modules Path.expand("../node_modules", __DIR__) @@ -32,11 +34,16 @@ defmodule QuickBEAM.NapiTest do Path.join(@node_modules, "sqlite-napi/sqlite-napi.#{platform}-#{arch}#{suffix}.node") end + defp reinitialize_addon(runtime, path, opts) do + QuickBEAM.load_addon(runtime, path, Keyword.put(opts, :allow_reinitialization, true)) + end + describe "test addon" do @describetag :napi_addon + @describetag :napi_test_addon test "load and inspect exports" do {:ok, rt} = QuickBEAM.start() - {:ok, exports} = QuickBEAM.load_addon(rt, @test_addon, as: "addon") + {:ok, exports} = reinitialize_addon(rt, @test_addon, as: "addon") assert Map.has_key?(exports, "hello") assert Map.has_key?(exports, "add") assert Map.has_key?(exports, "concat") @@ -49,14 +56,14 @@ defmodule QuickBEAM.NapiTest do test "call string function" do {:ok, rt} = QuickBEAM.start() - {:ok, _} = QuickBEAM.load_addon(rt, @test_addon, as: "addon") + {:ok, _} = reinitialize_addon(rt, @test_addon, as: "addon") assert {:ok, "hello from napi"} = QuickBEAM.eval(rt, "addon.hello()") QuickBEAM.stop(rt) end test "call numeric function" do {:ok, rt} = QuickBEAM.start() - {:ok, _} = QuickBEAM.load_addon(rt, @test_addon, as: "addon") + {:ok, _} = reinitialize_addon(rt, @test_addon, as: "addon") assert {:ok, 7} = QuickBEAM.eval(rt, "addon.add(3, 4)") assert {:ok, 0} = QuickBEAM.eval(rt, "addon.add(-5, 5)") assert {:ok, result} = QuickBEAM.eval(rt, "addon.add(1.5, 2.5)") @@ -66,7 +73,7 @@ defmodule QuickBEAM.NapiTest do test "call string concatenation" do {:ok, rt} = QuickBEAM.start() - {:ok, _} = QuickBEAM.load_addon(rt, @test_addon, as: "addon") + {:ok, _} = reinitialize_addon(rt, @test_addon, as: "addon") assert {:ok, "foobar"} = QuickBEAM.eval(rt, ~s[addon.concat("foo", "bar")]) assert {:ok, ""} = QuickBEAM.eval(rt, ~s[addon.concat("", "")]) QuickBEAM.stop(rt) @@ -74,7 +81,7 @@ defmodule QuickBEAM.NapiTest do test "call typeof checker" do {:ok, rt} = QuickBEAM.start() - {:ok, _} = QuickBEAM.load_addon(rt, @test_addon, as: "addon") + {:ok, _} = reinitialize_addon(rt, @test_addon, as: "addon") assert {:ok, "number"} = QuickBEAM.eval(rt, "addon.getType(42)") assert {:ok, "string"} = QuickBEAM.eval(rt, ~s[addon.getType("hi")]) assert {:ok, "boolean"} = QuickBEAM.eval(rt, "addon.getType(true)") @@ -87,7 +94,7 @@ defmodule QuickBEAM.NapiTest do test "call object creator" do {:ok, rt} = QuickBEAM.start() - {:ok, _} = QuickBEAM.load_addon(rt, @test_addon, as: "addon") + {:ok, _} = reinitialize_addon(rt, @test_addon, as: "addon") assert {:ok, %{"key" => "name", "value" => "QuickBEAM"}} = QuickBEAM.eval(rt, ~s[addon.createObject("name", "QuickBEAM")]) @@ -97,21 +104,21 @@ defmodule QuickBEAM.NapiTest do test "call array creator" do {:ok, rt} = QuickBEAM.start() - {:ok, _} = QuickBEAM.load_addon(rt, @test_addon, as: "addon") + {:ok, _} = reinitialize_addon(rt, @test_addon, as: "addon") assert {:ok, [10, 20, 30]} = QuickBEAM.eval(rt, "addon.makeArray(10, 20, 30)") QuickBEAM.stop(rt) end test "access scalar export" do {:ok, rt} = QuickBEAM.start() - {:ok, _} = QuickBEAM.load_addon(rt, @test_addon, as: "addon") + {:ok, _} = reinitialize_addon(rt, @test_addon, as: "addon") assert {:ok, 42} = QuickBEAM.eval(rt, "addon.version") QuickBEAM.stop(rt) end test "buffers are exposed as Uint8Array and buffer info reads bytes" do {:ok, rt} = QuickBEAM.start() - {:ok, _} = QuickBEAM.load_addon(rt, @test_addon, as: "addon") + {:ok, _} = reinitialize_addon(rt, @test_addon, as: "addon") assert {:ok, "Uint8Array"} = QuickBEAM.eval(rt, "addon.bufferKind()") assert {:ok, [10, 20, 30, 40]} = QuickBEAM.eval(rt, "addon.bufferInfo()") @@ -123,7 +130,7 @@ defmodule QuickBEAM.NapiTest do test "coerce to object preserves JS wrapper semantics" do {:ok, rt} = QuickBEAM.start() - {:ok, _} = QuickBEAM.load_addon(rt, @test_addon, as: "addon") + {:ok, _} = reinitialize_addon(rt, @test_addon, as: "addon") assert {:ok, "String"} = QuickBEAM.eval(rt, ~s[addon.coerceObjectType("hello")]) assert {:ok, "Number"} = QuickBEAM.eval(rt, "addon.coerceObjectType(123)") QuickBEAM.stop(rt) @@ -131,7 +138,7 @@ defmodule QuickBEAM.NapiTest do test "wrap and unwrap round-trip native pointer" do {:ok, rt} = QuickBEAM.start() - {:ok, _} = QuickBEAM.load_addon(rt, @test_addon, as: "addon") + {:ok, _} = reinitialize_addon(rt, @test_addon, as: "addon") assert {:ok, 1234} = QuickBEAM.eval(rt, "addon.wrapAndUnwrap()") assert {:ok, 5678} = QuickBEAM.eval(rt, "addon.removeWrapValue()") QuickBEAM.stop(rt) @@ -139,7 +146,7 @@ defmodule QuickBEAM.NapiTest do test "reset remains stable after wrapped objects and external buffers" do {:ok, rt} = QuickBEAM.start() - {:ok, _} = QuickBEAM.load_addon(rt, @test_addon, as: "addon") + {:ok, _} = reinitialize_addon(rt, @test_addon, as: "addon") assert {:ok, %{"wraps" => _wraps_before, "externalBuffers" => _buffers_before}} = QuickBEAM.eval(rt, "addon.finalizedCounts()") @@ -153,7 +160,7 @@ defmodule QuickBEAM.NapiTest do assert {:ok, 1} = QuickBEAM.eval(rt, "addon.clearExternalBufferKeepalive()") assert :ok = QuickBEAM.reset(rt) - {:ok, _} = QuickBEAM.load_addon(rt, @test_addon, as: "addon") + {:ok, _} = reinitialize_addon(rt, @test_addon, as: "addon") assert {:ok, %{"wraps" => _wraps_after, "externalBuffers" => _buffers_after}} = QuickBEAM.eval(rt, "addon.finalizedCounts()") @@ -163,26 +170,106 @@ defmodule QuickBEAM.NapiTest do end describe "error handling" do + @describetag :napi_test_addon test "invalid path" do {:ok, rt} = QuickBEAM.start() - assert {:error, _} = QuickBEAM.load_addon(rt, "/nonexistent/addon.node") + + assert {:error, {:addon_not_found, "/nonexistent/addon.node"}} = + QuickBEAM.load_addon(rt, "/nonexistent/addon.node") + QuickBEAM.stop(rt) end test "runtime remains functional after failed load" do {:ok, rt} = QuickBEAM.start() - {:error, _} = QuickBEAM.load_addon(rt, "/nonexistent/addon.node") + + assert {:error, {:addon_not_found, "/nonexistent/addon.node"}} = + QuickBEAM.load_addon(rt, "/nonexistent/addon.node") + + assert {:ok, 42} = QuickBEAM.eval(rt, "42") + QuickBEAM.stop(rt) + end + + test "validates addon lifecycle options" do + {:ok, rt} = QuickBEAM.start() + + assert {:error, {:invalid_option, :as, :addon}} = + QuickBEAM.load_addon(rt, @test_addon, as: :addon) + + assert {:error, {:invalid_option, :allow_reinitialization, :sometimes}} = + QuickBEAM.load_addon(rt, @test_addon, allow_reinitialization: :sometimes) + + assert {:error, {:unknown_option, :unknown}} = + QuickBEAM.load_addon(rt, @test_addon, unknown: true) + assert {:ok, 42} = QuickBEAM.eval(rt, "42") QuickBEAM.stop(rt) end - test "multiple addon loads" do + test "serializes concurrent first initialization" do + copy = + Path.join( + System.tmp_dir!(), + "quickbeam-addon-#{System.unique_integer([:positive])}.node" + ) + + File.cp!(@test_addon, copy) + runtimes = Enum.map(1..8, fn _index -> elem(QuickBEAM.start(), 1) end) + + on_exit(fn -> + Enum.each(runtimes, fn runtime -> + if Process.alive?(runtime), do: QuickBEAM.stop(runtime) + end) + + File.rm(copy) + end) + + results = + runtimes + |> Task.async_stream( + &QuickBEAM.load_addon(&1, copy, as: "addon"), + max_concurrency: 8, + ordered: false, + timeout: 5_000 + ) + |> Enum.map(fn {:ok, result} -> result end) + + assert Enum.count(results, &match?({:ok, _exports}, &1)) == 1 + + rejected_paths = + for {:error, {:addon_already_initialized, rejected_path}} <- results, + do: rejected_path + + assert length(rejected_paths) == 7 + assert rejected_paths |> Enum.uniq() |> length() == 1 + assert rejected_paths |> hd() |> Path.basename() == Path.basename(copy) + assert Enum.all?(runtimes, fn runtime -> QuickBEAM.eval(runtime, "42") == {:ok, 42} end) + end + + test "caches aliases locally and rejects implicit native reinitialization" do {:ok, rt} = QuickBEAM.start() - {:ok, _} = QuickBEAM.load_addon(rt, @test_addon, as: "a") + {:ok, _} = reinitialize_addon(rt, @test_addon, as: "a") {:ok, _} = QuickBEAM.load_addon(rt, @test_addon, as: "b") assert {:ok, "hello from napi"} = QuickBEAM.eval(rt, "a.hello()") assert {:ok, "hello from napi"} = QuickBEAM.eval(rt, "b.hello()") + + assert :ok = QuickBEAM.reset(rt) + + assert {:error, {:addon_already_initialized, rejected_path}} = + QuickBEAM.load_addon(rt, @test_addon, as: "afterReset") + + assert Path.basename(rejected_path) == Path.basename(@test_addon) + + assert {:ok, 42} = QuickBEAM.eval(rt, "42") QuickBEAM.stop(rt) + + {:ok, other_rt} = QuickBEAM.start() + + assert {:error, {:addon_already_initialized, ^rejected_path}} = + QuickBEAM.load_addon(other_rt, @test_addon, as: "other") + + assert {:ok, 42} = QuickBEAM.eval(other_rt, "42") + QuickBEAM.stop(other_rt) end end @@ -194,7 +281,7 @@ defmodule QuickBEAM.NapiTest do if !File.exists?(path), do: flunk("addon not found at #{path} — run mix npm.install") {:ok, rt} = QuickBEAM.start() - {:ok, exports} = QuickBEAM.load_addon(rt, path, as: "crc32mod") + {:ok, exports} = reinitialize_addon(rt, path, as: "crc32mod") assert Map.has_key?(exports, "crc32") assert Map.has_key?(exports, "crc32c") QuickBEAM.stop(rt) @@ -205,7 +292,7 @@ defmodule QuickBEAM.NapiTest do if !File.exists?(path), do: flunk("addon not found at #{path} — run mix npm.install") {:ok, rt} = QuickBEAM.start() - {:ok, _} = QuickBEAM.load_addon(rt, path, as: "crc32mod") + {:ok, _} = reinitialize_addon(rt, path, as: "crc32mod") assert {:ok, 907_060_870} = QuickBEAM.eval(rt, ~s[crc32mod.crc32("hello")]) QuickBEAM.stop(rt) end @@ -215,7 +302,7 @@ defmodule QuickBEAM.NapiTest do if !File.exists?(path), do: flunk("addon not found at #{path} — run mix npm.install") {:ok, rt} = QuickBEAM.start() - {:ok, _} = QuickBEAM.load_addon(rt, path, as: "crc32mod") + {:ok, _} = reinitialize_addon(rt, path, as: "crc32mod") assert {:ok, result} = QuickBEAM.eval(rt, ~s[crc32mod.crc32c("hello")]) assert is_integer(result) and result > 0 QuickBEAM.stop(rt) @@ -226,7 +313,7 @@ defmodule QuickBEAM.NapiTest do if !File.exists?(path), do: flunk("addon not found at #{path} — run mix npm.install") {:ok, rt} = QuickBEAM.start() - {:ok, _} = QuickBEAM.load_addon(rt, path, as: "crc32mod") + {:ok, _} = reinitialize_addon(rt, path, as: "crc32mod") assert {:ok, 0} = QuickBEAM.eval(rt, ~s[crc32mod.crc32("")]) QuickBEAM.stop(rt) end @@ -240,7 +327,7 @@ defmodule QuickBEAM.NapiTest do if !File.exists?(path), do: flunk("addon not found at #{path} — run mix npm.install") {:ok, rt} = QuickBEAM.start() - {:ok, exports} = QuickBEAM.load_addon(rt, path, as: "argon2") + {:ok, exports} = reinitialize_addon(rt, path, as: "argon2") assert Map.has_key?(exports, "hashSync") assert Map.has_key?(exports, "verifySync") assert exports["Algorithm"] == %{"Argon2d" => 0, "Argon2i" => 1, "Argon2id" => 2} @@ -252,7 +339,7 @@ defmodule QuickBEAM.NapiTest do if !File.exists?(path), do: flunk("addon not found at #{path} — run mix npm.install") {:ok, rt} = QuickBEAM.start() - {:ok, _} = QuickBEAM.load_addon(rt, path, as: "argon2") + {:ok, _} = reinitialize_addon(rt, path, as: "argon2") {:ok, hash} = QuickBEAM.eval(rt, ~s[argon2.hashSync("password123")]) assert String.starts_with?(hash, "$argon2") @@ -272,7 +359,7 @@ defmodule QuickBEAM.NapiTest do if !File.exists?(path), do: flunk("addon not found at #{path} — run mix npm.install") {:ok, rt} = QuickBEAM.start() - {:ok, exports} = QuickBEAM.load_addon(rt, path, as: "bcrypt") + {:ok, exports} = reinitialize_addon(rt, path, as: "bcrypt") assert Map.has_key?(exports, "hashSync") assert Map.has_key?(exports, "verifySync") assert exports["DEFAULT_COST"] == 12 @@ -284,7 +371,7 @@ defmodule QuickBEAM.NapiTest do if !File.exists?(path), do: flunk("addon not found at #{path} — run mix npm.install") {:ok, rt} = QuickBEAM.start() - {:ok, _} = QuickBEAM.load_addon(rt, path, as: "bcrypt") + {:ok, _} = reinitialize_addon(rt, path, as: "bcrypt") {:ok, hash} = QuickBEAM.eval(rt, ~s[bcrypt.hashSync("password123", 4)]) assert String.starts_with?(hash, "$2") @@ -300,7 +387,7 @@ defmodule QuickBEAM.NapiTest do if !File.exists?(path), do: flunk("addon not found at #{path} — run mix npm.install") {:ok, rt} = QuickBEAM.start() - {:ok, _} = QuickBEAM.load_addon(rt, path, as: "bcrypt") + {:ok, _} = reinitialize_addon(rt, path, as: "bcrypt") {:ok, salt} = QuickBEAM.eval(rt, "bcrypt.genSaltSync(4)") assert String.starts_with?(salt, "$2b$04$") QuickBEAM.stop(rt) @@ -310,7 +397,7 @@ defmodule QuickBEAM.NapiTest do describe "sqlite-napi" do @describetag :napi_sqlite - test "create database and execute SQL" do + test "executes SQL and parameterized statements within one addon lifecycle" do path = sqlite_path() if !File.exists?(path), do: flunk("addon not found at #{path} — run mix npm.install") @@ -319,33 +406,31 @@ defmodule QuickBEAM.NapiTest do assert {:ok, "ok"} = QuickBEAM.eval(rt, """ - const db = new sqlite.Database(":memory:"); - db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)"); - db.exec("INSERT INTO t VALUES (1, 'hello')"); - db.exec("INSERT INTO t VALUES (2, 'world')"); + const first = new sqlite.Database(":memory:"); + first.exec("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)"); + first.exec("INSERT INTO t VALUES (1, 'hello')"); + first.exec("INSERT INTO t VALUES (2, 'world')"); + + const second = new sqlite.Database(":memory:"); + second.exec("CREATE TABLE kv (key TEXT, val TEXT)"); + second.run("INSERT INTO kv VALUES (?, ?)", "greeting", "hello"); + second.run("INSERT INTO kv VALUES (?, ?)", "target", "world"); "ok" """) + assert {:ok, _exports} = QuickBEAM.load_addon(rt, path, as: "sqliteAlias") + assert {:ok, "function"} = QuickBEAM.eval(rt, "typeof sqliteAlias.Database") QuickBEAM.stop(rt) - end - test "parameterized insert with db.run" do - path = sqlite_path() - if !File.exists?(path), do: flunk("addon not found at #{path} — run mix npm.install") + {:ok, other_rt} = QuickBEAM.start() - {:ok, rt} = QuickBEAM.start() - {:ok, _} = QuickBEAM.load_addon(rt, path, as: "sqlite") + assert {:error, {:addon_already_initialized, rejected_path}} = + QuickBEAM.load_addon(other_rt, path, as: "sqlite") - assert {:ok, "ok"} = - QuickBEAM.eval(rt, """ - const db = new sqlite.Database(":memory:"); - db.exec("CREATE TABLE kv (key TEXT, val TEXT)"); - db.run("INSERT INTO kv VALUES (?, ?)", "greeting", "hello"); - db.run("INSERT INTO kv VALUES (?, ?)", "target", "world"); - "ok" - """) + assert Path.basename(rejected_path) == Path.basename(path) - QuickBEAM.stop(rt) + assert {:ok, 42} = QuickBEAM.eval(other_rt, "42") + QuickBEAM.stop(other_rt) end end end diff --git a/test/native_lifecycle_test.exs b/test/native_lifecycle_test.exs new file mode 100644 index 000000000..cfa1e25be --- /dev/null +++ b/test/native_lifecycle_test.exs @@ -0,0 +1,80 @@ +defmodule QuickBEAM.NativeLifecycleTest do + use ExUnit.Case, async: false + + @tag timeout: 120_000 + test "serializes concurrent runtime startup, explicit shutdown, and destruction" do + tasks = + for worker <- 1..8 do + Task.async(fn -> + for iteration <- 1..15 do + {:ok, runtime} = QuickBEAM.start() + expected = worker + iteration + assert {:ok, ^expected} = QuickBEAM.eval(runtime, "#{worker} + #{iteration}") + assert :ok = QuickBEAM.stop(runtime) + end + end) + end + + assert Enum.all?(Task.await_many(tasks, 120_000), &is_list/1) + end + + @tag timeout: 120_000 + test "concurrent native stop calls join each worker exactly once" do + runtime = QuickBEAM.Native.start_runtime(self(), %{}) + + runtime_results = + for _caller <- 1..32 do + Task.async(fn -> QuickBEAM.Native.stop_runtime(runtime) end) + end + |> Task.await_many(120_000) + + assert runtime_results == List.duplicate(:ok, 32) + + pool = QuickBEAM.Native.pool_start(%{}) + + pool_results = + for _caller <- 1..32 do + Task.async(fn -> QuickBEAM.Native.pool_stop(pool) end) + end + |> Task.await_many(120_000) + + assert pool_results == List.duplicate(:ok, 32) + end + + @tag timeout: 120_000 + test "resource destruction joins runtime workers when owners exit without stopping" do + monitors = + for _iteration <- 1..200 do + spawn_monitor(fn -> + _resource = QuickBEAM.Native.start_runtime(self(), %{}) + :ok + end) + |> elem(1) + end + + Enum.each(monitors, fn monitor -> + assert_receive {:DOWN, ^monitor, :process, _pid, :normal}, 120_000 + end) + end + + @tag timeout: 120_000 + test "resource destruction joins context-pool workers when owners exit" do + monitors = + for _iteration <- 1..100 do + spawn_monitor(fn -> + resource = QuickBEAM.Native.pool_start(%{}) + + for context_id <- 1..3 do + _ref = QuickBEAM.Native.pool_create_context(resource, context_id, self(), 0, 0) + end + + :ok + end) + |> elem(1) + end + + Enum.each(monitors, fn monitor -> + assert_receive {:DOWN, ^monitor, :process, _pid, :normal}, 120_000 + end) + end +end diff --git a/test/support/test262.ex b/test/support/test262.ex new file mode 100644 index 000000000..6115e2a62 --- /dev/null +++ b/test/support/test262.ex @@ -0,0 +1,278 @@ +defmodule QuickBEAM.Test262.Negative do + @moduledoc "Defines the typed Test262 negative-test metadata contract." + + use JSONCodec, strict: true, fast_path: :json + + defstruct [:phase, :type] + + @type phase :: :parse | :early | :resolution | :runtime + @type t :: %__MODULE__{ + phase: :parse | :early | :resolution | :runtime, + type: String.t() + } + + codec(:phase, atom: {:enum, [:parse, :early, :resolution, :runtime]}) +end + +defmodule QuickBEAM.Test262.Metadata do + @moduledoc "Defines the typed subset of Test262 YAML front matter used by the runner." + + use JSONCodec, strict: true, fast_path: :json + + defstruct flags: [], includes: [], features: [], negative: nil + + @type t :: %__MODULE__{ + flags: [String.t()], + includes: [String.t()], + features: [String.t()], + negative: QuickBEAM.Test262.Negative.t() | nil + } +end + +defmodule QuickBEAM.Test262 do + @moduledoc """ + Runs a pinned, bounded Test262 manifest against native QuickJS and the BEAM VM. + + The external Test262 checkout is supplied through `TEST262_PATH`; the corpus + is not vendored into the QuickBEAM package. Every result is classified rather + than silently falling back to the native engine. + """ + + alias QuickBEAM.JSError + alias QuickBEAM.Test262.Metadata + alias QuickBEAM.VM.Runtime.Engine + + @minimal_harness """ + function Test262Error(message) { + this.name = "Test262Error"; + this.message = message || ""; + } + function assert(condition, message) { + if (condition !== true) throw new Test262Error(message || "Expected true"); + } + assert.sameValue = function(actual, expected, message) { + var same = actual === expected && (actual !== 0 || 1 / actual === 1 / expected); + if (!same) same = actual !== actual && expected !== expected; + if (!same) throw new Test262Error(message || "Expected SameValue"); + }; + assert.notSameValue = function(actual, unexpected, message) { + var same = actual === unexpected && (actual !== 0 || 1 / actual === 1 / unexpected); + if (same || (actual !== actual && unexpected !== unexpected)) { + throw new Test262Error(message || "Expected different values"); + } + }; + assert.compareArray = function(actual, expected, message) { + assert.sameValue(actual.length, expected.length, message || "Array lengths differ"); + for (var index = 0; index < expected.length; index++) { + assert.sameValue(actual[index], expected[index], message || "Array values differ"); + } + }; + function $DONOTEVALUATE() { throw new Test262Error("Test must not be evaluated"); } + """ + + @type classification :: + :pass + | :vm_failure + | :native_failure + | :unsupported_flag + | :missing + + @type result :: %{ + path: String.t(), + classification: classification(), + vm: term(), + native: term(), + metadata: map() + } + + @doc "Loads the pinned selected-test manifest." + @spec load_manifest(Path.t()) :: keyword() + def load_manifest(path), do: path |> File.read!() |> Code.eval_string() |> elem(0) + + @doc "Returns the Test262 root configured for the current test process." + @spec configured_root() :: Path.t() | nil + def configured_root, do: System.get_env("TEST262_PATH") + + @doc "Parses the metadata fields needed by the bounded runner." + @spec parse_metadata(binary()) :: Metadata.t() + def parse_metadata(source) do + case metadata_block(source) do + {:ok, yaml} -> + yaml + |> String.trim() + |> YamlElixir.read_from_string!() + |> Metadata.from_map!() + + :missing -> + %Metadata{} + end + end + + defp metadata_block(source) do + opening = "/*---" + closing = "---*/" + + with {start, opening_size} <- :binary.match(source, opening), + body_start = start + opening_size, + body_size = byte_size(source) - body_start, + body = binary_part(source, body_start, body_size), + {closing_start, _closing_size} <- :binary.match(body, closing) do + {:ok, binary_part(body, 0, closing_start)} + else + :nomatch -> :missing + end + end + + @doc "Runs every entry in a selected manifest and returns classified results." + @spec run_manifest(Path.t(), keyword(), keyword()) :: [result()] + def run_manifest(root, manifest, opts \\ []) do + Enum.map(manifest[:tests], &run(root, &1, opts)) + end + + @doc "Runs one Test262 path against native QuickJS and the selected BEAM engine." + @spec run(Path.t(), Path.t(), keyword()) :: result() + def run(root, relative_path, opts \\ []) do + path = Path.join([root, "test", relative_path]) + + if File.regular?(path) do + source = File.read!(path) + metadata = parse_metadata(source) + + case unsupported_flag(metadata.flags) do + nil -> + run_supported(root, relative_path, source, metadata, opts) + + flag -> + result(relative_path, :unsupported_flag, {:unsupported_flag, flag}, :not_run, metadata) + end + else + result(relative_path, :missing, :missing, :missing, %{}) + end + end + + @doc "Summarizes classifications and computes the supported-test pass rate." + @spec summarize([result()]) :: map() + def summarize(results) do + counts = Enum.frequencies_by(results, & &1.classification) + supported = Map.get(counts, :pass, 0) + Map.get(counts, :vm_failure, 0) + pass_rate = if supported == 0, do: 0.0, else: Map.get(counts, :pass, 0) / supported + %{total: length(results), supported: supported, pass_rate: pass_rate, counts: counts} + end + + defp run_supported(root, relative_path, source, metadata, opts) do + full_source = + harness_source(root, metadata.includes) <> strict_prefix(metadata.flags) <> source + + native = native_result(full_source, metadata.negative) + vm = vm_result(full_source, metadata.negative, opts) + + classification = + cond do + native != :pass -> :native_failure + vm == :pass -> :pass + true -> :vm_failure + end + + result(relative_path, classification, vm, native, metadata) + end + + defp harness_source(root, includes) do + includes + |> Enum.reject(&(&1 in ["assert.js", "sta.js", "compareArray.js"])) + |> Enum.map_join("\n", fn include -> + path = Path.join([root, "harness", include]) + if File.regular?(path), do: File.read!(path), else: "" + end) + |> then(&(@minimal_harness <> "\n" <> &1 <> "\n")) + end + + defp vm_result(source, negative, opts) do + engine = Keyword.get(opts, :engine, :interpreter) + compiler_profile = Keyword.get(opts, :compiler_profile, :pure_v1) + pinned_programs = Keyword.get(opts, :pinned_programs, false) + + case QuickBEAM.VM.compile(source, filename: "test262.js") do + {:ok, program} -> + result = eval_program(program, engine, compiler_profile, pinned_programs) + classify_execution(result, negative, :runtime) + + {:error, error} -> + classify_execution({:error, error}, negative, :parse) + end + end + + defp eval_program(program, engine, compiler_profile, false), + do: + Engine.eval(program, + engine: engine, + compiler_profile: compiler_profile + ) + + defp eval_program(program, engine, compiler_profile, true) do + case QuickBEAM.VM.pin(program) do + {:ok, pinned} -> + try do + Engine.eval(pinned, + engine: engine, + compiler_profile: compiler_profile + ) + after + QuickBEAM.VM.unpin(pinned) + end + + {:error, reason} -> + {:error, reason} + end + end + + defp native_result(source, negative) do + case QuickBEAM.start(apis: false) do + {:ok, runtime} -> + try do + classify_execution(QuickBEAM.eval(runtime, source), negative, :runtime) + after + if Process.alive?(runtime), do: safe_stop(runtime) + end + + {:error, reason} -> + {:native_start, reason} + end + end + + defp classify_execution({:ok, _value}, nil, _phase), do: :pass + + defp classify_execution( + {:error, error}, + %QuickBEAM.Test262.Negative{phase: phase, type: type}, + actual_phase + ) do + if normalize_phase(phase) == actual_phase and error_name(error) == type, + do: :pass, + else: {:wrong_negative, actual_phase, error_name(error), error} + end + + defp classify_execution({:error, error}, nil, phase), do: {:unexpected_error, phase, error} + defp classify_execution({:ok, _value}, negative, _phase), do: {:missing_negative, negative} + + defp normalize_phase(:early), do: :parse + defp normalize_phase(phase) when phase in [:parse, :resolution, :runtime], do: phase + + defp error_name(%JSError{name: name}), do: name + defp error_name(error) when is_map(error), do: error[:name] || error["name"] + defp error_name(_error), do: nil + + defp unsupported_flag(flags) do + Enum.find(flags, &(&1 in ["async", "module", "raw", "CanBlockIsFalse"])) + end + + defp strict_prefix(flags), do: if("onlyStrict" in flags, do: "\"use strict\";\n", else: "") + + defp result(path, classification, vm, native, metadata), + do: %{path: path, classification: classification, vm: vm, native: native, metadata: metadata} + + defp safe_stop(runtime) do + QuickBEAM.stop(runtime) + catch + :exit, _reason -> :ok + end +end diff --git a/test/test262/manifest.exs b/test/test262/manifest.exs new file mode 100644 index 000000000..8cafdf66b --- /dev/null +++ b/test/test262/manifest.exs @@ -0,0 +1,76 @@ +[ + revision: "d1d583db95a521218f3eb8341a887fd63eda8ff1", + minimum_pass_rate: 1.0, + known_failures: %{}, + tests: [ + "built-ins/Array/isArray/15.4.3.2-1-1.js", + "built-ins/Array/isArray/15.4.3.2-1-2.js", + "built-ins/Array/isArray/15.4.3.2-1-3.js", + "built-ins/Array/isArray/15.4.3.2-1-4.js", + "built-ins/Array/isArray/15.4.3.2-1-5.js", + "built-ins/Array/isArray/15.4.3.2-2-1.js", + "built-ins/Object/keys/15.2.3.14-1-1.js", + "built-ins/Object/keys/return-order.js", + "built-ins/Object/setPrototypeOf/success.js", + "built-ins/String/prototype/charCodeAt/pos-rounding.js", + "built-ins/String/prototype/slice/S15.5.4.13_A2_T1.js", + "built-ins/String/prototype/slice/S15.5.4.13_A2_T2.js", + "language/expressions/object/prop-dup-get-get.js", + "language/expressions/object/prop-dup-get-set-get.js", + "language/expressions/object/prop-dup-set-get-set.js", + "language/expressions/object/setter-prop-desc.js", + "language/expressions/instanceof/S11.8.6_A2.1_T1.js", + "language/expressions/instanceof/S11.8.6_A2.1_T2.js", + "built-ins/Promise/resolve/resolve-thenable.js", + "built-ins/Promise/resolve/resolve-poisoned-then.js", + "built-ins/Promise/prototype/then/deferred-is-resolved-value.js", + "built-ins/Promise/prototype/finally/resolution-value-no-override.js", + "built-ins/Symbol/iterator/prop-desc.js", + "built-ins/Set/length.js", + "built-ins/Set/name.js", + "built-ins/Set/properties-of-the-set-prototype-object.js", + "built-ins/Set/prototype/add/add.js", + "built-ins/Set/prototype/add/length.js", + "built-ins/Set/prototype/add/name.js", + "built-ins/Set/prototype/add/returns-this.js", + "built-ins/Set/prototype/add/returns-this-when-ignoring-duplicate.js", + "built-ins/Set/prototype/has/has.js", + "built-ins/Set/prototype/has/length.js", + "built-ins/Set/prototype/has/name.js", + "built-ins/Set/prototype/has/returns-true-when-value-present-number.js", + "built-ins/Set/prototype/has/returns-false-when-value-not-present-number.js", + "built-ins/Set/prototype/size/size.js", + "built-ins/Set/prototype/size/returns-count-of-present-values-by-iterable.js", + "built-ins/Set/prototype/values/length.js", + "built-ins/Set/prototype/values/name.js", + "built-ins/Set/prototype/values/returns-iterator-empty.js", + "built-ins/Set/prototype/values/returns-iterator.js", + "built-ins/Error/length.js", + "built-ins/Error/name.js", + "built-ins/Error/prototype/constructor/prop-desc.js", + "built-ins/Error/prototype/message/prop-desc.js", + "built-ins/Error/prototype/name/prop-desc.js", + "built-ins/NativeErrors/TypeError/instance-proto.js", + "built-ins/NativeErrors/TypeError/prototype/constructor.js", + "built-ins/NativeErrors/TypeError/prototype/message.js", + "built-ins/NativeErrors/TypeError/prototype/name.js", + "built-ins/Function/prototype/call/name.js", + "built-ins/Function/prototype/call/S15.3.4.4_A10.js", + "built-ins/Array/constructor.js", + "built-ins/Array/length/S15.4.2.2_A2.2_T1.js", + "built-ins/Array/length/S15.4.2.2_A2.2_T2.js", + "built-ins/Array/length/S15.4.2.2_A2.2_T3.js", + "built-ins/Boolean/S15.6.1.1_A1_T1.js", + "built-ins/Boolean/S15.6.2.1_A1.js", + "built-ins/Number/prototype/toString/length.js", + "built-ins/Number/prototype/toString/name.js", + "built-ins/Number/prototype/toFixed/length.js", + "built-ins/Number/prototype/toFixed/name.js", + "built-ins/String/prototype/charCodeAt/name.js", + "built-ins/String/prototype/slice/name.js", + "built-ins/Set/prototype/add/will-not-add-duplicate-entry.js", + "built-ins/Set/prototype/add/will-not-add-duplicate-entry-normalizes-zero.js", + "built-ins/Set/prototype/has/returns-true-when-value-present-nan.js", + "built-ins/Set/prototype/has/returns-false-when-value-not-present-nan.js" + ] +] diff --git a/test/test_helper.exs b/test/test_helper.exs index d98385080..62d2ebda5 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1,6 +1,8 @@ Application.ensure_all_started(:telemetry) Application.ensure_all_started(:bandit) +Code.require_file("support/test262.ex", __DIR__) + # Compile the test N-API addon from C source test_addon_src = Path.expand("support/test_addon.c", __DIR__) test_addon_out = Path.expand("support/test_addon.node", __DIR__) diff --git a/test/toolchain/bundler_test.exs b/test/toolchain/bundler_test.exs index 12042d1ea..bc7502225 100644 --- a/test/toolchain/bundler_test.exs +++ b/test/toolchain/bundler_test.exs @@ -69,6 +69,46 @@ defmodule QuickBEAM.Toolchain.BundlerTest do assert js =~ ~s|hello("world")| end + @tag :tmp_dir + test "resolves bare imports between packages", %{tmp_dir: dir} do + setup_fake_package(dir, "outer-package", %{ + "package.json" => ~s({"name":"outer-package","module":"dist/index.js"}), + "dist/index.js" => + "import { value } from 'inner-package'; export const result = value + 1;" + }) + + setup_fake_package(dir, "inner-package", %{ + "package.json" => ~s({"name":"inner-package","module":"index.js"}), + "index.js" => "export const value = 41;" + }) + + write!(dir, "main.js", "import { result } from 'outer-package'; console.log(result);") + + assert {:ok, js} = QuickBEAM.JS.bundle_file(Path.join(dir, "main.js")) + assert js =~ "42" + refute js =~ ~r/\bimport\s/ + end + + @tag :tmp_dir + test "falls back to an ancestor node_modules directory", %{tmp_dir: dir} do + setup_fake_package(dir, "outer-package", %{ + "package.json" => ~s({"name":"outer-package","module":"dist/index.js"}), + "dist/index.js" => + "import { value } from 'hoisted-package'; export const result = value + 1;", + "node_modules/nested-only/package.json" => ~s({"name":"nested-only"}) + }) + + setup_fake_package(dir, "hoisted-package", %{ + "package.json" => ~s({"name":"hoisted-package","module":"index.js"}), + "index.js" => "export const value = 41;" + }) + + write!(dir, "main.js", "import { result } from 'outer-package'; console.log(result);") + + assert {:ok, js} = QuickBEAM.JS.bundle_file(Path.join(dir, "main.js")) + assert js =~ "42" + end + @tag :tmp_dir test "resolves scoped packages", %{tmp_dir: dir} do setup_fake_package(dir, "@myorg/utils", %{ diff --git a/test/vm/abi_test.exs b/test/vm/abi_test.exs new file mode 100644 index 000000000..0cfd7affe --- /dev/null +++ b/test/vm/abi_test.exs @@ -0,0 +1,65 @@ +defmodule QuickBEAM.VM.ABITest do + use ExUnit.Case, async: true + + alias QuickBEAM.VM.ABI + alias QuickBEAM.VM.ABI.Generator + alias QuickBEAM.VM.ABI.Source + alias QuickBEAM.VM.Bytecode.Opcode + + test "metadata is generated from the current vendored QuickJS sources" do + assert ABI.bytecode_version() == 26 + assert byte_size(ABI.fingerprint()) == 64 + assert Opcode.bc_version() == ABI.bytecode_version() + assert Opcode.num(:check_object) != nil + assert Opcode.num(:using_dispose) != nil + assert Opcode.info(Opcode.num(:await)) == {:await, 1, 1, 1, :none} + end + + test "parses exact C declarations with the bounded source parser" do + source = """ + #define BC_VERSION 26 + typedef enum BCTagEnum { + BC_TAG_NULL = 1, + BC_TAG_UNDEFINED, + } BCTagEnum; + """ + + assert Source.define!(source, "BC_VERSION") == "26" + + assert Source.enum_entries!(source, "BCTagEnum") == [ + "BC_TAG_NULL = 1", + "BC_TAG_UNDEFINED" + ] + + assert Source.macro_arguments(~S|DEF(name, "value,with,commas") /* comment */|, "DEF") == [ + ["name", ~s("value,with,commas")] + ] + end + + test "rejects unterminated C declarations" do + assert_raise ArgumentError, fn -> Source.enum_entries!("typedef enum Broken {", "Broken") end + assert_raise ArgumentError, fn -> Source.macro_arguments("DEF(name, value", "DEF") end + end + + test "rejects unknown ABI identifiers without creating atoms" do + source = """ + typedef enum BCTagEnum { + BC_TAG_QUICKBEAM_UNKNOWN_IDENTIFIER = 1, + } BCTagEnum; + """ + + identifier = "quickbeam_unknown_identifier" + + assert_raise ArgumentError, fn -> String.to_existing_atom(identifier) end + assert_raise ArgumentError, ~r/unknown tag identifier/, fn -> Generator.tags!(source) end + assert_raise ArgumentError, fn -> String.to_existing_atom(identifier) end + end + + test "predefined atom indexes include QuickJS v26 additions" do + atoms = ABI.predefined_atoms() + + assert "using" in Map.values(atoms) + assert "Symbol.dispose" in Map.values(atoms) + assert Opcode.js_atom_end() == map_size(atoms) + 1 + end +end diff --git a/test/vm/api_test.exs b/test/vm/api_test.exs new file mode 100644 index 000000000..b5622848d --- /dev/null +++ b/test/vm/api_test.exs @@ -0,0 +1,74 @@ +defmodule QuickBEAM.VM.APITest do + use ExUnit.Case, async: false + + alias QuickBEAM.VM + + test "rejects invalid public inputs without raising" do + assert {:error, :invalid_source} = VM.compile(:not_source) + assert {:error, :invalid_bytecode} = VM.decode(:not_bytecode) + assert {:error, :invalid_program} = VM.pin(:not_program) + assert {:error, :invalid_program} = VM.eval(:not_program) + assert {:error, :invalid_program} = VM.call(:not_program, "run") + assert {:error, :invalid_program} = VM.measure(:not_program) + assert {:error, :invalid_program} = VM.measure_call(:not_program, "run") + assert {:error, :invalid_function_name} = VM.call(:not_program, :run) + assert {:error, :invalid_arguments} = VM.call(:not_program, "run", :not_arguments) + assert {:error, :invalid_function_name} = VM.measure_call(:not_program, :run) + assert {:error, :invalid_arguments} = VM.measure_call(:not_program, "run", :not_arguments) + assert {:error, :invalid_pinned_program} = VM.unpin(:not_pinned) + end + + test "validates option lists and rejects unknown options" do + assert {:error, {:invalid_options, [:invalid]}} = VM.compile("1", [:invalid]) + + assert {:error, {:unknown_option, :runtime_options}} = + VM.compile("1", runtime_options: []) + + assert {:error, {:unknown_option, :unknown}} = VM.decode(<<>>, unknown: true) + + assert {:ok, program} = VM.compile("1") + assert {:error, {:invalid_options, [:invalid]}} = VM.eval(program, [:invalid]) + assert {:error, {:unknown_option, :engine}} = VM.eval(program, engine: :compiler) + + assert {:error, {:unknown_option, :compiler_profile}} = + VM.eval(program, compiler_profile: :scalar_v1) + + assert {:error, {:unknown_option, :unknown}} = VM.measure(program, unknown: true) + assert {:error, {:invalid_options, [:invalid]}} = VM.call(program, "run", [], [:invalid]) + assert {:error, {:unknown_option, :engine}} = VM.call(program, "run", [], engine: :compiler) + + assert {:error, {:unknown_option, :compiler_profile}} = + VM.measure_call(program, "run", [], compiler_profile: :scalar_v1) + end + + test "public verifier limits can only tighten built-in bounds" do + assert {:error, {:invalid_limit, :max_instructions}} = + VM.compile("1", max_instructions: 1_000_001) + + oversized = 16 * 1024 * 1024 + 1 + + assert {:error, {:invalid_option, :max_bytecode_bytes, ^oversized}} = + VM.compile("1", max_bytecode_bytes: oversized) + end + + test "public measurements contain only interpreter observations" do + assert {:ok, program} = VM.compile("1") + assert {:ok, measurement} = VM.measure(program) + refute Map.has_key?(measurement, :compiler_counters) + refute Map.has_key?(measurement, :compiler_regions) + end + + test "unpin reports stale handles through a stable error tuple" do + assert {:ok, program} = VM.compile("1") + assert {:ok, pinned} = VM.pin(program) + assert :ok = VM.unpin(pinned) + assert {:error, :pinned_program_unavailable} = VM.call(pinned, "run") + assert {:error, :pinned_program_unavailable} = VM.measure_call(pinned, "run") + assert {:error, :pinned_program_unavailable} = VM.unpin(pinned) + end + + test "internal identity and worker helpers are not part of the VM facade" do + refute function_exported?(VM, :worker_spawn_options, 1) + refute function_exported?(QuickBEAM.VM.Program, :put_pin_key, 1) + end +end diff --git a/test/vm/builtin/dsl_test.exs b/test/vm/builtin/dsl_test.exs new file mode 100644 index 000000000..744e15ab4 --- /dev/null +++ b/test/vm/builtin/dsl_test.exs @@ -0,0 +1,296 @@ +defmodule QuickBEAM.VM.Builtin.DSLTest do + use ExUnit.Case, async: true + + alias QuickBEAM.VM.Builtin + alias QuickBEAM.VM.Builtin.Array, as: ArrayBuiltin + alias QuickBEAM.VM.Builtin.Call + alias QuickBEAM.VM.Builtin.Contract.Error, as: ContractError + alias QuickBEAM.VM.Builtin.Error.Type, as: TypeErrorBuiltin + alias QuickBEAM.VM.Builtin.Function, as: FunctionBuiltin + alias QuickBEAM.VM.Builtin.Installer + alias QuickBEAM.VM.Builtin.Math, as: MathBuiltin + alias QuickBEAM.VM.Builtin.Object, as: ObjectBuiltin + alias QuickBEAM.VM.Builtin.Promise, as: PromiseBuiltin + alias QuickBEAM.VM.Builtin.Registry + alias QuickBEAM.VM.Builtin.Set, as: SetBuiltin + alias QuickBEAM.VM.Builtin.Spec + alias QuickBEAM.VM.Builtin.Spec.Accessor, as: AccessorSpec + alias QuickBEAM.VM.Builtin.Spec.Alias, as: AliasSpec + alias QuickBEAM.VM.Builtin.Spec.Function, as: FunctionSpec + alias QuickBEAM.VM.Builtin.Spec.Property, as: PropertySpec + alias QuickBEAM.VM.Builtin.String, as: StringBuiltin + alias QuickBEAM.VM.Builtin.Symbol, as: SymbolBuiltin + alias QuickBEAM.VM.Builtin.Validator + alias QuickBEAM.VM.Runtime.Invocation + alias QuickBEAM.VM.Runtime.Property + alias QuickBEAM.VM.Runtime.Reference + alias QuickBEAM.VM.Runtime.State + alias QuickBEAM.VM.Runtime.Symbol, as: RuntimeSymbol + + defmodule Fixture do + use QuickBEAM.VM.Builtin + + builtin "Fixture", kind: :namespace, profiles: [:core, :test] do + constant "answer", 40 + 2 + static :echo, length: 1 + static :invalid_result, js: "invalid", length: 0 + static_accessor :version, get: :version + end + + def echo(%Call{arguments: arguments, execution: execution}), + do: {:ok, List.first(arguments, :undefined), execution} + + def invalid_result(_call), do: :invalid + def version(%Call{execution: execution}), do: {:ok, "1.0", execution} + end + + test "evaluates constants and emits typed function and accessor specs" do + spec = Fixture.builtin_spec() + + assert spec.profiles == [:core, :test] + assert [%PropertySpec{key: "answer", value: 42} | _] = spec.statics + assert Enum.any?(spec.statics, &match?(%FunctionSpec{key: "echo"}, &1)) + assert Enum.any?(spec.statics, &match?(%AccessorSpec{key: "version", getter: :version}, &1)) + end + + test "validator rejects duplicate keys before installation" do + duplicate = %FunctionSpec{key: "same", handler: :echo} + + spec = %Spec{ + name: "Duplicate", + module: Fixture, + kind: :namespace, + statics: [duplicate, duplicate] + } + + assert_raise CompileError, ~r/duplicate static keys/, fn -> + Validator.validate!(spec, __ENV__) + end + end + + test "installs constants, methods, and accessors through one descriptor path" do + execution = Installer.install_all(execution(), [Fixture], :test) + fixture = Map.fetch!(execution.globals, "Fixture") + + assert {:ok, 42} = Property.get(fixture, "answer", execution) + assert {:ok, %Reference{} = echo} = Property.get(fixture, "echo", execution) + assert {:ok, "echo"} = Property.get(echo, "name", execution) + + assert {:ok, {:accessor, %Reference{} = getter, ^fixture}} = + Property.get(fixture, "version", execution) + + assert Invocation.callable?(getter, execution) + end + + test "rejects malformed builtin handler results as infrastructure contract errors" do + execution = execution() + token = {:declared_builtin, Fixture, :invalid_result} + + call = %Call{ + arguments: [], + this: :undefined, + caller: nil, + tail?: false, + execution: execution + } + + assert_raise ContractError, fn -> Builtin.invoke(token, call) end + end + + test "compiles declarative modules into immutable validated specs" do + math = MathBuiltin.builtin_spec() + array = ArrayBuiltin.builtin_spec() + + assert math.name == "Math" + assert math.kind == :namespace + assert math.profiles == [:core] + assert Enum.map(math.statics, & &1.key) == ~w(E PI floor max min pow random round) + assert Enum.count(math.statics, &match?(%FunctionSpec{}, &1)) == 6 + + assert Enum.any?( + math.statics, + &match?(%PropertySpec{key: "PI", value: value} when value > 3.14, &1) + ) + + assert array.name == "Array" + assert array.kind == :constructor + assert array.prototype_spec.kind == :array + assert array.prototype_spec.default_for == :array + assert [%FunctionSpec{key: "isArray", handler: :array?, length: 1}] = array.statics + + assert Enum.map(array.prototype, & &1.key) == + ~w(concat filter forEach includes join map push reduce slice some sort) + + assert Registry.modules(:core) == [ + QuickBEAM.VM.Builtin.Object, + QuickBEAM.VM.Builtin.Function, + QuickBEAM.VM.Builtin.Array, + QuickBEAM.VM.Builtin.Boolean, + QuickBEAM.VM.Builtin.Error, + QuickBEAM.VM.Builtin.Math, + QuickBEAM.VM.Builtin.Number, + QuickBEAM.VM.Builtin.String, + QuickBEAM.VM.Builtin.Symbol, + QuickBEAM.VM.Builtin.Uint8Array, + QuickBEAM.VM.Builtin.WeakMap, + QuickBEAM.VM.Builtin.Error.Eval, + QuickBEAM.VM.Builtin.Map, + QuickBEAM.VM.Builtin.Promise, + QuickBEAM.VM.Builtin.Error.Range, + QuickBEAM.VM.Builtin.Error.Reference, + QuickBEAM.VM.Builtin.Set, + QuickBEAM.VM.Builtin.Error.Syntax, + QuickBEAM.VM.Builtin.Error.Type, + QuickBEAM.VM.Builtin.Error.URI, + QuickBEAM.VM.Builtin.WeakSet + ] + + refute QuickBEAM.VM.Builtin.Console in Registry.modules(:core) + assert QuickBEAM.VM.Builtin.Console in Registry.modules(:ssr) + generation = Registry.generation() + assert Registry.modules(:core) == Enum.map(Registry.refresh()[:core], & &1.module) + assert Registry.generation() == generation + 1 + + assert StringBuiltin.builtin_spec().kind == :constructor + + object = ObjectBuiltin.builtin_spec() + assert object.prototype_spec.extends == nil + assert object.prototype_spec.default_for == :ordinary + + function = FunctionBuiltin.builtin_spec() + assert function.prototype_spec.extends == "Object" + assert function.prototype_spec.kind == :function + assert function.prototype_spec.callable == :prototype_call + assert function.prototype_spec.default_for == :function + + promise = PromiseBuiltin.builtin_spec() + assert promise.kind == :constructor + assert promise.constructor == :construct + assert promise.depends_on == ["Object", "Function", "Symbol"] + + error = TypeErrorBuiltin.builtin_spec() + assert error.prototype_spec.extends == "Error" + assert error.prototype_spec.error_type == "TypeError" + + set = SetBuiltin.builtin_spec() + assert set.kind == :constructor + assert Enum.any?(set.prototype, &match?(%AliasSpec{}, &1)) + + symbol = SymbolBuiltin.builtin_spec() + assert symbol.kind == :function + assert symbol.constructor == nil + + assert Enum.any?( + symbol.statics, + &match?(%{key: "iterator", value: %RuntimeSymbol{id: :iterator}}, &1) + ) + + assert Enum.map(ObjectBuiltin.builtin_spec().statics, & &1.key) == + ~w(assign create defineProperty defineProperties freeze getOwnPropertyDescriptor getOwnPropertyNames getPrototypeOf keys setPrototypeOf) + end + + test "installs callable but non-constructable Symbol semantics" do + source = + "(()=>{let first=Symbol();let second=Symbol();let rejected=false;try{new Symbol()}catch(error){rejected=error instanceof TypeError}return [typeof first,first===second,rejected]})()" + + assert {:ok, program} = QuickBEAM.VM.compile(source) + assert {:ok, ["symbol", false, true]} = QuickBEAM.VM.eval(program) + end + + test "installs real function objects with stable names, lengths, and descriptors" do + source = """ + [ + typeof Math, + Math.floor.name, + Math.floor.length, + Object.keys(Math).length, + Math.PI > 3.14, + Math.E > 2.71, + Array.isArray.name, + Array.isArray.length, + Array.isArray([]), + Array.isArray({}), + String.fromCharCode.name, + String.fromCharCode.length, + Object.assign.name, + Object.assign.length, + Array.prototype.map.name, + Array.prototype.map.length, + String.prototype.slice.name, + String.prototype.slice.length, + Number.prototype.toString.name, + Number.prototype.toString.length + ] + """ + + assert {:ok, program} = QuickBEAM.VM.compile(source) + + assert {:ok, + [ + "object", + "floor", + 1, + 0, + true, + true, + "isArray", + 1, + true, + false, + "fromCharCode", + 1, + "assign", + 2, + "map", + 1, + "slice", + 2, + "toString", + 1 + ]} = QuickBEAM.VM.eval(program) + end + + test "runs immediate and resumable declarative handlers through canonical invocation" do + source = """ + (()=>{ + let seen=0 + let target={set value(next){seen=next}} + let source={get value(){return 42}} + let assigned=Object.assign(target,source) + return [seen,assigned===target,String.fromCharCode(65,66)] + })() + """ + + assert {:ok, program} = QuickBEAM.VM.compile(source) + assert {:ok, [42, true, "AB"]} = QuickBEAM.VM.eval(program) + end + + test "dispatches declarative String and Number prototype methods" do + source = """ + [ + "Alpha".toLowerCase(), + "alpha".startsWith("al"), + "alpha".includes("ph"), + "a,b".split(",").join("-"), + (255).toString(16), + new Number(12).toFixed(2), + new String("value").toString() + ] + """ + + assert {:ok, program} = QuickBEAM.VM.compile(source) + + assert {:ok, ["alpha", true, true, "a-b", "ff", "12.00", "value"]} = + QuickBEAM.VM.eval(program) + end + + test "dispatches declarative handlers through the canonical invocation planner" do + source = "[Math.floor(2.9),Math.round(2.4),Math.min(4,2),Math.max(4,2),Math.pow(2,3)]" + assert {:ok, program} = QuickBEAM.VM.compile(source) + assert {:ok, [2, 2, 2, 4, 8.0]} = QuickBEAM.VM.eval(program) + end + + defp execution do + %State{atoms: {}, max_stack_depth: 10, remaining_steps: 100, step_limit: 100} + end +end diff --git a/test/vm/bytecode/decoder_test.exs b/test/vm/bytecode/decoder_test.exs new file mode 100644 index 000000000..1f7b97628 --- /dev/null +++ b/test/vm/bytecode/decoder_test.exs @@ -0,0 +1,258 @@ +defmodule QuickBEAM.VM.Bytecode.DecoderTest do + use ExUnit.Case, async: true + + alias QuickBEAM.VM.ABI + alias QuickBEAM.VM.Bytecode.Checksum + alias QuickBEAM.VM.Bytecode.Decoder + alias QuickBEAM.VM.Bytecode.Instruction + alias QuickBEAM.VM.Bytecode.Opcode + alias QuickBEAM.VM.Bytecode.Verifier + alias QuickBEAM.VM.Program + alias QuickBEAM.VM.Program.Function + + setup do + {:ok, runtime} = QuickBEAM.start(apis: false) + + on_exit(fn -> + try do + QuickBEAM.stop(runtime) + catch + :exit, _ -> :ok + end + end) + + %{runtime: runtime} + end + + test "public compile API returns a verified program with the requested filename" do + source = "function render(){ return 'ok' } render()" + assert {:ok, %Program{} = program} = QuickBEAM.VM.compile(source, filename: "server.js") + + assert program.source_digest == :crypto.hash(:sha256, source) + assert byte_size(program.bytecode_digest) == 32 + assert program.root.filename == "server.js" + assert Enum.all?(nested_functions(program.root), &(&1.filename == "server.js")) + end + + test "decodes and verifies current QuickJS bytecode", %{runtime: runtime} do + {:ok, bytecode} = + QuickBEAM.compile(runtime, "function add(a, b) { return a + b } add(1, 2)") + + assert {:ok, %Program{} = program} = QuickBEAM.VM.decode(bytecode) + assert program.version == ABI.bytecode_version() + assert program.fingerprint == ABI.fingerprint() + assert program.bytecode_digest == :crypto.hash(:sha256, bytecode) + assert program.source_digest == nil + assert %Function{id: 0} = program.root + assert tuple_size(program.root.instructions) > 0 + end + + test "decodes source positions without inspecting source text", %{runtime: runtime} do + source = "let marker = 'line 999, column 999';\nlet value = 2;\nvalue" + {:ok, bytecode} = QuickBEAM.compile(runtime, source) + + assert {:ok, %Program{root: function}} = QuickBEAM.VM.decode(bytecode) + positions = Tuple.to_list(function.source_positions) + + assert {3, 1} in positions + refute {999, 999} in positions + end + + test "decodes representative QuickJS v26 opcode families", %{runtime: runtime} do + sources = [ + "function sum(n){ let x=0; for(let i=0;i x+y } outer(1)(2)", + "class A { constructor(x){ this.x=x } value(){ return this.x } } new A(1).value()", + "try { throw new Error('x') } catch (error) { error.message } finally { 1 }", + "async function f(){ return await Promise.resolve(3) } f()", + ~S|/a+/gi.test("aaa")| + ] + + for source <- sources do + assert {:ok, bytecode} = QuickBEAM.compile(runtime, source) + assert {:ok, %Program{}} = QuickBEAM.VM.decode(bytecode) + end + end + + test "assigns deterministic function identifiers", %{runtime: runtime} do + source = "function outer(){ return function inner(){ return 1 } } outer()" + {:ok, bytecode} = QuickBEAM.compile(runtime, source) + + assert {:ok, first} = QuickBEAM.VM.decode(bytecode) + assert {:ok, second} = QuickBEAM.VM.decode(bytecode) + assert first == second + + assert [0, 1, 2] == collect_function_ids(first.root) + end + + test "rejects a stale QuickJS bytecode version before decoding", %{runtime: runtime} do + {:ok, <<_version, checksum::binary-size(4), payload::binary>>} = + QuickBEAM.compile(runtime, "42") + + stale = <<25, checksum::binary, payload::binary>> + assert {:error, {:bad_version, 25}} = QuickBEAM.VM.decode(stale) + end + + test "rejects bytecode with a checksum mismatch", %{runtime: runtime} do + {:ok, bytecode} = QuickBEAM.compile(runtime, "42") + last = :binary.last(bytecode) + prefix = binary_part(bytecode, 0, byte_size(bytecode) - 1) + corrupted = prefix <> <> + + assert {:error, :checksum_mismatch} = QuickBEAM.VM.decode(corrupted) + end + + test "decodes serialized object keys as atom references" do + key = "key" + atom_index = Opcode.js_atom_end() + + payload = + IO.iodata_to_binary([ + Varint.LEB128.encode(1), + <<1>>, + encoded_string(key), + <>, + Varint.LEB128.encode(1), + Varint.LEB128.encode(atom_index * 2), + <>, + Varint.LEB128.encode(84) + ]) + + bytecode = bytecode_envelope(payload) + assert {:ok, %Program{root: {:object, %{^key => 42}}}} = Decoder.decode(bytecode) + assert {:error, :invalid_root_function} = QuickBEAM.VM.decode(bytecode) + end + + test "rejects overlong LEB128 fields" do + payload = <<0x80, 0x80, 0x80, 0x80, 0x80, 0>> + assert {:error, :bad_leb128} = payload |> bytecode_envelope() |> QuickBEAM.VM.decode() + end + + test "applies decoder and verifier limits", %{runtime: runtime} do + {:ok, bytecode} = QuickBEAM.compile(runtime, "1 + 2") + + assert {:error, {:limit_exceeded, :bytecode_bytes, byte_count}} = + QuickBEAM.VM.decode(bytecode, max_bytecode_bytes: 5) + + assert byte_count == byte_size(bytecode) + + assert {:error, {:limit_exceeded, :instructions, count}} = + QuickBEAM.VM.decode(bytecode, max_instructions: 1) + + assert count > 1 + end + + test "rejects a truncated variable definition with a typed error" do + fixture = + Path.expand("../../fixtures/vm/fuzz/regressions/truncated-vardef-flags.bin", __DIR__) + + assert {:error, :unexpected_end} = fixture |> File.read!() |> QuickBEAM.VM.decode() + end + + test "verifier rejects an invalid constant index", %{runtime: runtime} do + {:ok, bytecode} = QuickBEAM.compile(runtime, "42") + {:ok, program} = QuickBEAM.VM.decode(bytecode) + opcode = Opcode.num(:push_const) + + bad_function = %{ + program.root + | instructions: {{opcode, [999]}}, + source_positions: {{1, 1}} + } + + bad_program = %{program | root: bad_function} + + assert {:error, {:invalid_instruction, 0, 0, {:invalid_index, :constant, 999}}} = + Verifier.verify(bad_program) + end + + test "verifier rejects invalid control-flow and stack metadata", %{runtime: runtime} do + {:ok, bytecode} = QuickBEAM.compile(runtime, "42") + {:ok, program} = QuickBEAM.VM.decode(bytecode) + instruction_count = tuple_size(program.root.instructions) + + bad_jump = %{ + program.root + | instructions: + put_elem(program.root.instructions, 0, {Opcode.num(:goto), [instruction_count]}) + } + + assert {:error, {:invalid_instruction, 0, 0, {:invalid_index, :label, ^instruction_count}}} = + Verifier.verify(%{program | root: bad_jump}) + + underflow = %{ + program.root + | instructions: {{Opcode.num(:drop), []}, {Opcode.num(:return_undef), []}}, + source_positions: {{1, 1}, {1, 1}}, + stack_size: 0 + } + + assert {:error, {:invalid_stack, 0, {:stack_underflow, 0, 0, 1}}} = + Verifier.verify(%{program | root: underflow}) + + declared = %{program.root | stack_size: program.root.stack_size + 1} + + assert {:error, {:invalid_stack, 0, {:stack_size_mismatch, _declared}}} = + Verifier.verify(%{program | root: declared}) + + invalid_operand = %{ + program.root + | instructions: + put_elem(program.root.instructions, 0, {Opcode.num(:push_i32), [:not_an_integer]}) + } + + assert {:error, {:invalid_instruction, 0, 0, :invalid_operand_type}} = + Verifier.verify(%{program | root: invalid_operand}) + end + + test "verifier rejects malformed decoded container shapes without raising", %{runtime: runtime} do + {:ok, bytecode} = QuickBEAM.compile(runtime, "42") + {:ok, program} = QuickBEAM.VM.decode(bytecode) + + assert {:error, :invalid_root_function} = Verifier.verify(%{program | root: nil}) + + assert {:error, {:invalid_function, 0, :constants}} = + Verifier.verify(%{program | root: %{program.root | constants: :invalid}}) + + malformed_locals = %{program.root | var_count: 1, locals: [nil]} + + assert {:error, :invalid_variable_metadata} = + Verifier.verify(%{program | root: malformed_locals}) + + malformed_constant = %{program.root | constants: [{:array, :invalid}]} + + assert {:error, :invalid_array_constant} = + Verifier.verify(%{program | root: malformed_constant}) + + assert {:error, {:invalid_options, :invalid}} = Verifier.verify(program, :invalid) + assert {:error, {:invalid_option, :invalid}} = Verifier.verify(program, [:invalid]) + assert {:error, :invalid_root_function} = QuickBEAM.VM.eval(%{program | root: nil}) + end + + test "instruction decoder rejects labels inside an instruction" do + goto = Opcode.num(:goto) + + assert {:error, {:invalid_label, 1}} = + Instruction.decode(<>) + end + + defp encoded_string(value), + do: [Varint.LEB128.encode(byte_size(value) * 2), value] + + defp bytecode_envelope(payload) do + checksum = Checksum.calculate(payload) + <> + end + + defp nested_functions(%Function{} = function) do + [function | Enum.flat_map(function.constants, &nested_functions/1)] + end + + defp nested_functions(_constant), do: [] + + defp collect_function_ids(%Function{} = function) do + [function.id | Enum.flat_map(function.constants, &collect_function_ids/1)] + end + + defp collect_function_ids(_constant), do: [] +end diff --git a/test/vm/bytecode/varint_test.exs b/test/vm/bytecode/varint_test.exs new file mode 100644 index 000000000..d1448b641 --- /dev/null +++ b/test/vm/bytecode/varint_test.exs @@ -0,0 +1,31 @@ +defmodule QuickBEAM.VM.Bytecode.VarintTest do + use ExUnit.Case, async: true + + alias QuickBEAM.VM.Bytecode.Varint, as: VMVarint + + test "delegates unsigned decoding to Varint.LEB128 and preserves the remainder" do + encoded = Varint.LEB128.encode(300) + assert {:ok, 300, <<1, 2>>} = VMVarint.read_unsigned(encoded <> <<1, 2>>) + end + + test "decodes QuickJS ZigZag signed values through Varint.LEB128" do + encoded = Varint.LEB128.encode(1_248_969) + assert {:ok, -624_485, <<3>>} = VMVarint.read_signed(encoded <> <<3>>) + end + + test "reads QuickJS fixed-width little-endian fields separately from varints" do + assert {:ok, 0x12345678, <<9>>} = + VMVarint.read_fixed_u32(<<0x12345678::little-unsigned-32, 9>>) + end + + test "rejects unterminated and wider-than-32-bit encodings" do + assert {:error, :bad_leb128} = + VMVarint.read_unsigned(<<0x80, 0x80, 0x80, 0x80, 0x80, 0>>) + + assert {:error, :integer_overflow} = + VMVarint.read_unsigned(Varint.LEB128.encode(0x1_0000_0000)) + + assert {:error, :integer_overflow} = + VMVarint.read_signed(Varint.LEB128.encode(0x1_0000_0000)) + end +end diff --git a/test/vm/call_test.exs b/test/vm/call_test.exs new file mode 100644 index 000000000..5714203e1 --- /dev/null +++ b/test/vm/call_test.exs @@ -0,0 +1,177 @@ +defmodule QuickBEAM.VM.CallTest do + use ExUnit.Case, async: false + + alias QuickBEAM.VM + + test "calls a named global with native-compatible arguments and fresh state" do + source = """ + globalThis.counter = 0; + function render(input) { + counter += 1; + return { + count: counter, + label: input.label, + thisIsGlobal: this === globalThis + }; + } + """ + + assert {:ok, program} = VM.compile(source) + argument = %{"label" => "catalog"} + expected = %{"count" => 1, "label" => "catalog", "thisIsGlobal" => true} + + assert {:ok, ^expected} = VM.call(program, "render", [argument]) + assert {:ok, ^expected} = VM.call(program, "render", [argument]) + + assert {:ok, runtime} = QuickBEAM.start(apis: false) + + try do + assert {:ok, _value} = QuickBEAM.eval(runtime, source) + assert {:ok, ^expected} = QuickBEAM.call(runtime, "render", [argument]) + after + QuickBEAM.stop(runtime) + end + end + + test "awaits Promise results and asynchronous BEAM handlers" do + source = """ + async function render(value) { + const doubled = await Beam.call("double", value); + return await Promise.resolve(doubled + 1); + } + """ + + assert {:ok, program} = VM.compile(source) + + assert {:ok, 43} = + VM.call(program, "render", [21], + handlers: %{"double" => fn [value] -> value * 2 end} + ) + end + + test "finishes asynchronous program initialization before calling the target" do + source = """ + globalThis.ready = false; + function render() { return globalThis.ready; } + Promise.resolve().then(() => { globalThis.ready = true; }) + """ + + assert {:ok, program} = VM.compile(source) + assert {:ok, true} = VM.call(program, "render") + end + + test "reports missing and non-callable globals as stable JavaScript errors" do + assert {:ok, program} = VM.compile("globalThis.value = 42") + + assert {:error, %QuickBEAM.JSError{name: "ReferenceError", message: message}} = + VM.call(program, "missing") + + assert message == "missing is not defined" + + assert {:error, %QuickBEAM.JSError{name: "TypeError", message: message}} = + VM.call(program, "value") + + assert message =~ "is not a function" + end + + test "supports pinned calls and public call measurements" do + assert {:ok, program} = VM.compile("function add(left, right) { return left + right }") + assert {:ok, pinned} = VM.pin(program) + + try do + assert {:ok, 42} = VM.call(pinned, "add", [20, 22]) + assert {:ok, 42} = VM.call(pinned, "add", [20, 22], isolation: :caller) + assert {:ok, measurement} = VM.measure_call(pinned, "add", [20, 22]) + assert measurement.result == {:ok, 42} + assert measurement.steps > 0 + assert measurement.logical_memory_bytes > 0 + assert measurement.process_memory_bytes > 0 + refute Map.has_key?(measurement, :compiler_counters) + after + assert :ok = VM.unpin(pinned) + end + end + + test "charges initialization and calls under one deterministic step limit" do + assert {:ok, program} = + VM.compile( + "function sum(n) { let total = 0; while (n > 0) { total += n--; } return total }" + ) + + assert {:ok, measurement} = VM.measure_call(program, "sum", [20]) + assert measurement.result == {:ok, 210} + assert measurement.steps > 1 + + assert {:error, {:limit_exceeded, :steps, limit}} = + VM.call(program, "sum", [20], max_steps: measurement.steps - 1) + + assert limit == measurement.steps - 1 + end + + test "isolates concurrent pinned calls and their mutable globals" do + assert {:ok, program} = + VM.compile( + "let count = 0; async function render() { await Promise.resolve(); return ++count }" + ) + + assert {:ok, pinned} = VM.pin(program) + + try do + results = + 1..20 + |> Task.async_stream(fn _index -> VM.call(pinned, "render") end, + max_concurrency: 8, + ordered: false + ) + |> Enum.map(fn {:ok, result} -> result end) + + assert results == List.duplicate({:ok, 1}, 20) + after + assert :ok = VM.unpin(pinned) + end + end + + test "cancels an asynchronous handler when a call times out" do + parent = self() + + handler = fn [] -> + send(parent, {:handler_started, self()}) + Process.sleep(:infinity) + end + + assert {:ok, program} = + VM.compile("async function render() { return await Beam.call('wait') }") + + assert {:error, {:limit_exceeded, :timeout, 20}} = + VM.call(program, "render", [], handlers: %{"wait" => handler}, timeout: 20) + + assert_receive {:handler_started, handler_pid} + monitor = Process.monitor(handler_pid) + assert_receive {:DOWN, ^monitor, :process, ^handler_pid, _reason}, 1_000 + end + + test "charges external call arguments against logical memory limits" do + assert {:ok, program} = VM.compile("function identity(value) { return value }") + argument = String.duplicate("x", 8_192) + + assert {:ok, measurement} = VM.measure_call(program, "identity", [argument]) + assert measurement.result == {:ok, argument} + limit = measurement.logical_memory_bytes - 1 + + assert {:error, {:limit_exceeded, :memory_bytes, ^limit}} = + VM.call(program, "identity", [argument], memory_limit: limit) + end + + test "does not invoke the target after failed program initialization" do + source = """ + globalThis.called = false; + function render() { globalThis.called = true; return 42; } + throw new Error("initialization failed"); + """ + + assert {:ok, program} = VM.compile(source) + + assert {:error, %QuickBEAM.JSError{name: "Error", message: "initialization failed"}} = + VM.call(program, "render") + end +end diff --git a/test/vm/compiler/code_test.exs b/test/vm/compiler/code_test.exs new file mode 100644 index 000000000..91dbe297a --- /dev/null +++ b/test/vm/compiler/code_test.exs @@ -0,0 +1,219 @@ +defmodule QuickBEAM.VM.Compiler.CodeTest do + use ExUnit.Case, async: false + + alias QuickBEAM.VM.Compiler.Code + alias QuickBEAM.VM.Compiler.Code.Artifact + alias QuickBEAM.VM.Compiler.Code.Emitter + alias QuickBEAM.VM.Compiler.Code.Import + alias QuickBEAM.VM.Compiler.Code.Lifecycle + alias QuickBEAM.VM.Compiler.Code.Template + alias QuickBEAM.VM.Compiler.Contract + alias QuickBEAM.VM.Compiler.Deopt + alias QuickBEAM.VM.Compiler.Pool + alias QuickBEAM.VM.Compiler.Runtime + alias QuickBEAM.VM.Program.Function + alias QuickBEAM.VM.Runtime.Frame + alias QuickBEAM.VM.Runtime.State + + test "compiles a slot-specific module with only allowlisted runtime imports" do + module = hd(Contract.pool_modules()) + key = key(1) + + assert {:ok, %Artifact{} = artifact} = Emitter.emit(key, module, deopt_template()) + + assert artifact.module == module + assert artifact.digest == :crypto.hash(:sha256, artifact.binary) + assert {:ok, imports} = Import.imports(artifact.binary) + + assert imports == [ + {Runtime, :deopt, 4}, + {:erlang, :get_module_info, 1}, + {:erlang, :get_module_info, 2} + ] + + assert :ok = Import.validate(artifact.binary) + end + + test "loads and invokes generated code only while a pool lease is active" do + pool = start_pool(capacity: 1) + key = key(1) + assert {:ok, lease} = Pool.checkout(pool, key, deopt_template()) + assert :ok = Pool.validate_lease(pool, lease) + + frame = frame() + execution = execution() + + assert {:deopt, %Deopt{} = deopt} = lease.module.run(lease, frame, execution) + + assert deopt.artifact_key == key + assert deopt.pool_epoch == lease.epoch + assert deopt.generation == lease.generation + assert deopt.frame == frame + + assert :ok = Pool.checkin(pool, lease) + + assert {:error, :stale_compiler_lease} = + Code.invoke(pool, lease, frame, execution) + + assert :ok = Pool.drain(pool) + assert :code.is_loaded(lease.module) == false + end + + test "reuses one static module across distinct generated artifacts" do + pool = start_pool(capacity: 1) + + modules = + for id <- 1..25 do + assert {:ok, lease} = Pool.checkout(pool, key(id), deopt_template()) + + assert {:deopt, %Deopt{artifact_key: artifact_key}} = + lease.module.run(lease, frame(), execution()) + + assert artifact_key == key(id) + assert :ok = Pool.checkin(pool, lease) + lease.module + end + + assert Enum.uniq(modules) == [hd(Contract.pool_modules())] + assert Pool.stats(pool).counts == %{ready: 1} + end + + test "rejects a generated external call outside the runtime ABI" do + module = hd(Contract.pool_modules()) + expression = remote_call(File, :cwd!, []) + + assert {:error, {:disallowed_generated_calls, [{File, :cwd!, 0}]}} = + Emitter.emit( + key(1), + module, + template(expression, [:_Lease, :_Frame, :_Execution]) + ) + end + + test "rejects malformed module attributes, exports, and artifact digests" do + module = hd(Contract.pool_modules()) + bad_module = replace_attribute(deopt_template(), :module, Other.Generated.Module) + + assert {:error, {:invalid_compiler_module_attributes, [Other.Generated.Module]}} = + Emitter.emit(key(1), module, bad_module) + + bad_exports = replace_attribute(deopt_template(), :export, other: 3) + + assert {:error, {:invalid_compiler_exports, [[other: 3]]}} = + Emitter.emit(key(1), module, bad_exports) + + assert {:ok, artifact} = Emitter.emit(key(1), module, deopt_template()) + tampered = %{artifact | digest: <<0::256>>} + assert {:error, :artifact_digest_mismatch} = Lifecycle.install(module, tampered) + end + + test "soft purge quarantines a slot instead of killing a live code reference" do + pool = start_pool(capacity: 1) + parent = self() + + runner = + spawn(fn -> + {:ok, lease} = Pool.checkout(pool, key(1), blocking_template()) + :ok = Pool.checkin(pool, lease) + send(parent, {:generated_runner_ready, self(), lease.module}) + lease.module.run(lease, frame(), execution()) + end) + + assert_receive {:generated_runner_ready, ^runner, module} + assert {:error, :compiler_pool_busy} = Pool.checkout(pool, key(2), deopt_template()) + assert Process.alive?(runner) + + assert [%{status: :quarantined, reason: {:live_generated_code, ^module, :current}}] = + Pool.stats(pool).slots + + monitor = Process.monitor(runner) + send(runner, :release) + assert_receive {:DOWN, ^monitor, :process, ^runner, :normal} + assert :ok = Lifecycle.retire(module) + end + + defp start_pool(opts) do + start_supervised!( + {Pool, + Keyword.merge( + [backend: Code, task_supervisor: QuickBEAM.VM.TaskSupervisor], + opts + )} + ) + end + + defp deopt_template do + expression = + remote_call(Runtime, :deopt, [ + {:atom, 1, :unsupported_opcode}, + {:var, 1, :Lease}, + {:var, 1, :Frame}, + {:var, 1, :Execution} + ]) + + template(expression) + end + + defp blocking_template do + expression = + {:receive, 1, + [ + {:clause, 1, [{:atom, 1, :release}], [], [{:atom, 1, :ok}]} + ]} + + template(expression, [:_Lease, :_Frame, :_Execution]) + end + + defp template(expression, argument_names \\ [:Lease, :Frame, :Execution]) do + arguments = Enum.map(argument_names, &{:var, 1, &1}) + + %Template{ + forms: [ + {:attribute, 1, :module, Template.placeholder_module()}, + {:attribute, 1, :export, [run: 3]}, + {:function, 1, :run, 3, + [ + {:clause, 1, arguments, [], [expression]} + ]}, + {:eof, 1} + ] + } + end + + defp remote_call(module, function, arguments) do + {:call, 1, {:remote, 1, {:atom, 1, module}, {:atom, 1, function}}, arguments} + end + + defp replace_attribute(%Template{forms: forms} = template, name, value) do + forms = + Enum.map(forms, fn + {:attribute, line, ^name, _old} -> {:attribute, line, name, value} + form -> form + end) + + %{template | forms: forms} + end + + defp frame do + function = %Function{id: 0, atoms: {}, instructions: {{0, []}}} + + %Frame{ + function: function, + callable: function, + locals: {}, + args: {}, + this: :undefined + } + end + + defp execution do + %State{ + atoms: {}, + max_stack_depth: 32, + remaining_steps: 100, + step_limit: 100 + } + end + + defp key(integer), do: :crypto.hash(:sha256, <>) +end diff --git a/test/vm/compiler/contract_test.exs b/test/vm/compiler/contract_test.exs new file mode 100644 index 000000000..355d4425f --- /dev/null +++ b/test/vm/compiler/contract_test.exs @@ -0,0 +1,179 @@ +defmodule QuickBEAM.VM.Compiler.ContractTest do + use ExUnit.Case, async: false + + alias QuickBEAM.VM.Compiler.Contract + alias QuickBEAM.VM.Compiler.Deopt + alias QuickBEAM.VM.Program + alias QuickBEAM.VM.Program.Function + alias QuickBEAM.VM.Runtime.Frame + alias QuickBEAM.VM.Runtime.State + + test "uses one fixed unique module atom set" do + modules = Contract.pool_modules() + + assert length(modules) == 32 + assert length(Enum.uniq(modules)) == 32 + assert Contract.pool_capacity() == 32 + assert hd(modules) == QuickBEAM.VM.Compiler.Slot00 + assert List.last(modules) == QuickBEAM.VM.Compiler.Slot31 + end + + test "artifact identities are deterministic binaries and do not allocate per-program atoms" do + {program, function} = program_and_function() + assert {:ok, namespace} = Contract.program_identity(program) + assert byte_size(namespace) == Contract.artifact_key_bytes() + assert {:ok, first} = Contract.artifact_key(program, function) + assert {:ok, ^first} = Contract.artifact_key(program, function) + assert {:ok, ^first} = Contract.artifact_key_from_identity(namespace, function) + + assert {:ok, region} = + Contract.artifact_key_from_identity(namespace, function, region_entry: 0) + + assert {:ok, preferred} = + Contract.artifact_key_from_identity(namespace, function, region_preferred: true) + + refute region == first + refute preferred == first + + assert {:ok, admission} = Contract.region_admission_key(namespace, function.id, 0, :scalar_v1) + assert byte_size(admission) == Contract.artifact_key_bytes() + assert byte_size(first) == Contract.artifact_key_bytes() + + # Warm every code path before measuring the permanent atom table. + assert {:ok, _key} = Contract.artifact_key(program, %{function | id: 0}) + atom_count = :erlang.system_info(:atom_count) + + for id <- 1..10_000 do + assert {:ok, key} = Contract.artifact_key(program, %{function | id: id}) + assert is_binary(key) + end + + assert :erlang.system_info(:atom_count) == atom_count + end + + test "artifact identities cover the program fingerprint and immutable function" do + {program, function} = program_and_function() + assert {:ok, key} = Contract.artifact_key(program, function) + + assert {:ok, changed_program_key} = + Contract.artifact_key(%{program | fingerprint: "other"}, function) + + assert {:ok, changed_function_key} = + Contract.artifact_key(program, %{function | stack_size: function.stack_size + 1}) + + assert {:ok, changed_source_key} = + Contract.artifact_key( + %{program | source_digest: :crypto.hash(:sha256, "source")}, + function + ) + + assert {:ok, changed_atoms_key} = Contract.artifact_key(%{program | atoms: {"x"}}, function) + + assert {:ok, debug_only_key} = + Contract.artifact_key(program, %{ + function + | filename: "other.js", + line_num: 99, + col_num: 7, + pc2line: <<1, 2, 3>>, + source: "different source text", + source_positions: {{99, 7}, {99, 8}} + }) + + refute changed_program_key == key + refute changed_function_key == key + refute changed_source_key == key + refute changed_atoms_key == key + assert debug_only_key == key + + assert {:error, {:unknown_option, :unknown}} = + Contract.artifact_key(program, function, unknown: true) + + assert {:error, {:unsupported_compiler_profile, :future}} = + Contract.artifact_key(program, function, profile: :future) + + assert {:error, {:invalid_region_entry, -1}} = + Contract.artifact_key(program, function, region_entry: -1) + + assert {:error, {:invalid_region_preferred, :always}} = + Contract.artifact_key(program, function, region_preferred: :always) + end + + test "deoptimization state is owner-local and points before a valid instruction" do + {program, function} = program_and_function() + assert {:ok, artifact_key} = Contract.artifact_key(program, function) + frame = frame(function) + execution = execution(program) + + assert {:ok, %Deopt{} = deopt} = + Deopt.new(:unsupported_opcode, artifact_key, 1, 2, frame, execution) + + assert deopt.owner == self() + assert deopt.phase == :before_instruction + assert :ok = Deopt.validate(deopt) + + task = Task.async(fn -> Deopt.validate(deopt) end) + + assert {:error, {:deopt_owner_mismatch, owner, validator}} = Task.await(task) + assert owner == self() + refute validator == self() + end + + test "deoptimization rejects stale contracts and invalid boundaries" do + {program, function} = program_and_function() + assert {:ok, artifact_key} = Contract.artifact_key(program, function) + + assert {:ok, deopt} = + Deopt.new( + {:guard_failed, :primitive_number}, + artifact_key, + 0, + 0, + frame(function), + execution(program) + ) + + assert {:error, {:stale_compiler_contract, 0}} = + Deopt.validate(%{deopt | contract_version: 0}) + + bad_frame = %{deopt.frame | pc: tuple_size(function.instructions)} + + assert {:error, {:invalid_deopt_boundary, ^bad_frame}} = + Deopt.validate(%{deopt | frame: bad_frame}) + + assert {:error, {:invalid_artifact_key, <<0>>}} = + Deopt.validate(%{deopt | artifact_key: <<0>>}) + end + + defp program_and_function do + function = %Function{ + id: 1, + name: "contract", + atoms: {}, + instructions: {{:push_i32, [42]}, {:return, []}}, + stack_size: 1 + } + + program = %Program{version: 26, fingerprint: "fixture", atoms: {}, root: function} + {program, function} + end + + defp frame(function) do + %Frame{ + function: function, + callable: function, + locals: {}, + args: {}, + this: :undefined + } + end + + defp execution(program) do + %State{ + atoms: program.atoms, + max_stack_depth: 32, + remaining_steps: 100, + step_limit: 100 + } + end +end diff --git a/test/vm/compiler/counter_test.exs b/test/vm/compiler/counter_test.exs new file mode 100644 index 000000000..0e3fed1ae --- /dev/null +++ b/test/vm/compiler/counter_test.exs @@ -0,0 +1,35 @@ +defmodule QuickBEAM.VM.Compiler.CounterTest do + use ExUnit.Case, async: true + + alias QuickBEAM.VM.Compiler.Context + alias QuickBEAM.VM.Compiler.Counter + alias QuickBEAM.VM.Program + alias QuickBEAM.VM.Runtime.State + + test "keeps fixed OTP counters in the evaluation owner" do + execution = %State{ + atoms: {}, + compiler_context: %Context{ + counters: Counter.new(), + pool: self(), + program: %Program{version: 26, fingerprint: "test", atoms: {}, root: nil} + }, + max_stack_depth: 8, + remaining_steps: 8, + step_limit: 8 + } + + execution = Counter.increment(execution, :frame_attempts) + assert Counter.snapshot(execution).frame_attempts == 1 + + task = + Task.async(fn -> + foreign = Counter.increment(execution, :frame_attempts) + Counter.snapshot(foreign) + end) + + assert Task.await(task) == nil + assert Counter.snapshot(execution).frame_attempts == 1 + assert map_size(Counter.snapshot(execution).deopt_opcodes) == 0 + end +end diff --git a/test/vm/compiler/orchestration_test.exs b/test/vm/compiler/orchestration_test.exs new file mode 100644 index 000000000..67ef7e5fb --- /dev/null +++ b/test/vm/compiler/orchestration_test.exs @@ -0,0 +1,372 @@ +defmodule QuickBEAM.VM.Compiler.OrchestrationTest do + use ExUnit.Case, async: false + + alias QuickBEAM.VM.Compiler + alias QuickBEAM.VM.Compiler.Counter + alias QuickBEAM.VM.Compiler.Instrumentation + alias QuickBEAM.VM.Compiler.Pool + alias QuickBEAM.VM.Runtime.Engine + alias QuickBEAM.VM.Runtime.Engine.Measurement + + test "requires an explicitly supervised compiler service" do + assert {:ok, program} = QuickBEAM.VM.compile("40 + 2") + + assert {:error, {:compiler_error, {:compiler_pool_unavailable, Pool}}} = + Engine.eval(program, engine: :compiler) + end + + test "matches interpreter and native QuickJS for decoded pure fixtures" do + start_compiler() + assert {:ok, runtime} = QuickBEAM.start(apis: false) + + try do + for source <- ["40 + 2", "6 * 7", "10 > 3", "true ? 11 : 22", "(5 << 2) | 1"] do + assert {:ok, program} = QuickBEAM.VM.compile(source) + assert {:ok, expected} = QuickBEAM.eval(runtime, source) + assert Engine.eval(program) == {:ok, expected} + assert Engine.eval(program, engine: :compiler) == {:ok, expected} + end + after + QuickBEAM.stop(runtime) + end + end + + test "preserves JavaScript error identity across native, interpreter, and compiler tiers" do + start_compiler() + source = "(function explode(){ throw new TypeError('boom') })()" + assert {:ok, program} = QuickBEAM.VM.compile(source, filename: "compiler-error.js") + assert {:ok, runtime} = QuickBEAM.start(apis: false) + + try do + assert {:error, native_error} = QuickBEAM.eval(runtime, source) + assert {:error, interpreted_error} = Engine.eval(program) + + assert {:error, compiled_error} = + Engine.eval(program, engine: :compiler) + + assert %QuickBEAM.JSError{name: "TypeError", message: "boom"} = native_error + assert %QuickBEAM.JSError{name: "TypeError", message: "boom"} = interpreted_error + assert compiled_error == interpreted_error + after + QuickBEAM.stop(runtime) + end + end + + test "re-enters cached compilation for nested bytecode function frames" do + start_compiler() + expression = Enum.join(List.duplicate("value", 40), " + ") + source = "(function add(value) { return #{expression} })(1)" + assert {:ok, program} = QuickBEAM.VM.compile(source) + assert {:ok, 40} = Engine.eval(program, engine: :compiler) + + stats = Pool.stats(Pool) + assert stats.counts.ready >= 1 + assert stats.leases == 0 + end + + test "caches bounded owner-local skip decisions" do + start_compiler() + assert {:ok, program} = QuickBEAM.VM.compile("(function add(value) { return value + 1 })(41)") + assert {:ok, 42, execution} = Compiler.start(program) + + decisions = execution.compiler_context.decisions + assert map_size(decisions) == 2 + assert Enum.count(decisions, fn {_id, decision} -> decision == :skip end) == 2 + end + + test "removes interpreted-opcode instrumentation from ordinary compiler runs" do + start_compiler() + assert {:ok, program} = QuickBEAM.VM.compile("(function(n){while(n>0)n--;return n})(10)") + assert {:ok, 0, execution} = Compiler.start(program) + assert execution.compiler_context.instrumentation == nil + assert execution.compiler_context.counters == nil + + assert {:ok, 0, measured} = + Compiler.start(program, measurement_target: {self(), make_ref()}) + + assert measured.compiler_context.instrumentation == Instrumentation + assert %Counter{} = measured.compiler_context.counters + end + + test "uses the loaded artifact before repeating warm lowering" do + start_compiler() + expression = Enum.join(List.duplicate("value", 40), " + ") + source = "(function add(value) { return #{expression} })(1)" + assert {:ok, program} = QuickBEAM.VM.compile(source) + assert {:ok, 40, cold_execution} = Compiler.start(program) + assert {:ok, 40, warm_execution} = Compiler.start(program) + + assert Enum.any?(cold_execution.compiler_context.decisions, fn {_id, decision} -> + match?({:compile, _, _}, decision) + end) + + assert Enum.any?(warm_execution.compiler_context.decisions, fn {_id, decision} -> + match?({:cached, _}, decision) + end) + end + + test "caps owner-local eligibility metadata across many nested functions" do + start_compiler() + + source = + 0..299 + |> Enum.map_join(",", fn value -> "(function(){return #{value}})()" end) + + assert {:ok, program} = QuickBEAM.VM.compile(source) + assert {:ok, 299, execution} = Compiler.start(program) + assert map_size(execution.compiler_context.decisions) == 256 + end + + test "preserves stack limits and tail calls across nested compiler re-entry" do + start_compiler() + + assert {:ok, recursive} = + QuickBEAM.VM.compile("(function recurse(n){return 1+recurse(n+1)})(0)") + + expected = {:error, {:limit_exceeded, :stack_depth, 6}} + assert Engine.eval(recursive, max_stack_depth: 5) == expected + + assert Engine.eval(recursive, engine: :compiler, max_stack_depth: 5) == + expected + + assert {:ok, tail_recursive} = + QuickBEAM.VM.compile( + "(function recurse(n){if(n===0)return 0;return recurse(n-1)})(1000)" + ) + + assert {:ok, 0} = + Engine.eval(tail_recursive, + engine: :compiler, + max_stack_depth: 2 + ) + end + + test "deoptimizes into asynchronous handlers without duplicating effects" do + start_compiler() + assert {:ok, program} = QuickBEAM.VM.compile("Beam.call('double', 21)") + parent = self() + + handler = fn [value] -> + send(parent, :handler_called) + value * 2 + end + + assert {:ok, 42} = + Engine.eval(program, + engine: :compiler, + handlers: %{"double" => handler} + ) + + assert_receive :handler_called + refute_receive :handler_called, 20 + end + + test "cancels outstanding handlers when a compiled evaluation times out" do + start_compiler() + assert {:ok, program} = QuickBEAM.VM.compile("Beam.call('wait')") + + assert {:ok, 1} = + Engine.eval(program, + engine: :compiler, + handlers: %{"wait" => fn [] -> 1 end}, + timeout: 1_000 + ) + + parent = self() + + handler = fn [] -> + send(parent, {:compiler_handler_started, self()}) + Process.sleep(:infinity) + end + + assert {:error, {:limit_exceeded, :timeout, 200}} = + Engine.eval(program, + engine: :compiler, + handlers: %{"wait" => handler}, + timeout: 200 + ) + + assert_receive {:compiler_handler_started, handler_pid} + monitor = Process.monitor(handler_pid) + assert_receive {:DOWN, ^monitor, :process, ^handler_pid, _reason}, 1_000 + end + + test "preserves deterministic measurement counters" do + start_compiler() + assert {:ok, program} = QuickBEAM.VM.compile("({answer: 40 + 2})") + + assert {:ok, %Measurement{} = interpreted} = Engine.measure(program) + + assert {:ok, %Measurement{} = compiled} = + Engine.measure(program, engine: :compiler) + + assert compiled.result == interpreted.result + assert compiled.steps == interpreted.steps + assert compiled.logical_memory_bytes == interpreted.logical_memory_bytes + assert interpreted.compiler_counters == nil + assert compiled.compiler_counters.profile == :pure_v1 + assert compiled.compiler_counters.frame_attempts > 0 + assert compiled.compiler_counters.generated_steps <= compiled.steps + assert compiled.process_memory_bytes > 0 + assert compiled.reductions > 0 + end + + test "preserves step limits and outer timeout containment" do + start_compiler() + assert {:ok, finite} = QuickBEAM.VM.compile("40 + 2") + + expected = {:error, {:limit_exceeded, :steps, 2}} + assert Engine.eval(finite, max_steps: 2) == expected + assert Engine.eval(finite, engine: :compiler, max_steps: 2) == expected + + memory_error = {:error, {:limit_exceeded, :memory_bytes, 1}} + assert Engine.eval(finite, memory_limit: 1) == memory_error + + assert Engine.eval(finite, engine: :compiler, memory_limit: 1) == + memory_error + + assert {:ok, loop} = QuickBEAM.VM.compile("while (true) {}") + + assert {:error, {:limit_exceeded, :timeout, 50}} = + Engine.eval(loop, + engine: :compiler, + max_steps: 100_000_000, + timeout: 50 + ) + end + + test "matches the pinned Preact SSR fixture through async deoptimization" do + start_compiler() + + assert {:ok, source} = + QuickBEAM.JS.bundle_file("test/fixtures/vm/preact_ssr.js", + format: :esm, + minify: false + ) + + assert {:ok, program} = QuickBEAM.VM.compile(source, filename: "preact_ssr.js") + + props = %{ + "title" => "Compiler", + "products" => [ + %{"id" => 1, "name" => "Product 1", "inStock" => true, "priceCents" => 1_299} + ] + } + + options = [ + handlers: %{"load_props" => fn [] -> props end}, + max_steps: 20_000_000, + timeout: 2_000 + ] + + expected = Engine.eval(program, options) + assert Engine.eval(program, [engine: :compiler] ++ options) == expected + + assert Engine.eval( + program, + [engine: :compiler, compiler_profile: :scalar_v1] ++ options + ) == expected + end + + test "admits and reuses one bounded entry region for an oversized scalar function" do + start_compiler() + body = Enum.map_join(1..100, "", fn _index -> "value=value+1;" end) + + assert {:ok, program} = + QuickBEAM.VM.compile("(function(){let value=0;#{body}return value})()") + + assert {:ok, 100} = + Engine.eval(program, + engine: :compiler, + compiler_profile: :scalar_v1, + max_steps: 10_000 + ) + + opts = [ + engine: :compiler, + compiler_profile: :scalar_v1, + compiler_regions: true, + max_steps: 10_000 + ] + + assert {:ok, 100} = Engine.eval(program, opts) + assert {:ok, 100} = Engine.eval(program, opts) + + assert {:ok, interpreted} = Engine.measure(program, max_steps: 10_000) + assert {:ok, admitted} = Engine.measure(program, opts) + assert admitted.result == interpreted.result + assert admitted.steps == interpreted.steps + assert admitted.logical_memory_bytes == interpreted.logical_memory_bytes + assert admitted.compiler_counters.generated_steps == 32 + assert admitted.compiler_counters.region_compiled == 1 + + assert {:ok, cached} = Engine.measure(program, opts) + assert cached.result == interpreted.result + assert cached.steps == interpreted.steps + assert cached.logical_memory_bytes == interpreted.logical_memory_bytes + assert cached.compiler_counters.generated_steps == 32 + assert cached.compiler_counters.region_hot > 0 + assert cached.compiler_counters.region_compiled == 0 + assert Pool.stats(Pool).region_admissions == 2 + + Enum.each([1, 16, 32, 33, interpreted.steps - 1], fn step_limit -> + interpreter_result = Engine.eval(program, max_steps: step_limit) + + compiler_result = + Engine.eval(program, Keyword.put(opts, :max_steps, step_limit)) + + assert compiler_result == interpreter_result + end) + end + + test "shares cached generated code across isolated evaluation owners" do + start_compiler() + expression = Enum.join(List.duplicate("value", 40), " + ") + source = "(function add(value) { return #{expression} })(1)" + assert {:ok, program} = QuickBEAM.VM.compile(source) + + tasks = + for _ <- 1..40 do + Task.async(fn -> Engine.eval(program, engine: :compiler) end) + end + + assert Task.await_many(tasks, 5_000) == List.duplicate({:ok, 40}, 40) + + stats = Pool.stats(Pool) + assert stats.counts.ready >= 1 + assert stats.leases == 0 + assert stats.compilations == 0 + end + + test "validates compiler-facing evaluation options" do + assert {:ok, program} = QuickBEAM.VM.compile("42") + + assert {:error, {:invalid_option, :engine, :native}} = + Engine.eval(program, engine: :native) + + assert {:error, {:invalid_option, :compiler_pool, "pool"}} = + Engine.eval(program, engine: :compiler, compiler_pool: "pool") + + assert {:error, {:invalid_option, :compiler_profile, :unbounded}} = + Engine.eval(program, + engine: :compiler, + compiler_profile: :unbounded + ) + + assert {:error, {:invalid_option, :compiler_region_probe, :always}} = + Engine.eval(program, + engine: :compiler, + compiler_region_probe: :always + ) + + assert {:error, {:invalid_option, :compiler_regions, :always}} = + Engine.eval(program, + engine: :compiler, + compiler_regions: :always + ) + end + + defp start_compiler do + start_supervised!({Compiler, capacity: 4}) + end +end diff --git a/test/vm/compiler/pool_test.exs b/test/vm/compiler/pool_test.exs new file mode 100644 index 000000000..169858798 --- /dev/null +++ b/test/vm/compiler/pool_test.exs @@ -0,0 +1,482 @@ +defmodule QuickBEAM.VM.Compiler.PoolTest do + use ExUnit.Case, async: false + + alias QuickBEAM.VM.Compiler.Contract + alias QuickBEAM.VM.Compiler.Pool + + defmodule FakeBackend do + @moduledoc "Test backend that records compiler module-pool lifecycle calls." + + @behaviour QuickBEAM.VM.Compiler.Pool.Backend + + @state __MODULE__.State + + @doc "Returns the fake backend state process name." + def state_name, do: @state + + @doc "Configures the next retirement result for one module." + def put_retire_result(module, result) do + Agent.update(@state, &put_in(&1, [:retire_results, module], result)) + end + + @doc "Returns the recorded fake backend state." + def state, do: Agent.get(@state, & &1) + + @doc "Clears recorded calls without changing configured retirement results." + def clear_calls do + Agent.update(@state, fn state -> + %{state | compiles: %{}, compile_modules: [], installs: [], retires: []} + end) + end + + @impl true + def compile(key, module, {:block, test_pid}) do + record_compile(key, module) + send(test_pid, {:compile_started, key, module, self()}) + + receive do + {:complete_compilation, ^key, result} -> result + end + end + + def compile(key, module, {:sleep, milliseconds}) do + record_compile(key, module) + Process.sleep(milliseconds) + {:ok, {:artifact, key}} + end + + def compile(key, module, {:install_error, reason}) do + record_compile(key, module) + {:ok, {:install_error, reason}} + end + + def compile(key, module, {:exit, reason}) do + record_compile(key, module) + exit(reason) + end + + def compile(key, module, {:allocate, count}) do + record_compile(key, module) + {:ok, Enum.to_list(1..count)} + end + + def compile(key, module, _input) do + record_compile(key, module) + {:ok, {:artifact, key}} + end + + @impl true + def install(module, {:install_error, reason}) do + Agent.update(@state, &update_in(&1.installs, fn installs -> [module | installs] end)) + {:error, reason} + end + + def install(module, _artifact) do + Agent.update(@state, &update_in(&1.installs, fn installs -> [module | installs] end)) + :ok + end + + @impl true + def retire(module) do + Agent.get_and_update(@state, fn state -> + result = Map.get(state.retire_results, module, :ok) + {result, update_in(state.retires, fn retires -> [module | retires] end)} + end) + end + + defp record_compile(key, module) do + Agent.update(@state, fn state -> + state + |> update_in([:compiles, key], &((&1 || 0) + 1)) + |> update_in([:compile_modules], fn modules -> [module | modules] end) + end) + end + end + + setup do + initial_state = fn -> + %{compiles: %{}, compile_modules: [], installs: [], retires: [], retire_results: %{}} + end + + start_supervised!(%{ + id: FakeBackend.state_name(), + start: {Agent, :start_link, [initial_state, [name: FakeBackend.state_name()]]} + }) + + :ok + end + + test "joins concurrent cache misses into one supervised compilation" do + pool = start_pool(capacity: 2) + key = key(1) + parent = self() + + owners = + for _ <- 1..20 do + spawn_link(fn -> + result = Pool.checkout(pool, key, {:block, parent}) + send(parent, {:checkout_result, self(), result}) + + receive do + :release -> + {:ok, lease} = result + send(parent, {:checkin_result, self(), Pool.checkin(pool, lease)}) + end + end) + end + + assert_receive {:compile_started, ^key, module, compiler_pid} + refute_receive {:compile_started, ^key, _module, _pid}, 20 + send(compiler_pid, {:complete_compilation, key, {:ok, {:artifact, key}}}) + + results = + for _ <- owners do + assert_receive {:checkout_result, owner, {:ok, lease}} + assert lease.owner == owner + assert lease.module == module + lease + end + + assert MapSet.size(MapSet.new(results, & &1.token)) == 20 + assert FakeBackend.state().compiles[key] == 1 + assert Pool.stats(pool).leases == 20 + + Enum.each(owners, &send(&1, :release)) + + for owner <- owners do + assert_receive {:checkin_result, ^owner, :ok} + end + + assert eventually(fn -> Pool.stats(pool).leases == 0 end) + end + + test "removes a dead single-flight waiter without creating an orphan lease" do + pool = start_pool(capacity: 1) + key = key(1) + parent = self() + + waiter = spawn(fn -> Pool.checkout(pool, key, {:block, parent}) end) + assert_receive {:compile_started, ^key, _module, compiler_pid} + monitor = Process.monitor(waiter) + Process.exit(waiter, :kill) + assert_receive {:DOWN, ^monitor, :process, ^waiter, :killed} + + send(compiler_pid, {:complete_compilation, key, {:ok, {:artifact, key}}}) + assert eventually(fn -> Pool.stats(pool).counts == %{ready: 1} end) + assert Pool.stats(pool).leases == 0 + assert {:ok, lease} = Pool.checkout(pool, key) + assert FakeBackend.state().compiles[key] == 1 + assert :ok = Pool.checkin(pool, lease) + end + + test "probes warm artifacts without compiling on a miss" do + pool = start_pool(capacity: 1) + key = key(1) + + assert :miss = Pool.checkout_cached(pool, key) + assert :ok = Pool.remember_skip(pool, key(2)) + assert :skip = Pool.checkout_cached(pool, key(2)) + assert Pool.stats(pool).skips == 1 + assert FakeBackend.state().compiles == %{} + + assert {:ok, cold_lease} = Pool.checkout(pool, key, :cold_input) + assert :ok = Pool.checkin(pool, cold_lease) + assert {:ok, warm_lease} = Pool.checkout_cached(pool, key) + assert warm_lease.key == key + assert FakeBackend.state().compiles[key] == 1 + assert :ok = Pool.checkin(pool, warm_lease) + end + + test "bounds shared negative decisions without allocating key atoms" do + pool = start_pool(capacity: 1) + atom_count = :erlang.system_info(:atom_count) + + for id <- 1..300 do + assert :ok = Pool.remember_skip(pool, key(id)) + end + + assert Pool.stats(pool).skips == 256 + assert :miss = Pool.checkout_cached(pool, key(1)) + assert :skip = Pool.checkout_cached(pool, key(300)) + assert :erlang.system_info(:atom_count) == atom_count + end + + test "bounds shared region admission without allocating key atoms" do + pool = start_pool(capacity: 1) + atom_count = :erlang.system_info(:atom_count) + + assert :cold = Pool.admit_region(pool, key(1)) + assert :cold = Pool.admit_region(pool, key(1)) + assert :hot = Pool.admit_region(pool, key(1)) + assert :hot = Pool.admit_region(pool, key(1)) + + for id <- 2..300 do + assert :cold = Pool.admit_region(pool, key(id)) + end + + stats = Pool.stats(pool) + assert stats.region_admissions == 256 + assert stats.region_hot == 1 + assert stats.region_hot_capacity == 1 + assert :hot = Pool.admit_region(pool, key(1)) + assert :cold = Pool.admit_region(pool, key(2)) + assert :cold = Pool.admit_region(pool, key(2)) + assert :cold = Pool.admit_region(pool, key(2)) + assert :erlang.system_info(:atom_count) == atom_count + end + + @tag capture_log: true + test "makes a slot reusable after a compiler task exits" do + pool = start_pool(capacity: 1) + + assert {:error, {:compiler_compile_failed, {:compile_task_exit, :lowering_crash}}} = + Pool.checkout(pool, key(1), {:exit, :lowering_crash}) + + assert Pool.stats(pool).counts == %{free: 1} + assert {:ok, _lease} = Pool.checkout(pool, key(2)) + end + + test "keeps cache modules within capacity while reusing slots by LRU" do + pool = start_pool(capacity: 2) + atom_count = :erlang.system_info(:atom_count) + + for id <- 1..100 do + assert {:ok, lease} = Pool.checkout(pool, key(id)) + assert :ok = Pool.checkin(pool, lease) + end + + stats = Pool.stats(pool) + assert stats.capacity == 2 + assert stats.counts == %{ready: 2} + assert Enum.all?(stats.slots, &(&1.module in Enum.take(Contract.pool_modules(), 2))) + assert length(Enum.uniq(FakeBackend.state().compile_modules)) == 2 + assert length(FakeBackend.state().retires) == 98 + assert :erlang.system_info(:atom_count) == atom_count + end + + test "does not evict an actively leased module" do + pool = start_pool(capacity: 1) + assert {:ok, lease} = Pool.checkout(pool, key(1)) + assert {:error, :compiler_pool_busy} = Pool.checkout(pool, key(2)) + assert :ok = Pool.validate_lease(pool, lease) + assert :ok = Pool.checkin(pool, lease) + assert {:ok, replacement} = Pool.checkout(pool, key(2)) + assert replacement.generation == lease.generation + 1 + end + + test "owner death automatically releases every lease" do + pool = start_pool(capacity: 1) + parent = self() + + owner = + spawn(fn -> + result = Pool.checkout(pool, key(1)) + send(parent, {:owner_checkout, self(), result}) + Process.sleep(:infinity) + end) + + assert_receive {:owner_checkout, ^owner, {:ok, lease}} + assert Pool.stats(pool).leases == 1 + Process.exit(owner, :kill) + + assert eventually(fn -> Pool.stats(pool).leases == 0 end) + assert {:error, :compiler_lease_owner_mismatch} = Pool.validate_lease(pool, lease) + assert {:ok, replacement} = Pool.checkout(pool, key(2)) + assert replacement.module == lease.module + end + + test "rejects stale and cross-owner leases" do + pool = start_pool(capacity: 1) + assert {:ok, first} = Pool.checkout(pool, key(1)) + + task = Task.async(fn -> Pool.validate_lease(pool, first) end) + assert {:error, :compiler_lease_owner_mismatch} = Task.await(task) + + assert :ok = Pool.checkin(pool, first) + assert {:ok, second} = Pool.checkout(pool, key(2)) + assert second.generation > first.generation + assert {:error, :stale_compiler_lease} = Pool.validate_lease(pool, first) + assert {:error, :stale_compiler_lease} = Pool.checkin(pool, first) + end + + test "pool restart changes epoch and rejects every old lease" do + name = Pool + pool = start_pool(capacity: 1) + assert {:ok, lease} = Pool.checkout(pool, key(1)) + first_epoch = Pool.stats(pool).epoch + + FakeBackend.put_retire_result(lease.module, {:error, :live_code_reference}) + GenServer.stop(pool, :shutdown) + + assert eventually(fn -> is_pid(Process.whereis(name)) and Process.whereis(name) != pool end) + restarted = Process.whereis(name) + refute Pool.stats(restarted).epoch == first_epoch + assert {:error, :stale_compiler_lease} = Pool.validate_lease(restarted, lease) + + assert [%{status: :quarantined, reason: :live_code_reference}] = + Pool.stats(restarted).slots + end + + test "quarantines a slot when soft retirement fails" do + pool = start_pool(capacity: 1) + assert {:ok, lease} = Pool.checkout(pool, key(1)) + assert :ok = Pool.checkin(pool, lease) + FakeBackend.put_retire_result(lease.module, {:error, :live_code_reference}) + + assert {:error, :compiler_pool_busy} = Pool.checkout(pool, key(2)) + + assert [%{status: :quarantined, reason: :live_code_reference}] = + Pool.stats(pool).slots + + assert FakeBackend.state().retires == [lease.module] + end + + @tag capture_log: true + test "contains a compiler task that exceeds its BEAM heap ceiling" do + pool = start_pool(capacity: 1, compile_max_heap_bytes: 128 * 1024) + + assert {:error, {:compiler_compile_failed, {:compile_task_exit, :killed}}} = + Pool.checkout(pool, key(1), {:allocate, 1_000_000}) + + assert Pool.stats(pool).counts == %{free: 1} + assert {:ok, _lease} = Pool.checkout(pool, key(2)) + end + + test "bounds compilation time and makes the uninstalled slot reusable" do + pool = start_pool(capacity: 1, compile_timeout: 20) + key = key(1) + parent = self() + + task = Task.async(fn -> Pool.checkout(pool, key, {:block, parent}) end) + assert_receive {:compile_started, ^key, _module, compiler_pid} + + assert {:error, {:compiler_compile_failed, {:compile_timeout, 20}}} = Task.await(task) + refute Process.alive?(compiler_pid) + assert Pool.stats(pool).counts == %{free: 1} + assert {:ok, _lease} = Pool.checkout(pool, key(2)) + end + + test "quarantines a slot after an installation failure" do + pool = start_pool(capacity: 1) + + assert {:error, {:compiler_compile_failed, {:install_failed, :bad_beam}}} = + Pool.checkout(pool, key(1), {:install_error, :bad_beam}) + + assert [%{status: :quarantined, reason: {:install_failed, :bad_beam}}] = + Pool.stats(pool).slots + + assert {:error, :compiler_pool_busy} = Pool.checkout(pool, key(2)) + end + + test "drain cancels supervised compilation and rejects its waiters" do + pool = start_pool(capacity: 1) + key = key(1) + parent = self() + waiter = Task.async(fn -> Pool.checkout(pool, key, {:block, parent}) end) + assert_receive {:compile_started, ^key, _module, compiler_pid} + + assert :ok = Pool.drain(pool, 1_000) + assert {:error, {:compiler_compile_failed, :compiler_pool_stopping}} = Task.await(waiter) + refute Process.alive?(compiler_pid) + assert Pool.stats(pool).mode == :drained + assert Pool.stats(pool).counts == %{free: 1} + end + + test "drains active owners before retiring modules and rejects new work" do + pool = start_pool(capacity: 1) + assert {:ok, lease} = Pool.checkout(pool, key(1)) + + drain = Task.async(fn -> Pool.drain(pool, 1_000) end) + assert eventually(fn -> Pool.stats(pool).mode == :draining end) + assert {:error, :compiler_pool_stopping} = Pool.checkout(pool, key(2)) + refute Task.yield(drain, 20) + + assert :ok = Pool.checkin(pool, lease) + assert :ok = Task.await(drain) + assert Pool.stats(pool).mode == :drained + assert Pool.stats(pool).counts == %{free: 1} + assert FakeBackend.state().retires == [lease.module] + end + + test "bounds shutdown waiting without hard-purging an active slot" do + pool = start_pool(capacity: 1) + assert {:ok, lease} = Pool.checkout(pool, key(1)) + + assert {:error, {:compiler_pool_shutdown_timeout, 1}} = Pool.drain(pool, 20) + assert FakeBackend.state().retires == [] + assert Pool.stats(pool).mode == :draining + + assert :ok = Pool.checkin(pool, lease) + assert eventually(fn -> Pool.stats(pool).mode == :drained end) + assert FakeBackend.state().retires == [lease.module] + end + + test "soft-retires every reserved module name before admitting work" do + pool = + start_supervised!( + {Pool, backend: FakeBackend, task_supervisor: QuickBEAM.VM.TaskSupervisor, capacity: 1} + ) + + assert Process.alive?(pool) + assert Enum.sort(FakeBackend.state().retires) == Enum.sort(Contract.pool_modules()) + assert Pool.stats(pool).counts == %{free: 1} + end + + test "enforces one process-wide owner for the static module names" do + pool = start_pool(capacity: 1) + + assert {:error, {:already_started, ^pool}} = + Pool.start_link(backend: FakeBackend, capacity: 1) + + assert {:error, {:invalid_option, :name, :another_pool}} = + Pool.start_link(backend: FakeBackend, name: :another_pool) + end + + test "validates bounded startup options and artifact keys" do + assert {:error, {:invalid_artifact_key, <<1>>}} = + Pool.checkout(self(), <<1>>) + + assert {:error, {{:missing_option, :backend}, _child}} = + start_supervised({Pool, []}) + + assert {:error, {{:invalid_compiler_backend, String}, _child}} = + start_supervised({Pool, backend: String}) + + assert {:error, {{:invalid_option, :capacity, 33}, _child}} = + start_supervised({Pool, backend: FakeBackend, capacity: 33}) + + assert {:error, {{:invalid_option, :compile_timeout, 0}, _child}} = + start_supervised({Pool, backend: FakeBackend, compile_timeout: 0}) + + assert {:error, {{:invalid_option, :compile_max_heap_bytes, 0}, _child}} = + start_supervised({Pool, backend: FakeBackend, compile_max_heap_bytes: 0}) + end + + defp start_pool(opts) do + pool = + start_supervised!( + {Pool, + Keyword.merge( + [backend: FakeBackend, task_supervisor: QuickBEAM.VM.TaskSupervisor], + opts + )} + ) + + FakeBackend.clear_calls() + pool + end + + defp key(integer), do: :crypto.hash(:sha256, <>) + + defp eventually(function, attempts \\ 100) + defp eventually(function, 0), do: function.() + + defp eventually(function, attempts) do + if function.() do + true + else + Process.sleep(5) + eventually(function, attempts - 1) + end + end +end diff --git a/test/vm/compiler/profile/pure_test.exs b/test/vm/compiler/profile/pure_test.exs new file mode 100644 index 000000000..646a42f8b --- /dev/null +++ b/test/vm/compiler/profile/pure_test.exs @@ -0,0 +1,583 @@ +defmodule QuickBEAM.VM.Compiler.Profile.PureTest do + use ExUnit.Case, async: false + + alias QuickBEAM.VM.Bytecode.Opcode + alias QuickBEAM.VM.Compiler.Analysis.ControlFlow, as: CFG + alias QuickBEAM.VM.Compiler.Code + alias QuickBEAM.VM.Compiler.Code.Emitter + alias QuickBEAM.VM.Compiler.Code.Import + alias QuickBEAM.VM.Compiler.Contract + alias QuickBEAM.VM.Compiler.Deopt + alias QuickBEAM.VM.Compiler.Pool + alias QuickBEAM.VM.Compiler.Profile.Pure + alias QuickBEAM.VM.Compiler.Runtime + alias QuickBEAM.VM.Program + alias QuickBEAM.VM.Program.Function + alias QuickBEAM.VM.Runtime.Engine + alias QuickBEAM.VM.Runtime.Frame + alias QuickBEAM.VM.Runtime.Interpreter + alias QuickBEAM.VM.Runtime.Invocation + alias QuickBEAM.VM.Runtime.State + + test "builds deterministic CFG blocks with canonical successors and predecessors" do + function = branch_function() + assert {:ok, blocks} = CFG.analyze(function) + + assert Enum.map(blocks, &{&1.start_pc, &1.end_pc}) == [{0, 1}, {2, 3}, {4, 4}, {5, 5}] + + assert [first, second, third, fourth] = blocks + assert first.successors == [4, 2] + assert first.predecessors == [] + assert second.successors == [5] + assert second.predecessors == [0] + assert third.successors == [5] + assert third.predecessors == [0] + assert fourth.successors == [] + assert fourth.predecessors == [2, 4] + + assert [{0, :push_true, []}, {1, :if_false, [4]}] = first.instructions + end + + test "rejects malformed CFG targets even when analysis is called outside verification" do + function = %Function{ + id: 0, + atoms: {}, + instructions: {instruction(:goto, [99]), instruction(:return_undef)} + } + + assert {:error, {:invalid_compiler_target, 99}} = CFG.analyze(function) + + malformed = %{ + function + | instructions: {instruction(:goto, [:invalid]), instruction(:return_undef)} + } + + assert {:error, {:invalid_compiler_target, :invalid}} = CFG.analyze(malformed) + end + + test "prefilters only obviously small non-loop nested candidates" do + assert {:ok, small_program} = QuickBEAM.VM.compile("(function(value){return value+1})(41)") + small = Enum.find(small_program.root.constants, &is_struct(&1, Function)) + refute Pure.candidate?(small, 32, :pure_v1) + refute Pure.candidate?(small, 32, :scalar_v1) + assert Pure.candidate?(small, 1, :pure_v1) + + assert {:ok, loop_program} = + QuickBEAM.VM.compile("(function(n){while(n>0)n--;return n})(10)") + + loop = Enum.find(loop_program.root.constants, &is_struct(&1, Function)) + assert Pure.candidate?(loop, 10_000, :pure_v1) + assert Pure.candidate?(loop, 10_000, :scalar_v1) + end + + test "reports bounded scalar eligibility rejections" do + assert {:ok, argument_program} = + QuickBEAM.VM.compile("(function(a,b,c,d,e,f,g,h,i){return a})(1,2,3,4,5,6,7,8,9)") + + argument_function = + Enum.find(argument_program.root.constants, &is_struct(&1, Function)) + + assert Pure.scalar_eligibility(argument_function, :scalar_v1) == + {:ineligible, :argument_count} + + assert {:ok, loop_program} = + QuickBEAM.VM.compile("(function(n){let s=0;while(n>0){s+=n;n--}return s})(10)") + + loop_function = Enum.find(loop_program.root.constants, &is_struct(&1, Function)) + assert Pure.scalar_eligibility(loop_function, :scalar_v1) == :eligible + end + + test "extracts a bounded scalar entry region from an oversized function" do + body = Enum.map_join(1..100, "", fn _index -> "value=value+1;" end) + + assert {:ok, program} = + QuickBEAM.VM.compile("(function(){let value=0;#{body}return value})()") + + function = Enum.find(program.root.constants, &is_struct(&1, Function)) + + assert {:skip, 3} = Pure.prepare(function, 32, :scalar_v1) + + assert {:ok, region_template, 32} = Pure.prepare_region(function, 0, :scalar_v1) + assert Pure.scalar_template?(region_template) + + assert [{:function, _, :block, 3, clauses}] = + Enum.filter(region_template.forms, &match?({:function, _, :block, 3, _}, &1)) + + assert length(clauses) <= 17 + end + + test "emits bounded pure plans with explicit unsupported boundaries" do + function = arithmetic_function() + + assert {:ok, + %{ + 0 => + {[ + {:stack, :push_i32, [40]}, + {:stack, :push_i32, [2]}, + {:value, :add, []} + ], :unsupported_opcode} + }} = Pure.plan(function) + + assert {:skip, 3} = Pure.prepare(function, 4) + assert {:ok, prepared, 3} = Pure.prepare(function, 3) + assert {:ok, template} = Pure.lower(function) + assert prepared == template + + assert [:run, :block] == + for({:function, _line, name, _arity, _clauses} <- template.forms, do: name) + + module = hd(Contract.pool_modules()) + assert {:ok, artifact} = Emitter.emit(key(1), module, template) + + assert {:ok, imports} = Import.imports(artifact.binary) + assert {Runtime, :deopt_state, 4} in imports + refute {Runtime, :binary, 3} in imports + refute {Runtime, :execute_fast_block, 4} in imports + refute {Runtime, :execute_stack, 4} in imports + refute {Runtime, :execute_value, 4} in imports + refute {Runtime, :execute_plan, 4} in imports + end + + test "lowering unbounded function values does not allocate per-program atoms" do + function = arithmetic_function() + + for value <- 1..100 do + instructions = put_elem(function.instructions, 0, instruction(:push_i32, [value])) + assert {:ok, _template} = Pure.lower(%{function | id: value, instructions: instructions}) + end + + atom_count = :erlang.system_info(:atom_count) + + for value <- 1..10_000 do + instructions = put_elem(function.instructions, 0, instruction(:push_i32, [value])) + assert {:ok, _template} = Pure.lower(%{function | id: value, instructions: instructions}) + end + + assert :erlang.system_info(:atom_count) == atom_count + end + + test "repeated specialized module emission reuses fixed module and form atoms" do + pool = start_pool() + function = arithmetic_function() + + emit = fn value -> + instructions = put_elem(function.instructions, 0, instruction(:push_i32, [value])) + function = %{function | id: value, instructions: instructions} + assert {:ok, template} = Pure.lower(function) + assert {:ok, artifact_key} = Contract.artifact_key(program(function), function) + assert {:ok, lease} = Pool.checkout(pool, artifact_key, template) + assert :ok = Pool.checkin(pool, lease) + end + + Enum.each(1..5, emit) + atom_count = :erlang.system_info(:atom_count) + Enum.each(6..105, emit) + assert :erlang.system_info(:atom_count) == atom_count + end + + test "marks async instructions as explicit suspension boundaries" do + function = %Function{ + id: 0, + atoms: {}, + instructions: { + instruction(:undefined), + instruction(:await), + instruction(:return_async) + }, + stack_size: 1 + } + + assert {:ok, + %{ + 0 => {[{:stack, :undefined, []}], :suspension_boundary}, + 2 => {[], :unsupported_opcode} + }} = Pure.plan(function) + end + + test "caps one generated block plan at 256 instructions" do + instructions = + List.to_tuple(List.duplicate(instruction(:push_i32, [1]), 300) ++ [instruction(:return)]) + + function = %Function{id: 0, atoms: {}, instructions: instructions, stack_size: 300} + assert {:ok, %{0 => {operations, :unsupported_semantics}}} = Pure.plan(function) + assert length(operations) == 256 + end + + test "rejects functions exceeding specialized block and instruction caps" do + too_many_blocks = + for(pc <- 0..4_096, do: instruction(:goto, [pc + 1])) ++ [instruction(:return_undef)] + + function = %Function{ + id: 0, + atoms: {}, + instructions: List.to_tuple(too_many_blocks), + stack_size: 0 + } + + assert {:error, {:compiler_resource_limit, :blocks, 4_098, 4_096}} = + Pure.lower(function) + + too_many_operations = + Enum.flat_map(0..16, fn block -> + next_block = (block + 1) * 257 + List.duplicate(instruction(:undefined), 256) ++ [instruction(:goto, [next_block])] + end) ++ [instruction(:return_undef)] + + function = %{function | instructions: List.to_tuple(too_many_operations), stack_size: 4_352} + + assert {:error, {:compiler_resource_limit, :lowered_instructions, 4_352, 4_096}} = + Pure.lower(function) + end + + test "runs a lowered pure prefix and resumes the interpreter before return" do + function = arithmetic_function() + program = program(function) + pool = start_pool() + + assert {:ok, template} = Pure.lower(function) + assert {:ok, artifact_key} = Contract.artifact_key(program, function) + assert {:ok, lease} = Pool.checkout(pool, artifact_key, template) + + frame = frame(function) + execution = execution(4) + assert {:ok, plan} = Pure.plan(function) + + assert {:deopt, %Deopt{} = unspecialized} = + Runtime.execute_plan(lease, frame, execution, plan) + + assert {:deopt, %Deopt{} = deopt} = + Code.invoke(pool, lease, frame, execution) + + assert %{deopt.frame | compiler_allow_reentry: false} == unspecialized.frame + assert deopt.execution == unspecialized.execution + assert deopt.reason == :unsupported_opcode + assert deopt.frame.pc == 3 + assert deopt.frame.stack == [42] + assert deopt.execution.remaining_steps == 1 + assert :ok = Pool.checkin(pool, lease) + + assert Interpreter.resume_deopt(deopt) == {:ok, 42} + assert Interpreter.eval(program, max_steps: 4) == {:ok, 42} + end + + test "preserves exact step rejection across compiled-to-interpreter deoptimization" do + function = arithmetic_function() + program = program(function) + pool = start_pool() + + assert {:ok, template} = Pure.lower(function) + assert {:ok, artifact_key} = Contract.artifact_key(program, function) + assert {:ok, lease} = Pool.checkout(pool, artifact_key, template) + + assert {:deopt, %Deopt{} = deopt} = + Code.invoke(pool, lease, frame(function), execution(3)) + + assert deopt.execution.remaining_steps == 0 + assert :ok = Pool.checkin(pool, lease) + + expected = {:error, {:limit_exceeded, :steps, 3}} + assert Interpreter.resume_deopt(deopt) == expected + assert Interpreter.eval(program, max_steps: 3) == expected + end + + test "scalarizes bounded lexical loops with direct guarded BEAM operations" do + source = "(function(n){let s=0; for(let i=0;i 0 + assert compiled.compiler_counters.generated_steps < compiled.steps + assert compiled.compiler_counters.invocation_actions > 0 + + for limit <- [1, 10, div(interpreted.steps, 2), interpreted.steps - 1] do + assert Engine.eval(program, Keyword.put(opts, :max_steps, limit)) == + Engine.eval(program, max_steps: limit) + end + end + + test "materializes scalar tuple updates before invocation boundaries" do + start_pool() + + source = + "(function(e,t,n,r=false,i=false,a=0){" <> + "for(let o=a;o + "})([1,2],function(){},0)" + + assert {:ok, program} = QuickBEAM.VM.compile(source) + opts = [engine: :compiler, compiler_profile: :scalar_v1, max_steps: 10_000] + assert {:ok, interpreted} = Engine.measure(program, max_steps: 10_000) + assert {:ok, compiled} = Engine.measure(program, opts) + assert compiled.result == {:ok, 42} + assert compiled.result == interpreted.result + assert compiled.steps == interpreted.steps + assert compiled.logical_memory_bytes == interpreted.logical_memory_bytes + assert compiled.compiler_counters.generated_steps > 0 + assert compiled.compiler_counters.invocation_actions > 0 + + for limit <- [1, div(interpreted.steps, 2), interpreted.steps - 1] do + assert Engine.eval(program, Keyword.put(opts, :max_steps, limit)) == + Engine.eval(program, max_steps: limit) + end + end + + test "scalar globals preserve canonical state, errors, and resource counters" do + start_pool() + + source = + "var total=0;(function(n){for(let i=0;i 100 + assert compiled.compiler_counters.interpreted_opcodes[:get_var] == nil + assert compiled.compiler_counters.interpreted_opcodes[:put_var] == 1 + + for limit <- [1, 20, div(interpreted.steps, 2), interpreted.steps - 1] do + assert Engine.eval(program, Keyword.put(opts, :max_steps, limit)) == + Engine.eval(program, max_steps: limit) + end + + missing = "(function(n){for(let i=0;i 3", "true ? 11 : 22", "(5 << 2) | 1"] do + assert {:ok, %Program{root: function} = program} = QuickBEAM.VM.compile(source) + assert {:ok, template} = Pure.lower(function) + assert {:ok, artifact_key} = Contract.artifact_key(program, function) + assert {:ok, lease} = Pool.checkout(pool, artifact_key, template) + + frame = Invocation.new_frame(function, function, [], :undefined, {}) + execution = %{execution(100) | atoms: program.atoms} + + assert {:deopt, %Deopt{} = deopt} = + Code.invoke(pool, lease, frame, execution) + + assert deopt.frame.pc > 0 + assert :ok = Pool.checkin(pool, lease) + + assert Interpreter.resume_deopt(deopt) == + Engine.eval(program, max_steps: 100) + end + end + + test "lowers decoded function arguments and locals through canonical frames" do + source = "function calc(a, b) { let value = a + b; return value * 2 } calc" + assert {:ok, %Program{} = program} = QuickBEAM.VM.compile(source) + assert %Function{} = function = Enum.find(program.root.constants, &is_struct(&1, Function)) + pool = start_pool() + + assert {:ok, template} = Pure.lower(function) + assert {:ok, artifact_key} = Contract.artifact_key(program, function) + assert {:ok, lease} = Pool.checkout(pool, artifact_key, template) + + frame = Invocation.new_frame(function, function, [20, 1], :undefined, {}) + execution = %{execution(100) | atoms: program.atoms} + + assert {:deopt, %Deopt{} = deopt} = + Code.invoke(pool, lease, frame, execution) + + assert deopt.frame.pc > 0 + assert :ok = Pool.checkin(pool, lease) + assert Interpreter.resume_deopt(deopt) == {:ok, 42} + end + + test "tail-calls compiled successor blocks before deoptimizing at return" do + function = branch_function() + program = program(function) + pool = start_pool() + + assert {:ok, template} = Pure.lower(function) + assert {:ok, artifact_key} = Contract.artifact_key(program, function) + assert {:ok, lease} = Pool.checkout(pool, artifact_key, template) + + assert {:deopt, %Deopt{} = deopt} = + Code.invoke(pool, lease, frame(function), execution(10)) + + assert deopt.reason == :unsupported_opcode + assert deopt.frame.pc == 5 + assert deopt.frame.stack == [10] + assert deopt.execution.remaining_steps == 6 + assert :ok = Pool.checkin(pool, lease) + + assert Interpreter.resume_deopt(deopt) == {:ok, 10} + assert Interpreter.eval(program, max_steps: 10) == {:ok, 10} + end + + defp start_pool do + start_supervised!( + {Pool, backend: Code, task_supervisor: QuickBEAM.VM.TaskSupervisor, capacity: 1} + ) + end + + defp arithmetic_function do + %Function{ + id: 0, + atoms: {}, + instructions: { + instruction(:push_i32, [40]), + instruction(:push_i32, [2]), + instruction(:add), + instruction(:return) + }, + stack_size: 2 + } + end + + defp branch_function do + %Function{ + id: 0, + atoms: {}, + instructions: { + instruction(:push_true), + instruction(:if_false, [4]), + instruction(:push_i32, [10]), + instruction(:goto, [5]), + instruction(:push_i32, [20]), + instruction(:return) + }, + stack_size: 1 + } + end + + defp instruction(name, operands \\ []), do: {Opcode.num(name), operands} + + defp program(function) do + %Program{ + version: 26, + fingerprint: "pure-v1-test", + atoms: {}, + root: function + } + end + + defp frame(function) do + %Frame{ + function: function, + callable: function, + locals: {}, + args: {}, + this: :undefined + } + end + + defp execution(steps) do + %State{ + atoms: {}, + max_stack_depth: 32, + remaining_steps: steps, + step_limit: steps + } + end + + defp key(integer), do: :crypto.hash(:sha256, <>) +end diff --git a/test/vm/compiler/region/probe_test.exs b/test/vm/compiler/region/probe_test.exs new file mode 100644 index 000000000..bb9dca052 --- /dev/null +++ b/test/vm/compiler/region/probe_test.exs @@ -0,0 +1,52 @@ +defmodule QuickBEAM.VM.Compiler.Region.ProbeTest do + use ExUnit.Case, async: true + + alias QuickBEAM.VM.Compiler.Context + alias QuickBEAM.VM.Compiler.Region.Probe + alias QuickBEAM.VM.Program + alias QuickBEAM.VM.Program.Function + alias QuickBEAM.VM.Runtime.Frame + alias QuickBEAM.VM.Runtime.State + + test "samples fixed-capacity integer regions in the evaluation owner" do + execution = execution() + + execution = + Enum.reduce(0..64, execution, fn region, execution -> + frame = frame(region, region * 64) + + Enum.reduce(1..16, execution, fn _sample, execution -> + Probe.observe(execution, frame) + end) + end) + + snapshot = Probe.snapshot(execution) + assert snapshot.sample_interval == 16 + assert snapshot.window_size == 64 + assert snapshot.total_samples == 65 + assert length(snapshot.regions) == 64 + assert Enum.all?(snapshot.regions, &is_integer(&1.function_id)) + assert Enum.all?(snapshot.regions, &is_integer(&1.entry_pc)) + + assert Task.await(Task.async(fn -> Probe.snapshot(execution) end)) == nil + end + + defp execution do + %State{ + atoms: {}, + compiler_context: %Context{ + pool: self(), + program: %Program{version: 26, fingerprint: "test", atoms: {}, root: nil}, + region_probe: Probe.new() + }, + max_stack_depth: 8, + remaining_steps: 8, + step_limit: 8 + } + end + + defp frame(function_id, pc) do + function = %Function{id: function_id} + %Frame{function: function, callable: function, locals: {}, args: {}, pc: pc} + end +end diff --git a/test/vm/compiler/runtime_test.exs b/test/vm/compiler/runtime_test.exs new file mode 100644 index 000000000..c015bcc10 --- /dev/null +++ b/test/vm/compiler/runtime_test.exs @@ -0,0 +1,132 @@ +defmodule QuickBEAM.VM.Compiler.RuntimeTest do + use ExUnit.Case, async: true + + alias QuickBEAM.VM.Compiler.Contract + alias QuickBEAM.VM.Compiler.Deopt + alias QuickBEAM.VM.Compiler.Pool.Lease + alias QuickBEAM.VM.Compiler.Runtime + alias QuickBEAM.VM.Program.Function + alias QuickBEAM.VM.Runtime.Frame + alias QuickBEAM.VM.Runtime.State + + test "charges guaranteed blocks exactly and deoptimizes before a partial block" do + frame = frame() + execution = execution(5) + lease = lease() + + assert {:ok, ^frame, charged} = Runtime.charge_block(lease, frame, execution, 3) + assert charged.remaining_steps == 2 + + assert {:deopt, %Deopt{} = deopt} = Runtime.charge_block(lease, frame, charged, 3) + assert deopt.reason == :step_boundary + assert deopt.frame == frame + assert deopt.execution.remaining_steps == 2 + assert deopt.phase == :before_instruction + end + + test "rejects execution with another process's lease" do + owner_lease = lease() + frame = frame() + execution = execution(5) + + task = + Task.async(fn -> + { + Runtime.charge_block(owner_lease, frame, execution, 1), + Runtime.deopt(:unsupported_opcode, owner_lease, frame, execution) + } + end) + + assert { + {:error, :compiler_lease_owner_mismatch}, + {:error, :compiler_lease_owner_mismatch} + } = Task.await(task) + end + + test "reports an existing logical memory failure before charging steps" do + frame = frame() + execution = %{execution(5) | memory_exceeded: true, memory_limit: 64} + + assert {:error, {:limit_exceeded, :memory_bytes, 64}, ^execution} = + Runtime.charge_block(lease(), frame, execution, 1) + end + + test "delegates stack and value instructions to canonical opcode semantics" do + execution = execution(10) + frame = frame() + + assert {:ok, pushed, ^execution} = Runtime.execute_stack(:push_i32, [40], frame, execution) + assert pushed.pc == 1 + assert pushed.stack == [40] + + pushed = %{pushed | pc: 0} + assert {:ok, pushed, ^execution} = Runtime.execute_stack(:push_i32, [2], pushed, execution) + assert pushed.stack == [2, 40] + + pushed = %{pushed | pc: 0} + assert {:ok, added, ^execution} = Runtime.execute_value(:add, [], pushed, execution) + assert added.pc == 1 + assert added.stack == [42] + end + + test "delegates local reads and branches without bypassing canonical frame state" do + execution = execution(10) + frame = %{frame() | locals: {42}} + + assert {:ok, local, ^execution} = Runtime.execute_local(:get_loc, [0], frame, execution) + assert local.stack == [42] + assert local.pc == 1 + + branch = %{frame | stack: [false]} + assert {:ok, jumped, ^execution} = Runtime.execute_branch(:if_false, [7], branch, execution) + assert jumped.pc == 7 + assert jumped.stack == [] + end + + test "exposes only the declared primitive ABI operations" do + assert Runtime.version() == Contract.runtime_abi_version() + assert Runtime.truthy?("value") + assert Runtime.unary(:inc, 41) == 42 + assert Runtime.binary(:strict_eq, 42, 42.0) + + assert {:error, {:unsupported_compiler_local_operation, :rest, [0]}} = + Runtime.execute_local(:rest, [0], frame(), execution(10)) + end + + defp frame do + function = %Function{ + id: 0, + atoms: {}, + instructions: {{0, []}, {0, []}, {0, []}, {0, []}, {0, []}, {0, []}, {0, []}, {0, []}} + } + + %Frame{ + function: function, + callable: function, + locals: {}, + args: {}, + this: :undefined + } + end + + defp execution(steps) do + %State{ + atoms: {}, + max_stack_depth: 32, + remaining_steps: steps, + step_limit: steps + } + end + + defp lease do + %Lease{ + pool: self(), + module: hd(Contract.pool_modules()), + key: :crypto.hash(:sha256, "runtime-test"), + epoch: 1, + generation: 1, + token: make_ref(), + owner: self() + } + end +end diff --git a/test/vm/fuzz_test.exs b/test/vm/fuzz_test.exs new file mode 100644 index 000000000..4e40b0dd5 --- /dev/null +++ b/test/vm/fuzz_test.exs @@ -0,0 +1,119 @@ +defmodule QuickBEAM.VM.FuzzTest do + use ExUnit.Case, async: false + + alias QuickBEAM.VM.Fuzz + alias QuickBEAM.VM.Fuzz.Finding + + @seed 0x51424D + @regression_directory Path.expand("../fixtures/vm/fuzz/regressions", __DIR__) + + setup_all do + {:ok, runtime} = QuickBEAM.start(apis: false) + + bytecode = + Enum.map(Fuzz.default_sources(), fn {name, source} -> + assert {:ok, compiled} = QuickBEAM.compile(runtime, source) + {name, compiled} + end) + + QuickBEAM.stop(runtime) + + programs = + Enum.map(bytecode, fn {name, compiled} -> + assert {:ok, program} = QuickBEAM.VM.decode(compiled) + {name, program} + end) + + %{bytecode: bytecode, programs: programs} + end + + test "runs thousands of deterministic bounded decoder mutations", %{bytecode: corpus} do + assert {:ok, summary} = + Fuzz.run_bytecode(corpus, + seed: @seed, + iterations: 2_000, + timeout: 100, + max_heap_bytes: 16 * 1024 * 1024 + ) + + assert Fuzz.safe?(summary) + assert summary.findings == [] + assert Enum.sum(Map.values(summary.counts)) == 2_000 + assert map_size(summary.operation_counts) == 13 + assert summary.counts[:truncated] > 0 + assert summary.counts[:malformed_integer] > 0 + assert summary.counts[:limit] > 0 + end + + test "rejects every deliberately invalid verifier mutation", %{programs: corpus} do + assert {:ok, summary} = + Fuzz.run_verifier(corpus, + seed: @seed, + iterations: 1_000, + timeout: 100, + max_heap_bytes: 16 * 1024 * 1024 + ) + + assert Fuzz.safe?(summary) + assert summary.findings == [] + assert Enum.sum(Map.values(summary.counts)) == 1_000 + assert map_size(summary.operation_counts) == 17 + refute Map.has_key?(summary.counts, :accepted_invalid_program) + assert summary.counts[:invalid_jump] > 0 + assert summary.counts[:invalid_reference] > 0 + assert summary.counts[:stack_underflow] > 0 + assert summary.counts[:invalid_stack] > 0 + end + + test "mutation replay is independent of process random state", %{ + bytecode: corpus, + programs: programs + } do + {bytecode_name, bytecode} = Enum.at(corpus, 2) + {program_name, program} = Enum.at(programs, 3) + + :rand.seed(:exsss, {1, 2, 3}) + first_bytecode = Fuzz.bytecode_mutation(bytecode_name, bytecode, @seed, 719) + first_program = Fuzz.program_mutation(program_name, program, @seed, 719) + + :rand.seed(:exsss, {999, 888, 777}) + assert Fuzz.bytecode_mutation(bytecode_name, bytecode, @seed, 719) == first_bytecode + assert Fuzz.program_mutation(program_name, program, @seed, 719) == first_program + end + + test "writes replayable regression artifacts", %{bytecode: [{name, bytecode} | _rest]} do + directory = + Path.join( + System.tmp_dir!(), + "quickbeam-vm-regression-#{System.unique_integer([:positive])}" + ) + + on_exit(fn -> File.rm_rf!(directory) end) + + mutation = Fuzz.bytecode_mutation(name, bytecode, @seed, 17) + finding = %Finding{mutation: mutation, outcome: {:crash, :synthetic_test_reason}} + + assert {:ok, binary_path} = Fuzz.persist(finding, directory) + assert File.read!(binary_path) == mutation.value + + metadata_path = Path.rootname(binary_path) <> ".txt" + metadata = File.read!(metadata_path) + assert metadata =~ "seed: #{@seed}" + assert metadata =~ "iteration: 17" + assert metadata =~ "sha256:" + end + + test "persisted minimized regressions remain bounded typed rejections" do + for path <- Path.wildcard(Path.join(@regression_directory, "*.bin")) do + bytecode = File.read!(path) + + assert {:ok, {:rejected, classification, _reason}} = + Fuzz.probe_bytecode(bytecode, + timeout: 100, + max_heap_bytes: 16 * 1024 * 1024 + ) + + assert is_atom(classification) + end + end +end diff --git a/test/vm/measurement_test.exs b/test/vm/measurement_test.exs new file mode 100644 index 000000000..2f4ba7ed4 --- /dev/null +++ b/test/vm/measurement_test.exs @@ -0,0 +1,77 @@ +defmodule QuickBEAM.VM.MeasurementTest do + use ExUnit.Case, async: false + + alias QuickBEAM.VM.Measurement + + test "reports deterministic VM counters and endpoint process observations" do + assert {:ok, program} = QuickBEAM.VM.compile("({answer: 40 + 2})") + + assert {:ok, %Measurement{} = first} = QuickBEAM.VM.measure(program) + assert first.result == {:ok, %{"answer" => 42}} + assert first.wall_time_us >= 0 + assert first.steps > 0 + assert first.logical_memory_bytes > 0 + assert first.process_memory_bytes > 0 + assert first.reductions > 0 + + assert {:ok, %Measurement{} = second} = QuickBEAM.VM.measure(program) + assert second.result == first.result + assert second.steps == first.steps + assert second.logical_memory_bytes == first.logical_memory_bytes + end + + test "measures an asynchronously resumed evaluation" do + assert {:ok, program} = QuickBEAM.VM.compile("Beam.call('double', 21)") + handler = fn [value] -> value * 2 end + + assert {:ok, %Measurement{} = measurement} = + QuickBEAM.VM.measure(program, handlers: %{"double" => handler}) + + assert measurement.result == {:ok, 42} + assert measurement.steps > 0 + assert measurement.logical_memory_bytes > 0 + end + + test "retains final counters for an interpreter resource rejection" do + assert {:ok, program} = QuickBEAM.VM.compile("while (true) {}") + + assert {:ok, %Measurement{} = measurement} = + QuickBEAM.VM.measure(program, max_steps: 100, timeout: 1_000) + + assert measurement.result == {:error, {:limit_exceeded, :steps, 100}} + assert measurement.steps == 100 + assert measurement.logical_memory_bytes > 0 + end + + test "reports an outer timeout and terminates the outstanding handler" do + parent = self() + assert {:ok, program} = QuickBEAM.VM.compile("Beam.call('wait')") + + handler = fn [] -> + send(parent, {:handler_started, self()}) + Process.sleep(:infinity) + end + + assert {:ok, %Measurement{} = measurement} = + QuickBEAM.VM.measure(program, + handlers: %{"wait" => handler}, + timeout: 200 + ) + + assert measurement.result == {:error, {:limit_exceeded, :timeout, 200}} + assert measurement.wall_time_us >= 200_000 + assert measurement.steps == nil + assert measurement.logical_memory_bytes == nil + + assert_receive {:handler_started, handler_pid} + monitor = Process.monitor(handler_pid) + assert_receive {:DOWN, ^monitor, :process, ^handler_pid, _reason}, 1_000 + end + + test "returns validation errors before starting a measurement" do + assert {:ok, program} = QuickBEAM.VM.compile("42") + + assert {:error, {:invalid_option, :max_steps, 0}} = + QuickBEAM.VM.measure(program, max_steps: 0) + end +end diff --git a/test/vm/memory_limit_test.exs b/test/vm/memory_limit_test.exs new file mode 100644 index 000000000..5639b2e8e --- /dev/null +++ b/test/vm/memory_limit_test.exs @@ -0,0 +1,69 @@ +defmodule QuickBEAM.VM.MemoryLimitTest do + use ExUnit.Case, async: false + + alias QuickBEAM.VM.Runtime.Memory + alias QuickBEAM.VM.Runtime.Object + + test "specialized fresh-object accounting preserves the canonical estimate" do + assert Memory.estimate(%Object{}) == 520 + end + + test "validates the JavaScript allocation budget" do + assert {:ok, program} = QuickBEAM.VM.compile("1") + + for limit <- [0, -1, "1 MB"] do + assert {:error, {:invalid_option, :memory_limit, ^limit}} = + QuickBEAM.VM.eval(program, memory_limit: limit) + end + + assert {:ok, 1} = QuickBEAM.VM.eval(program, memory_limit: :infinity) + end + + test "enforces the logical allocation budget in caller and isolated modes" do + source = "{let values=[]; for(let i=0;i<1000;i++) values[i]={}; values.length}" + assert {:ok, program} = QuickBEAM.VM.compile(source) + + for isolation <- [:caller, :process] do + assert {:error, {:limit_exceeded, :memory_bytes, 20_000}} = + QuickBEAM.VM.eval(program, + isolation: isolation, + memory_limit: 20_000, + max_steps: 100_000 + ) + end + end + + test "memory limits cannot be intercepted by JavaScript catch handlers" do + source = "try { let values=[]; while(true) values[values.length]={} } catch(error) { 42 }" + assert {:ok, program} = QuickBEAM.VM.compile(source) + + assert {:error, {:limit_exceeded, :memory_bytes, 20_000}} = + QuickBEAM.VM.eval(program, memory_limit: 20_000, max_steps: 100_000) + end + + test "contains oversized host results and reclaims the evaluation owner" do + test_process = self() + source = "(async function(){return await Beam.call('large_result')})()" + assert {:ok, program} = QuickBEAM.VM.compile(source) + + handler = fn [] -> + {:links, links} = Process.info(self(), :links) + send(test_process, {:handler_process, self(), links}) + Enum.to_list(1..5_000) + end + + assert {:error, {:limit_exceeded, :memory_bytes, 150_000}} = + QuickBEAM.VM.eval(program, + handlers: %{"large_result" => handler}, + memory_limit: 150_000, + timeout: 5_000 + ) + + assert_receive {:handler_process, handler_pid, links} + supervisor = Process.whereis(QuickBEAM.VM.TaskSupervisor) + owner_pid = Enum.find(links, &(&1 != supervisor)) + + refute Process.alive?(handler_pid) + refute Process.alive?(owner_pid) + end +end diff --git a/test/vm/preact_ssr_test.exs b/test/vm/preact_ssr_test.exs new file mode 100644 index 000000000..d7ab407df --- /dev/null +++ b/test/vm/preact_ssr_test.exs @@ -0,0 +1,148 @@ +defmodule QuickBEAM.VM.PreactSSRTest do + use ExUnit.Case, async: false + + @fixture "test/fixtures/vm/preact_ssr.js" + + setup_all do + assert {:ok, source} = QuickBEAM.JS.bundle_file(@fixture, format: :esm, minify: false) + assert {:ok, program} = QuickBEAM.VM.compile(source, filename: @fixture) + call_source = call_source!() + assert {:ok, call_program} = QuickBEAM.VM.compile(call_source, filename: @fixture) + {:ok, source: source, program: program, call_source: call_source, call_program: call_program} + end + + test "renders pinned Preact HTML after an asynchronous Beam.call", %{program: program} do + props = props("Featured", 1) + + handler = fn [] -> + Process.sleep(5) + props + end + + assert {:ok, html} = + QuickBEAM.VM.eval(program, + handlers: %{"load_props" => handler}, + max_steps: 20_000_000, + timeout: 2_000 + ) + + assert html == + ~s(

Featured

  • Product 1: $12.99
) + end + + test "matches the vendored native QuickJS renderer", %{program: program, source: source} do + props = props("Native parity", 2) + handlers = %{"load_props" => fn [] -> props end} + + assert {:ok, runtime} = QuickBEAM.start(handlers: handlers) + + try do + assert {:ok, %{}} = QuickBEAM.eval(runtime, source, timeout: 2_000) + + assert {:ok, native_html} = + QuickBEAM.eval(runtime, "await globalThis.__quickbeamSSRResult", timeout: 2_000) + + assert {:ok, beam_html} = + QuickBEAM.VM.eval(program, + handlers: handlers, + max_steps: 20_000_000, + timeout: 2_000 + ) + + assert beam_html == native_html + after + QuickBEAM.stop(runtime) + end + end + + test "calls the same named async renderer shape as the native runtime", %{ + call_program: call_program, + call_source: call_source + } do + props = props("Call parity", 3) + handlers = %{"load_props" => fn [] -> props end} + + assert {:ok, runtime} = QuickBEAM.start(handlers: handlers, apis: false) + + try do + assert {:ok, %{}} = QuickBEAM.eval(runtime, call_source, timeout: 2_000) + assert {:ok, native_html} = QuickBEAM.call(runtime, "__quickbeamRender", [], timeout: 2_000) + + assert {:ok, beam_html} = + QuickBEAM.VM.call(call_program, "__quickbeamRender", [], + profile: :ssr, + handlers: handlers, + max_steps: 20_000_000, + timeout: 2_000 + ) + + assert beam_html == native_html + assert beam_html =~ "

Call parity

" + after + QuickBEAM.stop(runtime) + end + end + + test "shares one program across isolated concurrent renders", %{program: program} do + tasks = + for id <- 1..20 do + Task.async(fn -> + props = props("Catalog #{id}", id) + + QuickBEAM.VM.eval(program, + handlers: %{"load_props" => fn [] -> props end}, + max_steps: 20_000_000, + timeout: 2_000 + ) + end) + end + + results = Task.await_many(tasks, 5_000) + + for {{:ok, html}, id} <- Enum.zip(results, 1..20) do + assert html =~ "

Catalog #{id}

" + assert html =~ ~s(data-id="#{id}") + assert html =~ "Product #{id}" + end + end + + defp call_source! do + source = File.read!(@fixture) + + call_source = + source + |> String.replace( + "globalThis.__quickbeamSSRResult = (async function", + "globalThis.__quickbeamRender = async function" + ) + |> String.replace("})();\n\nglobalThis.__quickbeamSSRResult;", "};") + + refute call_source == source + + temporary = + Path.join(Path.dirname(@fixture), ".call-#{System.unique_integer([:positive])}.js") + + File.write!(temporary, call_source) + + try do + assert {:ok, bundled} = QuickBEAM.JS.bundle_file(temporary, format: :esm, minify: false) + bundled + after + File.rm!(temporary) + end + end + + defp props(title, id) do + %{ + "title" => title, + "products" => [ + %{ + "id" => id, + "name" => "Product #{id}", + "inStock" => rem(id, 2) == 1, + "priceCents" => 1_299 + (id - 1) * 100 + } + ] + } + end +end diff --git a/test/vm/program/store_test.exs b/test/vm/program/store_test.exs new file mode 100644 index 000000000..668008f40 --- /dev/null +++ b/test/vm/program/store_test.exs @@ -0,0 +1,209 @@ +defmodule QuickBEAM.VM.Program.StoreTest do + use ExUnit.Case, async: false + + alias QuickBEAM.VM.Program + alias QuickBEAM.VM.Program.Identity + alias QuickBEAM.VM.Program.Store + + test "coalesces concurrent first pin admission by program identity" do + program = program(:crypto.strong_rand_bytes(32)) + + pinned = + 1..20 + |> Task.async_stream(fn _index -> Store.pin(program) end, + max_concurrency: 20, + ordered: false + ) + |> Enum.map(fn {:ok, {:ok, pinned}} -> pinned end) + + assert length(Enum.uniq(pinned)) == 1 + [handle | _rest] = pinned + assert {:ok, lease} = Store.checkout(handle) + assert {:ok, ^program} = Store.fetch(lease) + Store.checkin(lease) + assert :ok = Store.unpin(handle) + assert eventually(fn -> Store.unpin(handle) == :not_pinned end) + end + + test "pins one immutable program under concurrent bounded leases" do + program = program(:crypto.strong_rand_bytes(32)) + + assert {:ok, pinned} = Store.pin(program) + parent = self() + + tasks = + Enum.map(1..20, fn _index -> + Task.async(fn -> + {:ok, lease} = Store.checkout(pinned) + send(parent, {:lease, lease}) + + receive do + :finish -> Store.checkin(lease) + end + end) + end) + + leases = + Enum.map(tasks, fn _task -> + receive do + {:lease, lease} -> lease + end + end) + + assert Enum.uniq_by(leases, &{&1.slot, &1.token}) |> length() == 1 + assert Enum.all?(leases, fn lease -> Store.fetch(lease) == {:ok, program} end) + assert :ok = Store.unpin(pinned) + assert Enum.all?(leases, fn lease -> Store.fetch(lease) == {:ok, program} end) + assert :unavailable = Store.checkout(pinned) + assert :retiring = Store.pin(program) + + Enum.each(tasks, &send(&1.pid, :finish)) + Enum.each(tasks, &Task.await/1) + assert eventually(fn -> Store.unpin(pinned) == :not_pinned end) + assert Enum.all?(leases, fn lease -> Store.fetch(lease) == {:error, :stale_lease} end) + end + + test "restores fixed persistent slots after the store restarts" do + program = program(:crypto.strong_rand_bytes(32)) + assert {:ok, pinned} = Store.pin(program) + + old_store = Process.whereis(Store) + monitor = Process.monitor(old_store) + Process.exit(old_store, :kill) + assert_receive {:DOWN, ^monitor, :process, ^old_store, :killed} + assert eventually(fn -> is_pid(Process.whereis(Store)) end) + + assert {:ok, lease} = Store.checkout(pinned) + assert {:ok, ^program} = Store.fetch(lease) + Store.checkin(lease) + assert :ok = Store.unpin(pinned) + end + + test "rejects programs above the bounded pinned bytecode size" do + program = %{program(:crypto.strong_rand_bytes(32)) | bytecode_size: 2 * 1024 * 1024 + 1} + assert {:error, :program_too_large} = Store.pin(program) + assert :not_pinned = Store.unpin(program) + end + + test "rejects decoded programs above the per-program residency bound" do + oversized = :binary.copy(<<0>>, 33 * 1024 * 1024) + program = %{program(:crypto.strong_rand_bytes(32)) | root: oversized} + + assert {:error, :program_too_large} = Store.pin(program) + assert :not_pinned = Store.unpin(program) + end + + test "rejects admission above the total decoded residency budget" do + payload = :binary.copy(<<0>>, 27 * 1024 * 1024) + + pinned = + Enum.map(1..4, fn _index -> + candidate = %{program(:crypto.strong_rand_bytes(32)) | root: payload} + assert {:ok, pinned} = Store.pin(candidate) + pinned + end) + + on_exit(fn -> Enum.each(pinned, &Store.unpin/1) end) + + candidate = %{program(:crypto.strong_rand_bytes(32)) | root: payload} + assert {:error, :residency_budget} = Store.pin(candidate) + end + + test "rejects a ninth pinned program without evicting fixed slots" do + pinned = + Enum.map(1..8, fn _index -> + assert {:ok, pinned} = Store.pin(program(:crypto.strong_rand_bytes(32))) + pinned + end) + + on_exit(fn -> Enum.each(pinned, &Store.unpin/1) end) + + ninth = program(:crypto.strong_rand_bytes(32)) + assert :unavailable = Store.pin(ninth) + + leases = + Enum.map(pinned, fn handle -> + assert {:ok, lease} = Store.checkout(handle) + lease + end) + + Enum.each(leases, &Store.checkin/1) + end + + test "owner death returns a lease and completes deferred unpinning" do + program = program(:crypto.strong_rand_bytes(32)) + assert {:ok, pinned} = Store.pin(program) + parent = self() + + {owner, monitor} = + spawn_monitor(fn -> + {:ok, lease} = Store.checkout(pinned) + send(parent, {:leased, lease}) + Process.sleep(:infinity) + end) + + assert_receive {:leased, lease} + assert :ok = Store.unpin(pinned) + assert {:ok, ^program} = Store.fetch(lease) + Process.exit(owner, :kill) + assert_receive {:DOWN, ^monitor, :process, ^owner, :killed} + + assert eventually(fn -> Store.unpin(pinned) == :not_pinned end) + assert {:error, :stale_lease} = Store.fetch(lease) + end + + test "unpinned handles fail explicitly instead of copying or falling back" do + assert {:ok, program} = QuickBEAM.VM.compile("1 + 1") + assert {:ok, pinned} = QuickBEAM.VM.pin(program) + assert :ok = QuickBEAM.VM.unpin(pinned) + assert {:error, :pinned_program_unavailable} = QuickBEAM.VM.eval(pinned) + end + + test "pin identity includes source and filename identity" do + base = %Program{ + version: 26, + fingerprint: "abi", + atoms: {}, + root: %{filename: "first.js"}, + bytecode_digest: :crypto.strong_rand_bytes(32), + bytecode_size: 100_000, + source_digest: :crypto.strong_rand_bytes(32) + } + + first = Identity.put(base) + + second = + base |> put_in([Access.key(:root), :filename], "second.js") |> Identity.put() + + changed_source = + %{base | source_digest: :crypto.strong_rand_bytes(32)} |> Identity.put() + + refute first.pin_key == second.pin_key + refute first.pin_key == changed_source.pin_key + end + + defp eventually(fun, attempts \\ 20) + + defp eventually(fun, attempts) when attempts > 0 do + if fun.() do + true + else + Process.sleep(5) + eventually(fun, attempts - 1) + end + end + + defp eventually(_fun, 0), do: false + + defp program(key) do + %Program{ + version: QuickBEAM.VM.bytecode_version(), + fingerprint: QuickBEAM.VM.fingerprint(), + atoms: {}, + root: %{filename: "test.js"}, + bytecode_digest: key, + bytecode_size: 100_000, + pin_key: key + } + end +end diff --git a/test/vm/runtime/async_semantics_test.exs b/test/vm/runtime/async_semantics_test.exs new file mode 100644 index 000000000..0a91aed4b --- /dev/null +++ b/test/vm/runtime/async_semantics_test.exs @@ -0,0 +1,100 @@ +defmodule QuickBEAM.VM.Runtime.AsyncSemanticsTest do + use ExUnit.Case, async: true + + alias QuickBEAM.VM.Program.Function + alias QuickBEAM.VM.Runtime.Async + alias QuickBEAM.VM.Runtime.Boundary + alias QuickBEAM.VM.Runtime.Frame + alias QuickBEAM.VM.Runtime.Invocation + alias QuickBEAM.VM.Runtime.Promise + alias QuickBEAM.VM.Runtime.Promise.Reaction + alias QuickBEAM.VM.Runtime.State + + test "enters async functions through an owner-local Promise boundary" do + execution = execution() + caller = frame() + function = %Function{id: 1, func_kind: 2, arg_count: 1} + + assert {:run, %Frame{function: ^function, args: {7}}, execution} = + Async.enter(function, function, {}, [7], :receiver, caller, execution, false) + + assert %Boundary.Async{mode: :push, caller: ^caller, promise: promise} = + hd(execution.callers) + + assert Promise.state(execution, promise) == :pending + assert execution.depth == 2 + end + + test "detaches await state into a coroutine waiter and delivers the async Promise" do + execution = execution() + caller = frame() + function = %Function{id: 2, func_kind: 2} + + {:run, resume_frame, execution} = + Async.enter(function, function, {}, [], :undefined, caller, execution, false) + + {awaited, execution} = Promise.new(execution) + + assert {:ok, {:complete, async_promise, ^caller, execution, false}} = + Async.detach_await(resume_frame, execution, awaited) + + assert Promise.state(execution, async_promise) == :pending + assert [_coroutine] = Map.fetch!(execution.promise_waiters, awaited.id) + assert execution.callers == [] + assert execution.depth == 1 + end + + test "Promise reactions either invoke callbacks or propagate settlements" do + execution = execution() + {result_promise, execution} = Promise.new(execution) + + reaction = %Reaction{ + result_promise: result_promise, + on_fulfilled: :undefined, + on_rejected: :undefined + } + + assert {:idle, execution} = Async.run_reaction(reaction, {:ok, 42}, execution) + assert Promise.state(execution, result_promise) == {:fulfilled, 42} + + {next_promise, execution} = Promise.new(execution) + reaction = %{reaction | result_promise: next_promise, on_fulfilled: {:builtin, "callback"}} + + assert {:invoke, {:builtin, "callback"}, [42], :undefined, boundary, ^execution, false} = + Async.run_reaction(reaction, {:ok, 42}, execution) + + assert boundary.promise == next_promise + end + + test "correlates host replies and settles their owner-local Promises" do + execution = execution() + {promise, execution} = Promise.new(execution) + operation = make_ref() + execution = %{execution | operations: %{operation => {promise, self()}}} + + assert {:ok, execution} = Async.settle_host_reply(execution, operation, {:ok, %{"x" => 1}}) + assert execution.operations == %{} + assert execution.memory_used > 0 + assert Promise.state(execution, promise) == {:fulfilled, %{"x" => 1}} + assert Async.settle_host_reply(execution, operation, {:ok, 2}) == :stale + end + + test "host calls return explicit errors and owner-local rejected Promises" do + execution = execution() + + assert {:error, {:type_error, :invalid_beam_call}, ^execution} = + Async.start_host_call([], execution) + + assert {:ok, promise, execution} = Async.start_host_call(["missing"], execution) + assert Promise.state(execution, promise) == {:rejected, {:unknown_handler, "missing"}} + end + + defp execution do + %State{atoms: {}, max_stack_depth: 10, remaining_steps: 100, step_limit: 100} + end + + defp frame do + function = %Function{id: 0} + Invocation.new_frame(function, function, [], :undefined, {}) + end +end diff --git a/test/vm/runtime/async_test.exs b/test/vm/runtime/async_test.exs new file mode 100644 index 000000000..519078465 --- /dev/null +++ b/test/vm/runtime/async_test.exs @@ -0,0 +1,139 @@ +defmodule QuickBEAM.VM.Runtime.AsyncTest do + use ExUnit.Case, async: true + + test "awaits an asynchronous BEAM handler without blocking the evaluation process" do + source = "(async function(){return await Beam.call('double', 21)})()" + assert {:ok, program} = QuickBEAM.VM.compile(source) + + handler = fn [value] -> + Process.sleep(5) + value * 2 + end + + assert {:ok, 42} = QuickBEAM.VM.eval(program, handlers: %{"double" => handler}) + end + + test "settles multiple in-flight handlers independently" do + source = """ + (async function() { + const slow = Beam.call("delay", 15, 4) + const fast = Beam.call("delay", 0, 2) + return (await slow) * 10 + await fast + })() + """ + + assert {:ok, program} = QuickBEAM.VM.compile(source) + + handler = fn [delay, value] -> + Process.sleep(delay) + value + end + + assert {:ok, 42} = QuickBEAM.VM.eval(program, handlers: %{"delay" => handler}) + end + + test "aggregates concurrently running Beam.call Promises" do + source = """ + (async function() { + const values = await Promise.all([ + Beam.call("delay", 15, 40), + Beam.call("delay", 0, 2) + ]) + return values[0] + values[1] + })() + """ + + assert {:ok, program} = QuickBEAM.VM.compile(source) + + handler = fn [delay, value] -> + Process.sleep(delay) + value + end + + assert {:ok, 42} = QuickBEAM.VM.eval(program, handlers: %{"delay" => handler}) + end + + test "resumes handler failures through JavaScript exception unwinding" do + source = """ + (async function() { + try { + return await Beam.call("fail") + } catch (error) { + return 42 + } + })() + """ + + assert {:ok, program} = QuickBEAM.VM.compile(source) + handler = fn [] -> raise "handler failed" end + assert {:ok, 42} = QuickBEAM.VM.eval(program, handlers: %{"fail" => handler}) + end + + test "rejects unknown handlers as catchable JavaScript errors" do + source = """ + (async function() { + try { + return await Beam.call("missing") + } catch (error) { + return 42 + } + })() + """ + + assert {:ok, program} = QuickBEAM.VM.compile(source) + assert {:ok, 42} = QuickBEAM.VM.eval(program) + end + + test "cancels an unobserved handler without terminating caller-isolated evaluation" do + test_process = self() + assert {:ok, program} = QuickBEAM.VM.compile("Beam.call('wait'); 42") + + handler = fn [] -> + send(test_process, {:unobserved_handler_started, self()}) + Process.sleep(:infinity) + end + + assert {:ok, 42} = + QuickBEAM.VM.eval(program, + handlers: %{"wait" => handler}, + isolation: :caller + ) + + assert_receive {:unobserved_handler_started, handler_pid} + monitor = Process.monitor(handler_pid) + assert_receive {:DOWN, ^monitor, :process, ^handler_pid, _reason}, 1_000 + end + + test "wall-clock timeout terminates an outstanding handler task" do + test_process = self() + source = "(async function(){return await Beam.call('wait')})()" + assert {:ok, program} = QuickBEAM.VM.compile(source) + + handler = fn [] -> + send(test_process, {:handler_started, self()}) + Process.sleep(:infinity) + end + + assert {:error, {:limit_exceeded, :timeout, 1_000}} = + QuickBEAM.VM.eval(program, handlers: %{"wait" => handler}, timeout: 1_000) + + assert_receive {:handler_started, handler_pid} + monitor = Process.monitor(handler_pid) + assert_receive {:DOWN, ^monitor, :process, ^handler_pid, _reason}, 1_000 + end + + test "bounds concurrently outstanding BEAM handlers per evaluation" do + calls = Enum.map_join(1..65, "\n", fn _index -> "Beam.call('ok');" end) + assert {:ok, program} = QuickBEAM.VM.compile(calls <> "\n42") + + assert {:error, {:limit_exceeded, :host_operations, 64}} = + QuickBEAM.VM.eval(program, handlers: %{"ok" => fn [] -> :ok end}) + end + + test "validates handler names and arities before starting evaluation" do + assert {:ok, program} = QuickBEAM.VM.compile("1") + + assert {:error, {:invalid_option, :handlers, _handlers}} = + QuickBEAM.VM.eval(program, handlers: %{bad: fn -> :bad end}) + end +end diff --git a/test/vm/runtime/error_test.exs b/test/vm/runtime/error_test.exs new file mode 100644 index 000000000..6c286cac7 --- /dev/null +++ b/test/vm/runtime/error_test.exs @@ -0,0 +1,150 @@ +defmodule QuickBEAM.VM.Runtime.ErrorTest do + use ExUnit.Case, async: true + + test "returns source-mapped JavaScript errors with JavaScript call frames" do + source = """ + function inner() { + return missingValue + 1 + } + function outer() { + return 1 + inner() + } + outer() + """ + + assert {:ok, program} = QuickBEAM.VM.compile(source, filename: "renderer.js") + + assert {:error, %QuickBEAM.JSError{} = error} = QuickBEAM.VM.eval(program) + assert error.name == "ReferenceError" + assert error.message == "missingValue is not defined" + assert error.filename == "renderer.js" + assert error.line == 1 + assert Enum.map(error.frames, & &1.function) == ["inner", "outer", ""] + assert error.stack =~ "ReferenceError: missingValue is not defined" + assert error.stack =~ "at inner (renderer.js:1:" + assert error.stack =~ "at outer (renderer.js:5:" + refute error.stack =~ "lib/quickbeam" + end + + test "exposes generated errors as JavaScript error-like values to catch blocks" do + source = "try { missingValue } catch (error) { error.name + ': ' + error.message }" + assert {:ok, program} = QuickBEAM.VM.compile(source) + assert {:ok, "ReferenceError: missingValue is not defined"} = QuickBEAM.VM.eval(program) + end + + test "gives generated errors JavaScript constructor identity" do + source = """ + try { + missingValue + } catch (error) { + [ + error instanceof ReferenceError, + error instanceof Error, + error.name, + error.message, + error.toString() + ] + } + """ + + assert {:ok, program} = QuickBEAM.VM.compile(source) + + assert {:ok, + [ + true, + true, + "ReferenceError", + "missingValue is not defined", + "ReferenceError: missingValue is not defined" + ]} = QuickBEAM.VM.eval(program) + end + + test "supports constructed derived errors and stable uncaught conversion" do + source = """ + const error = new RangeError("outside range") + if (!(error instanceof RangeError) || !(error instanceof Error)) throw 1 + throw error + """ + + assert {:ok, program} = QuickBEAM.VM.compile(source, filename: "range.js") + assert {:error, %QuickBEAM.JSError{} = error} = QuickBEAM.VM.eval(program) + assert error.name == "RangeError" + assert error.message == "outside range" + assert error.filename == "range.js" + assert error.stack =~ "RangeError: outside range" + end + + test "matches Error hierarchy construction and prototype descriptors" do + source = + "(()=>{let empty=new Error();let plain=Error();let typed=TypeError('wrong');return [empty.toString(),Object.prototype.hasOwnProperty.call(empty,'message'),Object.prototype.hasOwnProperty.call(plain,'message'),typed.toString(),typed instanceof TypeError,typed instanceof Error,TypeError.prototype.constructor===TypeError]})()" + + assert {:ok, program} = QuickBEAM.VM.compile(source) + + assert {:ok, ["Error", false, false, "TypeError: wrong", true, true, true]} = + QuickBEAM.VM.eval(program) + end + + test "normalizes uncaught type errors" do + assert {:ok, program} = QuickBEAM.VM.compile("const value = 1; value()", filename: "type.js") + + assert {:error, %QuickBEAM.JSError{} = error} = QuickBEAM.VM.eval(program) + assert error.name == "TypeError" + assert error.message =~ "is not a function" + assert error.filename == "type.js" + assert error.stack =~ "type.js:1:" + end + + test "normalizes asynchronous handler failures without exposing Elixir frames" do + source = "(async function load(){return await Beam.call('fail')})()" + assert {:ok, program} = QuickBEAM.VM.compile(source, filename: "async.js") + + handler = fn [] -> raise "database unavailable" end + + assert {:error, %QuickBEAM.JSError{} = error} = + QuickBEAM.VM.eval(program, handlers: %{"fail" => handler}) + + assert error.name == "Error" + assert error.message == "database unavailable" + assert error.stack =~ "at load (async.js:1:" + refute error.stack =~ "lib/quickbeam" + refute error.stack =~ "error_test.exs" + end + + test "normalizes thrown handler terms without crashing error conversion" do + source = "(async function load(){return await Beam.call('fail')})()" + assert {:ok, program} = QuickBEAM.VM.compile(source, filename: "async.js") + + assert {:error, %QuickBEAM.JSError{} = error} = + QuickBEAM.VM.eval(program, handlers: %{"fail" => fn [] -> throw(:unavailable) end}) + + assert error.name == "Error" + assert error.message == "{:throw, :unavailable}" + refute error.stack =~ "lib/quickbeam" + end + + test "normalizes non-string native error properties defensively" do + error = + QuickBEAM.JSError.from_js_value(%{ + name: %{kind: :custom}, + message: {:bad, :value}, + frames: :invalid + }) + + assert error.name == "%{kind: :custom}" + assert error.message == "{:bad, :value}" + assert error.frames == [] + end + + test "keeps infrastructure and resource failures distinct from JavaScript errors" do + assert {:ok, loop} = QuickBEAM.VM.compile("while(true) {}") + + assert {:error, {:limit_exceeded, :steps, 10}} = + QuickBEAM.VM.eval(loop, max_steps: 10) + + assert {:ok, unsupported} = + QuickBEAM.VM.compile("function* unsupported(){yield 1} unsupported()") + + assert {:error, {:unsupported_opcode, :initial_yield, _operands}} = + QuickBEAM.VM.eval(unsupported) + end +end diff --git a/test/vm/runtime/exception_test.exs b/test/vm/runtime/exception_test.exs new file mode 100644 index 000000000..61207ac40 --- /dev/null +++ b/test/vm/runtime/exception_test.exs @@ -0,0 +1,98 @@ +defmodule QuickBEAM.VM.Runtime.ExceptionTest do + use ExUnit.Case, async: true + + alias QuickBEAM.VM.Program.Function + alias QuickBEAM.VM.Runtime.Boundary + alias QuickBEAM.VM.Runtime.Exception + alias QuickBEAM.VM.Runtime.Frame + alias QuickBEAM.VM.Runtime.Promise + alias QuickBEAM.VM.Runtime.State + alias QuickBEAM.VM.Runtime.Thrown + + test "materializes thrown values and resumes the nearest catch target" do + execution = execution() + frame = %{frame("current.js", "current") | pc: 2, stack: [1, {:catch, 7}, :below]} + + assert {:run, resumed, ^execution} = Exception.throw_at("boom", frame, execution) + assert resumed.pc == 7 + assert resumed.stack == ["boom", :below] + end + + test "unwinds explicit caller frames into stable JavaScript-only stacks" do + caller = %{frame("caller.js", "caller") | pc: 1} + current = %{frame("current.js", "current") | pc: 0} + execution = %{execution() | callers: [caller], depth: 2} + + assert {:error, %QuickBEAM.JSError{} = error, execution} = + Exception.throw_at({:type_error, :not_callable}, current, execution) + + assert Enum.map(error.frames, & &1.function) == ["current", "caller"] + assert Enum.map(error.frames, & &1.filename) == ["current.js", "caller.js"] + assert execution.depth == 1 + end + + test "rejects thenable and Promise executor boundaries with preserved thrown state" do + execution = execution() + {thenable_promise, execution} = Promise.new(execution) + + execution = %{ + execution + | promises: Map.put(execution.promises, thenable_promise.id, :resolving) + } + + thenable = %Boundary.Thenable{promise: thenable_promise, depth: 1} + assert {:idle, execution} = Exception.throw_from("then failed", thenable, execution) + assert {:rejected, %Thrown{value: "then failed"}} = Promise.state(execution, thenable_promise) + + {executor_promise, execution} = Promise.new(execution) + caller = frame("caller.js", "caller") + + boundary = %Boundary.PromiseExecutor{ + promise: executor_promise, + caller: caller, + depth: 1, + tail?: false + } + + assert {:complete, ^executor_promise, ^caller, execution, false} = + Exception.throw_from("executor failed", boundary, execution) + + assert {:rejected, %Thrown{value: "executor failed"}} = + Promise.state(execution, executor_promise) + end + + test "converts async unwinding into an explicit async completion action" do + execution = execution() + {promise, execution} = Promise.new(execution) + caller = frame("caller.js", "caller") + boundary = %Boundary.Async{promise: promise, caller: caller, depth: 1, mode: :push} + execution = %{execution | callers: [boundary], depth: 2} + + assert {:async, {:complete, ^promise, ^caller, execution, false}} = + Exception.throw_at("async failed", frame("async.js", "load"), execution) + + assert {:rejected, %Thrown{value: "async failed", frames: [async_frame]}} = + Promise.state(execution, promise) + + assert async_frame.function == "load" + assert execution.callers == [] + assert execution.depth == 1 + end + + defp execution do + %State{atoms: {}, max_stack_depth: 10, remaining_steps: 100, step_limit: 100} + end + + defp frame(filename, name) do + function = %Function{ + id: name, + name: name, + filename: filename, + line_num: 3, + col_num: 4, + instructions: {{0, []}} + } + + %Frame{function: function, callable: function, locals: {}, args: {}, stack: []} + end +end diff --git a/test/vm/runtime/heap_test.exs b/test/vm/runtime/heap_test.exs new file mode 100644 index 000000000..0a6709d33 --- /dev/null +++ b/test/vm/runtime/heap_test.exs @@ -0,0 +1,131 @@ +defmodule QuickBEAM.VM.Runtime.HeapTest do + use ExUnit.Case, async: true + + alias QuickBEAM.VM.Runtime.Heap + alias QuickBEAM.VM.Runtime.Property.Descriptor + alias QuickBEAM.VM.Runtime.State + alias QuickBEAM.VM.Runtime.Value.Export + + test "resolves inherited properties through the prototype chain" do + execution = execution() + {prototype, execution} = Heap.allocate(execution) + {:ok, execution} = Heap.define(execution, prototype, "inherited", 42) + {object, execution} = Heap.allocate(execution, :ordinary, prototype: prototype) + + assert {:ok, 42} = Heap.get(execution, object, "inherited") + assert {:ok, :undefined} = Heap.get(execution, object, "missing") + end + + test "bulk dense arrays preserve sequential allocation and exact accounting" do + values = ["first", :undefined, 42] + {bulk, bulk_execution} = Heap.allocate_array(execution(), values) + + {sequential, sequential_execution} = Heap.allocate(execution(), :array) + + sequential_execution = + values + |> Enum.with_index() + |> Enum.reduce(sequential_execution, fn {value, index}, execution -> + {:ok, execution} = Heap.define(execution, sequential, index, value) + execution + end) + + assert bulk_execution.memory_used == sequential_execution.memory_used + + assert Heap.fetch_object(bulk_execution, bulk) == + Heap.fetch_object(sequential_execution, sequential) + + assert {:ok, values} == Export.value(bulk, bulk_execution) + assert {:ok, %{property_order: []}} = Heap.fetch_object(bulk_execution, bulk) + end + + test "stores default descriptors compactly and retains exceptional descriptors" do + {ordinary, execution} = Heap.allocate(execution()) + {:ok, execution} = Heap.define(execution, ordinary, "answer", 42) + {array, execution} = Heap.allocate(execution, :array) + {:ok, execution} = Heap.define(execution, array, 0, :undefined) + {:ok, execution} = Heap.define(execution, array, 1, "fixed", writable: false) + + assert {:ok, ordinary_object} = Heap.fetch_object(execution, ordinary) + assert ordinary_object.properties["answer"] == {42} + assert {:ok, %Descriptor{value: 42}} = Heap.own_property(execution, ordinary, "answer") + assert {:ok, %{"answer" => 42}} = Export.value(ordinary, execution) + + assert {:ok, array_object} = Heap.fetch_object(execution, array) + assert array_object.properties[0] == {:undefined} + assert %Descriptor{value: "fixed", writable: false} = array_object.properties[1] + assert {:ok, %Descriptor{value: :undefined}} = Heap.own_property(execution, array, 0) + assert {:ok, :undefined} = Heap.get(execution, array, 0) + assert {:error, {:property_not_writable, 1}} = Heap.put(execution, array, 1, "changed") + end + + test "tracks array length while preserving sparse entries" do + execution = execution() + {array, execution} = Heap.allocate(execution, :array) + {:ok, execution} = Heap.put(execution, array, 2, "third") + + assert {:ok, 3} = Heap.get(execution, array, "length") + assert {:ok, :undefined} = Heap.get(execution, array, 0) + assert {:ok, "third"} = Heap.get(execution, array, 2) + assert {:ok, [:undefined, :undefined, "third"]} = Export.value(array, execution) + end + + test "shrinks array length without materializing an enumerable length property" do + execution = execution() + {array, execution} = Heap.allocate(execution, :array) + {:ok, execution} = Heap.put(execution, array, 0, "first") + {:ok, execution} = Heap.put(execution, array, 2, "third") + {:ok, execution} = Heap.put(execution, array, "length", 1) + + assert {:ok, 1} = Heap.get(execution, array, "length") + assert {:ok, :undefined} = Heap.get(execution, array, 2) + assert {:ok, [0]} = Heap.own_keys(execution, array) + end + + test "rejects writes shadowing an inherited non-writable property" do + execution = execution() + {prototype, execution} = Heap.allocate(execution) + {:ok, execution} = Heap.define(execution, prototype, "fixed", 1, writable: false) + {object, execution} = Heap.allocate(execution, :ordinary, prototype: prototype) + + assert {:error, {:property_not_writable, "fixed"}} = + Heap.put(execution, object, "fixed", 2) + + assert {:ok, 1} = Heap.get(execution, object, "fixed") + end + + test "orders integer keys before string keys and preserves string insertion order" do + execution = execution() + {object, execution} = Heap.allocate(execution) + {:ok, execution} = Heap.put(execution, object, "second", 2) + {:ok, execution} = Heap.put(execution, object, 4, 4) + {:ok, execution} = Heap.put(execution, object, "first", 1) + {:ok, execution} = Heap.put(execution, object, 1, 1) + + assert {:ok, [1, 4, "second", "first"]} = Heap.own_keys(execution, object) + assert {:ok, %{property_order: ["second", "first"]}} = Heap.fetch_object(execution, object) + end + + test "honors writable and configurable descriptor flags" do + execution = execution() + {object, execution} = Heap.allocate(execution) + + {:ok, execution} = + Heap.define(execution, object, "fixed", 1, writable: false, configurable: false) + + assert {:error, {:property_not_writable, "fixed"}} = Heap.put(execution, object, "fixed", 2) + assert {:ok, false, ^execution} = Heap.delete(execution, object, "fixed") + end + + test "rejects cyclic owner-local objects during result conversion" do + execution = execution() + {object, execution} = Heap.allocate(execution) + {:ok, execution} = Heap.put(execution, object, "self", object) + + assert {:error, {:cyclic_result, object.id}} == Export.value(object, execution) + end + + defp execution do + %State{atoms: {}, max_stack_depth: 10, remaining_steps: 10, step_limit: 10} + end +end diff --git a/test/vm/runtime/interpreter_test.exs b/test/vm/runtime/interpreter_test.exs new file mode 100644 index 000000000..8a4ad78ac --- /dev/null +++ b/test/vm/runtime/interpreter_test.exs @@ -0,0 +1,270 @@ +defmodule QuickBEAM.VM.Runtime.InterpreterTest do + use ExUnit.Case, async: true + + alias QuickBEAM.VM.Program + alias QuickBEAM.VM.Runtime.Continuation + alias QuickBEAM.VM.Runtime.Interpreter + + test "evaluates arithmetic and comparisons in an isolated BEAM process" do + assert {:ok, program} = QuickBEAM.VM.compile("(2 + 3 * 4) === 14") + assert {:ok, true} = QuickBEAM.VM.eval(program) + end + + test "evaluates lexical locals and control-flow loops" do + source = "{ let sum=0; for(let i=0;i<10;i++) sum+=i; sum }" + assert {:ok, program} = QuickBEAM.VM.compile(source) + assert {:ok, 45} = QuickBEAM.VM.eval(program) + end + + test "invokes bytecode functions with arguments" do + assert {:ok, program} = QuickBEAM.VM.compile("(function(a,b){return a*b})(6,7)") + assert {:ok, 42} = QuickBEAM.VM.eval(program) + end + + test "keeps mutable captured variables alive after their defining frame returns" do + source = """ + (function(counter) { return counter() + counter() })( + (function() { let x=1; return function() { x++; return x } })() + ) + """ + + assert {:ok, program} = QuickBEAM.VM.compile(source) + assert {:ok, 5} = QuickBEAM.VM.eval(program) + end + + test "forwards captured cells through nested closures" do + source = "(function(x){return function(){return function(){x++;return x}}})(40)()()" + assert {:ok, program} = QuickBEAM.VM.compile(source) + assert {:ok, 41} = QuickBEAM.VM.eval(program) + end + + test "isolates captured-variable cells across concurrent evaluations" do + source = "(function(){let x=0;return function(){return ++x}})()()" + assert {:ok, program} = QuickBEAM.VM.compile(source) + + tasks = for _ <- 1..40, do: Task.async(fn -> QuickBEAM.VM.eval(program) end) + assert Task.await_many(tasks) == List.duplicate({:ok, 1}, 40) + end + + test "executes array callback methods with resumable native frames" do + source = "[1,2,3,4].map(x=>x*2).filter(x=>x>4).reduce((sum,x)=>sum+x,0)" + assert {:ok, program} = QuickBEAM.VM.compile(source) + assert {:ok, 14} = QuickBEAM.VM.eval(program) + + assert {:ok, some} = QuickBEAM.VM.compile("[1,2,3].some(x=>x===2)") + assert {:ok, true} = QuickBEAM.VM.eval(some) + end + + test "installs host namespaces only through the explicit SSR profile" do + assert {:ok, program} = QuickBEAM.VM.compile("typeof console") + assert {:ok, "undefined"} = QuickBEAM.VM.eval(program) + assert {:ok, "object"} = QuickBEAM.VM.eval(program, profile: :ssr) + + assert {:ok, global_alias} = + QuickBEAM.VM.compile("globalThis.answer = 42; answer + (globalThis.console ? 0 : 1)") + + assert {:ok, 42} = QuickBEAM.VM.eval(global_alias, profile: :ssr) + + assert {:error, {:invalid_option, :profile, :browser}} = + QuickBEAM.VM.eval(program, profile: :browser) + end + + test "supports maps, rest parameters, classes, and private fields" do + source = """ + (() => { + const map = new Map([["answer", 40]]) + map.set("extra", 2) + let total = 0 + for (const [_key, value] of map) total += value + + class Box { + #value = total + get() { return this.#value } + } + + return new Box().get() + })() + """ + + assert {:ok, program} = QuickBEAM.VM.compile(source) + assert {:ok, 42} = QuickBEAM.VM.eval(program) + + assert {:ok, rest} = + QuickBEAM.VM.compile(~S|(function(...values){return values.join("-")})(1,2,3)|) + + assert {:ok, "1-2-3"} = QuickBEAM.VM.eval(rest) + + assert {:ok, invalid_class_call} = QuickBEAM.VM.compile("class Box {}; Box()") + assert {:error, %QuickBEAM.JSError{name: "TypeError"}} = QuickBEAM.VM.eval(invalid_class_call) + end + + test "supports arguments slicing, sets, regular expressions, and function prototypes" do + source = "(function(){return [].slice.call(arguments,1).join('-')})('a','b','c')" + assert {:ok, arguments} = QuickBEAM.VM.compile(source) + assert {:ok, "b-c"} = QuickBEAM.VM.eval(arguments) + + assert {:ok, set} = QuickBEAM.VM.compile("new Set(['present']).has('present')") + assert {:ok, true} = QuickBEAM.VM.eval(set) + + assert {:ok, regexp} = QuickBEAM.VM.compile("/beam/.test('quickbeam')") + assert {:ok, true} = QuickBEAM.VM.eval(regexp) + + assert {:ok, global_regexp} = + QuickBEAM.VM.compile("const r=/a/g; r.test('aa') && r.test('aa') && !r.test('aa')") + + assert {:ok, true} = QuickBEAM.VM.eval(global_regexp) + + assert {:ok, prototype} = + QuickBEAM.VM.compile( + "{function Example(){}; Example.prototype.answer=42; Example.prototype.answer}" + ) + + assert {:ok, 42} = QuickBEAM.VM.eval(prototype) + end + + test "evaluates and exports object and array values" do + source = "{let object={answer: 41}; object.answer++; [object.answer, [1,2,3].length]}" + assert {:ok, program} = QuickBEAM.VM.compile(source) + assert {:ok, [42, 3]} = QuickBEAM.VM.eval(program) + end + + test "isolates object heaps across concurrent evaluations" do + source = "{let object={count: input}; object.count++; object.count}" + assert {:ok, program} = QuickBEAM.VM.compile(source) + + tasks = + for input <- 1..40 do + Task.async(fn -> QuickBEAM.VM.eval(program, vars: %{"input" => input}) end) + end + + assert Task.await_many(tasks) == Enum.map(1..40, &{:ok, &1 + 1}) + end + + test "isolates mutations of the shared immutable builtin template" do + source = "Object.__quickbeam_count = (Object.__quickbeam_count || 0) + 1" + assert {:ok, program} = QuickBEAM.VM.compile(source) + + tasks = for _ <- 1..40, do: Task.async(fn -> QuickBEAM.VM.eval(program) end) + assert Task.await_many(tasks) == List.duplicate({:ok, 1}, 40) + assert {:ok, 1} = QuickBEAM.VM.eval(program) + + assert {:ok, globals} = QuickBEAM.VM.compile("[Infinity, typeof Beam.call]") + + assert {:ok, [42, "function"]} = + QuickBEAM.VM.eval(globals, vars: %{"Infinity" => 42, "Beam" => :shadowed}) + end + + test "injects independent globals for each evaluation" do + assert {:ok, program} = QuickBEAM.VM.compile("input + 1") + + tasks = + for input <- 1..20 do + Task.async(fn -> QuickBEAM.VM.eval(program, vars: %{"input" => input}) end) + end + + assert Task.await_many(tasks) == Enum.map(1..20, &{:ok, &1 + 1}) + end + + test "enforces the deterministic instruction budget" do + assert {:ok, program} = QuickBEAM.VM.compile("while (true) {}") + + assert {:error, {:limit_exceeded, :steps, 100}} = + QuickBEAM.VM.eval(program, max_steps: 100, timeout: 1_000) + end + + test "enforces the JavaScript call-stack depth independently of the BEAM stack" do + source = "(function recurse(n){return 1+recurse(n+1)})(0)" + assert {:ok, program} = QuickBEAM.VM.compile(source) + + assert {:error, {:limit_exceeded, :stack_depth, 6}} = + QuickBEAM.VM.eval(program, max_stack_depth: 5) + end + + test "replaces the current frame for QuickJS tail calls" do + source = "(function recurse(n){if(n===0)return 0;return recurse(n-1)})(1000)" + assert {:ok, program} = QuickBEAM.VM.compile(source) + assert {:ok, 0} = QuickBEAM.VM.eval(program, max_stack_depth: 2) + end + + test "terminates an evaluation process at the wall-clock deadline" do + assert {:ok, program} = QuickBEAM.VM.compile("while (true) {}") + + assert {:error, {:limit_exceeded, :timeout, 10}} = + QuickBEAM.VM.eval(program, max_steps: 1_000_000_000, timeout: 10) + + assert {:ok, finite} = QuickBEAM.VM.compile("40 + 2") + assert {:ok, 42} = QuickBEAM.VM.eval(finite) + end + + test "unwinds JavaScript throws to same-frame and caller-frame catch handlers" do + sources = [ + "try { throw 41 } catch (error) { error + 1 }", + "(function(){try{return (function(){throw 41})()}catch(error){return error+1}})()" + ] + + for source <- sources do + assert {:ok, program} = QuickBEAM.VM.compile(source) + assert {:ok, 42} = QuickBEAM.VM.eval(program) + end + end + + test "executes finally subroutines while preserving return and throw completion" do + assert {:ok, returning} = QuickBEAM.VM.compile("(function(){try{return 42}finally{1}})()") + assert {:ok, 42} = QuickBEAM.VM.eval(returning) + + assert {:ok, throwing} = QuickBEAM.VM.compile("try { throw 42 } finally { 1 }") + + assert {:error, %QuickBEAM.JSError{name: "Error", message: "42"}} = + QuickBEAM.VM.eval(throwing) + end + + test "catches reference and call errors as JavaScript exceptions" do + assert {:ok, reference_error} = QuickBEAM.VM.compile("try { missing } catch (error) { 42 }") + assert {:ok, 42} = QuickBEAM.VM.eval(reference_error) + + assert {:ok, type_error} = QuickBEAM.VM.compile("try { (1)() } catch (error) { 42 }") + assert {:ok, 42} = QuickBEAM.VM.eval(type_error) + end + + test "does not expose VM resource limits to JavaScript catch handlers" do + assert {:ok, program} = QuickBEAM.VM.compile("try { while(true) {} } catch (error) { 42 }") + + assert {:error, {:limit_exceeded, :steps, 100}} = + QuickBEAM.VM.eval(program, max_steps: 100) + end + + test "captures and resumes the full caller stack across a nested await" do + source = "(async function(){ return await marker })()" + assert {:ok, %Program{} = program} = QuickBEAM.VM.compile(source) + pending = {:pending, make_ref()} + + assert {:suspended, %Continuation{} = continuation} = + Interpreter.eval(program, vars: %{"marker" => pending}, max_steps: 100) + + assert [_caller] = continuation.execution.callers + assert {:ok, "resumed"} = Interpreter.resume(continuation, {:ok, "resumed"}) + end + + test "unwinds a rejected nested await into an outer catch handler" do + source = """ + (async function() { + try { + return await (async function() { await 0; throw 41 })() + } catch (error) { + return error + 1 + } + })() + """ + + assert {:ok, program} = QuickBEAM.VM.compile(source) + assert {:ok, 42} = QuickBEAM.VM.eval(program) + end + + test "reports unsupported opcodes without crashing the caller" do + assert {:ok, program} = + QuickBEAM.VM.compile("function* unsupported(){yield 1} unsupported()") + + assert {:error, {:unsupported_opcode, :initial_yield, []}} = QuickBEAM.VM.eval(program) + assert Process.alive?(self()) + end +end diff --git a/test/vm/runtime/invocation_test.exs b/test/vm/runtime/invocation_test.exs new file mode 100644 index 000000000..ea9478881 --- /dev/null +++ b/test/vm/runtime/invocation_test.exs @@ -0,0 +1,88 @@ +defmodule QuickBEAM.VM.Runtime.InvocationTest do + use ExUnit.Case, async: true + + alias QuickBEAM.VM.Builtin.Action + alias QuickBEAM.VM.Builtin.Call + alias QuickBEAM.VM.Program.Function + alias QuickBEAM.VM.Runtime.Boundary + alias QuickBEAM.VM.Runtime.Frame + alias QuickBEAM.VM.Runtime.Heap + alias QuickBEAM.VM.Runtime.Invocation + alias QuickBEAM.VM.Runtime.State + + test "plans ordinary and closure calls as explicit frame entries" do + execution = execution() + function = %Function{id: 1, arg_count: 1, var_count: 2} + caller = frame() + + assert {:enter, ^function, ^function, {}, [7], :receiver, ^caller, ^execution, false} = + Invocation.plan(function, [7], :receiver, caller, execution) + + closure = {:closure, function, {:cell}} + + assert {:enter, ^function, ^closure, {:cell}, [], :undefined, ^caller, ^execution, true} = + Invocation.plan(closure, [], :undefined, caller, execution, true) + + assert %Frame{locals: locals, args: {7}, this: :receiver} = + Invocation.new_frame(function, function, [7], :receiver, {}) + + assert tuple_size(locals) == 3 + end + + test "normal and constructor bound calls select the correct receiver" do + execution = execution() + caller = frame() + bound = {:bound_function, :target, :bound_receiver, [1]} + + assert {:dispatch, :target, [1, 2], :bound_receiver, ^caller, ^execution, false} = + Invocation.plan(bound, [2], :ignored, caller, execution) + + boundary = %Boundary.Constructor{instance: :instance, caller: caller, depth: 1} + + assert {:dispatch, :target, [1, 2], :instance, ^boundary, ^execution, false} = + Invocation.plan(bound, [2], :instance, boundary, execution) + end + + test "resolves function references while preserving the function object identity" do + execution = execution() + function = %Function{id: 2, has_prototype: true} + {reference, execution} = Heap.allocate(execution, :function, callable: function) + caller = frame() + + assert {:enter, ^function, ^reference, {}, [], :undefined, ^caller, ^execution, false} = + Invocation.plan(reference, [], :undefined, caller, execution) + + assert Invocation.typeof(reference, execution) == "function" + assert Invocation.constructable?(reference, execution) + end + + test "plans declarative Function bind and call without entering interpreter frames" do + execution = execution() + caller = frame() + target = {:host_function, :beam_call} + + call = %Call{ + arguments: [:receiver, 1, 2], + this: target, + caller: caller, + tail?: false, + execution: execution + } + + assert {:ok, {:bound_function, ^target, :receiver, [1, 2]}, ^execution} = + QuickBEAM.VM.Builtin.Function.bind(call) + + assert %Action{ + value: {:dispatch, ^target, [1, 2], :receiver, ^caller, ^execution, true} + } = QuickBEAM.VM.Builtin.Function.call(%{call | tail?: true}) + end + + defp execution do + %State{atoms: {}, max_stack_depth: 10, remaining_steps: 100, step_limit: 100} + end + + defp frame do + function = %Function{id: 0} + Invocation.new_frame(function, function, [], :undefined, {}) + end +end diff --git a/test/vm/runtime/object_test.exs b/test/vm/runtime/object_test.exs new file mode 100644 index 000000000..00268bfc0 --- /dev/null +++ b/test/vm/runtime/object_test.exs @@ -0,0 +1,238 @@ +defmodule QuickBEAM.VM.Runtime.ObjectTest do + use ExUnit.Case, async: false + + setup do + assert {:ok, runtime} = QuickBEAM.start(apis: false) + + on_exit(fn -> + if Process.alive?(runtime) do + try do + QuickBEAM.stop(runtime) + catch + :exit, _reason -> :ok + end + end + end) + + %{runtime: runtime} + end + + test "matches native array length and sparse deletion semantics", %{runtime: runtime} do + sources = [ + "(()=>{let value=[1,2,3];value.length=1;return [value.length,value[1]===void 0,Object.keys(value).join(',')]})()", + "(()=>{let value=[];value[3]=4;delete value[3];return [value.length,Object.keys(value).length]})()", + "(()=>{let value=Array(3);value[1]=2;return [value.length,Object.keys(value).join(',')]})()", + "(()=>{let value=[1];Object.defineProperty(value,'length',{writable:false});try{value[1]=2}catch(error){}return [value.length,value[1]===void 0]})()", + "(()=>{let value=[0,1,2];Object.defineProperty(value,'2',{configurable:false});try{value.length=1}catch(error){}return [value.length,value[2]]})()" + ] + + for source <- sources do + assert_vm_matches_native(runtime, source) + end + end + + test "matches native sparse-array callback and hole-preservation semantics", %{runtime: runtime} do + sources = [ + "(()=>{let value=Array(4);value[1]=2;let calls=[];let mapped=value.map((item,index)=>{calls.push(index);return item*2});return [calls.join(','),mapped.length,Object.keys(mapped).join(','),mapped[1]]})()", + "(()=>{let value=Array(5);value[1]=1;value[3]=3;let each=[];value.forEach((item,index)=>each.push(index));let filtered=value.filter(item=>item>1);let some=value.some((item,index)=>index===2);return [each.join(','),filtered.length,filtered[0],some]})()", + "(()=>{let value=Array(5);value[2]=4;value[4]=6;let calls=[];let result=value.reduce((sum,item,index)=>{calls.push(index);return sum+item});return [result,calls.join(',')]})()", + "(()=>{let value=Array(3);let calls=0;let initial=value.reduce(()=>{calls++;return 0},42);let error='';try{value.reduce(()=>0)}catch(reason){error=reason.name}return [initial,calls,error]})()", + "(()=>{let value=Array(4);value[1]=2;let sliced=value.slice(0);let joined=value.join('-');let extra=Array(2);extra[1]=3;let combined=value.concat(extra);return [sliced.length,Object.keys(sliced).join(','),joined,combined.length,Object.keys(combined).join(',')]})()", + "(()=>{let value=Array(3);value[0]=void 0;value[1]=null;return value.join(',')})()" + ] + + for source <- sources do + assert_vm_matches_native(runtime, source) + end + end + + test "matches native data descriptors and inherited write restrictions", %{runtime: runtime} do + sources = [ + "(()=>{let value={};Object.defineProperty(value,'hidden',{value:42});let descriptor=Object.getOwnPropertyDescriptor(value,'hidden');return [value.hidden,Object.keys(value).length,descriptor.writable,descriptor.enumerable,descriptor.configurable]})()", + "(()=>{let prototype={};Object.defineProperty(prototype,'fixed',{value:1,writable:false});let value=Object.create(prototype);try{value.fixed=2}catch(error){}return [value.fixed,Object.keys(value).length]})()", + "(()=>{let value={};Object.defineProperty(value,'fixed',{value:1,writable:false,configurable:false});try{Object.defineProperty(value,'fixed',{value:2})}catch(error){return error.name}})()" + ] + + for source <- sources do + assert_vm_matches_native(runtime, source) + end + end + + test "matches native own and inherited accessor invocation", %{runtime: runtime} do + sources = [ + "(()=>{let value={get answer(){return 42}};return value.answer})()", + "(()=>{let prototype={get answer(){return this.value}};let value=Object.create(prototype);value.value=42;return value.answer})()", + "(()=>{let seen=0;let prototype={set answer(value){seen=value}};let object=Object.create(prototype);object.answer=42;return seen})()", + "(()=>{let value={get answer(){throw 42}};try{return value.answer}catch(error){return error}})()", + "(()=>{let value={set answer(next){throw next}};try{value.answer=42}catch(error){return error}})()" + ] + + for source <- sources do + assert_vm_matches_native(runtime, source) + end + end + + test "matches native accessor descriptors and descriptor reflection", %{runtime: runtime} do + sources = [ + "(()=>{let value={};let getter=function(){return 42};Object.defineProperty(value,'answer',{get:getter,enumerable:true});let descriptor=Object.getOwnPropertyDescriptor(value,'answer');return [value.answer,descriptor.get===getter,descriptor.set===void 0,descriptor.enumerable,('value' in descriptor)]})()", + "(()=>{let stored=0;let value={};Object.defineProperty(value,'answer',{get:function(){return stored},set:function(next){stored=next}});value.answer=42;return value.answer})()", + "(()=>{let value={};Object.defineProperty(value,'answer',{get:void 0});let descriptor=Object.getOwnPropertyDescriptor(value,'answer');return [value.answer===void 0,('get' in descriptor),('value' in descriptor)]})()", + "(async()=>{let value={};Object.defineProperty(value,'answer',{get:async function(){await 0;return 42}});return await value.answer})()" + ] + + for source <- sources do + assert_vm_matches_native(runtime, source) + end + end + + test "resumes Object.assign through source getters and target setters", %{runtime: runtime} do + sources = [ + "(()=>{let source={get answer(){return 42}};return Object.assign({},source).answer})()", + "(()=>{let seen=0;let target={set answer(value){seen=value}};Object.assign(target,{answer:42});return seen})()", + "(()=>{let source={get answer(){throw 42}};try{Object.assign({},source)}catch(error){return error}})()", + "(()=>{let symbol=Symbol.iterator;let source={[symbol]:42,answer:1};let target=Object.assign({},source);return [Object.keys(source).join(','),target[symbol]]})()", + "(()=>({[Symbol.iterator]:42,answer:1}))()" + ] + + for source <- sources do + assert_vm_matches_native(runtime, source) + end + end + + test "matches declarative constructor calls, boxing, and bootstrap topology", %{ + runtime: runtime + } do + sources = [ + "(()=>{let sparse=Array(3);let values=new Array(1,2);return [sparse.length,Object.keys(sparse).length,values.join(',')]})()", + "(()=>{let invalid=[-1,1.5,4294967296,NaN].map(value=>{try{Array(value)}catch(error){return error.name}});return [Array(3.0).length,Array('3').length,invalid.join(',')]})()", + "(()=>{let boxed=new Number(42);return [Number('2'),typeof boxed,boxed.toString()]})()", + "(()=>{let boxed=new String('ok');return [String(42),typeof boxed,boxed.toString()]})()", + "(()=>{let boxed=new Boolean(false);return [Boolean(0),Boolean(1),typeof boxed,boxed?1:0]})()", + "(()=>{let number=Object(42);let string=Object('ok');return [typeof number,number instanceof Number,number.toString(),string instanceof String,string.toString()]})()", + "(()=>[Object.getPrototypeOf(Object)===Function.prototype,Object.getPrototypeOf(Function)===Function.prototype,Object.getPrototypeOf(Function.prototype)===Object.prototype,Object.getPrototypeOf(Object.prototype)===null,typeof Function.prototype])()", + "(()=>[Array.isArray(Array.prototype),Number.prototype.toString(),String.prototype.toString(),Function.prototype.name,Function.prototype.length])()" + ] + + for source <- sources do + assert_vm_matches_native(runtime, source) + end + end + + test "rejects dynamic Function construction explicitly" do + assert {:ok, program} = + QuickBEAM.VM.compile( + "(()=>{try{Function('return 1')}catch(error){return error.name}})()" + ) + + assert {:ok, "TypeError"} = QuickBEAM.VM.eval(program) + end + + test "matches declarative Function prototype call and bind", %{runtime: runtime} do + sources = [ + "(()=>{function add(a,b){return this.base+a+b}return add.call({base:1},2,3)})()", + "(()=>{function add(a,b){return this.base+a+b}let bound=add.bind({base:1},2);return bound(3)})()", + "(()=>{let call=Function.prototype.call;return call.call((value)=>value+1,void 0,41)})()" + ] + + for source <- sources do + assert_vm_matches_native(runtime, source) + end + end + + test "matches native Set construction, aliases, and insertion order", %{runtime: runtime} do + sources = [ + "(()=>{let value=new Set([2,1,2]);return [value.size,value.has(1),value.has(3)]})()", + "(()=>{let value=new Set([2,1,2]);let iterator=value.values();return [iterator.next().value,iterator.next().value,iterator.next().done]})()", + "(()=>{let value=new Set();return value[Symbol.iterator]===value.values})()", + "(()=>{let value=new Set();return value.add(2).add(1).add(2).size})()", + "(()=>{let value=new Set([1,2/2,NaN,NaN,-0,0]);return [value.size,value.has(1.0),value.has(NaN),value.delete(NaN),value.has(NaN),value.size]})()", + "(()=>{let iterable={[Symbol.iterator](){let i=0;return {next(){i++;return i<=2?{value:i,done:false}:{done:true}}}}};return new Set(iterable).size})()", + "(()=>{let iterable={[Symbol.iterator](){return {next(){throw 42}}}};try{new Set(iterable)}catch(error){return error}})()", + "(()=>{try{Set()}catch(error){return error.name}})()" + ] + + for source <- sources do + assert_vm_matches_native(runtime, source) + end + end + + test "matches native weak collection object-key validation", %{runtime: runtime} do + sources = [ + "(()=>{let key={};let value=new WeakMap([[key,42]]);return [value.get(key),value.has(key),value.delete(key),value.has(key)]})()", + "(()=>{try{new WeakMap([[1,42]])}catch(error){return error.name}})()", + "(()=>{try{new WeakMap().set(1,42)}catch(error){return error.name}})()", + "(()=>{let key={};let value=new WeakSet([key]);return [value.has(key),value.delete(key),value.has(key)]})()", + "(()=>{try{new WeakSet([1])}catch(error){return error.name}})()", + "(()=>{try{new WeakSet().add(1)}catch(error){return error.name}})()" + ] + + for source <- sources do + assert_vm_matches_native(runtime, source) + end + end + + test "matches native prototype mutation and cycle rejection", %{runtime: runtime} do + sources = [ + "(()=>{let prototype={answer:42};let value={};Object.setPrototypeOf(value,prototype);return [value.answer,Object.getPrototypeOf(value)===prototype]})()", + "(()=>{let first={},second={};Object.setPrototypeOf(first,second);try{Object.setPrototypeOf(second,first)}catch(error){return error.name}})()" + ] + + for source <- sources do + assert_vm_matches_native(runtime, source) + end + end + + test "matches native constructor returns and instanceof", %{runtime: runtime} do + sources = [ + "(()=>{function Value(){this.answer=42}let value=new Value();return [value.answer,value instanceof Value]})()", + "(()=>{function Value(){this.answer=1;return {answer:42}}return new Value().answer})()", + "(()=>{function Value(){this.answer=42;return 1}return new Value().answer})()", + "(()=>{function Value(){}let Bound=Value.bind(null);let value=new Bound();return [value instanceof Value,value instanceof Bound]})()", + "(()=>{try{new (async function(){})()}catch(error){return error.name}})()" + ] + + for source <- sources do + assert_vm_matches_native(runtime, source) + end + end + + test "indexes and slices strings as UTF-16 code units", %{runtime: runtime} do + sources = [ + ~S|["😀".length,"😀".charCodeAt(0),"😀".charCodeAt(1)]|, + ~S|["😀x".slice(0,2),"😀x".slice(1,2),"😀x"[0]]|, + ~S|String.fromCharCode(55357,56832)| + ] + + for source <- sources do + assert_vm_matches_native(runtime, source) + end + end + + test "returns enumerable keys separately from all own property names", %{runtime: runtime} do + sources = [ + "(()=>{let value={visible:1};Object.defineProperty(value,'hidden',{value:2});return [Object.keys(value).join(','),Object.getOwnPropertyNames(value).join(',')]})()", + "(()=>{let value=[];value[2]=2;Object.defineProperty(value,'hidden',{value:1});return Object.getOwnPropertyNames(value).join(',')})()" + ] + + for source <- sources do + assert_vm_matches_native(runtime, source) + end + end + + test "orders integer properties before string insertion order", %{runtime: runtime} do + sources = [ + "(()=>{let value={second:2};value[4]=4;value.first=1;value[1]=1;return Object.keys(value).join(',')})()", + "(()=>{let value=[];value[4294967295]=1;return [value.length,Object.keys(value).join(',')]})()" + ] + + for source <- sources do + assert_vm_matches_native(runtime, source) + end + end + + defp assert_vm_matches_native(runtime, source) do + assert {:ok, program} = QuickBEAM.VM.compile(source) + assert {:ok, expected} = QuickBEAM.eval(runtime, "await (#{source})") + assert {:ok, ^expected} = QuickBEAM.VM.eval(program) + end +end diff --git a/test/vm/runtime/opcode_test.exs b/test/vm/runtime/opcode_test.exs new file mode 100644 index 000000000..63a5efb48 --- /dev/null +++ b/test/vm/runtime/opcode_test.exs @@ -0,0 +1,223 @@ +defmodule QuickBEAM.VM.Runtime.OpcodeTest do + use ExUnit.Case, async: true + + alias QuickBEAM.VM.Program.Function + alias QuickBEAM.VM.Runtime.Frame + alias QuickBEAM.VM.Runtime.Heap + alias QuickBEAM.VM.Runtime.Object + alias QuickBEAM.VM.Runtime.Opcode.Control + alias QuickBEAM.VM.Runtime.Opcode.Invocation, as: CallOpcodes + alias QuickBEAM.VM.Runtime.Opcode.Local, as: Locals + alias QuickBEAM.VM.Runtime.Opcode.Object, as: Objects + alias QuickBEAM.VM.Runtime.Opcode.Stack + alias QuickBEAM.VM.Runtime.Opcode.Value, as: Values + alias QuickBEAM.VM.Runtime.Property + alias QuickBEAM.VM.Runtime.State + + test "opcode families publish non-overlapping routing tables" do + opcodes = + [CallOpcodes, Control, Locals, Objects, Stack, Values] + |> Enum.flat_map(& &1.opcodes()) + + assert length(opcodes) == MapSet.size(MapSet.new(opcodes)) + end + + test "stack opcodes transform explicit frames without advancing the program counter" do + execution = execution() + frame = frame([:a, :b, :c]) + + assert {:next, %Frame{pc: 4, stack: [:a, :b, :a, :b, :c]}, ^execution} = + Stack.execute(:dup2, [], frame, execution) + + assert {:next, %Frame{stack: [:b, :a, :c]}, ^execution} = + Stack.execute(:swap, [], frame, execution) + + assert {:next, %Frame{stack: [20, :a, :b, :c]}, ^execution} = + Stack.execute(:push_const, [1], frame, execution) + + assert {:next, %Frame{stack: [{:bigint, 9}, :a, :b, :c]}, ^execution} = + Stack.execute(:push_bigint_i32, [9], frame, execution) + end + + test "value opcodes delegate arithmetic, coercion, and post-update semantics" do + execution = execution() + + assert {:next, %Frame{stack: [7, :rest]}, ^execution} = + Values.execute(:add, [], frame([4, 3, :rest]), execution) + + assert {:next, %Frame{stack: [true, :rest]}, ^execution} = + Values.execute(:lnot, [], frame([0, :rest]), execution) + + assert {:next, %Frame{stack: [4, 3, :rest]}, ^execution} = + Values.execute(:post_inc, [], frame([3, :rest]), execution) + + assert {:throw, {:type_error, :cannot_convert_to_object}, %Frame{}, ^execution} = + Values.execute(:to_object, [], frame([nil]), execution) + end + + test "control opcodes return explicit branch, return, throw, and await actions" do + execution = execution() + + assert {:run, %Frame{pc: 9, stack: [:rest]}, ^execution} = + Control.execute(:if_true, [9], frame([true, :rest]), execution) + + assert {:next, %Frame{stack: [{:catch, 12}, :rest]}, ^execution} = + Control.execute(:catch, [12], frame([:rest]), execution) + + assert {:return, 42, ^execution} = Control.execute(:return, [], frame([42]), execution) + + assert {:throw, "boom", %Frame{stack: [:rest]}, ^execution} = + Control.execute(:throw, [], frame(["boom", :rest]), execution) + + assert {:await_immediate, {:ok, 7}, %Frame{stack: [:rest]}, ^execution} = + Control.execute(:await, [], frame([7, :rest]), execution) + end + + test "local opcodes preserve mutable closure-cell identity" do + execution = %{execution() | cells: %{0 => 10}, next_cell_id: 1} + frame = %{frame([20]) | locals: {{:cell, 0}}} + + assert {:next, %Frame{locals: {{:cell, 0}}, stack: []}, execution} = + Locals.execute(:put_loc, [0], frame, execution) + + assert execution.cells == %{0 => 20} + + assert {:next, %Frame{stack: [20]}, ^execution} = + Locals.execute(:get_loc, [0], %{frame | stack: []}, execution) + + uninitialized = %{frame | locals: {:uninitialized}, stack: []} + + assert {:throw, {:reference_error, 0}, ^uninitialized, ^execution} = + Locals.execute(:get_loc_check, [0], uninitialized, execution) + end + + test "closure opcodes promote captured slots into owner-local cells" do + child = %Function{ + id: 2, + closure_vars: [%{closure_type: 0, var_idx: 0}], + instructions: {{0, []}} + } + + parent = %Function{ + id: 1, + arg_count: 0, + constants: [child], + instructions: {{0, []}} + } + + frame = %Frame{function: parent, callable: parent, locals: {7}, args: {}, stack: []} + + assert {:next, %Frame{locals: {{:cell, 0}}, stack: [reference]}, execution} = + Locals.execute(:fclosure, [0], frame, execution()) + + assert execution.cells == %{0 => 7} + + assert {:ok, %Object{callable: {:closure, ^child, {{:cell, 0}}}}} = + Heap.fetch_object(execution, reference) + end + + test "invocation opcodes decode ordinary, method, and tail-call stacks" do + execution = execution() + + assert {:invoke, :callable, [:first, :second], :undefined, %Frame{stack: [:rest]}, ^execution, + false} = + CallOpcodes.execute( + :call, + [2], + frame([:second, :first, :callable, :rest]), + execution + ) + + assert {:invoke, :callable, [:argument], :receiver, %Frame{stack: [:rest]}, ^execution, true} = + CallOpcodes.execute( + :tail_call_method, + [1], + frame([:argument, :callable, :receiver, :rest]), + execution + ) + + assert {:error, {:invalid_stack, :call}, ^execution} = + CallOpcodes.execute(:call, [2], frame([:only_one]), execution) + end + + test "constructor opcodes allocate instances through canonical invocation semantics" do + execution = execution() + function = %Function{id: 3, has_prototype: true} + {constructor, execution} = Heap.allocate(execution, :function, callable: function) + {prototype, execution} = Heap.allocate(execution) + {:ok, execution} = Property.define(constructor, "prototype", prototype, execution) + + assert {:invoke_constructor, ^constructor, [:argument], instance, %Frame{stack: [:rest]}, + execution} = + CallOpcodes.execute( + :call_constructor, + [1], + frame([:argument, constructor, constructor, :rest]), + execution + ) + + assert {:ok, %Object{prototype: ^prototype, internal: :constructor_instance}} = + Heap.fetch_object(execution, instance) + end + + test "object opcodes return resumable getter and setter actions" do + execution = execution() + {object, execution} = Heap.allocate(execution) + getter = {:builtin, "getter"} + setter = {:builtin, "setter"} + + {:ok, execution} = Property.define_accessor(object, "value", :getter, getter, execution) + {:ok, execution} = Property.define_accessor(object, "value", :setter, setter, execution) + + assert {:invoke_getter, ^getter, ^object, %Frame{stack: [:rest]}, ^execution} = + Objects.execute(:get_field, ["value"], frame([object, :rest]), execution) + + assert {:invoke_setter, ^setter, 42, ^object, %Frame{stack: [:rest]}, ^execution} = + Objects.execute(:put_field, ["value"], frame([42, object, :rest]), execution) + end + + test "object opcodes allocate arrays and enumerate canonical property order" do + execution = execution() + + assert {:next, %Frame{stack: [array, :rest]}, execution} = + Objects.execute(:array_from, [2], frame([2, 1, :rest]), execution) + + assert {:ok, 1} = Property.get(array, 0, execution) + assert {:ok, 2} = Property.get(array, 1, execution) + + assert {:next, %Frame{stack: [{:for_in, [0, 1], 0}]}, ^execution} = + Objects.execute(:for_in_start, [], frame([array]), execution) + end + + test "value opcodes share canonical callable and prototype semantics" do + execution = execution() + function = %Function{id: 1, has_prototype: true} + {constructor, execution} = Heap.allocate(execution, :function, callable: function) + {prototype, execution} = Heap.allocate(execution) + {object, execution} = Heap.allocate(execution, :ordinary, prototype: prototype) + {:ok, execution} = Property.define(constructor, "prototype", prototype, execution) + + assert {:next, %Frame{stack: [true]}, ^execution} = + Values.execute(:is_function, [], frame([constructor]), execution) + + assert {:next, %Frame{stack: [true]}, ^execution} = + Values.execute(:instanceof, [], frame([constructor, object]), execution) + end + + defp execution do + %State{atoms: {}, max_stack_depth: 10, remaining_steps: 100, step_limit: 100} + end + + defp frame(stack) do + function = %Function{id: 0, constants: [10, 20], instructions: {{0, []}}} + + %Frame{ + function: function, + callable: function, + locals: {}, + args: {}, + pc: 4, + stack: stack + } + end +end diff --git a/test/vm/runtime/promise_test.exs b/test/vm/runtime/promise_test.exs new file mode 100644 index 000000000..f4a3a8427 --- /dev/null +++ b/test/vm/runtime/promise_test.exs @@ -0,0 +1,211 @@ +defmodule QuickBEAM.VM.Runtime.PromiseTest do + use ExUnit.Case, async: false + + setup do + assert {:ok, runtime} = QuickBEAM.start(apis: false) + + on_exit(fn -> + if Process.alive?(runtime) do + try do + QuickBEAM.stop(runtime) + catch + :exit, _reason -> :ok + end + end + end) + + %{runtime: runtime} + end + + test "matches native fulfillment chains and returned-Promise assimilation", %{runtime: runtime} do + sources = [ + "Promise.resolve(1).then(x=>x+1).then(x=>x*2)", + "Promise.resolve(1).then(async x=>{await 0;return x+41})", + "(async()=>{return (async()=>42)()})()" + ] + + for source <- sources do + assert_vm_matches_native(runtime, source) + end + end + + test "supports Promise construction, resolver idempotence, and static helpers", %{ + runtime: runtime + } do + sources = [ + "new Promise(resolve=>resolve(42))", + "new Promise((resolve,reject)=>{resolve(42);reject(1)})", + "new Promise(resolve=>{resolve({then: next=>next(42)});resolve(1)})", + "(()=>{let release;let source=new Promise(resolve=>release=resolve);let result=new Promise(resolve=>{resolve(source);resolve(42)});release(1);return result})()", + "new Promise((resolve,reject)=>reject(41)).catch(value=>value+1)", + "new Promise(()=>{throw 42}).catch(value=>value)", + "new Promise(async resolve=>{await 0;resolve(42)})", + "(()=>{try{Promise(()=>{})}catch(error){return error.name}})()", + "(()=>{try{new Promise()}catch(error){return error.name}})()", + "Promise.reject(42).catch(value=>value)", + "(()=>{let promise=Promise.resolve(1);return Promise.resolve(promise)===promise})()" + ] + + for source <- sources do + assert_vm_matches_native(runtime, source) + end + end + + test "assimilates thenables returned from resolution and reactions", %{runtime: runtime} do + sources = [ + "Promise.resolve({then: resolve=>resolve(42)})", + "Promise.resolve(1).then(()=>({then: resolve=>resolve(42)}))", + "Promise.resolve({then: ()=>{throw 42}}).catch(value=>value)" + ] + + for source <- sources do + assert_vm_matches_native(runtime, source) + end + end + + test "matches native Promise combinators", %{runtime: runtime} do + sources = [ + "Promise.all([Promise.resolve(1),2,Promise.resolve(3)]).then(values=>values.join(','))", + "Promise.race([Promise.resolve(42),Promise.resolve(1)])", + "Promise.allSettled([Promise.resolve(1),Promise.reject(2)]).then(values=>values[1].reason)", + "Promise.any([Promise.reject(1),Promise.resolve(42)])", + "Promise.any([Promise.reject(1),Promise.reject(2)]).catch(error=>error.errors.join(','))", + "Promise.all('ab').then(values=>values.join(''))", + "Promise.all(new Set([2,1,2])).then(values=>values.join(','))", + "Promise.all(Array(2)).then(values=>[values.length,values[0]===void 0,values[1]===void 0])", + "Promise.all(1).catch(error=>error.name)", + "Promise.all({[Symbol.iterator](){let i=0;return {next(){i++;return i<=3?{value:i,done:false}:{done:true}}}}}).then(values=>values.join(','))", + "Promise.all({[Symbol.iterator]:function(){let done=false;return {next(){if(done)return {done:true};done=true;return {value:42,done:false}}}}}).then(values=>values[0])", + "(()=>{let reads=0;let iterable={get [Symbol.iterator](){reads++;return function(){let done=false;return {next(){if(done)return {done:true};done=true;return {value:42,done:false}}}}}};return Promise.all(iterable).then(values=>[values[0],reads])})()", + "Promise.all({get [Symbol.iterator](){throw 41}}).catch(value=>value+1)", + "Promise.all({[Symbol.iterator](){throw 42}}).catch(value=>value)", + "Promise.all({[Symbol.iterator](){let iterator={done:false,get next(){return function(){if(this.done)return {done:true};this.done=true;return {value:42,done:false}}}};return iterator}}).then(values=>values[0])", + "Promise.all({[Symbol.iterator](){return {next(){throw 42}}}}).catch(value=>value)", + "Promise.all({[Symbol.iterator](){return {next(){return {get done(){throw 42}}}}}}).catch(value=>value)", + "Promise.all({[Symbol.iterator](){let done=false;return {next(){if(done)return {done:true};done=true;return {done:false,get value(){throw 42}}}}}}).catch(value=>value)", + "(()=>{let closed=false;let iterable={[Symbol.iterator](){return {next(){throw 1},return(){closed=true;return {done:true}}}}};return Promise.all(iterable).catch(()=>closed)})()", + "(()=>{let closed=false;let iterable={[Symbol.iterator](){return {next(){return 1},return(){closed=true;return {done:true}}}}};return Promise.all(iterable).catch(()=>closed)})()", + "(()=>{let log='';let iterable={[Symbol.iterator](){let index=0;return {next(){index++;if(index===1)return {value:{get then(){log+='t';return resolve=>resolve(1)}},done:false};log+='n';return {done:true}}}}};return Promise.all(iterable).then(()=>log)})()" + ] + + for source <- sources do + assert_vm_matches_native(runtime, source) + end + end + + test "reads thenable accessors synchronously and invokes returned then functions as jobs", %{ + runtime: runtime + } do + sources = [ + "(()=>{let order='';Promise.resolve({get then(){order+='a';return resolve=>{order+='c';resolve()}}});order+='b';return order})()", + "(async()=>{let order='';Promise.resolve({get then(){order+='a';return resolve=>{order+='c';resolve()}}});order+='b';await 0;return order})()", + "Promise.resolve({get then(){return resolve=>resolve(42)}})", + "Promise.resolve({get then(){throw 42}}).catch(value=>value)", + "Promise.resolve({get then(){return 42}}).then(value=>value.then)" + ] + + for source <- sources do + assert_vm_matches_native(runtime, source) + end + end + + test "runs reactions as FIFO microtasks after synchronous code", %{runtime: runtime} do + source = """ + (async()=>{ + let order = "" + let promise = Promise.resolve().then(() => order += "b") + order += "a" + await promise + return order + })() + """ + + assert_vm_matches_native(runtime, source) + assert {:ok, program} = QuickBEAM.VM.compile(source) + assert {:ok, "ab"} = QuickBEAM.VM.eval(program) + end + + test "detaches nested async frames and resumes their caller independently", %{runtime: runtime} do + source = """ + (async()=>{ + let order = "" + let promise = (async()=>{ order += "a"; await 0; order += "c" })() + order += "b" + await promise + return order + })() + """ + + assert_vm_matches_native(runtime, source) + assert {:ok, program} = QuickBEAM.VM.compile(source) + assert {:ok, "abc"} = QuickBEAM.VM.eval(program) + end + + test "propagates rejection through catch and preserves primitive throws", %{runtime: runtime} do + sources = [ + "(async()=>{throw 41})().catch(value=>value+1)", + "Promise.resolve(1).finally(()=>{throw 42}).catch(value=>value)", + "(async()=>{try{return await (async()=>{await 0;throw 41})()}catch(value){return value+1}})()" + ] + + for source <- sources do + assert_vm_matches_native(runtime, source) + end + end + + test "finally preserves completion and waits for returned Promises", %{runtime: runtime} do + sources = [ + "Promise.resolve(42).finally(()=>1)", + "Promise.resolve(42).finally(()=>Promise.resolve(1))", + "Promise.resolve(42).finally(()=>({then: resolve=>resolve(1)}))", + "(async()=>{throw 42})().finally(()=>Promise.resolve()).catch(value=>value)" + ] + + for source <- sources do + assert_vm_matches_native(runtime, source) + end + end + + test "rejects Promise self-resolution", %{runtime: runtime} do + source = """ + (async()=>{ + let promise + promise = Promise.resolve().then(() => promise) + try { await promise } catch (error) { return error.name } + })() + """ + + assert_vm_matches_native(runtime, source) + assert {:ok, program} = QuickBEAM.VM.compile(source) + assert {:ok, "TypeError"} = QuickBEAM.VM.eval(program) + end + + test "releases caller depth before detached reactions run" do + assert {:ok, program} = QuickBEAM.VM.compile("Promise.resolve(42).then(value=>value)") + assert {:ok, 42} = QuickBEAM.VM.eval(program, max_stack_depth: 1) + end + + test "preserves deterministic limits across detached continuations" do + source = "(async()=>{await 0;while(true){}})()" + assert {:ok, program} = QuickBEAM.VM.compile(source) + + assert {:error, {:limit_exceeded, :steps, 100}} = + QuickBEAM.VM.eval(program, max_steps: 100) + end + + test "bounds custom iterators with the shared step limit" do + source = + "Promise.all({[Symbol.iterator](){return {next(){return {value:1,done:false}}}}})" + + assert {:ok, program} = QuickBEAM.VM.compile(source) + + assert {:error, {:limit_exceeded, :steps, 200}} = + QuickBEAM.VM.eval(program, max_steps: 200) + end + + defp assert_vm_matches_native(runtime, source) do + assert {:ok, program} = QuickBEAM.VM.compile(source) + assert {:ok, expected} = QuickBEAM.eval(runtime, "await (#{source})") + assert {:ok, ^expected} = QuickBEAM.VM.eval(program) + end +end diff --git a/test/vm/runtime/property_test.exs b/test/vm/runtime/property_test.exs new file mode 100644 index 000000000..b70ec5e09 --- /dev/null +++ b/test/vm/runtime/property_test.exs @@ -0,0 +1,64 @@ +defmodule QuickBEAM.VM.Runtime.PropertyTest do + use ExUnit.Case, async: true + + alias QuickBEAM.VM.Builtin.Runtime, as: BuiltinRuntime + alias QuickBEAM.VM.Runtime.Heap + alias QuickBEAM.VM.Runtime.Invocation + alias QuickBEAM.VM.Runtime.Property + alias QuickBEAM.VM.Runtime.Reference + alias QuickBEAM.VM.Runtime.State + + test "returns explicit getter and setter actions with the original receiver" do + execution = execution() + {prototype, execution} = Heap.allocate(execution) + {object, execution} = Heap.allocate(execution, :ordinary, prototype: prototype) + getter = {:builtin, "getter"} + setter = {:builtin, "setter"} + + {:ok, execution} = + Property.define_accessor(prototype, "value", :getter, getter, execution) + + {:ok, execution} = + Property.define_accessor(prototype, "value", :setter, setter, execution) + + assert {:ok, {:accessor, ^getter, ^object}} = Property.get(object, "value", execution) + assert {:error, {:invoke_setter, ^setter}} = Property.put(object, "value", 42, execution) + end + + test "resolves primitive and callable properties through intrinsic prototypes" do + execution = BuiltinRuntime.install(execution()) + {callable, execution} = Heap.allocate(execution, :function, callable: {:builtin, "callable"}) + + assert {:ok, %Reference{} = bind} = Property.get(callable, "bind", execution) + assert Invocation.callable?(bind, execution) + assert {:ok, "bind"} = Property.get(bind, "name", execution) + assert {:ok, 2} = Property.get("😀", "length", execution) + assert {:ok, <<0xED, 0xA0, 0xBD>>} = Property.get("😀", 0, execution) + + assert {:ok, %Reference{} = to_string} = Property.get(42, "toString", execution) + assert Invocation.callable?(to_string, execution) + assert {:ok, "toString"} = Property.get(to_string, "name", execution) + end + + test "centralizes descriptors, enumeration, and prototype operations" do + execution = execution() + {prototype, execution} = Heap.allocate(execution) + {object, execution} = Heap.allocate(execution) + + {:ok, execution} = + Property.define(object, "hidden", 1, execution, enumerable: false) + + {:ok, execution} = Property.define(object, 2, 2, execution) + {:ok, execution} = Property.set_prototype(object, prototype, execution) + + assert {:ok, [2]} = Property.enumerable_keys(object, execution) + assert {:ok, ["2", "hidden"]} = Property.own_property_names(object, execution) + assert {:ok, ^prototype} = Property.prototype(object, execution) + assert Property.prototype_chain_contains?(object, prototype, execution) + assert Property.has_property?(object, "hidden", execution) + end + + defp execution do + %State{atoms: {}, max_stack_depth: 10, remaining_steps: 100, step_limit: 100} + end +end diff --git a/test/vm/runtime/value_test.exs b/test/vm/runtime/value_test.exs new file mode 100644 index 000000000..97763e733 --- /dev/null +++ b/test/vm/runtime/value_test.exs @@ -0,0 +1,117 @@ +defmodule QuickBEAM.VM.Runtime.ValueTest do + use ExUnit.Case, async: true + + alias QuickBEAM.VM.Runtime.Symbol + alias QuickBEAM.VM.Runtime.Value + + test "centralizes JavaScript truthiness and primitive equality" do + for value <- [nil, :undefined, :nan, false, 0, 0.0, ""] do + refute Value.truthy?(value) + end + + for value <- [true, 1, -1, "0", [], %{}] do + assert Value.truthy?(value) + end + + assert Value.strict_equal?(1, 1.0) + refute Value.strict_equal?(:nan, :nan) + assert Value.abstract_equal?(nil, :undefined) + assert Value.abstract_equal?(true, 1) + assert Value.abstract_equal?(42, "42") + refute Value.abstract_equal?(0, nil) + assert Value.to_number(" ") == 0 + assert Value.to_number("Infinity") == :infinity + assert Value.to_number("-Infinity") == :neg_infinity + assert Value.to_number("0x10") == 16 + assert Value.to_number("0b10") == 2 + assert Value.to_number("0o10") == 8 + assert Value.to_string_value(-0.0) == "0" + assert Value.to_string_value(1.0) == "1" + assert Value.typeof(Symbol.iterator()) == "symbol" + assert Value.strict_equal?(Symbol.iterator(), Symbol.iterator()) + end + + test "dispatches canonical unary and binary arithmetic operations" do + assert Value.unary(:plus, "12") == 12 + assert Value.unary(:neg, "2") == -2 + assert Value.unary(:lnot, 0) + assert Value.unary(:inc, 2) == 3 + + assert Value.binary(:add, "value=", 3) == "value=3" + assert Value.binary(:sub, "7", 2) == 5 + assert Value.binary(:mul, 3, 4) == 12 + assert Value.binary(:div, 1, 0) == :infinity + assert Value.binary(:div, -1, 0) == :neg_infinity + assert Value.binary(:div, 1, -0.0) == :neg_infinity + assert Value.binary(:div, -1, -0.0) == :infinity + assert Value.binary(:div, 0, 0) == :nan + assert Value.binary(:div, :infinity, :infinity) == :nan + assert Value.binary(:div, :neg_infinity, 2) == :neg_infinity + assert Value.binary(:div, 2, :infinity) == 0.0 + assert Value.binary(:mod, 7, 4) == 3 + assert <<1::1, _magnitude::63>> = <> + assert Value.binary(:mod, :infinity, 4) == :nan + assert Value.binary(:mod, 4, :infinity) == 4 + assert Value.binary(:add, :infinity, :neg_infinity) == :nan + assert Value.binary(:sub, :infinity, :infinity) == :nan + assert Value.binary(:mul, :neg_infinity, -2) == :infinity + assert Value.binary(:mul, :infinity, 0) == :nan + assert Value.binary(:pow, 2, 3) == 8.0 + assert Value.binary(:pow, :infinity, -1) == 0.0 + assert Value.binary(:pow, -1, :infinity) == :nan + assert Value.unary(:neg, :infinity) == :neg_infinity + assert <<1::1, _magnitude::63>> = <> + assert Value.binary(:lt, "10", "2") + assert Value.binary(:lt, :neg_infinity, -1) + assert Value.binary(:lt, 1, :infinity) + refute Value.binary(:gt, :neg_infinity, 1) + assert Value.binary(:eq, "1", 1) + refute Value.binary(:strict_eq, "1", 1) + end + + test "executes extended-number arithmetic without leaking BEAM arithmetic errors" do + source = + "[Infinity + -Infinity, Infinity - Infinity, -Infinity * -2, Infinity * 0, Infinity / Infinity, 1 / -0, -4 % 2, Infinity ** -1, (-1) ** Infinity, -Infinity < -1, 1 < Infinity]" + + assert {:ok, program} = QuickBEAM.VM.compile(source) + + assert {:ok, + [ + :nan, + :nan, + :infinity, + :nan, + :nan, + :neg_infinity, + negative_zero, + +0.0, + :nan, + true, + true + ]} = QuickBEAM.VM.eval(program) + + assert <<1::1, _magnitude::63>> = <> + end + + test "uses signed Int32 coercion for bitwise opcode families" do + assert Value.binary(:and, 7, 3) == 3 + assert Value.binary(:or, 4, 1) == 5 + assert Value.binary(:xor, 7, 3) == 4 + assert Value.binary(:shl, 1, 33) == 2 + assert Value.binary(:sar, -4, 1) == -2 + assert Value.binary(:shr, -1, 1) == 0x7FFFFFFF + assert Value.unary(:not, 0) == -1 + assert Value.to_int32(0xFFFFFFFF) == -1 + end + + test "routes JavaScript string operations through UTF-16 code units" do + high_surrogate = <<0xED, 0xA0, 0xBD>> + + assert Value.string_length("😀") == 2 + assert Value.string_at("😀", 0) == high_surrogate + assert Value.string_char_code_at("😀", 0) == 0xD83D + assert Value.string_slice("😀", 0, 1) == high_surrogate + assert Value.string_from_units([0xD83D]) == high_surrogate + assert Value.string_from_char_codes([0x1D83D]) == high_surrogate + end +end diff --git a/test/vm/svelte_ssr_test.exs b/test/vm/svelte_ssr_test.exs new file mode 100644 index 000000000..20045bafd --- /dev/null +++ b/test/vm/svelte_ssr_test.exs @@ -0,0 +1,109 @@ +defmodule QuickBEAM.VM.SvelteSSRTest do + use ExUnit.Case, async: false + + alias QuickBEAM.VM.Compiler + alias QuickBEAM.VM.Runtime.Engine + + @fixture "test/fixtures/vm/svelte_ssr.js" + @eval_opts [ + profile: :ssr, + max_steps: 20_000_000, + memory_limit: 64_000_000, + timeout: 5_000 + ] + + setup_all do + start_supervised!({Compiler, capacity: 8}) + + assert {:ok, source} = + QuickBEAM.JS.bundle_file(@fixture, format: :esm, minify: true) + + assert {:ok, program} = QuickBEAM.VM.compile(source, filename: @fixture) + {:ok, source: source, program: program} + end + + test "renders pinned Svelte body and head after an asynchronous Beam.call", %{program: program} do + assert {:ok, rendered} = eval(program, props("Featured", 1)) + + assert rendered == %{ + "body" => + ~s(

Featured

  • Product 1: $12.99
), + "head" => "Featured" + } + end + + test "matches the vendored native QuickJS renderer", %{program: program, source: source} do + props = props("Native parity", 2) + handlers = %{"load_props" => fn [] -> props end} + assert {:ok, runtime} = QuickBEAM.start(handlers: handlers) + + try do + assert {:ok, %{}} = QuickBEAM.eval(runtime, source, timeout: 5_000) + + assert {:ok, native_rendered} = + QuickBEAM.eval(runtime, "await globalThis.__quickbeamSSRResult", timeout: 5_000) + + assert {:ok, beam_rendered} = + Engine.eval(program, [handlers: handlers] ++ @eval_opts) + + assert {:ok, compiler_rendered} = + Engine.eval( + program, + [engine: :compiler, handlers: handlers] ++ @eval_opts + ) + + assert {:ok, scalar_rendered} = + Engine.eval( + program, + [engine: :compiler, compiler_profile: :scalar_v1, handlers: handlers] ++ + @eval_opts + ) + + assert beam_rendered == native_rendered + assert compiler_rendered == native_rendered + assert scalar_rendered == native_rendered + after + QuickBEAM.stop(runtime) + end + end + + test "shares one program across isolated concurrent Svelte renders", %{program: program} do + tasks = + for id <- 1..12 do + Task.async(fn -> eval(program, props("Catalog #{id}", id)) end) + end + + for {{:ok, rendered}, id} <- Enum.zip(Task.await_many(tasks, 10_000), 1..12) do + assert rendered["body"] =~ "

Catalog #{id}

" + assert rendered["body"] =~ ~s(data-id="#{id}") + assert rendered["body"] =~ "Product #{id}" + assert rendered["head"] == "Catalog #{id}" + end + end + + defp eval(program, props) do + handler = fn [] -> + Process.sleep(5) + props + end + + Engine.eval( + program, + [handlers: %{"load_props" => handler}] ++ @eval_opts + ) + end + + defp props(title, id) do + %{ + "title" => title, + "products" => [ + %{ + "id" => id, + "name" => "Product #{id}", + "inStock" => rem(id, 2) == 1, + "priceCents" => 1_299 + (id - 1) * 100 + } + ] + } + end +end diff --git a/test/vm/test262_test.exs b/test/vm/test262_test.exs new file mode 100644 index 000000000..c35d002e2 --- /dev/null +++ b/test/vm/test262_test.exs @@ -0,0 +1,184 @@ +defmodule QuickBEAM.VM.Test262Test do + use ExUnit.Case, async: false + + @moduletag :test262 + @manifest_path Path.expand("../test262/manifest.exs", __DIR__) + @manifest QuickBEAM.Test262.load_manifest(@manifest_path) + @root QuickBEAM.Test262.configured_root() + + test "parses bounded Test262 metadata" do + source = """ + /*--- + flags: [onlyStrict] + includes: [compareArray.js] + features: + - async-functions + negative: + phase: runtime + type: TypeError + ---*/ + """ + + assert %QuickBEAM.Test262.Metadata{ + flags: ["onlyStrict"], + includes: ["compareArray.js"], + features: ["async-functions"], + negative: %QuickBEAM.Test262.Negative{phase: :runtime, type: "TypeError"} + } = QuickBEAM.Test262.parse_metadata(source) + end + + test "rejects unknown negative phases through the typed codec" do + source = """ + /*--- + negative: + phase: invented + type: TypeError + ---*/ + """ + + assert_raise JSONCodec.Error, fn -> QuickBEAM.Test262.parse_metadata(source) end + end + + test "summarizes supported results without counting explicit skips" do + assert is_list(@manifest[:tests]) + + results = [ + %{classification: :pass}, + %{classification: :vm_failure}, + %{classification: :unsupported_flag}, + %{classification: :missing} + ] + + assert %{ + total: 4, + supported: 2, + pass_rate: 0.5, + counts: %{pass: 1, vm_failure: 1, unsupported_flag: 1, missing: 1} + } = QuickBEAM.Test262.summarize(results) + end + + if @root do + @tag timeout: 120_000 + test "selected manifest meets its pinned differential conformance threshold" do + {revision, 0} = System.cmd("git", ["-C", @root, "rev-parse", "HEAD"]) + assert String.trim(revision) == @manifest[:revision] + + results = QuickBEAM.Test262.run_manifest(@root, @manifest) + summary = QuickBEAM.Test262.summarize(results) + + failures = + Enum.filter(results, &(&1.classification in [:missing, :native_failure, :vm_failure])) + + actual_vm_failures = + results + |> Enum.filter(&(&1.classification == :vm_failure)) + |> Enum.map(& &1.path) + |> MapSet.new() + + expected_vm_failures = @manifest[:known_failures] |> Map.keys() |> MapSet.new() + + assert actual_vm_failures == expected_vm_failures, failure_message(summary, failures) + + assert summary.pass_rate >= @manifest[:minimum_pass_rate], + failure_message(summary, failures) + + refute Enum.any?(results, &(&1.classification == :missing)), + failure_message(summary, failures) + + refute Enum.any?(results, &(&1.classification == :native_failure)), + failure_message(summary, failures) + end + + @tag timeout: 180_000 + test "compiler tier meets the selected differential conformance threshold" do + start_supervised!({QuickBEAM.VM.Compiler, capacity: 8}) + assert_compiler_manifest(:pure_v1) + end + + @tag timeout: 180_000 + test "scalar compiler profile meets the selected differential conformance threshold" do + start_supervised!({QuickBEAM.VM.Compiler, capacity: 8}) + assert_compiler_manifest(:scalar_v1) + end + + @tag timeout: 180_000 + test "pinned interpreter meets the selected differential conformance threshold" do + assert_vm_manifest(pinned_programs: true) + end + + @tag timeout: 180_000 + test "pinned compiler tier meets the selected differential conformance threshold" do + start_supervised!({QuickBEAM.VM.Compiler, capacity: 8}) + assert_vm_manifest(engine: :compiler, compiler_profile: :pure_v1, pinned_programs: true) + end + + @tag timeout: 180_000 + test "pinned scalar compiler meets the selected differential conformance threshold" do + start_supervised!({QuickBEAM.VM.Compiler, capacity: 8}) + assert_vm_manifest(engine: :compiler, compiler_profile: :scalar_v1, pinned_programs: true) + end + + defp assert_compiler_manifest(profile), + do: assert_vm_manifest(engine: :compiler, compiler_profile: profile) + + defp assert_vm_manifest(opts) do + results = QuickBEAM.Test262.run_manifest(@root, @manifest, opts) + + summary = QuickBEAM.Test262.summarize(results) + + failures = + Enum.filter(results, &(&1.classification in [:missing, :native_failure, :vm_failure])) + + actual_vm_failures = + results + |> Enum.filter(&(&1.classification == :vm_failure)) + |> Enum.map(& &1.path) + |> MapSet.new() + + expected_vm_failures = @manifest[:known_failures] |> Map.keys() |> MapSet.new() + assert actual_vm_failures == expected_vm_failures, failure_message(summary, failures) + + assert summary.pass_rate >= @manifest[:minimum_pass_rate], + failure_message(summary, failures) + + refute Enum.any?(results, &(&1.classification == :missing)), + failure_message(summary, failures) + + refute Enum.any?(results, &(&1.classification == :native_failure)), + failure_message(summary, failures) + end + + defp failure_message(summary, failures) do + details = + Enum.map_join(failures, "\n", fn result -> + "#{result.classification}: #{result.path}\n VM: #{inspect(result.vm, limit: 8)}\n native: #{inspect(result.native, limit: 8)}" + end) + + "Test262 summary: #{inspect(summary)}\n#{details}" + end + else + @tag skip: "set TEST262_PATH to the pinned Test262 checkout" + test "selected manifest meets its pinned differential conformance threshold" do + end + + @tag skip: "set TEST262_PATH to the pinned Test262 checkout" + test "compiler tier meets the selected differential conformance threshold" do + end + + @tag skip: "set TEST262_PATH to the pinned Test262 checkout" + test "scalar compiler profile meets the selected differential conformance threshold" do + end + + @tag skip: "set TEST262_PATH to the pinned Test262 checkout" + test "pinned interpreter meets the selected differential conformance threshold" do + end + + @tag skip: "set TEST262_PATH to the pinned Test262 checkout" + test "pinned compiler tier meets the selected differential conformance threshold" do + end + + @tag skip: "set TEST262_PATH to the pinned Test262 checkout" + test "pinned scalar compiler meets the selected differential conformance threshold" do + end + end +end diff --git a/test/vm/vue_ssr_test.exs b/test/vm/vue_ssr_test.exs new file mode 100644 index 000000000..4bef86b77 --- /dev/null +++ b/test/vm/vue_ssr_test.exs @@ -0,0 +1,131 @@ +defmodule QuickBEAM.VM.VueSSRTest do + use ExUnit.Case, async: false + + alias QuickBEAM.VM.Compiler + alias QuickBEAM.VM.Compiler.Pool + alias QuickBEAM.VM.Runtime.Engine + + @fixture "test/fixtures/vm/vue_ssr.js" + @bundle_opts [ + format: :esm, + minify: true, + define: %{ + "__VUE_OPTIONS_API__" => "true", + "__VUE_PROD_DEVTOOLS__" => "false", + "__VUE_PROD_HYDRATION_MISMATCH_DETAILS__" => "false", + "process.env.NODE_ENV" => ~s("production") + } + ] + @eval_opts [ + profile: :ssr, + max_steps: 50_000_000, + memory_limit: 256_000_000, + timeout: 5_000 + ] + + setup_all do + start_supervised!({Compiler, capacity: 8}) + assert {:ok, source} = QuickBEAM.JS.bundle_file(@fixture, @bundle_opts) + assert {:ok, program} = QuickBEAM.VM.compile(source, filename: @fixture) + assert {:ok, pinned_program} = QuickBEAM.VM.pin(program) + on_exit(fn -> QuickBEAM.VM.unpin(pinned_program) end) + {:ok, source: source, program: program, pinned_program: pinned_program} + end + + test "renders pinned Vue HTML after an asynchronous Beam.call", %{program: program} do + assert {:ok, html} = eval(program, props("Featured", 1)) + + assert html == + ~s(

Featured

  • Product 1: $12.99
) + end + + test "matches the vendored native QuickJS renderer", %{ + program: program, + pinned_program: pinned_program, + source: source + } do + props = props("Native parity", 2) + handlers = %{"load_props" => fn [] -> props end} + assert {:ok, runtime} = QuickBEAM.start(handlers: handlers) + + try do + assert {:ok, %{}} = QuickBEAM.eval(runtime, source, timeout: 5_000) + + assert {:ok, native_html} = + QuickBEAM.eval(runtime, "await globalThis.__quickbeamSSRResult", timeout: 5_000) + + assert {:ok, beam_html} = + Engine.eval(program, [handlers: handlers] ++ @eval_opts) + + assert {:ok, compiler_html} = + Engine.eval( + program, + [engine: :compiler, handlers: handlers] ++ @eval_opts + ) + + assert {:ok, scalar_html} = + Engine.eval( + program, + [engine: :compiler, compiler_profile: :scalar_v1, handlers: handlers] ++ + @eval_opts + ) + + assert {:ok, pinned_compiler_html} = + Engine.eval( + pinned_program, + [engine: :compiler, handlers: handlers] ++ @eval_opts + ) + + assert beam_html == native_html + assert compiler_html == native_html + assert scalar_html == native_html + assert pinned_compiler_html == native_html + stats = Pool.stats(Pool) + assert stats.counts.ready >= 1 + assert stats.skips >= 1 + after + QuickBEAM.stop(runtime) + end + end + + test "pins one lightweight program handle across isolated concurrent Vue renders", %{ + pinned_program: program + } do + tasks = + for id <- 1..8 do + Task.async(fn -> eval(program, props("Catalog #{id}", id)) end) + end + + for {{:ok, html}, id} <- Enum.zip(Task.await_many(tasks, 10_000), 1..8) do + assert html =~ "

Catalog #{id}

" + assert html =~ ~s(data-id="#{id}") + assert html =~ "Product #{id}" + end + end + + defp eval(program, props) do + handler = fn [] -> + Process.sleep(5) + props + end + + Engine.eval( + program, + [handlers: %{"load_props" => handler}] ++ @eval_opts + ) + end + + defp props(title, id) do + %{ + "title" => title, + "products" => [ + %{ + "id" => id, + "name" => "Product #{id}", + "inStock" => rem(id, 2) == 1, + "priceCents" => 1_299 + (id - 1) * 100 + } + ] + } + end +end