From 5db7d13be2d17f31d5ddaf1d7157a9e458f0cdf8 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 10 Jul 2026 12:01:46 +0200 Subject: [PATCH 01/87] Add isolated QuickJS bytecode interpreter kernel --- docs/beam-interpreter-architecture.md | 641 ++++++++++++++++++++ lib/quickbeam/vm.ex | 214 +++++++ lib/quickbeam/vm/abi.ex | 53 ++ lib/quickbeam/vm/abi_generator.ex | 72 +++ lib/quickbeam/vm/checksum.ex | 36 ++ lib/quickbeam/vm/closure_variable.ex | 5 + lib/quickbeam/vm/continuation.ex | 11 + lib/quickbeam/vm/decoder.ex | 750 ++++++++++++++++++++++++ lib/quickbeam/vm/execution.ex | 24 + lib/quickbeam/vm/frame.ex | 15 + lib/quickbeam/vm/function.ex | 38 ++ lib/quickbeam/vm/instruction_decoder.ex | 234 ++++++++ lib/quickbeam/vm/interpreter.ex | 422 +++++++++++++ lib/quickbeam/vm/opcodes.ex | 173 ++++++ lib/quickbeam/vm/predefined_atoms.ex | 12 + lib/quickbeam/vm/program.ex | 13 + lib/quickbeam/vm/source_position.ex | 12 + lib/quickbeam/vm/value.ex | 166 ++++++ lib/quickbeam/vm/variable.ex | 14 + lib/quickbeam/vm/varint.ex | 79 +++ lib/quickbeam/vm/verifier.ex | 259 ++++++++ mix.exs | 1 + mix.lock | 1 + test/vm/abi_test.exs | 22 + test/vm/decoder_test.exs | 178 ++++++ test/vm/interpreter_test.exs | 87 +++ test/vm/leb128_test.exs | 31 + 27 files changed, 3563 insertions(+) create mode 100644 docs/beam-interpreter-architecture.md create mode 100644 lib/quickbeam/vm.ex create mode 100644 lib/quickbeam/vm/abi.ex create mode 100644 lib/quickbeam/vm/abi_generator.ex create mode 100644 lib/quickbeam/vm/checksum.ex create mode 100644 lib/quickbeam/vm/closure_variable.ex create mode 100644 lib/quickbeam/vm/continuation.ex create mode 100644 lib/quickbeam/vm/decoder.ex create mode 100644 lib/quickbeam/vm/execution.ex create mode 100644 lib/quickbeam/vm/frame.ex create mode 100644 lib/quickbeam/vm/function.ex create mode 100644 lib/quickbeam/vm/instruction_decoder.ex create mode 100644 lib/quickbeam/vm/interpreter.ex create mode 100644 lib/quickbeam/vm/opcodes.ex create mode 100644 lib/quickbeam/vm/predefined_atoms.ex create mode 100644 lib/quickbeam/vm/program.ex create mode 100644 lib/quickbeam/vm/source_position.ex create mode 100644 lib/quickbeam/vm/value.ex create mode 100644 lib/quickbeam/vm/variable.ex create mode 100644 lib/quickbeam/vm/varint.ex create mode 100644 lib/quickbeam/vm/verifier.ex create mode 100644 test/vm/abi_test.exs create mode 100644 test/vm/decoder_test.exs create mode 100644 test/vm/interpreter_test.exs create mode 100644 test/vm/leb128_test.exs diff --git a/docs/beam-interpreter-architecture.md b/docs/beam-interpreter-architecture.md new file mode 100644 index 000000000..2dc1910cd --- /dev/null +++ b/docs/beam-interpreter-architecture.md @@ -0,0 +1,641 @@ +# BEAM Interpreter Architecture + +Status: proposed architecture for the next major QuickBEAM release. + +## Summary + +QuickBEAM should support executing decoded QuickJS bytecode in ordinary BEAM +processes. The first production target is isolated, CPU-bound server-side +rendering: compile a bundle once, share the immutable program, and evaluate it +in many independently scheduled BEAM processes. + +This is a second execution engine, not a replacement implementation of every +feature of the stateful native QuickJS runtime. + +```elixir +{:ok, program} = QuickBEAM.VM.compile(bundle, filename: "server.js") + +Task.async_stream(requests, fn props -> + QuickBEAM.VM.eval(program, + vars: %{"props" => props}, + timeout: 250, + max_steps: 5_000_000, + memory_limit: 64 * 1024 * 1024 + ) +end) +``` + +The immutable program is safe to share. Every evaluation owns its JavaScript +heap, globals, jobs, and limits. A failed or timed-out render only terminates its +evaluation process. + +## Goals + +1. Execute QuickJS-generated bytecode as reduction-accounted BEAM code. +2. Let long JavaScript work coexist fairly with other BEAM processes. +3. Isolate failures, timeouts, heaps, globals, and microtask queues per render. +4. Suspend and resume `async`/`await` around asynchronous BEAM handlers without + polling or blocking a scheduler. +5. Compile and decode an SSR bundle once and reuse it concurrently. +6. Preserve the existing `QuickBEAM.JS.Error` error shape where practical. +7. Validate bytecode before execution and reject incompatible artifacts. +8. Establish differential conformance tests against the vendored QuickJS build. +9. Keep the interpreter architecture compatible with a future optional BEAM + compiler without making that compiler part of the first release gate. + +## Non-goals for the first release + +- Replacing the existing stateful native runtime. +- Sharing one mutable JavaScript realm concurrently across BEAM processes. +- Full browser, DOM, Node, N-API, WebAssembly, or Web API parity. +- Full Test262 compliance. +- Loading arbitrary bytecode produced by another QuickJS version or build. +- Runtime compilation of JavaScript directly to BEAM modules. +- Persisting decoded programs across QuickBEAM upgrades without validation. + +## Product model: two explicit engines + +### Native runtime + +The existing `QuickBEAM` runtime remains the stateful engine: + +- one GenServer owns one QuickJS context; +- globals and functions persist across calls; +- calls to one realm serialize; +- DOM, Web APIs, N-API, and the existing compatibility surface remain native. + +### BEAM engine + +`QuickBEAM.VM` is an isolated execution engine: + +- a `%QuickBEAM.VM.Program{}` is immutable and reusable; +- each evaluation executes in a dedicated BEAM process by default; +- the JavaScript heap is local to that process; +- there is no implicit persistent state between evaluations; +- concurrent evaluations of one program are independent. + +This distinction must be visible in the API. A `mode: :beam` option on a +stateful runtime is misleading if state actually belongs to whichever process +called `eval/3`. + +A stateful BEAM realm may be added later as `QuickBEAM.VM.Session`. Its owner +process would retain the heap and serialize realm operations. Separate sessions +would still be scheduled independently. + +## Public API + +### Compile and decode + +```elixir +@spec QuickBEAM.VM.compile(String.t(), keyword()) :: + {:ok, QuickBEAM.VM.Program.t()} | {:error, term()} + +@spec QuickBEAM.VM.decode(binary(), keyword()) :: + {:ok, QuickBEAM.VM.Program.t()} | {:error, term()} +``` + +Suggested compile options: + +- `:filename` — source name used in JavaScript stack traces; +- `:source_type` — `:script` or `:module`; +- `:compiler` — compiler service or pool to use; +- `:debug_info` — retain source position data; +- decoder resource limits such as `:max_bytecode_bytes`. + +`compile/2` asks the vendored QuickJS compiler for serialized bytecode, decodes +it, verifies it, and returns an immutable program. Compilation may use a native +compiler pool, but evaluation must not require a QuickJS execution context. + +`decode/2` is an advanced API. It accepts only bytecode matching the running +QuickBEAM build fingerprint. + +### Evaluate + +```elixir +@spec QuickBEAM.VM.eval(QuickBEAM.VM.Program.t(), keyword()) :: + {:ok, term()} | {:error, QuickBEAM.JS.Error.t() | term()} + +@spec QuickBEAM.VM.eval!(QuickBEAM.VM.Program.t(), keyword()) :: term() +``` + +Suggested evaluation options: + +- `:vars` — per-evaluation global inputs; +- `:handlers` — explicitly allowed BEAM host calls; +- `:timeout` — wall-clock deadline; +- `:max_steps` — deterministic interpreter instruction budget; +- `:memory_limit` — maximum evaluation process memory; +- `:max_stack_depth` — JavaScript call depth; +- `:isolation` — `:process` by default, optionally `:caller` for trusted code; +- `:return` — conversion policy for the result. + +The safe default is `isolation: :process`. QuickBEAM starts a monitored worker, +awaits its result, and terminates it when the deadline is exceeded. Running in +the caller is useful for trusted low-latency code but cannot provide the same +failure and timeout boundary. + +### Preload in a supervision tree + +Applications should be able to compile a program once during startup and keep +it in their own process state. A later convenience cache may expose: + +```elixir +children = [ + {QuickBEAM.VM.CompilerPool, name: MyApp.JSCompiler, size: 2}, + {MyApp.Renderer, source: "priv/server.js"} +] +``` + +The first release does not need a global program registry. Ordinary immutable +Elixir data is already shareable, and application ownership avoids hidden cache +lifecycle rules. + +## Program artifact + +A program should contain only immutable data: + +```elixir +%QuickBEAM.VM.Program{ + format_version: 1, + engine_fingerprint: binary, + source_digest: binary, + filename: binary, + atoms: tuple, + root: QuickBEAM.VM.Function.t(), + features: map +} +``` + +It must not contain: + +- process identifiers or process-dictionary references; +- mutable heap objects; +- handler closures; +- native QuickJS pointers; +- dynamically created module names or atoms derived from JavaScript input. + +Function identifiers must be deterministic within the artifact. Runtime object +identifiers are allocated only inside an evaluation. + +## Compilation pipeline + +```text +source or bundled source + | + v +vendored QuickJS compile-only context + | + v +serialized QuickJS bytecode + | + +--> checksum and build-fingerprint validation + v +bounded bytecode decoder + | + v +control-flow and stack verifier + | + v +immutable QuickBEAM.VM.Program +``` + +The compile-only native context should not install browser polyfills, create a +DOM, or retain a user realm. Compiler contexts can be pooled independently from +stateful QuickBEAM runtimes. + +Bundling and TypeScript transformation remain separate source-toolchain stages. +The BEAM interpreter consumes JavaScript or a prebuilt bundle; it does not need +a second JavaScript parser. + +## QuickJS bytecode compatibility + +QuickJS bytecode is a private ABI. `BC_VERSION` alone is insufficient as a +long-lived compatibility promise. + +The engine fingerprint should include at least: + +- QuickJS-NG source revision or release; +- `BC_VERSION`; +- a digest of `quickjs-opcode.h`; +- a digest of `quickjs-atom.h`; +- relevant QuickJS compile feature flags; +- QuickBEAM bytecode decoder format version. + +Opcode, atom, tag, and flag definitions must be generated from the same vendored +QuickJS source used to build the NIF. Hand-maintained duplicate tables are not +an acceptable release boundary. + +A generated manifest should fail compilation when the vendored headers change +without regenerated VM definitions and fixtures. + +Decoded program caching must use the complete engine fingerprint and source +digest. An incompatible artifact returns a structured error; it never falls +back to best-effort decoding. + +## Decoder and verifier + +The decoder processes untrusted binary input and must be bounded before the +interpreter sees it. + +Required checks include: + +- serialized checksum verification; +- exact version and fingerprint match; +- bounded bytecode, atom, constant, function, and debug-info sizes; +- bounded nested-function depth; +- valid atom and constant references; +- known opcode and operand formats; +- branch targets on instruction boundaries; +- valid exception-region targets; +- stack underflow and declared maximum-stack consistency; +- valid local, argument, closure, and constant indexes; +- no trailing serialized data; +- total decoded instruction limit. + +Decoder failures must return errors rather than raising or partially installing +state. Unsigned and signed LEB128 fields use the `varint` package, wrapped with +QuickJS's 32-bit width and encoded-length limits. The decoder should have +mutation fuzzing and corpus tests for each supported QuickJS release. + +## Evaluation process and ownership + +A dedicated evaluation process owns all mutable state: + +```text +Evaluation process + - interpreter frames and operand stacks + - globals and lexical environments + - JavaScript object heap + - closure cells + - prototypes and shapes + - promise jobs and timers allowed by the profile + - host-call context + - step, stack, memory, and time limits +``` + +Process-dictionary storage is acceptable as an implementation detail only when +execution occurs in a dedicated owner process. Process exit then provides a +strong final cleanup boundary. Heap access must remain behind a storage module +so profiling can justify a different backend later. + +Object references are meaningful only inside their owning evaluation. Public +results are converted to ordinary BEAM values before crossing the process +boundary. Returning live JavaScript object handles is out of scope for isolated +evaluation. + +## Scheduling and limits + +### Scheduler cooperation + +The tail-recursive opcode dispatch loop naturally consumes BEAM reductions and +is preempted by the VM. It should not use a self-message after an arbitrary +number of opcodes merely to simulate scheduling. + +Fairness must be tested under `+S 1`, where a CPU-bound render competes with a +latency-sensitive BEAM process on one scheduler. + +All host operations used during evaluation must also cooperate with the BEAM: + +- CPU-heavy native functions must be dirty NIFs or avoided; +- network and file operations must be asynchronous; +- the interpreter must not busy-poll promises or timers. + +### Step budget + +`max_steps` is a deterministic JavaScript execution limit, separate from BEAM +reductions. Every opcode has a cost, with optional higher weights for expensive +operations. Accounting may be batched for speed, but loops cannot avoid it. + +Exhaustion returns a specific limit error and terminates the evaluation worker. +It must not silently resume with a replenished budget. + +### Wall-clock timeout + +For `isolation: :process`, the supervising caller owns the deadline and kills +the evaluation worker if it does not reply. Internal safepoint checks improve +error reporting but are not the authoritative timeout mechanism. + +### Memory limit + +Use both: + +1. a process-level heap limit as a final containment boundary; and +2. periodic explicit memory checks at interpreter safepoints for a controlled + JavaScript error or QuickBEAM limit error. + +The JavaScript object-store mark-and-sweep collector may reclaim unreachable +entries retained in the process dictionary. Regardless of collector behavior, +process termination must reclaim the entire evaluation. + +### Stack limit + +JavaScript call depth is tracked explicitly. It must not rely on exhausting the +BEAM process stack or native C stack. + +## JavaScript values and heap + +Keep `null` and `undefined` distinct. Common scalar values may use native BEAM +terms, while semantic sentinels and reference types use explicit tagged values. + +The value representation, coercion rules, property semantics, invocation rules, +and built-ins form one canonical semantic layer shared by the interpreter and +any future compiler. A compiled path must not grow a second implementation of +JavaScript semantics. + +Recommended initial representation: + +- numbers — integer/float plus explicit non-finite sentinels where required; +- strings — binaries with explicit UTF-16-aware operations; +- booleans — `true` and `false`; +- null — `nil`; +- undefined — a dedicated sentinel; +- bigint — tagged arbitrary-precision integer; +- symbol — tagged unique identity; +- object/function — owner-local heap references. + +String indexing, lengths, regular expressions, and source positions must follow +JavaScript UTF-16 semantics even though Elixir binaries are UTF-8. + +## ECMAScript and host profiles + +The first production profile should be explicit rather than implying browser +parity: + +```elixir +QuickBEAM.VM.compile(source, profile: :ssr) +``` + +The `:ssr` profile should contain only the APIs demonstrated as necessary by the +chosen SSR acceptance fixture. Likely candidates are core ECMAScript built-ins, +`console`, text encoding, URL support, and selected deterministic helpers. + +The recommended first fixture is a pinned `preact` plus +`preact-render-to-string` bundle that returns real HTML from request props. The +prototype branch's Preact benchmark only walks and summarizes a VNode tree, and +the current `examples/ssr` application renders through the native lexbor DOM; +neither by itself demonstrates end-to-end SSR inside the BEAM interpreter. + +DOM, fetch, sockets, workers, N-API, and WebAssembly remain unsupported unless +added through separately reviewed profiles. Unsupported features fail clearly; +they do not silently delegate arbitrary object operations to the native runtime. + +## Host calls and async work + +Async/await and asynchronous BEAM handlers are part of the first SSR milestone. +Handlers are evaluation options, not fields in the immutable program: + +```elixir +QuickBEAM.VM.eval(program, + vars: %{"request" => request}, + handlers: %{ + "load_props" => fn [request] -> MyApp.Pages.load_props(request) end + } +) +``` + +`Beam.call` dispatches handler work under a supervised task and immediately +returns a pending JavaScript Promise. Handler completion sends an unforgeable +operation reference and a converted result back to the evaluation owner. +Handler exceptions and task exits reject the Promise; they cannot corrupt the +interpreter heap. + +`Beam.callSync` should be excluded from the initial `:ssr` profile. An arbitrary +Elixir handler called inline can block the evaluation process and weakens +cancellation guarantees. + +Async execution uses explicit suspended continuations: + +1. invocation of an async JavaScript function creates its result Promise; +2. the interpreter reaches `await` and captures the resumable frame, operand + stack, lexical context, exception state, and remaining limits; +3. if the awaited Promise is pending, control returns to the owner event loop; +4. the owner drains runnable microtasks and then waits in `receive` for a host + reply, cancellation, or the next timer deadline; +5. settling a Promise enqueues its reactions in FIFO order; +6. a fulfilled await resumes with its value, while a rejected await resumes + through the JavaScript throw path; +7. timeout or owner exit cancels all outstanding handler tasks. + +Continuations and Promise objects never leave the evaluation owner process, +because they contain owner-local heap references. The event loop must not use +`Process.sleep/1`, repeated short receive timeouts, or recursive promise polling. +When no job is runnable it performs one receive whose timeout is the minimum of +the evaluation deadline and next timer deadline. + +Multiple in-flight handlers are allowed. Replies are correlated by operation +reference and may arrive in any order; JavaScript microtask ordering remains +deterministic after each settlement. Stale replies after cancellation are +ignored. + +```text +Evaluation owner Host Task Supervisor +---------------- -------------------- +run bytecode + Beam.call(name, args) + create pending Promise + start child -----------------------> run Elixir handler +continue until await +save continuation +run available microtasks +receive <----------------------------- {operation_ref, result} +settle Promise +run reactions +resume continuation +``` + +The evaluation deadline covers JavaScript execution, waiting for handlers, and +result conversion. The worker tracks every host task it starts. On timeout, +cancellation, or worker shutdown, those tasks are terminated before the final +result is returned. Handlers that create external side effects remain +application responsibilities and should use their own idempotency or +cancellation mechanisms. + +## Errors, tracing, and observability + +JavaScript throws should become `%QuickBEAM.JS.Error{}` with JavaScript name, +message, filename, line, column, and stack frames when debug metadata is present. + +Limit and infrastructure failures should remain distinguishable: + +- JavaScript throw; +- bytecode incompatibility or verification failure; +- step limit; +- timeout; +- memory limit; +- stack limit; +- unsupported opcode or host feature; +- compiler service failure. + +Telemetry events should cover compile/decode/evaluate start and stop, duration, +steps, peak memory, result category, and execution profile. Do not include source +or values by default. + +## Conformance strategy + +Correctness is measured against the exact vendored QuickJS build. + +1. **Decoder fixtures** — compile representative source with QuickJS and assert + decoded functions, operands, constants, stack effects, and source positions. +2. **Opcode tests** — at least one valid fixture per supported opcode family. +3. **Differential tests** — run the same source and inputs through native + QuickJS and the BEAM engine and compare values or error shapes. +4. **Property tests** — arithmetic, coercion, comparison, property descriptors, + arrays, strings, closures, and control flow. +5. **Selected Test262** — publish the exact included set and pass rate; never + describe a selected subset as full compliance. +6. **Real SSR fixture** — render a pinned Preact or React-compatible bundle and + compare HTML against native QuickJS. +7. **Fuzzing** — malformed serialized bytecode and differential source corpora. + +Skipped tests need categorized reasons: unsupported profile, unsupported +language feature, native QuickJS mismatch, harness limitation, or known defect. + +## Performance and scheduler acceptance + +Correctness and isolation are release gates; speculative speedups are not. +Benchmark reports must separate: + +- compilation and decoding; +- cold evaluation; +- warm evaluation; +- result conversion; +- native runtime GenServer round trips; +- interpreter-only execution. + +Minimum scheduler acceptance scenarios: + +- under `+S 1`, a long render does not starve a periodic BEAM process beyond a + defined latency bound; +- a timed-out infinite loop is terminated within a defined grace interval; +- terminating one render does not affect concurrent renders; +- 100 concurrent evaluations have isolated globals and heaps; +- evaluation process memory is reclaimed after completion; +- a compiled program can be shared without copying mutable runtime state. + +A reasonable first performance gate is an agreed maximum slowdown on the pinned +SSR workload, measured end to end. It should be set from reproducible benchmark +data rather than early arithmetic microbenchmarks. + +## Rollout + +The BEAM engine should be opt-in for the first major release: + +```elixir +QuickBEAM.VM.compile/2 +QuickBEAM.VM.eval/2 +``` + +Do not automatically fall back between engines. Silent fallback hides semantic +and scheduling differences. If an application wants fallback, it can choose it +explicitly after handling an unsupported-feature error. + +After the SSR profile is stable, a convenience integration may preload programs +for Phoenix rendering. A stateful `QuickBEAM.VM.Session` and an optimizing +BEAM compiler are later features with separate acceptance gates. + +## Implementation milestones + +### Milestone 0 — branch extraction and contracts + +- Start from current `master`; do not rebase or merge the prototype history. +- Record the supported SSR fixture and API contract. +- Add the engine fingerprint format and generated QuickJS metadata. +- Extract only decoder fixtures and the smallest reusable data structures. + +Exit gate: current QuickJS bytecode compiles, fingerprints, decodes, verifies, +and rejects stale or malformed artifacts. + +### Milestone 1 — isolated interpreter kernel + +- Implement immutable program/function structures. +- Implement frame, stack, control flow, calls, closures, exceptions, and limits. +- Run each evaluation in a monitored process. +- Represent interpreter suspension and resumption explicitly. +- Add single-scheduler fairness, kill, timeout, and isolation tests. + +Exit gate: core language differential suite passes, infinite loops are safely +contained, and a suspended frame can resume with a value or JavaScript throw. + +### Milestone 2 — canonical object model and core runtime + +- Add objects, arrays, prototypes, descriptors, strings, symbols, iterators, + promises, microtasks, async functions, await, and garbage collection. +- Add supervised asynchronous `Beam.call` handlers and cancellation. +- Keep semantics shared rather than embedding behavior in opcode handlers. + +Exit gate: selected language and built-in Test262 suites meet a published pass +threshold with no unexplained skips, and async differential tests match QuickJS +for fulfillment, rejection, ordering, and nested awaits. + +### Milestone 3 — SSR vertical slice + +- Define the `:ssr` profile. +- Compile a pinned `preact` plus `preact-render-to-string` bundle once. +- Load request data through `await Beam.call("load_props", request)` and render + real HTML after the handler Promise settles. +- Render concurrently with independent request state. +- Compare output, Promise ordering, and errors with native QuickJS. +- Publish scheduler, memory, cancellation, and performance benchmark results. + +Exit gate: the async SSR fixture is correct, isolated, bounded, cancellable, and +operationally observable. + +### Milestone 4 — hardening and release + +- Fuzz decoder and verifier. +- Audit process-dictionary ownership and cleanup. +- Test malformed programs, handler failures, cancellation, and supervisor exits. +- Document unsupported features and migration guidance. +- Run normal CI with warnings treated as errors. + +Exit gate: no fallback to native execution during BEAM evaluation, all release +limits are enforced, and the compatibility matrix is published. + +### Later — optional compiler and stateful sessions + +Only after interpreter correctness is stable: + +- compile verified basic blocks to Erlang abstract forms; +- reuse the same runtime ABI and semantics; +- use a bounded module-name pool to avoid atom leaks; +- deopt explicitly to verified interpreter states; +- add stateful owner-process sessions if demanded by real workloads. + +## Prototype branch extraction map + +Potentially salvage after focused review and adaptation: + +- bytecode LEB128 and serialization tests; +- program/function/variable structures; +- instruction decoder and opcode-family fixtures; +- interpreter frame and selected opcode handlers; +- process-owned heap abstraction; +- coercion, invocation, and object-model differential tests; +- the Preact SSR fixture and benchmark harness. + +Do not carry directly into the first implementation: + +- the 3,025-commit branch history; +- hand-maintained QuickJS v25 opcode/atom tables; +- `mode: :beam` state attached to arbitrary caller processes; +- the experimental QuickJS-bytecode-to-BEAM compiler; +- a second JavaScript source parser/compiler; +- broad browser and Node API reimplementations; +- generated parser test volume unrelated to the SSR release gate; +- hardcoded billion-step budgets or ignored timeout options. + +The extraction should happen as small, reviewable vertical slices on a fresh +branch based on current `master`. + +## Decisions required before implementation + +1. Confirm `preact` plus `preact-render-to-string` as the first pinned SSR + acceptance fixture, or select another renderer. +2. Which asynchronous host operations beyond `Beam.call` and timers belong in + the initial profile, if any? +3. What end-to-end slowdown relative to native QuickJS is acceptable initially? +4. What Test262 subsets and pass threshold define the supported language level? +5. Should `isolation: :caller` be public in the first release or remain an + internal benchmark option? +6. Which error shape should represent process-enforced timeout and memory + termination? diff --git a/lib/quickbeam/vm.ex b/lib/quickbeam/vm.ex new file mode 100644 index 000000000..ef4d70631 --- /dev/null +++ b/lib/quickbeam/vm.ex @@ -0,0 +1,214 @@ +defmodule QuickBEAM.VM do + @moduledoc """ + Compile and validate QuickJS bytecode for execution by the BEAM engine. + + This milestone exposes the immutable, version-locked program pipeline. The + interpreter itself is intentionally not part of this module yet. + """ + + alias QuickBEAM.VM.{ABI, Decoder, Function, Interpreter, Program, Verifier} + + @type program :: QuickBEAM.VM.Program.t() + + @max_bytecode_bytes 16 * 1024 * 1024 + @default_timeout 5_000 + + @doc """ + Compiles JavaScript with the vendored QuickJS compiler and returns a verified + immutable program. + + A bare temporary native runtime is used until the dedicated compiler pool is + introduced. Use `decode/2` when bytecode has already been compiled. + """ + @spec compile(String.t(), keyword()) :: {:ok, program()} | {:error, term()} + def compile(source, opts \\ []) when is_binary(source) and is_list(opts) do + {runtime_options, opts} = Keyword.pop(opts, :runtime_options, []) + {filename, decode_options} = Keyword.pop(opts, :filename) + runtime_options = Keyword.put(runtime_options, :apis, false) + + with :ok <- validate_filename(filename), + {:ok, runtime} <- QuickBEAM.start(runtime_options) do + try do + with {:ok, bytecode} <- QuickBEAM.compile(runtime, source), + {:ok, program} <- decode(bytecode, decode_options) do + {:ok, maybe_put_filename(program, filename)} + end + after + QuickBEAM.stop(runtime) + end + end + end + + @doc "Decodes and verifies bytecode from this exact QuickJS build." + @spec decode(binary(), keyword()) :: {:ok, program()} | {:error, term()} + def decode(bytecode, opts \\ []) when is_binary(bytecode) and is_list(opts) do + {max_bytecode_bytes, verifier_options} = + Keyword.pop(opts, :max_bytecode_bytes, @max_bytecode_bytes) + + with :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, program} + end + 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 + + @doc """ + Evaluates a verified program in an isolated BEAM process. + + Supported kernel options are `:vars`, `:timeout`, `:max_steps`, and + `:max_stack_depth`. `isolation: :caller` is available for trusted diagnostics. + """ + @spec eval(Program.t(), keyword()) :: {:ok, term()} | {:error, term()} + def eval(%Program{} = program, opts \\ []) when is_list(opts) do + with :ok <- Verifier.verify(program), + {:ok, options} <- evaluation_options(opts) do + case options.isolation do + :caller -> Interpreter.eval(program, Map.to_list(options.interpreter)) + :process -> eval_isolated(program, options) + end + end + end + + defp evaluation_options(opts) do + allowed = [:isolation, :max_stack_depth, :max_steps, :timeout, :vars] + + case Keyword.keys(opts) -- allowed do + [] -> validate_evaluation_options(opts) + [unknown | _] -> {:error, {:unknown_option, unknown}} + end + end + + defp validate_evaluation_options(opts) do + isolation = Keyword.get(opts, :isolation, :process) + 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) + vars = Keyword.get(opts, :vars, %{}) + + cond do + isolation not in [:caller, :process] -> + {:error, {:invalid_option, :isolation, isolation}} + + timeout != :infinity and (not is_integer(timeout) or timeout <= 0) -> + {:error, {:invalid_option, :timeout, timeout}} + + not is_integer(max_steps) or max_steps <= 0 -> + {:error, {:invalid_option, :max_steps, max_steps}} + + not is_integer(max_stack_depth) or max_stack_depth <= 0 -> + {:error, {:invalid_option, :max_stack_depth, max_stack_depth}} + + not is_map(vars) -> + {:error, {:invalid_option, :vars, vars}} + + true -> + {:ok, + %{ + isolation: isolation, + timeout: timeout, + interpreter: %{ + max_steps: max_steps, + max_stack_depth: max_stack_depth, + vars: vars + } + }} + end + end + + defp eval_isolated(program, options) do + caller = self() + reply_ref = make_ref() + + {pid, monitor_ref} = + spawn_monitor(fn -> + result = safe_interpret(program, options.interpreter) + send(caller, {reply_ref, result}) + end) + + await_evaluation(pid, monitor_ref, reply_ref, options.timeout) + end + + defp safe_interpret(program, options) do + case Interpreter.eval(program, Map.to_list(options)) do + {:suspended, _continuation} -> {:error, {:unsupported, :async_wait}} + result -> result + end + rescue + exception -> {:error, {:interpreter_crash, exception, __STACKTRACE__}} + catch + kind, reason -> {:error, {:interpreter_crash, {kind, reason}, __STACKTRACE__}} + end + + defp await_evaluation(pid, monitor_ref, reply_ref, :infinity) do + receive do + {^reply_ref, result} -> + Process.demonitor(monitor_ref, [:flush]) + result + + {:DOWN, ^monitor_ref, :process, ^pid, reason} -> + {:error, {:evaluation_process_exit, reason}} + end + end + + defp await_evaluation(pid, monitor_ref, reply_ref, timeout) do + receive do + {^reply_ref, result} -> + Process.demonitor(monitor_ref, [:flush]) + result + + {:DOWN, ^monitor_ref, :process, ^pid, reason} -> + {:error, {:evaluation_process_exit, reason}} + after + timeout -> + Process.exit(pid, :kill) + await_down(monitor_ref, pid) + {:error, {:limit_exceeded, :timeout, timeout}} + end + end + + 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 + + @doc "Returns the exact vendored QuickJS bytecode ABI fingerprint." + defdelegate fingerprint(), to: ABI + + @doc "Returns the vendored QuickJS bytecode version." + defdelegate bytecode_version(), to: ABI +end diff --git a/lib/quickbeam/vm/abi.ex b/lib/quickbeam/vm/abi.ex new file mode 100644 index 000000000..87bd90a3d --- /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.ABIGenerator + + @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 ABIGenerator.version!(@quickjs_source) + @tags ABIGenerator.tags!(@quickjs_source) + @opcodes ABIGenerator.opcodes!(@opcode_source) + @atoms ABIGenerator.atoms!(@atom_source) + @fingerprint ABIGenerator.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 false + def tags, do: @tags + + @doc false + def opcodes, do: @opcodes + + @doc false + 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..acd38d514 --- /dev/null +++ b/lib/quickbeam/vm/abi_generator.ex @@ -0,0 +1,72 @@ +defmodule QuickBEAM.VM.ABIGenerator do + @moduledoc false + + @opcode_pattern ~r/^\s*DEF\(\s*([A-Za-z0-9_]+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*([A-Za-z0-9_]+)\s*\)/m + @atom_pattern ~r/^\s*DEF\(\s*([A-Za-z0-9_]+)\s*,\s*"([^"]*)"\s*\)/m + + def version!(source) do + case Regex.run(~r/^#define\s+BC_VERSION\s+(\d+)$/m, source) do + [_, version] -> String.to_integer(version) + _ -> raise "BC_VERSION not found in vendored quickjs.c" + end + end + + def tags!(source) do + with [_, body] <- Regex.run(~r/typedef enum BCTagEnum\s*\{(.*?)\}\s*BCTagEnum;/s, source) do + body + |> String.split(",") + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + |> Enum.reduce({%{}, 0}, fn entry, {tags, previous} -> + case Regex.run(~r/^BC_TAG_([A-Z0-9_]+)(?:\s*=\s*(\d+))?$/, entry) do + [_, name] -> + value = previous + 1 + {Map.put(tags, tag_name(name), value), value} + + [_, name, explicit] -> + value = String.to_integer(explicit) + {Map.put(tags, tag_name(name), value), value} + + _ -> + raise "unsupported bytecode tag definition: #{inspect(entry)}" + end + end) + |> elem(0) + else + _ -> raise "BCTagEnum not found in vendored quickjs.c" + end + end + + def opcodes!(header) do + rows = + @opcode_pattern + |> Regex.scan(header) + |> Enum.with_index() + |> Map.new(fn {[_, name, size, pops, pushes, format], opcode} -> + {opcode, + {String.to_atom(name), String.to_integer(size), String.to_integer(pops), + String.to_integer(pushes), String.to_atom(format)}} + end) + + if map_size(rows) == 0 or map_size(rows) > 256 do + raise "invalid opcode table size: #{map_size(rows)}" + end + + rows + end + + def atoms!(header) do + @atom_pattern + |> Regex.scan(header) + |> Enum.with_index(1) + |> Map.new(fn {[_, _name, value], index} -> {index, value} end) + end + + 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_name(name), do: name |> String.downcase() |> String.to_atom() +end diff --git a/lib/quickbeam/vm/checksum.ex b/lib/quickbeam/vm/checksum.ex new file mode 100644 index 000000000..a78db94af --- /dev/null +++ b/lib/quickbeam/vm/checksum.ex @@ -0,0 +1,36 @@ +defmodule QuickBEAM.VM.Checksum do + @moduledoc false + + import Bitwise + + @factor 0x9E370001 + @mask 0xFFFFFFFF + + @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} + + @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/closure_variable.ex b/lib/quickbeam/vm/closure_variable.ex new file mode 100644 index 000000000..0b2513421 --- /dev/null +++ b/lib/quickbeam/vm/closure_variable.ex @@ -0,0 +1,5 @@ +defmodule QuickBEAM.VM.ClosureVariable do + @moduledoc "JavaScript closure capture metadata." + + defstruct [:name, :var_idx, :closure_type, :is_const, :is_lexical, :var_kind] +end diff --git a/lib/quickbeam/vm/continuation.ex b/lib/quickbeam/vm/continuation.ex new file mode 100644 index 000000000..9583611d4 --- /dev/null +++ b/lib/quickbeam/vm/continuation.ex @@ -0,0 +1,11 @@ +defmodule QuickBEAM.VM.Continuation do + @moduledoc false + + @enforce_keys [:frame, :execution] + defstruct [:frame, :execution] + + @type t :: %__MODULE__{ + frame: QuickBEAM.VM.Frame.t(), + execution: QuickBEAM.VM.Execution.t() + } +end diff --git a/lib/quickbeam/vm/decoder.ex b/lib/quickbeam/vm/decoder.ex new file mode 100644 index 000000000..53cd98fcb --- /dev/null +++ b/lib/quickbeam/vm/decoder.ex @@ -0,0 +1,750 @@ +defmodule QuickBEAM.VM.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, + Checksum, + Function, + InstructionDecoder, + Opcodes, + PredefinedAtoms, + Program + } + + alias QuickBEAM.VM.Varint, as: LEB128 + import Bitwise + + # JS_ATOM_NULL=0, plus 228 DEF entries from quickjs-atom.h + @js_atom_end Opcodes.js_atom_end() + + # Pre-compute tag constants for use in match clauses + @tag_null Opcodes.bc_tag_null() + @tag_undefined Opcodes.bc_tag_undefined() + @tag_bool_false Opcodes.bc_tag_bool_false() + @tag_bool_true Opcodes.bc_tag_bool_true() + @tag_int32 Opcodes.bc_tag_int32() + @tag_float64 Opcodes.bc_tag_float64() + @tag_string Opcodes.bc_tag_string() + @tag_function_bytecode Opcodes.bc_tag_function_bytecode() + @tag_object Opcodes.bc_tag_object() + @tag_array Opcodes.bc_tag_array() + @tag_big_int Opcodes.bc_tag_big_int() + @tag_template_object Opcodes.bc_tag_template_object() + @tag_regexp Opcodes.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(), + 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 + if type == 0 do + with {:ok, atom_id, rest2} <- LEB128.read_fixed_u32(rest), + {:ok, atom} <- predefined_atom(atom_id) do + read_atom_list(rest2, count - 1, [atom | acc]) + end + else + with {:ok, str, rest2} <- read_string_raw(rest) do + read_atom_list(rest2, count - 1, [str | acc]) + end + end + end + end + + defp predefined_atom(atom_id) do + case PredefinedAtoms.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, v, rest} <- LEB128.read_unsigned(data) do + if band(v, 1) == 1 do + {:ok, {:tagged_int, bsr(v, 1)}, rest} + else + idx = bsr(v, 1) + + name = + case idx do + 0 -> + "" + + n when n < @js_atom_end -> + {:predefined, n} + + _ -> + local_idx = idx - @js_atom_end + + if local_idx < tuple_size(atoms), + do: elem(atoms, local_idx), + else: {:error, {:unknown_atom, idx}} + end + + case name do + {:error, reason} -> {:error, reason} + name -> {:ok, name, rest} + end + end + end + 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, len_encoded, rest} <- LEB128.read_unsigned(data) do + byte_len = bsr(len_encoded, 1) + + if byte_size(rest) < byte_len do + {:error, :unexpected_end} + else + <> = rest + {:ok, raw, rest2} + end + end + end + + defp read_string_raw(data) do + with {:ok, len_encoded, rest} <- LEB128.read_unsigned(data) do + is_wide = band(len_encoded, 1) == 1 + char_len = bsr(len_encoded, 1) + byte_len = if is_wide, do: char_len * 2, else: char_len + + if byte_size(rest) < byte_len do + {:error, :unexpected_end} + else + <> = rest + + if is_wide do + {:ok, wide_to_utf8(str), rest2} + else + {:ok, latin1_to_utf8(str), rest2} + end + end + end + 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 == Opcodes.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 + if byte_size(rest) < byte_code_len do + {:error, :unexpected_end} + else + <> = rest + + with {:ok, instructions} <- InstructionDecoder.decode(byte_code, arg_count), + {:ok, debug_info, rest} <- read_debug_info(rest, flags_map.has_debug_info, atoms) do + fun = %Function{ + 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, + 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: strict > 0, + has_prototype: flags_map.has_prototype, + has_simple_parameter_list: flags_map.has_simple_parameter_list, + is_derived_class_constructor: flags_map.is_derived_class_constructor, + need_home_object: flags_map.need_home_object, + func_kind: flags_map.func_kind, + new_target_allowed: flags_map.new_target_allowed, + super_call_allowed: flags_map.super_call_allowed, + super_allowed: flags_map.super_allowed, + arguments_allowed: flags_map.arguments_allowed, + has_debug_info: flags_map.has_debug_info + } + + {:ok, fun, rest} + end + end + end + 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), + <> <- 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.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.ClosureVariable{ + 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 Opcodes.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/execution.ex b/lib/quickbeam/vm/execution.ex new file mode 100644 index 000000000..e4c91e0ee --- /dev/null +++ b/lib/quickbeam/vm/execution.ex @@ -0,0 +1,24 @@ +defmodule QuickBEAM.VM.Execution do + @moduledoc false + + @enforce_keys [:atoms, :max_stack_depth, :remaining_steps, :step_limit] + defstruct [ + :atoms, + :current_function, + :step_limit, + globals: %{}, + depth: 0, + max_stack_depth: 1_000, + remaining_steps: 0 + ] + + @type t :: %__MODULE__{ + atoms: tuple(), + current_function: QuickBEAM.VM.Function.t() | nil, + globals: map(), + depth: non_neg_integer(), + max_stack_depth: pos_integer(), + remaining_steps: non_neg_integer(), + step_limit: pos_integer() + } +end diff --git a/lib/quickbeam/vm/frame.ex b/lib/quickbeam/vm/frame.ex new file mode 100644 index 000000000..51cfb1f4a --- /dev/null +++ b/lib/quickbeam/vm/frame.ex @@ -0,0 +1,15 @@ +defmodule QuickBEAM.VM.Frame do + @moduledoc false + + @enforce_keys [:function, :locals, :args] + defstruct [:function, :locals, :args, :this, pc: 0, stack: []] + + @type t :: %__MODULE__{ + function: QuickBEAM.VM.Function.t(), + locals: tuple(), + args: tuple(), + this: term(), + pc: non_neg_integer(), + stack: [term()] + } +end diff --git a/lib/quickbeam/vm/function.ex b/lib/quickbeam/vm/function.ex new file mode 100644 index 000000000..29e93211f --- /dev/null +++ b/lib/quickbeam/vm/function.ex @@ -0,0 +1,38 @@ +defmodule QuickBEAM.VM.Function do + @moduledoc "JavaScript function metadata and pre-resolved VM instructions used by the interpreter and BEAM compiler." + + @type t :: %__MODULE__{} + + 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, + extra_atoms: [], + 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 + ] +end diff --git a/lib/quickbeam/vm/instruction_decoder.ex b/lib/quickbeam/vm/instruction_decoder.ex new file mode 100644 index 000000000..f7f502c18 --- /dev/null +++ b/lib/quickbeam/vm/instruction_decoder.ex @@ -0,0 +1,234 @@ +defmodule QuickBEAM.VM.InstructionDecoder 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.Opcodes + 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 Opcodes.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 Opcodes.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 + case operands_for(bc, pos + 1, op, fmt, offset_map, ac) do + {:ok, operands} -> + decode_pass2( + bc, + len, + pos + size, + idx + 1, + offset_map, + [ + {op, operands} | acc + ], + ac + ) + + {:error, _} = err -> + err + end + end + 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, Opcodes.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 == Opcodes.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 Opcodes.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/interpreter.ex b/lib/quickbeam/vm/interpreter.ex new file mode 100644 index 000000000..f87cb0bc5 --- /dev/null +++ b/lib/quickbeam/vm/interpreter.ex @@ -0,0 +1,422 @@ +defmodule QuickBEAM.VM.Interpreter do + @moduledoc false + + import Bitwise + + alias QuickBEAM.VM.{ + Continuation, + Execution, + Frame, + Function, + Opcodes, + PredefinedAtoms, + Program, + Value + } + + @default_max_steps 5_000_000 + @default_max_stack_depth 1_000 + + @type result :: + {:ok, term()} + | {:error, term()} + | {:suspended, Continuation.t()} + + @spec eval(Program.t(), keyword()) :: result() + def eval(%Program{} = program, opts \\ []) do + max_steps = Keyword.get(opts, :max_steps, @default_max_steps) + + execution = %Execution{ + atoms: program.atoms, + globals: Map.new(Keyword.get(opts, :vars, %{})), + max_stack_depth: Keyword.get(opts, :max_stack_depth, @default_max_stack_depth), + remaining_steps: max_steps, + step_limit: max_steps + } + + case invoke(program.root, [], :undefined, execution) do + {:ok, value, _execution} -> {:ok, value} + {:error, reason, _execution} -> {:error, reason} + {:suspended, continuation} -> {:suspended, continuation} + end + end + + @spec resume(Continuation.t(), {:ok, term()} | {:error, term()}) :: result() + def resume(%Continuation{} = continuation, {:ok, value}) do + frame = %{continuation.frame | stack: [value | continuation.frame.stack]} + + case run(frame, continuation.execution) do + {:ok, result, _execution} -> {:ok, result} + {:error, reason, _execution} -> {:error, reason} + {:suspended, continuation} -> {:suspended, continuation} + end + end + + def resume(%Continuation{}, {:error, reason}), do: {:error, {:js_throw, reason}} + + defp invoke(%Function{} = function, args, this, %Execution{} = execution) do + if execution.depth >= execution.max_stack_depth do + {:error, {:limit_exceeded, :stack_depth, execution.depth + 1}, execution} + else + previous_function = execution.current_function + + execution = %{ + execution + | current_function: function, + depth: execution.depth + 1 + } + + local_count = max(function.arg_count + function.var_count, 1) + + frame = %Frame{ + function: function, + locals: :erlang.make_tuple(local_count, :undefined), + args: List.to_tuple(args), + this: this + } + + case run(frame, execution) do + {:ok, value, execution} -> + {:ok, value, restore_call(execution, previous_function)} + + {:error, reason, execution} -> + {:error, reason, restore_call(execution, previous_function)} + + {:suspended, continuation} -> + {:suspended, continuation} + end + end + end + + defp invoke({:closure, %Function{} = function, _captures}, args, this, execution), + do: invoke(function, args, this, execution) + + defp invoke(value, _args, _this, execution), + do: {:error, {:not_callable, value}, execution} + + defp restore_call(execution, previous_function) do + %{execution | current_function: previous_function, depth: execution.depth - 1} + end + + defp run(_frame, %Execution{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{} = frame, %Execution{} = execution) do + {opcode, operands} = elem(frame.function.instructions, frame.pc) + {name, _size, _pops, _pushes, _format} = Opcodes.info(opcode) + {name, operands} = Opcodes.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(:push_i32, [value], frame, execution), do: push(frame, execution, value) + defp execute(:push_i8, [value], frame, execution), do: push(frame, execution, value) + defp execute(:push_i16, [value], frame, execution), do: push(frame, execution, value) + defp execute(:undefined, [], frame, execution), do: push(frame, execution, :undefined) + defp execute(:null, [], frame, execution), do: push(frame, execution, nil) + defp execute(:push_false, [], frame, execution), do: push(frame, execution, false) + defp execute(:push_true, [], frame, execution), do: push(frame, execution, true) + + defp execute(:push_bigint_i32, [value], frame, execution), + do: push(frame, execution, {:bigint, value}) + + defp execute(:push_const, [index], frame, execution), + do: push(frame, execution, Enum.at(frame.function.constants, index)) + + defp execute(:push_const8, [index], frame, execution), + do: execute(:push_const, [index], frame, execution) + + defp execute(:push_atom_value, [atom], frame, execution), + do: push(frame, execution, resolve_atom(atom, execution)) + + defp execute(:push_this, [], frame, execution), do: push(frame, execution, frame.this) + + defp execute(:special_object, [2], frame, execution), + do: push(frame, execution, execution.current_function) + + defp execute(:special_object, [_type], frame, execution), + do: push(frame, execution, :undefined) + + defp execute(:drop, [], %{stack: [_value | stack]} = frame, execution), + do: continue(%{frame | stack: stack}, execution) + + defp execute(:dup, [], %{stack: [value | _]} = frame, execution), + do: continue(%{frame | stack: [value | frame.stack]}, execution) + + defp execute(:dup1, [], %{stack: [a, b | stack]} = frame, execution), + do: continue(%{frame | stack: [a, b, a, b | stack]}, execution) + + defp execute(:dup2, [], %{stack: [a, b | stack]} = frame, execution), + do: continue(%{frame | stack: [a, b, a, b | stack]}, execution) + + defp execute(:nip, [], %{stack: [a, _b | stack]} = frame, execution), + do: continue(%{frame | stack: [a | stack]}, execution) + + defp execute(:nip1, [], %{stack: [a, b, _c | stack]} = frame, execution), + do: continue(%{frame | stack: [a, b | stack]}, execution) + + defp execute(:swap, [], %{stack: [a, b | stack]} = frame, execution), + do: continue(%{frame | stack: [b, a | stack]}, execution) + + defp execute(:insert2, [], %{stack: [a, b | stack]} = frame, execution), + do: continue(%{frame | stack: [a, b, a | stack]}, execution) + + defp execute(:insert3, [], %{stack: [a, b, c | stack]} = frame, execution), + do: continue(%{frame | stack: [a, b, c, a | stack]}, execution) + + defp execute(:get_arg, [index], frame, execution), + do: push(frame, execution, tuple_get(frame.args, index)) + + defp execute(:get_loc, [index], frame, execution), + do: push(frame, execution, elem(frame.locals, index)) + + defp execute(:get_loc0_loc1, [first, second], frame, execution) do + stack = [elem(frame.locals, first), elem(frame.locals, second) | frame.stack] + continue(%{frame | stack: stack}, execution) + end + + defp execute(:put_loc, [index], %{stack: [value | stack]} = frame, execution) do + frame = %{frame | locals: put_elem(frame.locals, index, value), stack: stack} + continue(frame, execution) + end + + defp execute(:set_loc, [index], %{stack: [value | _]} = frame, execution) do + frame = %{frame | locals: put_elem(frame.locals, index, value)} + continue(frame, execution) + end + + defp execute(:set_loc_uninitialized, [index], frame, execution) do + frame = %{frame | locals: put_elem(frame.locals, index, :uninitialized)} + continue(frame, execution) + end + + defp execute(:get_loc_check, [index], frame, execution) do + case elem(frame.locals, index) do + :uninitialized -> {:error, {:js_throw, {:reference_error, index}}, execution} + value -> push(frame, execution, value) + end + end + + defp execute(:put_loc_check_init, [index], frame, execution), + do: execute(:put_loc, [index], frame, execution) + + defp execute(:put_loc_check, [index], frame, execution), + do: execute(:put_loc, [index], frame, execution) + + defp execute(:close_loc, [_index], frame, execution), do: continue(frame, execution) + + defp execute(:inc_loc, [index], frame, execution), + do: update_local(frame, execution, index, &Value.add(&1, 1)) + + defp execute(:dec_loc, [index], frame, execution), + do: update_local(frame, execution, index, &Value.subtract(&1, 1)) + + defp execute(:add_loc, [index], %{stack: [value | stack]} = frame, execution) do + locals = put_elem(frame.locals, index, Value.add(elem(frame.locals, index), value)) + continue(%{frame | locals: locals, stack: stack}, execution) + end + + defp execute(:if_false, [target], %{stack: [value | stack]} = frame, execution) do + pc = if Value.truthy?(value), do: frame.pc + 1, else: target + run(%{frame | pc: pc, stack: stack}, execution) + end + + defp execute(:if_false8, [target], frame, execution), + do: execute(:if_false, [target], frame, execution) + + defp execute(:if_true, [target], %{stack: [value | stack]} = frame, execution) do + pc = if Value.truthy?(value), do: target, else: frame.pc + 1 + run(%{frame | pc: pc, stack: stack}, execution) + end + + defp execute(:if_true8, [target], frame, execution), + do: execute(:if_true, [target], frame, execution) + + defp execute(:goto, [target], frame, execution), do: run(%{frame | pc: target}, execution) + defp execute(:goto8, [target], frame, execution), do: execute(:goto, [target], frame, execution) + + defp execute(:goto16, [target], frame, execution), + do: execute(:goto, [target], frame, execution) + + defp execute(:return, [], %{stack: [value | _stack]}, execution), do: {:ok, value, execution} + defp execute(:return_undef, [], _frame, execution), do: {:ok, :undefined, execution} + + defp execute(:return_async, [], %{stack: [value | _stack]}, execution), + do: {:ok, value, execution} + + defp execute(:add, [], frame, execution), do: binary(frame, execution, &Value.add/2) + defp execute(:sub, [], frame, execution), do: binary(frame, execution, &Value.subtract/2) + defp execute(:mul, [], frame, execution), do: binary(frame, execution, &Value.multiply/2) + defp execute(:div, [], frame, execution), do: binary(frame, execution, &Value.divide/2) + defp execute(:mod, [], frame, execution), do: binary(frame, execution, &Value.modulo/2) + defp execute(:pow, [], frame, execution), do: binary(frame, execution, &Value.power/2) + defp execute(:lt, [], frame, execution), do: compare(frame, execution, &Kernel./2) + defp execute(:gte, [], frame, execution), do: compare(frame, execution, &Kernel.>=/2) + defp execute(:eq, [], frame, execution), do: binary(frame, execution, &Value.abstract_equal?/2) + + defp execute(:neq, [], frame, execution), + do: binary(frame, execution, &(not Value.abstract_equal?(&1, &2))) + + defp execute(:strict_eq, [], frame, execution), + do: binary(frame, execution, &Value.strict_equal?/2) + + defp execute(:strict_neq, [], frame, execution), + do: binary(frame, execution, &(not Value.strict_equal?(&1, &2))) + + defp execute(:and, [], frame, execution), do: bitwise(frame, execution, &band/2) + defp execute(:or, [], frame, execution), do: bitwise(frame, execution, &bor/2) + defp execute(:xor, [], frame, execution), do: bitwise(frame, execution, &bxor/2) + defp execute(:shl, [], frame, execution), do: binary(frame, execution, &Value.shift_left/2) + defp execute(:sar, [], frame, execution), do: binary(frame, execution, &Value.shift_right/2) + + defp execute(:shr, [], frame, execution), + do: binary(frame, execution, &Value.shift_right_unsigned/2) + + defp execute(:neg, [], frame, execution), do: unary(frame, execution, &Value.negate/1) + defp execute(:plus, [], frame, execution), do: unary(frame, execution, &Value.to_number/1) + defp execute(:not, [], frame, execution), do: unary(frame, execution, &Value.bitwise_not/1) + defp execute(:lnot, [], frame, execution), do: unary(frame, execution, &(not Value.truthy?(&1))) + defp execute(:typeof, [], frame, execution), do: unary(frame, execution, &Value.typeof/1) + defp execute(:inc, [], frame, execution), do: unary(frame, execution, &Value.add(&1, 1)) + defp execute(:dec, [], frame, execution), do: unary(frame, execution, &Value.subtract(&1, 1)) + + defp execute(:post_inc, [], %{stack: [value | stack]} = frame, execution) do + continue(%{frame | stack: [Value.add(value, 1), value | stack]}, execution) + end + + defp execute(:post_dec, [], %{stack: [value | stack]} = frame, execution) do + continue(%{frame | stack: [Value.subtract(value, 1), value | stack]}, execution) + end + + defp execute(:fclosure, [index], frame, execution) do + function = Enum.at(frame.function.constants, index) + push(frame, execution, function) + end + + defp execute(:fclosure8, [index], frame, execution), + do: execute(:fclosure, [index], frame, execution) + + defp execute(:call, [argument_count], frame, execution), + do: call(frame, execution, argument_count, false) + + defp execute(:tail_call, [argument_count], frame, execution), + do: call(frame, execution, argument_count, true) + + defp execute(:get_var, [atom], frame, execution) do + name = resolve_atom(atom, execution) + + case Map.fetch(execution.globals, name) do + {:ok, value} -> push(frame, execution, value) + :error -> {:error, {:js_throw, {:reference_error, name}}, execution} + end + end + + defp execute(:get_var_undef, [atom], frame, execution) do + name = resolve_atom(atom, execution) + push(frame, execution, Map.get(execution.globals, name, :undefined)) + end + + defp 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 = %{execution | globals: Map.put(execution.globals, name, value)} + continue(%{frame | stack: stack}, execution) + end + + defp execute(name, [_atom | _flags], frame, execution) + when name in [:define_var, :check_define_var], + do: continue(frame, execution) + + defp execute(:throw, [], %{stack: [value | _stack]}, execution), + do: {:error, {:js_throw, value}, execution} + + defp execute(:await, [], %{stack: [{:pending, _reference} | stack]} = frame, execution) do + continuation = %Continuation{frame: next_frame(%{frame | stack: stack}), execution: execution} + {:suspended, continuation} + end + + defp execute(:await, [], %{stack: [{:resolved, value} | stack]} = frame, execution), + do: continue(%{frame | stack: [value | stack]}, execution) + + defp execute(:await, [], %{stack: [{:rejected, reason} | _stack]}, execution), + do: {:error, {:js_throw, reason}, execution} + + defp execute(:await, [], frame, execution), do: continue(frame, execution) + + defp execute(name, _operands, frame, execution) + when name in [:nop, :set_name, :set_name_computed, :check_ctor, :close_loc], + do: continue(frame, execution) + + defp execute(name, operands, _frame, execution), + do: {:error, {:unsupported_opcode, name, operands}, execution} + + defp call(%Frame{stack: stack} = frame, execution, argument_count, tail?) do + {arguments, callable_and_rest} = Enum.split(stack, argument_count) + + case callable_and_rest do + [callable | rest] -> + case invoke(callable, Enum.reverse(arguments), :undefined, execution) do + {:ok, value, execution} when tail? -> + {:ok, value, execution} + + {:ok, value, execution} -> + continue(%{frame | stack: [value | rest]}, execution) + + {:error, reason, execution} -> + {:error, reason, execution} + + {:suspended, _continuation} -> + {:error, {:unsupported, :nested_suspension}, execution} + end + + _ -> + {:error, {:invalid_stack, :call}, execution} + end + end + + defp push(frame, execution, value), + do: continue(%{frame | stack: [value | frame.stack]}, execution) + + defp continue(frame, execution), do: run(next_frame(frame), execution) + defp next_frame(frame), do: %{frame | pc: frame.pc + 1} + + defp unary(%Frame{stack: [value | stack]} = frame, execution, operation), + do: continue(%{frame | stack: [operation.(value) | stack]}, execution) + + defp binary(%Frame{stack: [right, left | stack]} = frame, execution, operation), + do: continue(%{frame | stack: [operation.(left, right) | stack]}, execution) + + defp compare(frame, execution, operation), + do: binary(frame, execution, &Value.compare(&1, &2, operation)) + + defp bitwise(frame, execution, operation), + do: binary(frame, execution, &Value.bitwise(&1, &2, operation)) + + defp update_local(frame, execution, index, operation) do + locals = put_elem(frame.locals, index, operation.(elem(frame.locals, index))) + continue(%{frame | locals: locals}, execution) + end + + defp tuple_get(tuple, index) when index < tuple_size(tuple), do: elem(tuple, index) + defp tuple_get(_tuple, _index), do: :undefined + + defp resolve_atom(:empty_string, _execution), do: "" + defp resolve_atom({:tagged_int, value}, _execution), do: value + + defp resolve_atom({:predefined, index}, _execution), + do: PredefinedAtoms.lookup(index) || {:predefined, index} + + defp 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 + + defp resolve_atom(value, _execution), do: value +end diff --git a/lib/quickbeam/vm/opcodes.ex b/lib/quickbeam/vm/opcodes.ex new file mode 100644 index 000000000..812c92b62 --- /dev/null +++ b/lib/quickbeam/vm/opcodes.ex @@ -0,0 +1,173 @@ +defmodule QuickBEAM.VM.Opcodes 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 false + 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 false + 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/predefined_atoms.ex b/lib/quickbeam/vm/predefined_atoms.ex new file mode 100644 index 000000000..366c31599 --- /dev/null +++ b/lib/quickbeam/vm/predefined_atoms.ex @@ -0,0 +1,12 @@ +defmodule QuickBEAM.VM.PredefinedAtoms 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/program.ex b/lib/quickbeam/vm/program.ex new file mode 100644 index 000000000..9259e404c --- /dev/null +++ b/lib/quickbeam/vm/program.ex @@ -0,0 +1,13 @@ +defmodule QuickBEAM.VM.Program do + @moduledoc "Compiled or decoded JavaScript program: atom table plus top-level VM value." + + @enforce_keys [:version, :fingerprint, :atoms, :root] + defstruct [:version, :fingerprint, :atoms, :root] + + @type t :: %__MODULE__{ + version: non_neg_integer(), + fingerprint: String.t(), + atoms: tuple(), + root: term() + } +end diff --git a/lib/quickbeam/vm/source_position.ex b/lib/quickbeam/vm/source_position.ex new file mode 100644 index 000000000..278c2619e --- /dev/null +++ b/lib/quickbeam/vm/source_position.ex @@ -0,0 +1,12 @@ +defmodule QuickBEAM.VM.SourcePosition do + @moduledoc "Resolves VM instruction positions to source line and column metadata." + + @doc "Resolves a VM function instruction index to source line and column information." + def source_position(%QuickBEAM.VM.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.Function{} = fun, _insn_index), + do: {fun.line_num, fun.col_num} +end diff --git a/lib/quickbeam/vm/value.ex b/lib/quickbeam/vm/value.ex new file mode 100644 index 000000000..ec3186033 --- /dev/null +++ b/lib/quickbeam/vm/value.ex @@ -0,0 +1,166 @@ +defmodule QuickBEAM.VM.Value do + @moduledoc false + + import Bitwise + + 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 + + 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 + + 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) + + 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: numeric_binary(a, b, &Kernel.+/2) + def subtract(a, b), do: numeric_binary(a, b, &Kernel.-/2) + def multiply(a, b), do: numeric_binary(a, b, &Kernel.*/2) + + def divide(a, b) do + a = to_number(a) + b = to_number(b) + + cond do + a == :nan or b == :nan -> :nan + b == 0 and a == 0 -> :nan + b == 0 and a > 0 -> :infinity + b == 0 and a < 0 -> :neg_infinity + true -> a / b + end + end + + def modulo(a, b) do + a = to_number(a) + b = to_number(b) + + cond do + a == :nan or b == :nan or b == 0 -> :nan + is_integer(a) and is_integer(b) -> rem(a, b) + true -> :math.fmod(a, b) + end + end + + def power(a, b) do + case {to_number(a), to_number(b)} do + {:nan, _} -> :nan + {_, :nan} -> :nan + {left, right} -> :math.pow(left, right) + end + end + + def negate(value) do + case to_number(value) do + :nan -> :nan + number -> -number + end + end + + def compare(a, b, operation) do + {a, b} = + if is_binary(a) and is_binary(b), + do: {a, b}, + else: {to_number(a), to_number(b)} + + if a == :nan or b == :nan do + false + else + operation.(a, b) + end + end + + def bitwise(a, b, operation), do: operation.(to_int32(a), to_int32(b)) + def shift_left(a, b), do: bsl(to_int32(a), band(to_int32(b), 31)) + def shift_right(a, b), do: bsr(to_int32(a), band(to_int32(b), 31)) + def shift_right_unsigned(a, b), do: bsr(band(to_int32(a), 0xFFFFFFFF), band(to_int32(b), 31)) + def bitwise_not(value), do: bnot(to_int32(value)) + + 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.Function{}), do: "function" + def typeof({:closure, %QuickBEAM.VM.Function{}, _captures}), do: "function" + def typeof(_value), do: "object" + + 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(""), do: 0 + + def to_number(value) when is_binary(value) do + value = String.trim(value) + + case Integer.parse(value) do + {integer, ""} -> + integer + + _ -> + case Float.parse(value) do + {float, ""} -> float + _ -> :nan + end + end + end + + def to_number(_value), do: :nan + + 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 + + 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), do: Float.to_string(value) + def to_string_value(value) when is_binary(value), do: value + def to_string_value(_value), do: "[object Object]" + + defp numeric_binary(a, b, operation) do + case {to_number(a), to_number(b)} do + {:nan, _} -> :nan + {_, :nan} -> :nan + {left, right} -> operation.(left, right) + end + end + + defp signed32(value) do + value = band(value, 0xFFFFFFFF) + if value >= 0x80000000, do: value - 0x100000000, else: value + end +end diff --git a/lib/quickbeam/vm/variable.ex b/lib/quickbeam/vm/variable.ex new file mode 100644 index 000000000..1a616f728 --- /dev/null +++ b/lib/quickbeam/vm/variable.ex @@ -0,0 +1,14 @@ +defmodule QuickBEAM.VM.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 + ] +end diff --git a/lib/quickbeam/vm/varint.ex b/lib/quickbeam/vm/varint.ex new file mode 100644 index 000000000..9f11cc2e5 --- /dev/null +++ b/lib/quickbeam/vm/varint.ex @@ -0,0 +1,79 @@ +defmodule QuickBEAM.VM.Varint do + @moduledoc """ + Bounded QuickJS integer decoding backed by `Varint.LEB128` and + `Varint.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 + + @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 + + @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, value, rest} <- decode_signed(binary), + true <- value >= @min_i32 and value <= @max_i32 do + {:ok, value, rest} + else + false -> {:error, :integer_overflow} + {:error, _} = error -> error + end + end + + @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} + + @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 + try do + {value, rest} = Varint.LEB128.decode(binary) + {:ok, value, rest} + rescue + ArgumentError -> {:error, :bad_leb128} + end + end + + defp decode_signed(binary) do + try do + {value, rest} = Varint.SLEB128.decode(binary) + {:ok, value, rest} + rescue + ArgumentError -> {:error, :bad_sleb128} + end + 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/verifier.ex b/lib/quickbeam/vm/verifier.ex new file mode 100644 index 000000000..57023e356 --- /dev/null +++ b/lib/quickbeam/vm/verifier.ex @@ -0,0 +1,259 @@ +defmodule QuickBEAM.VM.Verifier do + @moduledoc false + + alias QuickBEAM.VM.{ABI, Function, Opcodes, Program} + + @js_atom_end Opcodes.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 + } + + @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 <- within(tuple_size(program.atoms), limits.max_atoms, :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} + + defp limits(opts) do + Enum.reduce_while(opts, {:ok, @default_limits}, fn + {key, value}, {:ok, limits} + when is_map_key(@default_limits, key) and is_integer(value) and value > 0 -> + {:cont, {:ok, Map.put(limits, key, value)}} + + {key, _value}, _acc when is_map_key(@default_limits, key) -> + {:halt, {:error, {:invalid_limit, key}}} + + {key, _value}, _acc -> + {:halt, {:error, {:unknown_option, key}}} + end) + end + + 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 <- 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, counts} <- add_counts(function, limits, counts) do + Enum.reduce_while(function.constants, {:ok, counts}, fn constant, {:ok, counts} -> + case verify_value(constant, atoms, limits, depth + 1, counts) do + {:ok, counts} -> {:cont, {:ok, counts}} + {:error, _} = error -> {:halt, error} + end + end) + end + end + + defp verify_value({:array, values}, atoms, limits, depth, counts), + do: verify_values(values, atoms, limits, depth, counts) + + defp verify_value({:object, values}, atoms, limits, depth, counts), + do: verify_values(Map.values(values), atoms, limits, depth, counts) + + defp verify_value({:template_object, {:array, values}, raw}, atoms, limits, depth, counts) do + with {:ok, counts} <- verify_values(values, atoms, limits, depth, counts) do + verify_value(raw, atoms, limits, depth, counts) + end + end + + 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_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}} + + 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}} + + invalid_non_negative_fields?(function) -> + {:error, {:invalid_function, function.id, :negative_count}} + + true -> + verify_capture_indexes(function) + 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_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 Opcodes.info(opcode) do + nil -> + {:error, {:unknown_opcode, opcode}} + + {name, _size, _pops, _pushes, format} -> + with :ok <- verify_operand_count(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_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_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) do + verify_secondary_operand(name, operands, function) + end + end + + 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 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/mix.exs b/mix.exs index f1c15ce79..1f6512892 100644 --- a/mix.exs +++ b/mix.exs @@ -70,6 +70,7 @@ 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"}, + {:varint, "~> 1.6"}, {:oxc, "~> 0.17.2"}, {:npm, "~> 0.7.5", optional: true}, {:mint_web_socket, "~> 1.0"}, diff --git a/mix.lock b/mix.lock index be257bcab..74e5ac196 100644 --- a/mix.lock +++ b/mix.lock @@ -39,6 +39,7 @@ "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"}, "zig_get": {:hex, :zig_get, "0.15.2", "a6ccaa894213839ba95615bf9be2b2c9268e37ab9547a2344830202cfd6d7cc0", [:mix], [], "hexpm", "e6b0028f2d5a8da791ff8037deff5b492784017d8d241e598377a24bd765f56f"}, diff --git a/test/vm/abi_test.exs b/test/vm/abi_test.exs new file mode 100644 index 000000000..1f0f6ef45 --- /dev/null +++ b/test/vm/abi_test.exs @@ -0,0 +1,22 @@ +defmodule QuickBEAM.VM.ABITest do + use ExUnit.Case, async: true + + alias QuickBEAM.VM.{ABI, Opcodes} + + test "metadata is generated from the current vendored QuickJS sources" do + assert ABI.bytecode_version() == 26 + assert byte_size(ABI.fingerprint()) == 64 + assert Opcodes.bc_version() == ABI.bytecode_version() + assert Opcodes.num(:check_object) != nil + assert Opcodes.num(:using_dispose) != nil + assert Opcodes.info(Opcodes.num(:await)) == {:await, 1, 1, 1, :none} + 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 Opcodes.js_atom_end() == map_size(atoms) + 1 + end +end diff --git a/test/vm/decoder_test.exs b/test/vm/decoder_test.exs new file mode 100644 index 000000000..69629c099 --- /dev/null +++ b/test/vm/decoder_test.exs @@ -0,0 +1,178 @@ +defmodule QuickBEAM.VM.DecoderTest do + use ExUnit.Case, async: true + + alias QuickBEAM.VM.{ABI, Checksum, Function, InstructionDecoder, Opcodes, Program, Verifier} + + 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 + assert {:ok, %Program{} = program} = + QuickBEAM.VM.compile("function render(){ return 'ok' } render()", + filename: "server.js" + ) + + 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 %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 = Opcodes.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.SLEB128.encode(42) + ]) + + assert {:ok, %Program{root: {:object, %{^key => 42}}}} = + payload |> bytecode_envelope() |> QuickBEAM.VM.decode() + 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 "verifier rejects an invalid constant index", %{runtime: runtime} do + {:ok, bytecode} = QuickBEAM.compile(runtime, "42") + {:ok, program} = QuickBEAM.VM.decode(bytecode) + opcode = Opcodes.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 "instruction decoder rejects labels inside an instruction" do + goto = Opcodes.num(:goto) + + assert {:error, {:invalid_label, 1}} = + InstructionDecoder.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/interpreter_test.exs b/test/vm/interpreter_test.exs new file mode 100644 index 000000000..a389b26ad --- /dev/null +++ b/test/vm/interpreter_test.exs @@ -0,0 +1,87 @@ +defmodule QuickBEAM.VM.InterpreterTest do + use ExUnit.Case, async: true + + alias QuickBEAM.VM.{Continuation, Interpreter, Opcodes, Program} + + 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 "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 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 "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 "captures and resumes an explicit await continuation" do + assert {:ok, %Program{} = program} = QuickBEAM.VM.compile("0") + reference = make_ref() + root = program.root + + instructions = { + {Opcodes.num(:push_const), [0]}, + {Opcodes.num(:await), []}, + {Opcodes.num(:return), []} + } + + root = %{ + root + | constants: [{:pending, reference}], + instructions: instructions, + source_positions: {{1, 1}, {1, 1}, {1, 1}} + } + + assert {:suspended, %Continuation{} = continuation} = + Interpreter.eval(%{program | root: root}, max_steps: 10) + + assert {:ok, "resumed"} = Interpreter.resume(continuation, {:ok, "resumed"}) + end + + test "reports unsupported opcodes without crashing the caller" do + assert {:ok, program} = QuickBEAM.VM.compile("({answer: 42})") + assert {:error, {:unsupported_opcode, _opcode, _operands}} = QuickBEAM.VM.eval(program) + assert Process.alive?(self()) + end +end diff --git a/test/vm/leb128_test.exs b/test/vm/leb128_test.exs new file mode 100644 index 000000000..fdb517dfe --- /dev/null +++ b/test/vm/leb128_test.exs @@ -0,0 +1,31 @@ +defmodule QuickBEAM.VM.VarintTest do + use ExUnit.Case, async: true + + alias QuickBEAM.VM.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 "delegates signed decoding to Varint.SLEB128 and preserves the remainder" do + encoded = Varint.SLEB128.encode(-624_485) + 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.SLEB128.encode(0x8000_0000)) + end +end From e810933af20e47cbd45a85753ad0aa011091e3de Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 10 Jul 2026 12:09:11 +0200 Subject: [PATCH 02/87] Add resumable frame stacks and closure cells --- lib/quickbeam/vm/execution.ex | 12 +- lib/quickbeam/vm/frame.ex | 6 +- lib/quickbeam/vm/interpreter.ex | 258 ++++++++++++++++++++++++-------- test/vm/interpreter_test.exs | 59 +++++--- 4 files changed, 243 insertions(+), 92 deletions(-) diff --git a/lib/quickbeam/vm/execution.ex b/lib/quickbeam/vm/execution.ex index e4c91e0ee..1d45b48f4 100644 --- a/lib/quickbeam/vm/execution.ex +++ b/lib/quickbeam/vm/execution.ex @@ -4,20 +4,24 @@ defmodule QuickBEAM.VM.Execution do @enforce_keys [:atoms, :max_stack_depth, :remaining_steps, :step_limit] defstruct [ :atoms, - :current_function, :step_limit, + callers: [], + cells: %{}, + depth: 1, globals: %{}, - depth: 0, max_stack_depth: 1_000, + next_cell_id: 0, remaining_steps: 0 ] @type t :: %__MODULE__{ atoms: tuple(), - current_function: QuickBEAM.VM.Function.t() | nil, + callers: [QuickBEAM.VM.Frame.t()], + cells: %{optional(non_neg_integer()) => term()}, + depth: pos_integer(), globals: map(), - depth: non_neg_integer(), max_stack_depth: pos_integer(), + next_cell_id: non_neg_integer(), remaining_steps: non_neg_integer(), step_limit: pos_integer() } diff --git a/lib/quickbeam/vm/frame.ex b/lib/quickbeam/vm/frame.ex index 51cfb1f4a..a904fb250 100644 --- a/lib/quickbeam/vm/frame.ex +++ b/lib/quickbeam/vm/frame.ex @@ -1,11 +1,13 @@ defmodule QuickBEAM.VM.Frame do @moduledoc false - @enforce_keys [:function, :locals, :args] - defstruct [:function, :locals, :args, :this, pc: 0, stack: []] + @enforce_keys [:function, :callable, :locals, :args] + defstruct [:function, :callable, :locals, :args, :this, closure_refs: {}, pc: 0, stack: []] @type t :: %__MODULE__{ function: QuickBEAM.VM.Function.t(), + callable: term(), + closure_refs: tuple(), locals: tuple(), args: tuple(), this: term(), diff --git a/lib/quickbeam/vm/interpreter.ex b/lib/quickbeam/vm/interpreter.ex index f87cb0bc5..106523a57 100644 --- a/lib/quickbeam/vm/interpreter.ex +++ b/lib/quickbeam/vm/interpreter.ex @@ -34,7 +34,9 @@ defmodule QuickBEAM.VM.Interpreter do step_limit: max_steps } - case invoke(program.root, [], :undefined, execution) do + frame = new_frame(program.root, program.root, [], :undefined, {}) + + case run(frame, execution) do {:ok, value, _execution} -> {:ok, value} {:error, reason, _execution} -> {:error, reason} {:suspended, continuation} -> {:suspended, continuation} @@ -54,48 +56,43 @@ defmodule QuickBEAM.VM.Interpreter do def resume(%Continuation{}, {:error, reason}), do: {:error, {:js_throw, reason}} - defp invoke(%Function{} = function, args, this, %Execution{} = execution) do - if execution.depth >= execution.max_stack_depth do - {:error, {:limit_exceeded, :stack_depth, execution.depth + 1}, execution} - else - previous_function = execution.current_function - - execution = %{ - execution - | current_function: function, - depth: execution.depth + 1 - } - - local_count = max(function.arg_count + function.var_count, 1) - - frame = %Frame{ - function: function, - locals: :erlang.make_tuple(local_count, :undefined), - args: List.to_tuple(args), - this: this - } + defp enter_call(callable, args, this, caller, execution, tail?) do + with {:ok, function, closure_refs} <- callable_parts(callable) do + depth = if tail?, do: execution.depth, else: execution.depth + 1 - case run(frame, execution) do - {:ok, value, execution} -> - {:ok, value, restore_call(execution, previous_function)} + 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} - {:error, reason, execution} -> - {:error, reason, restore_call(execution, previous_function)} - - {:suspended, continuation} -> - {:suspended, continuation} + run(new_frame(function, callable, args, this, closure_refs), execution) end + else + {:error, reason} -> {:error, reason, execution} end end - defp invoke({:closure, %Function{} = function, _captures}, args, this, execution), - do: invoke(function, args, this, execution) + 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}} - defp invoke(value, _args, _this, execution), - do: {:error, {:not_callable, value}, execution} + defp new_frame(function, callable, args, this, closure_refs) do + local_count = max(function.arg_count + function.var_count, 1) - defp restore_call(execution, previous_function) do - %{execution | current_function: previous_function, depth: execution.depth - 1} + %Frame{ + function: function, + callable: callable, + closure_refs: closure_refs, + locals: :erlang.make_tuple(local_count, :undefined), + args: List.to_tuple(args), + this: this + } end defp run(_frame, %Execution{remaining_steps: 0} = execution), @@ -136,7 +133,7 @@ defmodule QuickBEAM.VM.Interpreter do defp execute(:push_this, [], frame, execution), do: push(frame, execution, frame.this) defp execute(:special_object, [2], frame, execution), - do: push(frame, execution, execution.current_function) + do: push(frame, execution, frame.callable) defp execute(:special_object, [_type], frame, execution), do: push(frame, execution, :undefined) @@ -169,33 +166,44 @@ defmodule QuickBEAM.VM.Interpreter do do: continue(%{frame | stack: [a, b, c, a | stack]}, execution) defp execute(:get_arg, [index], frame, execution), - do: push(frame, execution, tuple_get(frame.args, index)) + do: push(frame, execution, read_slot(tuple_get(frame.args, index), execution)) + + defp execute(:put_arg, [index], %{stack: [value | stack]} = frame, execution) do + {args, execution} = write_tuple_slot(frame.args, index, value, execution) + continue(%{frame | args: args, stack: stack}, execution) + end + + defp execute(:set_arg, [index], %{stack: [value | _]} = frame, execution) do + {args, execution} = write_tuple_slot(frame.args, index, value, execution) + continue(%{frame | args: args}, execution) + end defp execute(:get_loc, [index], frame, execution), - do: push(frame, execution, elem(frame.locals, index)) + do: push(frame, execution, read_slot(elem(frame.locals, index), execution)) defp execute(:get_loc0_loc1, [first, second], frame, execution) do - stack = [elem(frame.locals, first), elem(frame.locals, second) | frame.stack] - continue(%{frame | stack: stack}, execution) + first = read_slot(elem(frame.locals, first), execution) + second = read_slot(elem(frame.locals, second), execution) + continue(%{frame | stack: [first, second | frame.stack]}, execution) end defp execute(:put_loc, [index], %{stack: [value | stack]} = frame, execution) do - frame = %{frame | locals: put_elem(frame.locals, index, value), stack: stack} - continue(frame, execution) + {locals, execution} = write_tuple_slot(frame.locals, index, value, execution) + continue(%{frame | locals: locals, stack: stack}, execution) end defp execute(:set_loc, [index], %{stack: [value | _]} = frame, execution) do - frame = %{frame | locals: put_elem(frame.locals, index, value)} - continue(frame, execution) + {locals, execution} = write_tuple_slot(frame.locals, index, value, execution) + continue(%{frame | locals: locals}, execution) end defp execute(:set_loc_uninitialized, [index], frame, execution) do - frame = %{frame | locals: put_elem(frame.locals, index, :uninitialized)} - continue(frame, execution) + {locals, execution} = write_tuple_slot(frame.locals, index, :uninitialized, execution) + continue(%{frame | locals: locals}, execution) end defp execute(:get_loc_check, [index], frame, execution) do - case elem(frame.locals, index) do + case read_slot(elem(frame.locals, index), execution) do :uninitialized -> {:error, {:js_throw, {:reference_error, index}}, execution} value -> push(frame, execution, value) end @@ -216,7 +224,11 @@ defmodule QuickBEAM.VM.Interpreter do do: update_local(frame, execution, index, &Value.subtract(&1, 1)) defp execute(:add_loc, [index], %{stack: [value | stack]} = frame, execution) do - locals = put_elem(frame.locals, index, Value.add(elem(frame.locals, index), value)) + current = read_slot(elem(frame.locals, index), execution) + + {locals, execution} = + write_tuple_slot(frame.locals, index, Value.add(current, value), execution) + continue(%{frame | locals: locals, stack: stack}, execution) end @@ -242,11 +254,14 @@ defmodule QuickBEAM.VM.Interpreter do defp execute(:goto16, [target], frame, execution), do: execute(:goto, [target], frame, execution) - defp execute(:return, [], %{stack: [value | _stack]}, execution), do: {:ok, value, execution} - defp execute(:return_undef, [], _frame, execution), do: {:ok, :undefined, execution} + defp execute(:return, [], %{stack: [value | _stack]}, execution), + do: return_value(value, execution) + + defp execute(:return_undef, [], _frame, execution), + do: return_value(:undefined, execution) defp execute(:return_async, [], %{stack: [value | _stack]}, execution), - do: {:ok, value, execution} + do: return_value(value, execution) defp execute(:add, [], frame, execution), do: binary(frame, execution, &Value.add/2) defp execute(:sub, [], frame, execution), do: binary(frame, execution, &Value.subtract/2) @@ -296,7 +311,8 @@ defmodule QuickBEAM.VM.Interpreter do defp execute(:fclosure, [index], frame, execution) do function = Enum.at(frame.function.constants, index) - push(frame, execution, function) + {callable, frame, execution} = capture_closure(function, frame, execution) + push(frame, execution, callable) end defp execute(:fclosure8, [index], frame, execution), @@ -308,6 +324,41 @@ defmodule QuickBEAM.VM.Interpreter do defp execute(:tail_call, [argument_count], frame, execution), do: call(frame, execution, argument_count, true) + defp execute(name, [index], frame, execution) + when name in [ + :get_var_ref, + :get_var_ref0, + :get_var_ref1, + :get_var_ref2, + :get_var_ref3, + :get_var_ref_check + ] do + value = read_reference(elem(frame.closure_refs, index), execution) + + if name == :get_var_ref_check and value == :uninitialized, + do: {:error, {:js_throw, {:reference_error, index}}, execution}, + else: push(frame, execution, value) + end + + defp execute(name, [index], %{stack: [value | stack]} = frame, execution) + when name in [ + :put_var_ref, + :put_var_ref0, + :put_var_ref1, + :put_var_ref2, + :put_var_ref3, + :put_var_ref_check, + :put_var_ref_check_init + ] do + execution = write_reference(elem(frame.closure_refs, index), value, execution) + continue(%{frame | stack: stack}, execution) + end + + defp execute(:set_var_ref, [index], %{stack: [value | _]} = frame, execution) do + execution = write_reference(elem(frame.closure_refs, index), value, execution) + continue(frame, execution) + end + defp execute(:get_var, [atom], frame, execution) do name = resolve_atom(atom, execution) @@ -361,19 +412,8 @@ defmodule QuickBEAM.VM.Interpreter do case callable_and_rest do [callable | rest] -> - case invoke(callable, Enum.reverse(arguments), :undefined, execution) do - {:ok, value, execution} when tail? -> - {:ok, value, execution} - - {:ok, value, execution} -> - continue(%{frame | stack: [value | rest]}, execution) - - {:error, reason, execution} -> - {:error, reason, execution} - - {:suspended, _continuation} -> - {:error, {:unsupported, :nested_suspension}, execution} - end + caller = %{next_frame(frame) | stack: rest} + enter_call(callable, Enum.reverse(arguments), :undefined, caller, execution, tail?) _ -> {:error, {:invalid_stack, :call}, execution} @@ -398,11 +438,97 @@ defmodule QuickBEAM.VM.Interpreter do defp bitwise(frame, execution, operation), do: binary(frame, execution, &Value.bitwise(&1, &2, operation)) + defp return_value(value, %Execution{callers: []} = execution), + do: {:ok, value, execution} + + defp return_value(value, %Execution{callers: [caller | callers]} = execution) do + execution = %{execution | callers: callers, depth: execution.depth - 1} + run(%{caller | stack: [value | caller.stack]}, execution) + end + defp update_local(frame, execution, index, operation) do - locals = put_elem(frame.locals, index, operation.(elem(frame.locals, index))) + value = read_slot(elem(frame.locals, index), execution) + {locals, execution} = write_tuple_slot(frame.locals, index, operation.(value), execution) continue(%{frame | locals: locals}, execution) 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 = %{ + execution + | cells: Map.put(execution.cells, id, value), + next_cell_id: id + 1 + } + + {reference, put_elem(tuple, index, reference), execution} + end + end + + defp read_slot({:cell, _id} = reference, execution), + do: read_reference(reference, execution) + + defp read_slot({:global, _name} = reference, execution), + do: read_reference(reference, execution) + + defp read_slot(value, _execution), do: value + + 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 diff --git a/test/vm/interpreter_test.exs b/test/vm/interpreter_test.exs index a389b26ad..3311fc50a 100644 --- a/test/vm/interpreter_test.exs +++ b/test/vm/interpreter_test.exs @@ -1,7 +1,7 @@ defmodule QuickBEAM.VM.InterpreterTest do use ExUnit.Case, async: true - alias QuickBEAM.VM.{Continuation, Interpreter, Opcodes, Program} + alias QuickBEAM.VM.{Continuation, Interpreter, Program} test "evaluates arithmetic and comparisons in an isolated BEAM process" do assert {:ok, program} = QuickBEAM.VM.compile("(2 + 3 * 4) === 14") @@ -19,6 +19,31 @@ defmodule QuickBEAM.VM.InterpreterTest do 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 "injects independent globals for each evaluation" do assert {:ok, program} = QuickBEAM.VM.compile("input + 1") @@ -38,13 +63,19 @@ defmodule QuickBEAM.VM.InterpreterTest do end test "enforces the JavaScript call-stack depth independently of the BEAM stack" do - source = "(function recurse(n){return recurse(n+1)})(0)" + 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) {}") @@ -55,27 +86,15 @@ defmodule QuickBEAM.VM.InterpreterTest do assert {:ok, 42} = QuickBEAM.VM.eval(finite) end - test "captures and resumes an explicit await continuation" do - assert {:ok, %Program{} = program} = QuickBEAM.VM.compile("0") - reference = make_ref() - root = program.root - - instructions = { - {Opcodes.num(:push_const), [0]}, - {Opcodes.num(:await), []}, - {Opcodes.num(:return), []} - } - - root = %{ - root - | constants: [{:pending, reference}], - instructions: instructions, - source_positions: {{1, 1}, {1, 1}, {1, 1}} - } + 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 | root: root}, max_steps: 10) + Interpreter.eval(program, vars: %{"marker" => pending}, max_steps: 100) + assert [_caller] = continuation.execution.callers assert {:ok, "resumed"} = Interpreter.resume(continuation, {:ok, "resumed"}) end From 7d53aa08b86956cb4ef45a684b03582da5a3bcca Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 10 Jul 2026 12:13:22 +0200 Subject: [PATCH 03/87] Add JavaScript exception unwinding --- lib/quickbeam/vm/interpreter.ex | 76 ++++++++++++++++++++++++--------- test/vm/interpreter_test.exs | 54 +++++++++++++++++++++++ 2 files changed, 111 insertions(+), 19 deletions(-) diff --git a/lib/quickbeam/vm/interpreter.ex b/lib/quickbeam/vm/interpreter.ex index 106523a57..8bb331999 100644 --- a/lib/quickbeam/vm/interpreter.ex +++ b/lib/quickbeam/vm/interpreter.ex @@ -36,25 +36,23 @@ defmodule QuickBEAM.VM.Interpreter do frame = new_frame(program.root, program.root, [], :undefined, {}) - case run(frame, execution) do - {:ok, value, _execution} -> {:ok, value} - {:error, reason, _execution} -> {:error, reason} - {:suspended, continuation} -> {:suspended, continuation} - end + normalize_result(run(frame, execution)) end @spec resume(Continuation.t(), {:ok, term()} | {:error, term()}) :: result() def resume(%Continuation{} = continuation, {:ok, value}) do frame = %{continuation.frame | stack: [value | continuation.frame.stack]} - case run(frame, continuation.execution) do - {:ok, result, _execution} -> {:ok, result} - {:error, reason, _execution} -> {:error, reason} - {:suspended, continuation} -> {:suspended, continuation} - end + normalize_result(run(frame, continuation.execution)) + end + + def resume(%Continuation{} = continuation, {:error, reason}) do + normalize_result(raise_js(reason, continuation.frame, continuation.execution)) end - def resume(%Continuation{}, {:error, reason}), do: {:error, {:js_throw, reason}} + defp normalize_result({:ok, value, _execution}), do: {:ok, value} + defp normalize_result({:error, reason, _execution}), do: {:error, reason} + defp normalize_result({:suspended, continuation}), do: {:suspended, continuation} defp enter_call(callable, args, this, caller, execution, tail?) do with {:ok, function, closure_refs} <- callable_parts(callable) do @@ -71,7 +69,7 @@ defmodule QuickBEAM.VM.Interpreter do run(new_frame(function, callable, args, this, closure_refs), execution) end else - {:error, reason} -> {:error, reason, execution} + {:error, reason} -> raise_js(reason, caller, execution) end end @@ -153,6 +151,9 @@ defmodule QuickBEAM.VM.Interpreter do defp execute(:nip, [], %{stack: [a, _b | stack]} = frame, execution), do: continue(%{frame | stack: [a | stack]}, execution) + defp execute(:nip_catch, [], frame, execution), + do: execute(:nip, [], frame, execution) + defp execute(:nip1, [], %{stack: [a, b, _c | stack]} = frame, execution), do: continue(%{frame | stack: [a, b | stack]}, execution) @@ -204,7 +205,7 @@ defmodule QuickBEAM.VM.Interpreter do defp execute(:get_loc_check, [index], frame, execution) do case read_slot(elem(frame.locals, index), execution) do - :uninitialized -> {:error, {:js_throw, {:reference_error, index}}, execution} + :uninitialized -> raise_js({:reference_error, index}, frame, execution) value -> push(frame, execution, value) end end @@ -232,6 +233,18 @@ defmodule QuickBEAM.VM.Interpreter do continue(%{frame | locals: locals, stack: stack}, execution) end + defp execute(:catch, [target], frame, execution) do + continue(%{frame | stack: [{:catch, target} | frame.stack]}, execution) + end + + defp execute(:gosub, [target], frame, execution) do + return_address = {:return_address, frame.pc + 1} + run(%{frame | pc: target, stack: [return_address | frame.stack]}, execution) + end + + defp execute(:ret, [], %{stack: [{:return_address, target} | stack]} = frame, execution), + do: run(%{frame | pc: target, stack: stack}, execution) + defp execute(:if_false, [target], %{stack: [value | stack]} = frame, execution) do pc = if Value.truthy?(value), do: frame.pc + 1, else: target run(%{frame | pc: pc, stack: stack}, execution) @@ -336,7 +349,7 @@ defmodule QuickBEAM.VM.Interpreter do value = read_reference(elem(frame.closure_refs, index), execution) if name == :get_var_ref_check and value == :uninitialized, - do: {:error, {:js_throw, {:reference_error, index}}, execution}, + do: raise_js({:reference_error, index}, frame, execution), else: push(frame, execution, value) end @@ -364,7 +377,7 @@ defmodule QuickBEAM.VM.Interpreter do case Map.fetch(execution.globals, name) do {:ok, value} -> push(frame, execution, value) - :error -> {:error, {:js_throw, {:reference_error, name}}, execution} + :error -> raise_js({:reference_error, name}, frame, execution) end end @@ -384,8 +397,8 @@ defmodule QuickBEAM.VM.Interpreter do when name in [:define_var, :check_define_var], do: continue(frame, execution) - defp execute(:throw, [], %{stack: [value | _stack]}, execution), - do: {:error, {:js_throw, value}, execution} + defp execute(:throw, [], %{stack: [value | stack]} = frame, execution), + do: raise_js(value, %{frame | stack: stack}, execution) defp execute(:await, [], %{stack: [{:pending, _reference} | stack]} = frame, execution) do continuation = %Continuation{frame: next_frame(%{frame | stack: stack}), execution: execution} @@ -395,8 +408,8 @@ defmodule QuickBEAM.VM.Interpreter do defp execute(:await, [], %{stack: [{:resolved, value} | stack]} = frame, execution), do: continue(%{frame | stack: [value | stack]}, execution) - defp execute(:await, [], %{stack: [{:rejected, reason} | _stack]}, execution), - do: {:error, {:js_throw, reason}, execution} + defp execute(:await, [], %{stack: [{:rejected, reason} | stack]} = frame, execution), + do: raise_js(reason, %{frame | stack: stack}, execution) defp execute(:await, [], frame, execution), do: continue(frame, execution) @@ -438,6 +451,31 @@ defmodule QuickBEAM.VM.Interpreter do defp bitwise(frame, execution, operation), do: binary(frame, execution, &Value.bitwise(&1, &2, operation)) + defp raise_js(reason, frame, execution) do + case split_at_catch(frame.stack) do + {:caught, target, stack_below_catch} -> + run(%{frame | pc: target, stack: [reason | stack_below_catch]}, execution) + + :uncaught -> + unwind_caller(reason, execution) + end + end + + defp unwind_caller(reason, %Execution{callers: []} = execution), + do: {:error, {:js_throw, reason}, execution} + + defp unwind_caller(reason, %Execution{callers: [caller | callers]} = execution) do + execution = %{execution | callers: callers, depth: execution.depth - 1} + raise_js(reason, caller, execution) + end + + 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 return_value(value, %Execution{callers: []} = execution), do: {:ok, value, execution} diff --git a/test/vm/interpreter_test.exs b/test/vm/interpreter_test.exs index 3311fc50a..d4a78f52b 100644 --- a/test/vm/interpreter_test.exs +++ b/test/vm/interpreter_test.exs @@ -86,6 +86,41 @@ defmodule QuickBEAM.VM.InterpreterTest do 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, {:js_throw, 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) @@ -98,6 +133,25 @@ defmodule QuickBEAM.VM.InterpreterTest do 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() { return await marker })() + } catch (error) { + return error + 1 + } + })() + """ + + assert {:ok, program} = QuickBEAM.VM.compile(source) + + assert {:suspended, continuation} = + Interpreter.eval(program, vars: %{"marker" => {:pending, make_ref()}}) + + assert {:ok, 42} = Interpreter.resume(continuation, {:error, 41}) + end + test "reports unsupported opcodes without crashing the caller" do assert {:ok, program} = QuickBEAM.VM.compile("({answer: 42})") assert {:error, {:unsupported_opcode, _opcode, _operands}} = QuickBEAM.VM.eval(program) From 09aa0cdb0652376a9b386e80802ab1d7b232e03c Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 10 Jul 2026 12:19:31 +0200 Subject: [PATCH 04/87] Add process-owned object heap --- lib/quickbeam/vm/execution.ex | 4 + lib/quickbeam/vm/export.ex | 81 ++++++++++++++ lib/quickbeam/vm/heap.ex | 165 +++++++++++++++++++++++++++++ lib/quickbeam/vm/interpreter.ex | 182 +++++++++++++++++++++++++++++++- lib/quickbeam/vm/object.ex | 14 +++ lib/quickbeam/vm/property.ex | 12 +++ lib/quickbeam/vm/reference.ex | 8 ++ lib/quickbeam/vm/value.ex | 1 + test/vm/heap_test.exs | 49 +++++++++ test/vm/interpreter_test.exs | 20 +++- 10 files changed, 533 insertions(+), 3 deletions(-) create mode 100644 lib/quickbeam/vm/export.ex create mode 100644 lib/quickbeam/vm/heap.ex create mode 100644 lib/quickbeam/vm/object.ex create mode 100644 lib/quickbeam/vm/property.ex create mode 100644 lib/quickbeam/vm/reference.ex create mode 100644 test/vm/heap_test.exs diff --git a/lib/quickbeam/vm/execution.ex b/lib/quickbeam/vm/execution.ex index 1d45b48f4..8ec1e5834 100644 --- a/lib/quickbeam/vm/execution.ex +++ b/lib/quickbeam/vm/execution.ex @@ -9,8 +9,10 @@ defmodule QuickBEAM.VM.Execution do cells: %{}, depth: 1, globals: %{}, + heap: %{}, max_stack_depth: 1_000, next_cell_id: 0, + next_object_id: 0, remaining_steps: 0 ] @@ -20,8 +22,10 @@ defmodule QuickBEAM.VM.Execution do cells: %{optional(non_neg_integer()) => term()}, depth: pos_integer(), globals: map(), + heap: %{optional(non_neg_integer()) => QuickBEAM.VM.Object.t()}, max_stack_depth: pos_integer(), next_cell_id: non_neg_integer(), + next_object_id: non_neg_integer(), remaining_steps: non_neg_integer(), step_limit: pos_integer() } diff --git a/lib/quickbeam/vm/export.ex b/lib/quickbeam/vm/export.ex new file mode 100644 index 000000000..70b0f213f --- /dev/null +++ b/lib/quickbeam/vm/export.ex @@ -0,0 +1,81 @@ +defmodule QuickBEAM.VM.Export do + @moduledoc false + + alias QuickBEAM.VM.{Execution, Heap, Object, Property, Reference} + + @spec value(term(), Execution.t()) :: {:ok, term()} | {:error, term()} + def value(value, %Execution{} = execution), do: convert(value, execution, MapSet.new()) + + defp convert(%Reference{id: id} = reference, execution, seen) do + if MapSet.member?(seen, id) do + {:error, {:cyclic_result, id}} + else + case Heap.fetch_object(execution, reference) do + {:ok, object} -> convert_object(object, execution, MapSet.put(seen, id)) + :error -> {:error, {:invalid_reference, id}} + end + end + end + + defp convert({:closure, _function, _references}, _execution, _seen), + do: {:error, :function_result} + + defp convert(%QuickBEAM.VM.Function{}, _execution, _seen), do: {:error, :function_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} + + defp convert_object( + %Object{kind: :array, length: length, properties: properties}, + execution, + seen + ) do + values = + if length == 0 do + [] + else + for index <- 0..(length - 1), do: property_value(properties, index) + end + + convert_list(values, execution, seen, []) + end + + defp convert_object(%Object{properties: properties}, execution, seen) do + properties + |> Enum.filter(fn {_key, property} -> property.enumerable end) + |> Enum.reduce_while({:ok, %{}}, fn {key, %Property{value: value}}, {:ok, result} -> + case convert(value, execution, seen) do + {:ok, value} -> {:cont, {:ok, Map.put(result, key, value)}} + {:error, reason} -> {:halt, {:error, reason}} + end + end) + end + + 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 + + defp property_value(properties, key) do + case Map.get(properties, key) do + %Property{value: value} -> value + nil -> :undefined + end + end +end diff --git a/lib/quickbeam/vm/heap.ex b/lib/quickbeam/vm/heap.ex new file mode 100644 index 000000000..b7e82bca0 --- /dev/null +++ b/lib/quickbeam/vm/heap.ex @@ -0,0 +1,165 @@ +defmodule QuickBEAM.VM.Heap do + @moduledoc false + + alias QuickBEAM.VM.{Execution, Object, Property, Reference} + + @max_prototype_depth 1_000 + + @spec allocate(Execution.t(), Object.kind(), keyword()) :: {Reference.t(), Execution.t()} + def allocate(%Execution{} = execution, kind \\ :ordinary, opts \\ []) do + id = execution.next_object_id + + object = %Object{ + kind: kind, + prototype: Keyword.get(opts, :prototype), + length: Keyword.get(opts, :length, 0) + } + + reference = %Reference{id: id} + execution = %{execution | heap: Map.put(execution.heap, id, object), next_object_id: id + 1} + {reference, execution} + end + + @spec fetch_object(Execution.t(), Reference.t()) :: {:ok, Object.t()} | :error + def fetch_object(%Execution{} = execution, %Reference{id: id}), + do: Map.fetch(execution.heap, id) + + @spec get(Execution.t(), Reference.t(), term()) :: {:ok, term()} | {:error, term()} + def get(%Execution{} = execution, %Reference{} = reference, key) do + get_with_depth(execution, reference, normalize_key(key), 0) + end + + @spec put(Execution.t(), Reference.t(), term(), term()) :: + {:ok, Execution.t()} | {:error, term()} + def put(%Execution{} = execution, %Reference{id: id} = reference, key, value) do + key = normalize_key(key) + + with {:ok, object} <- fetch_object(execution, reference), + :ok <- writable?(object, key) do + object = put_property(object, key, value) + {:ok, %{execution | heap: Map.put(execution.heap, id, object)}} + else + :error -> {:error, {:invalid_reference, id}} + {:error, reason} -> {:error, reason} + end + end + + @spec define(Execution.t(), Reference.t(), term(), term(), keyword()) :: + {:ok, Execution.t()} | {:error, term()} + def define(%Execution{} = execution, %Reference{id: id} = reference, key, value, opts \\ []) do + key = normalize_key(key) + + with {:ok, object} <- fetch_object(execution, reference) do + property = %Property{ + value: value, + writable: Keyword.get(opts, :writable, true), + enumerable: Keyword.get(opts, :enumerable, true), + configurable: Keyword.get(opts, :configurable, true) + } + + object = put_property_struct(object, key, property) + {:ok, %{execution | heap: Map.put(execution.heap, id, object)}} + else + :error -> {:error, {:invalid_reference, id}} + end + end + + @spec delete(Execution.t(), Reference.t(), term()) :: + {:ok, boolean(), Execution.t()} | {:error, term()} + def delete(%Execution{} = 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 + %Property{configurable: false} -> + {:ok, false, execution} + + _property -> + object = %{object | properties: Map.delete(object.properties, key)} + {:ok, true, %{execution | heap: Map.put(execution.heap, id, object)}} + end + + :error -> + {:error, {:invalid_reference, id}} + end + end + + @spec own_keys(Execution.t(), Reference.t()) :: {:ok, [term()]} | {:error, term()} + def own_keys(execution, %Reference{id: id} = reference) do + case fetch_object(execution, reference) do + {:ok, object} -> {:ok, Map.keys(object.properties)} + :error -> {:error, {:invalid_reference, id}} + end + end + + defp get_with_depth(_execution, _reference, _key, depth) when depth > @max_prototype_depth, + do: {:error, :prototype_chain_too_deep} + + defp get_with_depth(execution, %Reference{id: id} = reference, key, depth) do + case fetch_object(execution, reference) do + {:ok, %Object{kind: :array, length: length}} when key == "length" -> + {:ok, length} + + {:ok, object} -> + case Map.fetch(object.properties, key) do + {:ok, %Property{value: value}} -> + {:ok, value} + + :error when is_struct(object.prototype, Reference) -> + get_with_depth(execution, object.prototype, key, depth + 1) + + :error -> + {:ok, :undefined} + end + + :error -> + {:error, {:invalid_reference, id}} + end + end + + defp writable?(%Object{properties: properties, extensible: extensible}, key) do + case Map.fetch(properties, key) do + {:ok, %Property{writable: true}} -> :ok + {:ok, %Property{writable: false}} -> {:error, {:property_not_writable, key}} + :error when extensible -> :ok + :error -> {:error, {:object_not_extensible, key}} + end + end + + defp put_property(object, key, value) do + property = + case Map.get(object.properties, key) do + %Property{} = property -> %{property | value: value} + nil -> %Property{value: value} + end + + put_property_struct(object, key, property) + end + + defp put_property_struct(%Object{kind: :array} = object, key, property) + when is_integer(key) and key >= 0 do + %{ + object + | properties: Map.put(object.properties, key, property), + length: max(object.length, key + 1) + } + end + + defp put_property_struct(object, key, property), + do: %{object | properties: Map.put(object.properties, key, property)} + + defp normalize_key(key) when is_integer(key) and key >= 0, do: key + + defp normalize_key(key) when is_float(key) and key >= 0 and trunc(key) == key, + do: trunc(key) + + defp normalize_key(key) when is_binary(key) do + case Integer.parse(key) do + {index, ""} when index >= 0 -> if Integer.to_string(index) == key, do: index, else: key + _ -> key + end + end + + defp normalize_key(key), do: key +end diff --git a/lib/quickbeam/vm/interpreter.ex b/lib/quickbeam/vm/interpreter.ex index 8bb331999..aea02adf9 100644 --- a/lib/quickbeam/vm/interpreter.ex +++ b/lib/quickbeam/vm/interpreter.ex @@ -6,11 +6,14 @@ defmodule QuickBEAM.VM.Interpreter do alias QuickBEAM.VM.{ Continuation, Execution, + Export, Frame, Function, + Heap, Opcodes, PredefinedAtoms, Program, + Reference, Value } @@ -50,7 +53,7 @@ defmodule QuickBEAM.VM.Interpreter do normalize_result(raise_js(reason, continuation.frame, continuation.execution)) end - defp normalize_result({:ok, value, _execution}), do: {:ok, value} + defp normalize_result({:ok, value, execution}), do: Export.value(value, execution) defp normalize_result({:error, reason, _execution}), do: {:error, reason} defp normalize_result({:suspended, continuation}), do: {:suspended, continuation} @@ -136,6 +139,90 @@ defmodule QuickBEAM.VM.Interpreter do defp execute(:special_object, [_type], frame, execution), do: push(frame, execution, :undefined) + defp execute(:object, [], frame, execution) do + {reference, execution} = Heap.allocate(execution) + push(frame, execution, reference) + end + + defp execute(:array_from, [count], frame, execution) do + {elements, stack} = Enum.split(frame.stack, count) + {reference, execution} = Heap.allocate(execution, :array) + + execution = + elements + |> Enum.reverse() + |> Enum.with_index() + |> Enum.reduce(execution, fn {value, index}, execution -> + {:ok, execution} = Heap.define(execution, reference, index, value) + execution + end) + + push(%{frame | stack: stack}, execution, reference) + end + + defp execute( + :define_field, + [atom], + %{stack: [value, %Reference{} = object | stack]} = frame, + execution + ) do + key = resolve_atom(atom, execution) + + case Heap.define(execution, object, key, value) do + {:ok, execution} -> continue(%{frame | stack: [object | stack]}, execution) + {:error, reason} -> raise_js({:type_error, reason}, frame, execution) + end + end + + defp execute(:get_field, [atom], %{stack: [object | stack]} = frame, execution) do + get_property_and_continue(object, resolve_atom(atom, execution), stack, frame, execution) + end + + defp execute(:get_field2, [atom], %{stack: [object | stack]} = frame, execution) do + get_property_and_continue( + object, + resolve_atom(atom, execution), + [object | stack], + frame, + execution + ) + end + + defp execute(:get_array_el, [], %{stack: [key, object | stack]} = frame, execution) do + get_property_and_continue(object, key, stack, frame, execution) + end + + defp execute(:get_length, [], %{stack: [object | stack]} = frame, execution) do + get_property_and_continue(object, "length", stack, frame, execution) + end + + defp execute(:put_field, [atom], %{stack: [value, object | stack]} = frame, execution) do + put_property_and_continue( + object, + resolve_atom(atom, execution), + value, + stack, + frame, + execution + ) + end + + defp execute(:put_array_el, [], %{stack: [value, key, object | stack]} = frame, execution) do + put_property_and_continue(object, key, value, stack, frame, execution) + end + + defp execute(:delete, [], %{stack: [key, %Reference{} = object | stack]} = frame, execution) do + case Heap.delete(execution, object, key) do + {:ok, deleted?, execution} -> continue(%{frame | stack: [deleted? | stack]}, execution) + {:error, reason} -> raise_js({:type_error, reason}, frame, execution) + end + end + + defp execute(:to_propkey, [], frame, execution), do: continue(frame, execution) + + defp execute(:is_undefined_or_null, [], frame, execution), + do: unary(frame, execution, &(&1 in [:undefined, nil])) + defp execute(:drop, [], %{stack: [_value | stack]} = frame, execution), do: continue(%{frame | stack: stack}, execution) @@ -143,11 +230,14 @@ defmodule QuickBEAM.VM.Interpreter do do: continue(%{frame | stack: [value | frame.stack]}, execution) defp execute(:dup1, [], %{stack: [a, b | stack]} = frame, execution), - do: continue(%{frame | stack: [a, b, a, b | stack]}, execution) + do: continue(%{frame | stack: [a, b, b | stack]}, execution) defp execute(:dup2, [], %{stack: [a, b | stack]} = frame, execution), do: continue(%{frame | stack: [a, b, a, b | stack]}, execution) + defp execute(:dup3, [], %{stack: [a, b, c | stack]} = frame, execution), + do: continue(%{frame | stack: [a, b, c, a, b, c | stack]}, execution) + defp execute(:nip, [], %{stack: [a, _b | stack]} = frame, execution), do: continue(%{frame | stack: [a | stack]}, execution) @@ -160,6 +250,30 @@ defmodule QuickBEAM.VM.Interpreter do defp execute(:swap, [], %{stack: [a, b | stack]} = frame, execution), do: continue(%{frame | stack: [b, a | stack]}, execution) + defp execute(:swap2, [], %{stack: [a, b, c, d | stack]} = frame, execution), + do: continue(%{frame | stack: [c, d, a, b | stack]}, execution) + + defp execute(:perm3, [], %{stack: [a, b, c | stack]} = frame, execution), + do: continue(%{frame | stack: [a, c, b | stack]}, execution) + + defp execute(:perm4, [], %{stack: [a, b, c, d | stack]} = frame, execution), + do: continue(%{frame | stack: [a, c, d, b | stack]}, execution) + + defp execute(:perm5, [], %{stack: [a, b, c, d, e | stack]} = frame, execution), + do: continue(%{frame | stack: [a, c, d, e, b | stack]}, execution) + + defp execute(:rot3l, [], %{stack: [a, b, c | stack]} = frame, execution), + do: continue(%{frame | stack: [c, a, b | stack]}, execution) + + defp execute(:rot3r, [], %{stack: [a, b, c | stack]} = frame, execution), + do: continue(%{frame | stack: [b, c, a | stack]}, execution) + + defp execute(:rot4l, [], %{stack: [a, b, c, d | stack]} = frame, execution), + do: continue(%{frame | stack: [d, a, b, c | stack]}, execution) + + defp execute(:rot5l, [], %{stack: [a, b, c, d, e | stack]} = frame, execution), + do: continue(%{frame | stack: [e, a, b, c, d | stack]}, execution) + defp execute(:insert2, [], %{stack: [a, b | stack]} = frame, execution), do: continue(%{frame | stack: [a, b, a | stack]}, execution) @@ -451,6 +565,70 @@ defmodule QuickBEAM.VM.Interpreter do defp bitwise(frame, execution, operation), do: binary(frame, execution, &Value.bitwise(&1, &2, operation)) + defp get_property_and_continue(object, key, stack, frame, execution) do + case get_property(object, key, execution) do + {:ok, value} -> continue(%{frame | stack: [value | stack]}, execution) + {:error, reason} -> raise_js({:type_error, reason}, %{frame | stack: stack}, execution) + end + end + + defp put_property_and_continue(object, key, value, stack, frame, execution) do + case put_property(object, key, value, execution) do + {:ok, execution} -> continue(%{frame | stack: stack}, execution) + {:error, reason} -> raise_js({:type_error, reason}, %{frame | stack: stack}, execution) + end + end + + defp get_property(%Reference{} = object, key, execution), do: Heap.get(execution, object, key) + + defp get_property(object, key, _execution) when is_map(object) do + case Map.fetch(object, key) do + {:ok, value} -> {:ok, value} + :error -> {:ok, map_string_key(object, key)} + end + end + + defp get_property(object, "length", _execution) when is_binary(object), + do: {:ok, utf16_length(object)} + + defp get_property(object, key, _execution) when is_binary(object) and is_integer(key) do + {:ok, String.at(object, key) || :undefined} + end + + defp get_property(object, "length", _execution) when is_list(object), do: {:ok, length(object)} + + defp get_property(object, key, _execution) when is_list(object) and is_integer(key), + do: {:ok, Enum.at(object, key, :undefined)} + + defp get_property(object, _key, _execution) when object in [nil, :undefined], + do: {:error, :null_or_undefined_property_access} + + defp get_property(_object, _key, _execution), do: {:ok, :undefined} + + defp put_property(%Reference{} = object, key, value, execution), + do: Heap.put(execution, object, key, value) + + defp put_property(_object, _key, _value, _execution), do: {:error, :not_an_object} + + 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 + + defp utf16_length(value) do + value + |> :unicode.characters_to_binary(:utf8, {:utf16, :little}) + |> byte_size() + |> div(2) + end + defp raise_js(reason, frame, execution) do case split_at_catch(frame.stack) do {:caught, target, stack_below_catch} -> diff --git a/lib/quickbeam/vm/object.ex b/lib/quickbeam/vm/object.ex new file mode 100644 index 000000000..81a3ce1e5 --- /dev/null +++ b/lib/quickbeam/vm/object.ex @@ -0,0 +1,14 @@ +defmodule QuickBEAM.VM.Object do + @moduledoc false + + defstruct kind: :ordinary, prototype: nil, properties: %{}, extensible: true, length: 0 + + @type kind :: :ordinary | :array | :promise + @type t :: %__MODULE__{ + kind: kind(), + prototype: QuickBEAM.VM.Reference.t() | nil, + properties: %{optional(term()) => QuickBEAM.VM.Property.t()}, + extensible: boolean(), + length: non_neg_integer() + } +end diff --git a/lib/quickbeam/vm/property.ex b/lib/quickbeam/vm/property.ex new file mode 100644 index 000000000..96a019591 --- /dev/null +++ b/lib/quickbeam/vm/property.ex @@ -0,0 +1,12 @@ +defmodule QuickBEAM.VM.Property do + @moduledoc false + + defstruct value: :undefined, writable: true, enumerable: true, configurable: true + + @type t :: %__MODULE__{ + value: term(), + writable: boolean(), + enumerable: boolean(), + configurable: boolean() + } +end diff --git a/lib/quickbeam/vm/reference.ex b/lib/quickbeam/vm/reference.ex new file mode 100644 index 000000000..2d4016fe7 --- /dev/null +++ b/lib/quickbeam/vm/reference.ex @@ -0,0 +1,8 @@ +defmodule QuickBEAM.VM.Reference do + @moduledoc false + + @enforce_keys [:id] + defstruct [:id] + + @type t :: %__MODULE__{id: non_neg_integer()} +end diff --git a/lib/quickbeam/vm/value.ex b/lib/quickbeam/vm/value.ex index ec3186033..24b3fc7d1 100644 --- a/lib/quickbeam/vm/value.ex +++ b/lib/quickbeam/vm/value.ex @@ -103,6 +103,7 @@ defmodule QuickBEAM.VM.Value do def typeof(value) when is_binary(value), do: "string" def typeof(%QuickBEAM.VM.Function{}), do: "function" + def typeof(%QuickBEAM.VM.Reference{}), do: "object" def typeof({:closure, %QuickBEAM.VM.Function{}, _captures}), do: "function" def typeof(_value), do: "object" diff --git a/test/vm/heap_test.exs b/test/vm/heap_test.exs new file mode 100644 index 000000000..1870f76a0 --- /dev/null +++ b/test/vm/heap_test.exs @@ -0,0 +1,49 @@ +defmodule QuickBEAM.VM.HeapTest do + use ExUnit.Case, async: true + + alias QuickBEAM.VM.{Execution, Export, Heap} + + 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 "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 "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 + %Execution{atoms: {}, max_stack_depth: 10, remaining_steps: 10, step_limit: 10} + end +end diff --git a/test/vm/interpreter_test.exs b/test/vm/interpreter_test.exs index d4a78f52b..22892fcf6 100644 --- a/test/vm/interpreter_test.exs +++ b/test/vm/interpreter_test.exs @@ -44,6 +44,24 @@ defmodule QuickBEAM.VM.InterpreterTest do assert Task.await_many(tasks) == List.duplicate({:ok, 1}, 40) 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 "injects independent globals for each evaluation" do assert {:ok, program} = QuickBEAM.VM.compile("input + 1") @@ -153,7 +171,7 @@ defmodule QuickBEAM.VM.InterpreterTest do end test "reports unsupported opcodes without crashing the caller" do - assert {:ok, program} = QuickBEAM.VM.compile("({answer: 42})") + assert {:ok, program} = QuickBEAM.VM.compile("({get answer(){return 42}})") assert {:error, {:unsupported_opcode, _opcode, _operands}} = QuickBEAM.VM.eval(program) assert Process.alive?(self()) end From 148edd8003a6fe2651fa79b177c4354e54db0743 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 10 Jul 2026 12:27:32 +0200 Subject: [PATCH 05/87] Add async BEAM handlers and Promise scheduling --- lib/quickbeam/application.ex | 3 +- lib/quickbeam/vm.ex | 25 +++-- lib/quickbeam/vm/continuation.ex | 5 +- lib/quickbeam/vm/evaluator.ex | 76 +++++++++++++ lib/quickbeam/vm/execution.ex | 12 ++ lib/quickbeam/vm/export.ex | 10 +- lib/quickbeam/vm/interpreter.ex | 151 +++++++++++++++++++++++--- lib/quickbeam/vm/promise.ex | 40 +++++++ lib/quickbeam/vm/promise_reference.ex | 8 ++ lib/quickbeam/vm/value.ex | 1 + test/vm/async_test.exs | 90 +++++++++++++++ 11 files changed, 392 insertions(+), 29 deletions(-) create mode 100644 lib/quickbeam/vm/evaluator.ex create mode 100644 lib/quickbeam/vm/promise.ex create mode 100644 lib/quickbeam/vm/promise_reference.ex create mode 100644 test/vm/async_test.exs diff --git a/lib/quickbeam/application.ex b/lib/quickbeam/application.ex index f961ae4bf..4d84581d8 100644 --- a/lib/quickbeam/application.ex +++ b/lib/quickbeam/application.ex @@ -11,7 +11,8 @@ defmodule QuickBEAM.Application do start: {:pg, :start_link, [QuickBEAM.BroadcastChannel]} }, QuickBEAM.LockManager, - QuickBEAM.WasmAPI + QuickBEAM.WasmAPI, + {Task.Supervisor, name: QuickBEAM.VM.TaskSupervisor} ] QuickBEAM.Storage.init() diff --git a/lib/quickbeam/vm.ex b/lib/quickbeam/vm.ex index ef4d70631..83556dfc9 100644 --- a/lib/quickbeam/vm.ex +++ b/lib/quickbeam/vm.ex @@ -2,11 +2,11 @@ defmodule QuickBEAM.VM do @moduledoc """ Compile and validate QuickJS bytecode for execution by the BEAM engine. - This milestone exposes the immutable, version-locked program pipeline. The - interpreter itself is intentionally not part of this module yet. + Programs are immutable and version-locked; each evaluation owns its frames, + heap, Promise state, host operations, and resource limits. """ - alias QuickBEAM.VM.{ABI, Decoder, Function, Interpreter, Program, Verifier} + alias QuickBEAM.VM.{ABI, Decoder, Evaluator, Function, Program, Verifier} @type program :: QuickBEAM.VM.Program.t() @@ -87,22 +87,23 @@ defmodule QuickBEAM.VM do @doc """ Evaluates a verified program in an isolated BEAM process. - Supported kernel options are `:vars`, `:timeout`, `:max_steps`, and - `:max_stack_depth`. `isolation: :caller` is available for trusted diagnostics. + Supported options include `:vars`, asynchronous `:handlers`, `:timeout`, + `:max_steps`, and `:max_stack_depth`. `isolation: :caller` is available for + trusted diagnostics. """ @spec eval(Program.t(), keyword()) :: {:ok, term()} | {:error, term()} def eval(%Program{} = program, opts \\ []) when is_list(opts) do with :ok <- Verifier.verify(program), {:ok, options} <- evaluation_options(opts) do case options.isolation do - :caller -> Interpreter.eval(program, Map.to_list(options.interpreter)) + :caller -> Evaluator.eval(program, Map.to_list(options.interpreter)) :process -> eval_isolated(program, options) end end end defp evaluation_options(opts) do - allowed = [:isolation, :max_stack_depth, :max_steps, :timeout, :vars] + allowed = [:handlers, :isolation, :max_stack_depth, :max_steps, :timeout, :vars] case Keyword.keys(opts) -- allowed do [] -> validate_evaluation_options(opts) @@ -116,6 +117,7 @@ defmodule QuickBEAM.VM do max_steps = Keyword.get(opts, :max_steps, 5_000_000) max_stack_depth = Keyword.get(opts, :max_stack_depth, 1_000) vars = Keyword.get(opts, :vars, %{}) + handlers = Keyword.get(opts, :handlers, %{}) cond do isolation not in [:caller, :process] -> @@ -133,12 +135,19 @@ defmodule QuickBEAM.VM do not is_map(vars) -> {:error, {:invalid_option, :vars, vars}} + not is_map(handlers) or + not Enum.all?(handlers, fn {name, handler} -> + is_binary(name) and is_function(handler, 1) + end) -> + {:error, {:invalid_option, :handlers, handlers}} + true -> {:ok, %{ isolation: isolation, timeout: timeout, interpreter: %{ + handlers: handlers, max_steps: max_steps, max_stack_depth: max_stack_depth, vars: vars @@ -161,7 +170,7 @@ defmodule QuickBEAM.VM do end defp safe_interpret(program, options) do - case Interpreter.eval(program, Map.to_list(options)) do + case Evaluator.eval(program, Map.to_list(options)) do {:suspended, _continuation} -> {:error, {:unsupported, :async_wait}} result -> result end diff --git a/lib/quickbeam/vm/continuation.ex b/lib/quickbeam/vm/continuation.ex index 9583611d4..7bc594b3e 100644 --- a/lib/quickbeam/vm/continuation.ex +++ b/lib/quickbeam/vm/continuation.ex @@ -2,10 +2,11 @@ defmodule QuickBEAM.VM.Continuation do @moduledoc false @enforce_keys [:frame, :execution] - defstruct [:frame, :execution] + defstruct [:frame, :execution, :awaiting] @type t :: %__MODULE__{ frame: QuickBEAM.VM.Frame.t(), - execution: QuickBEAM.VM.Execution.t() + execution: QuickBEAM.VM.Execution.t(), + awaiting: QuickBEAM.VM.PromiseReference.t() | term() | nil } end diff --git a/lib/quickbeam/vm/evaluator.ex b/lib/quickbeam/vm/evaluator.ex new file mode 100644 index 000000000..f1265f349 --- /dev/null +++ b/lib/quickbeam/vm/evaluator.ex @@ -0,0 +1,76 @@ +defmodule QuickBEAM.VM.Evaluator do + @moduledoc false + + alias QuickBEAM.VM.{Continuation, Interpreter, Promise, PromiseReference, Program} + + @spec eval(Program.t(), keyword()) :: Interpreter.result() + def eval(%Program{} = program, opts \\ []) do + program + |> Interpreter.start(opts) + |> drive() + end + + defp drive({:suspended, %Continuation{awaiting: :microtask} = continuation}) do + case :queue.out(continuation.execution.jobs) do + {{:value, result}, jobs} -> + continuation = %{continuation | execution: %{continuation.execution | jobs: jobs}} + then_resume(result, continuation) + + {:empty, _jobs} -> + {:error, :missing_microtask} + end + end + + defp drive({:suspended, %Continuation{awaiting: %PromiseReference{}} = continuation}) do + await_host_reply(continuation) + end + + defp drive({:suspended, _continuation} = suspended), do: Interpreter.finish(suspended) + + defp drive({status, _value, execution} = result) when status in [:ok, :error] do + cancel_operations(execution.operations) + Interpreter.finish(result) + end + + defp await_host_reply(%Continuation{} = continuation) do + receive do + {:quickbeam_vm_host_reply, operation, result} -> + case Map.pop(continuation.execution.operations, operation) do + {nil, _operations} -> + await_host_reply(continuation) + + {{promise, _pid}, operations} -> + execution = %{continuation.execution | operations: operations} + execution = Promise.settle(execution, promise, result) + continuation = %{continuation | execution: execution} + + if promise.id == continuation.awaiting.id do + result = settled_result(promise, execution) + execution = %{execution | jobs: :queue.in(result, execution.jobs)} + drive({:suspended, %{continuation | execution: execution, awaiting: :microtask}}) + else + await_host_reply(continuation) + end + 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) do + continuation + |> Interpreter.resume_raw(result) + |> drive() + end + + defp cancel_operations(operations) do + Enum.each(operations, fn {_operation, {_promise, pid}} -> + if Process.alive?(pid), do: Process.exit(pid, :kill) + end) + end +end diff --git a/lib/quickbeam/vm/execution.ex b/lib/quickbeam/vm/execution.ex index 8ec1e5834..ad5f80623 100644 --- a/lib/quickbeam/vm/execution.ex +++ b/lib/quickbeam/vm/execution.ex @@ -9,10 +9,15 @@ defmodule QuickBEAM.VM.Execution do cells: %{}, depth: 1, globals: %{}, + handlers: %{}, heap: %{}, + jobs: {[], []}, max_stack_depth: 1_000, next_cell_id: 0, next_object_id: 0, + next_promise_id: 0, + operations: %{}, + promises: %{}, remaining_steps: 0 ] @@ -22,10 +27,17 @@ defmodule QuickBEAM.VM.Execution do cells: %{optional(non_neg_integer()) => term()}, depth: pos_integer(), globals: map(), + handlers: %{optional(String.t()) => function()}, heap: %{optional(non_neg_integer()) => QuickBEAM.VM.Object.t()}, + jobs: :queue.queue({:ok, term()} | {:error, term()}), max_stack_depth: pos_integer(), next_cell_id: non_neg_integer(), next_object_id: non_neg_integer(), + next_promise_id: non_neg_integer(), + operations: %{ + optional(reference()) => {QuickBEAM.VM.PromiseReference.t(), pid()} + }, + promises: %{optional(non_neg_integer()) => QuickBEAM.VM.Promise.state()}, remaining_steps: non_neg_integer(), step_limit: pos_integer() } diff --git a/lib/quickbeam/vm/export.ex b/lib/quickbeam/vm/export.ex index 70b0f213f..30d7b8cad 100644 --- a/lib/quickbeam/vm/export.ex +++ b/lib/quickbeam/vm/export.ex @@ -1,11 +1,19 @@ defmodule QuickBEAM.VM.Export do @moduledoc false - alias QuickBEAM.VM.{Execution, Heap, Object, Property, Reference} + alias QuickBEAM.VM.{Execution, Heap, Object, Promise, PromiseReference, Property, Reference} @spec value(term(), Execution.t()) :: {:ok, term()} | {:error, term()} def value(value, %Execution{} = execution), do: convert(value, execution, MapSet.new()) + defp convert(%PromiseReference{} = promise, execution, seen) do + case Promise.state(execution, promise) do + {:fulfilled, value} -> convert(value, execution, seen) + {:rejected, reason} -> {:error, {:js_throw, reason}} + :pending -> {:error, :pending_promise_result} + end + end + defp convert(%Reference{id: id} = reference, execution, seen) do if MapSet.member?(seen, id) do {:error, {:cyclic_result, id}} diff --git a/lib/quickbeam/vm/interpreter.ex b/lib/quickbeam/vm/interpreter.ex index aea02adf9..f3775b0f7 100644 --- a/lib/quickbeam/vm/interpreter.ex +++ b/lib/quickbeam/vm/interpreter.ex @@ -13,6 +13,8 @@ defmodule QuickBEAM.VM.Interpreter do Opcodes, PredefinedAtoms, Program, + Promise, + PromiseReference, Reference, Value } @@ -26,36 +28,44 @@ defmodule QuickBEAM.VM.Interpreter do | {:suspended, Continuation.t()} @spec eval(Program.t(), keyword()) :: result() - def eval(%Program{} = program, opts \\ []) do + def eval(%Program{} = program, opts \\ []), do: program |> start(opts) |> finish() + + @doc false + def start(%Program{} = program, opts \\ []) do max_steps = Keyword.get(opts, :max_steps, @default_max_steps) execution = %Execution{ atoms: program.atoms, globals: Map.new(Keyword.get(opts, :vars, %{})), + handlers: Map.new(Keyword.get(opts, :handlers, %{})), max_stack_depth: Keyword.get(opts, :max_stack_depth, @default_max_stack_depth), remaining_steps: max_steps, step_limit: max_steps } + execution = install_host_globals(execution) frame = new_frame(program.root, program.root, [], :undefined, {}) - - normalize_result(run(frame, execution)) + run(frame, execution) end @spec resume(Continuation.t(), {:ok, term()} | {:error, term()}) :: result() - def resume(%Continuation{} = continuation, {:ok, value}) do - frame = %{continuation.frame | stack: [value | continuation.frame.stack]} + def resume(%Continuation{} = continuation, result), + do: continuation |> resume_raw(result) |> finish() - normalize_result(run(frame, continuation.execution)) + @doc false + def resume_raw(%Continuation{} = continuation, {:ok, value}) do + frame = %{continuation.frame | stack: [value | continuation.frame.stack]} + run(frame, continuation.execution) end - def resume(%Continuation{} = continuation, {:error, reason}) do - normalize_result(raise_js(reason, continuation.frame, continuation.execution)) + def resume_raw(%Continuation{} = continuation, {:error, reason}) do + raise_js(reason, continuation.frame, continuation.execution) end - defp normalize_result({:ok, value, execution}), do: Export.value(value, execution) - defp normalize_result({:error, reason, _execution}), do: {:error, reason} - defp normalize_result({:suspended, continuation}), do: {:suspended, continuation} + @doc false + 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} defp enter_call(callable, args, this, caller, execution, tail?) do with {:ok, function, closure_refs} <- callable_parts(callable) do @@ -451,6 +461,9 @@ defmodule QuickBEAM.VM.Interpreter do defp execute(:tail_call, [argument_count], frame, execution), do: call(frame, execution, argument_count, true) + defp execute(:call_method, [argument_count], frame, execution), + do: call_method(frame, execution, argument_count) + defp execute(name, [index], frame, execution) when name in [ :get_var_ref, @@ -514,18 +527,43 @@ defmodule QuickBEAM.VM.Interpreter do defp execute(:throw, [], %{stack: [value | stack]} = frame, execution), do: raise_js(value, %{frame | stack: stack}, execution) - defp execute(:await, [], %{stack: [{:pending, _reference} | stack]} = frame, execution) do - continuation = %Continuation{frame: next_frame(%{frame | stack: stack}), execution: execution} + defp execute(:await, [], %{stack: [%PromiseReference{} = promise | stack]} = frame, execution) do + case Promise.state(execution, promise) do + :pending -> + continuation = %Continuation{ + frame: next_frame(%{frame | stack: stack}), + execution: execution, + awaiting: promise + } + + {:suspended, continuation} + + {:fulfilled, value} -> + suspend_microtask({:ok, value}, %{frame | stack: stack}, execution) + + {:rejected, reason} -> + suspend_microtask({:error, reason}, %{frame | stack: stack}, execution) + end + end + + defp execute(:await, [], %{stack: [{:pending, reference} | stack]} = frame, execution) do + continuation = %Continuation{ + frame: next_frame(%{frame | stack: stack}), + execution: execution, + awaiting: reference + } + {:suspended, continuation} end defp execute(:await, [], %{stack: [{:resolved, value} | stack]} = frame, execution), - do: continue(%{frame | stack: [value | stack]}, execution) + do: suspend_microtask({:ok, value}, %{frame | stack: stack}, execution) defp execute(:await, [], %{stack: [{:rejected, reason} | stack]} = frame, execution), - do: raise_js(reason, %{frame | stack: stack}, execution) + do: suspend_microtask({:error, reason}, %{frame | stack: stack}, execution) - defp execute(:await, [], frame, execution), do: continue(frame, execution) + defp execute(:await, [], %{stack: [value | stack]} = frame, execution), + do: suspend_microtask({:ok, value}, %{frame | stack: stack}, execution) defp execute(name, _operands, frame, execution) when name in [:nop, :set_name, :set_name_computed, :check_ctor, :close_loc], @@ -540,13 +578,32 @@ defmodule QuickBEAM.VM.Interpreter do case callable_and_rest do [callable | rest] -> caller = %{next_frame(frame) | stack: rest} - enter_call(callable, Enum.reverse(arguments), :undefined, caller, execution, tail?) + dispatch_call(callable, Enum.reverse(arguments), :undefined, caller, execution, tail?) _ -> {:error, {:invalid_stack, :call}, execution} end end + defp call_method(%Frame{stack: stack} = frame, execution, argument_count) do + {arguments, callable_and_this} = Enum.split(stack, argument_count) + + case callable_and_this do + [callable, this | rest] -> + caller = %{next_frame(frame) | stack: rest} + dispatch_call(callable, Enum.reverse(arguments), this, caller, execution, false) + + _ -> + {:error, {:invalid_stack, :call_method}, execution} + end + end + + defp dispatch_call({:host_function, :beam_call}, arguments, _this, caller, execution, tail?), + do: start_host_call(arguments, caller, execution, tail?) + + defp dispatch_call(callable, arguments, this, caller, execution, tail?), + do: enter_call(callable, arguments, this, caller, execution, tail?) + defp push(frame, execution, value), do: continue(%{frame | stack: [value | frame.stack]}, execution) @@ -565,6 +622,66 @@ defmodule QuickBEAM.VM.Interpreter do defp bitwise(frame, execution, operation), do: binary(frame, execution, &Value.bitwise(&1, &2, operation)) + defp suspend_microtask(result, frame, execution) do + execution = %{execution | jobs: :queue.in(result, execution.jobs)} + + continuation = %Continuation{ + frame: next_frame(frame), + execution: execution, + awaiting: :microtask + } + + {:suspended, continuation} + end + + defp install_host_globals(execution) do + {beam, execution} = Heap.allocate(execution) + {:ok, execution} = Heap.define(execution, beam, "call", {:host_function, :beam_call}) + %{execution | globals: Map.put(execution.globals, "Beam", beam)} + end + + defp start_host_call([name | arguments], caller, execution, tail?) when is_binary(name) 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 + + if tail?, + do: return_value(promise, execution), + else: run(%{caller | stack: [promise | caller.stack]}, execution) + end + + defp start_host_call(_arguments, caller, execution, _tail?), + do: raise_js({:type_error, :invalid_beam_call}, caller, execution) + + defp start_handler_task(handler, arguments, promise, execution) do + operation = make_ref() + owner = self() + + case Task.Supervisor.start_child(QuickBEAM.VM.TaskSupervisor, fn -> + Process.link(owner) + result = invoke_handler(handler, arguments) + send(owner, {:quickbeam_vm_host_reply, operation, result}) + 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 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 + defp get_property_and_continue(object, key, stack, frame, execution) do case get_property(object, key, execution) do {:ok, value} -> continue(%{frame | stack: [value | stack]}, execution) diff --git a/lib/quickbeam/vm/promise.ex b/lib/quickbeam/vm/promise.ex new file mode 100644 index 000000000..e40f70aaf --- /dev/null +++ b/lib/quickbeam/vm/promise.ex @@ -0,0 +1,40 @@ +defmodule QuickBEAM.VM.Promise do + @moduledoc false + + alias QuickBEAM.VM.{Execution, PromiseReference} + + @type state :: :pending | {:fulfilled, term()} | {:rejected, term()} + + @spec new(Execution.t()) :: {PromiseReference.t(), Execution.t()} + def new(%Execution{} = execution) do + id = execution.next_promise_id + reference = %PromiseReference{id: id} + + execution = %{ + execution + | next_promise_id: id + 1, + promises: Map.put(execution.promises, id, :pending) + } + + {reference, execution} + end + + @spec state(Execution.t(), PromiseReference.t()) :: state() + def state(%Execution{} = execution, %PromiseReference{id: id}), + do: Map.fetch!(execution.promises, id) + + @spec settle(Execution.t(), PromiseReference.t(), {:ok, term()} | {:error, term()}) :: + Execution.t() + def settle(%Execution{} = execution, %PromiseReference{id: id}, result) do + state = + case result do + {:ok, value} -> {:fulfilled, value} + {:error, reason} -> {:rejected, reason} + end + + case Map.fetch!(execution.promises, id) do + :pending -> %{execution | promises: Map.put(execution.promises, id, state)} + _settled -> execution + end + end +end diff --git a/lib/quickbeam/vm/promise_reference.ex b/lib/quickbeam/vm/promise_reference.ex new file mode 100644 index 000000000..be242e5e0 --- /dev/null +++ b/lib/quickbeam/vm/promise_reference.ex @@ -0,0 +1,8 @@ +defmodule QuickBEAM.VM.PromiseReference do + @moduledoc false + + @enforce_keys [:id] + defstruct [:id] + + @type t :: %__MODULE__{id: non_neg_integer()} +end diff --git a/lib/quickbeam/vm/value.ex b/lib/quickbeam/vm/value.ex index 24b3fc7d1..168959c6c 100644 --- a/lib/quickbeam/vm/value.ex +++ b/lib/quickbeam/vm/value.ex @@ -104,6 +104,7 @@ defmodule QuickBEAM.VM.Value do def typeof(value) when is_binary(value), do: "string" def typeof(%QuickBEAM.VM.Function{}), do: "function" def typeof(%QuickBEAM.VM.Reference{}), do: "object" + def typeof(%QuickBEAM.VM.PromiseReference{}), do: "object" def typeof({:closure, %QuickBEAM.VM.Function{}, _captures}), do: "function" def typeof(_value), do: "object" diff --git a/test/vm/async_test.exs b/test/vm/async_test.exs new file mode 100644 index 000000000..fddf359dd --- /dev/null +++ b/test/vm/async_test.exs @@ -0,0 +1,90 @@ +defmodule QuickBEAM.VM.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 "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 "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, 20}} = + QuickBEAM.VM.eval(program, 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 "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 From e6f59ad467ee96a4bb2f8e9d56c526ed14db6f39 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 10 Jul 2026 13:01:22 +0200 Subject: [PATCH 06/87] Fix nested package import rewriting --- lib/quickbeam/js/package_resolver.ex | 4 ++-- test/toolchain/bundler_test.exs | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/lib/quickbeam/js/package_resolver.ex b/lib/quickbeam/js/package_resolver.ex index c945bbcd2..ea36e3001 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 diff --git a/test/toolchain/bundler_test.exs b/test/toolchain/bundler_test.exs index 12042d1ea..49489775a 100644 --- a/test/toolchain/bundler_test.exs +++ b/test/toolchain/bundler_test.exs @@ -69,6 +69,26 @@ 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 "resolves scoped packages", %{tmp_dir: dir} do setup_fake_package(dir, "@myorg/utils", %{ From 5ca74e0e262d91aeb3fc429610775d17d68fbe78 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 10 Jul 2026 13:01:22 +0200 Subject: [PATCH 07/87] Add Preact SSR runtime slice --- docs/beam-interpreter-architecture.md | 8 +- lib/quickbeam/vm/builtins.ex | 420 ++++++++++++++++++++ lib/quickbeam/vm/execution.ex | 2 +- lib/quickbeam/vm/export.ex | 3 + lib/quickbeam/vm/heap.ex | 39 +- lib/quickbeam/vm/interpreter.ex | 529 +++++++++++++++++++++++++- lib/quickbeam/vm/native_frame.ex | 28 ++ lib/quickbeam/vm/object.ex | 14 +- lib/quickbeam/vm/regexp.ex | 8 + lib/quickbeam/vm/value.ex | 1 + package-lock.json | 68 +++- package.json | 2 + test/fixtures/vm/preact_ssr.js | 30 ++ test/vm/interpreter_test.exs | 28 ++ test/vm/preact_ssr_test.exs | 92 +++++ 15 files changed, 1232 insertions(+), 40 deletions(-) create mode 100644 lib/quickbeam/vm/builtins.ex create mode 100644 lib/quickbeam/vm/native_frame.ex create mode 100644 lib/quickbeam/vm/regexp.ex create mode 100644 test/fixtures/vm/preact_ssr.js create mode 100644 test/vm/preact_ssr_test.exs diff --git a/docs/beam-interpreter-architecture.md b/docs/beam-interpreter-architecture.md index 2dc1910cd..092a2fc9b 100644 --- a/docs/beam-interpreter-architecture.md +++ b/docs/beam-interpreter-architecture.md @@ -1,6 +1,12 @@ # BEAM Interpreter Architecture -Status: proposed architecture for the next major QuickBEAM release. +Status: implementation in progress for the next major QuickBEAM release. + +Implemented on the development branch: version-locked decoding and verification, +process-isolated evaluation, explicit frames and continuations, closures, +exceptions, owner-local objects, Promise scheduling, asynchronous `Beam.call`, +and a pinned Preact SSR acceptance fixture. The broader ECMAScript object model, +built-ins, conformance target, memory limit, and hardening work remain in progress. ## Summary diff --git a/lib/quickbeam/vm/builtins.ex b/lib/quickbeam/vm/builtins.ex new file mode 100644 index 000000000..16aafd36a --- /dev/null +++ b/lib/quickbeam/vm/builtins.ex @@ -0,0 +1,420 @@ +defmodule QuickBEAM.VM.Builtins do + @moduledoc false + + alias QuickBEAM.VM.{Execution, Heap, Object, Property, Reference, RegExp, Value} + + @constructors %{ + "Array" => ["isArray"], + "Object" => ["assign", "create", "keys"], + "Math" => ["floor", "max", "min", "random", "round"], + "String" => ["fromCharCode"], + "Error" => [], + "Promise" => ["resolve"], + "Set" => [] + } + + @spec install(Execution.t()) :: Execution.t() + def install(execution) do + Enum.reduce(@constructors, execution, fn {name, methods}, execution -> + {object, execution} = Heap.allocate(execution, :ordinary, callable: {:builtin, name}) + + execution = + Enum.reduce(methods, execution, fn method, execution -> + {:ok, execution} = + Heap.define(execution, object, method, {:builtin_method, name, method}, + enumerable: false + ) + + execution + end) + + execution = maybe_install_prototype(name, object, execution) + %{execution | globals: Map.put_new(execution.globals, name, object)} + end) + end + + @spec callable(Execution.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 + + @spec call(term(), term(), [term()], Execution.t()) :: + {:ok, term(), Execution.t()} | {:error, term(), Execution.t()} + def call({:builtin_method, "Array", "isArray"}, _this, [value], execution), + do: {:ok, array?(value, execution), execution} + + def call({:builtin_method, "Object", "keys"}, _this, [value], execution) do + with {:ok, keys} <- own_keys(value, execution) do + {array, execution} = array_from(keys, execution) + {:ok, array, execution} + else + {:error, reason} -> {:error, reason, execution} + end + end + + def call({:builtin_method, "Object", "create"}, _this, [prototype], execution) do + prototype = if match?(%Reference{}, prototype), do: prototype, else: nil + {object, execution} = Heap.allocate(execution, :ordinary, prototype: prototype) + {:ok, object, execution} + end + + def call({:builtin_method, "Object", "assign"}, _this, [target | sources], execution) do + Enum.reduce_while(sources, {:ok, target, execution}, fn source, {:ok, target, execution} -> + case assign(target, source, execution) do + {:ok, execution} -> {:cont, {:ok, target, execution}} + {:error, reason} -> {:halt, {:error, reason, execution}} + end + end) + end + + def call({:builtin_method, "Math", "floor"}, _this, [value], execution), + do: {:ok, value |> Value.to_number() |> floor_number(), execution} + + def call({:builtin_method, "Math", "round"}, _this, [value], execution), + do: {:ok, value |> Value.to_number() |> round_number(), execution} + + def call({:builtin_method, "Math", "random"}, _this, [], execution), + do: {:ok, 0.5, execution} + + def call({:builtin_method, "Math", "min"}, _this, values, execution), + do: {:ok, numeric_extreme(values, &min/2, :infinity), execution} + + def call({:builtin_method, "Math", "max"}, _this, values, execution), + do: {:ok, numeric_extreme(values, &max/2, :neg_infinity), execution} + + def call({:builtin_method, "String", "fromCharCode"}, _this, values, execution) do + string = values |> Enum.map(&Value.to_int32/1) |> List.to_string() + {:ok, string, execution} + end + + def call({:builtin_method, "Promise", "resolve"}, _this, values, execution) do + {promise, execution} = QuickBEAM.VM.Promise.new(execution) + + value = + case values do + [value | _] -> value + [] -> :undefined + end + + execution = QuickBEAM.VM.Promise.settle(execution, promise, {:ok, value}) + {:ok, promise, execution} + end + + def call({:primitive_method, :number, "toString"}, value, arguments, execution) do + radix = + case arguments do + [radix | _] -> Value.to_int32(radix) + [] -> 10 + end + + {:ok, number_to_string(value, radix), execution} + end + + def call({:primitive_method, :number, "toFixed"}, value, arguments, execution) do + digits = + case arguments do + [digits | _] -> Value.to_int32(digits) + [] -> 0 + end + + {:ok, :erlang.float_to_binary(value / 1, decimals: digits), execution} + end + + def call({:primitive_method, :string, "toString"}, value, _arguments, execution), + do: {:ok, value, execution} + + def call({:primitive_method, :string, "toLowerCase"}, value, _arguments, execution), + do: {:ok, String.downcase(value), execution} + + def call({:primitive_method, :string, "startsWith"}, value, [prefix | _], execution), + do: {:ok, String.starts_with?(value, Value.to_string_value(prefix)), execution} + + def call({:primitive_method, :string, "includes"}, value, [part | _], execution), + do: {:ok, String.contains?(value, Value.to_string_value(part)), execution} + + def call({:primitive_method, :string, "charCodeAt"}, value, [index | _], execution) do + result = value |> String.at(Value.to_int32(index)) |> char_code() + {:ok, result, execution} + end + + def call({:primitive_method, :string, "slice"}, value, arguments, execution) do + {start, length} = slice_range(String.length(value), arguments) + {:ok, String.slice(value, start, length), execution} + end + + def call({:primitive_method, :string, "replace"}, value, [pattern, replacement | _], execution) do + {:ok, replace_string(value, pattern, Value.to_string_value(replacement)), execution} + end + + def call({:primitive_method, :string, "split"}, value, arguments, execution) do + parts = + case arguments do + [] -> [value] + [separator | _] -> String.split(value, Value.to_string_value(separator)) + end + + {array, execution} = array_from(parts, execution) + {:ok, array, execution} + end + + def call({:primitive_method, :regexp, "test"}, %RegExp{} = regexp, [value | _], execution), + do: {:ok, regex_match?(regexp, Value.to_string_value(value)), execution} + + def call({:primitive_method, :array, "join"}, value, arguments, execution) do + separator = + case arguments do + [separator | _] -> Value.to_string_value(separator) + [] -> "," + end + + with {:ok, values} <- array_values(value, execution) do + {:ok, Enum.map_join(values, separator, &Value.to_string_value/1), execution} + else + {:error, reason} -> {:error, reason, execution} + end + end + + def call({:primitive_method, :array, "slice"}, value, arguments, execution) do + with {:ok, values} <- array_values(value, execution) do + {start, length} = slice_range(length(values), arguments) + {array, execution} = array_from(Enum.slice(values, start, length), execution) + {:ok, array, execution} + else + {:error, reason} -> {:error, reason, execution} + end + end + + def call({:primitive_method, :array, "concat"}, value, arguments, execution) do + with {:ok, values} <- array_values(value, execution) do + values = + Enum.reduce(arguments, values, fn item, values -> + values ++ concat_values(item, execution) + end) + + {array, execution} = array_from(values, execution) + {:ok, array, execution} + else + {:error, reason} -> {:error, reason, execution} + end + end + + def call({:builtin, "String"}, _this, [value], execution), + do: {:ok, Value.to_string_value(value), execution} + + def call({:builtin, "String"}, _this, [], execution), do: {:ok, "", execution} + + def call({:builtin, "Set"}, _this, values, execution) do + entries = + case values do + [value | _] -> + case array_values(value, execution) do + {:ok, entries} -> entries + _ -> [] + end + + [] -> + [] + end + + {set, execution} = Heap.allocate(execution, :set, internal: MapSet.new(entries)) + {:ok, set, execution} + end + + def call({:primitive_method, :set, "has"}, %Reference{} = set, [value | _], execution) do + case Heap.fetch_object(execution, set) do + {:ok, %Object{kind: :set, internal: entries}} -> + {:ok, MapSet.member?(entries, value), execution} + + _ -> + {:error, :not_a_set, execution} + end + end + + def call({:primitive_method, :set, "add"}, %Reference{} = set, [value | _], execution) do + case Heap.update_object(execution, set, fn object -> + %{object | internal: MapSet.put(object.internal, value)} + end) do + {:ok, execution} -> {:ok, set, execution} + {:error, reason} -> {:error, reason, execution} + end + end + + def call({:builtin, "Array"}, _this, values, execution) do + {array, execution} = array_from(values, execution) + {:ok, array, execution} + end + + def call({:builtin, "Object"}, _this, [value], execution) when value not in [nil, :undefined], + do: {:ok, value, execution} + + def call({:builtin, "Object"}, _this, _values, execution) do + {object, execution} = Heap.allocate(execution) + {:ok, object, execution} + end + + def call({:builtin, "Error"}, _this, values, execution), + do: + {:ok, %{name: "Error", message: Value.to_string_value(List.first(values) || "")}, execution} + + def call(callable, _this, _arguments, execution), + do: {:error, {:unsupported_builtin, callable}, execution} + + defp maybe_install_prototype("Promise", constructor, execution) do + {prototype, execution} = Heap.allocate(execution) + + {:ok, execution} = + Heap.define(execution, prototype, "then", {:promise_method, "then"}, enumerable: false) + + {:ok, execution} = + Heap.define(execution, constructor, "prototype", prototype, enumerable: false) + + execution + end + + defp maybe_install_prototype(_name, _constructor, execution), do: execution + + defp array?(%Reference{} = reference, execution) do + match?({:ok, %Object{kind: :array}}, Heap.fetch_object(execution, reference)) + end + + defp array?(value, _execution), do: is_list(value) + + defp array_from(values, execution) do + {array, execution} = Heap.allocate(execution, :array) + + execution = + values + |> Enum.with_index() + |> Enum.reduce(execution, fn {value, index}, execution -> + {:ok, execution} = Heap.define(execution, array, index, value) + execution + end) + + {array, execution} + end + + defp own_keys(%Reference{} = reference, execution), do: Heap.own_keys(execution, reference) + 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, []} + + defp assign(%Reference{} = target, source, execution) do + with {:ok, keys} <- own_keys(source, execution) do + Enum.reduce_while(keys, {:ok, execution}, fn key, {:ok, execution} -> + with {:ok, value} <- property(source, key, execution), + {:ok, execution} <- Heap.put(execution, target, key, value) do + {:cont, {:ok, execution}} + else + {:error, reason} -> {:halt, {:error, reason}} + end + end) + end + end + + defp assign(_target, _source, _execution), do: {:error, :not_an_object} + + defp property(%Reference{} = reference, key, execution), do: Heap.get(execution, reference, key) + + defp property(value, key, _execution) when is_map(value), + do: {:ok, Map.get(value, key, :undefined)} + + defp property(value, key, _execution) when is_list(value), + do: {:ok, Enum.at(value, key, :undefined)} + + defp array_values(value, _execution) when is_list(value), do: {:ok, value} + + defp array_values(%Reference{} = reference, execution) do + case Heap.fetch_object(execution, reference) do + {:ok, %Object{kind: :array, length: length, properties: properties}} -> + values = + if length == 0, + do: [], + else: for(index <- 0..(length - 1), do: property_value(properties, index)) + + {:ok, values} + + _ -> + {:error, :not_an_array} + end + end + + defp array_values(_value, _execution), do: {:error, :not_an_array} + + defp concat_values(value, execution) do + case array_values(value, execution) do + {:ok, values} -> values + {:error, _} -> [value] + end + end + + defp property_value(properties, index) do + case Map.get(properties, index) do + %Property{value: value} -> value + nil -> :undefined + end + end + + defp slice_range(size, arguments) do + start = + case arguments do + [start | _] -> normalize_index(Value.to_int32(start), size) + [] -> 0 + end + + finish = + case arguments do + [_start, finish | _] -> normalize_index(Value.to_int32(finish), size) + _ -> size + end + + {start, max(finish - start, 0)} + end + + defp normalize_index(index, size) when index < 0, do: max(size + index, 0) + defp normalize_index(index, size), do: min(index, size) + + 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: Integer.to_string(value, radix) + + defp number_to_string(value, _radix), do: Value.to_string_value(value) + + defp char_code(nil), do: :nan + defp char_code(character), do: character |> String.to_charlist() |> hd() + + defp regex_match?(%RegExp{source: source}, value) do + case Regex.compile(source) do + {:ok, regex} -> Regex.match?(regex, value) + {:error, _} -> false + end + end + + defp replace_string(value, %RegExp{source: source}, replacement) do + case Regex.compile(source) do + {:ok, regex} -> Regex.replace(regex, value, replacement) + {:error, _} -> value + end + end + + defp replace_string(value, pattern, replacement), + do: String.replace(value, Value.to_string_value(pattern), replacement, global: false) + + defp floor_number(value) when is_number(value), do: floor(value) + defp floor_number(value), do: value + defp round_number(value) when is_number(value), do: round(value) + defp round_number(value), do: value + + defp numeric_extreme(values, operation, initial) do + Enum.reduce(values, initial, fn value, result -> + operation.(Value.to_number(value), result) + end) + end +end diff --git a/lib/quickbeam/vm/execution.ex b/lib/quickbeam/vm/execution.ex index ad5f80623..6c0799c2a 100644 --- a/lib/quickbeam/vm/execution.ex +++ b/lib/quickbeam/vm/execution.ex @@ -23,7 +23,7 @@ defmodule QuickBEAM.VM.Execution do @type t :: %__MODULE__{ atoms: tuple(), - callers: [QuickBEAM.VM.Frame.t()], + callers: [QuickBEAM.VM.Frame.t() | QuickBEAM.VM.NativeFrame.t()], cells: %{optional(non_neg_integer()) => term()}, depth: pos_integer(), globals: map(), diff --git a/lib/quickbeam/vm/export.ex b/lib/quickbeam/vm/export.ex index 30d7b8cad..dee753515 100644 --- a/lib/quickbeam/vm/export.ex +++ b/lib/quickbeam/vm/export.ex @@ -45,6 +45,9 @@ defmodule QuickBEAM.VM.Export do defp convert(value, _execution, _seen), do: {:ok, value} + defp convert_object(%Object{callable: callable}, _execution, _seen) when not is_nil(callable), + do: {:error, :function_result} + defp convert_object( %Object{kind: :array, length: length, properties: properties}, execution, diff --git a/lib/quickbeam/vm/heap.ex b/lib/quickbeam/vm/heap.ex index b7e82bca0..713dc7770 100644 --- a/lib/quickbeam/vm/heap.ex +++ b/lib/quickbeam/vm/heap.ex @@ -12,7 +12,9 @@ defmodule QuickBEAM.VM.Heap do object = %Object{ kind: kind, prototype: Keyword.get(opts, :prototype), - length: Keyword.get(opts, :length, 0) + length: Keyword.get(opts, :length, 0), + callable: Keyword.get(opts, :callable), + internal: Keyword.get(opts, :internal) } reference = %Reference{id: id} @@ -29,6 +31,24 @@ defmodule QuickBEAM.VM.Heap do get_with_depth(execution, reference, normalize_key(key), 0) end + @spec has_property?(Execution.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 + @spec put(Execution.t(), Reference.t(), term(), term()) :: {:ok, Execution.t()} | {:error, term()} def put(%Execution{} = execution, %Reference{id: id} = reference, key, value) do @@ -85,11 +105,24 @@ defmodule QuickBEAM.VM.Heap do end end + @spec update_object(Execution.t(), Reference.t(), (Object.t() -> Object.t())) :: + {:ok, Execution.t()} | {:error, term()} + def update_object(%Execution{} = 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 + @spec own_keys(Execution.t(), Reference.t()) :: {:ok, [term()]} | {:error, term()} def own_keys(execution, %Reference{id: id} = reference) do case fetch_object(execution, reference) do - {:ok, object} -> {:ok, Map.keys(object.properties)} - :error -> {:error, {:invalid_reference, id}} + {:ok, object} -> + keys = for {key, property} <- object.properties, property.enumerable, do: key + {:ok, keys} + + :error -> + {:error, {:invalid_reference, id}} end end diff --git a/lib/quickbeam/vm/interpreter.ex b/lib/quickbeam/vm/interpreter.ex index f3775b0f7..a0f031e64 100644 --- a/lib/quickbeam/vm/interpreter.ex +++ b/lib/quickbeam/vm/interpreter.ex @@ -4,18 +4,21 @@ defmodule QuickBEAM.VM.Interpreter do import Bitwise alias QuickBEAM.VM.{ + Builtins, Continuation, Execution, Export, Frame, Function, Heap, + NativeFrame, Opcodes, PredefinedAtoms, Program, Promise, PromiseReference, Reference, + RegExp, Value } @@ -67,7 +70,7 @@ defmodule QuickBEAM.VM.Interpreter do def finish({:error, reason, _execution}), do: {:error, reason} def finish({:suspended, continuation}), do: {:suspended, continuation} - defp enter_call(callable, args, this, caller, execution, tail?) do + defp enter_call(callable, args, this, caller, execution, tail?, frame_callable \\ nil) do with {:ok, function, closure_refs} <- callable_parts(callable) do depth = if tail?, do: execution.depth, else: execution.depth + 1 @@ -79,10 +82,11 @@ defmodule QuickBEAM.VM.Interpreter do do: execution, else: %{execution | callers: [caller | execution.callers], depth: depth} - run(new_frame(function, callable, args, this, closure_refs), execution) + frame_callable = frame_callable || callable + run(new_frame(function, frame_callable, args, this, closure_refs), execution) end else - {:error, reason} -> raise_js(reason, caller, execution) + {:error, reason} -> raise_js(call_error(reason, caller), caller, execution) end end @@ -143,6 +147,25 @@ defmodule QuickBEAM.VM.Interpreter do defp execute(:push_this, [], frame, execution), do: push(frame, execution, frame.this) + defp execute(:regexp, [], %{stack: [bytecode, source | stack]} = frame, execution) do + push(%{frame | stack: stack}, execution, %RegExp{source: source, bytecode: bytecode}) + end + + defp 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} = Heap.define(execution, arguments, index, read_slot(value, execution)) + execution + end) + + push(frame, execution, arguments) + end + defp execute(:special_object, [2], frame, execution), do: push(frame, execution, frame.callable) @@ -230,9 +253,24 @@ defmodule QuickBEAM.VM.Interpreter do defp execute(:to_propkey, [], frame, execution), do: continue(frame, execution) + defp execute(:to_object, [], %{stack: [value | _]} = frame, execution) + when value in [nil, :undefined], + do: raise_js({:type_error, :cannot_convert_to_object}, frame, execution) + + defp execute(:to_object, [], frame, execution), do: continue(frame, execution) + defp execute(:is_undefined_or_null, [], frame, execution), do: unary(frame, execution, &(&1 in [:undefined, nil])) + defp execute(:is_undefined, [], frame, execution), + do: unary(frame, execution, &(&1 == :undefined)) + + defp execute(:is_null, [], frame, execution), + do: unary(frame, execution, &is_nil/1) + + defp execute(:is_function, [], %{stack: [value | stack]} = frame, execution), + do: continue(%{frame | stack: [type_of(value, execution) == "function" | stack]}, execution) + defp execute(:drop, [], %{stack: [_value | stack]} = frame, execution), do: continue(%{frame | stack: stack}, execution) @@ -357,6 +395,22 @@ defmodule QuickBEAM.VM.Interpreter do continue(%{frame | locals: locals, stack: stack}, execution) end + defp execute(:for_in_start, [], %{stack: [object | stack]} = frame, execution) do + case enumerable_keys(object, execution) do + {:ok, keys} -> continue(%{frame | stack: [{:for_in, keys, 0} | stack]}, execution) + {:error, reason} -> raise_js({:type_error, reason}, %{frame | stack: stack}, execution) + end + end + + defp execute(:for_in_next, [], %{stack: [{:for_in, keys, index} | stack]} = frame, execution) do + if index < length(keys) do + iterator = {:for_in, keys, index + 1} + continue(%{frame | stack: [false, Enum.at(keys, index), iterator | stack]}, execution) + else + continue(%{frame | stack: [true, :undefined, {:for_in, keys, index} | stack]}, execution) + end + end + defp execute(:catch, [target], frame, execution) do continue(%{frame | stack: [{:catch, target} | frame.stack]}, execution) end @@ -421,6 +475,10 @@ defmodule QuickBEAM.VM.Interpreter do defp execute(:strict_neq, [], frame, execution), do: binary(frame, execution, &(not Value.strict_equal?(&1, &2))) + defp execute(:in, [], %{stack: [object, key | stack]} = frame, execution) do + continue(%{frame | stack: [has_property?(object, key, execution) | stack]}, execution) + end + defp execute(:and, [], frame, execution), do: bitwise(frame, execution, &band/2) defp execute(:or, [], frame, execution), do: bitwise(frame, execution, &bor/2) defp execute(:xor, [], frame, execution), do: bitwise(frame, execution, &bxor/2) @@ -434,7 +492,18 @@ defmodule QuickBEAM.VM.Interpreter do defp execute(:plus, [], frame, execution), do: unary(frame, execution, &Value.to_number/1) defp execute(:not, [], frame, execution), do: unary(frame, execution, &Value.bitwise_not/1) defp execute(:lnot, [], frame, execution), do: unary(frame, execution, &(not Value.truthy?(&1))) - defp execute(:typeof, [], frame, execution), do: unary(frame, execution, &Value.typeof/1) + + defp execute(:typeof, [], %{stack: [value | stack]} = frame, execution) do + continue(%{frame | stack: [type_of(value, execution) | stack]}, execution) + end + + defp execute(:typeof_is_function, [], %{stack: [value | stack]} = frame, execution) do + continue(%{frame | stack: [type_of(value, execution) == "function" | stack]}, execution) + end + + defp execute(:typeof_is_undefined, [], frame, execution), + do: unary(frame, execution, &(&1 == :undefined)) + defp execute(:inc, [], frame, execution), do: unary(frame, execution, &Value.add(&1, 1)) defp execute(:dec, [], frame, execution), do: unary(frame, execution, &Value.subtract(&1, 1)) @@ -449,7 +518,8 @@ defmodule QuickBEAM.VM.Interpreter do defp execute(:fclosure, [index], frame, execution) do function = Enum.at(frame.function.constants, index) {callable, frame, execution} = capture_closure(function, frame, execution) - push(frame, execution, callable) + {reference, execution} = allocate_function(callable, function, execution) + push(frame, execution, reference) end defp execute(:fclosure8, [index], frame, execution), @@ -462,7 +532,13 @@ defmodule QuickBEAM.VM.Interpreter do do: call(frame, execution, argument_count, true) defp execute(:call_method, [argument_count], frame, execution), - do: call_method(frame, execution, argument_count) + do: call_method(frame, execution, argument_count, false) + + defp execute(:tail_call_method, [argument_count], frame, execution), + do: call_method(frame, execution, argument_count, true) + + defp execute(:call_constructor, [argument_count], frame, execution), + do: call_constructor(frame, execution, argument_count) defp execute(name, [index], frame, execution) when name in [ @@ -585,25 +661,284 @@ defmodule QuickBEAM.VM.Interpreter do end end - defp call_method(%Frame{stack: stack} = frame, execution, argument_count) do + defp call_method(%Frame{stack: stack} = frame, execution, argument_count, tail?) do {arguments, callable_and_this} = Enum.split(stack, argument_count) case callable_and_this do [callable, this | rest] -> caller = %{next_frame(frame) | stack: rest} - dispatch_call(callable, Enum.reverse(arguments), this, caller, execution, false) + dispatch_call(callable, Enum.reverse(arguments), this, caller, execution, tail?) _ -> {:error, {:invalid_stack, :call_method}, execution} end end + defp call_constructor(%Frame{stack: stack} = frame, execution, argument_count) do + {arguments, constructor_and_new_target} = Enum.split(stack, argument_count) + + case constructor_and_new_target do + [_new_target, constructor | rest] -> + caller = %{next_frame(frame) | stack: rest} + dispatch_call(constructor, Enum.reverse(arguments), :undefined, caller, execution, false) + + _ -> + {:error, {:invalid_stack, :call_constructor}, execution} + end + end + defp dispatch_call({:host_function, :beam_call}, arguments, _this, caller, execution, tail?), do: start_host_call(arguments, caller, execution, tail?) + defp dispatch_call( + {:bound_function, target, bound_this, bound_arguments}, + arguments, + _this, + caller, + execution, + tail? + ), + do: + dispatch_call(target, bound_arguments ++ arguments, bound_this, caller, execution, tail?) + + defp dispatch_call( + {:function_method, "bind"}, + [bound_this | bound_arguments], + target, + caller, + execution, + false + ) do + run( + %{caller | stack: [{:bound_function, target, bound_this, bound_arguments} | caller.stack]}, + execution + ) + end + + defp dispatch_call({:function_method, "call"}, arguments, target, caller, execution, tail?) do + {this, arguments} = + case arguments do + [this | rest] -> {this, rest} + [] -> {:undefined, []} + end + + dispatch_call(target, arguments, this, caller, execution, tail?) + end + + defp dispatch_call( + {:promise_method, "then"}, + arguments, + %PromiseReference{} = promise, + caller, + execution, + tail? + ) do + case {Promise.state(execution, promise), arguments} do + {{:fulfilled, value}, [callback | _]} -> + dispatch_call(callback, [value], :undefined, caller, execution, tail?) + + {{:rejected, reason}, [_fulfilled, rejected | _]} -> + dispatch_call(rejected, [reason], :undefined, caller, execution, tail?) + + _ -> + if tail?, + do: return_value(promise, execution), + else: run(%{caller | stack: [promise | caller.stack]}, execution) + end + end + + defp dispatch_call(%Reference{} = reference, arguments, this, caller, execution, tail?) do + case Builtins.callable(execution, reference) do + nil -> + raise_js(call_error({:not_callable, reference}, caller), caller, execution) + + callable when elem(callable, 0) in [:builtin, :builtin_method, :primitive_method] -> + dispatch_call(callable, arguments, this, caller, execution, tail?) + + callable -> + enter_call(callable, arguments, this, caller, execution, tail?, reference) + end + end + + defp dispatch_call( + {:primitive_method, :array, method}, + arguments, + receiver, + caller, + execution, + tail? + ) + when method in ["filter", "forEach", "map", "reduce", "some"] do + start_array_iteration(method, receiver, arguments, caller, execution, tail?) + end + + defp dispatch_call(callable, arguments, this, caller, execution, tail?) + when elem(callable, 0) in [:builtin, :builtin_method, :primitive_method] do + case Builtins.call(callable, this, arguments, execution) do + {:ok, value, execution} -> + if tail?, + do: return_value(value, execution), + else: run(%{caller | stack: [value | caller.stack]}, execution) + + {:error, reason, execution} -> + raise_js({:type_error, reason}, caller, execution) + end + end + defp dispatch_call(callable, arguments, this, caller, execution, tail?), do: enter_call(callable, arguments, this, caller, execution, tail?) + 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 = %NativeFrame{ + operation: operation, + values: values, + callback: callback, + receiver: receiver, + caller: caller, + tail?: tail? + } + + native = + if operation == :reduce do + case rest do + [initial | _] -> %{native | accumulator: initial} + [] when tuple_size(values) > 0 -> %{native | accumulator: elem(values, 0), index: 1} + [] -> native + end + else + native + end + + if operation == :reduce and tuple_size(values) == 0 and rest == [] do + raise_js({:type_error, :reduce_of_empty_array}, caller, execution) + else + invoke_native_next(native, execution) + end + else + {:error, reason} -> raise_js({:type_error, reason}, caller, execution) + [] -> raise_js({:type_error, :missing_callback}, caller, execution) + end + end + + defp invoke_native_next(%NativeFrame{} = native, execution) + when native.index >= tuple_size(native.values) do + finish_native(native, native_result(native, execution), execution) + end + + defp invoke_native_next(%NativeFrame{} = native, execution) do + value = elem(native.values, native.index) + + 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 + + defp resume_native(value, %NativeFrame{} = native, execution) do + current = elem(native.values, native.index) + + native = + case native.operation do + :map -> + %{native | index: native.index + 1, results: [value | native.results]} + + :filter -> + %{ + native + | index: native.index + 1, + results: + if(Value.truthy?(value), do: [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(%NativeFrame{operation: operation, results: results}, execution) + when operation in [:map, :filter] do + allocate_array(Enum.reverse(results), execution) + end + + defp native_result(%NativeFrame{operation: :for_each}, execution), + do: {:value, :undefined, execution} + + defp native_result(%NativeFrame{operation: :some}, execution), do: {:value, false, execution} + + defp native_result(%NativeFrame{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(values, execution) do + {array, execution} = Heap.allocate(execution, :array) + + execution = + values + |> Enum.with_index() + |> Enum.reduce(execution, fn {value, index}, execution -> + {:ok, execution} = Heap.define(execution, array, index, value) + execution + end) + + {:value, array, execution} + end + + defp interpreter_array_values(value, _execution) when is_list(value), do: {:ok, value} + + defp interpreter_array_values(%Reference{} = reference, execution) do + case Heap.fetch_object(execution, reference) do + {:ok, %QuickBEAM.VM.Object{kind: :array, length: length, properties: properties}} -> + values = + if length == 0 do + [] + else + for index <- 0..(length - 1) do + case Map.get(properties, index) do + %QuickBEAM.VM.Property{value: value} -> value + nil -> :undefined + end + end + end + + {:ok, values} + + _ -> + {:error, :not_an_array} + end + end + + defp interpreter_array_values(_value, _execution), do: {:error, :not_an_array} + defp push(frame, execution, value), do: continue(%{frame | stack: [value | frame.stack]}, execution) @@ -635,9 +970,17 @@ defmodule QuickBEAM.VM.Interpreter do end defp install_host_globals(execution) do + execution = Builtins.install(execution) {beam, execution} = Heap.allocate(execution) {:ok, execution} = Heap.define(execution, beam, "call", {:host_function, :beam_call}) - %{execution | globals: Map.put(execution.globals, "Beam", beam)} + {global_this, execution} = Heap.allocate(execution) + + globals = + execution.globals + |> Map.put("Beam", beam) + |> Map.put("globalThis", global_this) + + %{execution | globals: globals} end defp start_host_call([name | arguments], caller, execution, tail?) when is_binary(name) do @@ -682,23 +1025,95 @@ defmodule QuickBEAM.VM.Interpreter do kind, reason -> {:error, {:handler_exception, {kind, reason}, __STACKTRACE__}} end + defp call_error(reason, %NativeFrame{caller: caller}), do: call_error(reason, caller) + + defp call_error(reason, caller) do + pc = max(caller.pc - 1, 0) + {reason, {caller.function.name, pc, elem(caller.function.source_positions, pc)}} + end + + defp type_of(%Reference{} = reference, execution) do + if Builtins.callable(execution, reference), do: "function", else: "object" + end + + defp type_of(value, _execution) + when is_tuple(value) and + elem(value, 0) in [ + :builtin, + :builtin_method, + :bound_function, + :function_method, + :host_function, + :primitive_method, + :promise_method + ], + do: "function" + + defp type_of(value, _execution), do: Value.typeof(value) + defp get_property_and_continue(object, key, stack, frame, execution) do case get_property(object, key, execution) do - {:ok, value} -> continue(%{frame | stack: [value | stack]}, execution) - {:error, reason} -> raise_js({:type_error, reason}, %{frame | stack: stack}, execution) + {:ok, value} -> + continue(%{frame | stack: [value | stack]}, execution) + + {:error, reason} -> + location = + {frame.function.name, frame.pc, elem(frame.function.source_positions, frame.pc), key} + + raise_js({:type_error, {reason, location}}, %{frame | stack: stack}, execution) end end defp put_property_and_continue(object, key, value, stack, frame, execution) do case put_property(object, key, value, execution) do - {:ok, execution} -> continue(%{frame | stack: stack}, execution) - {:error, reason} -> raise_js({:type_error, reason}, %{frame | stack: stack}, execution) + {:ok, execution} -> + continue(%{frame | stack: stack}, execution) + + {:error, reason} -> + location = + {frame.function.name, frame.pc, elem(frame.function.source_positions, frame.pc)} + + raise_js({:type_error, {reason, location}}, %{frame | stack: stack}, execution) end end - defp get_property(%Reference{} = object, key, execution), do: Heap.get(execution, object, key) + defp get_property(%Reference{} = object, key, execution) do + case Heap.get(execution, object, key) do + {:ok, :undefined} = missing -> + cond do + key in ["bind", "call"] and not is_nil(Builtins.callable(execution, object)) -> + {:ok, {:function_method, key}} + + reference_kind(object, execution) in [:array, :set] and is_binary(key) -> + {:ok, {:primitive_method, reference_kind(object, execution), key}} + + true -> + missing + end + + result -> + result + end + end + + defp get_property(%PromiseReference{}, "then", _execution), do: {:ok, {:promise_method, "then"}} + + defp get_property(%RegExp{}, key, _execution) when is_binary(key), + do: {:ok, {:primitive_method, :regexp, key}} + + defp get_property(object, key, _execution) + when is_tuple(object) and key in ["bind", "call"] and + elem(object, 0) in [ + :builtin, + :builtin_method, + :bound_function, + :host_function, + :primitive_method, + :promise_method + ], + do: {:ok, {:function_method, key}} - defp get_property(object, key, _execution) when is_map(object) do + defp get_property(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)} @@ -712,11 +1127,20 @@ defmodule QuickBEAM.VM.Interpreter do {:ok, String.at(object, key) || :undefined} end + defp get_property(object, key, _execution) when is_binary(object) and is_binary(key), + do: {:ok, {:primitive_method, :string, key}} + defp get_property(object, "length", _execution) when is_list(object), do: {:ok, length(object)} defp get_property(object, key, _execution) when is_list(object) and is_integer(key), do: {:ok, Enum.at(object, key, :undefined)} + defp get_property(object, key, _execution) when is_list(object) and is_binary(key), + do: {:ok, {:primitive_method, :array, key}} + + defp get_property(object, key, _execution) when is_number(object) and is_binary(key), + do: {:ok, {:primitive_method, :number, key}} + defp get_property(object, _key, _execution) when object in [nil, :undefined], do: {:error, :null_or_undefined_property_access} @@ -725,7 +1149,39 @@ defmodule QuickBEAM.VM.Interpreter do defp put_property(%Reference{} = object, key, value, execution), do: Heap.put(execution, object, key, value) - defp put_property(_object, _key, _value, _execution), do: {:error, :not_an_object} + defp put_property(object, _key, _value, _execution), do: {:error, {:not_an_object, object}} + + defp enumerable_keys(%Reference{} = reference, execution), + do: Heap.own_keys(execution, reference) + + defp enumerable_keys(value, _execution) when is_map(value), do: {:ok, Map.keys(value)} + defp enumerable_keys([], _execution), do: {:ok, []} + + defp enumerable_keys(value, _execution) when is_list(value), + do: {:ok, Enum.to_list(0..(length(value) - 1))} + + defp enumerable_keys(value, _execution) when value in [nil, :undefined], do: {:ok, []} + defp enumerable_keys(_value, _execution), do: {:ok, []} + + defp has_property?(%Reference{} = reference, key, execution), + do: Heap.has_property?(execution, reference, key) + + defp has_property?(value, key, _execution) when is_map(value), do: Map.has_key?(value, key) + + defp has_property?(value, key, _execution) when is_list(value) and is_integer(key), + do: key >= 0 and key < length(value) + + defp has_property?(value, "length", _execution) when is_list(value) or is_binary(value), + do: true + + defp has_property?(_value, _key, _execution), do: false + + defp reference_kind(reference, execution) do + case Heap.fetch_object(execution, reference) do + {:ok, %QuickBEAM.VM.Object{kind: kind}} -> kind + :error -> nil + end + end defp map_string_key(map, key) when is_binary(key) do case Enum.find(map, fn @@ -746,6 +1202,9 @@ defmodule QuickBEAM.VM.Interpreter do |> div(2) end + defp raise_js(reason, %NativeFrame{caller: caller}, execution), + do: raise_js(reason, caller, execution) + defp raise_js(reason, frame, execution) do case split_at_catch(frame.stack) do {:caught, target, stack_below_catch} -> @@ -759,7 +1218,12 @@ defmodule QuickBEAM.VM.Interpreter do defp unwind_caller(reason, %Execution{callers: []} = execution), do: {:error, {:js_throw, reason}, execution} - defp unwind_caller(reason, %Execution{callers: [caller | callers]} = execution) do + defp unwind_caller(reason, %Execution{callers: [%NativeFrame{} = native | callers]} = execution) do + execution = %{execution | callers: callers, depth: execution.depth - 1} + raise_js(reason, native.caller, execution) + end + + defp unwind_caller(reason, %Execution{callers: [%Frame{} = caller | callers]} = execution) do execution = %{execution | callers: callers, depth: execution.depth - 1} raise_js(reason, caller, execution) end @@ -774,7 +1238,12 @@ defmodule QuickBEAM.VM.Interpreter do defp return_value(value, %Execution{callers: []} = execution), do: {:ok, value, execution} - defp return_value(value, %Execution{callers: [caller | callers]} = execution) do + defp return_value(value, %Execution{callers: [%NativeFrame{} = native | callers]} = execution) do + execution = %{execution | callers: callers, depth: execution.depth - 1} + resume_native(value, native, execution) + end + + defp return_value(value, %Execution{callers: [%Frame{} = caller | callers]} = execution) do execution = %{execution | callers: callers, depth: execution.depth - 1} run(%{caller | stack: [value | caller.stack]}, execution) end @@ -785,6 +1254,30 @@ defmodule QuickBEAM.VM.Interpreter do continue(%{frame | locals: locals}, execution) end + defp allocate_function(callable, function, execution) do + {reference, execution} = Heap.allocate(execution, :ordinary, callable: callable) + + if function.has_prototype do + {prototype, execution} = Heap.allocate(execution) + + {:ok, execution} = + Heap.define(execution, prototype, "constructor", reference, + enumerable: false, + configurable: true + ) + + {:ok, execution} = + Heap.define(execution, reference, "prototype", prototype, + enumerable: false, + configurable: false + ) + + {reference, execution} + else + {reference, execution} + end + end + defp capture_closure(%Function{closure_vars: []} = function, frame, execution), do: {function, frame, execution} diff --git a/lib/quickbeam/vm/native_frame.ex b/lib/quickbeam/vm/native_frame.ex new file mode 100644 index 000000000..ed67d525f --- /dev/null +++ b/lib/quickbeam/vm/native_frame.ex @@ -0,0 +1,28 @@ +defmodule QuickBEAM.VM.NativeFrame do + @moduledoc false + + @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.Frame.t(), + accumulator: term(), + index: non_neg_integer(), + results: [term()], + tail?: boolean() + } +end diff --git a/lib/quickbeam/vm/object.ex b/lib/quickbeam/vm/object.ex index 81a3ce1e5..b85eb9e25 100644 --- a/lib/quickbeam/vm/object.ex +++ b/lib/quickbeam/vm/object.ex @@ -1,14 +1,22 @@ defmodule QuickBEAM.VM.Object do @moduledoc false - defstruct kind: :ordinary, prototype: nil, properties: %{}, extensible: true, length: 0 + defstruct kind: :ordinary, + prototype: nil, + properties: %{}, + extensible: true, + length: 0, + callable: nil, + internal: nil - @type kind :: :ordinary | :array | :promise + @type kind :: :ordinary | :array | :promise | :set @type t :: %__MODULE__{ kind: kind(), prototype: QuickBEAM.VM.Reference.t() | nil, properties: %{optional(term()) => QuickBEAM.VM.Property.t()}, extensible: boolean(), - length: non_neg_integer() + length: non_neg_integer(), + callable: term() | nil, + internal: term() } end diff --git a/lib/quickbeam/vm/regexp.ex b/lib/quickbeam/vm/regexp.ex new file mode 100644 index 000000000..1989e7daf --- /dev/null +++ b/lib/quickbeam/vm/regexp.ex @@ -0,0 +1,8 @@ +defmodule QuickBEAM.VM.RegExp do + @moduledoc false + + @enforce_keys [:source, :bytecode] + defstruct [:source, :bytecode] + + @type t :: %__MODULE__{source: String.t(), bytecode: binary()} +end diff --git a/lib/quickbeam/vm/value.ex b/lib/quickbeam/vm/value.ex index 168959c6c..b697a10b1 100644 --- a/lib/quickbeam/vm/value.ex +++ b/lib/quickbeam/vm/value.ex @@ -105,6 +105,7 @@ defmodule QuickBEAM.VM.Value do def typeof(%QuickBEAM.VM.Function{}), do: "function" def typeof(%QuickBEAM.VM.Reference{}), do: "object" def typeof(%QuickBEAM.VM.PromiseReference{}), do: "object" + def typeof(%QuickBEAM.VM.RegExp{}), do: "object" def typeof({:closure, %QuickBEAM.VM.Function{}, _captures}), do: "function" def typeof(_value), do: "object" diff --git a/package-lock.json b/package-lock.json index 5f72818fc..135d0cbd4 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,6 +9,8 @@ "@node-rs/argon2": "^2.0.2", "@node-rs/bcrypt": "^1.10.7", "@node-rs/crc32": "^1.10.6", + "preact": "10.29.7", + "preact-render-to-string": "6.7.0", "sqlite-napi": "1.2.0" }, "devDependencies": { @@ -965,7 +967,8 @@ "optional": true, "os": [ "darwin" - ] + ], + "peer": true }, "node_modules/@oven/bun-darwin-x64": { "version": "1.3.13", @@ -978,7 +981,8 @@ "optional": true, "os": [ "darwin" - ] + ], + "peer": true }, "node_modules/@oven/bun-darwin-x64-baseline": { "version": "1.3.13", @@ -991,7 +995,8 @@ "optional": true, "os": [ "darwin" - ] + ], + "peer": true }, "node_modules/@oven/bun-linux-aarch64": { "version": "1.3.13", @@ -1004,7 +1009,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@oven/bun-linux-aarch64-musl": { "version": "1.3.13", @@ -1017,7 +1023,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@oven/bun-linux-x64": { "version": "1.3.13", @@ -1030,7 +1037,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@oven/bun-linux-x64-baseline": { "version": "1.3.13", @@ -1043,7 +1051,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@oven/bun-linux-x64-musl": { "version": "1.3.13", @@ -1056,7 +1065,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@oven/bun-linux-x64-musl-baseline": { "version": "1.3.13", @@ -1069,7 +1079,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@oven/bun-windows-aarch64": { "version": "1.3.13", @@ -1082,7 +1093,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@oven/bun-windows-x64": { "version": "1.3.13", @@ -1095,7 +1107,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@oven/bun-windows-x64-baseline": { "version": "1.3.13", @@ -1108,7 +1121,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@oxfmt/binding-android-arm-eabi": { "version": "0.48.0", @@ -2772,7 +2786,6 @@ "integrity": "sha512-YUSGSLUnoolsu8gxISEDio3q1rtsCozwfOzASUn3DT2mR2EeQ93uEEnen7s+6LpF+lyTQFln1pQfqwBh/fsVEg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "tsgolint": "bin/tsgolint.js" }, @@ -2813,6 +2826,33 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "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", diff --git a/package.json b/package.json index a85b9f53a..45317f977 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,8 @@ "@node-rs/argon2": "^2.0.2", "@node-rs/bcrypt": "^1.10.7", "@node-rs/crc32": "^1.10.6", + "preact": "10.29.7", + "preact-render-to-string": "6.7.0", "sqlite-napi": "1.2.0" }, "devDependencies": { 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/vm/interpreter_test.exs b/test/vm/interpreter_test.exs index 22892fcf6..5a1ff3621 100644 --- a/test/vm/interpreter_test.exs +++ b/test/vm/interpreter_test.exs @@ -44,6 +44,34 @@ defmodule QuickBEAM.VM.InterpreterTest do 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 "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, 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) diff --git a/test/vm/preact_ssr_test.exs b/test/vm/preact_ssr_test.exs new file mode 100644 index 000000000..7c524cc40 --- /dev/null +++ b/test/vm/preact_ssr_test.exs @@ -0,0 +1,92 @@ +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) + {:ok, source: source, program: 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 "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 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 From 9e694b1dc76af4baf64a722232e3247f4e6db56c Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 10 Jul 2026 15:03:40 +0200 Subject: [PATCH 08/87] Add VM memory containment --- docs/beam-interpreter-architecture.md | 21 +++++--- lib/quickbeam/vm.ex | 61 +++++++++++++++++----- lib/quickbeam/vm/evaluator.ex | 9 +++- lib/quickbeam/vm/execution.ex | 6 +++ lib/quickbeam/vm/heap.ex | 11 +++- lib/quickbeam/vm/interpreter.ex | 12 ++++- lib/quickbeam/vm/memory.ex | 57 ++++++++++++++++++++ lib/quickbeam/vm/promise.ex | 4 +- test/vm/memory_limit_test.exs | 75 +++++++++++++++++++++++++++ 9 files changed, 234 insertions(+), 22 deletions(-) create mode 100644 lib/quickbeam/vm/memory.ex create mode 100644 test/vm/memory_limit_test.exs diff --git a/docs/beam-interpreter-architecture.md b/docs/beam-interpreter-architecture.md index 092a2fc9b..59b694cb0 100644 --- a/docs/beam-interpreter-architecture.md +++ b/docs/beam-interpreter-architecture.md @@ -326,12 +326,21 @@ error reporting but are not the authoritative timeout mechanism. Use both: 1. a process-level heap limit as a final containment boundary; and -2. periodic explicit memory checks at interpreter safepoints for a controlled - JavaScript error or QuickBEAM limit error. - -The JavaScript object-store mark-and-sweep collector may reclaim unreachable -entries retained in the process dictionary. Regardless of collector behavior, -process termination must reclaim the entire evaluation. +2. explicit owner-local allocation accounting checked at every interpreter + safepoint for a controlled QuickBEAM limit error. + +`memory_limit` is the JavaScript allocation budget. Objects, properties, +closure cells, Promises, injected variables, and host results are charged to it. +The accounting is intentionally conservative and monotonic until garbage +collection is introduced. Isolated workers additionally use BEAM's +`max_heap_size`, with fixed runtime overhead above the JavaScript budget so the +realm can report controlled allocation failures before the final process +boundary is reached. `memory_limit: :infinity` disables both limits for trusted +diagnostics. + +Regardless of controlled accounting, process termination reclaims the entire +evaluation heap. The process ceiling contains interpreter frames and other BEAM +state that is not represented in the JavaScript object-store counters. ### Stack limit diff --git a/lib/quickbeam/vm.ex b/lib/quickbeam/vm.ex index 83556dfc9..c6af651cb 100644 --- a/lib/quickbeam/vm.ex +++ b/lib/quickbeam/vm.ex @@ -12,6 +12,8 @@ defmodule QuickBEAM.VM do @max_bytecode_bytes 16 * 1024 * 1024 @default_timeout 5_000 + @default_memory_limit 64 * 1024 * 1024 + @worker_heap_overhead 16 * 1024 * 1024 @doc """ Compiles JavaScript with the vendored QuickJS compiler and returns a verified @@ -88,7 +90,9 @@ defmodule QuickBEAM.VM do Evaluates a verified program in an isolated BEAM process. Supported options include `:vars`, asynchronous `:handlers`, `:timeout`, - `:max_steps`, and `:max_stack_depth`. `isolation: :caller` is available for + `:max_steps`, `:max_stack_depth`, and the JavaScript allocation budget + `:memory_limit`. Isolated workers also receive a BEAM process heap ceiling. + `isolation: :caller` is available for trusted diagnostics. """ @spec eval(Program.t(), keyword()) :: {:ok, term()} | {:error, term()} @@ -103,7 +107,15 @@ defmodule QuickBEAM.VM do end defp evaluation_options(opts) do - allowed = [:handlers, :isolation, :max_stack_depth, :max_steps, :timeout, :vars] + allowed = [ + :handlers, + :isolation, + :max_stack_depth, + :max_steps, + :memory_limit, + :timeout, + :vars + ] case Keyword.keys(opts) -- allowed do [] -> validate_evaluation_options(opts) @@ -116,6 +128,7 @@ defmodule QuickBEAM.VM do 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) vars = Keyword.get(opts, :vars, %{}) handlers = Keyword.get(opts, :handlers, %{}) @@ -132,6 +145,9 @@ defmodule QuickBEAM.VM do not is_integer(max_stack_depth) or max_stack_depth <= 0 -> {:error, {:invalid_option, :max_stack_depth, max_stack_depth}} + memory_limit != :infinity and (not is_integer(memory_limit) or memory_limit <= 0) -> + {:error, {:invalid_option, :memory_limit, memory_limit}} + not is_map(vars) -> {:error, {:invalid_option, :vars, vars}} @@ -145,11 +161,13 @@ defmodule QuickBEAM.VM do {:ok, %{ isolation: isolation, + memory_limit: memory_limit, timeout: timeout, interpreter: %{ handlers: handlers, max_steps: max_steps, max_stack_depth: max_stack_depth, + memory_limit: memory_limit, vars: vars } }} @@ -160,13 +178,14 @@ defmodule QuickBEAM.VM do caller = self() reply_ref = make_ref() - {pid, monitor_ref} = - spawn_monitor(fn -> - result = safe_interpret(program, options.interpreter) - send(caller, {reply_ref, result}) - end) + worker = fn -> + result = safe_interpret(program, options.interpreter) + send(caller, {reply_ref, result}) + end + + {pid, monitor_ref} = :erlang.spawn_opt(worker, worker_spawn_options(options.memory_limit)) - await_evaluation(pid, monitor_ref, reply_ref, options.timeout) + await_evaluation(pid, monitor_ref, reply_ref, options.timeout, options.memory_limit) end defp safe_interpret(program, options) do @@ -180,25 +199,37 @@ defmodule QuickBEAM.VM do kind, reason -> {:error, {:interpreter_crash, {kind, reason}, __STACKTRACE__}} end - defp await_evaluation(pid, monitor_ref, reply_ref, :infinity) do + 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} -> - {:error, {:evaluation_process_exit, reason}} + evaluation_exit(reason, memory_limit) end end - defp await_evaluation(pid, monitor_ref, reply_ref, timeout) do + 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} -> - {:error, {:evaluation_process_exit, reason}} + evaluation_exit(reason, memory_limit) after timeout -> Process.exit(pid, :kill) @@ -207,6 +238,12 @@ defmodule QuickBEAM.VM do 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 diff --git a/lib/quickbeam/vm/evaluator.ex b/lib/quickbeam/vm/evaluator.ex index f1265f349..b0d4dc3e3 100644 --- a/lib/quickbeam/vm/evaluator.ex +++ b/lib/quickbeam/vm/evaluator.ex @@ -1,7 +1,7 @@ defmodule QuickBEAM.VM.Evaluator do @moduledoc false - alias QuickBEAM.VM.{Continuation, Interpreter, Promise, PromiseReference, Program} + alias QuickBEAM.VM.{Continuation, Interpreter, Memory, Promise, PromiseReference, Program} @spec eval(Program.t(), keyword()) :: Interpreter.result() def eval(%Program{} = program, opts \\ []) do @@ -41,6 +41,7 @@ defmodule QuickBEAM.VM.Evaluator do {{promise, _pid}, operations} -> execution = %{continuation.execution | operations: operations} + execution = charge_host_result(execution, result) execution = Promise.settle(execution, promise, result) continuation = %{continuation | execution: execution} @@ -55,6 +56,12 @@ defmodule QuickBEAM.VM.Evaluator do end end + defp charge_host_result(execution, {:ok, value}), + do: Memory.charge(execution, Memory.estimate(value)) + + defp charge_host_result(execution, {:error, reason}), + do: Memory.charge(execution, Memory.estimate(reason)) + defp settled_result(promise, execution) do case Promise.state(execution, promise) do {:fulfilled, value} -> {:ok, value} diff --git a/lib/quickbeam/vm/execution.ex b/lib/quickbeam/vm/execution.ex index 6c0799c2a..72429e15e 100644 --- a/lib/quickbeam/vm/execution.ex +++ b/lib/quickbeam/vm/execution.ex @@ -13,6 +13,9 @@ defmodule QuickBEAM.VM.Execution do heap: %{}, jobs: {[], []}, max_stack_depth: 1_000, + memory_exceeded: false, + memory_limit: :infinity, + memory_used: 0, next_cell_id: 0, next_object_id: 0, next_promise_id: 0, @@ -31,6 +34,9 @@ defmodule QuickBEAM.VM.Execution do heap: %{optional(non_neg_integer()) => QuickBEAM.VM.Object.t()}, jobs: :queue.queue({:ok, term()} | {:error, term()}), max_stack_depth: pos_integer(), + memory_exceeded: boolean(), + memory_limit: pos_integer() | :infinity, + memory_used: non_neg_integer(), next_cell_id: non_neg_integer(), next_object_id: non_neg_integer(), next_promise_id: non_neg_integer(), diff --git a/lib/quickbeam/vm/heap.ex b/lib/quickbeam/vm/heap.ex index 713dc7770..438f4fe47 100644 --- a/lib/quickbeam/vm/heap.ex +++ b/lib/quickbeam/vm/heap.ex @@ -1,7 +1,7 @@ defmodule QuickBEAM.VM.Heap do @moduledoc false - alias QuickBEAM.VM.{Execution, Object, Property, Reference} + alias QuickBEAM.VM.{Execution, Memory, Object, Property, Reference} @max_prototype_depth 1_000 @@ -18,6 +18,7 @@ defmodule QuickBEAM.VM.Heap do } reference = %Reference{id: id} + execution = Memory.charge_object(execution, object) execution = %{execution | heap: Map.put(execution.heap, id, object), next_object_id: id + 1} {reference, execution} end @@ -56,6 +57,7 @@ defmodule QuickBEAM.VM.Heap do with {:ok, object} <- fetch_object(execution, reference), :ok <- writable?(object, key) do + execution = maybe_charge_property(execution, object, key, value) object = put_property(object, key, value) {:ok, %{execution | heap: Map.put(execution.heap, id, object)}} else @@ -77,6 +79,7 @@ defmodule QuickBEAM.VM.Heap do configurable: Keyword.get(opts, :configurable, true) } + execution = maybe_charge_property(execution, object, key, value) object = put_property_struct(object, key, property) {:ok, %{execution | heap: Map.put(execution.heap, id, object)}} else @@ -151,6 +154,12 @@ defmodule QuickBEAM.VM.Heap do end 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 writable?(%Object{properties: properties, extensible: extensible}, key) do case Map.fetch(properties, key) do {:ok, %Property{writable: true}} -> :ok diff --git a/lib/quickbeam/vm/interpreter.ex b/lib/quickbeam/vm/interpreter.ex index a0f031e64..93d2320dc 100644 --- a/lib/quickbeam/vm/interpreter.ex +++ b/lib/quickbeam/vm/interpreter.ex @@ -11,6 +11,7 @@ defmodule QuickBEAM.VM.Interpreter do Frame, Function, Heap, + Memory, NativeFrame, Opcodes, PredefinedAtoms, @@ -37,15 +38,19 @@ defmodule QuickBEAM.VM.Interpreter do def start(%Program{} = program, opts \\ []) do max_steps = Keyword.get(opts, :max_steps, @default_max_steps) + vars = Map.new(Keyword.get(opts, :vars, %{})) + execution = %Execution{ atoms: program.atoms, - globals: Map.new(Keyword.get(opts, :vars, %{})), + 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), remaining_steps: max_steps, step_limit: max_steps } + execution = Memory.charge(execution, Memory.estimate(vars)) execution = install_host_globals(execution) frame = new_frame(program.root, program.root, [], :undefined, {}) run(frame, execution) @@ -110,6 +115,9 @@ defmodule QuickBEAM.VM.Interpreter do } end + defp run(_frame, %Execution{memory_exceeded: true} = execution), + do: {:error, {:limit_exceeded, :memory_bytes, execution.memory_limit}, execution} + defp run(_frame, %Execution{remaining_steps: 0} = execution), do: {:error, {:limit_exceeded, :steps, execution.step_limit}, execution} @@ -1318,6 +1326,8 @@ defmodule QuickBEAM.VM.Interpreter do id = execution.next_cell_id reference = {:cell, id} + execution = Memory.charge_cell(execution, value) + execution = %{ execution | cells: Map.put(execution.cells, id, value), diff --git a/lib/quickbeam/vm/memory.ex b/lib/quickbeam/vm/memory.ex new file mode 100644 index 000000000..632c4d909 --- /dev/null +++ b/lib/quickbeam/vm/memory.ex @@ -0,0 +1,57 @@ +defmodule QuickBEAM.VM.Memory do + @moduledoc false + + alias QuickBEAM.VM.Execution + + @object_bytes 128 + @property_bytes 64 + @cell_bytes 32 + @promise_bytes 64 + + @spec charge(Execution.t(), non_neg_integer()) :: Execution.t() + def charge(%Execution{} = 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 + + def charge_object(execution, object), do: charge(execution, @object_bytes + estimate(object)) + + def charge_property(execution, key, value), + do: charge(execution, @property_bytes + estimate(key) + estimate(value)) + + def charge_cell(execution, value), do: charge(execution, @cell_bytes + estimate(value)) + def charge_promise(execution), do: charge(execution, @promise_bytes) + + @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.Reference{}), do: 16 + def estimate(%QuickBEAM.VM.PromiseReference{}), do: 16 + def estimate(%QuickBEAM.VM.Function{}), do: 16 + + 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/promise.ex b/lib/quickbeam/vm/promise.ex index e40f70aaf..46512e02a 100644 --- a/lib/quickbeam/vm/promise.ex +++ b/lib/quickbeam/vm/promise.ex @@ -1,7 +1,7 @@ defmodule QuickBEAM.VM.Promise do @moduledoc false - alias QuickBEAM.VM.{Execution, PromiseReference} + alias QuickBEAM.VM.{Execution, Memory, PromiseReference} @type state :: :pending | {:fulfilled, term()} | {:rejected, term()} @@ -10,6 +10,8 @@ defmodule QuickBEAM.VM.Promise do id = execution.next_promise_id reference = %PromiseReference{id: id} + execution = Memory.charge_promise(execution) + execution = %{ execution | next_promise_id: id + 1, diff --git a/test/vm/memory_limit_test.exs b/test/vm/memory_limit_test.exs new file mode 100644 index 000000000..99f51485c --- /dev/null +++ b/test/vm/memory_limit_test.exs @@ -0,0 +1,75 @@ +defmodule QuickBEAM.VM.MemoryLimitTest do + use ExUnit.Case, async: false + + 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 "the BEAM heap ceiling contains interpreter frame growth" do + source = "(function recurse(n){return 1+recurse(n+1)})(0)" + assert {:ok, program} = QuickBEAM.VM.compile(source) + + assert {:error, {:limit_exceeded, :memory_bytes, 1_000_000}} = + QuickBEAM.VM.eval(program, + memory_limit: 1_000_000, + max_stack_depth: 200_000, + max_steps: 5_000_000, + timeout: 5_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..500_000) + end + + assert {:error, {:limit_exceeded, :memory_bytes, 1_000_000}} = + QuickBEAM.VM.eval(program, + handlers: %{"large_result" => handler}, + memory_limit: 1_000_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 From 14d24bf230f3046e70173433c358831b1455087b Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 10 Jul 2026 15:17:39 +0200 Subject: [PATCH 09/87] Add source-mapped VM JavaScript errors --- docs/beam-interpreter-architecture.md | 8 +- lib/quickbeam/js_error.ex | 108 +++++++++++++++++++++++++- lib/quickbeam/vm/export.ex | 2 +- lib/quickbeam/vm/interpreter.ex | 100 ++++++++++++++++-------- lib/quickbeam/vm/varint.ex | 20 ++--- test/vm/decoder_test.exs | 2 +- test/vm/error_test.exs | 72 +++++++++++++++++ test/vm/interpreter_test.exs | 4 +- test/vm/leb128_test.exs | 6 +- 9 files changed, 266 insertions(+), 56 deletions(-) create mode 100644 test/vm/error_test.exs diff --git a/docs/beam-interpreter-architecture.md b/docs/beam-interpreter-architecture.md index 59b694cb0..ad8117c75 100644 --- a/docs/beam-interpreter-architecture.md +++ b/docs/beam-interpreter-architecture.md @@ -467,8 +467,12 @@ cancellation mechanisms. ## Errors, tracing, and observability -JavaScript throws should become `%QuickBEAM.JS.Error{}` with JavaScript name, -message, filename, line, column, and stack frames when debug metadata is present. +JavaScript throws become `%QuickBEAM.JSError{}` with JavaScript name, message, +filename, line, column, and structured JavaScript stack frames when debug +metadata is present. Generated `TypeError` and `ReferenceError` values remain +ordinary error-like JavaScript values while a `catch` block handles them; they +are converted only when they escape the evaluation. Elixir handler stack traces +must never appear in the public JavaScript stack. Limit and infrastructure failures should remain distinguishable: diff --git a/lib/quickbeam/js_error.ex b/lib/quickbeam/js_error.ex index 6e3533542..57ab14523 100644 --- a/lib/quickbeam/js_error.ex +++ b/lib/quickbeam/js_error.ex @@ -1,10 +1,21 @@ defmodule QuickBEAM.JSError do - defexception [:message, :name, :stack] + 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 @@ -17,7 +28,11 @@ defmodule QuickBEAM.JSError do %__MODULE__{ message: to_string(value[:message] || value["message"] || inspect(value)), name: to_string(value[:name] || value["name"] || "Error"), - stack: get_stack(value) + stack: get_stack(value), + filename: value[:filename] || value["filename"], + line: value[:line] || value["line"], + column: value[:column] || value["column"], + frames: value[:frames] || value["frames"] || [] } end @@ -29,10 +44,95 @@ defmodule QuickBEAM.JSError do %__MODULE__{message: inspect(value), name: "Error", stack: nil} end + @doc false + def vm_exception_value(reason) + when is_tuple(reason) and + elem(reason, 0) in [ + :handler_exception, + :not_callable, + :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 false + @spec from_vm(term(), [frame()]) :: t() + def from_vm(reason, frames) do + {name, message} = vm_name_and_message(reason) + first = List.first(frames) || %{} + + %__MODULE__{ + name: name, + message: message, + filename: first[:filename], + line: first[:line], + column: 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) do + { + to_string(value[:name] || value["name"] || "Error"), + to_string(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({: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", 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.function || "" + filename = frame.filename || "" + line = frame.line || 1 + column = frame.column || 1 + " at #{function} (#{filename}:#{line}:#{column})" + end) + + "#{name}: #{message}\n#{rendered}" + end + defp get_stack(value) do case value[:stack] || value["stack"] do nil -> nil - s -> to_string(s) + stack -> to_string(stack) end end end diff --git a/lib/quickbeam/vm/export.ex b/lib/quickbeam/vm/export.ex index dee753515..223975ca4 100644 --- a/lib/quickbeam/vm/export.ex +++ b/lib/quickbeam/vm/export.ex @@ -9,7 +9,7 @@ defmodule QuickBEAM.VM.Export do defp convert(%PromiseReference{} = promise, execution, seen) do case Promise.state(execution, promise) do {:fulfilled, value} -> convert(value, execution, seen) - {:rejected, reason} -> {:error, {:js_throw, reason}} + {:rejected, reason} -> {:error, QuickBEAM.JSError.from_vm(reason, [])} :pending -> {:error, :pending_promise_result} end end diff --git a/lib/quickbeam/vm/interpreter.ex b/lib/quickbeam/vm/interpreter.ex index 93d2320dc..defd774c1 100644 --- a/lib/quickbeam/vm/interpreter.ex +++ b/lib/quickbeam/vm/interpreter.ex @@ -67,7 +67,7 @@ defmodule QuickBEAM.VM.Interpreter do end def resume_raw(%Continuation{} = continuation, {:error, reason}) do - raise_js(reason, continuation.frame, continuation.execution) + raise_js_from_caller(reason, continuation.frame, continuation.execution) end @doc false @@ -91,7 +91,7 @@ defmodule QuickBEAM.VM.Interpreter do run(new_frame(function, frame_callable, args, this, closure_refs), execution) end else - {:error, reason} -> raise_js(call_error(reason, caller), caller, execution) + {:error, reason} -> raise_js_from_caller(reason, caller, execution) end end @@ -758,7 +758,7 @@ defmodule QuickBEAM.VM.Interpreter do defp dispatch_call(%Reference{} = reference, arguments, this, caller, execution, tail?) do case Builtins.callable(execution, reference) do nil -> - raise_js(call_error({:not_callable, reference}, caller), caller, execution) + raise_js_from_caller({:not_callable, reference}, caller, execution) callable when elem(callable, 0) in [:builtin, :builtin_method, :primitive_method] -> dispatch_call(callable, arguments, this, caller, execution, tail?) @@ -789,7 +789,7 @@ defmodule QuickBEAM.VM.Interpreter do else: run(%{caller | stack: [value | caller.stack]}, execution) {:error, reason, execution} -> - raise_js({:type_error, reason}, caller, execution) + raise_js_from_caller({:type_error, reason}, caller, execution) end end @@ -831,13 +831,13 @@ defmodule QuickBEAM.VM.Interpreter do end if operation == :reduce and tuple_size(values) == 0 and rest == [] do - raise_js({:type_error, :reduce_of_empty_array}, caller, execution) + raise_js_from_caller({:type_error, :reduce_of_empty_array}, caller, execution) else invoke_native_next(native, execution) end else - {:error, reason} -> raise_js({:type_error, reason}, caller, execution) - [] -> raise_js({:type_error, :missing_callback}, caller, execution) + {:error, reason} -> raise_js_from_caller({:type_error, reason}, caller, execution) + [] -> raise_js_from_caller({:type_error, :missing_callback}, caller, execution) end end @@ -1006,7 +1006,7 @@ defmodule QuickBEAM.VM.Interpreter do end defp start_host_call(_arguments, caller, execution, _tail?), - do: raise_js({:type_error, :invalid_beam_call}, caller, execution) + do: raise_js_from_caller({:type_error, :invalid_beam_call}, caller, execution) defp start_handler_task(handler, arguments, promise, execution) do operation = make_ref() @@ -1033,13 +1033,6 @@ defmodule QuickBEAM.VM.Interpreter do kind, reason -> {:error, {:handler_exception, {kind, reason}, __STACKTRACE__}} end - defp call_error(reason, %NativeFrame{caller: caller}), do: call_error(reason, caller) - - defp call_error(reason, caller) do - pc = max(caller.pc - 1, 0) - {reason, {caller.function.name, pc, elem(caller.function.source_positions, pc)}} - end - defp type_of(%Reference{} = reference, execution) do if Builtins.callable(execution, reference), do: "function", else: "object" end @@ -1065,10 +1058,7 @@ defmodule QuickBEAM.VM.Interpreter do continue(%{frame | stack: [value | stack]}, execution) {:error, reason} -> - location = - {frame.function.name, frame.pc, elem(frame.function.source_positions, frame.pc), key} - - raise_js({:type_error, {reason, location}}, %{frame | stack: stack}, execution) + raise_js({:type_error, reason}, %{frame | stack: stack}, execution) end end @@ -1078,10 +1068,7 @@ defmodule QuickBEAM.VM.Interpreter do continue(%{frame | stack: stack}, execution) {:error, reason} -> - location = - {frame.function.name, frame.pc, elem(frame.function.source_positions, frame.pc)} - - raise_js({:type_error, {reason, location}}, %{frame | stack: stack}, execution) + raise_js({:type_error, reason}, %{frame | stack: stack}, execution) end end @@ -1211,31 +1198,80 @@ defmodule QuickBEAM.VM.Interpreter do end defp raise_js(reason, %NativeFrame{caller: caller}, execution), - do: raise_js(reason, caller, execution) + do: do_raise_js(QuickBEAM.JSError.vm_exception_value(reason), caller, execution, [], true) - defp raise_js(reason, frame, execution) do + defp raise_js(reason, frame, execution), + do: do_raise_js(QuickBEAM.JSError.vm_exception_value(reason), frame, execution, [], false) + + defp raise_js_from_caller(reason, %NativeFrame{caller: caller}, execution), + do: do_raise_js(QuickBEAM.JSError.vm_exception_value(reason), caller, execution, [], true) + + defp raise_js_from_caller(reason, frame, execution), + do: do_raise_js(QuickBEAM.JSError.vm_exception_value(reason), frame, execution, [], true) + + defp do_raise_js(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 -> - unwind_caller(reason, execution) + trace = [vm_stack_frame(frame, caller?) | trace] + unwind_caller(reason, execution, trace) end end - defp unwind_caller(reason, %Execution{callers: []} = execution), - do: {:error, {:js_throw, reason}, execution} + defp unwind_caller(reason, %Execution{callers: []} = execution, trace) do + error = QuickBEAM.JSError.from_vm(reason, Enum.reverse(trace)) + {:error, error, execution} + end - defp unwind_caller(reason, %Execution{callers: [%NativeFrame{} = native | callers]} = execution) do + defp unwind_caller( + reason, + %Execution{callers: [%NativeFrame{} = native | callers]} = execution, + trace + ) do execution = %{execution | callers: callers, depth: execution.depth - 1} - raise_js(reason, native.caller, execution) + do_raise_js(reason, native.caller, execution, trace, true) end - defp unwind_caller(reason, %Execution{callers: [%Frame{} = caller | callers]} = execution) do + defp unwind_caller( + reason, + %Execution{callers: [%Frame{} = caller | callers]} = execution, + trace + ) do execution = %{execution | callers: callers, depth: execution.depth - 1} - raise_js(reason, caller, execution) + do_raise_js(reason, caller, execution, trace, true) + end + + defp vm_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: PredefinedAtoms.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} diff --git a/lib/quickbeam/vm/varint.ex b/lib/quickbeam/vm/varint.ex index 9f11cc2e5..1ebf894b8 100644 --- a/lib/quickbeam/vm/varint.ex +++ b/lib/quickbeam/vm/varint.ex @@ -1,7 +1,9 @@ defmodule QuickBEAM.VM.Varint do @moduledoc """ - Bounded QuickJS integer decoding backed by `Varint.LEB128` and - `Varint.SLEB128`. + 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. @@ -31,11 +33,14 @@ defmodule QuickBEAM.VM.Varint do {: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, value, rest} <- decode_signed(binary), + {: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 @@ -58,15 +63,6 @@ defmodule QuickBEAM.VM.Varint do end end - defp decode_signed(binary) do - try do - {value, rest} = Varint.SLEB128.decode(binary) - {:ok, value, rest} - rescue - ArgumentError -> {:error, :bad_sleb128} - end - end - defp terminated_within_limit(_binary, 0, error), do: {:error, error} defp terminated_within_limit(<<>>, _remaining, error), do: {:error, error} diff --git a/test/vm/decoder_test.exs b/test/vm/decoder_test.exs index 69629c099..b16694b4a 100644 --- a/test/vm/decoder_test.exs +++ b/test/vm/decoder_test.exs @@ -106,7 +106,7 @@ defmodule QuickBEAM.VM.DecoderTest do Varint.LEB128.encode(1), Varint.LEB128.encode(atom_index * 2), <>, - Varint.SLEB128.encode(42) + Varint.LEB128.encode(84) ]) assert {:ok, %Program{root: {:object, %{^key => 42}}}} = diff --git a/test/vm/error_test.exs b/test/vm/error_test.exs new file mode 100644 index 000000000..6661a2502 --- /dev/null +++ b/test/vm/error_test.exs @@ -0,0 +1,72 @@ +defmodule QuickBEAM.VM.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 "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 "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("class Unsupported {}") + + assert {:error, {:unsupported_opcode, :define_class, _operands}} = + QuickBEAM.VM.eval(unsupported) + end +end diff --git a/test/vm/interpreter_test.exs b/test/vm/interpreter_test.exs index 5a1ff3621..af0c15435 100644 --- a/test/vm/interpreter_test.exs +++ b/test/vm/interpreter_test.exs @@ -149,7 +149,9 @@ defmodule QuickBEAM.VM.InterpreterTest do assert {:ok, 42} = QuickBEAM.VM.eval(returning) assert {:ok, throwing} = QuickBEAM.VM.compile("try { throw 42 } finally { 1 }") - assert {:error, {:js_throw, 42}} = QuickBEAM.VM.eval(throwing) + + assert {:error, %QuickBEAM.JSError{name: "Error", message: "42"}} = + QuickBEAM.VM.eval(throwing) end test "catches reference and call errors as JavaScript exceptions" do diff --git a/test/vm/leb128_test.exs b/test/vm/leb128_test.exs index fdb517dfe..42209d4cb 100644 --- a/test/vm/leb128_test.exs +++ b/test/vm/leb128_test.exs @@ -8,8 +8,8 @@ defmodule QuickBEAM.VM.VarintTest do assert {:ok, 300, <<1, 2>>} = VMVarint.read_unsigned(encoded <> <<1, 2>>) end - test "delegates signed decoding to Varint.SLEB128 and preserves the remainder" do - encoded = Varint.SLEB128.encode(-624_485) + 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 @@ -26,6 +26,6 @@ defmodule QuickBEAM.VM.VarintTest do VMVarint.read_unsigned(Varint.LEB128.encode(0x1_0000_0000)) assert {:error, :integer_overflow} = - VMVarint.read_signed(Varint.SLEB128.encode(0x8000_0000)) + VMVarint.read_signed(Varint.LEB128.encode(0x1_0000_0000)) end end From 9aab5d7ec51a7904350b03b7fe52f4da0fa7cc81 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 10 Jul 2026 15:17:45 +0200 Subject: [PATCH 10/87] Tune VM process heap containment --- lib/quickbeam/vm.ex | 2 +- test/vm/memory_limit_test.exs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/quickbeam/vm.ex b/lib/quickbeam/vm.ex index c6af651cb..d1a0fb6da 100644 --- a/lib/quickbeam/vm.ex +++ b/lib/quickbeam/vm.ex @@ -13,7 +13,7 @@ defmodule QuickBEAM.VM do @max_bytecode_bytes 16 * 1024 * 1024 @default_timeout 5_000 @default_memory_limit 64 * 1024 * 1024 - @worker_heap_overhead 16 * 1024 * 1024 + @worker_heap_overhead 4 * 1024 * 1024 @doc """ Compiles JavaScript with the vendored QuickJS compiler and returns a verified diff --git a/test/vm/memory_limit_test.exs b/test/vm/memory_limit_test.exs index 99f51485c..354972617 100644 --- a/test/vm/memory_limit_test.exs +++ b/test/vm/memory_limit_test.exs @@ -55,13 +55,13 @@ defmodule QuickBEAM.VM.MemoryLimitTest do handler = fn [] -> {:links, links} = Process.info(self(), :links) send(test_process, {:handler_process, self(), links}) - Enum.to_list(1..500_000) + Enum.to_list(1..5_000) end - assert {:error, {:limit_exceeded, :memory_bytes, 1_000_000}} = + assert {:error, {:limit_exceeded, :memory_bytes, 20_000}} = QuickBEAM.VM.eval(program, handlers: %{"large_result" => handler}, - memory_limit: 1_000_000, + memory_limit: 20_000, timeout: 5_000 ) From 14b5c4686687487e53bcc05e506179d98752a166 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 10 Jul 2026 15:22:48 +0200 Subject: [PATCH 11/87] Stabilize VM containment tests --- lib/quickbeam/vm.ex | 5 +++-- test/vm/async_test.exs | 4 ++-- test/vm/memory_limit_test.exs | 17 +++++++---------- 3 files changed, 12 insertions(+), 14 deletions(-) diff --git a/lib/quickbeam/vm.ex b/lib/quickbeam/vm.ex index d1a0fb6da..96165c5e6 100644 --- a/lib/quickbeam/vm.ex +++ b/lib/quickbeam/vm.ex @@ -199,9 +199,10 @@ defmodule QuickBEAM.VM do kind, reason -> {:error, {:interpreter_crash, {kind, reason}, __STACKTRACE__}} end - defp worker_spawn_options(:infinity), do: [:monitor] + @doc false + def worker_spawn_options(:infinity), do: [:monitor] - defp worker_spawn_options(memory_limit) do + def 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) diff --git a/test/vm/async_test.exs b/test/vm/async_test.exs index fddf359dd..eea567684 100644 --- a/test/vm/async_test.exs +++ b/test/vm/async_test.exs @@ -73,8 +73,8 @@ defmodule QuickBEAM.VM.AsyncTest do Process.sleep(:infinity) end - assert {:error, {:limit_exceeded, :timeout, 20}} = - QuickBEAM.VM.eval(program, handlers: %{"wait" => handler}, timeout: 20) + assert {:error, {:limit_exceeded, :timeout, 100}} = + QuickBEAM.VM.eval(program, handlers: %{"wait" => handler}, timeout: 100) assert_receive {:handler_started, handler_pid} monitor = Process.monitor(handler_pid) diff --git a/test/vm/memory_limit_test.exs b/test/vm/memory_limit_test.exs index 354972617..2781d30b0 100644 --- a/test/vm/memory_limit_test.exs +++ b/test/vm/memory_limit_test.exs @@ -34,17 +34,14 @@ defmodule QuickBEAM.VM.MemoryLimitTest do QuickBEAM.VM.eval(program, memory_limit: 20_000, max_steps: 100_000) end - test "the BEAM heap ceiling contains interpreter frame growth" do - source = "(function recurse(n){return 1+recurse(n+1)})(0)" - assert {:ok, program} = QuickBEAM.VM.compile(source) + test "isolated workers receive a BEAM max-heap containment boundary" do + assert [:monitor, {:max_heap_size, heap_limit}] = + QuickBEAM.VM.worker_spawn_options(1_000_000) - assert {:error, {:limit_exceeded, :memory_bytes, 1_000_000}} = - QuickBEAM.VM.eval(program, - memory_limit: 1_000_000, - max_stack_depth: 200_000, - max_steps: 5_000_000, - timeout: 5_000 - ) + assert heap_limit.kill + refute heap_limit.error_logger + assert heap_limit.size > div(1_000_000, :erlang.system_info(:wordsize)) + assert QuickBEAM.VM.worker_spawn_options(:infinity) == [:monitor] end test "contains oversized host results and reclaims the evaluation owner" do From 5bab7b21b4d553c7f68b282eb8fea7a90ba57ed8 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 10 Jul 2026 15:22:48 +0200 Subject: [PATCH 12/87] Update npm lockfile for SSR dependencies --- npm.lock | 90 +++++++++++++++++++++++++++++++++----------------------- 1 file changed, 53 insertions(+), 37 deletions(-) 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" From caecfa8e9ddb8569fe11f799f37a3d26037c2be8 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 10 Jul 2026 16:05:21 +0200 Subject: [PATCH 13/87] Add detached async frames and Promise reactions --- docs/beam-interpreter-architecture.md | 22 +- lib/quickbeam/js_error.ex | 3 + lib/quickbeam/vm/async_boundary.ex | 19 + lib/quickbeam/vm/builtins.ex | 38 +- lib/quickbeam/vm/coroutine.ex | 12 + lib/quickbeam/vm/evaluator.ex | 154 ++++- lib/quickbeam/vm/execution.ex | 21 +- lib/quickbeam/vm/interpreter.ex | 535 ++++++++++++++++-- lib/quickbeam/vm/promise.ex | 375 +++++++++++- lib/quickbeam/vm/promise_executor_boundary.ex | 13 + lib/quickbeam/vm/reaction.ex | 13 + lib/quickbeam/vm/reaction_boundary.ex | 13 + lib/quickbeam/vm/thenable_boundary.ex | 11 + lib/quickbeam/vm/thrown.ex | 8 + test/vm/async_test.exs | 21 + test/vm/interpreter_test.exs | 8 +- test/vm/promise_test.exs | 167 ++++++ 17 files changed, 1337 insertions(+), 96 deletions(-) create mode 100644 lib/quickbeam/vm/async_boundary.ex create mode 100644 lib/quickbeam/vm/coroutine.ex create mode 100644 lib/quickbeam/vm/promise_executor_boundary.ex create mode 100644 lib/quickbeam/vm/reaction.ex create mode 100644 lib/quickbeam/vm/reaction_boundary.ex create mode 100644 lib/quickbeam/vm/thenable_boundary.ex create mode 100644 lib/quickbeam/vm/thrown.ex create mode 100644 test/vm/promise_test.exs diff --git a/docs/beam-interpreter-architecture.md b/docs/beam-interpreter-architecture.md index ad8117c75..e6355d960 100644 --- a/docs/beam-interpreter-architecture.md +++ b/docs/beam-interpreter-architecture.md @@ -3,10 +3,12 @@ Status: implementation in progress for the next major QuickBEAM release. Implemented on the development branch: version-locked decoding and verification, -process-isolated evaluation, explicit frames and continuations, closures, -exceptions, owner-local objects, Promise scheduling, asynchronous `Beam.call`, -and a pinned Preact SSR acceptance fixture. The broader ECMAScript object model, -built-ins, conformance target, memory limit, and hardening work remain in progress. +process-isolated evaluation, explicit frames and detached async continuations, +closures, exceptions, owner-local objects, Promise reactions and combinators, +asynchronous `Beam.call`, logical and process memory containment, stable +JavaScript errors, and a pinned Preact SSR acceptance fixture. Broader ECMAScript +conformance, object-model hardening, garbage collection, and release hardening +remain in progress. ## Summary @@ -442,6 +444,18 @@ reference and may arrive in any order; JavaScript microtask ordering remains deterministic after each settlement. Stale replies after cancellation are ignored. +Async invocation returns its Promise immediately. An async frame that reaches +`await` detaches from its caller and is scheduled as an owner-local coroutine, +so several suspended async functions and host operations can coexist. Awaiting +an already-settled Promise still enqueues resumption as a microtask rather than +resuming inline. + +The Promise runtime provides FIFO `.then`, `.catch`, and `.finally` reactions, +resolution and rejection propagation, Promise and thenable assimilation, +self-resolution protection, idempotent resolver functions, and `all`, +`allSettled`, `any`, and `race`. Callback results are assimilated before their +reaction Promise settles, including thenables returned from `.finally`. + ```text Evaluation owner Host Task Supervisor ---------------- -------------------- diff --git a/lib/quickbeam/js_error.ex b/lib/quickbeam/js_error.ex index 57ab14523..ae810ba93 100644 --- a/lib/quickbeam/js_error.ex +++ b/lib/quickbeam/js_error.ex @@ -62,6 +62,9 @@ defmodule QuickBEAM.JSError do @doc false @spec from_vm(term(), [frame()]) :: t() + def from_vm(%QuickBEAM.VM.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) first = List.first(frames) || %{} diff --git a/lib/quickbeam/vm/async_boundary.ex b/lib/quickbeam/vm/async_boundary.ex new file mode 100644 index 000000000..248353a26 --- /dev/null +++ b/lib/quickbeam/vm/async_boundary.ex @@ -0,0 +1,19 @@ +defmodule QuickBEAM.VM.AsyncBoundary do + @moduledoc false + + @enforce_keys [:promise, :depth] + defstruct [:promise, :caller, :depth, mode: :push] + + @type t :: %__MODULE__{ + promise: QuickBEAM.VM.PromiseReference.t(), + caller: + QuickBEAM.VM.Frame.t() + | QuickBEAM.VM.NativeFrame.t() + | QuickBEAM.VM.ReactionBoundary.t() + | QuickBEAM.VM.PromiseExecutorBoundary.t() + | QuickBEAM.VM.ThenableBoundary.t() + | nil, + depth: pos_integer(), + mode: :push | :return | :reaction | :executor | :thenable | :detached + } +end diff --git a/lib/quickbeam/vm/builtins.ex b/lib/quickbeam/vm/builtins.ex index 16aafd36a..e7b528ec1 100644 --- a/lib/quickbeam/vm/builtins.ex +++ b/lib/quickbeam/vm/builtins.ex @@ -9,7 +9,7 @@ defmodule QuickBEAM.VM.Builtins do "Math" => ["floor", "max", "min", "random", "round"], "String" => ["fromCharCode"], "Error" => [], - "Promise" => ["resolve"], + "Promise" => ["all", "allSettled", "any", "race", "reject", "resolve"], "Set" => [] } @@ -90,6 +90,29 @@ defmodule QuickBEAM.VM.Builtins do {:ok, string, execution} end + def call({:builtin_method, "Promise", method}, _this, [iterable | _], execution) + when method in ["all", "allSettled", "any", "race"] do + case array_values(iterable, execution) do + {:ok, values} -> + kind = + %{"all" => :all, "allSettled" => :all_settled, "any" => :any, "race" => :race}[method] + + {promise, execution} = QuickBEAM.VM.Promise.aggregate(execution, kind, values) + {:ok, promise, execution} + + {:error, reason} -> + {:error, reason, execution} + end + end + + def call( + {:builtin_method, "Promise", "resolve"}, + _this, + [%QuickBEAM.VM.PromiseReference{} = promise | _], + execution + ), + do: {:ok, promise, execution} + def call({:builtin_method, "Promise", "resolve"}, _this, values, execution) do {promise, execution} = QuickBEAM.VM.Promise.new(execution) @@ -103,6 +126,19 @@ defmodule QuickBEAM.VM.Builtins do {:ok, promise, execution} end + def call({:builtin_method, "Promise", "reject"}, _this, values, execution) do + {promise, execution} = QuickBEAM.VM.Promise.new(execution) + + reason = + case values do + [reason | _] -> reason + [] -> :undefined + end + + execution = QuickBEAM.VM.Promise.settle(execution, promise, {:error, reason}) + {:ok, promise, execution} + end + def call({:primitive_method, :number, "toString"}, value, arguments, execution) do radix = case arguments do diff --git a/lib/quickbeam/vm/coroutine.ex b/lib/quickbeam/vm/coroutine.ex new file mode 100644 index 000000000..3dbfb0a7b --- /dev/null +++ b/lib/quickbeam/vm/coroutine.ex @@ -0,0 +1,12 @@ +defmodule QuickBEAM.VM.Coroutine do + @moduledoc false + + @enforce_keys [:frame, :callers, :boundary] + defstruct [:frame, :callers, :boundary] + + @type t :: %__MODULE__{ + frame: QuickBEAM.VM.Frame.t(), + callers: [QuickBEAM.VM.Frame.t() | QuickBEAM.VM.NativeFrame.t()], + boundary: QuickBEAM.VM.AsyncBoundary.t() + } +end diff --git a/lib/quickbeam/vm/evaluator.ex b/lib/quickbeam/vm/evaluator.ex index b0d4dc3e3..2f38d22c4 100644 --- a/lib/quickbeam/vm/evaluator.ex +++ b/lib/quickbeam/vm/evaluator.ex @@ -1,7 +1,17 @@ defmodule QuickBEAM.VM.Evaluator do @moduledoc false - alias QuickBEAM.VM.{Continuation, Interpreter, Memory, Promise, PromiseReference, Program} + alias QuickBEAM.VM.{ + Continuation, + Coroutine, + Execution, + Interpreter, + Memory, + Promise, + PromiseReference, + Program, + Reaction + } @spec eval(Program.t(), keyword()) :: Interpreter.result() def eval(%Program{} = program, opts \\ []) do @@ -10,9 +20,12 @@ defmodule QuickBEAM.VM.Evaluator do |> drive() end + defp drive({:ok, %PromiseReference{} = promise, execution}), + do: await_final_promise(promise, execution) + defp drive({:suspended, %Continuation{awaiting: :microtask} = continuation}) do case :queue.out(continuation.execution.jobs) do - {{:value, result}, jobs} -> + {{:value, result}, jobs} when elem(result, 0) in [:ok, :error] -> continuation = %{continuation | execution: %{continuation.execution | jobs: jobs}} then_resume(result, continuation) @@ -22,7 +35,7 @@ defmodule QuickBEAM.VM.Evaluator do end defp drive({:suspended, %Continuation{awaiting: %PromiseReference{}} = continuation}) do - await_host_reply(continuation) + await_legacy_promise(continuation) end defp drive({:suspended, _continuation} = suspended), do: Interpreter.finish(suspended) @@ -32,30 +45,133 @@ defmodule QuickBEAM.VM.Evaluator do Interpreter.finish(result) end - defp await_host_reply(%Continuation{} = continuation) do + defp drive({:idle, execution}) do + cancel_operations(execution.operations) + {:error, :idle_evaluation} + end + + defp await_final_promise(%PromiseReference{} = promise, execution) do + case Promise.state(execution, promise) do + {:fulfilled, value} -> + finish_final({:ok, value, execution}) + + {:rejected, %QuickBEAM.JSError{} = error} -> + finish_final({:error, error, execution}) + + {:rejected, reason} -> + finish_final({:error, QuickBEAM.JSError.from_vm(reason, []), execution}) + + :pending -> + drive_event_loop(promise, execution) + end + end + + defp drive_event_loop(final_promise, execution) do + case :queue.out(execution.jobs) do + {{:value, job}, jobs} -> + execution = %{execution | jobs: jobs} + run_job(job, final_promise, execution) + + {:empty, _jobs} when map_size(execution.operations) > 0 -> + receive_host_reply(final_promise, execution) + + {:empty, _jobs} -> + finish_final({:error, {:promise_deadlock, final_promise.id}, execution}) + end + end + + defp run_job({:resume_coroutine, %Coroutine{} = coroutine, result}, final_promise, execution) do + coroutine + |> Interpreter.resume_coroutine(result, execution) + |> continue_final(final_promise) + end + + defp run_job( + {:assimilate_thenable, promise, thenable, callable}, + final_promise, + execution + ) do + promise + |> Interpreter.assimilate_thenable(thenable, callable, execution) + |> continue_final(final_promise) + end + + defp run_job({:run_reaction, %Reaction{} = reaction, result}, final_promise, execution) do + reaction + |> Interpreter.run_reaction(result, execution) + |> continue_final(final_promise) + end + + defp run_job({:aggregate_settle, id, index, result}, final_promise, execution) do + execution = Promise.settle_aggregate(execution, id, index, result) + await_final_promise(final_promise, execution) + end + + defp run_job({:settle_assimilated, promise, result}, final_promise, execution) do + execution = Promise.settle_assimilated(execution, promise, result) + await_final_promise(final_promise, execution) + end + + defp run_job({:settle_promise, promise, result}, final_promise, execution) do + execution = Promise.settle(execution, promise, result) + await_final_promise(final_promise, execution) + end + + defp continue_final({:idle, execution}, final_promise), + do: await_final_promise(final_promise, execution) + + defp continue_final({:ok, _value, execution}, final_promise), + do: await_final_promise(final_promise, execution) + + defp continue_final({:error, _reason, _execution} = error, _final_promise), + do: finish_final(error) + + defp continue_final({:suspended, continuation}, _final_promise), + do: drive({:suspended, continuation}) + + defp receive_host_reply(final_promise, execution) do receive do {:quickbeam_vm_host_reply, operation, result} -> - case Map.pop(continuation.execution.operations, operation) do - {nil, _operations} -> - await_host_reply(continuation) - - {{promise, _pid}, operations} -> - execution = %{continuation.execution | operations: operations} - execution = charge_host_result(execution, result) - execution = Promise.settle(execution, promise, result) + case settle_host_reply(execution, operation, result) do + {:ok, execution} -> await_final_promise(final_promise, execution) + :stale -> receive_host_reply(final_promise, execution) + end + end + end + + defp await_legacy_promise(%Continuation{} = continuation) do + receive do + {:quickbeam_vm_host_reply, operation, result} -> + case settle_host_reply(continuation.execution, operation, result) do + {:ok, execution} -> continuation = %{continuation | execution: execution} - if promise.id == continuation.awaiting.id do - result = settled_result(promise, execution) + if Promise.state(execution, continuation.awaiting) == :pending do + await_legacy_promise(continuation) + else + result = settled_result(continuation.awaiting, execution) execution = %{execution | jobs: :queue.in(result, execution.jobs)} drive({:suspended, %{continuation | execution: execution, awaiting: :microtask}}) - else - await_host_reply(continuation) end + + :stale -> + await_legacy_promise(continuation) end end end + defp 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 = charge_host_result(execution, result) + {:ok, Promise.settle(execution, promise, result)} + end + end + defp charge_host_result(execution, {:ok, value}), do: Memory.charge(execution, Memory.estimate(value)) @@ -75,6 +191,12 @@ defmodule QuickBEAM.VM.Evaluator do |> drive() end + defp finish_final({status, _value, %Execution{} = execution} = result) + when status in [:ok, :error] do + cancel_operations(execution.operations) + Interpreter.finish(result) + end + defp cancel_operations(operations) do Enum.each(operations, fn {_operation, {_promise, pid}} -> if Process.alive?(pid), do: Process.exit(pid, :kill) diff --git a/lib/quickbeam/vm/execution.ex b/lib/quickbeam/vm/execution.ex index 72429e15e..c809b9765 100644 --- a/lib/quickbeam/vm/execution.ex +++ b/lib/quickbeam/vm/execution.ex @@ -20,19 +20,28 @@ defmodule QuickBEAM.VM.Execution do next_object_id: 0, next_promise_id: 0, operations: %{}, + promise_waiters: %{}, + promise_aggregates: %{}, promises: %{}, remaining_steps: 0 ] @type t :: %__MODULE__{ atoms: tuple(), - callers: [QuickBEAM.VM.Frame.t() | QuickBEAM.VM.NativeFrame.t()], + callers: [ + QuickBEAM.VM.Frame.t() + | QuickBEAM.VM.NativeFrame.t() + | QuickBEAM.VM.AsyncBoundary.t() + | QuickBEAM.VM.ReactionBoundary.t() + | QuickBEAM.VM.PromiseExecutorBoundary.t() + | QuickBEAM.VM.ThenableBoundary.t() + ], cells: %{optional(non_neg_integer()) => term()}, - depth: pos_integer(), + depth: non_neg_integer(), globals: map(), handlers: %{optional(String.t()) => function()}, heap: %{optional(non_neg_integer()) => QuickBEAM.VM.Object.t()}, - jobs: :queue.queue({:ok, term()} | {:error, term()}), + jobs: :queue.queue(term()), max_stack_depth: pos_integer(), memory_exceeded: boolean(), memory_limit: pos_integer() | :infinity, @@ -43,7 +52,11 @@ defmodule QuickBEAM.VM.Execution do operations: %{ optional(reference()) => {QuickBEAM.VM.PromiseReference.t(), pid()} }, - promises: %{optional(non_neg_integer()) => QuickBEAM.VM.Promise.state()}, + promise_waiters: %{optional(non_neg_integer()) => [term()]}, + promise_aggregates: %{optional(reference()) => map()}, + promises: %{ + optional(non_neg_integer()) => QuickBEAM.VM.Promise.state() | :resolving + }, remaining_steps: non_neg_integer(), step_limit: pos_integer() } diff --git a/lib/quickbeam/vm/interpreter.ex b/lib/quickbeam/vm/interpreter.ex index defd774c1..510cdad6d 100644 --- a/lib/quickbeam/vm/interpreter.ex +++ b/lib/quickbeam/vm/interpreter.ex @@ -4,8 +4,10 @@ defmodule QuickBEAM.VM.Interpreter do import Bitwise alias QuickBEAM.VM.{ + AsyncBoundary, Builtins, Continuation, + Coroutine, Execution, Export, Frame, @@ -17,9 +19,14 @@ defmodule QuickBEAM.VM.Interpreter do PredefinedAtoms, Program, Promise, + PromiseExecutorBoundary, PromiseReference, + Reaction, + ReactionBoundary, Reference, RegExp, + ThenableBoundary, + Thrown, Value } @@ -70,31 +77,136 @@ defmodule QuickBEAM.VM.Interpreter do raise_js_from_caller(reason, continuation.frame, continuation.execution) end + @doc false + def resume_coroutine(%Coroutine{} = coroutine, result, %Execution{} = 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_js_from_caller(reason, coroutine.frame, execution) + end + end + + @doc false + def assimilate_thenable(promise, thenable, callable, %Execution{} = execution) do + boundary = %ThenableBoundary{promise: promise, depth: execution.depth} + resolve = {:promise_resolver, promise, :resolve_assimilated} + reject = {:promise_resolver, promise, :reject_assimilated} + dispatch_call(callable, [resolve, reject], thenable, boundary, execution, false) + end + + @doc false + def run_reaction(%Reaction{} = reaction, result, %Execution{} = execution) do + callback = + case result do + {:ok, _value} -> reaction.on_fulfilled + {:error, _reason} -> reaction.on_rejected + end + + if type_of(callback, execution) == "function" do + boundary = %ReactionBoundary{ + promise: reaction.result_promise, + depth: execution.depth, + mode: reaction.kind, + original_result: result + } + + arguments = if reaction.kind == :finally, do: [], else: [reaction_argument(result)] + dispatch_call(callback, arguments, :undefined, boundary, execution, false) + else + execution = Promise.settle(execution, reaction.result_promise, result) + {:idle, execution} + end + end + + defp reaction_argument({:ok, value}), do: value + defp reaction_argument({:error, %Thrown{value: value}}), do: value + defp reaction_argument({:error, reason}), do: reason + @doc false 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_call(callable, args, this, caller, execution, tail?, frame_callable \\ nil) do with {:ok, function, closure_refs} <- callable_parts(callable) 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} + frame_callable = frame_callable || callable + + if function.func_kind == 2 do + enter_async_call( + function, + frame_callable, + closure_refs, + args, + this, + caller, + execution, + tail? + ) else - execution = - if tail?, - do: execution, - else: %{execution | callers: [caller | execution.callers], depth: depth} - - frame_callable = frame_callable || callable - run(new_frame(function, frame_callable, args, this, closure_refs), execution) + enter_sync_call( + function, + frame_callable, + closure_refs, + args, + this, + caller, + execution, + tail? + ) end else {:error, reason} -> raise_js_from_caller(reason, caller, execution) 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(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 + 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?(%ReactionBoundary{}, caller) -> :reaction + match?(%PromiseExecutorBoundary{}, caller) -> :executor + match?(%ThenableBoundary{}, caller) -> :thenable + tail? -> :return + true -> :push + end + + boundary = %AsyncBoundary{ + promise: promise, + caller: if(tail?, do: nil, else: caller), + depth: execution.depth, + mode: mode + } + + execution = %{execution | callers: [boundary | execution.callers], depth: depth} + run(new_frame(function, callable, args, this, closure_refs), execution) + end + end + defp callable_parts(%Function{} = function), do: {:ok, function, {}} defp callable_parts({:closure, %Function{} = function, closure_refs}), @@ -460,7 +572,7 @@ defmodule QuickBEAM.VM.Interpreter do do: return_value(:undefined, execution) defp execute(:return_async, [], %{stack: [value | _stack]}, execution), - do: return_value(value, execution) + do: complete_async(value, execution) defp execute(:add, [], frame, execution), do: binary(frame, execution, &Value.add/2) defp execute(:sub, [], frame, execution), do: binary(frame, execution, &Value.subtract/2) @@ -612,21 +724,11 @@ defmodule QuickBEAM.VM.Interpreter do do: raise_js(value, %{frame | stack: stack}, execution) defp execute(:await, [], %{stack: [%PromiseReference{} = promise | stack]} = frame, execution) do - case Promise.state(execution, promise) do - :pending -> - continuation = %Continuation{ - frame: next_frame(%{frame | stack: stack}), - execution: execution, - awaiting: promise - } - - {:suspended, continuation} - - {:fulfilled, value} -> - suspend_microtask({:ok, value}, %{frame | stack: stack}, execution) + frame = %{frame | stack: stack} - {:rejected, reason} -> - suspend_microtask({:error, reason}, %{frame | stack: stack}, execution) + case detach_async(frame, execution, promise) do + {:ok, result} -> result + :no_async_boundary -> suspend_promise_legacy(frame, execution, promise) end end @@ -641,13 +743,13 @@ defmodule QuickBEAM.VM.Interpreter do end defp execute(:await, [], %{stack: [{:resolved, value} | stack]} = frame, execution), - do: suspend_microtask({:ok, value}, %{frame | stack: stack}, execution) + do: await_immediate({:ok, value}, %{frame | stack: stack}, execution) defp execute(:await, [], %{stack: [{:rejected, reason} | stack]} = frame, execution), - do: suspend_microtask({:error, reason}, %{frame | stack: stack}, execution) + do: await_immediate({:error, reason}, %{frame | stack: stack}, execution) defp execute(:await, [], %{stack: [value | stack]} = frame, execution), - do: suspend_microtask({:ok, value}, %{frame | stack: stack}, execution) + do: await_immediate({:ok, value}, %{frame | stack: stack}, execution) defp execute(name, _operands, frame, execution) when name in [:nop, :set_name, :set_name_computed, :check_ctor, :close_loc], @@ -698,6 +800,65 @@ defmodule QuickBEAM.VM.Interpreter do defp dispatch_call({:host_function, :beam_call}, arguments, _this, caller, execution, tail?), do: start_host_call(arguments, caller, execution, tail?) + defp dispatch_call({:builtin, "Promise"}, [executor | _], _this, caller, execution, tail?) do + {promise, execution} = Promise.new(execution) + + boundary = %PromiseExecutorBoundary{ + promise: promise, + caller: caller, + depth: execution.depth, + tail?: tail? + } + + resolve = {:promise_resolver, promise, :resolve} + reject = {:promise_resolver, promise, :reject} + + if type_of(executor, execution) == "function" do + dispatch_call(executor, [resolve, reject], :undefined, boundary, execution, false) + else + execution = + Promise.settle( + execution, + promise, + {:error, {:type_error, :promise_executor_not_callable}} + ) + + complete_executor(boundary, execution) + end + end + + defp dispatch_call({:builtin, "Promise"}, _arguments, _this, caller, execution, tail?) do + {promise, execution} = Promise.new(execution) + + execution = + Promise.settle(execution, promise, {:error, {:type_error, :missing_promise_executor}}) + + complete_call_result(promise, caller, execution, tail?) + end + + defp dispatch_call( + {: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) + end + + complete_call_result(:undefined, caller, execution, tail?) + end + defp dispatch_call( {:bound_function, target, bound_this, bound_arguments}, arguments, @@ -741,18 +902,36 @@ defmodule QuickBEAM.VM.Interpreter do execution, tail? ) do - case {Promise.state(execution, promise), arguments} do - {{:fulfilled, value}, [callback | _]} -> - dispatch_call(callback, [value], :undefined, caller, execution, tail?) + on_fulfilled = Enum.at(arguments, 0, :undefined) + on_rejected = Enum.at(arguments, 1, :undefined) + {result_promise, execution} = Promise.react(execution, promise, on_fulfilled, on_rejected) + complete_call_result(result_promise, caller, execution, tail?) + end - {{:rejected, reason}, [_fulfilled, rejected | _]} -> - dispatch_call(rejected, [reason], :undefined, caller, execution, tail?) + defp dispatch_call( + {:promise_method, "catch"}, + arguments, + %PromiseReference{} = promise, + caller, + execution, + tail? + ) do + on_rejected = Enum.at(arguments, 0, :undefined) + {result_promise, execution} = Promise.react(execution, promise, :undefined, on_rejected) + complete_call_result(result_promise, caller, execution, tail?) + end - _ -> - if tail?, - do: return_value(promise, execution), - else: run(%{caller | stack: [promise | caller.stack]}, execution) - end + defp dispatch_call( + {:promise_method, "finally"}, + arguments, + %PromiseReference{} = promise, + caller, + execution, + tail? + ) do + callback = Enum.at(arguments, 0, :undefined) + {result_promise, execution} = Promise.finally(execution, promise, callback) + complete_call_result(result_promise, caller, execution, tail?) end defp dispatch_call(%Reference{} = reference, arguments, this, caller, execution, tail?) do @@ -784,9 +963,7 @@ defmodule QuickBEAM.VM.Interpreter do when elem(callable, 0) in [:builtin, :builtin_method, :primitive_method] do case Builtins.call(callable, this, arguments, execution) do {:ok, value, execution} -> - if tail?, - do: return_value(value, execution), - else: run(%{caller | stack: [value | caller.stack]}, execution) + complete_call_result(value, caller, execution, tail?) {:error, reason, execution} -> raise_js_from_caller({:type_error, reason}, caller, execution) @@ -796,6 +973,55 @@ defmodule QuickBEAM.VM.Interpreter do defp dispatch_call(callable, arguments, this, caller, execution, tail?), do: enter_call(callable, arguments, this, caller, execution, tail?) + defp complete_call_result(value, %ReactionBoundary{} = boundary, execution, _tail?), + do: complete_reaction(boundary, value, execution) + + defp complete_call_result(_value, %PromiseExecutorBoundary{} = boundary, execution, _tail?), + do: complete_executor(boundary, execution) + + defp complete_call_result(_value, %ThenableBoundary{}, execution, _tail?), + do: {:idle, execution} + + defp complete_call_result(value, %NativeFrame{} = 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_executor(boundary, execution) do + complete_call_result(boundary.promise, boundary.caller, execution, boundary.tail?) + end + + defp complete_reaction(%ReactionBoundary{mode: :then} = boundary, value, execution) do + execution = Promise.settle(execution, boundary.promise, {:ok, value}) + {:idle, execution} + end + + defp complete_reaction(%ReactionBoundary{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 + defp start_array_iteration(method, receiver, arguments, caller, execution, tail?) do with {:ok, value_list} <- interpreter_array_values(receiver, execution), [callback | rest] <- arguments do @@ -965,6 +1191,63 @@ defmodule QuickBEAM.VM.Interpreter do defp bitwise(frame, execution, operation), do: binary(frame, execution, &Value.bitwise(&1, &2, operation)) + defp detach_async(frame, execution, awaited_promise) do + case Enum.split_while(execution.callers, &(!match?(%AsyncBoundary{}, &1))) do + {inner_callers, [%AsyncBoundary{} = boundary | outer_callers]} -> + coroutine = %Coroutine{ + frame: next_frame(frame), + callers: inner_callers, + boundary: %{boundary | caller: nil, depth: 0, mode: :detached} + } + + execution = %{execution | callers: outer_callers, depth: boundary.depth} + execution = Promise.await(execution, awaited_promise, coroutine) + {:ok, deliver_async_promise(boundary, execution)} + + {_callers, []} -> + :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 Enum.split_while(execution.callers, &(!match?(%AsyncBoundary{}, &1))) do + {inner_callers, [%AsyncBoundary{} = boundary | outer_callers]} -> + coroutine = %Coroutine{ + frame: next_frame(frame), + callers: inner_callers, + boundary: %{boundary | caller: nil, depth: 0, mode: :detached} + } + + execution = %{execution | callers: outer_callers, depth: boundary.depth} + execution = Promise.enqueue_coroutine(execution, coroutine, result) + {:ok, deliver_async_promise(boundary, execution)} + + {_callers, []} -> + :no_async_boundary + end + end + + defp suspend_promise_legacy(frame, execution, promise) do + case Promise.state(execution, promise) do + :pending -> + {:suspended, + %Continuation{frame: next_frame(frame), execution: execution, awaiting: promise}} + + {:fulfilled, value} -> + suspend_microtask({:ok, value}, frame, execution) + + {:rejected, reason} -> + suspend_microtask({:error, reason}, frame, execution) + end + end + defp suspend_microtask(result, frame, execution) do execution = %{execution | jobs: :queue.in(result, execution.jobs)} @@ -1046,7 +1329,8 @@ defmodule QuickBEAM.VM.Interpreter do :function_method, :host_function, :primitive_method, - :promise_method + :promise_method, + :promise_resolver ], do: "function" @@ -1091,7 +1375,9 @@ defmodule QuickBEAM.VM.Interpreter do end end - defp get_property(%PromiseReference{}, "then", _execution), do: {:ok, {:promise_method, "then"}} + defp get_property(%PromiseReference{}, method, _execution) + when method in ["catch", "finally", "then"], + do: {:ok, {:promise_method, method}} defp get_property(%RegExp{}, key, _execution) when is_binary(key), do: {:ok, {:primitive_method, :regexp, key}} @@ -1104,7 +1390,8 @@ defmodule QuickBEAM.VM.Interpreter do :bound_function, :host_function, :primitive_method, - :promise_method + :promise_method, + :promise_resolver ], do: {:ok, {:function_method, key}} @@ -1197,17 +1484,51 @@ defmodule QuickBEAM.VM.Interpreter do |> div(2) end - defp raise_js(reason, %NativeFrame{caller: caller}, execution), - do: do_raise_js(QuickBEAM.JSError.vm_exception_value(reason), caller, execution, [], true) + defp raise_js(reason, %NativeFrame{caller: caller}, execution) do + {reason, trace} = throw_state(reason) + do_raise_js(reason, caller, execution, trace, true) + end + + defp raise_js(reason, frame, execution) do + {reason, trace} = throw_state(reason) + do_raise_js(reason, frame, execution, trace, false) + end + + defp raise_js_from_caller(reason, %ThenableBoundary{} = boundary, execution) do + {reason, trace} = throw_state(reason) + thrown = %Thrown{value: reason, frames: Enum.reverse(trace)} + execution = Promise.settle_assimilated(execution, boundary.promise, {:error, thrown}) + {:idle, execution} + end + + defp raise_js_from_caller(reason, %PromiseExecutorBoundary{} = boundary, execution) do + {reason, trace} = throw_state(reason) + thrown = %Thrown{value: reason, frames: Enum.reverse(trace)} + execution = Promise.settle(execution, boundary.promise, {:error, thrown}) + complete_executor(boundary, execution) + end + + defp raise_js_from_caller(reason, %ReactionBoundary{} = boundary, execution) do + {reason, trace} = throw_state(reason) + thrown = %Thrown{value: reason, frames: Enum.reverse(trace)} + execution = Promise.settle(execution, boundary.promise, {:error, thrown}) + {:idle, execution} + end + + defp raise_js_from_caller(reason, %NativeFrame{caller: caller}, execution) do + {reason, trace} = throw_state(reason) + do_raise_js(reason, caller, execution, trace, true) + end - defp raise_js(reason, frame, execution), - do: do_raise_js(QuickBEAM.JSError.vm_exception_value(reason), frame, execution, [], false) + defp raise_js_from_caller(reason, frame, execution) do + {reason, trace} = throw_state(reason) + do_raise_js(reason, frame, execution, trace, true) + end - defp raise_js_from_caller(reason, %NativeFrame{caller: caller}, execution), - do: do_raise_js(QuickBEAM.JSError.vm_exception_value(reason), caller, execution, [], true) + defp throw_state(%Thrown{value: value, frames: frames}), + do: {QuickBEAM.JSError.vm_exception_value(value), Enum.reverse(frames)} - defp raise_js_from_caller(reason, frame, execution), - do: do_raise_js(QuickBEAM.JSError.vm_exception_value(reason), frame, execution, [], true) + defp throw_state(reason), do: {QuickBEAM.JSError.vm_exception_value(reason), []} defp do_raise_js(reason, frame, execution, trace, caller?) do case split_at_catch(frame.stack) do @@ -1225,6 +1546,50 @@ defmodule QuickBEAM.VM.Interpreter do {:error, error, execution} end + defp unwind_caller( + reason, + %Execution{callers: [%ThenableBoundary{} = boundary | callers]} = execution, + trace + ) do + thrown = %Thrown{value: reason, frames: Enum.reverse(trace)} + execution = %{execution | callers: callers, depth: boundary.depth} + execution = Promise.settle_assimilated(execution, boundary.promise, {:error, thrown}) + {:idle, execution} + end + + defp unwind_caller( + reason, + %Execution{callers: [%PromiseExecutorBoundary{} = boundary | callers]} = execution, + trace + ) do + thrown = %Thrown{value: reason, frames: Enum.reverse(trace)} + execution = %{execution | callers: callers, depth: boundary.depth} + execution = Promise.settle(execution, boundary.promise, {:error, thrown}) + complete_executor(boundary, execution) + end + + defp unwind_caller( + reason, + %Execution{callers: [%ReactionBoundary{} = boundary | callers]} = execution, + trace + ) do + thrown = %Thrown{value: reason, frames: Enum.reverse(trace)} + execution = %{execution | callers: callers, depth: boundary.depth} + execution = Promise.settle(execution, boundary.promise, {:error, thrown}) + {:idle, execution} + end + + defp unwind_caller( + reason, + %Execution{callers: [%AsyncBoundary{} = boundary | callers]} = execution, + trace + ) do + thrown = %Thrown{value: reason, frames: Enum.reverse(trace)} + execution = %{execution | callers: callers, depth: boundary.depth} + execution = Promise.settle(execution, boundary.promise, {:error, thrown}) + deliver_async_promise(boundary, execution) + end + defp unwind_caller( reason, %Execution{callers: [%NativeFrame{} = native | callers]} = execution, @@ -1279,8 +1644,70 @@ defmodule QuickBEAM.VM.Interpreter do end end + defp complete_async( + value, + %Execution{callers: [%AsyncBoundary{} = boundary | callers]} = execution + ) do + execution = %{execution | callers: callers, depth: boundary.depth} + execution = Promise.settle(execution, boundary.promise, {:ok, value}) + deliver_async_promise(boundary, execution) + end + + defp complete_async(value, execution), do: return_value(value, execution) + + defp deliver_async_promise( + %AsyncBoundary{mode: :push, caller: caller, promise: promise}, + execution + ), + do: run(%{caller | stack: [promise | caller.stack]}, execution) + + defp deliver_async_promise(%AsyncBoundary{mode: :return, promise: promise}, execution), + do: return_value(promise, execution) + + defp deliver_async_promise( + %AsyncBoundary{mode: :reaction, caller: boundary, promise: promise}, + execution + ) do + complete_reaction(boundary, promise, execution) + end + + defp deliver_async_promise(%AsyncBoundary{mode: :executor, caller: boundary}, execution), + do: complete_executor(boundary, execution) + + defp deliver_async_promise(%AsyncBoundary{mode: :thenable}, execution), + do: {:idle, execution} + + defp deliver_async_promise(%AsyncBoundary{mode: :detached}, execution), + do: {:idle, execution} + defp return_value(value, %Execution{callers: []} = execution), - do: {:ok, value, execution} + do: {:ok, value, %{execution | depth: 0}} + + defp return_value(value, %Execution{callers: [%AsyncBoundary{} | _]} = execution), + do: complete_async(value, execution) + + defp return_value( + value, + %Execution{callers: [%ReactionBoundary{} = boundary | callers]} = execution + ) do + execution = %{execution | callers: callers, depth: boundary.depth} + complete_reaction(boundary, value, execution) + end + + defp return_value( + _value, + %Execution{callers: [%PromiseExecutorBoundary{} = boundary | callers]} = execution + ) do + execution = %{execution | callers: callers, depth: boundary.depth} + complete_executor(boundary, execution) + end + + defp return_value( + _value, + %Execution{callers: [%ThenableBoundary{} = boundary | callers]} = execution + ) do + {:idle, %{execution | callers: callers, depth: boundary.depth}} + end defp return_value(value, %Execution{callers: [%NativeFrame{} = native | callers]} = execution) do execution = %{execution | callers: callers, depth: execution.depth - 1} diff --git a/lib/quickbeam/vm/promise.ex b/lib/quickbeam/vm/promise.ex index 46512e02a..fa3d8719a 100644 --- a/lib/quickbeam/vm/promise.ex +++ b/lib/quickbeam/vm/promise.ex @@ -1,7 +1,16 @@ defmodule QuickBEAM.VM.Promise do @moduledoc false - alias QuickBEAM.VM.{Execution, Memory, PromiseReference} + alias QuickBEAM.VM.{ + Builtins, + Coroutine, + Execution, + Heap, + Memory, + PromiseReference, + Reaction, + Reference + } @type state :: :pending | {:fulfilled, term()} | {:rejected, term()} @@ -9,7 +18,6 @@ defmodule QuickBEAM.VM.Promise do def new(%Execution{} = execution) do id = execution.next_promise_id reference = %PromiseReference{id: id} - execution = Memory.charge_promise(execution) execution = %{ @@ -22,21 +30,366 @@ defmodule QuickBEAM.VM.Promise do end @spec state(Execution.t(), PromiseReference.t()) :: state() - def state(%Execution{} = execution, %PromiseReference{id: id}), - do: Map.fetch!(execution.promises, id) + def state(%Execution{} = execution, %PromiseReference{id: id}) do + case Map.fetch!(execution.promises, id) do + :resolving -> :pending + state -> state + end + end + + @spec await(Execution.t(), PromiseReference.t(), Coroutine.t()) :: Execution.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 + + @spec react(Execution.t(), PromiseReference.t(), term(), term()) :: + {PromiseReference.t(), Execution.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 + } + + execution = + 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 + + {result_promise, execution} + end + + @spec aggregate(Execution.t(), :all | :all_settled | :any | :race, [term()]) :: + {PromiseReference.t(), Execution.t()} + def aggregate(execution, kind, values) do + {result_promise, execution} = new(execution) + + if values == [] do + result = + case kind do + :all -> {:ok, []} + :all_settled -> {:ok, []} + :any -> {:error, aggregate_error([])} + :race -> nil + end + + execution = if result, do: settle(execution, result_promise, result), else: execution + {result_promise, 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) + + {result_promise, execution} + end + end + + @spec settle_aggregate( + Execution.t(), + reference(), + non_neg_integer(), + {:ok, term()} | {:error, term()} + ) :: + Execution.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 + + @spec finally(Execution.t(), PromiseReference.t(), term()) :: + {PromiseReference.t(), Execution.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 + } + + execution = + 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 + + {result_promise, execution} + end + + @spec settle_after_finally( + Execution.t(), + PromiseReference.t(), + PromiseReference.t(), + {:ok, term()} | {:error, term()} + ) :: Execution.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 + + @spec enqueue_coroutine(Execution.t(), Coroutine.t(), {:ok, term()} | {:error, term()}) :: + Execution.t() + def enqueue_coroutine(execution, %Coroutine{} = coroutine, result), + do: enqueue(execution, {:resume_coroutine, coroutine, result}) @spec settle(Execution.t(), PromiseReference.t(), {:ok, term()} | {:error, term()}) :: Execution.t() - def settle(%Execution{} = execution, %PromiseReference{id: id}, result) do - state = + 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}) + + :none -> + settle_result(execution, promise, result) + end + + _settled_or_resolving -> + execution + end + end + + def settle(%Execution{} = execution, %PromiseReference{} = promise, result), + do: settle_result(execution, promise, result) + + @doc false + @spec settle_assimilated(Execution.t(), PromiseReference.t(), {:ok, term()} | {:error, term()}) :: + Execution.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 + + defp settle_result(%Execution{} = 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 Heap.get(execution, reference, "then") do + {:ok, %Reference{} = callable} -> + if Builtins.callable(execution, callable), do: {:ok, callable}, else: :none + + {:ok, callable} + when is_tuple(callable) and + elem(callable, 0) in [ + :bound_function, + :builtin, + :builtin_method, + :host_function, + :primitive_method, + :promise_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} -> {:fulfilled, value} - {:error, reason} -> {:rejected, reason} + {:ok, value} -> %{"status" => "fulfilled", "value" => value} + {:error, reason} -> %{"status" => "rejected", "reason" => unwrap_reason(reason)} end - case Map.fetch!(execution.promises, id) do - :pending -> %{execution | promises: Map.put(execution.promises, id, state)} - _settled -> execution + 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.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)} end diff --git a/lib/quickbeam/vm/promise_executor_boundary.ex b/lib/quickbeam/vm/promise_executor_boundary.ex new file mode 100644 index 000000000..145eb9fae --- /dev/null +++ b/lib/quickbeam/vm/promise_executor_boundary.ex @@ -0,0 +1,13 @@ +defmodule QuickBEAM.VM.PromiseExecutorBoundary do + @moduledoc false + + @enforce_keys [:promise, :caller, :depth] + defstruct [:promise, :caller, :depth, tail?: false] + + @type t :: %__MODULE__{ + promise: QuickBEAM.VM.PromiseReference.t(), + caller: QuickBEAM.VM.Frame.t() | QuickBEAM.VM.NativeFrame.t(), + depth: non_neg_integer(), + tail?: boolean() + } +end diff --git a/lib/quickbeam/vm/reaction.ex b/lib/quickbeam/vm/reaction.ex new file mode 100644 index 000000000..2d99ec34e --- /dev/null +++ b/lib/quickbeam/vm/reaction.ex @@ -0,0 +1,13 @@ +defmodule QuickBEAM.VM.Reaction do + @moduledoc false + + @enforce_keys [:result_promise] + defstruct [:result_promise, kind: :then, on_fulfilled: :undefined, on_rejected: :undefined] + + @type t :: %__MODULE__{ + result_promise: QuickBEAM.VM.PromiseReference.t(), + kind: :then | :finally, + on_fulfilled: term(), + on_rejected: term() + } +end diff --git a/lib/quickbeam/vm/reaction_boundary.ex b/lib/quickbeam/vm/reaction_boundary.ex new file mode 100644 index 000000000..971e0f5be --- /dev/null +++ b/lib/quickbeam/vm/reaction_boundary.ex @@ -0,0 +1,13 @@ +defmodule QuickBEAM.VM.ReactionBoundary do + @moduledoc false + + @enforce_keys [:promise, :depth] + defstruct [:promise, :depth, mode: :then, original_result: nil] + + @type t :: %__MODULE__{ + promise: QuickBEAM.VM.PromiseReference.t(), + depth: non_neg_integer(), + mode: :then | :finally, + original_result: {:ok, term()} | {:error, term()} | nil + } +end diff --git a/lib/quickbeam/vm/thenable_boundary.ex b/lib/quickbeam/vm/thenable_boundary.ex new file mode 100644 index 000000000..d37a6f7da --- /dev/null +++ b/lib/quickbeam/vm/thenable_boundary.ex @@ -0,0 +1,11 @@ +defmodule QuickBEAM.VM.ThenableBoundary do + @moduledoc false + + @enforce_keys [:promise, :depth] + defstruct [:promise, :depth] + + @type t :: %__MODULE__{ + promise: QuickBEAM.VM.PromiseReference.t(), + depth: non_neg_integer() + } +end diff --git a/lib/quickbeam/vm/thrown.ex b/lib/quickbeam/vm/thrown.ex new file mode 100644 index 000000000..56e2432c3 --- /dev/null +++ b/lib/quickbeam/vm/thrown.ex @@ -0,0 +1,8 @@ +defmodule QuickBEAM.VM.Thrown do + @moduledoc false + + @enforce_keys [:value, :frames] + defstruct [:value, :frames] + + @type t :: %__MODULE__{value: term(), frames: [QuickBEAM.JSError.frame()]} +end diff --git a/test/vm/async_test.exs b/test/vm/async_test.exs index eea567684..dc7b8185b 100644 --- a/test/vm/async_test.exs +++ b/test/vm/async_test.exs @@ -32,6 +32,27 @@ defmodule QuickBEAM.VM.AsyncTest do 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() { diff --git a/test/vm/interpreter_test.exs b/test/vm/interpreter_test.exs index af0c15435..7ae73d8b2 100644 --- a/test/vm/interpreter_test.exs +++ b/test/vm/interpreter_test.exs @@ -185,7 +185,7 @@ defmodule QuickBEAM.VM.InterpreterTest do source = """ (async function() { try { - return await (async function() { return await marker })() + return await (async function() { await 0; throw 41 })() } catch (error) { return error + 1 } @@ -193,11 +193,7 @@ defmodule QuickBEAM.VM.InterpreterTest do """ assert {:ok, program} = QuickBEAM.VM.compile(source) - - assert {:suspended, continuation} = - Interpreter.eval(program, vars: %{"marker" => {:pending, make_ref()}}) - - assert {:ok, 42} = Interpreter.resume(continuation, {:error, 41}) + assert {:ok, 42} = QuickBEAM.VM.eval(program) end test "reports unsupported opcodes without crashing the caller" do diff --git a/test/vm/promise_test.exs b/test/vm/promise_test.exs new file mode 100644 index 000000000..598b6c275 --- /dev/null +++ b/test/vm/promise_test.exs @@ -0,0 +1,167 @@ +defmodule QuickBEAM.VM.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)})", + "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(','))" + ] + + 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 + + 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 From b84803cf73bc31203411129a3afd0a1db25c20c0 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 10 Jul 2026 16:18:55 +0200 Subject: [PATCH 14/87] Document VM internals without suppressing docs --- lib/quickbeam/js_error.ex | 13 +++++++++--- lib/quickbeam/vm.ex | 2 +- lib/quickbeam/vm/abi.ex | 6 +++--- lib/quickbeam/vm/abi_generator.ex | 7 ++++++- lib/quickbeam/vm/async_boundary.ex | 7 ++++++- lib/quickbeam/vm/builtins.ex | 4 +++- lib/quickbeam/vm/checksum.ex | 2 +- lib/quickbeam/vm/continuation.ex | 2 +- lib/quickbeam/vm/coroutine.ex | 7 ++++++- lib/quickbeam/vm/evaluator.ex | 7 ++++++- lib/quickbeam/vm/execution.ex | 7 ++++++- lib/quickbeam/vm/export.ex | 7 ++++++- lib/quickbeam/vm/frame.ex | 2 +- lib/quickbeam/vm/heap.ex | 7 ++++++- lib/quickbeam/vm/interpreter.ex | 20 ++++++++++++------- lib/quickbeam/vm/memory.ex | 7 ++++++- lib/quickbeam/vm/native_frame.ex | 2 +- lib/quickbeam/vm/object.ex | 2 +- lib/quickbeam/vm/opcodes.ex | 4 ++-- lib/quickbeam/vm/promise.ex | 14 +++++++++++-- lib/quickbeam/vm/promise_executor_boundary.ex | 7 ++++++- lib/quickbeam/vm/promise_reference.ex | 2 +- lib/quickbeam/vm/property.ex | 2 +- lib/quickbeam/vm/reaction.ex | 4 +++- lib/quickbeam/vm/reaction_boundary.ex | 7 ++++++- lib/quickbeam/vm/reference.ex | 2 +- lib/quickbeam/vm/regexp.ex | 2 +- lib/quickbeam/vm/thenable_boundary.ex | 4 +++- lib/quickbeam/vm/thrown.ex | 7 ++++++- lib/quickbeam/vm/value.ex | 4 +++- lib/quickbeam/vm/verifier.ex | 7 ++++++- mix.exs | 14 +++++++++++++ 32 files changed, 148 insertions(+), 43 deletions(-) diff --git a/lib/quickbeam/js_error.ex b/lib/quickbeam/js_error.ex index ae810ba93..a613d84b9 100644 --- a/lib/quickbeam/js_error.ex +++ b/lib/quickbeam/js_error.ex @@ -1,4 +1,11 @@ defmodule QuickBEAM.JSError do + @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 :: %{ @@ -23,7 +30,7 @@ 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)), @@ -44,7 +51,7 @@ defmodule QuickBEAM.JSError do %__MODULE__{message: inspect(value), name: "Error", stack: nil} end - @doc false + @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 [ @@ -60,7 +67,7 @@ defmodule QuickBEAM.JSError do def vm_exception_value(reason), do: reason - @doc false + @doc "Builds an exception from an uncaught VM value and JavaScript stack frames." @spec from_vm(term(), [frame()]) :: t() def from_vm(%QuickBEAM.VM.Thrown{value: value, frames: async_frames}, frames), do: from_vm(value, async_frames ++ frames) diff --git a/lib/quickbeam/vm.ex b/lib/quickbeam/vm.ex index 96165c5e6..cb3ebc353 100644 --- a/lib/quickbeam/vm.ex +++ b/lib/quickbeam/vm.ex @@ -199,7 +199,7 @@ defmodule QuickBEAM.VM do kind, reason -> {:error, {:interpreter_crash, {kind, reason}, __STACKTRACE__}} end - @doc false + @doc "Returns the monitored worker spawn options for an evaluation memory limit." def worker_spawn_options(:infinity), do: [:monitor] def worker_spawn_options(memory_limit) do diff --git a/lib/quickbeam/vm/abi.ex b/lib/quickbeam/vm/abi.ex index 87bd90a3d..bbb40ce54 100644 --- a/lib/quickbeam/vm/abi.ex +++ b/lib/quickbeam/vm/abi.ex @@ -42,12 +42,12 @@ defmodule QuickBEAM.VM.ABI do @doc "Returns a fingerprint for the exact vendored QuickJS bytecode ABI." def fingerprint, do: @fingerprint - @doc false + @doc "Returns serialized-value tags generated from the vendored QuickJS source." def tags, do: @tags - @doc false + @doc "Returns opcode metadata generated from the vendored QuickJS header." def opcodes, do: @opcodes - @doc false + @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 index acd38d514..f3f076338 100644 --- a/lib/quickbeam/vm/abi_generator.ex +++ b/lib/quickbeam/vm/abi_generator.ex @@ -1,5 +1,10 @@ defmodule QuickBEAM.VM.ABIGenerator do - @moduledoc false + @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. + """ @opcode_pattern ~r/^\s*DEF\(\s*([A-Za-z0-9_]+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*([A-Za-z0-9_]+)\s*\)/m @atom_pattern ~r/^\s*DEF\(\s*([A-Za-z0-9_]+)\s*,\s*"([^"]*)"\s*\)/m diff --git a/lib/quickbeam/vm/async_boundary.ex b/lib/quickbeam/vm/async_boundary.ex index 248353a26..12fbc5d8a 100644 --- a/lib/quickbeam/vm/async_boundary.ex +++ b/lib/quickbeam/vm/async_boundary.ex @@ -1,5 +1,10 @@ defmodule QuickBEAM.VM.AsyncBoundary do - @moduledoc false + @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] diff --git a/lib/quickbeam/vm/builtins.ex b/lib/quickbeam/vm/builtins.ex index e7b528ec1..91c7b2b90 100644 --- a/lib/quickbeam/vm/builtins.ex +++ b/lib/quickbeam/vm/builtins.ex @@ -1,5 +1,7 @@ defmodule QuickBEAM.VM.Builtins do - @moduledoc false + @moduledoc """ + Installs and dispatches the JavaScript built-ins supported by the VM profile. + """ alias QuickBEAM.VM.{Execution, Heap, Object, Property, Reference, RegExp, Value} diff --git a/lib/quickbeam/vm/checksum.ex b/lib/quickbeam/vm/checksum.ex index a78db94af..5befd6459 100644 --- a/lib/quickbeam/vm/checksum.ex +++ b/lib/quickbeam/vm/checksum.ex @@ -1,5 +1,5 @@ defmodule QuickBEAM.VM.Checksum do - @moduledoc false + @moduledoc "Computes stable checksums for decoded VM program artifacts." import Bitwise diff --git a/lib/quickbeam/vm/continuation.ex b/lib/quickbeam/vm/continuation.ex index 7bc594b3e..a2dd9acc8 100644 --- a/lib/quickbeam/vm/continuation.ex +++ b/lib/quickbeam/vm/continuation.ex @@ -1,5 +1,5 @@ defmodule QuickBEAM.VM.Continuation do - @moduledoc false + @moduledoc "Captures a legacy suspended frame and its owning execution state." @enforce_keys [:frame, :execution] defstruct [:frame, :execution, :awaiting] diff --git a/lib/quickbeam/vm/coroutine.ex b/lib/quickbeam/vm/coroutine.ex index 3dbfb0a7b..88f0e34ff 100644 --- a/lib/quickbeam/vm/coroutine.ex +++ b/lib/quickbeam/vm/coroutine.ex @@ -1,5 +1,10 @@ defmodule QuickBEAM.VM.Coroutine do - @moduledoc false + @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] diff --git a/lib/quickbeam/vm/evaluator.ex b/lib/quickbeam/vm/evaluator.ex index 2f38d22c4..3a4627cb3 100644 --- a/lib/quickbeam/vm/evaluator.ex +++ b/lib/quickbeam/vm/evaluator.ex @@ -1,5 +1,10 @@ defmodule QuickBEAM.VM.Evaluator do - @moduledoc false + @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.{ Continuation, diff --git a/lib/quickbeam/vm/execution.ex b/lib/quickbeam/vm/execution.ex index c809b9765..0c91127b3 100644 --- a/lib/quickbeam/vm/execution.ex +++ b/lib/quickbeam/vm/execution.ex @@ -1,5 +1,10 @@ defmodule QuickBEAM.VM.Execution do - @moduledoc false + @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. + """ @enforce_keys [:atoms, :max_stack_depth, :remaining_steps, :step_limit] defstruct [ diff --git a/lib/quickbeam/vm/export.ex b/lib/quickbeam/vm/export.ex index 223975ca4..c231b2271 100644 --- a/lib/quickbeam/vm/export.ex +++ b/lib/quickbeam/vm/export.ex @@ -1,5 +1,10 @@ defmodule QuickBEAM.VM.Export do - @moduledoc false + @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.{Execution, Heap, Object, Promise, PromiseReference, Property, Reference} diff --git a/lib/quickbeam/vm/frame.ex b/lib/quickbeam/vm/frame.ex index a904fb250..621e77829 100644 --- a/lib/quickbeam/vm/frame.ex +++ b/lib/quickbeam/vm/frame.ex @@ -1,5 +1,5 @@ defmodule QuickBEAM.VM.Frame do - @moduledoc false + @moduledoc "Defines an explicit JavaScript bytecode call frame." @enforce_keys [:function, :callable, :locals, :args] defstruct [:function, :callable, :locals, :args, :this, closure_refs: {}, pc: 0, stack: []] diff --git a/lib/quickbeam/vm/heap.ex b/lib/quickbeam/vm/heap.ex index 438f4fe47..3c8cc1831 100644 --- a/lib/quickbeam/vm/heap.ex +++ b/lib/quickbeam/vm/heap.ex @@ -1,5 +1,10 @@ defmodule QuickBEAM.VM.Heap do - @moduledoc false + @moduledoc """ + Manages objects and properties in an evaluation's process-owned heap. + + Live references are valid only with the `QuickBEAM.VM.Execution` that owns + them and are exported to ordinary BEAM values at the evaluation boundary. + """ alias QuickBEAM.VM.{Execution, Memory, Object, Property, Reference} diff --git a/lib/quickbeam/vm/interpreter.ex b/lib/quickbeam/vm/interpreter.ex index 510cdad6d..b8a27f0a4 100644 --- a/lib/quickbeam/vm/interpreter.ex +++ b/lib/quickbeam/vm/interpreter.ex @@ -1,5 +1,11 @@ defmodule QuickBEAM.VM.Interpreter do - @moduledoc false + @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. + """ import Bitwise @@ -41,7 +47,7 @@ defmodule QuickBEAM.VM.Interpreter do @spec eval(Program.t(), keyword()) :: result() def eval(%Program{} = program, opts \\ []), do: program |> start(opts) |> finish() - @doc false + @doc "Starts interpreting a program and returns its raw machine result." def start(%Program{} = program, opts \\ []) do max_steps = Keyword.get(opts, :max_steps, @default_max_steps) @@ -67,7 +73,7 @@ defmodule QuickBEAM.VM.Interpreter do def resume(%Continuation{} = continuation, result), do: continuation |> resume_raw(result) |> finish() - @doc false + @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) @@ -77,7 +83,7 @@ defmodule QuickBEAM.VM.Interpreter do raise_js_from_caller(reason, continuation.frame, continuation.execution) end - @doc false + @doc "Resumes a detached async coroutine with a Promise settlement." def resume_coroutine(%Coroutine{} = coroutine, result, %Execution{} = execution) do callers = coroutine.callers ++ [coroutine.boundary] frame_depth = Enum.count(coroutine.callers, &match?(%Frame{}, &1)) @@ -89,7 +95,7 @@ defmodule QuickBEAM.VM.Interpreter do end end - @doc false + @doc "Invokes a thenable and connects its resolver functions to a Promise." def assimilate_thenable(promise, thenable, callable, %Execution{} = execution) do boundary = %ThenableBoundary{promise: promise, depth: execution.depth} resolve = {:promise_resolver, promise, :resolve_assimilated} @@ -97,7 +103,7 @@ defmodule QuickBEAM.VM.Interpreter do dispatch_call(callable, [resolve, reject], thenable, boundary, execution, false) end - @doc false + @doc "Runs one queued Promise reaction against a source settlement." def run_reaction(%Reaction{} = reaction, result, %Execution{} = execution) do callback = case result do @@ -125,7 +131,7 @@ defmodule QuickBEAM.VM.Interpreter do defp reaction_argument({:error, %Thrown{value: value}}), do: value defp reaction_argument({:error, reason}), do: reason - @doc false + @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} diff --git a/lib/quickbeam/vm/memory.ex b/lib/quickbeam/vm/memory.ex index 632c4d909..935083263 100644 --- a/lib/quickbeam/vm/memory.ex +++ b/lib/quickbeam/vm/memory.ex @@ -1,5 +1,10 @@ defmodule QuickBEAM.VM.Memory do - @moduledoc false + @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.Execution diff --git a/lib/quickbeam/vm/native_frame.ex b/lib/quickbeam/vm/native_frame.ex index ed67d525f..19462555a 100644 --- a/lib/quickbeam/vm/native_frame.ex +++ b/lib/quickbeam/vm/native_frame.ex @@ -1,5 +1,5 @@ defmodule QuickBEAM.VM.NativeFrame do - @moduledoc false + @moduledoc "Defines resumable state for a VM-implemented native callback." @enforce_keys [:operation, :values, :callback, :receiver, :caller] defstruct [ diff --git a/lib/quickbeam/vm/object.ex b/lib/quickbeam/vm/object.ex index b85eb9e25..279496494 100644 --- a/lib/quickbeam/vm/object.ex +++ b/lib/quickbeam/vm/object.ex @@ -1,5 +1,5 @@ defmodule QuickBEAM.VM.Object do - @moduledoc false + @moduledoc "Defines an object stored in an evaluation-owned VM heap." defstruct kind: :ordinary, prototype: nil, diff --git a/lib/quickbeam/vm/opcodes.ex b/lib/quickbeam/vm/opcodes.ex index 812c92b62..ec325e802 100644 --- a/lib/quickbeam/vm/opcodes.ex +++ b/lib/quickbeam/vm/opcodes.ex @@ -54,7 +54,7 @@ defmodule QuickBEAM.VM.Opcodes do end for {name, value} <- @tags do - @doc false + @doc "Returns a generated serialized-bytecode tag value." def unquote(:"bc_tag_#{name}")(), do: unquote(value) end @@ -159,7 +159,7 @@ defmodule QuickBEAM.VM.Opcodes do end end - @doc false + @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} -> diff --git a/lib/quickbeam/vm/promise.ex b/lib/quickbeam/vm/promise.ex index fa3d8719a..39f9cb80b 100644 --- a/lib/quickbeam/vm/promise.ex +++ b/lib/quickbeam/vm/promise.ex @@ -1,5 +1,10 @@ defmodule QuickBEAM.VM.Promise do - @moduledoc false + @moduledoc """ + Implements owner-local Promise state, reactions, adoption, and combinators. + + Promise state and jobs live in `QuickBEAM.VM.Execution`; this module only + transforms that explicit state and never starts independent processes. + """ alias QuickBEAM.VM.{ Builtins, @@ -213,7 +218,12 @@ defmodule QuickBEAM.VM.Promise do def settle(%Execution{} = execution, %PromiseReference{} = promise, result), do: settle_result(execution, promise, result) - @doc false + @doc """ + Settles a Promise whose resolution was locked while adopting a thenable. + + Settlements are ignored unless the Promise is currently in the internal + resolving state, which preserves resolver idempotence. + """ @spec settle_assimilated(Execution.t(), PromiseReference.t(), {:ok, term()} | {:error, term()}) :: Execution.t() def settle_assimilated(execution, %PromiseReference{id: id} = promise, result) do diff --git a/lib/quickbeam/vm/promise_executor_boundary.ex b/lib/quickbeam/vm/promise_executor_boundary.ex index 145eb9fae..d345185d6 100644 --- a/lib/quickbeam/vm/promise_executor_boundary.ex +++ b/lib/quickbeam/vm/promise_executor_boundary.ex @@ -1,5 +1,10 @@ defmodule QuickBEAM.VM.PromiseExecutorBoundary do - @moduledoc false + @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] diff --git a/lib/quickbeam/vm/promise_reference.ex b/lib/quickbeam/vm/promise_reference.ex index be242e5e0..4b3b57442 100644 --- a/lib/quickbeam/vm/promise_reference.ex +++ b/lib/quickbeam/vm/promise_reference.ex @@ -1,5 +1,5 @@ defmodule QuickBEAM.VM.PromiseReference do - @moduledoc false + @moduledoc "Identifies a Promise in one evaluation's Promise store." @enforce_keys [:id] defstruct [:id] diff --git a/lib/quickbeam/vm/property.ex b/lib/quickbeam/vm/property.ex index 96a019591..a8bbacf63 100644 --- a/lib/quickbeam/vm/property.ex +++ b/lib/quickbeam/vm/property.ex @@ -1,5 +1,5 @@ defmodule QuickBEAM.VM.Property do - @moduledoc false + @moduledoc "Defines a JavaScript property value and its descriptor flags." defstruct value: :undefined, writable: true, enumerable: true, configurable: true diff --git a/lib/quickbeam/vm/reaction.ex b/lib/quickbeam/vm/reaction.ex index 2d99ec34e..d24240a2a 100644 --- a/lib/quickbeam/vm/reaction.ex +++ b/lib/quickbeam/vm/reaction.ex @@ -1,5 +1,7 @@ defmodule QuickBEAM.VM.Reaction do - @moduledoc false + @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] diff --git a/lib/quickbeam/vm/reaction_boundary.ex b/lib/quickbeam/vm/reaction_boundary.ex index 971e0f5be..5a96e142f 100644 --- a/lib/quickbeam/vm/reaction_boundary.ex +++ b/lib/quickbeam/vm/reaction_boundary.ex @@ -1,5 +1,10 @@ defmodule QuickBEAM.VM.ReactionBoundary do - @moduledoc false + @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] diff --git a/lib/quickbeam/vm/reference.ex b/lib/quickbeam/vm/reference.ex index 2d4016fe7..fc51712d8 100644 --- a/lib/quickbeam/vm/reference.ex +++ b/lib/quickbeam/vm/reference.ex @@ -1,5 +1,5 @@ defmodule QuickBEAM.VM.Reference do - @moduledoc false + @moduledoc "Identifies an object in one evaluation-owned VM heap." @enforce_keys [:id] defstruct [:id] diff --git a/lib/quickbeam/vm/regexp.ex b/lib/quickbeam/vm/regexp.ex index 1989e7daf..09d36aebb 100644 --- a/lib/quickbeam/vm/regexp.ex +++ b/lib/quickbeam/vm/regexp.ex @@ -1,5 +1,5 @@ defmodule QuickBEAM.VM.RegExp do - @moduledoc false + @moduledoc "Defines the VM representation of a JavaScript regular expression." @enforce_keys [:source, :bytecode] defstruct [:source, :bytecode] diff --git a/lib/quickbeam/vm/thenable_boundary.ex b/lib/quickbeam/vm/thenable_boundary.ex index d37a6f7da..a3a974f96 100644 --- a/lib/quickbeam/vm/thenable_boundary.ex +++ b/lib/quickbeam/vm/thenable_boundary.ex @@ -1,5 +1,7 @@ defmodule QuickBEAM.VM.ThenableBoundary do - @moduledoc false + @moduledoc """ + Tracks invocation of a foreign thenable's `then` method during assimilation. + """ @enforce_keys [:promise, :depth] defstruct [:promise, :depth] diff --git a/lib/quickbeam/vm/thrown.ex b/lib/quickbeam/vm/thrown.ex index 56e2432c3..0cf457093 100644 --- a/lib/quickbeam/vm/thrown.ex +++ b/lib/quickbeam/vm/thrown.ex @@ -1,5 +1,10 @@ defmodule QuickBEAM.VM.Thrown do - @moduledoc false + @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] diff --git a/lib/quickbeam/vm/value.ex b/lib/quickbeam/vm/value.ex index b697a10b1..1163bcf04 100644 --- a/lib/quickbeam/vm/value.ex +++ b/lib/quickbeam/vm/value.ex @@ -1,5 +1,7 @@ defmodule QuickBEAM.VM.Value do - @moduledoc false + @moduledoc """ + Implements JavaScript primitive coercion, equality, and numeric operations. + """ import Bitwise diff --git a/lib/quickbeam/vm/verifier.ex b/lib/quickbeam/vm/verifier.ex index 57023e356..871910948 100644 --- a/lib/quickbeam/vm/verifier.ex +++ b/lib/quickbeam/vm/verifier.ex @@ -1,5 +1,10 @@ defmodule QuickBEAM.VM.Verifier do - @moduledoc false + @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, Function, Opcodes, Program} diff --git a/mix.exs b/mix.exs index 1f6512892..957ed01eb 100644 --- a/mix.exs +++ b/mix.exs @@ -110,7 +110,21 @@ defmodule QuickBEAM.MixProject do groups_for_extras: [ Guides: ["docs/javascript-api.md", "docs/architecture.md"] ], + filter_modules: &documented_module?/2, source_ref: "v#{@version}" ] end + + defp documented_module?(module, _metadata) do + public_vm_modules = [ + QuickBEAM.VM.ABI, + QuickBEAM.VM.ClosureVariable, + QuickBEAM.VM.Function, + QuickBEAM.VM.Program, + QuickBEAM.VM.SourcePosition, + QuickBEAM.VM.Variable + ] + + not String.starts_with?(inspect(module), "QuickBEAM.VM.") or module in public_vm_modules + end end From 10b29ea37f73c96ef13d5169ed23fd13009acd6b Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 10 Jul 2026 16:34:58 +0200 Subject: [PATCH 15/87] Harden VM object and string semantics --- docs/beam-interpreter-architecture.md | 10 +- lib/quickbeam/vm/builtins.ex | 169 +++++++++++- lib/quickbeam/vm/constructor_boundary.ex | 17 ++ lib/quickbeam/vm/execution.ex | 1 + lib/quickbeam/vm/heap.ex | 317 ++++++++++++++++++++--- lib/quickbeam/vm/interpreter.ex | 143 +++++++++- lib/quickbeam/vm/object.ex | 4 + lib/quickbeam/vm/utf16.ex | 102 ++++++++ test/vm/heap_test.exs | 35 +++ test/vm/object_model_test.exs | 99 +++++++ 10 files changed, 840 insertions(+), 57 deletions(-) create mode 100644 lib/quickbeam/vm/constructor_boundary.ex create mode 100644 lib/quickbeam/vm/utf16.ex create mode 100644 test/vm/object_model_test.exs diff --git a/docs/beam-interpreter-architecture.md b/docs/beam-interpreter-architecture.md index e6355d960..7427eb55d 100644 --- a/docs/beam-interpreter-architecture.md +++ b/docs/beam-interpreter-architecture.md @@ -371,7 +371,15 @@ Recommended initial representation: - object/function — owner-local heap references. String indexing, lengths, regular expressions, and source positions must follow -JavaScript UTF-16 semantics even though Elixir binaries are UTF-8. +JavaScript UTF-16 semantics even though Elixir binaries are UTF-8. The VM now +uses explicit UTF-16 code-unit operations and preserves lone surrogates as +WTF-8 binaries at the BEAM boundary, matching native QuickJS conversion. + +The owner-local heap enforces data descriptors across prototype chains, array +length truncation and write restrictions, sparse deletion, ECMAScript own-key +ordering, prototype-cycle rejection, constructor return rules, and +`instanceof`. Accessor descriptors remain explicitly unsupported until getter +and setter invocation is integrated with resumable interpreter boundaries. ## ECMAScript and host profiles diff --git a/lib/quickbeam/vm/builtins.ex b/lib/quickbeam/vm/builtins.ex index 91c7b2b90..35a61e4c8 100644 --- a/lib/quickbeam/vm/builtins.ex +++ b/lib/quickbeam/vm/builtins.ex @@ -3,11 +3,21 @@ defmodule QuickBEAM.VM.Builtins do Installs and dispatches the JavaScript built-ins supported by the VM profile. """ - alias QuickBEAM.VM.{Execution, Heap, Object, Property, Reference, RegExp, Value} + import Bitwise + + alias QuickBEAM.VM.{Execution, Heap, Object, Property, Reference, RegExp, UTF16, Value} @constructors %{ "Array" => ["isArray"], - "Object" => ["assign", "create", "keys"], + "Object" => [ + "assign", + "create", + "defineProperty", + "getOwnPropertyDescriptor", + "getPrototypeOf", + "keys", + "setPrototypeOf" + ], "Math" => ["floor", "max", "min", "random", "round"], "String" => ["fromCharCode"], "Error" => [], @@ -57,12 +67,83 @@ defmodule QuickBEAM.VM.Builtins do end end - def call({:builtin_method, "Object", "create"}, _this, [prototype], execution) do - prototype = if match?(%Reference{}, prototype), do: prototype, else: nil + def call({:builtin_method, "Object", "create"}, _this, [prototype], execution) + when is_nil(prototype) or is_struct(prototype, Reference) do {object, execution} = Heap.allocate(execution, :ordinary, prototype: prototype) {:ok, object, execution} end + def call({:builtin_method, "Object", "create"}, _this, [_prototype], execution), + do: {:error, :invalid_prototype, execution} + + def call( + {:builtin_method, "Object", "defineProperty"}, + _this, + [%Reference{} = target, key, descriptor | _], + execution + ) do + with {:ok, current} <- Heap.own_property(execution, target, key), + {:ok, definition} <- descriptor_definition(descriptor, current, execution), + {:ok, execution} <- + Heap.define( + execution, + target, + key, + definition.value, + writable: definition.writable, + enumerable: definition.enumerable, + configurable: definition.configurable + ) do + {:ok, target, execution} + else + {:error, reason} -> {:error, reason, execution} + end + end + + def call( + {:builtin_method, "Object", "getOwnPropertyDescriptor"}, + _this, + [%Reference{} = target, key | _], + execution + ) do + case Heap.own_property(execution, target, key) 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 call( + {:builtin_method, "Object", "getPrototypeOf"}, + _this, + [%Reference{} = target | _], + execution + ) do + case Heap.prototype(execution, target) do + {:ok, prototype} -> {:ok, prototype, execution} + {:error, reason} -> {:error, reason, execution} + end + end + + def call( + {:builtin_method, "Object", "setPrototypeOf"}, + _this, + [%Reference{} = target, prototype | _], + execution + ) + when is_nil(prototype) or is_struct(prototype, Reference) do + case Heap.set_prototype(execution, target, prototype) do + {:ok, execution} -> {:ok, target, execution} + {:error, reason} -> {:error, reason, execution} + end + end + def call({:builtin_method, "Object", "assign"}, _this, [target | sources], execution) do Enum.reduce_while(sources, {:ok, target, execution}, fn source, {:ok, target, execution} -> case assign(target, source, execution) do @@ -88,7 +169,7 @@ defmodule QuickBEAM.VM.Builtins do do: {:ok, numeric_extreme(values, &max/2, :neg_infinity), execution} def call({:builtin_method, "String", "fromCharCode"}, _this, values, execution) do - string = values |> Enum.map(&Value.to_int32/1) |> List.to_string() + string = values |> Enum.map(&(Value.to_int32(&1) &&& 0xFFFF)) |> UTF16.from_units() {:ok, string, execution} end @@ -174,13 +255,13 @@ defmodule QuickBEAM.VM.Builtins do do: {:ok, String.contains?(value, Value.to_string_value(part)), execution} def call({:primitive_method, :string, "charCodeAt"}, value, [index | _], execution) do - result = value |> String.at(Value.to_int32(index)) |> char_code() + result = UTF16.char_code_at(value, Value.to_int32(index)) {:ok, result, execution} end def call({:primitive_method, :string, "slice"}, value, arguments, execution) do - {start, length} = slice_range(String.length(value), arguments) - {:ok, String.slice(value, start, length), execution} + {start, length} = slice_range(UTF16.length(value), arguments) + {:ok, UTF16.slice(value, start, length), execution} end def call({:primitive_method, :string, "replace"}, value, [pattern, replacement | _], execution) do @@ -280,6 +361,12 @@ defmodule QuickBEAM.VM.Builtins do end end + def call({:builtin, "Array"}, _this, [length], execution) + when is_integer(length) and length >= 0 do + {array, execution} = Heap.allocate(execution, :array, length: length) + {:ok, array, execution} + end + def call({:builtin, "Array"}, _this, values, execution) do {array, execution} = array_from(values, execution) {:ok, array, execution} @@ -314,6 +401,69 @@ defmodule QuickBEAM.VM.Builtins do defp maybe_install_prototype(_name, _constructor, execution), do: execution + defp descriptor_definition(descriptor, current, execution) do + with {:ok, getter, getter?} <- descriptor_field(descriptor, "get", execution), + {:ok, setter, setter?} <- descriptor_field(descriptor, "set", execution), + true <- not getter? or getter == :undefined, + true <- not setter? or setter == :undefined, + {: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) do + current = current || %Property{writable: false, enumerable: false, configurable: false} + + {:ok, + %Property{ + value: if(value?, do: value, else: current.value), + writable: if(writable?, do: Value.truthy?(writable), else: current.writable), + enumerable: if(enumerable?, do: Value.truthy?(enumerable), else: current.enumerable), + configurable: + if(configurable?, do: Value.truthy?(configurable), else: current.configurable) + }} + else + false -> {:error, :accessor_descriptors_unsupported} + {:error, reason} -> {:error, reason} + end + end + + defp descriptor_field(%Reference{} = descriptor, key, execution) do + if Heap.has_property?(execution, descriptor, key) do + case Heap.get(execution, descriptor, key) do + {: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) + + execution = + [ + {"value", property.value}, + {"writable", property.writable}, + {"enumerable", property.enumerable}, + {"configurable", property.configurable} + ] + |> Enum.reduce(execution, fn {key, value}, execution -> + {:ok, execution} = Heap.define(execution, descriptor, key, value) + execution + end) + + {descriptor, execution} + end + defp array?(%Reference{} = reference, execution) do match?({:ok, %Object{kind: :array}}, Heap.fetch_object(execution, reference)) end @@ -425,9 +575,6 @@ defmodule QuickBEAM.VM.Builtins do defp number_to_string(value, _radix), do: Value.to_string_value(value) - defp char_code(nil), do: :nan - defp char_code(character), do: character |> String.to_charlist() |> hd() - defp regex_match?(%RegExp{source: source}, value) do case Regex.compile(source) do {:ok, regex} -> Regex.match?(regex, value) diff --git a/lib/quickbeam/vm/constructor_boundary.ex b/lib/quickbeam/vm/constructor_boundary.ex new file mode 100644 index 000000000..047c03f9a --- /dev/null +++ b/lib/quickbeam/vm/constructor_boundary.ex @@ -0,0 +1,17 @@ +defmodule QuickBEAM.VM.ConstructorBoundary 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.Reference.t(), + caller: QuickBEAM.VM.Frame.t() | QuickBEAM.VM.NativeFrame.t(), + depth: non_neg_integer() + } +end diff --git a/lib/quickbeam/vm/execution.ex b/lib/quickbeam/vm/execution.ex index 0c91127b3..95b04962c 100644 --- a/lib/quickbeam/vm/execution.ex +++ b/lib/quickbeam/vm/execution.ex @@ -38,6 +38,7 @@ defmodule QuickBEAM.VM.Execution do | QuickBEAM.VM.NativeFrame.t() | QuickBEAM.VM.AsyncBoundary.t() | QuickBEAM.VM.ReactionBoundary.t() + | QuickBEAM.VM.ConstructorBoundary.t() | QuickBEAM.VM.PromiseExecutorBoundary.t() | QuickBEAM.VM.ThenableBoundary.t() ], diff --git a/lib/quickbeam/vm/heap.ex b/lib/quickbeam/vm/heap.ex index 3c8cc1831..a954ef1af 100644 --- a/lib/quickbeam/vm/heap.ex +++ b/lib/quickbeam/vm/heap.ex @@ -61,13 +61,14 @@ defmodule QuickBEAM.VM.Heap do key = normalize_key(key) with {:ok, object} <- fetch_object(execution, reference), - :ok <- writable?(object, key) do + {:ok, object} <- put_value(execution, object, key, value) do execution = maybe_charge_property(execution, object, key, value) - object = put_property(object, key, value) - {:ok, %{execution | heap: Map.put(execution.heap, id, object)}} + updated = put_property(object, key, value) + {:ok, %{execution | heap: Map.put(execution.heap, id, updated)}} else :error -> {:error, {:invalid_reference, id}} {:error, reason} -> {:error, reason} + {:array_length, object} -> {:ok, %{execution | heap: Map.put(execution.heap, id, object)}} end end @@ -76,19 +77,74 @@ defmodule QuickBEAM.VM.Heap do def define(%Execution{} = execution, %Reference{id: id} = reference, key, value, opts \\ []) do key = normalize_key(key) - with {:ok, object} <- fetch_object(execution, reference) do - property = %Property{ - value: value, - writable: Keyword.get(opts, :writable, true), - enumerable: Keyword.get(opts, :enumerable, true), - configurable: Keyword.get(opts, :configurable, true) - } - + with {:ok, object} <- fetch_object(execution, reference), + {:ok, object} <- define_property(object, key, value, opts) do execution = maybe_charge_property(execution, object, key, value) + property = property(value, opts) object = put_property_struct(object, key, property) {:ok, %{execution | heap: Map.put(execution.heap, id, object)}} else :error -> {:error, {:invalid_reference, id}} + {:error, reason} -> {:error, reason} + {:array_length, object} -> {:ok, %{execution | heap: Map.put(execution.heap, id, object)}} + end + end + + @doc "Returns an object's own property descriptor without traversing its prototype." + @spec own_property(Execution.t(), Reference.t(), term()) :: + {:ok, Property.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, + %Property{ + value: object.length, + writable: object.length_writable, + enumerable: false, + configurable: false + }} + + {:ok, object} -> + {:ok, Map.get(object.properties, key)} + + :error -> + {:error, {:invalid_reference, id}} + end + end + + @doc "Replaces an object's prototype after validating the owner-local chain." + @spec set_prototype(Execution.t(), Reference.t(), Reference.t() | nil) :: + {:ok, Execution.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?(Execution.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(Execution.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 @@ -104,7 +160,12 @@ defmodule QuickBEAM.VM.Heap do {:ok, false, execution} _property -> - object = %{object | properties: Map.delete(object.properties, key)} + 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 @@ -126,14 +187,50 @@ defmodule QuickBEAM.VM.Heap do def own_keys(execution, %Reference{id: id} = reference) do case fetch_object(execution, reference) do {:ok, object} -> - keys = for {key, property} <- object.properties, property.enumerable, do: key - {:ok, keys} + integer_keys = + object.properties + |> Enum.filter(fn {key, property} -> is_integer(key) and property.enumerable end) + |> Enum.map(&elem(&1, 0)) + |> Enum.sort() + + string_keys = + Enum.filter(object.property_order, fn key -> + not is_integer(key) and match?(%Property{enumerable: true}, 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 + case prototype_contains?(execution, prototype, reference, 0) do + true -> {:error, :cyclic_prototype} + false -> :ok + end + 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, depth) when depth > @max_prototype_depth, do: {:error, :prototype_chain_too_deep} @@ -165,15 +262,97 @@ defmodule QuickBEAM.VM.Heap do else: Memory.charge_property(execution, key, value) end - defp writable?(%Object{properties: properties, extensible: extensible}, key) do - case Map.fetch(properties, key) do - {:ok, %Property{writable: true}} -> :ok - {:ok, %Property{writable: false}} -> {:error, {:property_not_writable, key}} - :error when extensible -> :ok - :error -> {:error, {:object_not_extensible, key}} + 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 + cond do + match?(%Property{writable: false}, object.properties[key]) -> + {:error, {:property_not_writable, key}} + + Map.has_key?(object.properties, key) -> + {:ok, object} + + object.kind == :array and is_integer(key) and key >= object.length and + not object.length_writable -> + {:error, {:property_not_writable, "length"}} + + inherited_non_writable?(execution, object.prototype, key, 0) -> + {:error, {:property_not_writable, key}} + + object.extensible -> + {:ok, object} + + true -> + {:error, {:object_not_extensible, key}} + end + end + + 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 + candidate = property(value, opts) + + case Map.get(object.properties, key) do + %Property{configurable: false} = current -> + cond do + candidate.configurable -> + {:error, {:property_not_configurable, key}} + + candidate.enumerable != current.enumerable -> + {:error, {:property_not_configurable, key}} + + not current.writable and candidate.writable -> + {:error, {:property_not_writable, key}} + + not current.writable and candidate.value != current.value -> + {:error, {:property_not_writable, key}} + + true -> + {:ok, object} + end + + nil when not object.extensible -> + {:error, {:object_not_extensible, key}} + + _current -> + if object.kind == :array and is_integer(key) and key >= object.length and + not object.length_writable, + do: {:error, {:property_not_writable, "length"}}, + else: {:ok, object} + end + end + + defp property(value, opts) do + %Property{ + 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 property = case Map.get(object.properties, key) do @@ -186,25 +365,99 @@ defmodule QuickBEAM.VM.Heap do defp put_property_struct(%Object{kind: :array} = object, key, property) when is_integer(key) and key >= 0 do - %{ - object - | properties: Map.put(object.properties, key, property), - length: max(object.length, key + 1) - } + object + |> remember_property(key) + |> then(fn object -> + %{ + object + | properties: Map.put(object.properties, key, property), + length: max(object.length, key + 1) + } + end) end - defp put_property_struct(object, key, property), - do: %{object | properties: Map.put(object.properties, key, property)} + defp put_property_struct(object, key, property) do + object = remember_property(object, key) + %{object | properties: Map.put(object.properties, key, property)} + end - defp normalize_key(key) when is_integer(key) and key >= 0, do: key + 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_non_writable?(_execution, nil, _key, _depth), do: false + + defp inherited_non_writable?(_execution, _prototype, _key, depth) + when depth > @max_prototype_depth, + do: true + + defp inherited_non_writable?(execution, %Reference{} = prototype, key, depth) do + case fetch_object(execution, prototype) do + {:ok, object} -> + case Map.get(object.properties, key) do + %Property{writable: writable} -> not writable + nil -> inherited_non_writable?(execution, object.prototype, key, depth + 1) + end + + :error -> + false + end + end + + defp array_length_writable(%Object{length_writable: true}), do: :ok + defp array_length_writable(_object), do: {:error, {:property_not_writable, "length"}} - defp normalize_key(key) when is_float(key) and key >= 0 and trunc(key) == key, - do: trunc(key) + 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?(%Property{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 is_binary(key) do case Integer.parse(key) do - {index, ""} when index >= 0 -> if Integer.to_string(index) == key, do: index, else: key - _ -> key + {index, ""} when index in 0..4_294_967_294 -> + if Integer.to_string(index) == key, do: index, else: key + + _ -> + key end end diff --git a/lib/quickbeam/vm/interpreter.ex b/lib/quickbeam/vm/interpreter.ex index b8a27f0a4..18348f6a1 100644 --- a/lib/quickbeam/vm/interpreter.ex +++ b/lib/quickbeam/vm/interpreter.ex @@ -13,6 +13,7 @@ defmodule QuickBEAM.VM.Interpreter do AsyncBoundary, Builtins, Continuation, + ConstructorBoundary, Coroutine, Execution, Export, @@ -33,6 +34,7 @@ defmodule QuickBEAM.VM.Interpreter do RegExp, ThenableBoundary, Thrown, + UTF16, Value } @@ -605,6 +607,24 @@ defmodule QuickBEAM.VM.Interpreter do continue(%{frame | stack: [has_property?(object, key, execution) | stack]}, execution) end + defp execute( + :instanceof, + [], + %{stack: [constructor, object | stack]} = frame, + execution + ) do + with "function" <- type_of(constructor, execution), + {:ok, %Reference{} = prototype} <- instanceof_prototype(constructor, execution) do + result = + is_struct(object, Reference) and + Heap.prototype_chain_contains?(execution, object, prototype) + + continue(%{frame | stack: [result | stack]}, execution) + else + _invalid -> raise_js({:type_error, :invalid_instanceof_target}, frame, execution) + end + end + defp execute(:and, [], frame, execution), do: bitwise(frame, execution, &band/2) defp execute(:or, [], frame, execution), do: bitwise(frame, execution, &bor/2) defp execute(:xor, [], frame, execution), do: bitwise(frame, execution, &bxor/2) @@ -795,14 +815,70 @@ defmodule QuickBEAM.VM.Interpreter do case constructor_and_new_target do [_new_target, constructor | rest] -> - caller = %{next_frame(frame) | stack: rest} - dispatch_call(constructor, Enum.reverse(arguments), :undefined, caller, execution, false) + if constructable?(constructor, execution) do + caller = %{next_frame(frame) | stack: rest} + prototype = constructor_prototype(constructor, execution) + {instance, execution} = Heap.allocate(execution, :ordinary, prototype: prototype) + + boundary = %ConstructorBoundary{ + instance: instance, + caller: caller, + depth: execution.depth + } + + dispatch_call( + constructor, + Enum.reverse(arguments), + instance, + boundary, + execution, + false + ) + else + raise_js({:type_error, :not_a_constructor}, frame, execution) + end _ -> {:error, {:invalid_stack, :call_constructor}, execution} end end + defp constructable?(%Reference{} = constructor, execution) do + case Builtins.callable(execution, constructor) do + nil -> false + callable -> constructable?(callable, execution) + end + end + + defp constructable?(%Function{has_prototype: has_prototype}, _execution), do: has_prototype + + defp constructable?({:closure, %Function{has_prototype: has_prototype}, _refs}, _execution), + do: has_prototype + + defp constructable?({:bound_function, target, _this, _arguments}, execution), + do: constructable?(target, execution) + + defp constructable?({:builtin, name}, _execution), + do: name in ["Array", "Error", "Object", "Promise", "Set", "String"] + + defp constructable?(_constructor, _execution), do: false + + defp constructor_prototype({:bound_function, target, _this, _arguments}, execution), + do: constructor_prototype(target, execution) + + defp constructor_prototype(constructor, execution) do + case get_property(constructor, "prototype", execution) do + {:ok, %Reference{} = prototype} -> prototype + _other -> nil + end + end + + defp instanceof_prototype({:bound_function, target, _this, _arguments}, execution), + do: instanceof_prototype(target, execution) + + defp instanceof_prototype(constructor, execution), + do: get_property(constructor, "prototype", execution) + defp dispatch_call({:host_function, :beam_call}, arguments, _this, caller, execution, tail?), do: start_host_call(arguments, caller, execution, tail?) @@ -865,6 +941,16 @@ defmodule QuickBEAM.VM.Interpreter do complete_call_result(:undefined, caller, execution, tail?) end + defp dispatch_call( + {:bound_function, target, _bound_this, bound_arguments}, + arguments, + this, + %ConstructorBoundary{} = caller, + execution, + tail? + ), + do: dispatch_call(target, bound_arguments ++ arguments, this, caller, execution, tail?) + defp dispatch_call( {:bound_function, target, bound_this, bound_arguments}, arguments, @@ -982,6 +1068,9 @@ defmodule QuickBEAM.VM.Interpreter do defp complete_call_result(value, %ReactionBoundary{} = boundary, execution, _tail?), do: complete_reaction(boundary, value, execution) + defp complete_call_result(value, %ConstructorBoundary{} = boundary, execution, _tail?), + do: complete_constructor(value, boundary, execution) + defp complete_call_result(_value, %PromiseExecutorBoundary{} = boundary, execution, _tail?), do: complete_executor(boundary, execution) @@ -997,6 +1086,20 @@ defmodule QuickBEAM.VM.Interpreter do else: run(%{caller | stack: [value | caller.stack]}, execution) end + 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 @@ -1409,11 +1512,10 @@ defmodule QuickBEAM.VM.Interpreter do end defp get_property(object, "length", _execution) when is_binary(object), - do: {:ok, utf16_length(object)} + do: {:ok, UTF16.length(object)} - defp get_property(object, key, _execution) when is_binary(object) and is_integer(key) do - {:ok, String.at(object, key) || :undefined} - end + defp get_property(object, key, _execution) when is_binary(object) and is_integer(key), + do: {:ok, UTF16.at(object, key)} defp get_property(object, key, _execution) when is_binary(object) and is_binary(key), do: {:ok, {:primitive_method, :string, key}} @@ -1483,13 +1585,6 @@ defmodule QuickBEAM.VM.Interpreter do defp map_string_key(_map, _key), do: :undefined - defp utf16_length(value) do - value - |> :unicode.characters_to_binary(:utf8, {:utf16, :little}) - |> byte_size() - |> div(2) - end - defp raise_js(reason, %NativeFrame{caller: caller}, execution) do {reason, trace} = throw_state(reason) do_raise_js(reason, caller, execution, trace, true) @@ -1500,6 +1595,11 @@ defmodule QuickBEAM.VM.Interpreter do do_raise_js(reason, frame, execution, trace, false) end + defp raise_js_from_caller(reason, %ConstructorBoundary{} = boundary, execution) do + {reason, trace} = throw_state(reason) + do_raise_js(reason, boundary.caller, execution, trace, true) + end + defp raise_js_from_caller(reason, %ThenableBoundary{} = boundary, execution) do {reason, trace} = throw_state(reason) thrown = %Thrown{value: reason, frames: Enum.reverse(trace)} @@ -1552,6 +1652,15 @@ defmodule QuickBEAM.VM.Interpreter do {:error, error, execution} end + defp unwind_caller( + reason, + %Execution{callers: [%ConstructorBoundary{} = boundary | callers]} = execution, + trace + ) do + execution = %{execution | callers: callers, depth: boundary.depth} + do_raise_js(reason, boundary.caller, execution, trace, true) + end + defp unwind_caller( reason, %Execution{callers: [%ThenableBoundary{} = boundary | callers]} = execution, @@ -1692,6 +1801,14 @@ defmodule QuickBEAM.VM.Interpreter do defp return_value(value, %Execution{callers: [%AsyncBoundary{} | _]} = execution), do: complete_async(value, execution) + defp return_value( + value, + %Execution{callers: [%ConstructorBoundary{} = boundary | callers]} = execution + ) do + execution = %{execution | callers: callers, depth: boundary.depth} + complete_constructor(value, boundary, execution) + end + defp return_value( value, %Execution{callers: [%ReactionBoundary{} = boundary | callers]} = execution diff --git a/lib/quickbeam/vm/object.ex b/lib/quickbeam/vm/object.ex index 279496494..9688b207c 100644 --- a/lib/quickbeam/vm/object.ex +++ b/lib/quickbeam/vm/object.ex @@ -4,8 +4,10 @@ defmodule QuickBEAM.VM.Object do defstruct kind: :ordinary, prototype: nil, properties: %{}, + property_order: [], extensible: true, length: 0, + length_writable: true, callable: nil, internal: nil @@ -14,8 +16,10 @@ defmodule QuickBEAM.VM.Object do kind: kind(), prototype: QuickBEAM.VM.Reference.t() | nil, properties: %{optional(term()) => QuickBEAM.VM.Property.t()}, + property_order: [term()], extensible: boolean(), length: non_neg_integer(), + length_writable: boolean(), callable: term() | nil, internal: term() } diff --git a/lib/quickbeam/vm/utf16.ex b/lib/quickbeam/vm/utf16.ex new file mode 100644 index 000000000..9136aaefa --- /dev/null +++ b/lib/quickbeam/vm/utf16.ex @@ -0,0 +1,102 @@ +defmodule QuickBEAM.VM.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/test/vm/heap_test.exs b/test/vm/heap_test.exs index 1870f76a0..9ac183033 100644 --- a/test/vm/heap_test.exs +++ b/test/vm/heap_test.exs @@ -24,6 +24,41 @@ defmodule QuickBEAM.VM.HeapTest do 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) + end + test "honors writable and configurable descriptor flags" do execution = execution() {object, execution} = Heap.allocate(execution) diff --git a/test/vm/object_model_test.exs b/test/vm/object_model_test.exs new file mode 100644 index 000000000..85ca0d9b7 --- /dev/null +++ b/test/vm/object_model_test.exs @@ -0,0 +1,99 @@ +defmodule QuickBEAM.VM.ObjectModelTest 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 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 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 "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, source) + assert {:ok, ^expected} = QuickBEAM.VM.eval(program) + end +end From ce65322b88ea1ff1bffadc2ca8989380fd2128e1 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 10 Jul 2026 16:52:06 +0200 Subject: [PATCH 16/87] Add resumable property accessor semantics --- docs/beam-interpreter-architecture.md | 12 +- lib/quickbeam/vm/accessor_boundary.ex | 17 ++ lib/quickbeam/vm/async_boundary.ex | 5 +- lib/quickbeam/vm/builtins.ex | 123 +++++++--- lib/quickbeam/vm/evaluator.ex | 16 ++ lib/quickbeam/vm/execution.ex | 5 + lib/quickbeam/vm/heap.ex | 128 ++++++++-- lib/quickbeam/vm/interpreter.ex | 270 ++++++++++++++++++++- lib/quickbeam/vm/object_assign_boundary.ex | 33 +++ lib/quickbeam/vm/promise.ex | 35 ++- lib/quickbeam/vm/property.ex | 14 +- lib/quickbeam/vm/then_getter_boundary.ex | 18 ++ test/vm/interpreter_test.exs | 2 +- test/vm/object_model_test.exs | 41 +++- test/vm/promise_test.exs | 16 ++ 15 files changed, 671 insertions(+), 64 deletions(-) create mode 100644 lib/quickbeam/vm/accessor_boundary.ex create mode 100644 lib/quickbeam/vm/object_assign_boundary.ex create mode 100644 lib/quickbeam/vm/then_getter_boundary.ex diff --git a/docs/beam-interpreter-architecture.md b/docs/beam-interpreter-architecture.md index 7427eb55d..e485f543e 100644 --- a/docs/beam-interpreter-architecture.md +++ b/docs/beam-interpreter-architecture.md @@ -375,11 +375,13 @@ JavaScript UTF-16 semantics even though Elixir binaries are UTF-8. The VM now uses explicit UTF-16 code-unit operations and preserves lone surrogates as WTF-8 binaries at the BEAM boundary, matching native QuickJS conversion. -The owner-local heap enforces data descriptors across prototype chains, array -length truncation and write restrictions, sparse deletion, ECMAScript own-key -ordering, prototype-cycle rejection, constructor return rules, and -`instanceof`. Accessor descriptors remain explicitly unsupported until getter -and setter invocation is integrated with resumable interpreter boundaries. +The owner-local heap enforces data and accessor descriptors across prototype +chains, array length truncation and write restrictions, sparse deletion, +ECMAScript own-key ordering, prototype-cycle rejection, constructor return +rules, and `instanceof`. Getter, setter, and `Object.assign` calls use resumable +boundaries so arbitrary JavaScript and exceptions do not escape the explicit +machine state. Promise resolution reads accessor-backed `then` properties +synchronously and queues invocation of returned then functions as microtasks. ## ECMAScript and host profiles diff --git a/lib/quickbeam/vm/accessor_boundary.ex b/lib/quickbeam/vm/accessor_boundary.ex new file mode 100644 index 000000000..c899bc567 --- /dev/null +++ b/lib/quickbeam/vm/accessor_boundary.ex @@ -0,0 +1,17 @@ +defmodule QuickBEAM.VM.AccessorBoundary 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.Frame.t(), + depth: non_neg_integer() + } +end diff --git a/lib/quickbeam/vm/async_boundary.ex b/lib/quickbeam/vm/async_boundary.ex index 12fbc5d8a..9f0e44415 100644 --- a/lib/quickbeam/vm/async_boundary.ex +++ b/lib/quickbeam/vm/async_boundary.ex @@ -12,11 +12,14 @@ defmodule QuickBEAM.VM.AsyncBoundary do @type t :: %__MODULE__{ promise: QuickBEAM.VM.PromiseReference.t(), caller: - QuickBEAM.VM.Frame.t() + QuickBEAM.VM.AccessorBoundary.t() + | QuickBEAM.VM.Frame.t() + | QuickBEAM.VM.ObjectAssignBoundary.t() | QuickBEAM.VM.NativeFrame.t() | QuickBEAM.VM.ReactionBoundary.t() | QuickBEAM.VM.PromiseExecutorBoundary.t() | QuickBEAM.VM.ThenableBoundary.t() + | QuickBEAM.VM.ThenGetterBoundary.t() | nil, depth: pos_integer(), mode: :push | :return | :reaction | :executor | :thenable | :detached diff --git a/lib/quickbeam/vm/builtins.ex b/lib/quickbeam/vm/builtins.ex index 35a61e4c8..d91dadfca 100644 --- a/lib/quickbeam/vm/builtins.ex +++ b/lib/quickbeam/vm/builtins.ex @@ -84,16 +84,7 @@ defmodule QuickBEAM.VM.Builtins do ) do with {:ok, current} <- Heap.own_property(execution, target, key), {:ok, definition} <- descriptor_definition(descriptor, current, execution), - {:ok, execution} <- - Heap.define( - execution, - target, - key, - definition.value, - writable: definition.writable, - enumerable: definition.enumerable, - configurable: definition.configurable - ) do + {:ok, execution} <- define_property(execution, target, key, definition) do {:ok, target, execution} else {:error, reason} -> {:error, reason, execution} @@ -404,32 +395,91 @@ defmodule QuickBEAM.VM.Builtins do defp descriptor_definition(descriptor, current, execution) do with {:ok, getter, getter?} <- descriptor_field(descriptor, "get", execution), {:ok, setter, setter?} <- descriptor_field(descriptor, "set", execution), - true <- not getter? or getter == :undefined, - true <- not setter? or setter == :undefined, {: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) do + 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 || %Property{writable: false, enumerable: false, configurable: false} + accessor? = getter? or setter? or (not value? and not writable? and accessor?(current)) {:ok, - %Property{ - value: if(value?, do: value, else: current.value), - writable: if(writable?, do: Value.truthy?(writable), else: current.writable), - enumerable: if(enumerable?, do: Value.truthy?(enumerable), else: current.enumerable), - configurable: - if(configurable?, do: Value.truthy?(configurable), else: current.configurable) - }} + if accessor? do + %Property{ + kind: :accessor, + value: :undefined, + writable: false, + enumerable: if(enumerable?, do: Value.truthy?(enumerable), else: current.enumerable), + configurable: + if(configurable?, do: Value.truthy?(configurable), else: current.configurable), + getter: if(getter?, do: getter, else: current.getter), + setter: if(setter?, do: setter, else: current.setter) + } + else + %Property{ + value: if(value?, do: value, else: current.value), + writable: if(writable?, do: Value.truthy?(writable), else: current.writable), + enumerable: if(enumerable?, do: Value.truthy?(enumerable), else: current.enumerable), + configurable: + if(configurable?, do: Value.truthy?(configurable), else: current.configurable) + } + end} else - false -> {:error, :accessor_descriptors_unsupported} {:error, reason} -> {:error, reason} end end + 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 callable_value?(value, execution), + do: {:ok, value}, + else: {:error, :accessor_not_callable} + end + + defp callable_value?(%Reference{} = reference, execution), + do: not is_nil(callable(execution, reference)) + + defp callable_value?(value, _execution) when is_tuple(value), + do: + elem(value, 0) in [ + :bound_function, + :builtin, + :builtin_method, + :host_function, + :primitive_method, + :promise_method, + :promise_resolver + ] + + defp callable_value?(_value, _execution), do: false + + defp accessor?(%Property{kind: :accessor}), do: true + defp accessor?(_property), do: false + + defp define_property(execution, target, key, %Property{} = property) do + if accessor?(property) do + Heap.define_descriptor(execution, target, key, property) + else + Heap.define(execution, target, key, property.value, + writable: property.writable, + enumerable: property.enumerable, + configurable: property.configurable + ) + end + end + defp descriptor_field(%Reference{} = descriptor, key, execution) do if Heap.has_property?(execution, descriptor, key) do case Heap.get(execution, descriptor, key) do + {:ok, {:accessor, _getter, _receiver}} -> {:error, :accessor_descriptor_field} {:ok, value} -> {:ok, value, true} {:error, reason} -> {:error, reason} end @@ -449,13 +499,25 @@ defmodule QuickBEAM.VM.Builtins do 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 = - [ - {"value", property.value}, - {"writable", property.writable}, - {"enumerable", property.enumerable}, - {"configurable", property.configurable} - ] + fields |> Enum.reduce(execution, fn {key, value}, execution -> {:ok, execution} = Heap.define(execution, descriptor, key, value) execution @@ -508,7 +570,12 @@ defmodule QuickBEAM.VM.Builtins do defp assign(_target, _source, _execution), do: {:error, :not_an_object} - defp property(%Reference{} = reference, key, execution), do: Heap.get(execution, reference, key) + defp property(%Reference{} = reference, key, execution) do + case Heap.get(execution, reference, key) do + {:ok, {:accessor, _getter, _receiver}} -> {:error, :accessor_in_object_assign} + result -> result + end + end defp property(value, key, _execution) when is_map(value), do: {:ok, Map.get(value, key, :undefined)} diff --git a/lib/quickbeam/vm/evaluator.ex b/lib/quickbeam/vm/evaluator.ex index 3a4627cb3..4ec8e26c0 100644 --- a/lib/quickbeam/vm/evaluator.ex +++ b/lib/quickbeam/vm/evaluator.ex @@ -56,6 +56,16 @@ defmodule QuickBEAM.VM.Evaluator do end defp await_final_promise(%PromiseReference{} = promise, execution) do + if :queue.is_empty(execution.sync_jobs) do + await_settled_promise(promise, execution) + else + execution + |> Interpreter.run_synchronous_job() + |> continue_final(promise) + end + end + + defp await_settled_promise(promise, execution) do case Promise.state(execution, promise) do {:fulfilled, value} -> finish_final({:ok, value, execution}) @@ -91,6 +101,12 @@ defmodule QuickBEAM.VM.Evaluator do |> continue_final(final_promise) end + defp run_job({:read_thenable, promise, thenable, getter}, final_promise, execution) do + promise + |> Interpreter.read_thenable(thenable, getter, execution) + |> continue_final(final_promise) + end + defp run_job( {:assimilate_thenable, promise, thenable, callable}, final_promise, diff --git a/lib/quickbeam/vm/execution.ex b/lib/quickbeam/vm/execution.ex index 95b04962c..690513892 100644 --- a/lib/quickbeam/vm/execution.ex +++ b/lib/quickbeam/vm/execution.ex @@ -17,6 +17,7 @@ defmodule QuickBEAM.VM.Execution do handlers: %{}, heap: %{}, jobs: {[], []}, + sync_jobs: {[], []}, max_stack_depth: 1_000, memory_exceeded: false, memory_limit: :infinity, @@ -36,11 +37,14 @@ defmodule QuickBEAM.VM.Execution do callers: [ QuickBEAM.VM.Frame.t() | QuickBEAM.VM.NativeFrame.t() + | QuickBEAM.VM.ObjectAssignBoundary.t() + | QuickBEAM.VM.AccessorBoundary.t() | QuickBEAM.VM.AsyncBoundary.t() | QuickBEAM.VM.ReactionBoundary.t() | QuickBEAM.VM.ConstructorBoundary.t() | QuickBEAM.VM.PromiseExecutorBoundary.t() | QuickBEAM.VM.ThenableBoundary.t() + | QuickBEAM.VM.ThenGetterBoundary.t() ], cells: %{optional(non_neg_integer()) => term()}, depth: non_neg_integer(), @@ -48,6 +52,7 @@ defmodule QuickBEAM.VM.Execution do handlers: %{optional(String.t()) => function()}, heap: %{optional(non_neg_integer()) => QuickBEAM.VM.Object.t()}, jobs: :queue.queue(term()), + sync_jobs: :queue.queue(term()), max_stack_depth: pos_integer(), memory_exceeded: boolean(), memory_limit: pos_integer() | :infinity, diff --git a/lib/quickbeam/vm/heap.ex b/lib/quickbeam/vm/heap.ex index a954ef1af..324775c07 100644 --- a/lib/quickbeam/vm/heap.ex +++ b/lib/quickbeam/vm/heap.ex @@ -34,7 +34,7 @@ defmodule QuickBEAM.VM.Heap do @spec get(Execution.t(), Reference.t(), term()) :: {:ok, term()} | {:error, term()} def get(%Execution{} = execution, %Reference{} = reference, key) do - get_with_depth(execution, reference, normalize_key(key), 0) + get_with_depth(execution, reference, normalize_key(key), reference, 0) end @spec has_property?(Execution.t(), Reference.t(), term()) :: boolean() @@ -148,6 +148,54 @@ defmodule QuickBEAM.VM.Heap do end end + @doc "Defines or updates an accessor property on an owner-local object." + @spec define_accessor( + Execution.t(), + Reference.t(), + term(), + :getter | :setter, + term(), + keyword() + ) :: {:ok, Execution.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) + + with {:ok, object} <- fetch_object(execution, reference) do + current = Map.get(object.properties, key) + + property = %Property{ + 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) + else + :error -> {:error, {:invalid_reference, id}} + end + end + + @doc "Defines a complete data or accessor descriptor on an owner-local object." + @spec define_descriptor(Execution.t(), Reference.t(), term(), Property.t()) :: + {:ok, Execution.t()} | {:error, term()} + def define_descriptor(execution, %Reference{id: id} = reference, key, %Property{} = property) do + key = normalize_key(key) + + with {:ok, object} <- fetch_object(execution, reference) do + if object.kind == :array and key == "length", + do: {:error, {:property_not_configurable, "length"}}, + else: store_descriptor(execution, id, object, key, property) + else + :error -> {:error, {:invalid_reference, id}} + end + end + @spec delete(Execution.t(), Reference.t(), term()) :: {:ok, boolean(), Execution.t()} | {:error, term()} def delete(%Execution{} = execution, %Reference{id: id} = reference, key) do @@ -231,21 +279,28 @@ defmodule QuickBEAM.VM.Heap do end end - defp get_with_depth(_execution, _reference, _key, depth) when depth > @max_prototype_depth, - do: {:error, :prototype_chain_too_deep} + 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, depth) do + defp get_with_depth(execution, %Reference{id: id} = reference, key, receiver, depth) do case fetch_object(execution, reference) do {:ok, %Object{kind: :array, length: length}} when key == "length" -> {:ok, length} {:ok, object} -> case Map.fetch(object.properties, key) do + {:ok, %Property{getter: getter}} when not is_nil(getter) -> + {:ok, {:accessor, getter, receiver}} + + {:ok, %Property{setter: setter}} when not is_nil(setter) -> + {:ok, :undefined} + {:ok, %Property{value: value}} -> {:ok, value} :error when is_struct(object.prototype, Reference) -> - get_with_depth(execution, object.prototype, key, depth + 1) + get_with_depth(execution, object.prototype, key, receiver, depth + 1) :error -> {:ok, :undefined} @@ -271,8 +326,17 @@ defmodule QuickBEAM.VM.Heap do end defp put_value(execution, object, key, _value) do + descriptor = + Map.get(object.properties, key) || inherited_property(execution, object.prototype, key, 0) + cond do - match?(%Property{writable: false}, object.properties[key]) -> + match?(%Property{setter: setter} when not is_nil(setter), descriptor) -> + {:error, {:invoke_setter, descriptor.setter}} + + accessor?(descriptor) -> + {:error, {:property_not_writable, key}} + + match?(%Property{writable: false}, descriptor) -> {:error, {:property_not_writable, key}} Map.has_key?(object.properties, key) -> @@ -282,9 +346,6 @@ defmodule QuickBEAM.VM.Heap do not object.length_writable -> {:error, {:property_not_writable, "length"}} - inherited_non_writable?(execution, object.prototype, key, 0) -> - {:error, {:property_not_writable, key}} - object.extensible -> {:ok, object} @@ -311,9 +372,10 @@ defmodule QuickBEAM.VM.Heap do end end - defp define_property(object, key, value, opts) do - candidate = property(value, opts) + defp define_property(object, key, value, opts), + do: validate_definition(object, key, property(value, opts)) + defp validate_definition(object, key, candidate) do case Map.get(object.properties, key) do %Property{configurable: false} = current -> cond do @@ -323,10 +385,17 @@ defmodule QuickBEAM.VM.Heap do candidate.enumerable != current.enumerable -> {:error, {:property_not_configurable, key}} - not current.writable and candidate.writable -> + accessor?(candidate) != accessor?(current) -> + {:error, {:property_not_configurable, key}} + + accessor?(current) and + (candidate.getter != current.getter or candidate.setter != current.setter) -> + {:error, {:property_not_configurable, key}} + + not accessor?(current) and not current.writable and candidate.writable -> {:error, {:property_not_writable, key}} - not current.writable and candidate.value != current.value -> + not accessor?(current) and not current.writable and candidate.value != current.value -> {:error, {:property_not_writable, key}} true -> @@ -344,6 +413,17 @@ defmodule QuickBEAM.VM.Heap do end end + 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?(%Property{kind: :accessor}), do: true + defp accessor?(_property), do: false + defp property(value, opts) do %Property{ value: value, @@ -387,25 +467,29 @@ defmodule QuickBEAM.VM.Heap do else: %{object | property_order: object.property_order ++ [key]} end - defp inherited_non_writable?(_execution, nil, _key, _depth), do: false + defp inherited_property(_execution, nil, _key, _depth), do: nil - defp inherited_non_writable?(_execution, _prototype, _key, depth) + defp inherited_property(_execution, _prototype, _key, depth) when depth > @max_prototype_depth, - do: true + do: %Property{writable: false} - defp inherited_non_writable?(execution, %Reference{} = prototype, key, depth) do + defp inherited_property(execution, %Reference{} = prototype, key, depth) do case fetch_object(execution, prototype) do {:ok, object} -> - case Map.get(object.properties, key) do - %Property{writable: writable} -> not writable - nil -> inherited_non_writable?(execution, object.prototype, key, depth + 1) - end + Map.get(object.properties, key) || + inherited_property(execution, object.prototype, key, depth + 1) :error -> - false + nil end end + defp current_accessor(%Property{} = property, field), do: Map.fetch!(property, field) + defp current_accessor(nil, _field), do: nil + + defp current_flag(%Property{} = property, field, _default), do: Map.fetch!(property, field) + 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"}} diff --git a/lib/quickbeam/vm/interpreter.ex b/lib/quickbeam/vm/interpreter.ex index 18348f6a1..5ef74b5a3 100644 --- a/lib/quickbeam/vm/interpreter.ex +++ b/lib/quickbeam/vm/interpreter.ex @@ -10,6 +10,7 @@ defmodule QuickBEAM.VM.Interpreter do import Bitwise alias QuickBEAM.VM.{ + AccessorBoundary, AsyncBoundary, Builtins, Continuation, @@ -22,6 +23,7 @@ defmodule QuickBEAM.VM.Interpreter do Heap, Memory, NativeFrame, + ObjectAssignBoundary, Opcodes, PredefinedAtoms, Program, @@ -33,6 +35,7 @@ defmodule QuickBEAM.VM.Interpreter do Reference, RegExp, ThenableBoundary, + ThenGetterBoundary, Thrown, UTF16, Value @@ -97,6 +100,33 @@ defmodule QuickBEAM.VM.Interpreter do end end + @doc "Reads a thenable's accessor-backed `then` property during Promise resolution." + def read_thenable(promise, thenable, getter, %Execution{} = execution), + do: start_then_getter(promise, thenable, getter, nil, execution) + + @doc "Runs one pending synchronous Promise-resolution job, when present." + def run_synchronous_job(%Execution{} = execution) do + case :queue.out(execution.sync_jobs) do + {{:value, {:read_thenable, promise, thenable, getter}}, sync_jobs} -> + execution = %{execution | sync_jobs: sync_jobs} + start_then_getter(promise, thenable, getter, nil, execution) + + {:empty, _sync_jobs} -> + {:none, execution} + end + end + + defp start_then_getter(promise, thenable, getter, continuation, execution) do + boundary = %ThenGetterBoundary{ + promise: promise, + thenable: thenable, + depth: execution.depth, + continuation: continuation + } + + dispatch_call(getter, [], thenable, boundary, execution, false) + end + @doc "Invokes a thenable and connects its resolver functions to a Promise." def assimilate_thenable(promise, thenable, callable, %Execution{} = execution) do boundary = %ThenableBoundary{promise: promise, depth: execution.depth} @@ -235,6 +265,17 @@ defmodule QuickBEAM.VM.Interpreter do } end + defp run(frame, %Execution{} = execution) when execution.sync_jobs != {[], []} do + case :queue.out(execution.sync_jobs) do + {{:value, {:read_thenable, promise, thenable, getter}}, sync_jobs} -> + execution = %{execution | sync_jobs: sync_jobs} + start_then_getter(promise, thenable, getter, frame, execution) + + {:empty, _sync_jobs} -> + run(frame, %{execution | sync_jobs: {[], []}}) + end + end + defp run(_frame, %Execution{memory_exceeded: true} = execution), do: {:error, {:limit_exceeded, :memory_bytes, execution.memory_limit}, execution} @@ -321,6 +362,28 @@ defmodule QuickBEAM.VM.Interpreter do push(%{frame | stack: stack}, execution, reference) end + defp execute( + :define_method, + [atom, kind], + %{stack: [callable, %Reference{} = object | stack]} = frame, + execution + ) do + key = resolve_atom(atom, execution) + + result = + case kind do + 4 -> Heap.define(execution, object, key, callable) + 5 -> Heap.define_accessor(execution, object, key, :getter, callable) + 6 -> Heap.define_accessor(execution, object, key, :setter, callable) + _kind -> {:error, {:unsupported_method_kind, kind}} + end + + case result do + {:ok, execution} -> continue(%{frame | stack: [object | stack]}, execution) + {:error, reason} -> raise_js({:type_error, reason}, frame, execution) + end + end + defp execute( :define_field, [atom], @@ -1039,6 +1102,31 @@ defmodule QuickBEAM.VM.Interpreter do end end + defp dispatch_call( + {:builtin_method, "Object", "assign"}, + arguments, + _this, + caller, + execution, + tail? + ) do + case arguments do + [%Reference{} = target | sources] -> + boundary = %ObjectAssignBoundary{ + target: target, + sources: sources, + caller: caller, + depth: execution.depth, + tail?: tail? + } + + continue_object_assign(boundary, execution) + + _arguments -> + raise_js_from_caller({:type_error, :not_an_object}, caller, execution) + end + end + defp dispatch_call( {:primitive_method, :array, method}, arguments, @@ -1068,6 +1156,28 @@ defmodule QuickBEAM.VM.Interpreter do defp complete_call_result(value, %ReactionBoundary{} = boundary, execution, _tail?), do: complete_reaction(boundary, value, execution) + defp complete_call_result( + value, + %ObjectAssignBoundary{phase: :get} = boundary, + execution, + _tail? + ), + do: assign_object_value(boundary, boundary.key, value, execution) + + defp complete_call_result( + _value, + %ObjectAssignBoundary{phase: :set} = boundary, + execution, + _tail? + ), + do: continue_object_assign(%{boundary | phase: nil, key: nil}, execution) + + defp complete_call_result(value, %ThenGetterBoundary{} = boundary, execution, _tail?), + do: complete_then_getter(value, boundary, execution) + + defp complete_call_result(value, %AccessorBoundary{} = boundary, execution, _tail?), + do: complete_accessor(value, boundary, execution) + defp complete_call_result(value, %ConstructorBoundary{} = boundary, execution, _tail?), do: complete_constructor(value, boundary, execution) @@ -1086,6 +1196,29 @@ defmodule QuickBEAM.VM.Interpreter do else: run(%{caller | stack: [value | caller.stack]}, execution) end + defp complete_then_getter(value, boundary, execution) do + execution = + if type_of(value, execution) == "function" do + Promise.enqueue_assimilation(execution, boundary.promise, boundary.thenable, value) + else + Promise.fulfill_assimilated(execution, boundary.promise, boundary.thenable) + end + + continue_after_then_getter(boundary, execution) + end + + defp continue_after_then_getter(%ThenGetterBoundary{continuation: %Frame{} = frame}, execution), + do: run(frame, execution) + + defp continue_after_then_getter(%ThenGetterBoundary{continuation: nil}, execution), + do: {:idle, execution} + + defp complete_accessor(value, %AccessorBoundary{mode: :get} = boundary, execution), + do: run(%{boundary.caller | stack: [value | boundary.caller.stack]}, execution) + + defp complete_accessor(_value, %AccessorBoundary{mode: :set} = boundary, execution), + do: run(boundary.caller, execution) + defp complete_constructor(value, boundary, execution) do result = if constructor_object?(value), @@ -1131,6 +1264,53 @@ defmodule QuickBEAM.VM.Interpreter do {:idle, execution} end + defp continue_object_assign(%ObjectAssignBoundary{keys: [key | keys]} = boundary, execution) do + case get_property(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( + %ObjectAssignBoundary{keys: [], sources: [source | sources]} = boundary, + execution + ) do + case enumerable_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(%ObjectAssignBoundary{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 Heap.put(execution, boundary.target, key, value) 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 @@ -1447,6 +1627,15 @@ defmodule QuickBEAM.VM.Interpreter do defp get_property_and_continue(object, key, stack, frame, execution) do case get_property(object, key, execution) do + {:ok, {:accessor, getter, receiver}} -> + boundary = %AccessorBoundary{ + mode: :get, + caller: %{next_frame(frame) | stack: stack}, + depth: execution.depth + } + + dispatch_call(getter, [], receiver, boundary, execution, false) + {:ok, value} -> continue(%{frame | stack: [value | stack]}, execution) @@ -1460,6 +1649,15 @@ defmodule QuickBEAM.VM.Interpreter do {:ok, execution} -> continue(%{frame | stack: stack}, execution) + {:error, {:invoke_setter, setter}} -> + boundary = %AccessorBoundary{ + mode: :set, + caller: %{next_frame(frame) | stack: stack}, + depth: execution.depth + } + + dispatch_call(setter, [value], object, boundary, execution, false) + {:error, reason} -> raise_js({:type_error, reason}, %{frame | stack: stack}, execution) end @@ -1595,6 +1793,23 @@ defmodule QuickBEAM.VM.Interpreter do do_raise_js(reason, frame, execution, trace, false) end + defp raise_js_from_caller(reason, %ObjectAssignBoundary{} = boundary, execution) do + {reason, trace} = throw_state(reason) + do_raise_js(reason, boundary.caller, execution, trace, true) + end + + defp raise_js_from_caller(reason, %ThenGetterBoundary{} = boundary, execution) do + {reason, trace} = throw_state(reason) + thrown = %Thrown{value: reason, frames: Enum.reverse(trace)} + execution = Promise.settle_assimilated(execution, boundary.promise, {:error, thrown}) + continue_after_then_getter(boundary, execution) + end + + defp raise_js_from_caller(reason, %AccessorBoundary{} = boundary, execution) do + {reason, trace} = throw_state(reason) + do_raise_js(reason, boundary.caller, execution, trace, true) + end + defp raise_js_from_caller(reason, %ConstructorBoundary{} = boundary, execution) do {reason, trace} = throw_state(reason) do_raise_js(reason, boundary.caller, execution, trace, true) @@ -1652,6 +1867,35 @@ defmodule QuickBEAM.VM.Interpreter do {:error, error, execution} end + defp unwind_caller( + reason, + %Execution{callers: [%ObjectAssignBoundary{} = boundary | callers]} = execution, + trace + ) do + execution = %{execution | callers: callers, depth: boundary.depth} + do_raise_js(reason, boundary.caller, execution, trace, true) + end + + defp unwind_caller( + reason, + %Execution{callers: [%ThenGetterBoundary{} = boundary | callers]} = execution, + trace + ) do + thrown = %Thrown{value: reason, frames: Enum.reverse(trace)} + execution = %{execution | callers: callers, depth: boundary.depth} + execution = Promise.settle_assimilated(execution, boundary.promise, {:error, thrown}) + continue_after_then_getter(boundary, execution) + end + + defp unwind_caller( + reason, + %Execution{callers: [%AccessorBoundary{} = boundary | callers]} = execution, + trace + ) do + execution = %{execution | callers: callers, depth: boundary.depth} + do_raise_js(reason, boundary.caller, execution, trace, true) + end + defp unwind_caller( reason, %Execution{callers: [%ConstructorBoundary{} = boundary | callers]} = execution, @@ -1774,7 +2018,7 @@ defmodule QuickBEAM.VM.Interpreter do %AsyncBoundary{mode: :push, caller: caller, promise: promise}, execution ), - do: run(%{caller | stack: [promise | caller.stack]}, execution) + do: complete_call_result(promise, caller, execution, false) defp deliver_async_promise(%AsyncBoundary{mode: :return, promise: promise}, execution), do: return_value(promise, execution) @@ -1801,6 +2045,30 @@ defmodule QuickBEAM.VM.Interpreter do defp return_value(value, %Execution{callers: [%AsyncBoundary{} | _]} = execution), do: complete_async(value, execution) + defp return_value( + value, + %Execution{callers: [%ObjectAssignBoundary{} = boundary | callers]} = execution + ) do + execution = %{execution | callers: callers, depth: boundary.depth} + complete_call_result(value, boundary, execution, false) + end + + defp return_value( + value, + %Execution{callers: [%ThenGetterBoundary{} = boundary | callers]} = execution + ) do + execution = %{execution | callers: callers, depth: boundary.depth} + complete_then_getter(value, boundary, execution) + end + + defp return_value( + value, + %Execution{callers: [%AccessorBoundary{} = boundary | callers]} = execution + ) do + execution = %{execution | callers: callers, depth: boundary.depth} + complete_accessor(value, boundary, execution) + end + defp return_value( value, %Execution{callers: [%ConstructorBoundary{} = boundary | callers]} = execution diff --git a/lib/quickbeam/vm/object_assign_boundary.ex b/lib/quickbeam/vm/object_assign_boundary.ex new file mode 100644 index 000000000..e08c56096 --- /dev/null +++ b/lib/quickbeam/vm/object_assign_boundary.ex @@ -0,0 +1,33 @@ +defmodule QuickBEAM.VM.ObjectAssignBoundary 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.Reference.t(), + sources: [term()], + source: term() | nil, + caller: QuickBEAM.VM.Frame.t() | QuickBEAM.VM.NativeFrame.t(), + depth: non_neg_integer(), + phase: :get | :set | nil, + key: term() | nil, + keys: [term()], + tail?: boolean() + } +end diff --git a/lib/quickbeam/vm/promise.ex b/lib/quickbeam/vm/promise.ex index 39f9cb80b..b651a2339 100644 --- a/lib/quickbeam/vm/promise.ex +++ b/lib/quickbeam/vm/promise.ex @@ -168,6 +168,12 @@ defmodule QuickBEAM.VM.Promise do end end + @doc "Enqueues invocation of a callable `then` result as a Promise microtask." + @spec enqueue_assimilation(Execution.t(), PromiseReference.t(), Reference.t(), term()) :: + Execution.t() + def enqueue_assimilation(execution, promise, thenable, callable), + do: enqueue(execution, {:assimilate_thenable, promise, thenable, callable}) + @spec enqueue_coroutine(Execution.t(), Coroutine.t(), {:ok, term()} | {:error, term()}) :: Execution.t() def enqueue_coroutine(execution, %Coroutine{} = coroutine, result), @@ -206,6 +212,10 @@ defmodule QuickBEAM.VM.Promise do 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 @@ -219,10 +229,10 @@ defmodule QuickBEAM.VM.Promise do do: settle_result(execution, promise, result) @doc """ - Settles a Promise whose resolution was locked while adopting a thenable. + Settles a Promise whose resolution was locked while adopting another value. - Settlements are ignored unless the Promise is currently in the internal - resolving state, which preserves resolver idempotence. + The adopted value is recursively resolved according to the Promise resolution + procedure, including Promise and thenable assimilation. """ @spec settle_assimilated(Execution.t(), PromiseReference.t(), {:ok, term()} | {:error, term()}) :: Execution.t() @@ -237,6 +247,19 @@ defmodule QuickBEAM.VM.Promise do end end + @doc "Fulfills a resolving Promise after a non-callable `then` getter result." + @spec fulfill_assimilated(Execution.t(), PromiseReference.t(), term()) :: Execution.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(%Execution{} = execution, %PromiseReference{id: id}, result) do case Map.fetch!(execution.promises, id) do :pending -> @@ -260,6 +283,9 @@ defmodule QuickBEAM.VM.Promise do defp then_callable(execution, reference) do case Heap.get(execution, reference, "then") do + {:ok, {:accessor, getter, receiver}} -> + {:getter, getter, receiver} + {:ok, %Reference{} = callable} -> if Builtins.callable(execution, callable), do: {:ok, callable}, else: :none @@ -402,4 +428,7 @@ defmodule QuickBEAM.VM.Promise do 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)} end diff --git a/lib/quickbeam/vm/property.ex b/lib/quickbeam/vm/property.ex index a8bbacf63..61445d10a 100644 --- a/lib/quickbeam/vm/property.ex +++ b/lib/quickbeam/vm/property.ex @@ -1,12 +1,22 @@ defmodule QuickBEAM.VM.Property do @moduledoc "Defines a JavaScript property value and its descriptor flags." - defstruct value: :undefined, writable: true, enumerable: true, configurable: true + 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() + configurable: boolean(), + getter: term() | nil, + setter: term() | nil } end diff --git a/lib/quickbeam/vm/then_getter_boundary.ex b/lib/quickbeam/vm/then_getter_boundary.ex new file mode 100644 index 000000000..45a319d02 --- /dev/null +++ b/lib/quickbeam/vm/then_getter_boundary.ex @@ -0,0 +1,18 @@ +defmodule QuickBEAM.VM.ThenGetterBoundary 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.PromiseReference.t(), + thenable: QuickBEAM.VM.Reference.t(), + depth: non_neg_integer(), + continuation: QuickBEAM.VM.Frame.t() | nil + } +end diff --git a/test/vm/interpreter_test.exs b/test/vm/interpreter_test.exs index 7ae73d8b2..6a54ad76d 100644 --- a/test/vm/interpreter_test.exs +++ b/test/vm/interpreter_test.exs @@ -197,7 +197,7 @@ defmodule QuickBEAM.VM.InterpreterTest do end test "reports unsupported opcodes without crashing the caller" do - assert {:ok, program} = QuickBEAM.VM.compile("({get answer(){return 42}})") + assert {:ok, program} = QuickBEAM.VM.compile("class UnsupportedClass {}") assert {:error, {:unsupported_opcode, _opcode, _operands}} = QuickBEAM.VM.eval(program) assert Process.alive?(self()) end diff --git a/test/vm/object_model_test.exs b/test/vm/object_model_test.exs index 85ca0d9b7..67abe7e35 100644 --- a/test/vm/object_model_test.exs +++ b/test/vm/object_model_test.exs @@ -43,6 +43,45 @@ defmodule QuickBEAM.VM.ObjectModelTest do 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}})()" + ] + + 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]})()", @@ -93,7 +132,7 @@ defmodule QuickBEAM.VM.ObjectModelTest do defp assert_vm_matches_native(runtime, source) do assert {:ok, program} = QuickBEAM.VM.compile(source) - assert {:ok, expected} = QuickBEAM.eval(runtime, source) + assert {:ok, expected} = QuickBEAM.eval(runtime, "await (#{source})") assert {:ok, ^expected} = QuickBEAM.VM.eval(program) end end diff --git a/test/vm/promise_test.exs b/test/vm/promise_test.exs index 598b6c275..e358e6cab 100644 --- a/test/vm/promise_test.exs +++ b/test/vm/promise_test.exs @@ -75,6 +75,22 @@ defmodule QuickBEAM.VM.PromiseTest do 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()=>{ From 5fba5ed4256e6d3dc2e7ac615e9c76c3dac89e1f Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 10 Jul 2026 17:10:08 +0200 Subject: [PATCH 17/87] Add pinned Test262 conformance gate --- docs/beam-interpreter-architecture.md | 3 + docs/test262-conformance.md | 65 +++++++ lib/quickbeam/js_error.ex | 2 +- lib/quickbeam/vm/builtins.ex | 230 ++++++++++++++++++++++++- lib/quickbeam/vm/execution.ex | 4 + lib/quickbeam/vm/heap.ex | 8 +- lib/quickbeam/vm/interpreter.ex | 14 +- lib/quickbeam/vm/object.ex | 2 +- lib/quickbeam/vm/value.ex | 2 + mix.exs | 7 +- test/support/test262.ex | 237 ++++++++++++++++++++++++++ test/test262/manifest.exs | 38 +++++ test/test_helper.exs | 2 + test/vm/test262_test.exs | 93 ++++++++++ 14 files changed, 696 insertions(+), 11 deletions(-) create mode 100644 docs/test262-conformance.md create mode 100644 test/support/test262.ex create mode 100644 test/test262/manifest.exs create mode 100644 test/vm/test262_test.exs diff --git a/docs/beam-interpreter-architecture.md b/docs/beam-interpreter-architecture.md index e485f543e..277f55ad3 100644 --- a/docs/beam-interpreter-architecture.md +++ b/docs/beam-interpreter-architecture.md @@ -532,6 +532,9 @@ Correctness is measured against the exact vendored QuickJS build. Skipped tests need categorized reasons: unsupported profile, unsupported language feature, native QuickJS mismatch, harness limitation, or known defect. +The initial pinned baseline selects 22 tests, explicitly excludes four async +harness tests, and passes 16 of 18 supported cases (88.9%); see +[`test262-conformance.md`](test262-conformance.md) for the exact gate. ## Performance and scheduler acceptance diff --git a/docs/test262-conformance.md b/docs/test262-conformance.md new file mode 100644 index 000000000..c5c626a38 --- /dev/null +++ b/docs/test262-conformance.md @@ -0,0 +1,65 @@ +# Selected Test262 Conformance + +QuickBEAM uses a pinned, bounded Test262 manifest as a differential correctness +gate for `QuickBEAM.VM`. Full Test262 compliance is not claimed. + +## Baseline + +- Test262 revision: `d1d583db95a521218f3eb8341a887fd63eda8ff1` +- Selected tests: 22 +- Explicitly unsupported by flags: 4 asynchronous tests +- Supported tests: 18 +- Passing: 16 +- Known failures: 2 +- Supported-test pass rate: **88.9%** +- Required pass rate: **85%** + +The exact paths and classified known failures live in +`test/test262/manifest.exs`. A newly passing known failure and an unclassified +new failure both fail the gate, so the manifest cannot silently hide changes. + +Current known failures are: + +1. `language/expressions/object/setter-prop-desc.js` — harness incompatibility; + the official `propertyHelper.js` requires `Object.getOwnPropertyNames` and a + broader `Function` method surface. +2. `language/expressions/instanceof/S11.8.6_A2.1_T2.js` — interpreter bug; + generated `ReferenceError` values do not yet carry JavaScript constructor + identity. + +## Running the gate + +Create a checkout at the pinned revision and provide its path explicitly: + +```sh +git clone --filter=blob:none --sparse https://github.com/tc39/test262.git /tmp/test262 +git -C /tmp/test262 checkout d1d583db95a521218f3eb8341a887fd63eda8ff1 +git -C /tmp/test262 sparse-checkout set harness test + +TEST262_PATH=/tmp/test262 \ + mix test test/vm/test262_test.exs --include test262 +``` + +Without `TEST262_PATH`, metadata-parser and summary tests still run while the +external corpus gate is reported as skipped. + +## Classification + +Each selected path is run in a fresh native QuickJS runtime and an isolated +BEAM VM evaluation. Results are classified as: + +- `pass` — both engines satisfy the Test262 expectation; +- `vm_failure` — native QuickJS passes and the BEAM VM fails; +- `native_failure` — the selected test or harness is incompatible with the + vendored native engine; +- `unsupported_flag` — module, raw, or asynchronous harness behavior is outside + the current bounded runner; +- `missing` — the pinned corpus does not contain a manifest path. + +There is no automatic native fallback during BEAM evaluation. The native result +is used only as a differential oracle. + +The runner supplies a small assertion harness for `assert`, `sameValue`, +`notSameValue`, and `compareArray`. Additional official harness includes are +loaded only when requested by a selected test. Tests that depend on unsupported +harness APIs remain explicitly classified rather than being rewritten to pass. diff --git a/lib/quickbeam/js_error.ex b/lib/quickbeam/js_error.ex index a613d84b9..cf9eb976d 100644 --- a/lib/quickbeam/js_error.ex +++ b/lib/quickbeam/js_error.ex @@ -89,7 +89,7 @@ defmodule QuickBEAM.JSError do defp vm_name_and_message(%__MODULE__{} = error), do: {error.name, error.message} - defp vm_name_and_message(%{} = value) do + defp vm_name_and_message(%{} = value) when not is_struct(value) do { to_string(value[:name] || value["name"] || "Error"), to_string(value[:message] || value["message"] || inspect(value)) diff --git a/lib/quickbeam/vm/builtins.ex b/lib/quickbeam/vm/builtins.ex index d91dadfca..ef1f73560 100644 --- a/lib/quickbeam/vm/builtins.ex +++ b/lib/quickbeam/vm/builtins.ex @@ -9,6 +9,7 @@ defmodule QuickBEAM.VM.Builtins do @constructors %{ "Array" => ["isArray"], + "Boolean" => [], "Object" => [ "assign", "create", @@ -18,7 +19,9 @@ defmodule QuickBEAM.VM.Builtins do "keys", "setPrototypeOf" ], + "Function" => [], "Math" => ["floor", "max", "min", "random", "round"], + "Number" => [], "String" => ["fromCharCode"], "Error" => [], "Promise" => ["all", "allSettled", "any", "race", "reject", "resolve"], @@ -27,8 +30,12 @@ defmodule QuickBEAM.VM.Builtins do @spec install(Execution.t()) :: Execution.t() def install(execution) do - Enum.reduce(@constructors, execution, fn {name, methods}, execution -> - {object, execution} = Heap.allocate(execution, :ordinary, callable: {:builtin, name}) + @constructors + |> Enum.sort_by(fn {name, _methods} -> + if name == "Object", do: {0, name}, else: {1, name} + end) + |> Enum.reduce(execution, fn {name, methods}, execution -> + {object, execution} = Heap.allocate(execution, :function, callable: {:builtin, name}) execution = Enum.reduce(methods, execution, fn method, execution -> @@ -43,6 +50,7 @@ defmodule QuickBEAM.VM.Builtins do execution = maybe_install_prototype(name, object, execution) %{execution | globals: Map.put_new(execution.globals, name, object)} end) + |> link_constructor_prototypes() end @spec callable(Execution.t(), Reference.t()) :: term() | nil @@ -60,6 +68,7 @@ defmodule QuickBEAM.VM.Builtins do def call({:builtin_method, "Object", "keys"}, _this, [value], execution) do with {:ok, keys} <- own_keys(value, execution) do + keys = Enum.map(keys, &Value.to_string_value/1) {array, execution} = array_from(keys, execution) {:ok, array, execution} else @@ -213,6 +222,34 @@ defmodule QuickBEAM.VM.Builtins do {:ok, promise, execution} end + def call( + {:primitive_method, :object, "hasOwnProperty"}, + %Reference{} = object, + [key | _], + execution + ) do + case Heap.own_property(execution, object, key) do + {:ok, property} -> {:ok, not is_nil(property), execution} + {:error, reason} -> {:error, reason, execution} + end + end + + def call( + {:primitive_method, :object, "propertyIsEnumerable"}, + %Reference{} = object, + [key | _], + execution + ) do + case Heap.own_property(execution, object, key) do + {:ok, %Property{enumerable: enumerable}} -> {:ok, enumerable, execution} + {:ok, nil} -> {:ok, false, execution} + {:error, reason} -> {:error, reason, execution} + end + end + + def call({:primitive_method, :object, "toString"}, _object, _arguments, execution), + do: {:ok, "[object Object]", execution} + def call({:primitive_method, :number, "toString"}, value, arguments, execution) do radix = case arguments do @@ -233,6 +270,13 @@ defmodule QuickBEAM.VM.Builtins do {:ok, :erlang.float_to_binary(value / 1, decimals: digits), execution} end + def call({:primitive_method, :string, method}, %Reference{} = receiver, arguments, execution) do + case primitive_value(receiver, :string, execution) do + {:ok, value} -> call({:primitive_method, :string, method}, value, arguments, execution) + :error -> {:error, :incompatible_string_receiver, execution} + end + end + def call({:primitive_method, :string, "toString"}, value, _arguments, execution), do: {:ok, value, execution} @@ -311,6 +355,39 @@ defmodule QuickBEAM.VM.Builtins do end end + def call({:builtin, "Boolean"}, %Reference{} = receiver, values, execution) do + value = values |> List.first() |> Value.truthy?() + maybe_box_primitive(receiver, :boolean, value, execution) + end + + def call({:builtin, "Boolean"}, _this, values, execution), + do: {:ok, values |> List.first() |> Value.truthy?(), execution} + + def call({:builtin, "Number"}, %Reference{} = receiver, values, execution) do + value = + case values do + [value | _] -> Value.to_number(value) + [] -> 0 + end + + maybe_box_primitive(receiver, :number, value, execution) + end + + def call({:builtin, "Number"}, _this, [value], execution), + do: {:ok, Value.to_number(value), execution} + + def call({:builtin, "Number"}, _this, [], execution), do: {:ok, 0, execution} + + def call({:builtin, "String"}, %Reference{} = receiver, values, execution) do + value = + case values do + [value | _] -> Value.to_string_value(value) + [] -> "" + end + + maybe_box_primitive(receiver, :string, value, execution) + end + def call({:builtin, "String"}, _this, [value], execution), do: {:ok, Value.to_string_value(value), execution} @@ -352,6 +429,12 @@ defmodule QuickBEAM.VM.Builtins do end end + def call({:builtin, "FunctionPrototype"}, _this, _arguments, execution), + do: {:ok, :undefined, execution} + + def call({:builtin, "Function"}, _this, _arguments, execution), + do: {:error, :dynamic_function_unsupported, execution} + def call({:builtin, "Array"}, _this, [length], execution) when is_integer(length) and length >= 0 do {array, execution} = Heap.allocate(execution, :array, length: length) @@ -378,6 +461,89 @@ defmodule QuickBEAM.VM.Builtins do def call(callable, _this, _arguments, execution), do: {:error, {:unsupported_builtin, callable}, execution} + defp link_constructor_prototypes(execution) do + case execution.default_prototypes do + %{function: function_prototype} -> + Enum.reduce(Map.keys(@constructors), execution, fn name, execution -> + constructor = Map.fetch!(execution.globals, name) + {:ok, execution} = Heap.set_prototype(execution, constructor, function_prototype) + execution + end) + + _no_function_prototype -> + execution + end + end + + defp maybe_install_prototype("Object", constructor, execution) do + {prototype, execution} = Heap.allocate(execution, :ordinary, prototype: nil) + + execution = + Enum.reduce(["hasOwnProperty", "propertyIsEnumerable", "toString"], execution, fn method, + execution -> + {:ok, execution} = + Heap.define(execution, prototype, method, {:primitive_method, :object, method}, + enumerable: false + ) + + execution + end) + + {:ok, execution} = + Heap.define(execution, prototype, "constructor", constructor, enumerable: false) + + {:ok, execution} = + Heap.define(execution, constructor, "prototype", prototype, enumerable: false) + + %{ + execution + | default_prototypes: Map.put(execution.default_prototypes, :ordinary, prototype) + } + end + + defp maybe_install_prototype("Function", constructor, execution) do + {prototype, execution} = + Heap.allocate(execution, :function, + callable: {:builtin, "FunctionPrototype"}, + prototype: Map.get(execution.default_prototypes, :ordinary) + ) + + {:ok, execution} = + Heap.define(execution, constructor, "prototype", prototype, enumerable: false) + + %{ + execution + | default_prototypes: Map.put(execution.default_prototypes, :function, prototype) + } + end + + defp maybe_install_prototype("Array", constructor, execution) do + install_primitive_prototype( + constructor, + :array, + ["concat", "filter", "forEach", "join", "map", "reduce", "slice", "some"], + execution + ) + end + + defp maybe_install_prototype("String", constructor, execution) do + install_primitive_prototype( + constructor, + :string, + [ + "charCodeAt", + "includes", + "replace", + "slice", + "split", + "startsWith", + "toLowerCase", + "toString" + ], + execution + ) + end + defp maybe_install_prototype("Promise", constructor, execution) do {prototype, execution} = Heap.allocate(execution) @@ -392,6 +558,32 @@ defmodule QuickBEAM.VM.Builtins do defp maybe_install_prototype(_name, _constructor, execution), do: execution + defp install_primitive_prototype(constructor, kind, methods, execution) do + {prototype, execution} = Heap.allocate(execution) + + execution = + Enum.reduce(methods, execution, fn method, execution -> + {:ok, execution} = + Heap.define(execution, prototype, method, {:primitive_method, kind, method}, + enumerable: false + ) + + execution + end) + + {:ok, execution} = + Heap.define(execution, constructor, "prototype", prototype, enumerable: false) + + if kind == :array do + %{ + execution + | default_prototypes: Map.put(execution.default_prototypes, :array, prototype) + } + else + execution + 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), @@ -526,6 +718,26 @@ defmodule QuickBEAM.VM.Builtins do {descriptor, execution} end + defp maybe_box_primitive(receiver, kind, value, execution) do + case Heap.fetch_object(execution, receiver) do + {:ok, %Object{internal: :constructor_instance}} -> + {:ok, execution} = + Heap.update_object(execution, receiver, &%{&1 | internal: {:primitive, kind, value}}) + + {:ok, receiver, execution} + + _not_constructor -> + {:ok, value, execution} + end + end + + defp primitive_value(reference, kind, execution) do + case Heap.fetch_object(execution, reference) do + {:ok, %Object{internal: {:primitive, ^kind, value}}} -> {:ok, value} + _other -> :error + end + end + defp array?(%Reference{} = reference, execution) do match?({:ok, %Object{kind: :array}}, Heap.fetch_object(execution, reference)) end @@ -619,19 +831,29 @@ defmodule QuickBEAM.VM.Builtins do defp slice_range(size, arguments) do start = case arguments do - [start | _] -> normalize_index(Value.to_int32(start), size) + [start | _] -> normalize_slice_index(start, size) [] -> 0 end finish = case arguments do - [_start, finish | _] -> normalize_index(Value.to_int32(finish), size) + [_start, finish | _] -> normalize_slice_index(finish, size) _ -> size end {start, max(finish - start, 0)} end + defp normalize_slice_index(value, size) do + case Value.to_number(value) do + :infinity -> size + :neg_infinity -> 0 + :nan -> 0 + index when is_number(index) -> normalize_index(trunc(index), size) + _value -> 0 + end + end + defp normalize_index(index, size) when index < 0, do: max(size + index, 0) defp normalize_index(index, size), do: min(index, size) diff --git a/lib/quickbeam/vm/execution.ex b/lib/quickbeam/vm/execution.ex index 690513892..a50d02741 100644 --- a/lib/quickbeam/vm/execution.ex +++ b/lib/quickbeam/vm/execution.ex @@ -13,6 +13,7 @@ defmodule QuickBEAM.VM.Execution do callers: [], cells: %{}, depth: 1, + default_prototypes: %{}, globals: %{}, handlers: %{}, heap: %{}, @@ -48,6 +49,9 @@ defmodule QuickBEAM.VM.Execution do ], cells: %{optional(non_neg_integer()) => term()}, depth: non_neg_integer(), + default_prototypes: %{ + optional(QuickBEAM.VM.Object.kind()) => QuickBEAM.VM.Reference.t() + }, globals: map(), handlers: %{optional(String.t()) => function()}, heap: %{optional(non_neg_integer()) => QuickBEAM.VM.Object.t()}, diff --git a/lib/quickbeam/vm/heap.ex b/lib/quickbeam/vm/heap.ex index 324775c07..0f29dd4aa 100644 --- a/lib/quickbeam/vm/heap.ex +++ b/lib/quickbeam/vm/heap.ex @@ -14,9 +14,15 @@ defmodule QuickBEAM.VM.Heap do def allocate(%Execution{} = execution, kind \\ :ordinary, opts \\ []) do id = execution.next_object_id + prototype = + case Keyword.fetch(opts, :prototype) do + {:ok, prototype} -> prototype + :error -> Map.get(execution.default_prototypes, kind) + end + object = %Object{ kind: kind, - prototype: Keyword.get(opts, :prototype), + prototype: prototype, length: Keyword.get(opts, :length, 0), callable: Keyword.get(opts, :callable), internal: Keyword.get(opts, :internal) diff --git a/lib/quickbeam/vm/interpreter.ex b/lib/quickbeam/vm/interpreter.ex index 5ef74b5a3..5242726e4 100644 --- a/lib/quickbeam/vm/interpreter.ex +++ b/lib/quickbeam/vm/interpreter.ex @@ -881,7 +881,12 @@ defmodule QuickBEAM.VM.Interpreter do if constructable?(constructor, execution) do caller = %{next_frame(frame) | stack: rest} prototype = constructor_prototype(constructor, execution) - {instance, execution} = Heap.allocate(execution, :ordinary, prototype: prototype) + + {instance, execution} = + Heap.allocate(execution, :ordinary, + prototype: prototype, + internal: :constructor_instance + ) boundary = %ConstructorBoundary{ instance: instance, @@ -922,7 +927,7 @@ defmodule QuickBEAM.VM.Interpreter do do: constructable?(target, execution) defp constructable?({:builtin, name}, _execution), - do: name in ["Array", "Error", "Object", "Promise", "Set", "String"] + do: name in ["Array", "Boolean", "Error", "Number", "Object", "Promise", "Set", "String"] defp constructable?(_constructor, _execution), do: false @@ -1557,6 +1562,9 @@ defmodule QuickBEAM.VM.Interpreter do 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) @@ -2117,7 +2125,7 @@ defmodule QuickBEAM.VM.Interpreter do end defp allocate_function(callable, function, execution) do - {reference, execution} = Heap.allocate(execution, :ordinary, callable: callable) + {reference, execution} = Heap.allocate(execution, :function, callable: callable) if function.has_prototype do {prototype, execution} = Heap.allocate(execution) diff --git a/lib/quickbeam/vm/object.ex b/lib/quickbeam/vm/object.ex index 9688b207c..17efda5e9 100644 --- a/lib/quickbeam/vm/object.ex +++ b/lib/quickbeam/vm/object.ex @@ -11,7 +11,7 @@ defmodule QuickBEAM.VM.Object do callable: nil, internal: nil - @type kind :: :ordinary | :array | :promise | :set + @type kind :: :ordinary | :array | :function | :promise | :set @type t :: %__MODULE__{ kind: kind(), prototype: QuickBEAM.VM.Reference.t() | nil, diff --git a/lib/quickbeam/vm/value.ex b/lib/quickbeam/vm/value.ex index 1163bcf04..e0b12035e 100644 --- a/lib/quickbeam/vm/value.ex +++ b/lib/quickbeam/vm/value.ex @@ -117,6 +117,8 @@ defmodule QuickBEAM.VM.Value do 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 diff --git a/mix.exs b/mix.exs index 957ed01eb..1b531e8ef 100644 --- a/mix.exs +++ b/mix.exs @@ -105,10 +105,15 @@ defmodule QuickBEAM.MixProject do "README.md", "docs/javascript-api.md", "docs/architecture.md", + "docs/test262-conformance.md", "CHANGELOG.md" ], groups_for_extras: [ - Guides: ["docs/javascript-api.md", "docs/architecture.md"] + Guides: [ + "docs/javascript-api.md", + "docs/architecture.md", + "docs/test262-conformance.md" + ] ], filter_modules: &documented_module?/2, source_ref: "v#{@version}" diff --git a/test/support/test262.ex b/test/support/test262.ex new file mode 100644 index 000000000..24cb9b01e --- /dev/null +++ b/test/support/test262.ex @@ -0,0 +1,237 @@ +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. + """ + + @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()) :: map() + def parse_metadata(source) do + case Regex.run(~r/\/\*---\s*(.*?)\s*---\*\//s, source, capture: :all_but_first) do + [yaml] -> + %{ + flags: parse_list(yaml, "flags"), + includes: parse_list(yaml, "includes"), + features: parse_list(yaml, "features"), + negative: parse_negative(yaml) + } + + _no_metadata -> + %{flags: [], includes: [], features: [], negative: nil} + end + end + + @doc "Runs every entry in a selected manifest and returns classified results." + @spec run_manifest(Path.t(), keyword()) :: [result()] + def run_manifest(root, manifest) do + Enum.map(manifest[:tests], &run(root, &1)) + end + + @doc "Runs one Test262 path against both engines." + @spec run(Path.t(), Path.t()) :: result() + def run(root, relative_path) 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) + + 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) 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) + + 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) do + case QuickBEAM.VM.compile(source, filename: "test262.js") do + {:ok, program} -> classify_execution(QuickBEAM.VM.eval(program), negative, :runtime) + {:error, error} -> classify_execution({:error, error}, negative, :parse) + 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}, %{phase: phase, type: type}, actual_phase) do + if 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 error_name(%QuickBEAM.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 parse_list(yaml, key) do + inline = Regex.run(~r/^#{Regex.escape(key)}:\s*\[([^\]]*)\]/m, yaml, capture: :all_but_first) + + case inline do + [values] -> + values + |> String.split(",", trim: true) + |> Enum.map(&(&1 |> String.trim() |> String.trim("\"") |> String.trim("'"))) + + _ -> + case Regex.run(~r/^#{Regex.escape(key)}:\s*\n((?:\s+-\s+[^\n]+\n?)*)/m, yaml, + capture: :all_but_first + ) do + [items] -> + Regex.scan(~r/^\s+-\s+([^\n]+)/m, items, capture: :all_but_first) + |> List.flatten() + |> Enum.map(&String.trim/1) + + _ -> + [] + end + end + end + + defp parse_negative(yaml) do + case Regex.run( + ~r/^negative:\s*\n\s+phase:\s*([^\n]+)\n\s+type:\s*([^\n]+)/m, + yaml, + capture: :all_but_first + ) do + [phase, type] -> + %{phase: parse_phase(String.trim(phase)), type: String.trim(type)} + + _ -> + nil + end + end + + defp parse_phase("parse"), do: :parse + defp parse_phase("early"), do: :parse + defp parse_phase("resolution"), do: :resolution + defp parse_phase("runtime"), do: :runtime + defp parse_phase(_phase), do: :unknown + + defp result(path, classification, vm, native, metadata), + do: %{path: path, classification: classification, vm: vm, native: native, metadata: metadata} + + defp safe_stop(runtime) do + try do + QuickBEAM.stop(runtime) + catch + :exit, _reason -> :ok + end + end +end diff --git a/test/test262/manifest.exs b/test/test262/manifest.exs new file mode 100644 index 000000000..5d1e1f635 --- /dev/null +++ b/test/test262/manifest.exs @@ -0,0 +1,38 @@ +[ + revision: "d1d583db95a521218f3eb8341a887fd63eda8ff1", + minimum_pass_rate: 0.85, + known_failures: %{ + "language/expressions/object/setter-prop-desc.js" => %{ + category: :harness_incompatibility, + reason: "propertyHelper.js requires Object.getOwnPropertyNames and broader Function methods" + }, + "language/expressions/instanceof/S11.8.6_A2.1_T2.js" => %{ + category: :interpreter_bug, + reason: "generated ReferenceError values do not yet carry constructor identity" + } + }, + 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" + ] +] 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/vm/test262_test.exs b/test/vm/test262_test.exs new file mode 100644 index 000000000..dd5aa8e20 --- /dev/null +++ b/test/vm/test262_test.exs @@ -0,0 +1,93 @@ +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 %{ + flags: ["onlyStrict"], + includes: ["compareArray.js"], + features: ["async-functions"], + negative: %{phase: :runtime, type: "TypeError"} + } = QuickBEAM.Test262.parse_metadata(source) + 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 + + 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 + end +end From 8c90520d4b2831c3a479ab668c66e842899fd6ce Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 10 Jul 2026 21:20:45 +0200 Subject: [PATCH 18/87] Decode Test262 metadata with JSONCodec --- docs/test262-conformance.md | 5 ++ mix.exs | 2 + mix.lock | 3 ++ test/support/test262.ex | 99 +++++++++++++++++-------------------- test/vm/test262_test.exs | 16 +++++- 5 files changed, 69 insertions(+), 56 deletions(-) diff --git a/docs/test262-conformance.md b/docs/test262-conformance.md index c5c626a38..1c6f535e3 100644 --- a/docs/test262-conformance.md +++ b/docs/test262-conformance.md @@ -59,6 +59,11 @@ BEAM VM evaluation. Results are classified as: There is no automatic native fallback during BEAM evaluation. The native result is used only as a differential oracle. +Test262 YAML front matter is parsed by `YamlElixir` and decoded into strict, +typed metadata structs by `JSONCodec`. Flags, includes, features, negative-test +shape, and the finite phase enum therefore have one explicit boundary contract; +unknown phases produce structured codec errors rather than fallback atoms. + The runner supplies a small assertion harness for `assert`, `sameValue`, `notSameValue`, and `compareArray`. Additional official harness includes are loaded only when requested by a selected test. Tests that depend on unsupported diff --git a/mix.exs b/mix.exs index 1b531e8ef..e3ff77668 100644 --- a/mix.exs +++ b/mix.exs @@ -70,6 +70,8 @@ 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}, diff --git a/mix.lock b/mix.lock index 74e5ac196..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"}, @@ -42,6 +43,8 @@ "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/test/support/test262.ex b/test/support/test262.ex index 24cb9b01e..c0d37abbc 100644 --- a/test/support/test262.ex +++ b/test/support/test262.ex @@ -1,3 +1,34 @@ +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. @@ -59,19 +90,16 @@ defmodule QuickBEAM.Test262 do def configured_root, do: System.get_env("TEST262_PATH") @doc "Parses the metadata fields needed by the bounded runner." - @spec parse_metadata(binary()) :: map() + @spec parse_metadata(binary()) :: QuickBEAM.Test262.Metadata.t() def parse_metadata(source) do case Regex.run(~r/\/\*---\s*(.*?)\s*---\*\//s, source, capture: :all_but_first) do [yaml] -> - %{ - flags: parse_list(yaml, "flags"), - includes: parse_list(yaml, "includes"), - features: parse_list(yaml, "features"), - negative: parse_negative(yaml) - } + yaml + |> YamlElixir.read_from_string!() + |> QuickBEAM.Test262.Metadata.from_map!() _no_metadata -> - %{flags: [], includes: [], features: [], negative: nil} + %QuickBEAM.Test262.Metadata{} end end @@ -161,8 +189,12 @@ defmodule QuickBEAM.Test262 do defp classify_execution({:ok, _value}, nil, _phase), do: :pass - defp classify_execution({:error, error}, %{phase: phase, type: type}, actual_phase) do - if phase == actual_phase and error_name(error) == type, + 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 @@ -170,6 +202,9 @@ defmodule QuickBEAM.Test262 do 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(%QuickBEAM.JSError{name: name}), do: name defp error_name(error) when is_map(error), do: error[:name] || error["name"] defp error_name(_error), do: nil @@ -180,50 +215,6 @@ defmodule QuickBEAM.Test262 do defp strict_prefix(flags), do: if("onlyStrict" in flags, do: "\"use strict\";\n", else: "") - defp parse_list(yaml, key) do - inline = Regex.run(~r/^#{Regex.escape(key)}:\s*\[([^\]]*)\]/m, yaml, capture: :all_but_first) - - case inline do - [values] -> - values - |> String.split(",", trim: true) - |> Enum.map(&(&1 |> String.trim() |> String.trim("\"") |> String.trim("'"))) - - _ -> - case Regex.run(~r/^#{Regex.escape(key)}:\s*\n((?:\s+-\s+[^\n]+\n?)*)/m, yaml, - capture: :all_but_first - ) do - [items] -> - Regex.scan(~r/^\s+-\s+([^\n]+)/m, items, capture: :all_but_first) - |> List.flatten() - |> Enum.map(&String.trim/1) - - _ -> - [] - end - end - end - - defp parse_negative(yaml) do - case Regex.run( - ~r/^negative:\s*\n\s+phase:\s*([^\n]+)\n\s+type:\s*([^\n]+)/m, - yaml, - capture: :all_but_first - ) do - [phase, type] -> - %{phase: parse_phase(String.trim(phase)), type: String.trim(type)} - - _ -> - nil - end - end - - defp parse_phase("parse"), do: :parse - defp parse_phase("early"), do: :parse - defp parse_phase("resolution"), do: :resolution - defp parse_phase("runtime"), do: :runtime - defp parse_phase(_phase), do: :unknown - defp result(path, classification, vm, native, metadata), do: %{path: path, classification: classification, vm: vm, native: native, metadata: metadata} diff --git a/test/vm/test262_test.exs b/test/vm/test262_test.exs index dd5aa8e20..eff82134b 100644 --- a/test/vm/test262_test.exs +++ b/test/vm/test262_test.exs @@ -19,14 +19,26 @@ defmodule QuickBEAM.VM.Test262Test do ---*/ """ - assert %{ + assert %QuickBEAM.Test262.Metadata{ flags: ["onlyStrict"], includes: ["compareArray.js"], features: ["async-functions"], - negative: %{phase: :runtime, type: "TypeError"} + 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]) From bc5f83e5060bfdc6721f5ab5da7fe54098b693a7 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 10 Jul 2026 21:33:45 +0200 Subject: [PATCH 19/87] Document prototype extraction audit --- docs/beam-interpreter-architecture.md | 5 + docs/prototype-delta-audit.md | 373 ++++++++++++++++++++++++++ mix.exs | 2 + 3 files changed, 380 insertions(+) create mode 100644 docs/prototype-delta-audit.md diff --git a/docs/beam-interpreter-architecture.md b/docs/beam-interpreter-architecture.md index 277f55ad3..c298bfba1 100644 --- a/docs/beam-interpreter-architecture.md +++ b/docs/beam-interpreter-architecture.md @@ -649,6 +649,11 @@ Only after interpreter correctness is stable: ## Prototype branch extraction map +The focused subsystem audit and ordered extraction plan are published in +[`prototype-delta-audit.md`](prototype-delta-audit.md). The prototype is a source +of algorithms and tests, not a branch to merge or a runtime to layer underneath +the clean interpreter. + Potentially salvage after focused review and adaptation: - bytecode LEB128 and serialization tests; diff --git a/docs/prototype-delta-audit.md b/docs/prototype-delta-audit.md new file mode 100644 index 000000000..34d80d6ca --- /dev/null +++ b/docs/prototype-delta-audit.md @@ -0,0 +1,373 @@ +# Prototype Branch Delta Audit + +This audit compares the clean `beam-interpreter-v2` implementation with +`origin/beam-vm-interpreter`, reviewed in `.worktrees/beam-vm-review`. It is an +extraction guide, not a merge plan. + +The prototype contains useful semantic algorithms and a large test corpus, but +its mutable runtime is built around process-dictionary storage and a different +execution contract. No production source file should be copied without being +adapted to the current owner-local `%QuickBEAM.VM.Execution{}` model, generated +QuickJS v26 ABI, resource limits, and resumable invocation machinery. + +## Executive decision + +| Area | Decision | Reason | +|---|---|---| +| ABI, decoding, verification | Superseded | Current code uses generated QuickJS v26 metadata, fingerprints, checksums, bounds, and structural verification. | +| Interpreter kernel | Superseded structurally | Current explicit frames, boundaries, limits, async coroutines, and owner event loop are the production foundation. | +| Heap storage | Reject prototype implementation | Prototype heap storage uses the process dictionary; current heap is explicit evaluation state. | +| Error semantics | Adapt algorithms and tests | Prototype has useful Error hierarchy/prototype behavior, but construction and stack capture depend on the old heap and runtime macros. | +| Object enumeration | Adapt small algorithms and tests | Own-key filtering/order behavior is useful and maps cleanly onto current descriptors. | +| Arrays | Extract tests first; adapt selected algorithms | Prototype implementation is broad and tightly coupled to its object model. Sparse-hole and length algorithms are valuable. | +| Iterators | Adapt after canonical invocation/Symbol work | Protocol and IteratorClose logic are useful, but direct recursive invocation is incompatible with resumable callbacks. | +| Promises | Reject core implementation; adapt combinator tests/algorithms | Current detached async frames and reaction scheduler supersede the prototype Promise store. Iterable combinator logic remains useful. | +| Coercion/arithmetic | Adapt pure semantic functions and tests | The split into arithmetic, comparison, equality, and coercion is a good target, but object coercion and throws use prototype representations. | +| Test262 support | Superseded harness; extract test ideas | Current runner is pinned, differential, typed with JSONCodec, and classifies failures. Prototype paths and skip rationale remain useful input. | +| Preact SSR | Superseded | Current fixture performs real async rendering, native parity, and concurrent isolated reuse. | +| Web/Node APIs | Defer | Outside the first explicit SSR profile. | +| BEAM compiler | Quarantine for later extraction | It targets the prototype runtime ABI, creates dynamic module atoms, and relies on interpreter fallback and process-local runtime state. | + +## Scale and coupling + +The prototype has 331 VM source files and 103 VM test files. The clean branch +currently has 43 VM source files and 13 VM test files. Breadth in the prototype +is not evidence that a subsystem is production-ready. + +At least 27 prototype VM modules directly use process-dictionary operations. +Core modules that do not call `Process.get/put` themselves still call the +prototype heap facade, whose low-level store implementation does. For example: + +- `lib/quickbeam/vm/heap/store.ex` stores objects, arrays, shapes, and cells with + `Process.get/put`; +- `lib/quickbeam/vm/runtime/errors.ex` creates errors through that heap; +- `lib/quickbeam/vm/runtime/array.ex`, `runtime/promise.ex`, and + `semantics/iterators.ex` transitively depend on it. + +The five broad prototype runtime files reviewed for this audit total 5,304 lines: + +- `runtime/errors.ex` — 451; +- `runtime/object/enumeration.ex` — 231; +- `runtime/array.ex` — 3,131; +- `runtime/promise.ex` — 1,082; +- `semantics/iterators.ex` — 409. + +They should not be introduced as one extraction change. + +## Detailed extraction map + +### 1. Errors and constructor identity + +Prototype sources: + +- `lib/quickbeam/vm/runtime/errors.ex` +- `lib/quickbeam/vm/heap.ex` (`make_error/2`) +- `lib/quickbeam/vm/js_throw.ex` +- `lib/quickbeam/vm/stacktrace.ex` +- `test/vm/runtime/constructors_test.exs` + +Reusable concepts: + +- the standard error-name set; +- `Error.prototype` plus derived prototype topology; +- non-enumerable `name`, `message`, and stack-related properties; +- constructor-specific prototypes for `instanceof`; +- `AggregateError.errors` ordering; +- preserving catchable JavaScript objects until the API boundary; +- constructor and descriptor tests. + +Do not copy: + +- `Heap.make_error/2`, because it discovers constructors through process-local + global caches and stores objects by raw references; +- `throw({:js_throw, ...})` control flow; +- stack attachment based on prototype interpreter context; +- builtin definition macros as a prerequisite for the first error slice. + +Current adaptation target: + +1. Add an error-object kind or internal slot to the current owner-local heap. +2. Install `Error`, `TypeError`, `ReferenceError`, `RangeError`, and required + prototypes in `Builtins`. +3. Convert VM-generated catchable errors into owner-local references. +4. Resolve name/message/frames from the owning execution only when uncaught. +5. Preserve `%QuickBEAM.JSError{}` as the stable external representation. + +This is the highest-value immediate extraction and closes the remaining selected +Test262 constructor-identity failure. + +### 2. Own-property enumeration + +Prototype sources: + +- `lib/quickbeam/vm/runtime/object/enumeration.ex` +- `lib/quickbeam/vm/object_model/own_property.ex` +- `test/vm/object_descriptors_order_test.exs` +- `test/vm/object_keys_test.exs` +- `test/vm/object_own_property_primitives_test.exs` + +Reusable concepts: + +- numeric keys first, then string insertion order, then symbols; +- distinction between enumerable keys and all own property names; +- descriptor-aware `Object.keys`, values, and entries; +- UTF-16 indexed string own properties; +- tests for non-enumerable properties and descriptor order. + +Current adaptation target: + +- extend current `Heap.own_keys/2` with an all-own-keys operation rather than + copying prototype map/shape traversal; +- implement `Object.getOwnPropertyNames` over current `Property` structs; +- add only the small `propertyHelper.js` dependencies required by the pinned + conformance manifest. + +The prototype implementation is useful as a specification checklist, not as +source code. + +### 3. Arrays and sparse holes + +Prototype sources: + +- `lib/quickbeam/vm/runtime/array.ex` +- `lib/quickbeam/vm/object_model/array_exotic.ex` +- `lib/quickbeam/vm/object_model/array_exotic_get.ex` +- `lib/quickbeam/vm/heap/arrays.ex` +- `test/vm/runtime/array_test.exs` +- typed-array and array descriptor tests + +Reusable concepts: + +- maximum array length and safe-integer bounds; +- hole-aware `HasProperty` before callback invocation; +- inherited values at sparse indices; +- length updates around non-configurable elements; +- iterator and species edge-case tests; +- mutation ordering for push/pop/shift/unshift/splice. + +Do not copy: + +- `:array` plus process-dictionary object storage; +- recursive callback invocation from runtime methods; +- the full 3,131-line API surface before profile demand; +- typed-array branches before ordinary arrays are conformant. + +Extraction sequence: + +1. Port sparse `map/filter/reduce/some/forEach` differential tests. +2. Add a canonical current-heap `has_index`/`get_index` operation. +3. Update resumable native callback frames to skip holes where required. +4. Add precise partial length truncation around non-configurable elements. +5. Add further methods only when selected Test262 or SSR code requires them. + +### 4. Iterators and iterable Promise combinators + +Prototype sources: + +- `lib/quickbeam/vm/semantics/iterators.ex` +- `lib/quickbeam/vm/execution/iterator_state.ex` +- `lib/quickbeam/vm/interpreter/ops/iterators.ex` +- `test/vm/iterator_semantics_test.exs` +- iterator portions of `runtime/promise.ex` + +Reusable concepts: + +- `GetIterator`, `IteratorNext`, result-object validation, and `IteratorClose`; +- close-on-abrupt-completion tests; +- custom array iterator replacement/deletion behavior; +- string iteration by code point while string indexing remains UTF-16; +- Promise combinator ordering and iterator closing. + +Do not copy: + +- recursive `Invocation.invoke_with_receiver` loops; +- prototype tagged values such as `{:obj, ref}` and `{:list_iter, ...}` without + adapting them to current owner-local references; +- synchronous collection of arbitrary iterators, which can bypass limits and + resumable JavaScript callbacks. + +Current adaptation target: + +- represent iterator operations as explicit resumable boundaries/jobs; +- use the same canonical invocation path as getters, setters, reactions, and + `Object.assign`; +- charge steps and memory on every transition; +- make `Promise.all/allSettled/any/race` consume iterables through that layer. + +### 5. Promise runtime + +Prototype source: + +- `lib/quickbeam/vm/runtime/promise.ex` +- `lib/quickbeam/vm/promise.ex` +- `lib/quickbeam/vm/job_queue.ex` + +Current status: + +The clean branch already supersedes the prototype in the production-critical +areas: detached async frames, multiple suspended coroutines, FIFO reactions, +thenable getter ordering, handler tasks, cancellation, Promise adoption, +self-resolution protection, and process-owned event-loop state. + +Potential extraction: + +- constructor/species tests; +- iterable combinator algorithms after iterator support; +- iterator-close-on-rejection cases; +- capability tests for subclassed Promise constructors. + +Reject the prototype Promise store and microtask queue. They use the old heap and +do not provide the current owner/task containment model. + +### 6. Value semantics + +Prototype sources: + +- `lib/quickbeam/vm/semantics/values.ex` +- `lib/quickbeam/vm/semantics/arithmetic.ex` +- `lib/quickbeam/vm/semantics/comparison.ex` +- `lib/quickbeam/vm/semantics/equality.ex` +- `lib/quickbeam/vm/semantics/coercion.ex` + +Reusable concepts: + +- splitting pure semantic families; +- infinity, NaN, negative zero, BigInt, and Symbol cases; +- differential/property tests; +- inlining only after semantics are centralized. + +Adaptation rule: + +Pure number/string clauses may be ported with focused tests. Any clause that +reads prototype objects, throws through `JSThrow`, or invokes user code must be +rewritten against current `Execution`, `Heap`, and resumable invocation. + +This split is a useful model for decomposing the current `Value` and +`Interpreter`, but the prototype module graph is too fragmented to copy. + +### 7. Interpreter decomposition + +Prototype sources include 40-plus opcode modules and dozens of object-model +micro-modules. The separation demonstrates useful boundaries, but it also +created overlapping routes for get/put/call semantics and a very large internal +surface. + +Target decomposition for the clean branch should be smaller: + +1. `Invocation` — ordinary, constructor, bound, builtin, and host calls; +2. `Properties` — get/set/define/delete/prototype and accessor boundaries; +3. `Async` — Promise resolution, jobs, coroutines, and handler completion; +4. `Exceptions` — throw values, catch/finally unwinding, and external errors; +5. `Values` — coercion, arithmetic, equality, comparison, and UTF-16; +6. opcode-family dispatch modules that call those canonical layers. + +No opcode module may implement an alternative property, invocation, or Promise +algorithm. + +### 8. BEAM compiler + +Prototype sources: + +- `lib/quickbeam/vm/compiler.ex` +- `lib/quickbeam/vm/compiler/analysis/*` +- `lib/quickbeam/vm/compiler/lowering/*` +- `lib/quickbeam/vm/compiler/runtime_abi/*` +- `lib/quickbeam/vm/compiler/runtime_helpers/*` +- compiler tests + +Potentially reusable later: + +- CFG and stack analyses; +- basic-block lowering structure; +- Erlang abstract-form emission patterns; +- diagnostics and disassembly tests; +- the concept of one explicit runtime ABI; +- resource caps on instruction, atom, constant, and generated-form counts. + +Release blockers in the prototype compiler: + +- generated module names create atoms from hashes and loaded modules are cached, + so the design needs a bounded reusable module pool and purge strategy; +- generated code targets prototype `RuntimeABI`, object tags, process heap, and + invocation context; +- semantic fallback assumes the prototype interpreter and heap; +- runtime step, stack, memory, async, and cancellation containment are not the + clean interpreter's limits; +- compiler correctness tests mix broad runtime support with lowering behavior; +- QuickJS v25 assumptions must be regenerated for v26. + +Compiler extraction gate: + +1. Current interpreter semantic layers are stable and independently tested. +2. A small versioned runtime ABI is documented. +3. Compiled operations call that ABI rather than prototype helpers. +4. A bounded module pool prevents atom/module leaks. +5. Runtime limits and async suspension have compiled-path acceptance tests. +6. Unsupported compiled regions deopt explicitly to verified interpreter state; + they never fall back to native execution. + +Until those gates hold, the compiler remains quarantined. + +## Test extraction policy + +Prototype tests are generally more reusable than prototype implementations. +For each selected area: + +1. copy the JavaScript scenario, not prototype setup helpers; +2. run it through current native QuickJS and `QuickBEAM.VM`; +3. remove `mode: :beam`, process-dictionary resets, and compiler assumptions; +4. preserve a source link in the commit message or test comment when useful; +5. keep only cases in the declared profile or conformance roadmap; +6. classify unsupported cases explicitly rather than weakening assertions. + +High-value test groups to adapt next: + +- error constructor/prototype and catch identity; +- object own names and descriptor order; +- sparse array callback behavior; +- iterator result validation and close-on-abrupt-completion; +- Promise iterable combinator ordering and closing; +- pure coercion and arithmetic sentinels. + +## Ordered long-term plan + +### Phase A — close the current bounded baseline + +- Adapt Error hierarchy concepts and tests. +- Adapt `Object.getOwnPropertyNames` semantics and only required harness helpers. +- Raise the selected Test262 baseline from 88.9% to 100%. + +### Phase B — establish canonical semantic modules + +- Split invocation, properties, async, exceptions, and values from the current + interpreter without changing behavior. +- Keep the current tests and SSR fixture green after each extraction. +- Avoid reproducing the prototype's hundreds of overlapping modules. + +### Phase C — expand conformance by profile demand + +- Add sparse-array tests and hole-aware native callback frames. +- Add Symbol/iterator foundations and iterable Promise combinators. +- Expand the pinned Test262 manifest with exact classified thresholds. +- Add decoder mutation fuzzing. + +### Phase D — production hardening + +- Resolve the parallel native-NIF finalizer race. +- Audit cancellation, worker shutdown, and owner-local cleanup. +- Publish scheduler, memory, timeout, and performance results. +- Freeze the supported SSR compatibility matrix. + +### Phase E — optional compiler extraction + +- Extract analyses and lowering patterns only after the runtime ABI is stable. +- Introduce a bounded module pool. +- Add explicit deoptimization to interpreter states. +- Keep compiler and interpreter differential tests mandatory. + +## Immediate next action + +Do not copy another prototype runtime module. Adapt the prototype Error hierarchy +and constructor-identity tests into the current owner-local heap, then implement +the small own-property-name surface required to bring the pinned Test262 subset +to 100%. diff --git a/mix.exs b/mix.exs index e3ff77668..71f778fb4 100644 --- a/mix.exs +++ b/mix.exs @@ -107,6 +107,7 @@ defmodule QuickBEAM.MixProject do "README.md", "docs/javascript-api.md", "docs/architecture.md", + "docs/prototype-delta-audit.md", "docs/test262-conformance.md", "CHANGELOG.md" ], @@ -114,6 +115,7 @@ defmodule QuickBEAM.MixProject do Guides: [ "docs/javascript-api.md", "docs/architecture.md", + "docs/prototype-delta-audit.md", "docs/test262-conformance.md" ] ], From 32e9f53209f0b160a0c38b2e4730560f1f4c26cf Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 10 Jul 2026 21:58:58 +0200 Subject: [PATCH 20/87] Add JavaScript error hierarchy and close Test262 baseline --- docs/beam-interpreter-architecture.md | 11 +- docs/prototype-delta-audit.md | 15 ++- docs/test262-conformance.md | 19 ++- lib/quickbeam/vm/builtins.ex | 171 +++++++++++++++++++++++++- lib/quickbeam/vm/evaluator.ex | 4 +- lib/quickbeam/vm/exceptions.ex | 66 ++++++++++ lib/quickbeam/vm/execution.ex | 2 + lib/quickbeam/vm/export.ex | 13 +- lib/quickbeam/vm/heap.ex | 22 ++++ lib/quickbeam/vm/interpreter.ex | 55 ++++++--- test/test262/manifest.exs | 13 +- test/vm/error_test.exs | 42 +++++++ test/vm/memory_limit_test.exs | 4 +- test/vm/object_model_test.exs | 11 ++ 14 files changed, 386 insertions(+), 62 deletions(-) create mode 100644 lib/quickbeam/vm/exceptions.ex diff --git a/docs/beam-interpreter-architecture.md b/docs/beam-interpreter-architecture.md index c298bfba1..939333ea3 100644 --- a/docs/beam-interpreter-architecture.md +++ b/docs/beam-interpreter-architecture.md @@ -493,10 +493,11 @@ cancellation mechanisms. JavaScript throws become `%QuickBEAM.JSError{}` with JavaScript name, message, filename, line, column, and structured JavaScript stack frames when debug -metadata is present. Generated `TypeError` and `ReferenceError` values remain -ordinary error-like JavaScript values while a `catch` block handles them; they -are converted only when they escape the evaluation. Elixir handler stack traces -must never appear in the public JavaScript stack. +metadata is present. Generated errors are owner-local JavaScript objects with +`Error`, `TypeError`, `ReferenceError`, `RangeError`, `SyntaxError`, `EvalError`, +and `URIError` prototype identity while JavaScript can catch them. They become +`%QuickBEAM.JSError{}` only when they escape the evaluation. Elixir handler +stack traces must never appear in the public JavaScript stack. Limit and infrastructure failures should remain distinguishable: @@ -533,7 +534,7 @@ Correctness is measured against the exact vendored QuickJS build. Skipped tests need categorized reasons: unsupported profile, unsupported language feature, native QuickJS mismatch, harness limitation, or known defect. The initial pinned baseline selects 22 tests, explicitly excludes four async -harness tests, and passes 16 of 18 supported cases (88.9%); see +harness tests, and passes all 18 supported cases (100%); see [`test262-conformance.md`](test262-conformance.md) for the exact gate. ## Performance and scheduler acceptance diff --git a/docs/prototype-delta-audit.md b/docs/prototype-delta-audit.md index 34d80d6ca..895e1ca54 100644 --- a/docs/prototype-delta-audit.md +++ b/docs/prototype-delta-audit.md @@ -331,11 +331,11 @@ High-value test groups to adapt next: ## Ordered long-term plan -### Phase A — close the current bounded baseline +### Phase A — close the current bounded baseline (complete) -- Adapt Error hierarchy concepts and tests. -- Adapt `Object.getOwnPropertyNames` semantics and only required harness helpers. -- Raise the selected Test262 baseline from 88.9% to 100%. +- Adapted Error hierarchy concepts and tests. +- Adapted `Object.getOwnPropertyNames` semantics and required harness helpers. +- Raised the selected Test262 baseline from 88.9% to 100%. ### Phase B — establish canonical semantic modules @@ -367,7 +367,6 @@ High-value test groups to adapt next: ## Immediate next action -Do not copy another prototype runtime module. Adapt the prototype Error hierarchy -and constructor-identity tests into the current owner-local heap, then implement -the small own-property-name surface required to bring the pinned Test262 subset -to 100%. +Proceed to Phase B. Split canonical invocation, property, async, exception, and +value semantics from the current interpreter without copying the prototype's +runtime modules or changing observable behavior. diff --git a/docs/test262-conformance.md b/docs/test262-conformance.md index 1c6f535e3..ce012fdae 100644 --- a/docs/test262-conformance.md +++ b/docs/test262-conformance.md @@ -9,23 +9,18 @@ gate for `QuickBEAM.VM`. Full Test262 compliance is not claimed. - Selected tests: 22 - Explicitly unsupported by flags: 4 asynchronous tests - Supported tests: 18 -- Passing: 16 -- Known failures: 2 -- Supported-test pass rate: **88.9%** -- Required pass rate: **85%** +- Passing: 18 +- Known failures: 0 +- Supported-test pass rate: **100%** +- Required pass rate: **100%** The exact paths and classified known failures live in `test/test262/manifest.exs`. A newly passing known failure and an unclassified new failure both fail the gate, so the manifest cannot silently hide changes. -Current known failures are: - -1. `language/expressions/object/setter-prop-desc.js` — harness incompatibility; - the official `propertyHelper.js` requires `Object.getOwnPropertyNames` and a - broader `Function` method surface. -2. `language/expressions/instanceof/S11.8.6_A2.1_T2.js` — interpreter bug; - generated `ReferenceError` values do not yet carry JavaScript constructor - identity. +The selected supported set currently has no known failures. The previous +`propertyHelper.js` dependency gap and generated `ReferenceError` constructor +identity failure are covered by the passing manifest. ## Running the gate diff --git a/lib/quickbeam/vm/builtins.ex b/lib/quickbeam/vm/builtins.ex index ef1f73560..878b331e5 100644 --- a/lib/quickbeam/vm/builtins.ex +++ b/lib/quickbeam/vm/builtins.ex @@ -7,6 +7,8 @@ defmodule QuickBEAM.VM.Builtins do alias QuickBEAM.VM.{Execution, Heap, Object, Property, Reference, RegExp, UTF16, Value} + @error_types ~w(Error EvalError RangeError ReferenceError SyntaxError TypeError URIError) + @constructors %{ "Array" => ["isArray"], "Boolean" => [], @@ -15,14 +17,21 @@ defmodule QuickBEAM.VM.Builtins do "create", "defineProperty", "getOwnPropertyDescriptor", + "getOwnPropertyNames", "getPrototypeOf", "keys", "setPrototypeOf" ], + "EvalError" => [], "Function" => [], - "Math" => ["floor", "max", "min", "random", "round"], + "Math" => ["floor", "max", "min", "pow", "random", "round"], "Number" => [], + "RangeError" => [], + "ReferenceError" => [], "String" => ["fromCharCode"], + "SyntaxError" => [], + "TypeError" => [], + "URIError" => [], "Error" => [], "Promise" => ["all", "allSettled", "any", "race", "reject", "resolve"], "Set" => [] @@ -61,6 +70,25 @@ defmodule QuickBEAM.VM.Builtins do end end + @doc "Allocates a catchable JavaScript error object in the current evaluation heap." + @spec new_error(Execution.t(), String.t(), String.t()) :: {Reference.t(), Execution.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}) + + {:ok, execution} = + Heap.define(execution, error, "message", message, + enumerable: false, + configurable: true, + writable: true + ) + + {error, execution} + end + @spec call(term(), term(), [term()], Execution.t()) :: {:ok, term(), Execution.t()} | {:error, term(), Execution.t()} def call({:builtin_method, "Array", "isArray"}, _this, [value], execution), @@ -119,6 +147,22 @@ defmodule QuickBEAM.VM.Builtins do end end + def call( + {:builtin_method, "Object", "getOwnPropertyNames"}, + _this, + [%Reference{} = target | _], + execution + ) do + case Heap.own_property_names(execution, target) do + {:ok, keys} -> + {array, execution} = array_from(keys, execution) + {:ok, array, execution} + + {:error, reason} -> + {:error, reason, execution} + end + end + def call( {:builtin_method, "Object", "getPrototypeOf"}, _this, @@ -168,6 +212,9 @@ defmodule QuickBEAM.VM.Builtins do def call({:builtin_method, "Math", "max"}, _this, values, execution), do: {:ok, numeric_extreme(values, &max/2, :neg_infinity), execution} + def call({:builtin_method, "Math", "pow"}, _this, [base, exponent | _], execution), + do: {:ok, Value.power(base, exponent), execution} + def call({:builtin_method, "String", "fromCharCode"}, _this, values, execution) do string = values |> Enum.map(&(Value.to_int32(&1) &&& 0xFFFF)) |> UTF16.from_units() {:ok, string, execution} @@ -250,6 +297,25 @@ defmodule QuickBEAM.VM.Builtins do def call({:primitive_method, :object, "toString"}, _object, _arguments, execution), do: {:ok, "[object Object]", execution} + def call({:primitive_method, :error, "toString"}, %Reference{} = error, _arguments, execution) do + with {:ok, name} <- Heap.get(execution, error, "name"), + {:ok, message} <- Heap.get(execution, error, "message") 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 call({:primitive_method, :number, "toString"}, value, arguments, execution) do radix = case arguments do @@ -317,6 +383,24 @@ defmodule QuickBEAM.VM.Builtins do def call({:primitive_method, :regexp, "test"}, %RegExp{} = regexp, [value | _], execution), do: {:ok, regex_match?(regexp, Value.to_string_value(value)), execution} + def call({:primitive_method, :array, "push"}, %Reference{} = array, values, 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} = Heap.put(execution, array, index, value) + execution + end) + + {:ok, length + Kernel.length(values), execution} + + _not_array -> + {:error, :not_an_array, execution} + end + end + def call({:primitive_method, :array, "join"}, value, arguments, execution) do separator = case arguments do @@ -454,9 +538,37 @@ defmodule QuickBEAM.VM.Builtins do {:ok, object, execution} end - def call({:builtin, "Error"}, _this, values, execution), - do: - {:ok, %{name: "Error", message: Value.to_string_value(List.first(values) || "")}, execution} + def call({:builtin, name}, this, values, execution) + when name in @error_types do + message = + case values do + [value | _] -> Value.to_string_value(value) + [] -> "" + end + + case constructor_instance?(this, execution) do + true -> + prototype = Map.get(execution.error_prototypes, name) + + {:ok, execution} = + Heap.update_object(execution, this, fn object -> + %{object | prototype: prototype, internal: {:error, name}} + end) + + {:ok, execution} = + Heap.define(execution, this, "message", message, + enumerable: false, + configurable: true, + writable: true + ) + + {:ok, this, execution} + + false -> + {error, execution} = new_error(execution, name, message) + {:ok, error, execution} + end + end def call(callable, _this, _arguments, execution), do: {:error, {:unsupported_builtin, callable}, execution} @@ -501,6 +613,46 @@ defmodule QuickBEAM.VM.Builtins do } end + defp maybe_install_prototype("Error", constructor, execution) do + object_prototype = Map.get(execution.default_prototypes, :ordinary) + {prototype, execution} = Heap.allocate(execution, :ordinary, prototype: object_prototype) + + execution = + [ + {"name", "Error"}, + {"message", ""}, + {"constructor", constructor}, + {"toString", {:primitive_method, :error, "toString"}} + ] + |> Enum.reduce(execution, fn {key, value}, execution -> + {:ok, execution} = Heap.define(execution, prototype, key, value, enumerable: false) + execution + end) + + {:ok, execution} = + Heap.define(execution, constructor, "prototype", prototype, enumerable: false) + + %{execution | error_prototypes: Map.put(execution.error_prototypes, "Error", prototype)} + end + + defp maybe_install_prototype(name, constructor, execution) + when name in @error_types do + error_prototype = Map.fetch!(execution.error_prototypes, "Error") + {prototype, execution} = Heap.allocate(execution, :ordinary, prototype: error_prototype) + + execution = + [{"name", name}, {"message", ""}, {"constructor", constructor}] + |> Enum.reduce(execution, fn {key, value}, execution -> + {:ok, execution} = Heap.define(execution, prototype, key, value, enumerable: false) + execution + end) + + {:ok, execution} = + Heap.define(execution, constructor, "prototype", prototype, enumerable: false) + + %{execution | error_prototypes: Map.put(execution.error_prototypes, name, prototype)} + end + defp maybe_install_prototype("Function", constructor, execution) do {prototype, execution} = Heap.allocate(execution, :function, @@ -521,7 +673,7 @@ defmodule QuickBEAM.VM.Builtins do install_primitive_prototype( constructor, :array, - ["concat", "filter", "forEach", "join", "map", "reduce", "slice", "some"], + ["concat", "filter", "forEach", "join", "map", "push", "reduce", "slice", "some"], execution ) end @@ -718,6 +870,15 @@ defmodule QuickBEAM.VM.Builtins do {descriptor, execution} end + 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 maybe_box_primitive(receiver, kind, value, execution) do case Heap.fetch_object(execution, receiver) do {:ok, %Object{internal: :constructor_instance}} -> diff --git a/lib/quickbeam/vm/evaluator.ex b/lib/quickbeam/vm/evaluator.ex index 4ec8e26c0..bebc1795e 100644 --- a/lib/quickbeam/vm/evaluator.ex +++ b/lib/quickbeam/vm/evaluator.ex @@ -10,6 +10,7 @@ defmodule QuickBEAM.VM.Evaluator do Continuation, Coroutine, Execution, + Exceptions, Interpreter, Memory, Promise, @@ -74,7 +75,8 @@ defmodule QuickBEAM.VM.Evaluator do finish_final({:error, error, execution}) {:rejected, reason} -> - finish_final({:error, QuickBEAM.JSError.from_vm(reason, []), execution}) + error = Exceptions.to_js_error(reason, execution, []) + finish_final({:error, error, execution}) :pending -> drive_event_loop(promise, execution) diff --git a/lib/quickbeam/vm/exceptions.ex b/lib/quickbeam/vm/exceptions.ex new file mode 100644 index 000000000..e3e046bb6 --- /dev/null +++ b/lib/quickbeam/vm/exceptions.ex @@ -0,0 +1,66 @@ +defmodule QuickBEAM.VM.Exceptions 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.{Builtins, Execution, Heap, Object, Reference, Thrown} + + @doc "Materializes a generated VM exception as a JavaScript heap value." + @spec materialize(term(), Execution.t()) :: {term(), Execution.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"] || "") + Builtins.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(), Execution.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(), Execution.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 Heap.get(execution, reference, "name") do + {:ok, value} when value not in [:undefined, nil] -> to_string_value(value) + _missing -> default_name + end + + message = + case Heap.get(execution, reference, "message") do + {:ok, :undefined} -> "" + {:ok, value} -> to_string_value(value) + {:error, _reason} -> "" + end + + {:ok, %{"name" => name, "message" => message}} + + _not_error -> + :error + end + end + + defp to_string_value(value) when is_binary(value), do: value + defp to_string_value(value), do: QuickBEAM.VM.Value.to_string_value(value) +end diff --git a/lib/quickbeam/vm/execution.ex b/lib/quickbeam/vm/execution.ex index a50d02741..96eb4d381 100644 --- a/lib/quickbeam/vm/execution.ex +++ b/lib/quickbeam/vm/execution.ex @@ -14,6 +14,7 @@ defmodule QuickBEAM.VM.Execution do cells: %{}, depth: 1, default_prototypes: %{}, + error_prototypes: %{}, globals: %{}, handlers: %{}, heap: %{}, @@ -52,6 +53,7 @@ defmodule QuickBEAM.VM.Execution do default_prototypes: %{ optional(QuickBEAM.VM.Object.kind()) => QuickBEAM.VM.Reference.t() }, + error_prototypes: %{optional(String.t()) => QuickBEAM.VM.Reference.t()}, globals: map(), handlers: %{optional(String.t()) => function()}, heap: %{optional(non_neg_integer()) => QuickBEAM.VM.Object.t()}, diff --git a/lib/quickbeam/vm/export.ex b/lib/quickbeam/vm/export.ex index c231b2271..2b361b9e2 100644 --- a/lib/quickbeam/vm/export.ex +++ b/lib/quickbeam/vm/export.ex @@ -6,7 +6,16 @@ defmodule QuickBEAM.VM.Export do references outside their evaluation process. """ - alias QuickBEAM.VM.{Execution, Heap, Object, Promise, PromiseReference, Property, Reference} + alias QuickBEAM.VM.{ + Exceptions, + Execution, + Heap, + Object, + Promise, + PromiseReference, + Property, + Reference + } @spec value(term(), Execution.t()) :: {:ok, term()} | {:error, term()} def value(value, %Execution{} = execution), do: convert(value, execution, MapSet.new()) @@ -14,7 +23,7 @@ defmodule QuickBEAM.VM.Export do defp convert(%PromiseReference{} = promise, execution, seen) do case Promise.state(execution, promise) do {:fulfilled, value} -> convert(value, execution, seen) - {:rejected, reason} -> {:error, QuickBEAM.JSError.from_vm(reason, [])} + {:rejected, reason} -> {:error, Exceptions.to_js_error(reason, execution, [])} :pending -> {:error, :pending_promise_result} end end diff --git a/lib/quickbeam/vm/heap.ex b/lib/quickbeam/vm/heap.ex index 0f29dd4aa..b854f84a7 100644 --- a/lib/quickbeam/vm/heap.ex +++ b/lib/quickbeam/vm/heap.ex @@ -237,6 +237,28 @@ defmodule QuickBEAM.VM.Heap do end end + @doc "Returns all own string property names in ECMAScript key order." + @spec own_property_names(Execution.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 + @spec own_keys(Execution.t(), Reference.t()) :: {:ok, [term()]} | {:error, term()} def own_keys(execution, %Reference{id: id} = reference) do case fetch_object(execution, reference) do diff --git a/lib/quickbeam/vm/interpreter.ex b/lib/quickbeam/vm/interpreter.ex index 5242726e4..15138aa87 100644 --- a/lib/quickbeam/vm/interpreter.ex +++ b/lib/quickbeam/vm/interpreter.ex @@ -17,6 +17,7 @@ defmodule QuickBEAM.VM.Interpreter do ConstructorBoundary, Coroutine, Execution, + Exceptions, Export, Frame, Function, @@ -927,7 +928,23 @@ defmodule QuickBEAM.VM.Interpreter do do: constructable?(target, execution) defp constructable?({:builtin, name}, _execution), - do: name in ["Array", "Boolean", "Error", "Number", "Object", "Promise", "Set", "String"] + do: + name in [ + "Array", + "Boolean", + "Error", + "EvalError", + "Number", + "Object", + "Promise", + "RangeError", + "ReferenceError", + "Set", + "String", + "SyntaxError", + "TypeError", + "URIError" + ] defp constructable?(_constructor, _execution), do: false @@ -1703,6 +1720,7 @@ defmodule QuickBEAM.VM.Interpreter do :builtin, :builtin_method, :bound_function, + :function_method, :host_function, :primitive_method, :promise_method, @@ -1792,72 +1810,77 @@ defmodule QuickBEAM.VM.Interpreter do defp map_string_key(_map, _key), do: :undefined defp raise_js(reason, %NativeFrame{caller: caller}, execution) do - {reason, trace} = throw_state(reason) + {reason, trace, execution} = throw_state(reason, execution) do_raise_js(reason, caller, execution, trace, true) end defp raise_js(reason, frame, execution) do - {reason, trace} = throw_state(reason) + {reason, trace, execution} = throw_state(reason, execution) do_raise_js(reason, frame, execution, trace, false) end defp raise_js_from_caller(reason, %ObjectAssignBoundary{} = boundary, execution) do - {reason, trace} = throw_state(reason) + {reason, trace, execution} = throw_state(reason, execution) do_raise_js(reason, boundary.caller, execution, trace, true) end defp raise_js_from_caller(reason, %ThenGetterBoundary{} = boundary, execution) do - {reason, trace} = throw_state(reason) + {reason, trace, execution} = throw_state(reason, execution) thrown = %Thrown{value: reason, frames: Enum.reverse(trace)} execution = Promise.settle_assimilated(execution, boundary.promise, {:error, thrown}) continue_after_then_getter(boundary, execution) end defp raise_js_from_caller(reason, %AccessorBoundary{} = boundary, execution) do - {reason, trace} = throw_state(reason) + {reason, trace, execution} = throw_state(reason, execution) do_raise_js(reason, boundary.caller, execution, trace, true) end defp raise_js_from_caller(reason, %ConstructorBoundary{} = boundary, execution) do - {reason, trace} = throw_state(reason) + {reason, trace, execution} = throw_state(reason, execution) do_raise_js(reason, boundary.caller, execution, trace, true) end defp raise_js_from_caller(reason, %ThenableBoundary{} = boundary, execution) do - {reason, trace} = throw_state(reason) + {reason, trace, execution} = throw_state(reason, execution) thrown = %Thrown{value: reason, frames: Enum.reverse(trace)} execution = Promise.settle_assimilated(execution, boundary.promise, {:error, thrown}) {:idle, execution} end defp raise_js_from_caller(reason, %PromiseExecutorBoundary{} = boundary, execution) do - {reason, trace} = throw_state(reason) + {reason, trace, execution} = throw_state(reason, execution) thrown = %Thrown{value: reason, frames: Enum.reverse(trace)} execution = Promise.settle(execution, boundary.promise, {:error, thrown}) complete_executor(boundary, execution) end defp raise_js_from_caller(reason, %ReactionBoundary{} = boundary, execution) do - {reason, trace} = throw_state(reason) + {reason, trace, execution} = throw_state(reason, execution) thrown = %Thrown{value: reason, frames: Enum.reverse(trace)} execution = Promise.settle(execution, boundary.promise, {:error, thrown}) {:idle, execution} end defp raise_js_from_caller(reason, %NativeFrame{caller: caller}, execution) do - {reason, trace} = throw_state(reason) + {reason, trace, execution} = throw_state(reason, execution) do_raise_js(reason, caller, execution, trace, true) end defp raise_js_from_caller(reason, frame, execution) do - {reason, trace} = throw_state(reason) + {reason, trace, execution} = throw_state(reason, execution) do_raise_js(reason, frame, execution, trace, true) end - defp throw_state(%Thrown{value: value, frames: frames}), - do: {QuickBEAM.JSError.vm_exception_value(value), Enum.reverse(frames)} + defp throw_state(%Thrown{value: value, frames: frames}, execution) do + {value, execution} = Exceptions.materialize(value, execution) + {value, Enum.reverse(frames), execution} + end - defp throw_state(reason), do: {QuickBEAM.JSError.vm_exception_value(reason), []} + defp throw_state(reason, execution) do + {value, execution} = Exceptions.materialize(reason, execution) + {value, [], execution} + end defp do_raise_js(reason, frame, execution, trace, caller?) do case split_at_catch(frame.stack) do @@ -1871,7 +1894,7 @@ defmodule QuickBEAM.VM.Interpreter do end defp unwind_caller(reason, %Execution{callers: []} = execution, trace) do - error = QuickBEAM.JSError.from_vm(reason, Enum.reverse(trace)) + error = Exceptions.to_js_error(reason, execution, Enum.reverse(trace)) {:error, error, execution} end diff --git a/test/test262/manifest.exs b/test/test262/manifest.exs index 5d1e1f635..13af3b6cd 100644 --- a/test/test262/manifest.exs +++ b/test/test262/manifest.exs @@ -1,16 +1,7 @@ [ revision: "d1d583db95a521218f3eb8341a887fd63eda8ff1", - minimum_pass_rate: 0.85, - known_failures: %{ - "language/expressions/object/setter-prop-desc.js" => %{ - category: :harness_incompatibility, - reason: "propertyHelper.js requires Object.getOwnPropertyNames and broader Function methods" - }, - "language/expressions/instanceof/S11.8.6_A2.1_T2.js" => %{ - category: :interpreter_bug, - reason: "generated ReferenceError values do not yet carry constructor identity" - } - }, + 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", diff --git a/test/vm/error_test.exs b/test/vm/error_test.exs index 6661a2502..70be3eaf8 100644 --- a/test/vm/error_test.exs +++ b/test/vm/error_test.exs @@ -32,6 +32,48 @@ defmodule QuickBEAM.VM.ErrorTest do 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 "normalizes uncaught type errors" do assert {:ok, program} = QuickBEAM.VM.compile("const value = 1; value()", filename: "type.js") diff --git a/test/vm/memory_limit_test.exs b/test/vm/memory_limit_test.exs index 2781d30b0..be968b22d 100644 --- a/test/vm/memory_limit_test.exs +++ b/test/vm/memory_limit_test.exs @@ -55,10 +55,10 @@ defmodule QuickBEAM.VM.MemoryLimitTest do Enum.to_list(1..5_000) end - assert {:error, {:limit_exceeded, :memory_bytes, 20_000}} = + assert {:error, {:limit_exceeded, :memory_bytes, 40_000}} = QuickBEAM.VM.eval(program, handlers: %{"large_result" => handler}, - memory_limit: 20_000, + memory_limit: 40_000, timeout: 5_000 ) diff --git a/test/vm/object_model_test.exs b/test/vm/object_model_test.exs index 67abe7e35..1bae68c71 100644 --- a/test/vm/object_model_test.exs +++ b/test/vm/object_model_test.exs @@ -119,6 +119,17 @@ defmodule QuickBEAM.VM.ObjectModelTest do 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(',')})()", From b92d0397d3a073ea4858ef5f27b1a5861f4ee790 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 13 Jul 2026 10:56:35 +0200 Subject: [PATCH 21/87] Extract canonical VM property semantics --- docs/beam-interpreter-architecture.md | 4 +- docs/prototype-delta-audit.md | 13 +- lib/quickbeam/vm/builtins.ex | 93 ++++++----- lib/quickbeam/vm/exceptions.ex | 6 +- lib/quickbeam/vm/interpreter.ex | 173 ++++---------------- lib/quickbeam/vm/promise.ex | 4 +- lib/quickbeam/vm/properties.ex | 218 ++++++++++++++++++++++++++ test/vm/properties_test.exs | 56 +++++++ 8 files changed, 377 insertions(+), 190 deletions(-) create mode 100644 lib/quickbeam/vm/properties.ex create mode 100644 test/vm/properties_test.exs diff --git a/docs/beam-interpreter-architecture.md b/docs/beam-interpreter-architecture.md index 939333ea3..e82504680 100644 --- a/docs/beam-interpreter-architecture.md +++ b/docs/beam-interpreter-architecture.md @@ -357,7 +357,9 @@ terms, while semantic sentinels and reference types use explicit tagged values. The value representation, coercion rules, property semantics, invocation rules, and built-ins form one canonical semantic layer shared by the interpreter and any future compiler. A compiled path must not grow a second implementation of -JavaScript semantics. +JavaScript semantics. `QuickBEAM.VM.Properties` is now the canonical boundary +for get, put, define, delete, descriptors, enumeration, primitive properties, +and prototype operations; accessor results remain explicit resumable actions. Recommended initial representation: diff --git a/docs/prototype-delta-audit.md b/docs/prototype-delta-audit.md index 895e1ca54..171c52c70 100644 --- a/docs/prototype-delta-audit.md +++ b/docs/prototype-delta-audit.md @@ -337,10 +337,13 @@ High-value test groups to adapt next: - Adapted `Object.getOwnPropertyNames` semantics and required harness helpers. - Raised the selected Test262 baseline from 88.9% to 100%. -### Phase B — establish canonical semantic modules +### Phase B — establish canonical semantic modules (in progress) -- Split invocation, properties, async, exceptions, and values from the current - interpreter without changing behavior. +- Property semantics now live behind one canonical property layer, including + explicit getter/setter actions used by the interpreter, built-ins, Promise + thenable lookup, and exception conversion. +- Split invocation, async, exceptions, and values from the current interpreter + without changing behavior. - Keep the current tests and SSR fixture green after each extraction. - Avoid reproducing the prototype's hundreds of overlapping modules. @@ -367,6 +370,6 @@ High-value test groups to adapt next: ## Immediate next action -Proceed to Phase B. Split canonical invocation, property, async, exception, and -value semantics from the current interpreter without copying the prototype's +Continue Phase B with canonical invocation. Keep ordinary, bound, constructor, +builtin, and host calls on one resumable path without copying the prototype's runtime modules or changing observable behavior. diff --git a/lib/quickbeam/vm/builtins.ex b/lib/quickbeam/vm/builtins.ex index 878b331e5..5e66b2a26 100644 --- a/lib/quickbeam/vm/builtins.ex +++ b/lib/quickbeam/vm/builtins.ex @@ -5,7 +5,17 @@ defmodule QuickBEAM.VM.Builtins do import Bitwise - alias QuickBEAM.VM.{Execution, Heap, Object, Property, Reference, RegExp, UTF16, Value} + alias QuickBEAM.VM.{ + Execution, + Heap, + Object, + Properties, + Property, + Reference, + RegExp, + UTF16, + Value + } @error_types ~w(Error EvalError RangeError ReferenceError SyntaxError TypeError URIError) @@ -49,7 +59,7 @@ defmodule QuickBEAM.VM.Builtins do execution = Enum.reduce(methods, execution, fn method, execution -> {:ok, execution} = - Heap.define(execution, object, method, {:builtin_method, name, method}, + Properties.define(object, method, {:builtin_method, name, method}, execution, enumerable: false ) @@ -80,7 +90,7 @@ defmodule QuickBEAM.VM.Builtins do Heap.allocate(execution, :ordinary, prototype: prototype, internal: {:error, name}) {:ok, execution} = - Heap.define(execution, error, "message", message, + Properties.define(error, "message", message, execution, enumerable: false, configurable: true, writable: true @@ -119,7 +129,7 @@ defmodule QuickBEAM.VM.Builtins do [%Reference{} = target, key, descriptor | _], execution ) do - with {:ok, current} <- Heap.own_property(execution, target, key), + with {:ok, current} <- Properties.own_property(target, key, execution), {:ok, definition} <- descriptor_definition(descriptor, current, execution), {:ok, execution} <- define_property(execution, target, key, definition) do {:ok, target, execution} @@ -134,7 +144,7 @@ defmodule QuickBEAM.VM.Builtins do [%Reference{} = target, key | _], execution ) do - case Heap.own_property(execution, target, key) do + case Properties.own_property(target, key, execution) do {:ok, nil} -> {:ok, :undefined, execution} @@ -153,7 +163,7 @@ defmodule QuickBEAM.VM.Builtins do [%Reference{} = target | _], execution ) do - case Heap.own_property_names(execution, target) do + case Properties.own_property_names(target, execution) do {:ok, keys} -> {array, execution} = array_from(keys, execution) {:ok, array, execution} @@ -169,7 +179,7 @@ defmodule QuickBEAM.VM.Builtins do [%Reference{} = target | _], execution ) do - case Heap.prototype(execution, target) do + case Properties.prototype(target, execution) do {:ok, prototype} -> {:ok, prototype, execution} {:error, reason} -> {:error, reason, execution} end @@ -182,7 +192,7 @@ defmodule QuickBEAM.VM.Builtins do execution ) when is_nil(prototype) or is_struct(prototype, Reference) do - case Heap.set_prototype(execution, target, prototype) do + case Properties.set_prototype(target, prototype, execution) do {:ok, execution} -> {:ok, target, execution} {:error, reason} -> {:error, reason, execution} end @@ -275,7 +285,7 @@ defmodule QuickBEAM.VM.Builtins do [key | _], execution ) do - case Heap.own_property(execution, object, key) do + case Properties.own_property(object, key, execution) do {:ok, property} -> {:ok, not is_nil(property), execution} {:error, reason} -> {:error, reason, execution} end @@ -287,7 +297,7 @@ defmodule QuickBEAM.VM.Builtins do [key | _], execution ) do - case Heap.own_property(execution, object, key) do + case Properties.own_property(object, key, execution) do {:ok, %Property{enumerable: enumerable}} -> {:ok, enumerable, execution} {:ok, nil} -> {:ok, false, execution} {:error, reason} -> {:error, reason, execution} @@ -298,8 +308,8 @@ defmodule QuickBEAM.VM.Builtins do do: {:ok, "[object Object]", execution} def call({:primitive_method, :error, "toString"}, %Reference{} = error, _arguments, execution) do - with {:ok, name} <- Heap.get(execution, error, "name"), - {:ok, message} <- Heap.get(execution, error, "message") do + with {:ok, name} <- Properties.get(error, "name", execution), + {:ok, message} <- Properties.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) @@ -390,7 +400,7 @@ defmodule QuickBEAM.VM.Builtins do values |> Enum.with_index(length) |> Enum.reduce(execution, fn {value, index}, execution -> - {:ok, execution} = Heap.put(execution, array, index, value) + {:ok, execution} = Properties.put(array, index, value, execution) execution end) @@ -556,7 +566,7 @@ defmodule QuickBEAM.VM.Builtins do end) {:ok, execution} = - Heap.define(execution, this, "message", message, + Properties.define(this, "message", message, execution, enumerable: false, configurable: true, writable: true @@ -578,7 +588,10 @@ defmodule QuickBEAM.VM.Builtins do %{function: function_prototype} -> Enum.reduce(Map.keys(@constructors), execution, fn name, execution -> constructor = Map.fetch!(execution.globals, name) - {:ok, execution} = Heap.set_prototype(execution, constructor, function_prototype) + + {:ok, execution} = + Properties.set_prototype(constructor, function_prototype, execution) + execution end) @@ -594,7 +607,7 @@ defmodule QuickBEAM.VM.Builtins do Enum.reduce(["hasOwnProperty", "propertyIsEnumerable", "toString"], execution, fn method, execution -> {:ok, execution} = - Heap.define(execution, prototype, method, {:primitive_method, :object, method}, + Properties.define(prototype, method, {:primitive_method, :object, method}, execution, enumerable: false ) @@ -602,10 +615,10 @@ defmodule QuickBEAM.VM.Builtins do end) {:ok, execution} = - Heap.define(execution, prototype, "constructor", constructor, enumerable: false) + Properties.define(prototype, "constructor", constructor, execution, enumerable: false) {:ok, execution} = - Heap.define(execution, constructor, "prototype", prototype, enumerable: false) + Properties.define(constructor, "prototype", prototype, execution, enumerable: false) %{ execution @@ -625,12 +638,14 @@ defmodule QuickBEAM.VM.Builtins do {"toString", {:primitive_method, :error, "toString"}} ] |> Enum.reduce(execution, fn {key, value}, execution -> - {:ok, execution} = Heap.define(execution, prototype, key, value, enumerable: false) + {:ok, execution} = + Properties.define(prototype, key, value, execution, enumerable: false) + execution end) {:ok, execution} = - Heap.define(execution, constructor, "prototype", prototype, enumerable: false) + Properties.define(constructor, "prototype", prototype, execution, enumerable: false) %{execution | error_prototypes: Map.put(execution.error_prototypes, "Error", prototype)} end @@ -643,12 +658,14 @@ defmodule QuickBEAM.VM.Builtins do execution = [{"name", name}, {"message", ""}, {"constructor", constructor}] |> Enum.reduce(execution, fn {key, value}, execution -> - {:ok, execution} = Heap.define(execution, prototype, key, value, enumerable: false) + {:ok, execution} = + Properties.define(prototype, key, value, execution, enumerable: false) + execution end) {:ok, execution} = - Heap.define(execution, constructor, "prototype", prototype, enumerable: false) + Properties.define(constructor, "prototype", prototype, execution, enumerable: false) %{execution | error_prototypes: Map.put(execution.error_prototypes, name, prototype)} end @@ -661,7 +678,7 @@ defmodule QuickBEAM.VM.Builtins do ) {:ok, execution} = - Heap.define(execution, constructor, "prototype", prototype, enumerable: false) + Properties.define(constructor, "prototype", prototype, execution, enumerable: false) %{ execution @@ -700,10 +717,12 @@ defmodule QuickBEAM.VM.Builtins do {prototype, execution} = Heap.allocate(execution) {:ok, execution} = - Heap.define(execution, prototype, "then", {:promise_method, "then"}, enumerable: false) + Properties.define(prototype, "then", {:promise_method, "then"}, execution, + enumerable: false + ) {:ok, execution} = - Heap.define(execution, constructor, "prototype", prototype, enumerable: false) + Properties.define(constructor, "prototype", prototype, execution, enumerable: false) execution end @@ -716,7 +735,7 @@ defmodule QuickBEAM.VM.Builtins do execution = Enum.reduce(methods, execution, fn method, execution -> {:ok, execution} = - Heap.define(execution, prototype, method, {:primitive_method, kind, method}, + Properties.define(prototype, method, {:primitive_method, kind, method}, execution, enumerable: false ) @@ -724,7 +743,7 @@ defmodule QuickBEAM.VM.Builtins do end) {:ok, execution} = - Heap.define(execution, constructor, "prototype", prototype, enumerable: false) + Properties.define(constructor, "prototype", prototype, execution, enumerable: false) if kind == :array do %{ @@ -810,9 +829,9 @@ defmodule QuickBEAM.VM.Builtins do defp define_property(execution, target, key, %Property{} = property) do if accessor?(property) do - Heap.define_descriptor(execution, target, key, property) + Properties.define_descriptor(target, key, property, execution) else - Heap.define(execution, target, key, property.value, + Properties.define(target, key, property.value, execution, writable: property.writable, enumerable: property.enumerable, configurable: property.configurable @@ -821,8 +840,8 @@ defmodule QuickBEAM.VM.Builtins do end defp descriptor_field(%Reference{} = descriptor, key, execution) do - if Heap.has_property?(execution, descriptor, key) do - case Heap.get(execution, descriptor, key) do + if Properties.has_property?(descriptor, key, execution) do + case Properties.get(descriptor, key, execution) do {:ok, {:accessor, _getter, _receiver}} -> {:error, :accessor_descriptor_field} {:ok, value} -> {:ok, value, true} {:error, reason} -> {:error, reason} @@ -863,7 +882,7 @@ defmodule QuickBEAM.VM.Builtins do execution = fields |> Enum.reduce(execution, fn {key, value}, execution -> - {:ok, execution} = Heap.define(execution, descriptor, key, value) + {:ok, execution} = Properties.define(descriptor, key, value, execution) execution end) @@ -912,14 +931,16 @@ defmodule QuickBEAM.VM.Builtins do values |> Enum.with_index() |> Enum.reduce(execution, fn {value, index}, execution -> - {:ok, execution} = Heap.define(execution, array, index, value) + {:ok, execution} = Properties.define(array, index, value, execution) execution end) {array, execution} end - defp own_keys(%Reference{} = reference, execution), do: Heap.own_keys(execution, reference) + defp own_keys(%Reference{} = reference, execution), + do: Properties.enumerable_keys(reference, execution) + defp own_keys(value, _execution) when is_map(value), do: {:ok, Map.keys(value)} defp own_keys([], _execution), do: {:ok, []} @@ -932,7 +953,7 @@ defmodule QuickBEAM.VM.Builtins do with {:ok, keys} <- own_keys(source, execution) do Enum.reduce_while(keys, {:ok, execution}, fn key, {:ok, execution} -> with {:ok, value} <- property(source, key, execution), - {:ok, execution} <- Heap.put(execution, target, key, value) do + {:ok, execution} <- Properties.put(target, key, value, execution) do {:cont, {:ok, execution}} else {:error, reason} -> {:halt, {:error, reason}} @@ -944,7 +965,7 @@ defmodule QuickBEAM.VM.Builtins do defp assign(_target, _source, _execution), do: {:error, :not_an_object} defp property(%Reference{} = reference, key, execution) do - case Heap.get(execution, reference, key) do + case Properties.get(reference, key, execution) do {:ok, {:accessor, _getter, _receiver}} -> {:error, :accessor_in_object_assign} result -> result end diff --git a/lib/quickbeam/vm/exceptions.ex b/lib/quickbeam/vm/exceptions.ex index e3e046bb6..6e92e4a30 100644 --- a/lib/quickbeam/vm/exceptions.ex +++ b/lib/quickbeam/vm/exceptions.ex @@ -6,7 +6,7 @@ defmodule QuickBEAM.VM.Exceptions do to `QuickBEAM.JSError` happens only after a value escapes the evaluation. """ - alias QuickBEAM.VM.{Builtins, Execution, Heap, Object, Reference, Thrown} + alias QuickBEAM.VM.{Builtins, Execution, Heap, Object, Properties, Reference, Thrown} @doc "Materializes a generated VM exception as a JavaScript heap value." @spec materialize(term(), Execution.t()) :: {term(), Execution.t()} @@ -42,13 +42,13 @@ defmodule QuickBEAM.VM.Exceptions do case Heap.fetch_object(execution, reference) do {:ok, %Object{internal: {:error, default_name}}} -> name = - case Heap.get(execution, reference, "name") do + case Properties.get(reference, "name", execution) do {:ok, value} when value not in [:undefined, nil] -> to_string_value(value) _missing -> default_name end message = - case Heap.get(execution, reference, "message") do + case Properties.get(reference, "message", execution) do {:ok, :undefined} -> "" {:ok, value} -> to_string_value(value) {:error, _reason} -> "" diff --git a/lib/quickbeam/vm/interpreter.ex b/lib/quickbeam/vm/interpreter.ex index 15138aa87..f1e11c555 100644 --- a/lib/quickbeam/vm/interpreter.ex +++ b/lib/quickbeam/vm/interpreter.ex @@ -29,6 +29,7 @@ defmodule QuickBEAM.VM.Interpreter do PredefinedAtoms, Program, Promise, + Properties, PromiseExecutorBoundary, PromiseReference, Reaction, @@ -38,7 +39,6 @@ defmodule QuickBEAM.VM.Interpreter do ThenableBoundary, ThenGetterBoundary, Thrown, - UTF16, Value } @@ -329,7 +329,9 @@ defmodule QuickBEAM.VM.Interpreter do values |> Enum.with_index() |> Enum.reduce(execution, fn {value, index}, execution -> - {:ok, execution} = Heap.define(execution, arguments, index, read_slot(value, execution)) + {:ok, execution} = + Properties.define(arguments, index, read_slot(value, execution), execution) + execution end) @@ -356,7 +358,7 @@ defmodule QuickBEAM.VM.Interpreter do |> Enum.reverse() |> Enum.with_index() |> Enum.reduce(execution, fn {value, index}, execution -> - {:ok, execution} = Heap.define(execution, reference, index, value) + {:ok, execution} = Properties.define(reference, index, value, execution) execution end) @@ -373,9 +375,9 @@ defmodule QuickBEAM.VM.Interpreter do result = case kind do - 4 -> Heap.define(execution, object, key, callable) - 5 -> Heap.define_accessor(execution, object, key, :getter, callable) - 6 -> Heap.define_accessor(execution, object, key, :setter, callable) + 4 -> Properties.define(object, key, callable, execution) + 5 -> Properties.define_accessor(object, key, :getter, callable, execution) + 6 -> Properties.define_accessor(object, key, :setter, callable, execution) _kind -> {:error, {:unsupported_method_kind, kind}} end @@ -393,7 +395,7 @@ defmodule QuickBEAM.VM.Interpreter do ) do key = resolve_atom(atom, execution) - case Heap.define(execution, object, key, value) do + case Properties.define(object, key, value, execution) do {:ok, execution} -> continue(%{frame | stack: [object | stack]}, execution) {:error, reason} -> raise_js({:type_error, reason}, frame, execution) end @@ -437,7 +439,7 @@ defmodule QuickBEAM.VM.Interpreter do end defp execute(:delete, [], %{stack: [key, %Reference{} = object | stack]} = frame, execution) do - case Heap.delete(execution, object, key) do + case Properties.delete(object, key, execution) do {:ok, deleted?, execution} -> continue(%{frame | stack: [deleted? | stack]}, execution) {:error, reason} -> raise_js({:type_error, reason}, frame, execution) end @@ -588,7 +590,7 @@ defmodule QuickBEAM.VM.Interpreter do end defp execute(:for_in_start, [], %{stack: [object | stack]} = frame, execution) do - case enumerable_keys(object, execution) do + case Properties.enumerable_keys(object, execution) do {:ok, keys} -> continue(%{frame | stack: [{:for_in, keys, 0} | stack]}, execution) {:error, reason} -> raise_js({:type_error, reason}, %{frame | stack: stack}, execution) end @@ -668,7 +670,10 @@ defmodule QuickBEAM.VM.Interpreter do do: binary(frame, execution, &(not Value.strict_equal?(&1, &2))) defp execute(:in, [], %{stack: [object, key | stack]} = frame, execution) do - continue(%{frame | stack: [has_property?(object, key, execution) | stack]}, execution) + continue( + %{frame | stack: [Properties.has_property?(object, key, execution) | stack]}, + execution + ) end defp execute( @@ -681,7 +686,7 @@ defmodule QuickBEAM.VM.Interpreter do {:ok, %Reference{} = prototype} <- instanceof_prototype(constructor, execution) do result = is_struct(object, Reference) and - Heap.prototype_chain_contains?(execution, object, prototype) + Properties.prototype_chain_contains?(object, prototype, execution) continue(%{frame | stack: [result | stack]}, execution) else @@ -952,7 +957,7 @@ defmodule QuickBEAM.VM.Interpreter do do: constructor_prototype(target, execution) defp constructor_prototype(constructor, execution) do - case get_property(constructor, "prototype", execution) do + case Properties.get(constructor, "prototype", execution) do {:ok, %Reference{} = prototype} -> prototype _other -> nil end @@ -962,7 +967,7 @@ defmodule QuickBEAM.VM.Interpreter do do: instanceof_prototype(target, execution) defp instanceof_prototype(constructor, execution), - do: get_property(constructor, "prototype", execution) + do: Properties.get(constructor, "prototype", execution) defp dispatch_call({:host_function, :beam_call}, arguments, _this, caller, execution, tail?), do: start_host_call(arguments, caller, execution, tail?) @@ -1287,7 +1292,7 @@ defmodule QuickBEAM.VM.Interpreter do end defp continue_object_assign(%ObjectAssignBoundary{keys: [key | keys]} = boundary, execution) do - case get_property(boundary.source, key, execution) do + case Properties.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) @@ -1304,7 +1309,7 @@ defmodule QuickBEAM.VM.Interpreter do %ObjectAssignBoundary{keys: [], sources: [source | sources]} = boundary, execution ) do - case enumerable_keys(source, execution) do + case Properties.enumerable_keys(source, execution) do {:ok, keys} -> continue_object_assign( %{boundary | source: source, sources: sources, keys: keys, phase: nil, key: nil}, @@ -1320,7 +1325,7 @@ defmodule QuickBEAM.VM.Interpreter do do: complete_call_result(boundary.target, boundary.caller, execution, boundary.tail?) defp assign_object_value(boundary, key, value, execution) do - case Heap.put(execution, boundary.target, key, value) do + case Properties.put(boundary.target, key, value, execution) do {:ok, execution} -> continue_object_assign(%{boundary | phase: nil, key: nil}, execution) @@ -1451,7 +1456,7 @@ defmodule QuickBEAM.VM.Interpreter do values |> Enum.with_index() |> Enum.reduce(execution, fn {value, index}, execution -> - {:ok, execution} = Heap.define(execution, array, index, value) + {:ok, execution} = Properties.define(array, index, value, execution) execution end) @@ -1574,7 +1579,10 @@ defmodule QuickBEAM.VM.Interpreter do defp install_host_globals(execution) do execution = Builtins.install(execution) {beam, execution} = Heap.allocate(execution) - {:ok, execution} = Heap.define(execution, beam, "call", {:host_function, :beam_call}) + + {:ok, execution} = + Properties.define(beam, "call", {:host_function, :beam_call}, execution) + {global_this, execution} = Heap.allocate(execution) globals = @@ -1651,7 +1659,7 @@ defmodule QuickBEAM.VM.Interpreter do defp type_of(value, _execution), do: Value.typeof(value) defp get_property_and_continue(object, key, stack, frame, execution) do - case get_property(object, key, execution) do + case Properties.get(object, key, execution) do {:ok, {:accessor, getter, receiver}} -> boundary = %AccessorBoundary{ mode: :get, @@ -1670,7 +1678,7 @@ defmodule QuickBEAM.VM.Interpreter do end defp put_property_and_continue(object, key, value, stack, frame, execution) do - case put_property(object, key, value, execution) do + case Properties.put(object, key, value, execution) do {:ok, execution} -> continue(%{frame | stack: stack}, execution) @@ -1688,127 +1696,6 @@ defmodule QuickBEAM.VM.Interpreter do end end - defp get_property(%Reference{} = object, key, execution) do - case Heap.get(execution, object, key) do - {:ok, :undefined} = missing -> - cond do - key in ["bind", "call"] and not is_nil(Builtins.callable(execution, object)) -> - {:ok, {:function_method, key}} - - reference_kind(object, execution) in [:array, :set] and is_binary(key) -> - {:ok, {:primitive_method, reference_kind(object, execution), key}} - - true -> - missing - end - - result -> - result - end - end - - defp get_property(%PromiseReference{}, method, _execution) - when method in ["catch", "finally", "then"], - do: {:ok, {:promise_method, method}} - - defp get_property(%RegExp{}, key, _execution) when is_binary(key), - do: {:ok, {:primitive_method, :regexp, key}} - - defp get_property(object, key, _execution) - when is_tuple(object) and key in ["bind", "call"] and - elem(object, 0) in [ - :builtin, - :builtin_method, - :bound_function, - :function_method, - :host_function, - :primitive_method, - :promise_method, - :promise_resolver - ], - do: {:ok, {:function_method, key}} - - defp get_property(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 - - defp get_property(object, "length", _execution) when is_binary(object), - do: {:ok, UTF16.length(object)} - - defp get_property(object, key, _execution) when is_binary(object) and is_integer(key), - do: {:ok, UTF16.at(object, key)} - - defp get_property(object, key, _execution) when is_binary(object) and is_binary(key), - do: {:ok, {:primitive_method, :string, key}} - - defp get_property(object, "length", _execution) when is_list(object), do: {:ok, length(object)} - - defp get_property(object, key, _execution) when is_list(object) and is_integer(key), - do: {:ok, Enum.at(object, key, :undefined)} - - defp get_property(object, key, _execution) when is_list(object) and is_binary(key), - do: {:ok, {:primitive_method, :array, key}} - - defp get_property(object, key, _execution) when is_number(object) and is_binary(key), - do: {:ok, {:primitive_method, :number, key}} - - defp get_property(object, _key, _execution) when object in [nil, :undefined], - do: {:error, :null_or_undefined_property_access} - - defp get_property(_object, _key, _execution), do: {:ok, :undefined} - - defp put_property(%Reference{} = object, key, value, execution), - do: Heap.put(execution, object, key, value) - - defp put_property(object, _key, _value, _execution), do: {:error, {:not_an_object, object}} - - defp enumerable_keys(%Reference{} = reference, execution), - do: Heap.own_keys(execution, reference) - - defp enumerable_keys(value, _execution) when is_map(value), do: {:ok, Map.keys(value)} - defp enumerable_keys([], _execution), do: {:ok, []} - - defp enumerable_keys(value, _execution) when is_list(value), - do: {:ok, Enum.to_list(0..(length(value) - 1))} - - defp enumerable_keys(value, _execution) when value in [nil, :undefined], do: {:ok, []} - defp enumerable_keys(_value, _execution), do: {:ok, []} - - defp has_property?(%Reference{} = reference, key, execution), - do: Heap.has_property?(execution, reference, key) - - defp has_property?(value, key, _execution) when is_map(value), do: Map.has_key?(value, key) - - defp has_property?(value, key, _execution) when is_list(value) and is_integer(key), - do: key >= 0 and key < length(value) - - defp has_property?(value, "length", _execution) when is_list(value) or is_binary(value), - do: true - - defp has_property?(_value, _key, _execution), do: false - - defp reference_kind(reference, execution) do - case Heap.fetch_object(execution, reference) do - {:ok, %QuickBEAM.VM.Object{kind: kind}} -> kind - :error -> nil - 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 - defp raise_js(reason, %NativeFrame{caller: caller}, execution) do {reason, trace, execution} = throw_state(reason, execution) do_raise_js(reason, caller, execution, trace, true) @@ -2154,13 +2041,13 @@ defmodule QuickBEAM.VM.Interpreter do {prototype, execution} = Heap.allocate(execution) {:ok, execution} = - Heap.define(execution, prototype, "constructor", reference, + Properties.define(prototype, "constructor", reference, execution, enumerable: false, configurable: true ) {:ok, execution} = - Heap.define(execution, reference, "prototype", prototype, + Properties.define(reference, "prototype", prototype, execution, enumerable: false, configurable: false ) diff --git a/lib/quickbeam/vm/promise.ex b/lib/quickbeam/vm/promise.ex index b651a2339..05e6176c8 100644 --- a/lib/quickbeam/vm/promise.ex +++ b/lib/quickbeam/vm/promise.ex @@ -10,8 +10,8 @@ defmodule QuickBEAM.VM.Promise do Builtins, Coroutine, Execution, - Heap, Memory, + Properties, PromiseReference, Reaction, Reference @@ -282,7 +282,7 @@ defmodule QuickBEAM.VM.Promise do end defp then_callable(execution, reference) do - case Heap.get(execution, reference, "then") do + case Properties.get(reference, "then", execution) do {:ok, {:accessor, getter, receiver}} -> {:getter, getter, receiver} diff --git a/lib/quickbeam/vm/properties.ex b/lib/quickbeam/vm/properties.ex new file mode 100644 index 000000000..5bc60398d --- /dev/null +++ b/lib/quickbeam/vm/properties.ex @@ -0,0 +1,218 @@ +defmodule QuickBEAM.VM.Properties 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.{ + Builtins, + Execution, + Heap, + PromiseReference, + Property, + Reference, + RegExp, + UTF16 + } + + @function_tags [ + :builtin, + :builtin_method, + :bound_function, + :function_method, + :host_function, + :primitive_method, + :promise_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(), Execution.t()) :: get_result() + def get(%Reference{} = object, key, execution) do + case Heap.get(execution, object, key) do + {:ok, :undefined} = missing -> + kind = kind(object, execution) + + cond do + key in ["bind", "call"] and not is_nil(Builtins.callable(execution, object)) -> + {:ok, {:function_method, key}} + + kind in [:array, :set] and is_binary(key) -> + {:ok, {:primitive_method, kind, key}} + + true -> + missing + end + + result -> + result + end + end + + def get(%PromiseReference{}, method, _execution) + when method in ["catch", "finally", "then"], + do: {:ok, {:promise_method, method}} + + 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 key in ["bind", "call"] and elem(object, 0) in @function_tags, + do: {:ok, {:function_method, 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, UTF16.length(object)} + + def get(object, key, _execution) when is_binary(object) and is_integer(key), + do: {:ok, UTF16.at(object, key)} + + def get(object, key, _execution) when is_binary(object) and is_binary(key), + do: {:ok, {:primitive_method, :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: {:ok, {:primitive_method, :array, key}} + + def get(object, key, _execution) when is_number(object) and is_binary(key), + do: {:ok, {:primitive_method, :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} + + @doc "Writes a JavaScript property or returns an accessor setter action." + @spec put(term(), term(), term(), Execution.t()) :: + {:ok, Execution.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(), Execution.t(), keyword()) :: + {:ok, Execution.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(), + Execution.t(), + keyword() + ) :: + {:ok, Execution.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(), Property.t(), Execution.t()) :: + {:ok, Execution.t()} | {:error, term()} + def define_descriptor(%Reference{} = object, key, %Property{} = 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(), Execution.t()) :: + {:ok, boolean(), Execution.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(), Execution.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 "Tests JavaScript property presence across an object's prototype chain." + @spec has_property?(term(), term(), Execution.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(), Execution.t()) :: + {:ok, Property.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(), Execution.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(), Execution.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, Execution.t()) :: + {:ok, Execution.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(), Execution.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(), Execution.t()) :: QuickBEAM.VM.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 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/test/vm/properties_test.exs b/test/vm/properties_test.exs new file mode 100644 index 000000000..53377d7bc --- /dev/null +++ b/test/vm/properties_test.exs @@ -0,0 +1,56 @@ +defmodule QuickBEAM.VM.PropertiesTest do + use ExUnit.Case, async: true + + alias QuickBEAM.VM.{Execution, Heap, Properties} + + 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} = + Properties.define_accessor(prototype, "value", :getter, getter, execution) + + {:ok, execution} = + Properties.define_accessor(prototype, "value", :setter, setter, execution) + + assert {:ok, {:accessor, ^getter, ^object}} = Properties.get(object, "value", execution) + assert {:error, {:invoke_setter, ^setter}} = Properties.put(object, "value", 42, execution) + end + + test "provides primitive, Promise, and callable pseudo-properties" do + execution = execution() + {callable, execution} = Heap.allocate(execution, :function, callable: {:builtin, "callable"}) + + assert {:ok, {:function_method, "bind"}} = Properties.get(callable, "bind", execution) + assert {:ok, 2} = Properties.get("😀", "length", execution) + assert {:ok, <<0xED, 0xA0, 0xBD>>} = Properties.get("😀", 0, execution) + + assert {:ok, {:primitive_method, :number, "toString"}} = + Properties.get(42, "toString", execution) + end + + test "centralizes descriptors, enumeration, and prototype operations" do + execution = execution() + {prototype, execution} = Heap.allocate(execution) + {object, execution} = Heap.allocate(execution) + + {:ok, execution} = + Properties.define(object, "hidden", 1, execution, enumerable: false) + + {:ok, execution} = Properties.define(object, 2, 2, execution) + {:ok, execution} = Properties.set_prototype(object, prototype, execution) + + assert {:ok, [2]} = Properties.enumerable_keys(object, execution) + assert {:ok, ["2", "hidden"]} = Properties.own_property_names(object, execution) + assert {:ok, ^prototype} = Properties.prototype(object, execution) + assert Properties.prototype_chain_contains?(object, prototype, execution) + assert Properties.has_property?(object, "hidden", execution) + end + + defp execution do + %Execution{atoms: {}, max_stack_depth: 10, remaining_steps: 100, step_limit: 100} + end +end From 445e652cd4ce06d5ebcd1b5254a96b901b94a7b5 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 13 Jul 2026 11:12:52 +0200 Subject: [PATCH 22/87] Extract canonical VM invocation semantics --- docs/beam-interpreter-architecture.md | 4 + docs/prototype-delta-audit.md | 13 +- lib/quickbeam/vm/interpreter.ex | 417 ++++++-------------------- lib/quickbeam/vm/invocation.ex | 324 ++++++++++++++++++++ lib/quickbeam/vm/promise.ex | 4 +- test/vm/invocation_test.exs | 84 ++++++ 6 files changed, 510 insertions(+), 336 deletions(-) create mode 100644 lib/quickbeam/vm/invocation.ex create mode 100644 test/vm/invocation_test.exs diff --git a/docs/beam-interpreter-architecture.md b/docs/beam-interpreter-architecture.md index e82504680..bbcc9f047 100644 --- a/docs/beam-interpreter-architecture.md +++ b/docs/beam-interpreter-architecture.md @@ -360,6 +360,10 @@ any future compiler. A compiled path must not grow a second implementation of JavaScript semantics. `QuickBEAM.VM.Properties` is now the canonical boundary for get, put, define, delete, descriptors, enumeration, primitive properties, and prototype operations; accessor results remain explicit resumable actions. +`QuickBEAM.VM.Invocation` is the corresponding canonical boundary for ordinary, +bound, constructor, built-in, Promise, and host-function calls. It produces +explicit call actions while the interpreter retains frame scheduling and +boundary resumption. Recommended initial representation: diff --git a/docs/prototype-delta-audit.md b/docs/prototype-delta-audit.md index 171c52c70..32d929dbc 100644 --- a/docs/prototype-delta-audit.md +++ b/docs/prototype-delta-audit.md @@ -342,8 +342,11 @@ High-value test groups to adapt next: - Property semantics now live behind one canonical property layer, including explicit getter/setter actions used by the interpreter, built-ins, Promise thenable lookup, and exception conversion. -- Split invocation, async, exceptions, and values from the current interpreter - without changing behavior. +- Invocation now has one canonical planner for ordinary, closure, bound, + constructor, built-in, Promise, and host calls. The interpreter executes its + explicit actions to preserve resumable scheduling and exception unwinding. +- Split async, exceptions, and values from the current interpreter without + changing behavior. - Keep the current tests and SSR fixture green after each extraction. - Avoid reproducing the prototype's hundreds of overlapping modules. @@ -370,6 +373,6 @@ High-value test groups to adapt next: ## Immediate next action -Continue Phase B with canonical invocation. Keep ordinary, bound, constructor, -builtin, and host calls on one resumable path without copying the prototype's -runtime modules or changing observable behavior. +Continue Phase B with canonical async scheduling and Promise transitions while +retaining explicit coroutines, jobs, handler ownership, limits, and resumable +interpreter boundaries. diff --git a/lib/quickbeam/vm/interpreter.ex b/lib/quickbeam/vm/interpreter.ex index f1e11c555..3bdc86d97 100644 --- a/lib/quickbeam/vm/interpreter.ex +++ b/lib/quickbeam/vm/interpreter.ex @@ -22,6 +22,7 @@ defmodule QuickBEAM.VM.Interpreter do Frame, Function, Heap, + Invocation, Memory, NativeFrame, ObjectAssignBoundary, @@ -71,7 +72,7 @@ defmodule QuickBEAM.VM.Interpreter do execution = Memory.charge(execution, Memory.estimate(vars)) execution = install_host_globals(execution) - frame = new_frame(program.root, program.root, [], :undefined, {}) + frame = Invocation.new_frame(program.root, program.root, [], :undefined, {}) run(frame, execution) end @@ -144,7 +145,7 @@ defmodule QuickBEAM.VM.Interpreter do {:error, _reason} -> reaction.on_rejected end - if type_of(callback, execution) == "function" do + if Invocation.typeof(callback, execution) == "function" do boundary = %ReactionBoundary{ promise: reaction.result_promise, depth: execution.depth, @@ -170,35 +171,38 @@ defmodule QuickBEAM.VM.Interpreter do def finish({:suspended, continuation}), do: {:suspended, continuation} def finish({:idle, _execution}), do: {:error, :idle_evaluation} - defp enter_call(callable, args, this, caller, execution, tail?, frame_callable \\ nil) do - with {:ok, function, closure_refs} <- callable_parts(callable) do - frame_callable = frame_callable || callable - - if function.func_kind == 2 do - enter_async_call( - function, - frame_callable, - closure_refs, - args, - this, - caller, - execution, - tail? - ) - else - enter_sync_call( - function, - frame_callable, - closure_refs, - args, - this, - caller, - execution, - tail? - ) - end + 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 - {:error, reason} -> raise_js_from_caller(reason, caller, execution) + enter_sync_call( + function, + callable, + closure_refs, + arguments, + this, + caller, + execution, + tail? + ) end end @@ -213,7 +217,7 @@ defmodule QuickBEAM.VM.Interpreter do do: execution, else: %{execution | callers: [caller | execution.callers], depth: depth} - run(new_frame(function, callable, args, this, closure_refs), execution) + run(Invocation.new_frame(function, callable, args, this, closure_refs), execution) end end @@ -242,30 +246,10 @@ defmodule QuickBEAM.VM.Interpreter do } execution = %{execution | callers: [boundary | execution.callers], depth: depth} - run(new_frame(function, callable, args, this, closure_refs), execution) + run(Invocation.new_frame(function, callable, args, this, closure_refs), 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}} - - defp new_frame(function, callable, args, this, closure_refs) do - local_count = max(function.arg_count + function.var_count, 1) - - %Frame{ - function: function, - callable: callable, - closure_refs: closure_refs, - locals: :erlang.make_tuple(local_count, :undefined), - args: List.to_tuple(args), - this: this - } - end - defp run(frame, %Execution{} = execution) when execution.sync_jobs != {[], []} do case :queue.out(execution.sync_jobs) do {{:value, {:read_thenable, promise, thenable, getter}}, sync_jobs} -> @@ -463,7 +447,11 @@ defmodule QuickBEAM.VM.Interpreter do do: unary(frame, execution, &is_nil/1) defp execute(:is_function, [], %{stack: [value | stack]} = frame, execution), - do: continue(%{frame | stack: [type_of(value, execution) == "function" | stack]}, execution) + do: + continue( + %{frame | stack: [Invocation.typeof(value, execution) == "function" | stack]}, + execution + ) defp execute(:drop, [], %{stack: [_value | stack]} = frame, execution), do: continue(%{frame | stack: stack}, execution) @@ -682,8 +670,9 @@ defmodule QuickBEAM.VM.Interpreter do %{stack: [constructor, object | stack]} = frame, execution ) do - with "function" <- type_of(constructor, execution), - {:ok, %Reference{} = prototype} <- instanceof_prototype(constructor, execution) do + with "function" <- Invocation.typeof(constructor, execution), + {:ok, %Reference{} = prototype} <- + Invocation.instanceof_prototype(constructor, execution) do result = is_struct(object, Reference) and Properties.prototype_chain_contains?(object, prototype, execution) @@ -709,11 +698,14 @@ defmodule QuickBEAM.VM.Interpreter do defp execute(:lnot, [], frame, execution), do: unary(frame, execution, &(not Value.truthy?(&1))) defp execute(:typeof, [], %{stack: [value | stack]} = frame, execution) do - continue(%{frame | stack: [type_of(value, execution) | stack]}, execution) + continue(%{frame | stack: [Invocation.typeof(value, execution) | stack]}, execution) end defp execute(:typeof_is_function, [], %{stack: [value | stack]} = frame, execution) do - continue(%{frame | stack: [type_of(value, execution) == "function" | stack]}, execution) + continue( + %{frame | stack: [Invocation.typeof(value, execution) == "function" | stack]}, + execution + ) end defp execute(:typeof_is_undefined, [], frame, execution), @@ -884,9 +876,9 @@ defmodule QuickBEAM.VM.Interpreter do case constructor_and_new_target do [_new_target, constructor | rest] -> - if constructable?(constructor, execution) do + if Invocation.constructable?(constructor, execution) do caller = %{next_frame(frame) | stack: rest} - prototype = constructor_prototype(constructor, execution) + prototype = Invocation.constructor_prototype(constructor, execution) {instance, execution} = Heap.allocate(execution, :ordinary, @@ -917,268 +909,55 @@ defmodule QuickBEAM.VM.Interpreter do end end - defp constructable?(%Reference{} = constructor, execution) do - case Builtins.callable(execution, constructor) do - nil -> false - callable -> constructable?(callable, execution) - end + defp dispatch_call(callable, arguments, this, caller, execution, tail?) do + callable + |> Invocation.plan(arguments, this, caller, execution, tail?) + |> execute_invocation() end - defp constructable?(%Function{has_prototype: has_prototype}, _execution), do: has_prototype - - defp constructable?({:closure, %Function{has_prototype: has_prototype}, _refs}, _execution), - do: has_prototype - - defp constructable?({:bound_function, target, _this, _arguments}, execution), - do: constructable?(target, execution) - - defp constructable?({:builtin, name}, _execution), - do: - name in [ - "Array", - "Boolean", - "Error", - "EvalError", - "Number", - "Object", - "Promise", - "RangeError", - "ReferenceError", - "Set", - "String", - "SyntaxError", - "TypeError", - "URIError" - ] - - defp constructable?(_constructor, _execution), do: false - - defp constructor_prototype({:bound_function, target, _this, _arguments}, execution), - do: constructor_prototype(target, execution) - - defp constructor_prototype(constructor, execution) do - case Properties.get(constructor, "prototype", execution) do - {:ok, %Reference{} = prototype} -> prototype - _other -> nil - end - end - - defp instanceof_prototype({:bound_function, target, _this, _arguments}, execution), - do: instanceof_prototype(target, execution) - - defp instanceof_prototype(constructor, execution), - do: Properties.get(constructor, "prototype", execution) + defp execute_invocation({:dispatch, callable, arguments, this, caller, execution, tail?}), + do: dispatch_call(callable, arguments, this, caller, execution, tail?) - defp dispatch_call({:host_function, :beam_call}, 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 dispatch_call({:builtin, "Promise"}, [executor | _], _this, caller, execution, tail?) do - {promise, execution} = Promise.new(execution) - - boundary = %PromiseExecutorBoundary{ - promise: promise, + defp execute_invocation({:object_assign, target, sources, caller, execution, tail?}) do + boundary = %ObjectAssignBoundary{ + target: target, + sources: sources, caller: caller, depth: execution.depth, tail?: tail? } - resolve = {:promise_resolver, promise, :resolve} - reject = {:promise_resolver, promise, :reject} - - if type_of(executor, execution) == "function" do - dispatch_call(executor, [resolve, reject], :undefined, boundary, execution, false) - else - execution = - Promise.settle( - execution, - promise, - {:error, {:type_error, :promise_executor_not_callable}} - ) - - complete_executor(boundary, execution) - end + continue_object_assign(boundary, execution) end - defp dispatch_call({:builtin, "Promise"}, _arguments, _this, caller, execution, tail?) do - {promise, execution} = Promise.new(execution) - - execution = - Promise.settle(execution, promise, {:error, {:type_error, :missing_promise_executor}}) - - complete_call_result(promise, caller, execution, tail?) - end - - defp dispatch_call( - {: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) - end - - complete_call_result(:undefined, caller, execution, tail?) - end - - defp dispatch_call( - {:bound_function, target, _bound_this, bound_arguments}, - arguments, - this, - %ConstructorBoundary{} = caller, - execution, - tail? + defp execute_invocation( + {:array_iteration, method, receiver, arguments, caller, execution, tail?} ), - do: dispatch_call(target, bound_arguments ++ arguments, this, caller, execution, tail?) - - defp dispatch_call( - {:bound_function, target, bound_this, bound_arguments}, - arguments, - _this, - caller, - execution, - tail? - ), - do: - dispatch_call(target, bound_arguments ++ arguments, bound_this, caller, execution, tail?) - - defp dispatch_call( - {:function_method, "bind"}, - [bound_this | bound_arguments], - target, - caller, - execution, - false - ) do - run( - %{caller | stack: [{:bound_function, target, bound_this, bound_arguments} | caller.stack]}, - execution - ) - end - - defp dispatch_call({:function_method, "call"}, arguments, target, caller, execution, tail?) do - {this, arguments} = - case arguments do - [this | rest] -> {this, rest} - [] -> {:undefined, []} - end - - dispatch_call(target, arguments, this, caller, execution, tail?) - end - - defp dispatch_call( - {:promise_method, "then"}, - arguments, - %PromiseReference{} = promise, - caller, - execution, - tail? - ) do - on_fulfilled = Enum.at(arguments, 0, :undefined) - on_rejected = Enum.at(arguments, 1, :undefined) - {result_promise, execution} = Promise.react(execution, promise, on_fulfilled, on_rejected) - complete_call_result(result_promise, caller, execution, tail?) - end - - defp dispatch_call( - {:promise_method, "catch"}, - arguments, - %PromiseReference{} = promise, - caller, - execution, - tail? - ) do - on_rejected = Enum.at(arguments, 0, :undefined) - {result_promise, execution} = Promise.react(execution, promise, :undefined, on_rejected) - complete_call_result(result_promise, caller, execution, tail?) - end - - defp dispatch_call( - {:promise_method, "finally"}, - arguments, - %PromiseReference{} = promise, - caller, - execution, - tail? - ) do - callback = Enum.at(arguments, 0, :undefined) - {result_promise, execution} = Promise.finally(execution, promise, callback) - complete_call_result(result_promise, caller, execution, tail?) - end - - defp dispatch_call(%Reference{} = reference, arguments, this, caller, execution, tail?) do - case Builtins.callable(execution, reference) do - nil -> - raise_js_from_caller({:not_callable, reference}, caller, execution) - - callable when elem(callable, 0) in [:builtin, :builtin_method, :primitive_method] -> - dispatch_call(callable, arguments, this, caller, execution, tail?) - - callable -> - enter_call(callable, arguments, this, caller, execution, tail?, reference) - end - end - - defp dispatch_call( - {:builtin_method, "Object", "assign"}, - arguments, - _this, - caller, - execution, - tail? - ) do - case arguments do - [%Reference{} = target | sources] -> - boundary = %ObjectAssignBoundary{ - target: target, - sources: sources, - caller: caller, - depth: execution.depth, - tail?: tail? - } - - continue_object_assign(boundary, execution) - - _arguments -> - raise_js_from_caller({:type_error, :not_an_object}, caller, execution) - end - end - - defp dispatch_call( - {:primitive_method, :array, method}, - arguments, - receiver, - caller, - execution, - tail? - ) - when method in ["filter", "forEach", "map", "reduce", "some"] do - start_array_iteration(method, receiver, arguments, caller, execution, tail?) - end - - defp dispatch_call(callable, arguments, this, caller, execution, tail?) - when elem(callable, 0) in [:builtin, :builtin_method, :primitive_method] do - case Builtins.call(callable, this, arguments, execution) do - {:ok, value, execution} -> - complete_call_result(value, caller, execution, tail?) - - {:error, reason, execution} -> - raise_js_from_caller({:type_error, reason}, caller, execution) - end - end - - defp dispatch_call(callable, arguments, this, caller, execution, tail?), - do: enter_call(callable, arguments, this, caller, execution, tail?) + do: start_array_iteration(method, receiver, arguments, caller, execution, tail?) defp complete_call_result(value, %ReactionBoundary{} = boundary, execution, _tail?), do: complete_reaction(boundary, value, execution) @@ -1225,7 +1004,7 @@ defmodule QuickBEAM.VM.Interpreter do defp complete_then_getter(value, boundary, execution) do execution = - if type_of(value, execution) == "function" do + if Invocation.typeof(value, execution) == "function" do Promise.enqueue_assimilation(execution, boundary.promise, boundary.thenable, value) else Promise.fulfill_assimilated(execution, boundary.promise, boundary.thenable) @@ -1638,26 +1417,6 @@ defmodule QuickBEAM.VM.Interpreter do kind, reason -> {:error, {:handler_exception, {kind, reason}, __STACKTRACE__}} end - defp type_of(%Reference{} = reference, execution) do - if Builtins.callable(execution, reference), do: "function", else: "object" - end - - defp type_of(value, _execution) - when is_tuple(value) and - elem(value, 0) in [ - :builtin, - :builtin_method, - :bound_function, - :function_method, - :host_function, - :primitive_method, - :promise_method, - :promise_resolver - ], - do: "function" - - defp type_of(value, _execution), do: Value.typeof(value) - defp get_property_and_continue(object, key, stack, frame, execution) do case Properties.get(object, key, execution) do {:ok, {:accessor, getter, receiver}} -> diff --git a/lib/quickbeam/vm/invocation.ex b/lib/quickbeam/vm/invocation.ex new file mode 100644 index 000000000..6c96baaab --- /dev/null +++ b/lib/quickbeam/vm/invocation.ex @@ -0,0 +1,324 @@ +defmodule QuickBEAM.VM.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.{ + Builtins, + ConstructorBoundary, + Execution, + Frame, + Function, + Promise, + PromiseExecutorBoundary, + PromiseReference, + Properties, + Reference, + Value + } + + @builtin_tags [:builtin, :builtin_method, :primitive_method] + @error_constructors ~w(Error EvalError RangeError ReferenceError SyntaxError TypeError URIError) + + @type action :: + {:dispatch, term(), [term()], term(), term(), Execution.t(), boolean()} + | {:enter, Function.t(), term(), tuple(), [term()], term(), term(), Execution.t(), + boolean()} + | {:complete, term(), term(), Execution.t(), boolean()} + | {:error, term(), term(), Execution.t()} + | {:host_call, [term()], term(), Execution.t(), boolean()} + | {:object_assign, Reference.t(), [term()], term(), Execution.t(), boolean()} + | {:array_iteration, String.t(), term(), [term()], term(), Execution.t(), boolean()} + + @doc "Plans one invocation without executing interpreter frames." + @spec plan(term(), [term()], term(), term(), Execution.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({:builtin, "Promise"}, [executor | _], _this, caller, execution, tail?) do + {promise, execution} = Promise.new(execution) + + boundary = %PromiseExecutorBoundary{ + promise: promise, + caller: caller, + depth: execution.depth, + tail?: tail? + } + + if typeof(executor, execution) == "function" do + resolve = {:promise_resolver, promise, :resolve} + reject = {:promise_resolver, promise, :reject} + {:dispatch, executor, [resolve, reject], :undefined, boundary, execution, false} + else + execution = + Promise.settle( + execution, + promise, + {:error, {:type_error, :promise_executor_not_callable}} + ) + + {:complete, promise, caller, execution, tail?} + end + end + + def plan({:builtin, "Promise"}, _arguments, _this, caller, execution, tail?) do + {promise, execution} = Promise.new(execution) + + execution = + Promise.settle(execution, promise, {:error, {:type_error, :missing_promise_executor}}) + + {:complete, promise, caller, execution, tail?} + 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, + %ConstructorBoundary{} = 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( + {:function_method, "bind"}, + [bound_this | bound_arguments], + target, + caller, + execution, + false + ), + do: + {:complete, {:bound_function, target, bound_this, bound_arguments}, caller, execution, + false} + + def plan({:function_method, "call"}, arguments, target, caller, execution, tail?) do + {this, arguments} = + case arguments do + [this | rest] -> {this, rest} + [] -> {:undefined, []} + end + + {:dispatch, target, arguments, this, caller, execution, tail?} + end + + def plan( + {:promise_method, "then"}, + arguments, + %PromiseReference{} = promise, + caller, + execution, + tail? + ) do + on_fulfilled = Enum.at(arguments, 0, :undefined) + on_rejected = Enum.at(arguments, 1, :undefined) + {result, execution} = Promise.react(execution, promise, on_fulfilled, on_rejected) + {:complete, result, caller, execution, tail?} + end + + def plan( + {:promise_method, "catch"}, + arguments, + %PromiseReference{} = promise, + caller, + execution, + tail? + ) do + on_rejected = Enum.at(arguments, 0, :undefined) + {result, execution} = Promise.react(execution, promise, :undefined, on_rejected) + {:complete, result, caller, execution, tail?} + end + + def plan( + {:promise_method, "finally"}, + arguments, + %PromiseReference{} = promise, + caller, + execution, + tail? + ) do + callback = Enum.at(arguments, 0, :undefined) + {result, execution} = Promise.finally(execution, promise, callback) + {:complete, result, caller, execution, tail?} + end + + def plan(%Reference{} = reference, arguments, this, caller, execution, tail?) do + case Builtins.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( + {:builtin_method, "Object", "assign"}, + [%Reference{} = target | sources], + _this, + caller, + execution, + tail? + ), + do: {:object_assign, target, sources, caller, execution, tail?} + + def plan({:builtin_method, "Object", "assign"}, _arguments, _this, caller, execution, _tail?), + do: {:error, {:type_error, :not_an_object}, caller, execution} + + 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 Builtins.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(), Execution.t()) :: boolean() + def constructable?(%Reference{} = constructor, execution) do + case Builtins.callable(execution, constructor) do + nil -> false + callable -> constructable?(callable, execution) + 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?({:builtin, name}, _execution), + do: + name in (["Array", "Boolean", "Number", "Object", "Promise", "Set", "String"] ++ + @error_constructors) + + def constructable?(_constructor, _execution), do: false + + @doc "Returns the object prototype used for a constructor allocation." + @spec constructor_prototype(term(), Execution.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 Properties.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(), Execution.t()) :: Properties.get_result() + def instanceof_prototype({:bound_function, target, _this, _arguments}, execution), + do: instanceof_prototype(target, execution) + + def instanceof_prototype(constructor, execution), + do: Properties.get(constructor, "prototype", execution) + + @doc "Returns whether a VM value is callable by JavaScript." + @spec callable?(term(), Execution.t()) :: boolean() + def callable?(value, execution), do: typeof(value, execution) == "function" + + @doc "Returns the JavaScript `typeof` classification for a VM value." + @spec typeof(term(), Execution.t()) :: String.t() + def typeof(%Reference{} = reference, execution) do + if Builtins.callable(execution, reference), do: "function", else: "object" + end + + def typeof(value, _execution) + when is_tuple(value) and + elem(value, 0) in [ + :builtin, + :builtin_method, + :bound_function, + :function_method, + :host_function, + :primitive_method, + :promise_method, + :promise_resolver + ], + do: "function" + + def typeof(value, _execution), do: Value.typeof(value) + + @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) + + %Frame{ + function: function, + callable: callable, + closure_refs: closure_refs, + locals: :erlang.make_tuple(local_count, :undefined), + args: List.to_tuple(arguments), + 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/promise.ex b/lib/quickbeam/vm/promise.ex index 05e6176c8..088529cce 100644 --- a/lib/quickbeam/vm/promise.ex +++ b/lib/quickbeam/vm/promise.ex @@ -7,9 +7,9 @@ defmodule QuickBEAM.VM.Promise do """ alias QuickBEAM.VM.{ - Builtins, Coroutine, Execution, + Invocation, Memory, Properties, PromiseReference, @@ -287,7 +287,7 @@ defmodule QuickBEAM.VM.Promise do {:getter, getter, receiver} {:ok, %Reference{} = callable} -> - if Builtins.callable(execution, callable), do: {:ok, callable}, else: :none + if Invocation.callable?(callable, execution), do: {:ok, callable}, else: :none {:ok, callable} when is_tuple(callable) and diff --git a/test/vm/invocation_test.exs b/test/vm/invocation_test.exs new file mode 100644 index 000000000..7ab9d403b --- /dev/null +++ b/test/vm/invocation_test.exs @@ -0,0 +1,84 @@ +defmodule QuickBEAM.VM.InvocationTest do + use ExUnit.Case, async: true + + alias QuickBEAM.VM.{ConstructorBoundary, Execution, Frame, Function, Heap, Invocation} + + 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 = %ConstructorBoundary{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 Function bind and call without entering interpreter frames" do + execution = execution() + caller = frame() + + assert {:complete, {:bound_function, :target, :receiver, [1, 2]}, ^caller, ^execution, false} = + Invocation.plan( + {:function_method, "bind"}, + [:receiver, 1, 2], + :target, + caller, + execution + ) + + assert {:dispatch, :target, [1, 2], :receiver, ^caller, ^execution, true} = + Invocation.plan( + {:function_method, "call"}, + [:receiver, 1, 2], + :target, + caller, + execution, + true + ) + end + + defp execution do + %Execution{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 From aa943be2a0a30105b132c548e6a4040a85e8220b Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 13 Jul 2026 11:25:35 +0200 Subject: [PATCH 23/87] Extract canonical VM async semantics --- docs/beam-interpreter-architecture.md | 7 +- docs/prototype-delta-audit.md | 13 +- lib/quickbeam/vm/async.ex | 342 ++++++++++++++++++++++++++ lib/quickbeam/vm/evaluator.ex | 36 +-- lib/quickbeam/vm/interpreter.ex | 283 ++++----------------- test/vm/async_semantics_test.exs | 102 ++++++++ 6 files changed, 511 insertions(+), 272 deletions(-) create mode 100644 lib/quickbeam/vm/async.ex create mode 100644 test/vm/async_semantics_test.exs diff --git a/docs/beam-interpreter-architecture.md b/docs/beam-interpreter-architecture.md index bbcc9f047..21e5a71fa 100644 --- a/docs/beam-interpreter-architecture.md +++ b/docs/beam-interpreter-architecture.md @@ -415,7 +415,12 @@ they do not silently delegate arbitrary object operations to the native runtime. ## Host calls and async work Async/await and asynchronous BEAM handlers are part of the first SSR milestone. -Handlers are evaluation options, not fields in the immutable program: +`QuickBEAM.VM.Async` is the canonical state-transition layer for async frame +entry, coroutine detachment and resumption, await suspension, thenable and +reaction planning, Promise-boundary completion, and supervised handler startup. +It returns explicit actions; the interpreter remains responsible for executing +frames and unwinding JavaScript exceptions. Handlers are evaluation options, +not fields in the immutable program: ```elixir QuickBEAM.VM.eval(program, diff --git a/docs/prototype-delta-audit.md b/docs/prototype-delta-audit.md index 32d929dbc..e848acb1f 100644 --- a/docs/prototype-delta-audit.md +++ b/docs/prototype-delta-audit.md @@ -345,8 +345,11 @@ High-value test groups to adapt next: - Invocation now has one canonical planner for ordinary, closure, bound, constructor, built-in, Promise, and host calls. The interpreter executes its explicit actions to preserve resumable scheduling and exception unwinding. -- Split async, exceptions, and values from the current interpreter without - changing behavior. +- Async state transitions now have one canonical layer for async frame entry, + coroutine detachment/resumption, await suspension, reactions, thenables, + Promise-boundary completion, and supervised handler startup. +- Split exceptions and values from the current interpreter without changing + behavior. - Keep the current tests and SSR fixture green after each extraction. - Avoid reproducing the prototype's hundreds of overlapping modules. @@ -373,6 +376,6 @@ High-value test groups to adapt next: ## Immediate next action -Continue Phase B with canonical async scheduling and Promise transitions while -retaining explicit coroutines, jobs, handler ownership, limits, and resumable -interpreter boundaries. +Continue Phase B by consolidating JavaScript exception materialization and +unwinding around the canonical exception layer while preserving catchable +heap-backed errors and stable boundary conversion. diff --git a/lib/quickbeam/vm/async.ex b/lib/quickbeam/vm/async.ex new file mode 100644 index 000000000..323bbf9b0 --- /dev/null +++ b/lib/quickbeam/vm/async.ex @@ -0,0 +1,342 @@ +defmodule QuickBEAM.VM.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.{ + AsyncBoundary, + Continuation, + Coroutine, + Execution, + Frame, + Invocation, + Memory, + Promise, + PromiseExecutorBoundary, + PromiseReference, + Reaction, + ReactionBoundary, + ThenGetterBoundary, + ThenableBoundary, + Thrown + } + + @type result :: + {:run, Frame.t(), Execution.t()} + | {:raise, term(), Frame.t(), Execution.t()} + | {:invoke, term(), [term()], term(), term(), Execution.t(), boolean()} + | {:complete, term(), term(), Execution.t(), boolean()} + | {:return, term(), Execution.t()} + | {:idle, Execution.t()} + | {:suspended, Continuation.t()} + | {:error, term(), Execution.t()} + + @doc "Enters an async bytecode function behind an explicit Promise boundary." + @spec enter( + QuickBEAM.VM.Function.t(), + term(), + tuple(), + [term()], + term(), + term(), + Execution.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?(%ReactionBoundary{}, caller) -> :reaction + match?(%PromiseExecutorBoundary{}, caller) -> :executor + match?(%ThenableBoundary{}, caller) -> :thenable + tail? -> :return + true -> :push + end + + boundary = %AsyncBoundary{ + 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()}, Execution.t()) :: + result() + def resume_coroutine(%Coroutine{} = coroutine, result, %Execution{} = 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(), Execution.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(), Execution.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(), Execution.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(), Execution.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() | nil, Execution.t()) :: + result() + def read_thenable(promise, thenable, getter, continuation, execution) do + boundary = %ThenGetterBoundary{ + 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(), Execution.t()) :: result() + def assimilate_thenable(promise, thenable, callable, execution) do + boundary = %ThenableBoundary{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()}, Execution.t()) :: result() + def run_reaction(%Reaction{} = reaction, result, %Execution{} = execution) do + callback = + case result do + {:ok, _value} -> reaction.on_fulfilled + {:error, _reason} -> reaction.on_rejected + end + + if Invocation.callable?(callback, execution) do + boundary = %ReactionBoundary{ + 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(), ThenGetterBoundary.t(), Execution.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} + nil -> {:idle, execution} + end + end + + @doc "Completes a Promise reaction boundary." + @spec complete_reaction(ReactionBoundary.t(), term(), Execution.t()) :: result() + def complete_reaction(%ReactionBoundary{mode: :then} = boundary, value, execution), + do: {:idle, Promise.settle(execution, boundary.promise, {:ok, value})} + + def complete_reaction(%ReactionBoundary{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(AsyncBoundary.t(), {:ok, term()} | {:error, term()}, Execution.t()) :: result() + def complete(%AsyncBoundary{} = 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(Execution.t(), reference(), {:ok, term()} | {:error, term()}) :: + {:ok, Execution.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(Execution.t() | map()) :: :ok + def cancel_operations(%Execution{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()], Execution.t()) :: + {:ok, PromiseReference.t(), Execution.t()} | {:error, term(), Execution.t()} + def start_host_call([name | arguments], execution) when is_binary(name) 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} + 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?(%AsyncBoundary{}, &1))) do + {inner_callers, [%AsyncBoundary{} = 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(%AsyncBoundary{mode: :push, caller: caller, promise: promise}, execution), + do: {:complete, promise, caller, execution, false} + + defp deliver(%AsyncBoundary{mode: :return, promise: promise}, execution), + do: {:return, promise, execution} + + defp deliver( + %AsyncBoundary{mode: :reaction, caller: boundary, promise: promise}, + execution + ), + do: complete_reaction(boundary, promise, execution) + + defp deliver(%AsyncBoundary{mode: :executor, caller: boundary}, execution), + do: {:complete, boundary.promise, boundary.caller, execution, boundary.tail?} + + defp deliver(%AsyncBoundary{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 -> + Process.link(owner) + result = invoke_handler(handler, arguments) + send(owner, {:quickbeam_vm_host_reply, operation, result}) + 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 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/evaluator.ex b/lib/quickbeam/vm/evaluator.ex index bebc1795e..8d6429040 100644 --- a/lib/quickbeam/vm/evaluator.ex +++ b/lib/quickbeam/vm/evaluator.ex @@ -7,12 +7,12 @@ defmodule QuickBEAM.VM.Evaluator do """ alias QuickBEAM.VM.{ + Async, Continuation, Coroutine, Execution, Exceptions, Interpreter, - Memory, Promise, PromiseReference, Program, @@ -47,12 +47,12 @@ defmodule QuickBEAM.VM.Evaluator do defp drive({:suspended, _continuation} = suspended), do: Interpreter.finish(suspended) defp drive({status, _value, execution} = result) when status in [:ok, :error] do - cancel_operations(execution.operations) + Async.cancel_operations(execution) Interpreter.finish(result) end defp drive({:idle, execution}) do - cancel_operations(execution.operations) + Async.cancel_operations(execution) {:error, :idle_evaluation} end @@ -155,7 +155,7 @@ defmodule QuickBEAM.VM.Evaluator do defp receive_host_reply(final_promise, execution) do receive do {:quickbeam_vm_host_reply, operation, result} -> - case settle_host_reply(execution, operation, result) do + case Async.settle_host_reply(execution, operation, result) do {:ok, execution} -> await_final_promise(final_promise, execution) :stale -> receive_host_reply(final_promise, execution) end @@ -165,7 +165,7 @@ defmodule QuickBEAM.VM.Evaluator do defp await_legacy_promise(%Continuation{} = continuation) do receive do {:quickbeam_vm_host_reply, operation, result} -> - case settle_host_reply(continuation.execution, operation, result) do + case Async.settle_host_reply(continuation.execution, operation, result) do {:ok, execution} -> continuation = %{continuation | execution: execution} @@ -183,24 +183,6 @@ defmodule QuickBEAM.VM.Evaluator do end end - defp 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 = charge_host_result(execution, result) - {:ok, Promise.settle(execution, promise, result)} - end - end - - defp charge_host_result(execution, {:ok, value}), - do: Memory.charge(execution, Memory.estimate(value)) - - defp charge_host_result(execution, {:error, reason}), - do: Memory.charge(execution, Memory.estimate(reason)) - defp settled_result(promise, execution) do case Promise.state(execution, promise) do {:fulfilled, value} -> {:ok, value} @@ -216,13 +198,7 @@ defmodule QuickBEAM.VM.Evaluator do defp finish_final({status, _value, %Execution{} = execution} = result) when status in [:ok, :error] do - cancel_operations(execution.operations) + Async.cancel_operations(execution) Interpreter.finish(result) end - - defp cancel_operations(operations) do - Enum.each(operations, fn {_operation, {_promise, pid}} -> - if Process.alive?(pid), do: Process.exit(pid, :kill) - end) - end end diff --git a/lib/quickbeam/vm/interpreter.ex b/lib/quickbeam/vm/interpreter.ex index 3bdc86d97..b6cc95f90 100644 --- a/lib/quickbeam/vm/interpreter.ex +++ b/lib/quickbeam/vm/interpreter.ex @@ -11,6 +11,7 @@ defmodule QuickBEAM.VM.Interpreter do alias QuickBEAM.VM.{ AccessorBoundary, + Async, AsyncBoundary, Builtins, Continuation, @@ -91,79 +92,32 @@ defmodule QuickBEAM.VM.Interpreter do end @doc "Resumes a detached async coroutine with a Promise settlement." - def resume_coroutine(%Coroutine{} = coroutine, result, %Execution{} = 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_js_from_caller(reason, coroutine.frame, execution) - end - end + def resume_coroutine(%Coroutine{} = coroutine, result, %Execution{} = 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, %Execution{} = execution), - do: start_then_getter(promise, thenable, getter, nil, 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(%Execution{} = execution) do case :queue.out(execution.sync_jobs) do {{:value, {:read_thenable, promise, thenable, getter}}, sync_jobs} -> execution = %{execution | sync_jobs: sync_jobs} - start_then_getter(promise, thenable, getter, nil, execution) + promise |> Async.read_thenable(thenable, getter, nil, execution) |> execute_async() {:empty, _sync_jobs} -> {:none, execution} end end - defp start_then_getter(promise, thenable, getter, continuation, execution) do - boundary = %ThenGetterBoundary{ - promise: promise, - thenable: thenable, - depth: execution.depth, - continuation: continuation - } - - dispatch_call(getter, [], thenable, boundary, execution, false) - end - @doc "Invokes a thenable and connects its resolver functions to a Promise." - def assimilate_thenable(promise, thenable, callable, %Execution{} = execution) do - boundary = %ThenableBoundary{promise: promise, depth: execution.depth} - resolve = {:promise_resolver, promise, :resolve_assimilated} - reject = {:promise_resolver, promise, :reject_assimilated} - dispatch_call(callable, [resolve, reject], thenable, boundary, execution, false) - end + def assimilate_thenable(promise, thenable, callable, %Execution{} = 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, %Execution{} = execution) do - callback = - case result do - {:ok, _value} -> reaction.on_fulfilled - {:error, _reason} -> reaction.on_rejected - end - - if Invocation.typeof(callback, execution) == "function" do - boundary = %ReactionBoundary{ - promise: reaction.result_promise, - depth: execution.depth, - mode: reaction.kind, - original_result: result - } - - arguments = if reaction.kind == :finally, do: [], else: [reaction_argument(result)] - dispatch_call(callback, arguments, :undefined, boundary, execution, false) - else - execution = Promise.settle(execution, reaction.result_promise, result) - {:idle, execution} - end - end - - defp reaction_argument({:ok, value}), do: value - defp reaction_argument({:error, %Thrown{value: value}}), do: value - defp reaction_argument({:error, reason}), do: reason + def run_reaction(%Reaction{} = reaction, result, %Execution{} = 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) @@ -222,39 +176,32 @@ defmodule QuickBEAM.VM.Interpreter do end defp enter_async_call(function, callable, closure_refs, args, this, caller, execution, tail?) do - depth = if tail?, do: execution.depth, else: execution.depth + 1 + function + |> Async.enter(callable, closure_refs, args, this, caller, execution, tail?) + |> execute_async() + end - if depth > execution.max_stack_depth do - {:error, {:limit_exceeded, :stack_depth, depth}, execution} - else - {promise, execution} = Promise.new(execution) - - mode = - cond do - match?(%ReactionBoundary{}, caller) -> :reaction - match?(%PromiseExecutorBoundary{}, caller) -> :executor - match?(%ThenableBoundary{}, caller) -> :thenable - tail? -> :return - true -> :push - end + defp execute_async({:run, frame, execution}), do: run(frame, execution) - boundary = %AsyncBoundary{ - promise: promise, - caller: if(tail?, do: nil, else: caller), - depth: execution.depth, - mode: mode - } + defp execute_async({:raise, reason, frame, execution}), + do: raise_js_from_caller(reason, frame, execution) - execution = %{execution | callers: [boundary | execution.callers], depth: depth} - run(Invocation.new_frame(function, callable, args, this, closure_refs), execution) - end - end + 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({: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, %Execution{} = execution) when execution.sync_jobs != {[], []} do case :queue.out(execution.sync_jobs) do {{:value, {:read_thenable, promise, thenable, getter}}, sync_jobs} -> execution = %{execution | sync_jobs: sync_jobs} - start_then_getter(promise, thenable, getter, frame, execution) + promise |> Async.read_thenable(thenable, getter, frame, execution) |> execute_async() {:empty, _sync_jobs} -> run(frame, %{execution | sync_jobs: {[], []}}) @@ -1002,16 +949,8 @@ defmodule QuickBEAM.VM.Interpreter do else: run(%{caller | stack: [value | caller.stack]}, execution) end - defp complete_then_getter(value, boundary, execution) do - execution = - if Invocation.typeof(value, execution) == "function" do - Promise.enqueue_assimilation(execution, boundary.promise, boundary.thenable, value) - else - Promise.fulfill_assimilated(execution, boundary.promise, boundary.thenable) - end - - continue_after_then_getter(boundary, execution) - end + defp complete_then_getter(value, boundary, execution), + do: value |> Async.complete_then_getter(boundary, execution) |> execute_async() defp continue_after_then_getter(%ThenGetterBoundary{continuation: %Frame{} = frame}, execution), do: run(frame, execution) @@ -1043,32 +982,8 @@ defmodule QuickBEAM.VM.Interpreter do complete_call_result(boundary.promise, boundary.caller, execution, boundary.tail?) end - defp complete_reaction(%ReactionBoundary{mode: :then} = boundary, value, execution) do - execution = Promise.settle(execution, boundary.promise, {:ok, value}) - {:idle, execution} - end - - defp complete_reaction(%ReactionBoundary{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 + defp complete_reaction(boundary, value, execution), + do: boundary |> Async.complete_reaction(value, execution) |> execute_async() defp continue_object_assign(%ObjectAssignBoundary{keys: [key | keys]} = boundary, execution) do case Properties.get(boundary.source, key, execution) do @@ -1287,20 +1202,9 @@ defmodule QuickBEAM.VM.Interpreter do do: binary(frame, execution, &Value.bitwise(&1, &2, operation)) defp detach_async(frame, execution, awaited_promise) do - case Enum.split_while(execution.callers, &(!match?(%AsyncBoundary{}, &1))) do - {inner_callers, [%AsyncBoundary{} = boundary | outer_callers]} -> - coroutine = %Coroutine{ - frame: next_frame(frame), - callers: inner_callers, - boundary: %{boundary | caller: nil, depth: 0, mode: :detached} - } - - execution = %{execution | callers: outer_callers, depth: boundary.depth} - execution = Promise.await(execution, awaited_promise, coroutine) - {:ok, deliver_async_promise(boundary, execution)} - - {_callers, []} -> - :no_async_boundary + 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 @@ -1312,48 +1216,17 @@ defmodule QuickBEAM.VM.Interpreter do end defp detach_async_immediate(frame, execution, result) do - case Enum.split_while(execution.callers, &(!match?(%AsyncBoundary{}, &1))) do - {inner_callers, [%AsyncBoundary{} = boundary | outer_callers]} -> - coroutine = %Coroutine{ - frame: next_frame(frame), - callers: inner_callers, - boundary: %{boundary | caller: nil, depth: 0, mode: :detached} - } - - execution = %{execution | callers: outer_callers, depth: boundary.depth} - execution = Promise.enqueue_coroutine(execution, coroutine, result) - {:ok, deliver_async_promise(boundary, execution)} - - {_callers, []} -> - :no_async_boundary - end - end - - defp suspend_promise_legacy(frame, execution, promise) do - case Promise.state(execution, promise) do - :pending -> - {:suspended, - %Continuation{frame: next_frame(frame), execution: execution, awaiting: promise}} - - {:fulfilled, value} -> - suspend_microtask({:ok, value}, frame, execution) - - {:rejected, reason} -> - suspend_microtask({:error, reason}, frame, execution) + 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_microtask(result, frame, execution) do - execution = %{execution | jobs: :queue.in(result, execution.jobs)} - - continuation = %Continuation{ - frame: next_frame(frame), - execution: execution, - awaiting: :microtask - } + defp suspend_promise_legacy(frame, execution, promise), + do: frame |> next_frame() |> Async.suspend_promise(execution, promise) |> execute_async() - {:suspended, continuation} - end + defp suspend_microtask(result, frame, execution), + do: frame |> next_frame() |> Async.suspend_microtask(execution, result) |> execute_async() defp install_host_globals(execution) do execution = Builtins.install(execution) @@ -1375,48 +1248,13 @@ defmodule QuickBEAM.VM.Interpreter do %{execution | globals: globals} end - defp start_host_call([name | arguments], caller, execution, tail?) when is_binary(name) 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 - - if tail?, - do: return_value(promise, execution), - else: run(%{caller | stack: [promise | caller.stack]}, execution) - end - - defp start_host_call(_arguments, caller, execution, _tail?), - do: raise_js_from_caller({:type_error, :invalid_beam_call}, caller, execution) - - defp start_handler_task(handler, arguments, promise, execution) do - operation = make_ref() - owner = self() - - case Task.Supervisor.start_child(QuickBEAM.VM.TaskSupervisor, fn -> - Process.link(owner) - result = invoke_handler(handler, arguments) - send(owner, {:quickbeam_vm_host_reply, operation, result}) - end) do - {:ok, pid} -> - %{execution | operations: Map.put(execution.operations, operation, {promise, pid})} - - {:error, reason} -> - Promise.settle(execution, promise, {:error, {:handler_start_failed, reason}}) + 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, reason, execution} -> raise_js_from_caller(reason, caller, execution) 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 - defp get_property_and_continue(object, key, stack, frame, execution) do case Properties.get(object, key, execution) do {:ok, {:accessor, getter, receiver}} -> @@ -1622,8 +1460,7 @@ defmodule QuickBEAM.VM.Interpreter do ) do thrown = %Thrown{value: reason, frames: Enum.reverse(trace)} execution = %{execution | callers: callers, depth: boundary.depth} - execution = Promise.settle(execution, boundary.promise, {:error, thrown}) - deliver_async_promise(boundary, execution) + boundary |> Async.complete({:error, thrown}, execution) |> execute_async() end defp unwind_caller( @@ -1685,37 +1522,11 @@ defmodule QuickBEAM.VM.Interpreter do %Execution{callers: [%AsyncBoundary{} = boundary | callers]} = execution ) do execution = %{execution | callers: callers, depth: boundary.depth} - execution = Promise.settle(execution, boundary.promise, {:ok, value}) - deliver_async_promise(boundary, execution) + boundary |> Async.complete({:ok, value}, execution) |> execute_async() end defp complete_async(value, execution), do: return_value(value, execution) - defp deliver_async_promise( - %AsyncBoundary{mode: :push, caller: caller, promise: promise}, - execution - ), - do: complete_call_result(promise, caller, execution, false) - - defp deliver_async_promise(%AsyncBoundary{mode: :return, promise: promise}, execution), - do: return_value(promise, execution) - - defp deliver_async_promise( - %AsyncBoundary{mode: :reaction, caller: boundary, promise: promise}, - execution - ) do - complete_reaction(boundary, promise, execution) - end - - defp deliver_async_promise(%AsyncBoundary{mode: :executor, caller: boundary}, execution), - do: complete_executor(boundary, execution) - - defp deliver_async_promise(%AsyncBoundary{mode: :thenable}, execution), - do: {:idle, execution} - - defp deliver_async_promise(%AsyncBoundary{mode: :detached}, execution), - do: {:idle, execution} - defp return_value(value, %Execution{callers: []} = execution), do: {:ok, value, %{execution | depth: 0}} diff --git a/test/vm/async_semantics_test.exs b/test/vm/async_semantics_test.exs new file mode 100644 index 000000000..9f2270614 --- /dev/null +++ b/test/vm/async_semantics_test.exs @@ -0,0 +1,102 @@ +defmodule QuickBEAM.VM.AsyncSemanticsTest do + use ExUnit.Case, async: true + + alias QuickBEAM.VM.{ + Async, + AsyncBoundary, + Execution, + Frame, + Function, + Invocation, + Promise, + Reaction + } + + 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 %AsyncBoundary{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 + %Execution{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 From 9e6687d0a04df22825a8361a39b1baf7c424bf78 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 13 Jul 2026 11:49:42 +0200 Subject: [PATCH 24/87] Extract canonical VM exception semantics --- docs/beam-interpreter-architecture.md | 6 + docs/prototype-delta-audit.md | 11 +- lib/quickbeam/vm/exceptions.ex | 243 +++++++++++++++++++++++++- lib/quickbeam/vm/interpreter.ex | 232 ++---------------------- test/vm/exceptions_test.exs | 102 +++++++++++ 5 files changed, 368 insertions(+), 226 deletions(-) create mode 100644 test/vm/exceptions_test.exs diff --git a/docs/beam-interpreter-architecture.md b/docs/beam-interpreter-architecture.md index 21e5a71fa..e84e9875e 100644 --- a/docs/beam-interpreter-architecture.md +++ b/docs/beam-interpreter-architecture.md @@ -502,6 +502,12 @@ cancellation mechanisms. ## Errors, tracing, and observability +`QuickBEAM.VM.Exceptions` is the canonical exception layer. It materializes +catchable heap-backed errors, locates catch targets, unwinds frame and native +boundaries, preserves async stack frames, settles Promise-related boundaries, +and returns explicit continuation actions. The interpreter only executes those +actions. + JavaScript throws become `%QuickBEAM.JSError{}` with JavaScript name, message, filename, line, column, and structured JavaScript stack frames when debug metadata is present. Generated errors are owner-local JavaScript objects with diff --git a/docs/prototype-delta-audit.md b/docs/prototype-delta-audit.md index e848acb1f..8fbcfc0e5 100644 --- a/docs/prototype-delta-audit.md +++ b/docs/prototype-delta-audit.md @@ -348,8 +348,10 @@ High-value test groups to adapt next: - Async state transitions now have one canonical layer for async frame entry, coroutine detachment/resumption, await suspension, reactions, thenables, Promise-boundary completion, and supervised handler startup. -- Split exceptions and values from the current interpreter without changing - behavior. +- Exception materialization, catch lookup, frame and native-boundary unwinding, + async stack preservation, Promise-boundary rejection, and public error + conversion now share one canonical exception layer. +- Split values from the current interpreter without changing behavior. - Keep the current tests and SSR fixture green after each extraction. - Avoid reproducing the prototype's hundreds of overlapping modules. @@ -376,6 +378,5 @@ High-value test groups to adapt next: ## Immediate next action -Continue Phase B by consolidating JavaScript exception materialization and -unwinding around the canonical exception layer while preserving catchable -heap-backed errors and stable boundary conversion. +Continue Phase B by extracting canonical coercion, arithmetic, equality, +comparison, bitwise, and UTF-16-aware value semantics from opcode dispatch. diff --git a/lib/quickbeam/vm/exceptions.ex b/lib/quickbeam/vm/exceptions.ex index 6e92e4a30..ac2e4d91f 100644 --- a/lib/quickbeam/vm/exceptions.ex +++ b/lib/quickbeam/vm/exceptions.ex @@ -6,7 +6,56 @@ defmodule QuickBEAM.VM.Exceptions do to `QuickBEAM.JSError` happens only after a value escapes the evaluation. """ - alias QuickBEAM.VM.{Builtins, Execution, Heap, Object, Properties, Reference, Thrown} + alias QuickBEAM.VM.{ + AccessorBoundary, + Async, + AsyncBoundary, + Builtins, + ConstructorBoundary, + Execution, + Frame, + Function, + Heap, + NativeFrame, + Object, + ObjectAssignBoundary, + PredefinedAtoms, + Promise, + PromiseExecutorBoundary, + Properties, + ReactionBoundary, + Reference, + ThenGetterBoundary, + ThenableBoundary, + Thrown + } + + @type action :: + {:run, Frame.t(), Execution.t()} + | {:resume_then_getter, ThenGetterBoundary.t(), Execution.t()} + | {:complete, term(), term(), Execution.t(), boolean()} + | {:async, Async.result()} + | {:idle, Execution.t()} + | {:error, QuickBEAM.JSError.t(), Execution.t()} + + @doc "Raises a value at the current frame and plans catch or unwind execution." + @spec throw_at(term(), Frame.t() | NativeFrame.t(), Execution.t()) :: action() + def throw_at(reason, %NativeFrame{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(), Execution.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(), Execution.t()) :: {term(), Execution.t()} @@ -61,6 +110,198 @@ defmodule QuickBEAM.VM.Exceptions do 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, %ObjectAssignBoundary{} = boundary, execution, trace), + do: do_throw(reason, boundary.caller, execution, trace, true) + + defp throw_from_boundary(reason, %ThenGetterBoundary{} = 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, %AccessorBoundary{} = boundary, execution, trace), + do: do_throw(reason, boundary.caller, execution, trace, true) + + defp throw_from_boundary(reason, %ConstructorBoundary{} = boundary, execution, trace), + do: do_throw(reason, boundary.caller, execution, trace, true) + + defp throw_from_boundary(reason, %ThenableBoundary{} = boundary, execution, trace) do + execution = + Promise.settle_assimilated(execution, boundary.promise, {:error, thrown(reason, trace)}) + + {:idle, execution} + end + + defp throw_from_boundary(reason, %PromiseExecutorBoundary{} = 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, %ReactionBoundary{} = boundary, execution, trace) do + execution = Promise.settle(execution, boundary.promise, {:error, thrown(reason, trace)}) + {:idle, execution} + end + + defp throw_from_boundary(reason, %NativeFrame{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, %Execution{callers: []} = execution, trace) do + error = to_js_error(reason, execution, Enum.reverse(trace)) + {:error, error, execution} + end + + defp unwind_caller( + reason, + %Execution{callers: [%ObjectAssignBoundary{} = 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, + %Execution{callers: [%ThenGetterBoundary{} = boundary | callers]} = execution, + trace + ) do + execution = %{execution | callers: callers, depth: boundary.depth} + throw_from_boundary(reason, boundary, execution, trace) + end + + defp unwind_caller( + reason, + %Execution{callers: [%AccessorBoundary{} = 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, + %Execution{callers: [%ConstructorBoundary{} = 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, + %Execution{callers: [%ThenableBoundary{} = boundary | callers]} = execution, + trace + ) do + execution = %{execution | callers: callers, depth: boundary.depth} + throw_from_boundary(reason, boundary, execution, trace) + end + + defp unwind_caller( + reason, + %Execution{callers: [%PromiseExecutorBoundary{} = boundary | callers]} = execution, + trace + ) do + execution = %{execution | callers: callers, depth: boundary.depth} + throw_from_boundary(reason, boundary, execution, trace) + end + + defp unwind_caller( + reason, + %Execution{callers: [%ReactionBoundary{} = boundary | callers]} = execution, + trace + ) do + execution = %{execution | callers: callers, depth: boundary.depth} + throw_from_boundary(reason, boundary, execution, trace) + end + + defp unwind_caller( + reason, + %Execution{callers: [%AsyncBoundary{} = 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, + %Execution{callers: [%NativeFrame{} = 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, + %Execution{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: PredefinedAtoms.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: QuickBEAM.VM.Value.to_string_value(value) end diff --git a/lib/quickbeam/vm/interpreter.ex b/lib/quickbeam/vm/interpreter.ex index b6cc95f90..8c7a64cf5 100644 --- a/lib/quickbeam/vm/interpreter.ex +++ b/lib/quickbeam/vm/interpreter.ex @@ -30,7 +30,6 @@ defmodule QuickBEAM.VM.Interpreter do Opcodes, PredefinedAtoms, Program, - Promise, Properties, PromiseExecutorBoundary, PromiseReference, @@ -40,7 +39,6 @@ defmodule QuickBEAM.VM.Interpreter do RegExp, ThenableBoundary, ThenGetterBoundary, - Thrown, Value } @@ -1293,229 +1291,23 @@ defmodule QuickBEAM.VM.Interpreter do end end - defp raise_js(reason, %NativeFrame{caller: caller}, execution) do - {reason, trace, execution} = throw_state(reason, execution) - do_raise_js(reason, caller, execution, trace, true) - end - - defp raise_js(reason, frame, execution) do - {reason, trace, execution} = throw_state(reason, execution) - do_raise_js(reason, frame, execution, trace, false) - end + defp raise_js(reason, frame, execution), + do: reason |> Exceptions.throw_at(frame, execution) |> execute_exception() - defp raise_js_from_caller(reason, %ObjectAssignBoundary{} = boundary, execution) do - {reason, trace, execution} = throw_state(reason, execution) - do_raise_js(reason, boundary.caller, execution, trace, true) - end - - defp raise_js_from_caller(reason, %ThenGetterBoundary{} = boundary, execution) do - {reason, trace, execution} = throw_state(reason, execution) - thrown = %Thrown{value: reason, frames: Enum.reverse(trace)} - execution = Promise.settle_assimilated(execution, boundary.promise, {:error, thrown}) - continue_after_then_getter(boundary, execution) - end + defp raise_js_from_caller(reason, caller, execution), + do: reason |> Exceptions.throw_from(caller, execution) |> execute_exception() - defp raise_js_from_caller(reason, %AccessorBoundary{} = boundary, execution) do - {reason, trace, execution} = throw_state(reason, execution) - do_raise_js(reason, boundary.caller, execution, trace, true) - end - - defp raise_js_from_caller(reason, %ConstructorBoundary{} = boundary, execution) do - {reason, trace, execution} = throw_state(reason, execution) - do_raise_js(reason, boundary.caller, execution, trace, true) - end - - defp raise_js_from_caller(reason, %ThenableBoundary{} = boundary, execution) do - {reason, trace, execution} = throw_state(reason, execution) - thrown = %Thrown{value: reason, frames: Enum.reverse(trace)} - execution = Promise.settle_assimilated(execution, boundary.promise, {:error, thrown}) - {:idle, execution} - end + defp execute_exception({:run, frame, execution}), do: run(frame, execution) - defp raise_js_from_caller(reason, %PromiseExecutorBoundary{} = boundary, execution) do - {reason, trace, execution} = throw_state(reason, execution) - thrown = %Thrown{value: reason, frames: Enum.reverse(trace)} - execution = Promise.settle(execution, boundary.promise, {:error, thrown}) - complete_executor(boundary, execution) - end - - defp raise_js_from_caller(reason, %ReactionBoundary{} = boundary, execution) do - {reason, trace, execution} = throw_state(reason, execution) - thrown = %Thrown{value: reason, frames: Enum.reverse(trace)} - execution = Promise.settle(execution, boundary.promise, {:error, thrown}) - {:idle, execution} - end - - defp raise_js_from_caller(reason, %NativeFrame{caller: caller}, execution) do - {reason, trace, execution} = throw_state(reason, execution) - do_raise_js(reason, caller, execution, trace, true) - end - - defp raise_js_from_caller(reason, frame, execution) do - {reason, trace, execution} = throw_state(reason, execution) - do_raise_js(reason, frame, execution, trace, true) - end - - defp throw_state(%Thrown{value: value, frames: frames}, execution) do - {value, execution} = Exceptions.materialize(value, execution) - {value, Enum.reverse(frames), execution} - end - - defp throw_state(reason, execution) do - {value, execution} = Exceptions.materialize(reason, execution) - {value, [], execution} - end - - defp do_raise_js(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 = [vm_stack_frame(frame, caller?) | trace] - unwind_caller(reason, execution, trace) - end - end - - defp unwind_caller(reason, %Execution{callers: []} = execution, trace) do - error = Exceptions.to_js_error(reason, execution, Enum.reverse(trace)) - {:error, error, execution} - end - - defp unwind_caller( - reason, - %Execution{callers: [%ObjectAssignBoundary{} = boundary | callers]} = execution, - trace - ) do - execution = %{execution | callers: callers, depth: boundary.depth} - do_raise_js(reason, boundary.caller, execution, trace, true) - end - - defp unwind_caller( - reason, - %Execution{callers: [%ThenGetterBoundary{} = boundary | callers]} = execution, - trace - ) do - thrown = %Thrown{value: reason, frames: Enum.reverse(trace)} - execution = %{execution | callers: callers, depth: boundary.depth} - execution = Promise.settle_assimilated(execution, boundary.promise, {:error, thrown}) - continue_after_then_getter(boundary, execution) - end - - defp unwind_caller( - reason, - %Execution{callers: [%AccessorBoundary{} = boundary | callers]} = execution, - trace - ) do - execution = %{execution | callers: callers, depth: boundary.depth} - do_raise_js(reason, boundary.caller, execution, trace, true) - end + defp execute_exception({:resume_then_getter, boundary, execution}), + do: continue_after_then_getter(boundary, execution) - defp unwind_caller( - reason, - %Execution{callers: [%ConstructorBoundary{} = boundary | callers]} = execution, - trace - ) do - execution = %{execution | callers: callers, depth: boundary.depth} - do_raise_js(reason, boundary.caller, execution, trace, true) - end - - defp unwind_caller( - reason, - %Execution{callers: [%ThenableBoundary{} = boundary | callers]} = execution, - trace - ) do - thrown = %Thrown{value: reason, frames: Enum.reverse(trace)} - execution = %{execution | callers: callers, depth: boundary.depth} - execution = Promise.settle_assimilated(execution, boundary.promise, {:error, thrown}) - {:idle, execution} - end - - defp unwind_caller( - reason, - %Execution{callers: [%PromiseExecutorBoundary{} = boundary | callers]} = execution, - trace - ) do - thrown = %Thrown{value: reason, frames: Enum.reverse(trace)} - execution = %{execution | callers: callers, depth: boundary.depth} - execution = Promise.settle(execution, boundary.promise, {:error, thrown}) - complete_executor(boundary, execution) - end - - defp unwind_caller( - reason, - %Execution{callers: [%ReactionBoundary{} = boundary | callers]} = execution, - trace - ) do - thrown = %Thrown{value: reason, frames: Enum.reverse(trace)} - execution = %{execution | callers: callers, depth: boundary.depth} - execution = Promise.settle(execution, boundary.promise, {:error, thrown}) - {:idle, execution} - end - - defp unwind_caller( - reason, - %Execution{callers: [%AsyncBoundary{} = boundary | callers]} = execution, - trace - ) do - thrown = %Thrown{value: reason, frames: Enum.reverse(trace)} - execution = %{execution | callers: callers, depth: boundary.depth} - boundary |> Async.complete({:error, thrown}, execution) |> execute_async() - end - - defp unwind_caller( - reason, - %Execution{callers: [%NativeFrame{} = native | callers]} = execution, - trace - ) do - execution = %{execution | callers: callers, depth: execution.depth - 1} - do_raise_js(reason, native.caller, execution, trace, true) - end - - defp unwind_caller( - reason, - %Execution{callers: [%Frame{} = caller | callers]} = execution, - trace - ) do - execution = %{execution | callers: callers, depth: execution.depth - 1} - do_raise_js(reason, caller, execution, trace, true) - end - - defp vm_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: PredefinedAtoms.lookup(index) || "" - - defp normalize_function_name(name) when is_binary(name), do: name - defp normalize_function_name(name), do: inspect(name) + defp execute_exception({:complete, value, caller, execution, tail?}), + do: complete_call_result(value, caller, execution, tail?) - 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 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, diff --git a/test/vm/exceptions_test.exs b/test/vm/exceptions_test.exs new file mode 100644 index 000000000..c833c6f9f --- /dev/null +++ b/test/vm/exceptions_test.exs @@ -0,0 +1,102 @@ +defmodule QuickBEAM.VM.ExceptionsTest do + use ExUnit.Case, async: true + + alias QuickBEAM.VM.{ + AsyncBoundary, + Execution, + Exceptions, + Frame, + Function, + Promise, + PromiseExecutorBoundary, + ThenableBoundary, + 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} = Exceptions.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} = + Exceptions.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 = %ThenableBoundary{promise: thenable_promise, depth: 1} + assert {:idle, execution} = Exceptions.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 = %PromiseExecutorBoundary{ + promise: executor_promise, + caller: caller, + depth: 1, + tail?: false + } + + assert {:complete, ^executor_promise, ^caller, execution, false} = + Exceptions.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 = %AsyncBoundary{promise: promise, caller: caller, depth: 1, mode: :push} + execution = %{execution | callers: [boundary], depth: 2} + + assert {:async, {:complete, ^promise, ^caller, execution, false}} = + Exceptions.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 + %Execution{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 From ba457f23a758e8539ecf6ec9d83b3182ecdcdf81 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 13 Jul 2026 12:01:10 +0200 Subject: [PATCH 25/87] Consolidate canonical VM value semantics --- docs/beam-interpreter-architecture.md | 10 ++- docs/prototype-delta-audit.md | 10 ++- lib/quickbeam/vm/builtins.ex | 11 +-- lib/quickbeam/vm/interpreter.ex | 102 +++++++++------------- lib/quickbeam/vm/properties.ex | 6 +- lib/quickbeam/vm/value.ex | 117 +++++++++++++++++++++++++- test/vm/value_test.exs | 62 ++++++++++++++ 7 files changed, 240 insertions(+), 78 deletions(-) create mode 100644 test/vm/value_test.exs diff --git a/docs/beam-interpreter-architecture.md b/docs/beam-interpreter-architecture.md index e84e9875e..9e2b35b2b 100644 --- a/docs/beam-interpreter-architecture.md +++ b/docs/beam-interpreter-architecture.md @@ -357,9 +357,13 @@ terms, while semantic sentinels and reference types use explicit tagged values. The value representation, coercion rules, property semantics, invocation rules, and built-ins form one canonical semantic layer shared by the interpreter and any future compiler. A compiled path must not grow a second implementation of -JavaScript semantics. `QuickBEAM.VM.Properties` is now the canonical boundary -for get, put, define, delete, descriptors, enumeration, primitive properties, -and prototype operations; accessor results remain explicit resumable actions. +JavaScript semantics. `QuickBEAM.VM.Value` is the canonical boundary for +truthiness, primitive coercion, arithmetic, equality, comparison, bitwise +operations, `typeof`, and UTF-16 string operations. Opcode dispatch and +built-ins select an operation but do not reimplement it. +`QuickBEAM.VM.Properties` is the canonical boundary for get, put, define, +delete, descriptors, enumeration, primitive properties, and prototype +operations; accessor results remain explicit resumable actions. `QuickBEAM.VM.Invocation` is the corresponding canonical boundary for ordinary, bound, constructor, built-in, Promise, and host-function calls. It produces explicit call actions while the interpreter retains frame scheduling and diff --git a/docs/prototype-delta-audit.md b/docs/prototype-delta-audit.md index 8fbcfc0e5..7fd1493d3 100644 --- a/docs/prototype-delta-audit.md +++ b/docs/prototype-delta-audit.md @@ -337,7 +337,7 @@ High-value test groups to adapt next: - Adapted `Object.getOwnPropertyNames` semantics and required harness helpers. - Raised the selected Test262 baseline from 88.9% to 100%. -### Phase B — establish canonical semantic modules (in progress) +### Phase B — establish canonical semantic modules (complete) - Property semantics now live behind one canonical property layer, including explicit getter/setter actions used by the interpreter, built-ins, Promise @@ -351,7 +351,8 @@ High-value test groups to adapt next: - Exception materialization, catch lookup, frame and native-boundary unwinding, async stack preservation, Promise-boundary rejection, and public error conversion now share one canonical exception layer. -- Split values from the current interpreter without changing behavior. +- Value coercion, arithmetic, equality, comparison, bitwise operations, + `typeof`, and UTF-16 string operations now share one canonical value layer. - Keep the current tests and SSR fixture green after each extraction. - Avoid reproducing the prototype's hundreds of overlapping modules. @@ -378,5 +379,6 @@ High-value test groups to adapt next: ## Immediate next action -Continue Phase B by extracting canonical coercion, arithmetic, equality, -comparison, bitwise, and UTF-16-aware value semantics from opcode dispatch. +With the canonical semantic foundations in place, split opcode-family dispatch +into smaller modules that delegate to properties, invocation, async, +exceptions, and values without duplicating their behavior. diff --git a/lib/quickbeam/vm/builtins.ex b/lib/quickbeam/vm/builtins.ex index 5e66b2a26..4bb9ad090 100644 --- a/lib/quickbeam/vm/builtins.ex +++ b/lib/quickbeam/vm/builtins.ex @@ -3,8 +3,6 @@ defmodule QuickBEAM.VM.Builtins do Installs and dispatches the JavaScript built-ins supported by the VM profile. """ - import Bitwise - alias QuickBEAM.VM.{ Execution, Heap, @@ -13,7 +11,6 @@ defmodule QuickBEAM.VM.Builtins do Property, Reference, RegExp, - UTF16, Value } @@ -226,7 +223,7 @@ defmodule QuickBEAM.VM.Builtins do do: {:ok, Value.power(base, exponent), execution} def call({:builtin_method, "String", "fromCharCode"}, _this, values, execution) do - string = values |> Enum.map(&(Value.to_int32(&1) &&& 0xFFFF)) |> UTF16.from_units() + string = Value.string_from_char_codes(values) {:ok, string, execution} end @@ -366,13 +363,13 @@ defmodule QuickBEAM.VM.Builtins do do: {:ok, String.contains?(value, Value.to_string_value(part)), execution} def call({:primitive_method, :string, "charCodeAt"}, value, [index | _], execution) do - result = UTF16.char_code_at(value, Value.to_int32(index)) + result = Value.string_char_code_at(value, Value.to_int32(index)) {:ok, result, execution} end def call({:primitive_method, :string, "slice"}, value, arguments, execution) do - {start, length} = slice_range(UTF16.length(value), arguments) - {:ok, UTF16.slice(value, start, length), execution} + {start, length} = slice_range(Value.string_length(value), arguments) + {:ok, Value.string_slice(value, start, length), execution} end def call({:primitive_method, :string, "replace"}, value, [pattern, replacement | _], execution) do diff --git a/lib/quickbeam/vm/interpreter.ex b/lib/quickbeam/vm/interpreter.ex index 8c7a64cf5..72b99d5f7 100644 --- a/lib/quickbeam/vm/interpreter.ex +++ b/lib/quickbeam/vm/interpreter.ex @@ -7,8 +7,6 @@ defmodule QuickBEAM.VM.Interpreter do Elixir or native call stack. """ - import Bitwise - alias QuickBEAM.VM.{ AccessorBoundary, Async, @@ -382,14 +380,9 @@ defmodule QuickBEAM.VM.Interpreter do defp execute(:to_object, [], frame, execution), do: continue(frame, execution) - defp execute(:is_undefined_or_null, [], frame, execution), - do: unary(frame, execution, &(&1 in [:undefined, nil])) - - defp execute(:is_undefined, [], frame, execution), - do: unary(frame, execution, &(&1 == :undefined)) - - defp execute(:is_null, [], frame, execution), - do: unary(frame, execution, &is_nil/1) + defp execute(name, [], frame, execution) + when name in [:is_undefined_or_null, :is_undefined, :is_null], + do: value_unary(frame, execution, name) defp execute(:is_function, [], %{stack: [value | stack]} = frame, execution), do: @@ -508,16 +501,16 @@ defmodule QuickBEAM.VM.Interpreter do defp execute(:close_loc, [_index], frame, execution), do: continue(frame, execution) defp execute(:inc_loc, [index], frame, execution), - do: update_local(frame, execution, index, &Value.add(&1, 1)) + do: update_local(frame, execution, index, &Value.unary(:inc, &1)) defp execute(:dec_loc, [index], frame, execution), - do: update_local(frame, execution, index, &Value.subtract(&1, 1)) + do: update_local(frame, execution, index, &Value.unary(:dec, &1)) defp execute(:add_loc, [index], %{stack: [value | stack]} = frame, execution) do current = read_slot(elem(frame.locals, index), execution) {locals, execution} = - write_tuple_slot(frame.locals, index, Value.add(current, value), execution) + write_tuple_slot(frame.locals, index, Value.binary(:add, current, value), execution) continue(%{frame | locals: locals, stack: stack}, execution) end @@ -581,26 +574,24 @@ defmodule QuickBEAM.VM.Interpreter do defp execute(:return_async, [], %{stack: [value | _stack]}, execution), do: complete_async(value, execution) - defp execute(:add, [], frame, execution), do: binary(frame, execution, &Value.add/2) - defp execute(:sub, [], frame, execution), do: binary(frame, execution, &Value.subtract/2) - defp execute(:mul, [], frame, execution), do: binary(frame, execution, &Value.multiply/2) - defp execute(:div, [], frame, execution), do: binary(frame, execution, &Value.divide/2) - defp execute(:mod, [], frame, execution), do: binary(frame, execution, &Value.modulo/2) - defp execute(:pow, [], frame, execution), do: binary(frame, execution, &Value.power/2) - defp execute(:lt, [], frame, execution), do: compare(frame, execution, &Kernel./2) - defp execute(:gte, [], frame, execution), do: compare(frame, execution, &Kernel.>=/2) - defp execute(:eq, [], frame, execution), do: binary(frame, execution, &Value.abstract_equal?/2) - - defp execute(:neq, [], frame, execution), - do: binary(frame, execution, &(not Value.abstract_equal?(&1, &2))) - - defp execute(:strict_eq, [], frame, execution), - do: binary(frame, execution, &Value.strict_equal?/2) - - defp execute(:strict_neq, [], frame, execution), - do: binary(frame, execution, &(not Value.strict_equal?(&1, &2))) + defp execute(name, [], frame, execution) + when name in [ + :add, + :sub, + :mul, + :div, + :mod, + :pow, + :lt, + :lte, + :gt, + :gte, + :eq, + :neq, + :strict_eq, + :strict_neq + ], + do: value_binary(frame, execution, name) defp execute(:in, [], %{stack: [object, key | stack]} = frame, execution) do continue( @@ -628,19 +619,12 @@ defmodule QuickBEAM.VM.Interpreter do end end - defp execute(:and, [], frame, execution), do: bitwise(frame, execution, &band/2) - defp execute(:or, [], frame, execution), do: bitwise(frame, execution, &bor/2) - defp execute(:xor, [], frame, execution), do: bitwise(frame, execution, &bxor/2) - defp execute(:shl, [], frame, execution), do: binary(frame, execution, &Value.shift_left/2) - defp execute(:sar, [], frame, execution), do: binary(frame, execution, &Value.shift_right/2) - - defp execute(:shr, [], frame, execution), - do: binary(frame, execution, &Value.shift_right_unsigned/2) + defp execute(name, [], frame, execution) + when name in [:and, :or, :xor, :shl, :sar, :shr], + do: value_binary(frame, execution, name) - defp execute(:neg, [], frame, execution), do: unary(frame, execution, &Value.negate/1) - defp execute(:plus, [], frame, execution), do: unary(frame, execution, &Value.to_number/1) - defp execute(:not, [], frame, execution), do: unary(frame, execution, &Value.bitwise_not/1) - defp execute(:lnot, [], frame, execution), do: unary(frame, execution, &(not Value.truthy?(&1))) + defp execute(name, [], frame, execution) when name in [:neg, :plus, :not, :lnot], + do: value_unary(frame, execution, name) defp execute(:typeof, [], %{stack: [value | stack]} = frame, execution) do continue(%{frame | stack: [Invocation.typeof(value, execution) | stack]}, execution) @@ -654,17 +638,17 @@ defmodule QuickBEAM.VM.Interpreter do end defp execute(:typeof_is_undefined, [], frame, execution), - do: unary(frame, execution, &(&1 == :undefined)) + do: value_unary(frame, execution, :is_undefined) - defp execute(:inc, [], frame, execution), do: unary(frame, execution, &Value.add(&1, 1)) - defp execute(:dec, [], frame, execution), do: unary(frame, execution, &Value.subtract(&1, 1)) + defp execute(name, [], frame, execution) when name in [:inc, :dec], + do: value_unary(frame, execution, name) defp execute(:post_inc, [], %{stack: [value | stack]} = frame, execution) do - continue(%{frame | stack: [Value.add(value, 1), value | stack]}, execution) + continue(%{frame | stack: [Value.unary(:inc, value), value | stack]}, execution) end defp execute(:post_dec, [], %{stack: [value | stack]} = frame, execution) do - continue(%{frame | stack: [Value.subtract(value, 1), value | stack]}, execution) + continue(%{frame | stack: [Value.unary(:dec, value), value | stack]}, execution) end defp execute(:fclosure, [index], frame, execution) do @@ -1187,17 +1171,15 @@ defmodule QuickBEAM.VM.Interpreter do defp continue(frame, execution), do: run(next_frame(frame), execution) defp next_frame(frame), do: %{frame | pc: frame.pc + 1} - defp unary(%Frame{stack: [value | stack]} = frame, execution, operation), - do: continue(%{frame | stack: [operation.(value) | stack]}, execution) - - defp binary(%Frame{stack: [right, left | stack]} = frame, execution, operation), - do: continue(%{frame | stack: [operation.(left, right) | stack]}, execution) + defp value_unary(%Frame{stack: [value | stack]} = frame, execution, operation), + do: continue(%{frame | stack: [Value.unary(operation, value) | stack]}, execution) - defp compare(frame, execution, operation), - do: binary(frame, execution, &Value.compare(&1, &2, operation)) - - defp bitwise(frame, execution, operation), - do: binary(frame, execution, &Value.bitwise(&1, &2, operation)) + defp value_binary( + %Frame{stack: [right, left | stack]} = frame, + execution, + operation + ), + do: continue(%{frame | stack: [Value.binary(operation, left, right) | stack]}, execution) defp detach_async(frame, execution, awaited_promise) do case Async.detach_await(next_frame(frame), execution, awaited_promise) do diff --git a/lib/quickbeam/vm/properties.ex b/lib/quickbeam/vm/properties.ex index 5bc60398d..d1559ea3b 100644 --- a/lib/quickbeam/vm/properties.ex +++ b/lib/quickbeam/vm/properties.ex @@ -16,7 +16,7 @@ defmodule QuickBEAM.VM.Properties do Property, Reference, RegExp, - UTF16 + Value } @function_tags [ @@ -76,10 +76,10 @@ defmodule QuickBEAM.VM.Properties do end def get(object, "length", _execution) when is_binary(object), - do: {:ok, UTF16.length(object)} + do: {:ok, Value.string_length(object)} def get(object, key, _execution) when is_binary(object) and is_integer(key), - do: {:ok, UTF16.at(object, key)} + do: {:ok, Value.string_at(object, key)} def get(object, key, _execution) when is_binary(object) and is_binary(key), do: {:ok, {:primitive_method, :string, key}} diff --git a/lib/quickbeam/vm/value.ex b/lib/quickbeam/vm/value.ex index e0b12035e..e02523115 100644 --- a/lib/quickbeam/vm/value.ex +++ b/lib/quickbeam/vm/value.ex @@ -1,10 +1,18 @@ defmodule QuickBEAM.VM.Value do @moduledoc """ - Implements JavaScript primitive coercion, equality, and numeric operations. + 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.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 @@ -14,11 +22,15 @@ defmodule QuickBEAM.VM.Value do 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 @@ -33,11 +45,56 @@ defmodule QuickBEAM.VM.Value do 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, &Kernel./2) + def binary(:gte, left, right), do: compare(left, right, &Kernel.>=/2) + 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: numeric_binary(a, b, &Kernel.+/2) + + @doc "Implements numeric subtraction after JavaScript coercion." + @spec subtract(term(), term()) :: term() def subtract(a, b), do: numeric_binary(a, b, &Kernel.-/2) + + @doc "Implements numeric multiplication after JavaScript coercion." + @spec multiply(term(), term()) :: term() def multiply(a, b), do: numeric_binary(a, b, &Kernel.*/2) + @doc "Implements numeric division with represented JavaScript infinities and NaN." + @spec divide(term(), term()) :: term() def divide(a, b) do a = to_number(a) b = to_number(b) @@ -51,6 +108,8 @@ defmodule QuickBEAM.VM.Value do end end + @doc "Implements numeric remainder after JavaScript coercion." + @spec modulo(term(), term()) :: term() def modulo(a, b) do a = to_number(a) b = to_number(b) @@ -62,6 +121,8 @@ defmodule QuickBEAM.VM.Value do end end + @doc "Implements numeric exponentiation after JavaScript coercion." + @spec power(term(), term()) :: term() def power(a, b) do case {to_number(a), to_number(b)} do {:nan, _} -> :nan @@ -70,6 +131,8 @@ defmodule QuickBEAM.VM.Value do end end + @doc "Implements unary numeric negation." + @spec negate(term()) :: term() def negate(value) do case to_number(value) do :nan -> :nan @@ -77,6 +140,8 @@ defmodule QuickBEAM.VM.Value do end end + @doc "Compares strings lexically or other values numerically." + @spec compare(term(), term(), (term(), term() -> boolean())) :: boolean() def compare(a, b, operation) do {a, b} = if is_binary(a) and is_binary(b), @@ -90,12 +155,28 @@ defmodule QuickBEAM.VM.Value do 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" @@ -111,6 +192,8 @@ defmodule QuickBEAM.VM.Value do def typeof({:closure, %QuickBEAM.VM.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 @@ -138,6 +221,8 @@ defmodule QuickBEAM.VM.Value do 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) @@ -146,6 +231,8 @@ defmodule QuickBEAM.VM.Value do 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" @@ -158,6 +245,34 @@ defmodule QuickBEAM.VM.Value do 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 numeric_binary(a, b, operation) do case {to_number(a), to_number(b)} do {:nan, _} -> :nan diff --git a/test/vm/value_test.exs b/test/vm/value_test.exs new file mode 100644 index 000000000..e5abf97c8 --- /dev/null +++ b/test/vm/value_test.exs @@ -0,0 +1,62 @@ +defmodule QuickBEAM.VM.ValueTest do + use ExUnit.Case, async: true + + alias QuickBEAM.VM.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) + 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, 0, 0) == :nan + assert Value.binary(:mod, 7, 4) == 3 + assert Value.binary(:pow, 2, 3) == 8.0 + assert Value.binary(:lt, "10", "2") + assert Value.binary(:eq, "1", 1) + refute Value.binary(:strict_eq, "1", 1) + 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 From 6b24c4336b849e5f13a7418911d5b1eae4a0a3f7 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 13 Jul 2026 12:07:15 +0200 Subject: [PATCH 26/87] Split stack and value opcode families --- docs/beam-interpreter-architecture.md | 7 + docs/prototype-delta-audit.md | 15 +- lib/quickbeam/vm/interpreter.ex | 191 ++------------------------ lib/quickbeam/vm/opcodes/stack.ex | 126 +++++++++++++++++ lib/quickbeam/vm/opcodes/values.ex | 135 ++++++++++++++++++ test/vm/opcode_families_test.exs | 71 ++++++++++ 6 files changed, 364 insertions(+), 181 deletions(-) create mode 100644 lib/quickbeam/vm/opcodes/stack.ex create mode 100644 lib/quickbeam/vm/opcodes/values.ex create mode 100644 test/vm/opcode_families_test.exs diff --git a/docs/beam-interpreter-architecture.md b/docs/beam-interpreter-architecture.md index 9e2b35b2b..29312e403 100644 --- a/docs/beam-interpreter-architecture.md +++ b/docs/beam-interpreter-architecture.md @@ -369,6 +369,13 @@ bound, constructor, built-in, Promise, and host-function calls. It produces explicit call actions while the interpreter retains frame scheduling and boundary resumption. +Opcode-family modules transform explicit frames and return actions to the +interpreter rather than owning its run loop. `QuickBEAM.VM.Opcodes.Stack` now +handles literal and operand-stack operations, while +`QuickBEAM.VM.Opcodes.Values` handles coercion, arithmetic, comparisons, +bitwise operations, value tests, `in`, and `instanceof`. Their published opcode +lists are also the interpreter's routing source, preventing dispatch drift. + Recommended initial representation: - numbers — integer/float plus explicit non-finite sentinels where required; diff --git a/docs/prototype-delta-audit.md b/docs/prototype-delta-audit.md index 7fd1493d3..39cb95341 100644 --- a/docs/prototype-delta-audit.md +++ b/docs/prototype-delta-audit.md @@ -356,6 +356,15 @@ High-value test groups to adapt next: - Keep the current tests and SSR fixture green after each extraction. - Avoid reproducing the prototype's hundreds of overlapping modules. +### Interpreter decomposition (in progress) + +- Literal and operand-stack opcodes now live in one stack family module. +- Coercion, arithmetic, comparison, bitwise, value-test, `in`, and `instanceof` + opcodes now live in one value family module. +- Family-owned opcode lists are the interpreter's compile-time routing source. +- Continue with control flow, locals/closures, properties/objects, invocation, + and async opcode families without moving the run loop out of the interpreter. + ### Phase C — expand conformance by profile demand - Add sparse-array tests and hole-aware native callback frames. @@ -379,6 +388,6 @@ High-value test groups to adapt next: ## Immediate next action -With the canonical semantic foundations in place, split opcode-family dispatch -into smaller modules that delegate to properties, invocation, async, -exceptions, and values without duplicating their behavior. +Continue opcode-family decomposition with control flow and locals/closures, +keeping frame stepping, resource accounting, and action execution centralized +in the interpreter. diff --git a/lib/quickbeam/vm/interpreter.ex b/lib/quickbeam/vm/interpreter.ex index 72b99d5f7..648bd3332 100644 --- a/lib/quickbeam/vm/interpreter.ex +++ b/lib/quickbeam/vm/interpreter.ex @@ -40,9 +40,15 @@ defmodule QuickBEAM.VM.Interpreter do Value } + alias QuickBEAM.VM.Opcodes.Stack, as: StackOpcodes + alias QuickBEAM.VM.Opcodes.Values, as: ValueOpcodes + @default_max_steps 5_000_000 @default_max_stack_depth 1_000 + @stack_opcodes StackOpcodes.opcodes() + @value_opcodes ValueOpcodes.opcodes() + @type result :: {:ok, term()} | {:error, term()} @@ -222,28 +228,15 @@ defmodule QuickBEAM.VM.Interpreter do execute(name, operands, frame, execution) end - defp execute(:push_i32, [value], frame, execution), do: push(frame, execution, value) - defp execute(:push_i8, [value], frame, execution), do: push(frame, execution, value) - defp execute(:push_i16, [value], frame, execution), do: push(frame, execution, value) - defp execute(:undefined, [], frame, execution), do: push(frame, execution, :undefined) - defp execute(:null, [], frame, execution), do: push(frame, execution, nil) - defp execute(:push_false, [], frame, execution), do: push(frame, execution, false) - defp execute(:push_true, [], frame, execution), do: push(frame, execution, true) - - defp execute(:push_bigint_i32, [value], frame, execution), - do: push(frame, execution, {:bigint, value}) - - defp execute(:push_const, [index], frame, execution), - do: push(frame, execution, Enum.at(frame.function.constants, index)) + defp execute(name, operands, frame, execution) when name in @stack_opcodes, + do: name |> StackOpcodes.execute(operands, frame, execution) |> execute_opcode() - defp execute(:push_const8, [index], frame, execution), - do: execute(:push_const, [index], frame, execution) + defp execute(name, operands, frame, execution) when name in @value_opcodes, + do: name |> ValueOpcodes.execute(operands, frame, execution) |> execute_opcode() defp execute(:push_atom_value, [atom], frame, execution), do: push(frame, execution, resolve_atom(atom, execution)) - defp execute(:push_this, [], frame, execution), do: push(frame, execution, frame.this) - defp execute(:regexp, [], %{stack: [bytecode, source | stack]} = frame, execution) do push(%{frame | stack: stack}, execution, %RegExp{source: source, bytecode: bytecode}) end @@ -372,82 +365,6 @@ defmodule QuickBEAM.VM.Interpreter do end end - defp execute(:to_propkey, [], frame, execution), do: continue(frame, execution) - - defp execute(:to_object, [], %{stack: [value | _]} = frame, execution) - when value in [nil, :undefined], - do: raise_js({:type_error, :cannot_convert_to_object}, frame, execution) - - defp execute(:to_object, [], frame, execution), do: continue(frame, execution) - - defp execute(name, [], frame, execution) - when name in [:is_undefined_or_null, :is_undefined, :is_null], - do: value_unary(frame, execution, name) - - defp execute(:is_function, [], %{stack: [value | stack]} = frame, execution), - do: - continue( - %{frame | stack: [Invocation.typeof(value, execution) == "function" | stack]}, - execution - ) - - defp execute(:drop, [], %{stack: [_value | stack]} = frame, execution), - do: continue(%{frame | stack: stack}, execution) - - defp execute(:dup, [], %{stack: [value | _]} = frame, execution), - do: continue(%{frame | stack: [value | frame.stack]}, execution) - - defp execute(:dup1, [], %{stack: [a, b | stack]} = frame, execution), - do: continue(%{frame | stack: [a, b, b | stack]}, execution) - - defp execute(:dup2, [], %{stack: [a, b | stack]} = frame, execution), - do: continue(%{frame | stack: [a, b, a, b | stack]}, execution) - - defp execute(:dup3, [], %{stack: [a, b, c | stack]} = frame, execution), - do: continue(%{frame | stack: [a, b, c, a, b, c | stack]}, execution) - - defp execute(:nip, [], %{stack: [a, _b | stack]} = frame, execution), - do: continue(%{frame | stack: [a | stack]}, execution) - - defp execute(:nip_catch, [], frame, execution), - do: execute(:nip, [], frame, execution) - - defp execute(:nip1, [], %{stack: [a, b, _c | stack]} = frame, execution), - do: continue(%{frame | stack: [a, b | stack]}, execution) - - defp execute(:swap, [], %{stack: [a, b | stack]} = frame, execution), - do: continue(%{frame | stack: [b, a | stack]}, execution) - - defp execute(:swap2, [], %{stack: [a, b, c, d | stack]} = frame, execution), - do: continue(%{frame | stack: [c, d, a, b | stack]}, execution) - - defp execute(:perm3, [], %{stack: [a, b, c | stack]} = frame, execution), - do: continue(%{frame | stack: [a, c, b | stack]}, execution) - - defp execute(:perm4, [], %{stack: [a, b, c, d | stack]} = frame, execution), - do: continue(%{frame | stack: [a, c, d, b | stack]}, execution) - - defp execute(:perm5, [], %{stack: [a, b, c, d, e | stack]} = frame, execution), - do: continue(%{frame | stack: [a, c, d, e, b | stack]}, execution) - - defp execute(:rot3l, [], %{stack: [a, b, c | stack]} = frame, execution), - do: continue(%{frame | stack: [c, a, b | stack]}, execution) - - defp execute(:rot3r, [], %{stack: [a, b, c | stack]} = frame, execution), - do: continue(%{frame | stack: [b, c, a | stack]}, execution) - - defp execute(:rot4l, [], %{stack: [a, b, c, d | stack]} = frame, execution), - do: continue(%{frame | stack: [d, a, b, c | stack]}, execution) - - defp execute(:rot5l, [], %{stack: [a, b, c, d, e | stack]} = frame, execution), - do: continue(%{frame | stack: [e, a, b, c, d | stack]}, execution) - - defp execute(:insert2, [], %{stack: [a, b | stack]} = frame, execution), - do: continue(%{frame | stack: [a, b, a | stack]}, execution) - - defp execute(:insert3, [], %{stack: [a, b, c | stack]} = frame, execution), - do: continue(%{frame | stack: [a, b, c, a | stack]}, execution) - defp execute(:get_arg, [index], frame, execution), do: push(frame, execution, read_slot(tuple_get(frame.args, index), execution)) @@ -574,83 +491,6 @@ defmodule QuickBEAM.VM.Interpreter do defp execute(:return_async, [], %{stack: [value | _stack]}, execution), do: complete_async(value, execution) - defp execute(name, [], frame, execution) - when name in [ - :add, - :sub, - :mul, - :div, - :mod, - :pow, - :lt, - :lte, - :gt, - :gte, - :eq, - :neq, - :strict_eq, - :strict_neq - ], - do: value_binary(frame, execution, name) - - defp execute(:in, [], %{stack: [object, key | stack]} = frame, execution) do - continue( - %{frame | stack: [Properties.has_property?(object, key, execution) | stack]}, - execution - ) - end - - defp 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 - Properties.prototype_chain_contains?(object, prototype, execution) - - continue(%{frame | stack: [result | stack]}, execution) - else - _invalid -> raise_js({:type_error, :invalid_instanceof_target}, frame, execution) - end - end - - defp execute(name, [], frame, execution) - when name in [:and, :or, :xor, :shl, :sar, :shr], - do: value_binary(frame, execution, name) - - defp execute(name, [], frame, execution) when name in [:neg, :plus, :not, :lnot], - do: value_unary(frame, execution, name) - - defp execute(:typeof, [], %{stack: [value | stack]} = frame, execution) do - continue(%{frame | stack: [Invocation.typeof(value, execution) | stack]}, execution) - end - - defp execute(:typeof_is_function, [], %{stack: [value | stack]} = frame, execution) do - continue( - %{frame | stack: [Invocation.typeof(value, execution) == "function" | stack]}, - execution - ) - end - - defp execute(:typeof_is_undefined, [], frame, execution), - do: value_unary(frame, execution, :is_undefined) - - defp execute(name, [], frame, execution) when name in [:inc, :dec], - do: value_unary(frame, execution, name) - - defp execute(:post_inc, [], %{stack: [value | stack]} = frame, execution) do - continue(%{frame | stack: [Value.unary(:inc, value), value | stack]}, execution) - end - - defp execute(:post_dec, [], %{stack: [value | stack]} = frame, execution) do - continue(%{frame | stack: [Value.unary(:dec, value), value | stack]}, execution) - end - defp execute(:fclosure, [index], frame, execution) do function = Enum.at(frame.function.constants, index) {callable, frame, execution} = capture_closure(function, frame, execution) @@ -1171,15 +1011,10 @@ defmodule QuickBEAM.VM.Interpreter do defp continue(frame, execution), do: run(next_frame(frame), execution) defp next_frame(frame), do: %{frame | pc: frame.pc + 1} - defp value_unary(%Frame{stack: [value | stack]} = frame, execution, operation), - do: continue(%{frame | stack: [Value.unary(operation, value) | stack]}, execution) + defp execute_opcode({:next, frame, execution}), do: continue(frame, execution) - defp value_binary( - %Frame{stack: [right, left | stack]} = frame, - execution, - operation - ), - do: continue(%{frame | stack: [Value.binary(operation, left, right) | stack]}, execution) + defp execute_opcode({:throw, reason, frame, execution}), + do: raise_js(reason, frame, execution) defp detach_async(frame, execution, awaited_promise) do case Async.detach_await(next_frame(frame), execution, awaited_promise) do diff --git a/lib/quickbeam/vm/opcodes/stack.ex b/lib/quickbeam/vm/opcodes/stack.ex new file mode 100644 index 000000000..759577bb7 --- /dev/null +++ b/lib/quickbeam/vm/opcodes/stack.ex @@ -0,0 +1,126 @@ +defmodule QuickBEAM.VM.Opcodes.Stack do + @moduledoc """ + Executes literal and operand-stack QuickJS opcode families. + + The module only transforms explicit frames. It returns a `:next` action so the + interpreter remains the sole owner of instruction stepping and resource + accounting. + """ + + alias QuickBEAM.VM.{Execution, Frame} + + @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(), Execution.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(), Execution.t()) :: action() + def execute(name, [value], frame, execution) + when name in [:push_i32, :push_i8, :push_i16], + do: push(frame, execution, value) + + def execute(:push_bigint_i32, [value], frame, execution), + do: push(frame, execution, {:bigint, value}) + + def execute(:undefined, [], frame, execution), do: push(frame, execution, :undefined) + def execute(:null, [], frame, execution), do: push(frame, execution, nil) + def execute(:push_false, [], frame, execution), do: push(frame, execution, false) + def execute(:push_true, [], frame, execution), do: push(frame, execution, true) + def execute(:push_this, [], frame, execution), do: push(frame, execution, frame.this) + + def execute(name, [index], frame, execution) when name in [:push_const, :push_const8], + do: push(frame, execution, Enum.at(frame.function.constants, index)) + + def execute(:drop, [], %{stack: [_value | stack]} = frame, execution), + do: next(%{frame | stack: stack}, execution) + + def execute(:dup, [], %{stack: [value | _]} = frame, execution), + do: next(%{frame | stack: [value | frame.stack]}, execution) + + def execute(:dup1, [], %{stack: [a, b | stack]} = frame, execution), + do: next(%{frame | stack: [a, b, b | stack]}, execution) + + def execute(:dup2, [], %{stack: [a, b | stack]} = frame, execution), + do: next(%{frame | stack: [a, b, a, b | stack]}, execution) + + def execute(:dup3, [], %{stack: [a, b, c | stack]} = frame, execution), + do: next(%{frame | stack: [a, b, c, a, b, c | stack]}, execution) + + def execute(name, [], %{stack: [a, _b | stack]} = frame, execution) + when name in [:nip, :nip_catch], + do: next(%{frame | stack: [a | stack]}, execution) + + def execute(:nip1, [], %{stack: [a, b, _c | stack]} = frame, execution), + do: next(%{frame | stack: [a, b | stack]}, execution) + + def execute(:swap, [], %{stack: [a, b | stack]} = frame, execution), + do: next(%{frame | stack: [b, a | stack]}, execution) + + def execute(:swap2, [], %{stack: [a, b, c, d | stack]} = frame, execution), + do: next(%{frame | stack: [c, d, a, b | stack]}, execution) + + def execute(:perm3, [], %{stack: [a, b, c | stack]} = frame, execution), + do: next(%{frame | stack: [a, c, b | stack]}, execution) + + def execute(:perm4, [], %{stack: [a, b, c, d | stack]} = frame, execution), + do: next(%{frame | stack: [a, c, d, b | stack]}, execution) + + def execute(:perm5, [], %{stack: [a, b, c, d, e | stack]} = frame, execution), + do: next(%{frame | stack: [a, c, d, e, b | stack]}, execution) + + def execute(:rot3l, [], %{stack: [a, b, c | stack]} = frame, execution), + do: next(%{frame | stack: [c, a, b | stack]}, execution) + + def execute(:rot3r, [], %{stack: [a, b, c | stack]} = frame, execution), + do: next(%{frame | stack: [b, c, a | stack]}, execution) + + def execute(:rot4l, [], %{stack: [a, b, c, d | stack]} = frame, execution), + do: next(%{frame | stack: [d, a, b, c | stack]}, execution) + + def execute(:rot5l, [], %{stack: [a, b, c, d, e | stack]} = frame, execution), + do: next(%{frame | stack: [e, a, b, c, d | stack]}, execution) + + def execute(:insert2, [], %{stack: [a, b | stack]} = frame, execution), + do: next(%{frame | stack: [a, b, a | stack]}, execution) + + def execute(:insert3, [], %{stack: [a, b, c | stack]} = frame, execution), + do: next(%{frame | stack: [a, b, c, a | stack]}, execution) + + 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/opcodes/values.ex b/lib/quickbeam/vm/opcodes/values.ex new file mode 100644 index 000000000..bf67c354a --- /dev/null +++ b/lib/quickbeam/vm/opcodes/values.ex @@ -0,0 +1,135 @@ +defmodule QuickBEAM.VM.Opcodes.Values 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.{Execution, Frame, Invocation, Properties, Reference, 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_object, + :is_function, + :typeof, + :typeof_is_function, + :typeof_is_undefined, + :in, + :instanceof + ] + + @type action :: + {:next, Frame.t(), Execution.t()} + | {:throw, term(), Frame.t(), Execution.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(), Execution.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_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: [Properties.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 + Properties.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/test/vm/opcode_families_test.exs b/test/vm/opcode_families_test.exs new file mode 100644 index 000000000..6237d50a6 --- /dev/null +++ b/test/vm/opcode_families_test.exs @@ -0,0 +1,71 @@ +defmodule QuickBEAM.VM.OpcodeFamiliesTest do + use ExUnit.Case, async: true + + alias QuickBEAM.VM.{Execution, Frame, Function, Heap, Properties} + alias QuickBEAM.VM.Opcodes.{Stack, Values} + + 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 "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} = Properties.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 + %Execution{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 From c16dba130f9ae00da0d6f60655f956c4e9ae2c5c Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 13 Jul 2026 12:15:26 +0200 Subject: [PATCH 27/87] Split control and local opcode families --- docs/beam-interpreter-architecture.md | 8 +- docs/prototype-delta-audit.md | 14 +- lib/quickbeam/vm/interpreter.ex | 375 +++----------------------- lib/quickbeam/vm/opcodes/control.ex | 100 +++++++ lib/quickbeam/vm/opcodes/locals.ex | 319 ++++++++++++++++++++++ test/vm/opcode_families_test.exs | 73 ++++- 6 files changed, 541 insertions(+), 348 deletions(-) create mode 100644 lib/quickbeam/vm/opcodes/control.ex create mode 100644 lib/quickbeam/vm/opcodes/locals.ex diff --git a/docs/beam-interpreter-architecture.md b/docs/beam-interpreter-architecture.md index 29312e403..411381104 100644 --- a/docs/beam-interpreter-architecture.md +++ b/docs/beam-interpreter-architecture.md @@ -373,8 +373,12 @@ Opcode-family modules transform explicit frames and return actions to the interpreter rather than owning its run loop. `QuickBEAM.VM.Opcodes.Stack` now handles literal and operand-stack operations, while `QuickBEAM.VM.Opcodes.Values` handles coercion, arithmetic, comparisons, -bitwise operations, value tests, `in`, and `instanceof`. Their published opcode -lists are also the interpreter's routing source, preventing dispatch drift. +bitwise operations, value tests, `in`, and `instanceof`. +`QuickBEAM.VM.Opcodes.Control` handles branches, catch markers, returns, throws, +and await classification. `QuickBEAM.VM.Opcodes.Locals` owns argument/local +slots, closure-cell promotion, globals, atom resolution, and function-closure +allocation. Their published opcode lists are also the interpreter's routing +source, preventing dispatch drift. Recommended initial representation: diff --git a/docs/prototype-delta-audit.md b/docs/prototype-delta-audit.md index 39cb95341..dba683d50 100644 --- a/docs/prototype-delta-audit.md +++ b/docs/prototype-delta-audit.md @@ -361,9 +361,13 @@ High-value test groups to adapt next: - Literal and operand-stack opcodes now live in one stack family module. - Coercion, arithmetic, comparison, bitwise, value-test, `in`, and `instanceof` opcodes now live in one value family module. +- Control-flow opcodes now classify branches, catches, returns, throws, and + awaits as explicit interpreter actions. +- Local and closure opcodes now own argument/local slots, closure cells, + globals, atom resolution, and function-closure allocation. - Family-owned opcode lists are the interpreter's compile-time routing source. -- Continue with control flow, locals/closures, properties/objects, invocation, - and async opcode families without moving the run loop out of the interpreter. +- Continue with properties/objects, invocation, and async opcode families + without moving the run loop out of the interpreter. ### Phase C — expand conformance by profile demand @@ -388,6 +392,6 @@ High-value test groups to adapt next: ## Immediate next action -Continue opcode-family decomposition with control flow and locals/closures, -keeping frame stepping, resource accounting, and action execution centralized -in the interpreter. +Continue opcode-family decomposition with properties and object construction, +keeping accessor invocation, exception unwinding, frame stepping, and resource +accounting centralized through explicit actions. diff --git a/lib/quickbeam/vm/interpreter.ex b/lib/quickbeam/vm/interpreter.ex index 648bd3332..e06e05565 100644 --- a/lib/quickbeam/vm/interpreter.ex +++ b/lib/quickbeam/vm/interpreter.ex @@ -19,14 +19,12 @@ defmodule QuickBEAM.VM.Interpreter do Exceptions, Export, Frame, - Function, Heap, Invocation, Memory, NativeFrame, ObjectAssignBoundary, Opcodes, - PredefinedAtoms, Program, Properties, PromiseExecutorBoundary, @@ -40,12 +38,16 @@ defmodule QuickBEAM.VM.Interpreter do Value } + alias QuickBEAM.VM.Opcodes.Control, as: ControlOpcodes + alias QuickBEAM.VM.Opcodes.Locals, as: LocalOpcodes alias QuickBEAM.VM.Opcodes.Stack, as: StackOpcodes alias QuickBEAM.VM.Opcodes.Values, as: ValueOpcodes @default_max_steps 5_000_000 @default_max_stack_depth 1_000 + @control_opcodes ControlOpcodes.opcodes() + @local_opcodes LocalOpcodes.opcodes() @stack_opcodes StackOpcodes.opcodes() @value_opcodes ValueOpcodes.opcodes() @@ -228,6 +230,12 @@ defmodule QuickBEAM.VM.Interpreter do 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 @local_opcodes, + do: name |> LocalOpcodes.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() @@ -365,73 +373,6 @@ defmodule QuickBEAM.VM.Interpreter do end end - defp execute(:get_arg, [index], frame, execution), - do: push(frame, execution, read_slot(tuple_get(frame.args, index), execution)) - - defp execute(:put_arg, [index], %{stack: [value | stack]} = frame, execution) do - {args, execution} = write_tuple_slot(frame.args, index, value, execution) - continue(%{frame | args: args, stack: stack}, execution) - end - - defp execute(:set_arg, [index], %{stack: [value | _]} = frame, execution) do - {args, execution} = write_tuple_slot(frame.args, index, value, execution) - continue(%{frame | args: args}, execution) - end - - defp execute(:get_loc, [index], frame, execution), - do: push(frame, execution, read_slot(elem(frame.locals, index), execution)) - - defp execute(:get_loc0_loc1, [first, second], frame, execution) do - first = read_slot(elem(frame.locals, first), execution) - second = read_slot(elem(frame.locals, second), execution) - continue(%{frame | stack: [first, second | frame.stack]}, execution) - end - - defp execute(:put_loc, [index], %{stack: [value | stack]} = frame, execution) do - {locals, execution} = write_tuple_slot(frame.locals, index, value, execution) - continue(%{frame | locals: locals, stack: stack}, execution) - end - - defp execute(:set_loc, [index], %{stack: [value | _]} = frame, execution) do - {locals, execution} = write_tuple_slot(frame.locals, index, value, execution) - continue(%{frame | locals: locals}, execution) - end - - defp execute(:set_loc_uninitialized, [index], frame, execution) do - {locals, execution} = write_tuple_slot(frame.locals, index, :uninitialized, execution) - continue(%{frame | locals: locals}, execution) - end - - defp execute(:get_loc_check, [index], frame, execution) do - case read_slot(elem(frame.locals, index), execution) do - :uninitialized -> raise_js({:reference_error, index}, frame, execution) - value -> push(frame, execution, value) - end - end - - defp execute(:put_loc_check_init, [index], frame, execution), - do: execute(:put_loc, [index], frame, execution) - - defp execute(:put_loc_check, [index], frame, execution), - do: execute(:put_loc, [index], frame, execution) - - defp execute(:close_loc, [_index], frame, execution), do: continue(frame, execution) - - defp execute(:inc_loc, [index], frame, execution), - do: update_local(frame, execution, index, &Value.unary(:inc, &1)) - - defp execute(:dec_loc, [index], frame, execution), - do: update_local(frame, execution, index, &Value.unary(:dec, &1)) - - defp execute(:add_loc, [index], %{stack: [value | stack]} = frame, execution) do - current = read_slot(elem(frame.locals, index), execution) - - {locals, execution} = - write_tuple_slot(frame.locals, index, Value.binary(:add, current, value), execution) - - continue(%{frame | locals: locals, stack: stack}, execution) - end - defp execute(:for_in_start, [], %{stack: [object | stack]} = frame, execution) do case Properties.enumerable_keys(object, execution) do {:ok, keys} -> continue(%{frame | stack: [{:for_in, keys, 0} | stack]}, execution) @@ -448,59 +389,6 @@ defmodule QuickBEAM.VM.Interpreter do end end - defp execute(:catch, [target], frame, execution) do - continue(%{frame | stack: [{:catch, target} | frame.stack]}, execution) - end - - defp execute(:gosub, [target], frame, execution) do - return_address = {:return_address, frame.pc + 1} - run(%{frame | pc: target, stack: [return_address | frame.stack]}, execution) - end - - defp execute(:ret, [], %{stack: [{:return_address, target} | stack]} = frame, execution), - do: run(%{frame | pc: target, stack: stack}, execution) - - defp execute(:if_false, [target], %{stack: [value | stack]} = frame, execution) do - pc = if Value.truthy?(value), do: frame.pc + 1, else: target - run(%{frame | pc: pc, stack: stack}, execution) - end - - defp execute(:if_false8, [target], frame, execution), - do: execute(:if_false, [target], frame, execution) - - defp execute(:if_true, [target], %{stack: [value | stack]} = frame, execution) do - pc = if Value.truthy?(value), do: target, else: frame.pc + 1 - run(%{frame | pc: pc, stack: stack}, execution) - end - - defp execute(:if_true8, [target], frame, execution), - do: execute(:if_true, [target], frame, execution) - - defp execute(:goto, [target], frame, execution), do: run(%{frame | pc: target}, execution) - defp execute(:goto8, [target], frame, execution), do: execute(:goto, [target], frame, execution) - - defp execute(:goto16, [target], frame, execution), - do: execute(:goto, [target], frame, execution) - - defp execute(:return, [], %{stack: [value | _stack]}, execution), - do: return_value(value, execution) - - defp execute(:return_undef, [], _frame, execution), - do: return_value(:undefined, execution) - - defp execute(:return_async, [], %{stack: [value | _stack]}, execution), - do: complete_async(value, execution) - - defp execute(:fclosure, [index], frame, execution) do - function = Enum.at(frame.function.constants, index) - {callable, frame, execution} = capture_closure(function, frame, execution) - {reference, execution} = allocate_function(callable, function, execution) - push(frame, execution, reference) - end - - defp execute(:fclosure8, [index], frame, execution), - do: execute(:fclosure, [index], frame, execution) - defp execute(:call, [argument_count], frame, execution), do: call(frame, execution, argument_count, false) @@ -516,99 +404,8 @@ defmodule QuickBEAM.VM.Interpreter do defp execute(:call_constructor, [argument_count], frame, execution), do: call_constructor(frame, execution, argument_count) - defp execute(name, [index], frame, execution) - when name in [ - :get_var_ref, - :get_var_ref0, - :get_var_ref1, - :get_var_ref2, - :get_var_ref3, - :get_var_ref_check - ] do - value = read_reference(elem(frame.closure_refs, index), execution) - - if name == :get_var_ref_check and value == :uninitialized, - do: raise_js({:reference_error, index}, frame, execution), - else: push(frame, execution, value) - end - - defp execute(name, [index], %{stack: [value | stack]} = frame, execution) - when name in [ - :put_var_ref, - :put_var_ref0, - :put_var_ref1, - :put_var_ref2, - :put_var_ref3, - :put_var_ref_check, - :put_var_ref_check_init - ] do - execution = write_reference(elem(frame.closure_refs, index), value, execution) - continue(%{frame | stack: stack}, execution) - end - - defp execute(:set_var_ref, [index], %{stack: [value | _]} = frame, execution) do - execution = write_reference(elem(frame.closure_refs, index), value, execution) - continue(frame, execution) - end - - defp execute(:get_var, [atom], frame, execution) do - name = resolve_atom(atom, execution) - - case Map.fetch(execution.globals, name) do - {:ok, value} -> push(frame, execution, value) - :error -> raise_js({:reference_error, name}, frame, execution) - end - end - - defp execute(:get_var_undef, [atom], frame, execution) do - name = resolve_atom(atom, execution) - push(frame, execution, Map.get(execution.globals, name, :undefined)) - end - - defp 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 = %{execution | globals: Map.put(execution.globals, name, value)} - continue(%{frame | stack: stack}, execution) - end - - defp execute(name, [_atom | _flags], frame, execution) - when name in [:define_var, :check_define_var], - do: continue(frame, execution) - - defp execute(:throw, [], %{stack: [value | stack]} = frame, execution), - do: raise_js(value, %{frame | stack: stack}, execution) - - defp execute(:await, [], %{stack: [%PromiseReference{} = promise | stack]} = frame, execution) do - frame = %{frame | stack: stack} - - case detach_async(frame, execution, promise) do - {:ok, result} -> result - :no_async_boundary -> suspend_promise_legacy(frame, execution, promise) - end - end - - defp execute(:await, [], %{stack: [{:pending, reference} | stack]} = frame, execution) do - continuation = %Continuation{ - frame: next_frame(%{frame | stack: stack}), - execution: execution, - awaiting: reference - } - - {:suspended, continuation} - end - - defp execute(:await, [], %{stack: [{:resolved, value} | stack]} = frame, execution), - do: await_immediate({:ok, value}, %{frame | stack: stack}, execution) - - defp execute(:await, [], %{stack: [{:rejected, reason} | stack]} = frame, execution), - do: await_immediate({:error, reason}, %{frame | stack: stack}, execution) - - defp execute(:await, [], %{stack: [value | stack]} = frame, execution), - do: await_immediate({:ok, value}, %{frame | stack: stack}, execution) - defp execute(name, _operands, frame, execution) - when name in [:nop, :set_name, :set_name_computed, :check_ctor, :close_loc], + when name in [:nop, :set_name, :set_name_computed, :check_ctor], do: continue(frame, execution) defp execute(name, operands, _frame, execution), @@ -1012,10 +809,33 @@ defmodule QuickBEAM.VM.Interpreter do 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({: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 detach_async(frame, execution, awaited_promise) do case Async.detach_await(next_frame(frame), execution, awaited_promise) do {:ok, action} -> {:ok, execute_async(action)} @@ -1207,129 +1027,6 @@ defmodule QuickBEAM.VM.Interpreter do run(%{caller | stack: [value | caller.stack]}, execution) end - defp update_local(frame, execution, index, operation) do - value = read_slot(elem(frame.locals, index), execution) - {locals, execution} = write_tuple_slot(frame.locals, index, operation.(value), execution) - continue(%{frame | locals: locals}, execution) - 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} = - Properties.define(prototype, "constructor", reference, execution, - enumerable: false, - configurable: true - ) - - {:ok, execution} = - Properties.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_slot({:cell, _id} = reference, execution), - do: read_reference(reference, execution) - - defp read_slot({:global, _name} = reference, execution), - do: read_reference(reference, execution) - - defp read_slot(value, _execution), do: value - - 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 resolve_atom(:empty_string, _execution), do: "" - defp resolve_atom({:tagged_int, value}, _execution), do: value - - defp resolve_atom({:predefined, index}, _execution), - do: PredefinedAtoms.lookup(index) || {:predefined, index} - - defp 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 - - defp resolve_atom(value, _execution), do: value + defp read_slot(value, execution), do: LocalOpcodes.read_slot(value, execution) + defp resolve_atom(atom, execution), do: LocalOpcodes.resolve_atom(atom, execution) end diff --git a/lib/quickbeam/vm/opcodes/control.ex b/lib/quickbeam/vm/opcodes/control.ex new file mode 100644 index 000000000..37d8e9787 --- /dev/null +++ b/lib/quickbeam/vm/opcodes/control.ex @@ -0,0 +1,100 @@ +defmodule QuickBEAM.VM.Opcodes.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.{Execution, Frame, PromiseReference, 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(), Execution.t()} + | {:run, Frame.t(), Execution.t()} + | {:return, term(), Execution.t()} + | {:return_async, term(), Execution.t()} + | {:throw, term(), Frame.t(), Execution.t()} + | {:await_promise, PromiseReference.t(), Frame.t(), Execution.t()} + | {:await_legacy, reference(), Frame.t(), Execution.t()} + | {:await_immediate, {:ok, term()} | {:error, term()}, Frame.t(), Execution.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(), Execution.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/opcodes/locals.ex b/lib/quickbeam/vm/opcodes/locals.ex new file mode 100644 index 000000000..9eb8885ef --- /dev/null +++ b/lib/quickbeam/vm/opcodes/locals.ex @@ -0,0 +1,319 @@ +defmodule QuickBEAM.VM.Opcodes.Locals 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.{ + Execution, + Frame, + Function, + Heap, + Memory, + PredefinedAtoms, + Properties, + Value + } + + @get_reference_ops [ + :get_var_ref, + :get_var_ref0, + :get_var_ref1, + :get_var_ref2, + :get_var_ref3, + :get_var_ref_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 [ + :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(), Execution.t()} + | {:throw, term(), Frame.t(), Execution.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(), Execution.t()) :: action() + def execute(:get_arg, [index], frame, execution), + do: push(frame, execution, read_slot(tuple_get(frame.args, index), execution)) + + def execute(:put_arg, [index], %{stack: [value | stack]} = frame, execution) do + {args, execution} = write_tuple_slot(frame.args, index, value, execution) + next(%{frame | args: args, stack: stack}, execution) + end + + def execute(:set_arg, [index], %{stack: [value | _]} = frame, execution) do + {args, execution} = write_tuple_slot(frame.args, index, value, execution) + next(%{frame | args: args}, execution) + end + + def execute(:get_loc, [index], frame, execution), + do: push(frame, execution, read_slot(elem(frame.locals, index), execution)) + + def execute(:get_loc0_loc1, [first, second], frame, execution) do + first = read_slot(elem(frame.locals, first), execution) + second = read_slot(elem(frame.locals, second), execution) + next(%{frame | stack: [first, second | frame.stack]}, execution) + end + + def execute(:put_loc, [index], %{stack: [value | stack]} = frame, execution) do + {locals, execution} = write_tuple_slot(frame.locals, index, value, execution) + next(%{frame | locals: locals, stack: stack}, execution) + end + + def execute(:set_loc, [index], %{stack: [value | _]} = frame, execution) do + {locals, execution} = write_tuple_slot(frame.locals, index, value, execution) + next(%{frame | locals: locals}, execution) + end + + def execute(:set_loc_uninitialized, [index], frame, execution) do + {locals, execution} = write_tuple_slot(frame.locals, index, :uninitialized, execution) + next(%{frame | locals: locals}, 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(name, [index], frame, execution) when name in [:put_loc_check_init, :put_loc_check], + do: execute(:put_loc, [index], frame, execution) + + def execute(:close_loc, [_index], frame, execution), do: next(frame, execution) + + def execute(:inc_loc, [index], frame, execution), + do: update_local(frame, execution, index, &Value.unary(:inc, &1)) + + def execute(:dec_loc, [index], frame, execution), + do: update_local(frame, execution, index, &Value.unary(:dec, &1)) + + def execute(:add_loc, [index], %{stack: [value | stack]} = frame, execution) do + current = read_slot(elem(frame.locals, index), execution) + + {locals, execution} = + write_tuple_slot(frame.locals, index, Value.binary(:add, current, value), execution) + + next(%{frame | locals: locals, stack: stack}, execution) + end + + 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 Map.fetch(execution.globals, 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) + push(frame, execution, Map.get(execution.globals, name, :undefined)) + 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 = %{execution | globals: Map.put(execution.globals, 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) + {callable, frame, execution} = capture_closure(function, frame, execution) + {reference, execution} = allocate_function(callable, function, execution) + push(frame, execution, reference) + end + + @doc "Reads a direct value or owner-local cell/global slot." + @spec read_slot(term(), Execution.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(), Execution.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: PredefinedAtoms.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 + + defp update_local(frame, execution, index, operation) do + value = read_slot(elem(frame.locals, index), execution) + {locals, execution} = write_tuple_slot(frame.locals, index, operation.(value), execution) + next(%{frame | locals: locals}, execution) + 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} = + Properties.define(prototype, "constructor", reference, execution, + enumerable: false, + configurable: true + ) + + {:ok, execution} = + Properties.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/test/vm/opcode_families_test.exs b/test/vm/opcode_families_test.exs index 6237d50a6..220b26219 100644 --- a/test/vm/opcode_families_test.exs +++ b/test/vm/opcode_families_test.exs @@ -1,8 +1,16 @@ defmodule QuickBEAM.VM.OpcodeFamiliesTest do use ExUnit.Case, async: true - alias QuickBEAM.VM.{Execution, Frame, Function, Heap, Properties} - alias QuickBEAM.VM.Opcodes.{Stack, Values} + alias QuickBEAM.VM.{Execution, Frame, Function, Heap, Object, Properties} + alias QuickBEAM.VM.Opcodes.{Control, Locals, Stack, Values} + + test "opcode families publish non-overlapping routing tables" do + opcodes = + [Control, Locals, 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() @@ -37,6 +45,67 @@ defmodule QuickBEAM.VM.OpcodeFamiliesTest do 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 "value opcodes share canonical callable and prototype semantics" do execution = execution() function = %Function{id: 1, has_prototype: true} From 273f44883f1dd1004798075a78bcf1cfb0bebe6d Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 13 Jul 2026 12:54:34 +0200 Subject: [PATCH 28/87] Split object and property opcode family --- docs/beam-interpreter-architecture.md | 6 +- docs/prototype-delta-audit.md | 12 +- lib/quickbeam/vm/interpreter.ex | 216 +++----------------------- lib/quickbeam/vm/opcodes/locals.ex | 4 + lib/quickbeam/vm/opcodes/objects.ex | 207 ++++++++++++++++++++++++ test/vm/opcode_families_test.exs | 33 +++- 6 files changed, 278 insertions(+), 200 deletions(-) create mode 100644 lib/quickbeam/vm/opcodes/objects.ex diff --git a/docs/beam-interpreter-architecture.md b/docs/beam-interpreter-architecture.md index 411381104..9f197ad6d 100644 --- a/docs/beam-interpreter-architecture.md +++ b/docs/beam-interpreter-architecture.md @@ -377,8 +377,10 @@ bitwise operations, value tests, `in`, and `instanceof`. `QuickBEAM.VM.Opcodes.Control` handles branches, catch markers, returns, throws, and await classification. `QuickBEAM.VM.Opcodes.Locals` owns argument/local slots, closure-cell promotion, globals, atom resolution, and function-closure -allocation. Their published opcode lists are also the interpreter's routing -source, preventing dispatch drift. +allocation. `QuickBEAM.VM.Opcodes.Objects` handles object/array/RegExp +construction, field definition, property access, resumable accessors, deletion, +and `for...in` enumeration. Their published opcode lists are also the +interpreter's routing source, preventing dispatch drift. Recommended initial representation: diff --git a/docs/prototype-delta-audit.md b/docs/prototype-delta-audit.md index dba683d50..9794fbfe3 100644 --- a/docs/prototype-delta-audit.md +++ b/docs/prototype-delta-audit.md @@ -365,9 +365,11 @@ High-value test groups to adapt next: awaits as explicit interpreter actions. - Local and closure opcodes now own argument/local slots, closure cells, globals, atom resolution, and function-closure allocation. +- Object opcodes now own object/array/RegExp construction, field definitions, + property access, resumable accessor actions, deletion, and enumeration. - Family-owned opcode lists are the interpreter's compile-time routing source. -- Continue with properties/objects, invocation, and async opcode families - without moving the run loop out of the interpreter. +- Continue with invocation opcodes without moving the run loop out of the + interpreter. ### Phase C — expand conformance by profile demand @@ -392,6 +394,6 @@ High-value test groups to adapt next: ## Immediate next action -Continue opcode-family decomposition with properties and object construction, -keeping accessor invocation, exception unwinding, frame stepping, and resource -accounting centralized through explicit actions. +Finish opcode-family decomposition with ordinary, method, tail, and constructor +call opcodes while retaining invocation planning and frame scheduling in their +canonical layers. diff --git a/lib/quickbeam/vm/interpreter.ex b/lib/quickbeam/vm/interpreter.ex index e06e05565..427fc8d51 100644 --- a/lib/quickbeam/vm/interpreter.ex +++ b/lib/quickbeam/vm/interpreter.ex @@ -40,6 +40,7 @@ defmodule QuickBEAM.VM.Interpreter do alias QuickBEAM.VM.Opcodes.Control, as: ControlOpcodes alias QuickBEAM.VM.Opcodes.Locals, as: LocalOpcodes + alias QuickBEAM.VM.Opcodes.Objects, as: ObjectOpcodes alias QuickBEAM.VM.Opcodes.Stack, as: StackOpcodes alias QuickBEAM.VM.Opcodes.Values, as: ValueOpcodes @@ -48,6 +49,7 @@ defmodule QuickBEAM.VM.Interpreter do @control_opcodes ControlOpcodes.opcodes() @local_opcodes LocalOpcodes.opcodes() + @object_opcodes ObjectOpcodes.opcodes() @stack_opcodes StackOpcodes.opcodes() @value_opcodes ValueOpcodes.opcodes() @@ -236,159 +238,15 @@ defmodule QuickBEAM.VM.Interpreter do 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(:push_atom_value, [atom], frame, execution), - do: push(frame, execution, resolve_atom(atom, execution)) - - defp execute(:regexp, [], %{stack: [bytecode, source | stack]} = frame, execution) do - push(%{frame | stack: stack}, execution, %RegExp{source: source, bytecode: bytecode}) - end - - defp 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} = - Properties.define(arguments, index, read_slot(value, execution), execution) - - execution - end) - - push(frame, execution, arguments) - end - - defp execute(:special_object, [2], frame, execution), - do: push(frame, execution, frame.callable) - - defp execute(:special_object, [_type], frame, execution), - do: push(frame, execution, :undefined) - - defp execute(:object, [], frame, execution) do - {reference, execution} = Heap.allocate(execution) - push(frame, execution, reference) - end - - defp execute(:array_from, [count], frame, execution) do - {elements, stack} = Enum.split(frame.stack, count) - {reference, execution} = Heap.allocate(execution, :array) - - execution = - elements - |> Enum.reverse() - |> Enum.with_index() - |> Enum.reduce(execution, fn {value, index}, execution -> - {:ok, execution} = Properties.define(reference, index, value, execution) - execution - end) - - push(%{frame | stack: stack}, execution, reference) - end - - defp execute( - :define_method, - [atom, kind], - %{stack: [callable, %Reference{} = object | stack]} = frame, - execution - ) do - key = resolve_atom(atom, execution) - - result = - case kind do - 4 -> Properties.define(object, key, callable, execution) - 5 -> Properties.define_accessor(object, key, :getter, callable, execution) - 6 -> Properties.define_accessor(object, key, :setter, callable, execution) - _kind -> {:error, {:unsupported_method_kind, kind}} - end - - case result do - {:ok, execution} -> continue(%{frame | stack: [object | stack]}, execution) - {:error, reason} -> raise_js({:type_error, reason}, frame, execution) - end - end - - defp execute( - :define_field, - [atom], - %{stack: [value, %Reference{} = object | stack]} = frame, - execution - ) do - key = resolve_atom(atom, execution) - - case Properties.define(object, key, value, execution) do - {:ok, execution} -> continue(%{frame | stack: [object | stack]}, execution) - {:error, reason} -> raise_js({:type_error, reason}, frame, execution) - end - end - - defp execute(:get_field, [atom], %{stack: [object | stack]} = frame, execution) do - get_property_and_continue(object, resolve_atom(atom, execution), stack, frame, execution) - end - - defp execute(:get_field2, [atom], %{stack: [object | stack]} = frame, execution) do - get_property_and_continue( - object, - resolve_atom(atom, execution), - [object | stack], - frame, - execution - ) - end - - defp execute(:get_array_el, [], %{stack: [key, object | stack]} = frame, execution) do - get_property_and_continue(object, key, stack, frame, execution) - end - - defp execute(:get_length, [], %{stack: [object | stack]} = frame, execution) do - get_property_and_continue(object, "length", stack, frame, execution) - end - - defp execute(:put_field, [atom], %{stack: [value, object | stack]} = frame, execution) do - put_property_and_continue( - object, - resolve_atom(atom, execution), - value, - stack, - frame, - execution - ) - end - - defp execute(:put_array_el, [], %{stack: [value, key, object | stack]} = frame, execution) do - put_property_and_continue(object, key, value, stack, frame, execution) - end - - defp execute(:delete, [], %{stack: [key, %Reference{} = object | stack]} = frame, execution) do - case Properties.delete(object, key, execution) do - {:ok, deleted?, execution} -> continue(%{frame | stack: [deleted? | stack]}, execution) - {:error, reason} -> raise_js({:type_error, reason}, frame, execution) - end - end - - defp execute(:for_in_start, [], %{stack: [object | stack]} = frame, execution) do - case Properties.enumerable_keys(object, execution) do - {:ok, keys} -> continue(%{frame | stack: [{:for_in, keys, 0} | stack]}, execution) - {:error, reason} -> raise_js({:type_error, reason}, %{frame | stack: stack}, execution) - end - end - - defp execute(:for_in_next, [], %{stack: [{:for_in, keys, index} | stack]} = frame, execution) do - if index < length(keys) do - iterator = {:for_in, keys, index + 1} - continue(%{frame | stack: [false, Enum.at(keys, index), iterator | stack]}, execution) - else - continue(%{frame | stack: [true, :undefined, {:for_in, keys, index} | stack]}, execution) - end - end - defp execute(:call, [argument_count], frame, execution), do: call(frame, execution, argument_count, false) @@ -802,9 +660,6 @@ defmodule QuickBEAM.VM.Interpreter do defp interpreter_array_values(_value, _execution), do: {:error, :not_an_array} - defp push(frame, execution, value), - do: continue(%{frame | stack: [value | frame.stack]}, execution) - defp continue(frame, execution), do: run(next_frame(frame), execution) defp next_frame(frame), do: %{frame | pc: frame.pc + 1} @@ -836,6 +691,26 @@ defmodule QuickBEAM.VM.Interpreter do 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 = %AccessorBoundary{ + 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 = %AccessorBoundary{ + 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)} @@ -890,44 +765,6 @@ defmodule QuickBEAM.VM.Interpreter do end end - defp get_property_and_continue(object, key, stack, frame, execution) do - case Properties.get(object, key, execution) do - {:ok, {:accessor, getter, receiver}} -> - boundary = %AccessorBoundary{ - mode: :get, - caller: %{next_frame(frame) | stack: stack}, - depth: execution.depth - } - - dispatch_call(getter, [], receiver, boundary, execution, false) - - {:ok, value} -> - continue(%{frame | stack: [value | stack]}, execution) - - {:error, reason} -> - raise_js({:type_error, reason}, %{frame | stack: stack}, execution) - end - end - - defp put_property_and_continue(object, key, value, stack, frame, execution) do - case Properties.put(object, key, value, execution) do - {:ok, execution} -> - continue(%{frame | stack: stack}, execution) - - {:error, {:invoke_setter, setter}} -> - boundary = %AccessorBoundary{ - mode: :set, - caller: %{next_frame(frame) | stack: stack}, - depth: execution.depth - } - - dispatch_call(setter, [value], object, boundary, execution, false) - - {:error, reason} -> - raise_js({:type_error, reason}, %{frame | stack: stack}, execution) - end - end - defp raise_js(reason, frame, execution), do: reason |> Exceptions.throw_at(frame, execution) |> execute_exception() @@ -1026,7 +863,4 @@ defmodule QuickBEAM.VM.Interpreter do execution = %{execution | callers: callers, depth: execution.depth - 1} run(%{caller | stack: [value | caller.stack]}, execution) end - - defp read_slot(value, execution), do: LocalOpcodes.read_slot(value, execution) - defp resolve_atom(atom, execution), do: LocalOpcodes.resolve_atom(atom, execution) end diff --git a/lib/quickbeam/vm/opcodes/locals.ex b/lib/quickbeam/vm/opcodes/locals.ex index 9eb8885ef..76fc7e6c3 100644 --- a/lib/quickbeam/vm/opcodes/locals.ex +++ b/lib/quickbeam/vm/opcodes/locals.ex @@ -37,6 +37,7 @@ defmodule QuickBEAM.VM.Opcodes.Locals do ] @opcodes [ + :push_atom_value, :get_arg, :put_arg, :set_arg, @@ -74,6 +75,9 @@ defmodule QuickBEAM.VM.Opcodes.Locals do @doc "Executes one supported local, closure, argument, or global opcode." @spec execute(atom(), [term()], Frame.t(), Execution.t()) :: action() + def execute(:push_atom_value, [atom], frame, execution), + do: push(frame, execution, resolve_atom(atom, execution)) + def execute(:get_arg, [index], frame, execution), do: push(frame, execution, read_slot(tuple_get(frame.args, index), execution)) diff --git a/lib/quickbeam/vm/opcodes/objects.ex b/lib/quickbeam/vm/opcodes/objects.ex new file mode 100644 index 000000000..a4a02bd9c --- /dev/null +++ b/lib/quickbeam/vm/opcodes/objects.ex @@ -0,0 +1,207 @@ +defmodule QuickBEAM.VM.Opcodes.Objects do + @moduledoc """ + Executes object construction, property access, descriptors, and enumeration opcodes. + + Property behavior delegates to `QuickBEAM.VM.Properties`. Accessor-backed + operations return explicit invocation actions so the interpreter can preserve + resumable frames and JavaScript exception boundaries. + """ + + alias QuickBEAM.VM.{Execution, Frame, Heap, Properties, Reference, RegExp} + alias QuickBEAM.VM.Opcodes.Locals + + @opcodes [ + :regexp, + :special_object, + :object, + :array_from, + :define_method, + :define_field, + :get_field, + :get_field2, + :get_array_el, + :get_length, + :put_field, + :put_array_el, + :delete, + :for_in_start, + :for_in_next + ] + + @type action :: + {:next, Frame.t(), Execution.t()} + | {:throw, term(), Frame.t(), Execution.t()} + | {:invoke_getter, term(), term(), Frame.t(), Execution.t()} + | {:invoke_setter, term(), term(), term(), Frame.t(), Execution.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(), Execution.t()) :: action() + def execute(:regexp, [], %{stack: [bytecode, source | stack]} = frame, execution), + do: push(%{frame | stack: stack}, execution, %RegExp{source: source, bytecode: bytecode}) + + 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} = + Properties.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, [_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(execution, :array) + + execution = + elements + |> Enum.reverse() + |> Enum.with_index() + |> Enum.reduce(execution, fn {value, index}, execution -> + {:ok, execution} = Properties.define(reference, index, value, execution) + execution + end) + + push(%{frame | stack: stack}, execution, reference) + end + + def execute( + :define_method, + [atom, kind], + %{stack: [callable, %Reference{} = object | stack]} = frame, + execution + ) do + key = Locals.resolve_atom(atom, execution) + + result = + case kind do + 4 -> Properties.define(object, key, callable, execution) + 5 -> Properties.define_accessor(object, key, :getter, callable, execution) + 6 -> Properties.define_accessor(object, key, :setter, callable, execution) + _kind -> {:error, {:unsupported_method_kind, kind}} + end + + 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) + + case Properties.define(object, key, value, execution) do + {:ok, execution} -> next(%{frame | stack: [object | stack]}, execution) + {:error, reason} -> {:throw, {:type_error, reason}, 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 Properties.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 Properties.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 + + defp get(object, key, stack, frame, execution) do + frame = %{frame | stack: stack} + + case Properties.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 Properties.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/test/vm/opcode_families_test.exs b/test/vm/opcode_families_test.exs index 220b26219..5cde0a3f7 100644 --- a/test/vm/opcode_families_test.exs +++ b/test/vm/opcode_families_test.exs @@ -2,11 +2,11 @@ defmodule QuickBEAM.VM.OpcodeFamiliesTest do use ExUnit.Case, async: true alias QuickBEAM.VM.{Execution, Frame, Function, Heap, Object, Properties} - alias QuickBEAM.VM.Opcodes.{Control, Locals, Stack, Values} + alias QuickBEAM.VM.Opcodes.{Control, Locals, Objects, Stack, Values} test "opcode families publish non-overlapping routing tables" do opcodes = - [Control, Locals, Stack, Values] + [Control, Locals, Objects, Stack, Values] |> Enum.flat_map(& &1.opcodes()) assert length(opcodes) == MapSet.size(MapSet.new(opcodes)) @@ -106,6 +106,35 @@ defmodule QuickBEAM.VM.OpcodeFamiliesTest do Heap.fetch_object(execution, reference) 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} = Properties.define_accessor(object, "value", :getter, getter, execution) + {:ok, execution} = Properties.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} = Properties.get(array, 0, execution) + assert {:ok, 2} = Properties.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} From 44b1ac13a6f7180d808ba767f27c6e1b45a46d6d Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 13 Jul 2026 13:15:24 +0200 Subject: [PATCH 29/87] Split invocation opcode family --- docs/beam-interpreter-architecture.md | 6 +- docs/prototype-delta-audit.md | 14 ++-- lib/quickbeam/vm/interpreter.ex | 99 ++++++-------------------- lib/quickbeam/vm/opcodes/invocation.ex | 81 +++++++++++++++++++++ test/vm/opcode_families_test.exs | 47 +++++++++++- 5 files changed, 159 insertions(+), 88 deletions(-) create mode 100644 lib/quickbeam/vm/opcodes/invocation.ex diff --git a/docs/beam-interpreter-architecture.md b/docs/beam-interpreter-architecture.md index 9f197ad6d..3bf7db78c 100644 --- a/docs/beam-interpreter-architecture.md +++ b/docs/beam-interpreter-architecture.md @@ -379,8 +379,10 @@ and await classification. `QuickBEAM.VM.Opcodes.Locals` owns argument/local slots, closure-cell promotion, globals, atom resolution, and function-closure allocation. `QuickBEAM.VM.Opcodes.Objects` handles object/array/RegExp construction, field definition, property access, resumable accessors, deletion, -and `for...in` enumeration. Their published opcode lists are also the -interpreter's routing source, preventing dispatch drift. +and `for...in` enumeration. `QuickBEAM.VM.Opcodes.Invocation` decodes ordinary, +method, tail, and constructor call stacks into actions consumed by the canonical +invocation planner. Their published opcode lists are also the interpreter's +routing source, preventing dispatch drift. Recommended initial representation: diff --git a/docs/prototype-delta-audit.md b/docs/prototype-delta-audit.md index 9794fbfe3..d075861a8 100644 --- a/docs/prototype-delta-audit.md +++ b/docs/prototype-delta-audit.md @@ -356,7 +356,7 @@ High-value test groups to adapt next: - Keep the current tests and SSR fixture green after each extraction. - Avoid reproducing the prototype's hundreds of overlapping modules. -### Interpreter decomposition (in progress) +### Interpreter decomposition (complete) - Literal and operand-stack opcodes now live in one stack family module. - Coercion, arithmetic, comparison, bitwise, value-test, `in`, and `instanceof` @@ -367,9 +367,11 @@ High-value test groups to adapt next: globals, atom resolution, and function-closure allocation. - Object opcodes now own object/array/RegExp construction, field definitions, property access, resumable accessor actions, deletion, and enumeration. +- Invocation opcodes now decode ordinary, method, tail, and constructor call + stacks into explicit canonical invocation actions. - Family-owned opcode lists are the interpreter's compile-time routing source. -- Continue with invocation opcodes without moving the run loop out of the - interpreter. +- The interpreter now owns only routing, stepping, resource checks, action + execution, native callback frames, and boundary completion. ### Phase C — expand conformance by profile demand @@ -394,6 +396,6 @@ High-value test groups to adapt next: ## Immediate next action -Finish opcode-family decomposition with ordinary, method, tail, and constructor -call opcodes while retaining invocation planning and frame scheduling in their -canonical layers. +Proceed to Phase C with sparse-array hole behavior and hole-aware native +callback frames, then establish resumable iterator semantics for iterable +Promise combinators. diff --git a/lib/quickbeam/vm/interpreter.ex b/lib/quickbeam/vm/interpreter.ex index 427fc8d51..ee37b649d 100644 --- a/lib/quickbeam/vm/interpreter.ex +++ b/lib/quickbeam/vm/interpreter.ex @@ -39,6 +39,7 @@ defmodule QuickBEAM.VM.Interpreter do } alias QuickBEAM.VM.Opcodes.Control, as: ControlOpcodes + alias QuickBEAM.VM.Opcodes.Invocation, as: InvocationOpcodes alias QuickBEAM.VM.Opcodes.Locals, as: LocalOpcodes alias QuickBEAM.VM.Opcodes.Objects, as: ObjectOpcodes alias QuickBEAM.VM.Opcodes.Stack, as: StackOpcodes @@ -48,6 +49,7 @@ defmodule QuickBEAM.VM.Interpreter do @default_max_stack_depth 1_000 @control_opcodes ControlOpcodes.opcodes() + @invocation_opcodes InvocationOpcodes.opcodes() @local_opcodes LocalOpcodes.opcodes() @object_opcodes ObjectOpcodes.opcodes() @stack_opcodes StackOpcodes.opcodes() @@ -235,6 +237,9 @@ defmodule QuickBEAM.VM.Interpreter do 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() @@ -247,21 +252,6 @@ defmodule QuickBEAM.VM.Interpreter do defp execute(name, operands, frame, execution) when name in @value_opcodes, do: name |> ValueOpcodes.execute(operands, frame, execution) |> execute_opcode() - defp execute(:call, [argument_count], frame, execution), - do: call(frame, execution, argument_count, false) - - defp execute(:tail_call, [argument_count], frame, execution), - do: call(frame, execution, argument_count, true) - - defp execute(:call_method, [argument_count], frame, execution), - do: call_method(frame, execution, argument_count, false) - - defp execute(:tail_call_method, [argument_count], frame, execution), - do: call_method(frame, execution, argument_count, true) - - defp execute(:call_constructor, [argument_count], frame, execution), - do: call_constructor(frame, execution, argument_count) - defp execute(name, _operands, frame, execution) when name in [:nop, :set_name, :set_name_computed, :check_ctor], do: continue(frame, execution) @@ -269,70 +259,6 @@ defmodule QuickBEAM.VM.Interpreter do defp execute(name, operands, _frame, execution), do: {:error, {:unsupported_opcode, name, operands}, execution} - defp call(%Frame{stack: stack} = frame, execution, argument_count, tail?) do - {arguments, callable_and_rest} = Enum.split(stack, argument_count) - - case callable_and_rest do - [callable | rest] -> - caller = %{next_frame(frame) | stack: rest} - dispatch_call(callable, Enum.reverse(arguments), :undefined, caller, execution, tail?) - - _ -> - {:error, {:invalid_stack, :call}, execution} - end - end - - defp call_method(%Frame{stack: stack} = frame, execution, argument_count, tail?) do - {arguments, callable_and_this} = Enum.split(stack, argument_count) - - case callable_and_this do - [callable, this | rest] -> - caller = %{next_frame(frame) | stack: rest} - dispatch_call(callable, Enum.reverse(arguments), this, caller, execution, tail?) - - _ -> - {:error, {:invalid_stack, :call_method}, execution} - end - end - - defp call_constructor(%Frame{stack: stack} = frame, execution, argument_count) 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 - caller = %{next_frame(frame) | stack: rest} - prototype = Invocation.constructor_prototype(constructor, execution) - - {instance, execution} = - Heap.allocate(execution, :ordinary, - prototype: prototype, - internal: :constructor_instance - ) - - boundary = %ConstructorBoundary{ - instance: instance, - caller: caller, - depth: execution.depth - } - - dispatch_call( - constructor, - Enum.reverse(arguments), - instance, - boundary, - execution, - false - ) - else - raise_js({:type_error, :not_a_constructor}, frame, execution) - end - - _ -> - {:error, {:invalid_stack, :call_constructor}, execution} - end - end - defp dispatch_call(callable, arguments, this, caller, execution, tail?) do callable |> Invocation.plan(arguments, this, caller, execution, tail?) @@ -671,6 +597,21 @@ defmodule QuickBEAM.VM.Interpreter do 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 = %ConstructorBoundary{ + 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 diff --git a/lib/quickbeam/vm/opcodes/invocation.ex b/lib/quickbeam/vm/opcodes/invocation.ex new file mode 100644 index 000000000..9eee9e931 --- /dev/null +++ b/lib/quickbeam/vm/opcodes/invocation.ex @@ -0,0 +1,81 @@ +defmodule QuickBEAM.VM.Opcodes.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.{Execution, Frame, Heap, Invocation} + + @opcodes [:call, :tail_call, :call_method, :tail_call_method, :call_constructor] + + @type action :: + {:invoke, term(), [term()], term(), Frame.t(), Execution.t(), boolean()} + | {:invoke_constructor, term(), [term()], term(), Frame.t(), Execution.t()} + | {:throw, term(), Frame.t(), Execution.t()} + | {:error, term(), Execution.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(), Execution.t()) :: action() + 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(: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/test/vm/opcode_families_test.exs b/test/vm/opcode_families_test.exs index 5cde0a3f7..822a0f1cc 100644 --- a/test/vm/opcode_families_test.exs +++ b/test/vm/opcode_families_test.exs @@ -3,10 +3,11 @@ defmodule QuickBEAM.VM.OpcodeFamiliesTest do alias QuickBEAM.VM.{Execution, Frame, Function, Heap, Object, Properties} alias QuickBEAM.VM.Opcodes.{Control, Locals, Objects, Stack, Values} + alias QuickBEAM.VM.Opcodes.Invocation, as: CallOpcodes test "opcode families publish non-overlapping routing tables" do opcodes = - [Control, Locals, Objects, Stack, Values] + [CallOpcodes, Control, Locals, Objects, Stack, Values] |> Enum.flat_map(& &1.opcodes()) assert length(opcodes) == MapSet.size(MapSet.new(opcodes)) @@ -106,6 +107,50 @@ defmodule QuickBEAM.VM.OpcodeFamiliesTest do 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} = Properties.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) From de92f45f3ddc2db74681902737d5c206a3477442 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 13 Jul 2026 13:20:10 +0200 Subject: [PATCH 30/87] Preserve sparse array holes in native callbacks --- docs/beam-interpreter-architecture.md | 5 +- docs/prototype-delta-audit.md | 4 +- lib/quickbeam/vm/interpreter.ex | 93 +++++++++++++++++---------- test/vm/object_model_test.exs | 13 ++++ 4 files changed, 80 insertions(+), 35 deletions(-) diff --git a/docs/beam-interpreter-architecture.md b/docs/beam-interpreter-architecture.md index 3bf7db78c..2d618ad8b 100644 --- a/docs/beam-interpreter-architecture.md +++ b/docs/beam-interpreter-architecture.md @@ -403,7 +403,10 @@ WTF-8 binaries at the BEAM boundary, matching native QuickJS conversion. The owner-local heap enforces data and accessor descriptors across prototype chains, array length truncation and write restrictions, sparse deletion, ECMAScript own-key ordering, prototype-cycle rejection, constructor return -rules, and `instanceof`. Getter, setter, and `Object.assign` calls use resumable +rules, and `instanceof`. Native Array callback frames retain explicit holes: +`map` preserves their indices, while `filter`, `forEach`, `some`, and `reduce` +skip them; reduction without an initial value starts at the first present +entry. Getter, setter, and `Object.assign` calls use resumable boundaries so arbitrary JavaScript and exceptions do not escape the explicit machine state. Promise resolution reads accessor-backed `then` properties synchronously and queues invocation of returned then functions as microtasks. diff --git a/docs/prototype-delta-audit.md b/docs/prototype-delta-audit.md index d075861a8..4b8ff222a 100644 --- a/docs/prototype-delta-audit.md +++ b/docs/prototype-delta-audit.md @@ -375,7 +375,9 @@ High-value test groups to adapt next: ### Phase C — expand conformance by profile demand -- Add sparse-array tests and hole-aware native callback frames. +- Added differential sparse-array tests and hole-aware native callback frames; + callbacks skip holes, `map` preserves them, and `reduce` finds the first + present element when no initial value is supplied. - Add Symbol/iterator foundations and iterable Promise combinators. - Expand the pinned Test262 manifest with exact classified thresholds. - Add decoder mutation fuzzing. diff --git a/lib/quickbeam/vm/interpreter.ex b/lib/quickbeam/vm/interpreter.ex index ee37b649d..c3b041ed1 100644 --- a/lib/quickbeam/vm/interpreter.ex +++ b/lib/quickbeam/vm/interpreter.ex @@ -458,21 +458,12 @@ defmodule QuickBEAM.VM.Interpreter do tail?: tail? } - native = - if operation == :reduce do - case rest do - [initial | _] -> %{native | accumulator: initial} - [] when tuple_size(values) > 0 -> %{native | accumulator: elem(values, 0), index: 1} - [] -> native - end - else - native - end + case initialize_native(native, rest) do + {:ok, native} -> + invoke_native_next(native, execution) - if operation == :reduce and tuple_size(values) == 0 and rest == [] do - raise_js_from_caller({:type_error, :reduce_of_empty_array}, caller, execution) - else - 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) @@ -480,36 +471,66 @@ defmodule QuickBEAM.VM.Interpreter do end end + defp initialize_native(%NativeFrame{operation: :reduce} = native, [initial | _]), + do: {:ok, %{native | accumulator: initial}} + + defp initialize_native(%NativeFrame{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(%NativeFrame{} = 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(%NativeFrame{} = native, execution) when native.index >= tuple_size(native.values) do finish_native(native, native_result(native, execution), execution) end defp invoke_native_next(%NativeFrame{} = native, execution) do - value = elem(native.values, native.index) - - 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) + 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, %NativeFrame{} = native, execution) do - current = elem(native.values, native.index) + {:present, current} = elem(native.values, native.index) native = case native.operation do :map -> - %{native | index: native.index + 1, results: [value | native.results]} + %{native | index: native.index + 1, results: [{:present, value} | native.results]} :filter -> %{ native | index: native.index + 1, results: - if(Value.truthy?(value), do: [current | native.results], else: native.results) + if( + Value.truthy?(value), + do: [{:present, current} | native.results], + else: native.results + ) } :for_each -> @@ -546,21 +567,27 @@ defmodule QuickBEAM.VM.Interpreter do else: run(%{native.caller | stack: [value | native.caller.stack]}, execution) end - defp allocate_array(values, execution) do + defp allocate_array(entries, execution) do {array, execution} = Heap.allocate(execution, :array) execution = - values + entries |> Enum.with_index() - |> Enum.reduce(execution, fn {value, index}, execution -> - {:ok, execution} = Properties.define(array, index, value, execution) - execution + |> Enum.reduce(execution, fn + {{:present, value}, index}, execution -> + {:ok, execution} = Properties.define(array, index, value, execution) + execution + + {:hole, _index}, execution -> + execution end) + {:ok, execution} = Properties.define(array, "length", length(entries), execution) {:value, array, execution} end - defp interpreter_array_values(value, _execution) when is_list(value), do: {:ok, value} + 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 @@ -571,8 +598,8 @@ defmodule QuickBEAM.VM.Interpreter do else for index <- 0..(length - 1) do case Map.get(properties, index) do - %QuickBEAM.VM.Property{value: value} -> value - nil -> :undefined + %QuickBEAM.VM.Property{value: value} -> {:present, value} + nil -> :hole end end end diff --git a/test/vm/object_model_test.exs b/test/vm/object_model_test.exs index 1bae68c71..d2a661240 100644 --- a/test/vm/object_model_test.exs +++ b/test/vm/object_model_test.exs @@ -31,6 +31,19 @@ defmodule QuickBEAM.VM.ObjectModelTest do 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]})()" + ] + + 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]})()", From ba2f72abb72822c76cca20d3b3f9b568b2c63dd4 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 13 Jul 2026 13:29:41 +0200 Subject: [PATCH 31/87] Add declarative VM builtin DSL --- docs/beam-interpreter-architecture.md | 17 ++ docs/prototype-delta-audit.md | 20 ++- lib/quickbeam/vm/builtin.ex | 223 ++++++++++++++++++++++++++ lib/quickbeam/vm/builtin/call.ex | 21 +++ lib/quickbeam/vm/builtin/installer.ex | 140 ++++++++++++++++ lib/quickbeam/vm/builtin/registry.ex | 18 +++ lib/quickbeam/vm/builtin/spec.ex | 65 ++++++++ lib/quickbeam/vm/builtins.ex | 82 +++------- lib/quickbeam/vm/builtins/array.ex | 25 +++ lib/quickbeam/vm/builtins/math.ex | 72 +++++++++ lib/quickbeam/vm/invocation.ex | 32 +++- lib/quickbeam/vm/promise.ex | 1 + lib/quickbeam/vm/properties.ex | 1 + test/vm/builtin_dsl_test.exs | 51 ++++++ 14 files changed, 706 insertions(+), 62 deletions(-) create mode 100644 lib/quickbeam/vm/builtin.ex create mode 100644 lib/quickbeam/vm/builtin/call.ex create mode 100644 lib/quickbeam/vm/builtin/installer.ex create mode 100644 lib/quickbeam/vm/builtin/registry.ex create mode 100644 lib/quickbeam/vm/builtin/spec.ex create mode 100644 lib/quickbeam/vm/builtins/array.ex create mode 100644 lib/quickbeam/vm/builtins/math.ex create mode 100644 test/vm/builtin_dsl_test.exs diff --git a/docs/beam-interpreter-architecture.md b/docs/beam-interpreter-architecture.md index 2d618ad8b..5ad9ca7f3 100644 --- a/docs/beam-interpreter-architecture.md +++ b/docs/beam-interpreter-architecture.md @@ -411,6 +411,23 @@ boundaries so arbitrary JavaScript and exceptions do not escape the explicit machine state. Promise resolution reads accessor-backed `then` properties synchronously and queues invocation of returned then functions as microtasks. +## Declarative builtins + +Builtin modules use `QuickBEAM.VM.Builtin` to compile constructor, object, +extension, static, prototype, function-metadata, and descriptor declarations +into immutable specs. An explicit profile registry installs those specs in +dependency order into each owner-local execution through the canonical heap and +property layers. Runtime application-module discovery is forbidden. + +Installed functions are real owner-local function objects carrying stable +module/handler tokens, not captured closures. Calls receive an explicit +`QuickBEAM.VM.Builtin.Call` containing arguments, receiver, caller, tail mode, +and execution state, and are dispatched through the canonical invocation +planner. Compile-time checks reject missing handlers, invalid lengths, unknown +builtin kinds, and duplicate keys. `Math` and `Array.isArray` are the initial +migrated vertical slice; the legacy dispatcher remains only for builtins not yet +migrated. + ## ECMAScript and host profiles The first production profile should be explicit rather than implying browser diff --git a/docs/prototype-delta-audit.md b/docs/prototype-delta-audit.md index 4b8ff222a..2c3992428 100644 --- a/docs/prototype-delta-audit.md +++ b/docs/prototype-delta-audit.md @@ -373,6 +373,20 @@ High-value test groups to adapt next: - The interpreter now owns only routing, stepping, resource checks, action execution, native callback frames, and boundary completion. +### Declarative builtin migration (in progress) + +- Adapted the prototype's useful spec/installer idea without its process-local + heap, loaded-module discovery, giant multi-form macro surface, or generated + fallback dispatch. +- Builtin declarations now compile to immutable validated specs and install from + an explicit deterministic profile registry. +- Handlers use stable module/function tokens and explicit call contexts through + canonical invocation semantics. +- Migrated `Math` and `Array.isArray` as the first vertical slice, including real + function objects with descriptor, `name`, and `length` metadata. +- Continue migrating low-risk primitive builtins before resumable Object, + Promise, and Array callback methods. + ### Phase C — expand conformance by profile demand - Added differential sparse-array tests and hole-aware native callback frames; @@ -398,6 +412,6 @@ High-value test groups to adapt next: ## Immediate next action -Proceed to Phase C with sparse-array hole behavior and hole-aware native -callback frames, then establish resumable iterator semantics for iterable -Promise combinators. +Continue the declarative builtin migration with low-risk String and Object +statics, then exercise the DSL action contract on resumable Object and Array +methods before adding general iterator semantics. diff --git a/lib/quickbeam/vm/builtin.ex b/lib/quickbeam/vm/builtin.ex new file mode 100644 index 000000000..b22545667 --- /dev/null +++ b/lib/quickbeam/vm/builtin.ex @@ -0,0 +1,223 @@ +defmodule QuickBEAM.VM.Builtin do + @moduledoc """ + Provides the declarative DSL and runtime dispatcher for JavaScript builtins. + + Declarations compile into immutable `QuickBEAM.VM.Builtin.Spec` values. An + explicit registry installs those specs into each execution; no application + module discovery or process-global mutable state is used. + + defmodule MathBuiltin do + use QuickBEAM.VM.Builtin + + builtin "Math", kind: :object do + static "floor", :floor, length: 1 + end + + def floor(%QuickBEAM.VM.Builtin.Call{} = call), do: ... + end + """ + + alias QuickBEAM.VM.Builtin.{Call, FunctionSpec, PropertySpec, Spec} + + @type handler_result :: + {:ok, term(), QuickBEAM.VM.Execution.t()} + | {:error, term(), QuickBEAM.VM.Execution.t()} + | {:action, term()} + + @doc "Installs the builtin declaration macros in a module." + defmacro __using__(_opts) do + quote do + import QuickBEAM.VM.Builtin, + only: [ + builtin: 2, + builtin: 3, + prototype: 1, + static: 2, + static: 3, + method: 2, + method: 3, + static_value: 2, + static_value: 3, + prototype_value: 2, + prototype_value: 3 + ] + + Module.register_attribute(__MODULE__, :quickbeam_builtin_name, persist: false) + Module.register_attribute(__MODULE__, :quickbeam_builtin_options, persist: false) + Module.register_attribute(__MODULE__, :quickbeam_builtin_statics, accumulate: true) + Module.register_attribute(__MODULE__, :quickbeam_builtin_prototype, accumulate: true) + @before_compile QuickBEAM.VM.Builtin + end + end + + @doc "Declares a builtin and its static or prototype entries." + defmacro builtin(name, opts \\ [], do: block) do + quote do + @quickbeam_builtin_name unquote(name) + @quickbeam_builtin_options unquote(opts) + unquote(block) + end + end + + @doc "Groups prototype method or value declarations." + defmacro prototype(do: block), do: block + + @doc "Declares a static builtin function." + defmacro static(key, handler, opts \\ []) do + spec = function_spec(key, handler, opts) + + quote do + @quickbeam_builtin_statics unquote(Macro.escape(spec)) + end + end + + @doc "Declares a prototype builtin function." + defmacro method(key, handler, opts \\ []) do + spec = function_spec(key, handler, opts) + + quote do + @quickbeam_builtin_prototype unquote(Macro.escape(spec)) + end + end + + @doc "Declares a static data property." + defmacro static_value(key, value, opts \\ []) do + spec = property_spec(key, value, opts) + + quote do + @quickbeam_builtin_statics unquote(Macro.escape(spec)) + end + end + + @doc "Declares a prototype data property." + defmacro prototype_value(key, value, opts \\ []) do + spec = property_spec(key, value, opts) + + quote do + @quickbeam_builtin_prototype unquote(Macro.escape(spec)) + end + end + + @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 declarative builtin token to its module handler." + @spec invoke({:declared_builtin, module(), atom()}, Call.t()) :: handler_result() + def invoke({:declared_builtin, module, handler}, %Call{} = call), + do: apply(module, handler, [call]) + + @doc "Emits and validates a module's immutable builtin specification." + defmacro __before_compile__(env) do + 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() + + spec = %Spec{ + name: name, + module: env.module, + kind: Keyword.get(opts, :kind, :object), + constructor: Keyword.get(opts, :constructor), + profile: Keyword.get(opts, :profile, :core), + length: Keyword.get(opts, :length, 0), + statics: statics, + prototype: prototype + } + + 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 + + defp function_spec(key, handler, opts) do + %FunctionSpec{ + key: key, + 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 property_spec(key, value, opts) do + %PropertySpec{ + key: key, + value: value, + writable: Keyword.get(opts, :writable, false), + enumerable: Keyword.get(opts, :enumerable, false), + configurable: Keyword.get(opts, :configurable, false) + } + end + + defp validate!(%Spec{} = spec, env) do + unless is_binary(spec.name) and spec.name != "" do + compile_error!(env, "builtin name must be a non-empty string") + end + + unless spec.kind in [:object, :constructor, :extension] do + compile_error!(env, "unsupported builtin kind: #{inspect(spec.kind)}") + end + + unless is_integer(spec.length) and spec.length >= 0 do + compile_error!(env, "builtin length must be a non-negative integer") + end + + if spec.kind == :constructor and not is_atom(spec.constructor) do + compile_error!(env, "constructor builtins require a :constructor handler") + end + + entries = spec.statics ++ spec.prototype + duplicate_keys!(spec.statics, :static, env) + duplicate_keys!(spec.prototype, :prototype, env) + + handlers = + entries + |> Enum.filter(&is_struct(&1, FunctionSpec)) + |> Enum.map(& &1.handler) + |> then(fn handlers -> + if spec.constructor, do: [spec.constructor | handlers], else: handlers + end) + + Enum.each(handlers, fn handler -> + unless is_atom(handler) and Module.defines?(env.module, {handler, 1}, :def) do + compile_error!(env, "builtin handler #{inspect(handler)}/1 must be a public function") + end + end) + + Enum.each(entries, fn + %FunctionSpec{length: length} when is_integer(length) and length >= 0 -> + :ok + + %FunctionSpec{key: key} -> + compile_error!(env, "invalid function length for #{inspect(key)}") + + %PropertySpec{} -> + :ok + 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 compile_error!(env, description) do + raise CompileError, file: env.file, line: env.line, description: description + end +end diff --git a/lib/quickbeam/vm/builtin/call.ex b/lib/quickbeam/vm/builtin/call.ex new file mode 100644 index 000000000..572779a77 --- /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.Execution + + @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: Execution.t() + } +end diff --git a/lib/quickbeam/vm/builtin/installer.ex b/lib/quickbeam/vm/builtin/installer.ex new file mode 100644 index 000000000..42e1491d6 --- /dev/null +++ b/lib/quickbeam/vm/builtin/installer.ex @@ -0,0 +1,140 @@ +defmodule QuickBEAM.VM.Builtin.Installer do + @moduledoc """ + Installs declarative builtin specs into one owner-local VM execution. + + Installation is deterministic and threads `%QuickBEAM.VM.Execution{}` through + the canonical heap and property layers. Installed function objects carry + stable module/handler tokens rather than captured closures. + """ + + alias QuickBEAM.VM.Builtin.{FunctionSpec, PropertySpec, Spec} + alias QuickBEAM.VM.{Execution, Heap, Properties, Reference} + + @doc "Installs registered builtin modules for the selected profile." + @spec install_all(Execution.t(), [module()], atom()) :: Execution.t() + def install_all(execution, modules, profile \\ :core) do + specs = + modules + |> Enum.map(& &1.builtin_spec()) + |> Enum.filter(&(&1.profile == profile)) + + validate_registry!(specs) + Enum.reduce(specs, execution, &install(&2, &1)) + end + + @doc "Installs one immutable builtin specification." + @spec install(Execution.t(), Spec.t()) :: Execution.t() + def install(execution, %Spec{kind: :object} = 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: :extension} = 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) + {prototype, execution} = Heap.allocate(execution) + + {:ok, execution} = + Properties.define(prototype, "constructor", constructor, execution, + writable: true, + enumerable: false, + configurable: true + ) + + {:ok, execution} = + Properties.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) + put_global(execution, spec.name, constructor) + end + + defp install_prototype_entries(execution, _target, %Spec{prototype: []}), do: execution + + defp install_prototype_entries(execution, %Reference{} = constructor, spec) do + case Properties.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} = + Properties.define(target, spec.key, function, execution, + writable: spec.writable, + enumerable: spec.enumerable, + configurable: spec.configurable + ) + + execution + end + + defp install_entry(execution, target, _module, %PropertySpec{} = spec) do + {:ok, execution} = + Properties.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} = + Properties.define(function, "name", name, execution, + writable: false, + enumerable: false, + configurable: true + ) + + {:ok, execution} = + Properties.define(function, "length", length, execution, + writable: false, + enumerable: false, + configurable: true + ) + + {function, execution} + end + + defp put_global(execution, name, value), + do: %{execution | globals: Map.put(execution.globals, name, value)} + + defp validate_registry!(specs) 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 + end +end diff --git a/lib/quickbeam/vm/builtin/registry.ex b/lib/quickbeam/vm/builtin/registry.ex new file mode 100644 index 000000000..013c69288 --- /dev/null +++ b/lib/quickbeam/vm/builtin/registry.ex @@ -0,0 +1,18 @@ +defmodule QuickBEAM.VM.Builtin.Registry do + @moduledoc """ + Defines the explicit deterministic builtin registry for each VM profile. + + Modules are listed deliberately; runtime application-module discovery is not + used because it makes installation dependent on code-loading order. + """ + + @core [ + QuickBEAM.VM.Builtins.Math, + QuickBEAM.VM.Builtins.Array + ] + + @doc "Returns builtin modules installed for a profile in dependency order." + @spec modules(atom()) :: [module()] + def modules(:core), do: @core + def modules(_profile), do: [] +end diff --git a/lib/quickbeam/vm/builtin/spec.ex b/lib/quickbeam/vm/builtin/spec.ex new file mode 100644 index 000000000..6573f88ae --- /dev/null +++ b/lib/quickbeam/vm/builtin/spec.ex @@ -0,0 +1,65 @@ +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.{FunctionSpec, PropertySpec} + + @enforce_keys [:name, :module, :kind] + defstruct [ + :name, + :module, + :constructor, + :profile, + kind: :object, + length: 0, + statics: [], + prototype: [] + ] + + @type kind :: :object | :constructor | :extension + @type t :: %__MODULE__{ + name: String.t(), + module: module(), + kind: kind(), + constructor: atom() | nil, + profile: atom(), + length: non_neg_integer(), + statics: [FunctionSpec.t() | PropertySpec.t()], + prototype: [FunctionSpec.t() | PropertySpec.t()] + } +end + +defmodule QuickBEAM.VM.Builtin.FunctionSpec 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 + +defmodule QuickBEAM.VM.Builtin.PropertySpec 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/builtins.ex b/lib/quickbeam/vm/builtins.ex index 4bb9ad090..85953683b 100644 --- a/lib/quickbeam/vm/builtins.ex +++ b/lib/quickbeam/vm/builtins.ex @@ -14,10 +14,12 @@ defmodule QuickBEAM.VM.Builtins do Value } + alias QuickBEAM.VM.Builtin.{Installer, Registry} + @error_types ~w(Error EvalError RangeError ReferenceError SyntaxError TypeError URIError) @constructors %{ - "Array" => ["isArray"], + "Array" => [], "Boolean" => [], "Object" => [ "assign", @@ -31,7 +33,6 @@ defmodule QuickBEAM.VM.Builtins do ], "EvalError" => [], "Function" => [], - "Math" => ["floor", "max", "min", "pow", "random", "round"], "Number" => [], "RangeError" => [], "ReferenceError" => [], @@ -46,27 +47,30 @@ defmodule QuickBEAM.VM.Builtins do @spec install(Execution.t()) :: Execution.t() def install(execution) do - @constructors - |> Enum.sort_by(fn {name, _methods} -> - if name == "Object", do: {0, name}, else: {1, name} - end) - |> Enum.reduce(execution, fn {name, methods}, execution -> - {object, execution} = Heap.allocate(execution, :function, callable: {:builtin, name}) + execution = + @constructors + |> Enum.sort_by(fn {name, _methods} -> + if name == "Object", do: {0, name}, else: {1, name} + end) + |> Enum.reduce(execution, fn {name, methods}, execution -> + {object, execution} = Heap.allocate(execution, :function, callable: {:builtin, name}) - execution = - Enum.reduce(methods, execution, fn method, execution -> - {:ok, execution} = - Properties.define(object, method, {:builtin_method, name, method}, execution, - enumerable: false - ) + execution = + Enum.reduce(methods, execution, fn method, execution -> + {:ok, execution} = + Properties.define(object, method, {:builtin_method, name, method}, execution, + enumerable: false + ) - execution - end) + execution + end) - execution = maybe_install_prototype(name, object, execution) - %{execution | globals: Map.put_new(execution.globals, name, object)} - end) - |> link_constructor_prototypes() + execution = maybe_install_prototype(name, object, execution) + %{execution | globals: Map.put_new(execution.globals, name, object)} + end) + |> link_constructor_prototypes() + + Installer.install_all(execution, Registry.modules(:core)) end @spec callable(Execution.t(), Reference.t()) :: term() | nil @@ -98,9 +102,6 @@ defmodule QuickBEAM.VM.Builtins do @spec call(term(), term(), [term()], Execution.t()) :: {:ok, term(), Execution.t()} | {:error, term(), Execution.t()} - def call({:builtin_method, "Array", "isArray"}, _this, [value], execution), - do: {:ok, array?(value, execution), execution} - def call({:builtin_method, "Object", "keys"}, _this, [value], execution) do with {:ok, keys} <- own_keys(value, execution) do keys = Enum.map(keys, &Value.to_string_value/1) @@ -204,24 +205,6 @@ defmodule QuickBEAM.VM.Builtins do end) end - def call({:builtin_method, "Math", "floor"}, _this, [value], execution), - do: {:ok, value |> Value.to_number() |> floor_number(), execution} - - def call({:builtin_method, "Math", "round"}, _this, [value], execution), - do: {:ok, value |> Value.to_number() |> round_number(), execution} - - def call({:builtin_method, "Math", "random"}, _this, [], execution), - do: {:ok, 0.5, execution} - - def call({:builtin_method, "Math", "min"}, _this, values, execution), - do: {:ok, numeric_extreme(values, &min/2, :infinity), execution} - - def call({:builtin_method, "Math", "max"}, _this, values, execution), - do: {:ok, numeric_extreme(values, &max/2, :neg_infinity), execution} - - def call({:builtin_method, "Math", "pow"}, _this, [base, exponent | _], execution), - do: {:ok, Value.power(base, exponent), execution} - def call({:builtin_method, "String", "fromCharCode"}, _this, values, execution) do string = Value.string_from_char_codes(values) {:ok, string, execution} @@ -915,12 +898,6 @@ defmodule QuickBEAM.VM.Builtins do end end - defp array?(%Reference{} = reference, execution) do - match?({:ok, %Object{kind: :array}}, Heap.fetch_object(execution, reference)) - end - - defp array?(value, _execution), do: is_list(value) - defp array_from(values, execution) do {array, execution} = Heap.allocate(execution, :array) @@ -1059,15 +1036,4 @@ defmodule QuickBEAM.VM.Builtins do defp replace_string(value, pattern, replacement), do: String.replace(value, Value.to_string_value(pattern), replacement, global: false) - - defp floor_number(value) when is_number(value), do: floor(value) - defp floor_number(value), do: value - defp round_number(value) when is_number(value), do: round(value) - defp round_number(value), do: value - - defp numeric_extreme(values, operation, initial) do - Enum.reduce(values, initial, fn value, result -> - operation.(Value.to_number(value), result) - end) - end end diff --git a/lib/quickbeam/vm/builtins/array.ex b/lib/quickbeam/vm/builtins/array.ex new file mode 100644 index 000000000..de7d51ff5 --- /dev/null +++ b/lib/quickbeam/vm/builtins/array.ex @@ -0,0 +1,25 @@ +defmodule QuickBEAM.VM.Builtins.Array do + @moduledoc "Defines declarative additions to the core `Array` constructor." + + use QuickBEAM.VM.Builtin + + alias QuickBEAM.VM.Builtin.Call + alias QuickBEAM.VM.{Properties, Reference} + + builtin "Array", kind: :extension do + static("isArray", :is_array, length: 1) + end + + @doc "Implements `Array.isArray`." + def is_array(%Call{arguments: arguments, execution: execution}) do + value = List.first(arguments, :undefined) + + result = + case value do + %Reference{} = reference -> Properties.kind(reference, execution) == :array + value -> is_list(value) + end + + {:ok, result, execution} + end +end diff --git a/lib/quickbeam/vm/builtins/math.ex b/lib/quickbeam/vm/builtins/math.ex new file mode 100644 index 000000000..9c6ceb83f --- /dev/null +++ b/lib/quickbeam/vm/builtins/math.ex @@ -0,0 +1,72 @@ +defmodule QuickBEAM.VM.Builtins.Math do + @moduledoc "Defines the declarative core `Math` builtin object." + + use QuickBEAM.VM.Builtin + + alias QuickBEAM.VM.Builtin.Call + alias QuickBEAM.VM.Value + + builtin "Math", kind: :object do + static("floor", :floor_value, length: 1) + static("max", :max_value, length: 2) + static("min", :min_value, length: 2) + static("pow", :pow_value, length: 2) + static("random", :random_value, length: 0) + static("round", :round_value, 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/invocation.ex b/lib/quickbeam/vm/invocation.ex index 6c96baaab..e1b1b3f3f 100644 --- a/lib/quickbeam/vm/invocation.ex +++ b/lib/quickbeam/vm/invocation.ex @@ -9,6 +9,7 @@ defmodule QuickBEAM.VM.Invocation do """ alias QuickBEAM.VM.{ + Builtin, Builtins, ConstructorBoundary, Execution, @@ -22,7 +23,9 @@ defmodule QuickBEAM.VM.Invocation do Value } - @builtin_tags [:builtin, :builtin_method, :primitive_method] + alias QuickBEAM.VM.Builtin.Call + + @builtin_tags [:builtin, :builtin_method, :declared_builtin, :primitive_method] @error_constructors ~w(Error EvalError RangeError ReferenceError SyntaxError TypeError URIError) @type action :: @@ -42,6 +45,29 @@ defmodule QuickBEAM.VM.Invocation do 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, action} -> action + end + end + def plan({:builtin, "Promise"}, [executor | _], _this, caller, execution, tail?) do {promise, execution} = Promise.new(execution) @@ -236,6 +262,9 @@ defmodule QuickBEAM.VM.Invocation do 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?({:builtin, name}, _execution), do: name in (["Array", "Boolean", "Number", "Object", "Promise", "Set", "String"] ++ @@ -278,6 +307,7 @@ defmodule QuickBEAM.VM.Invocation do elem(value, 0) in [ :builtin, :builtin_method, + :declared_builtin, :bound_function, :function_method, :host_function, diff --git a/lib/quickbeam/vm/promise.ex b/lib/quickbeam/vm/promise.ex index 088529cce..f57f59bf8 100644 --- a/lib/quickbeam/vm/promise.ex +++ b/lib/quickbeam/vm/promise.ex @@ -295,6 +295,7 @@ defmodule QuickBEAM.VM.Promise do :bound_function, :builtin, :builtin_method, + :declared_builtin, :host_function, :primitive_method, :promise_method, diff --git a/lib/quickbeam/vm/properties.ex b/lib/quickbeam/vm/properties.ex index d1559ea3b..ded9546fa 100644 --- a/lib/quickbeam/vm/properties.ex +++ b/lib/quickbeam/vm/properties.ex @@ -22,6 +22,7 @@ defmodule QuickBEAM.VM.Properties do @function_tags [ :builtin, :builtin_method, + :declared_builtin, :bound_function, :function_method, :host_function, diff --git a/test/vm/builtin_dsl_test.exs b/test/vm/builtin_dsl_test.exs new file mode 100644 index 000000000..f6949ca4d --- /dev/null +++ b/test/vm/builtin_dsl_test.exs @@ -0,0 +1,51 @@ +defmodule QuickBEAM.VM.BuiltinDSLTest do + use ExUnit.Case, async: true + + alias QuickBEAM.VM.Builtin.{FunctionSpec, Registry} + + test "compiles declarative modules into immutable validated specs" do + math = QuickBEAM.VM.Builtins.Math.builtin_spec() + array = QuickBEAM.VM.Builtins.Array.builtin_spec() + + assert math.name == "Math" + assert math.kind == :object + assert math.profile == :core + assert Enum.map(math.statics, & &1.key) == ~w(floor max min pow random round) + assert Enum.all?(math.statics, &match?(%FunctionSpec{}, &1)) + + assert array.name == "Array" + assert array.kind == :extension + assert [%FunctionSpec{key: "isArray", handler: :is_array, length: 1}] = array.statics + + assert Registry.modules(:core) == [ + QuickBEAM.VM.Builtins.Math, + QuickBEAM.VM.Builtins.Array + ] + 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, + Array.isArray.name, + Array.isArray.length, + Array.isArray([]), + Array.isArray({}) + ] + """ + + assert {:ok, program} = QuickBEAM.VM.compile(source) + + assert {:ok, ["object", "floor", 1, 0, "isArray", 1, true, false]} = + 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 +end From c3ba2976ac95a14315cd0bfd4eab3840960c6a64 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 13 Jul 2026 13:36:53 +0200 Subject: [PATCH 32/87] Migrate resumable builtins to declarative DSL --- docs/beam-interpreter-architecture.md | 8 +- docs/prototype-delta-audit.md | 17 ++-- lib/quickbeam/vm/builtin/registry.ex | 4 +- lib/quickbeam/vm/builtins.ex | 128 +------------------------- lib/quickbeam/vm/builtins/array.ex | 33 +++++++ lib/quickbeam/vm/builtins/object.ex | 118 ++++++++++++++++++++++++ lib/quickbeam/vm/builtins/string.ex | 16 ++++ lib/quickbeam/vm/invocation.ex | 13 --- test/vm/builtin_dsl_test.exs | 52 ++++++++++- test/vm/memory_limit_test.exs | 4 +- 10 files changed, 238 insertions(+), 155 deletions(-) create mode 100644 lib/quickbeam/vm/builtins/object.ex create mode 100644 lib/quickbeam/vm/builtins/string.ex diff --git a/docs/beam-interpreter-architecture.md b/docs/beam-interpreter-architecture.md index 5ad9ca7f3..02b0a1d0c 100644 --- a/docs/beam-interpreter-architecture.md +++ b/docs/beam-interpreter-architecture.md @@ -424,9 +424,11 @@ module/handler tokens, not captured closures. Calls receive an explicit `QuickBEAM.VM.Builtin.Call` containing arguments, receiver, caller, tail mode, and execution state, and are dispatched through the canonical invocation planner. Compile-time checks reject missing handlers, invalid lengths, unknown -builtin kinds, and duplicate keys. `Math` and `Array.isArray` are the initial -migrated vertical slice; the legacy dispatcher remains only for builtins not yet -migrated. +builtin kinds, and duplicate keys. The migrated slice now includes `Math`, +`String.fromCharCode`, `Array.isArray`, resumable Array callback methods, and +low-risk plus resumable Object statics. The legacy dispatcher remains only for +builtins not yet migrated. Real function metadata increases the intrinsic heap +baseline, which remains included in logical memory accounting. ## ECMAScript and host profiles diff --git a/docs/prototype-delta-audit.md b/docs/prototype-delta-audit.md index 2c3992428..6a90326a8 100644 --- a/docs/prototype-delta-audit.md +++ b/docs/prototype-delta-audit.md @@ -382,10 +382,13 @@ High-value test groups to adapt next: an explicit deterministic profile registry. - Handlers use stable module/function tokens and explicit call contexts through canonical invocation semantics. -- Migrated `Math` and `Array.isArray` as the first vertical slice, including real - function objects with descriptor, `name`, and `length` metadata. -- Continue migrating low-risk primitive builtins before resumable Object, - Promise, and Array callback methods. +- Migrated `Math`, `String.fromCharCode`, `Array.isArray`, Array callback + methods, and selected Object statics with real function objects and descriptor, + `name`, and `length` metadata. +- `Object.assign` and Array callback methods prove that DSL handlers can return + canonical resumable actions without recursively invoking JavaScript. +- Continue with the remaining descriptor-heavy Object methods and low-risk + primitive methods before Promise constructors and combinators. ### Phase C — expand conformance by profile demand @@ -412,6 +415,6 @@ High-value test groups to adapt next: ## Immediate next action -Continue the declarative builtin migration with low-risk String and Object -statics, then exercise the DSL action contract on resumable Object and Array -methods before adding general iterator semantics. +Continue the declarative builtin migration with descriptor-heavy Object +methods and low-risk primitive methods, then migrate Promise construction and +combinators onto general resumable iterator semantics. diff --git a/lib/quickbeam/vm/builtin/registry.ex b/lib/quickbeam/vm/builtin/registry.ex index 013c69288..cc39824a5 100644 --- a/lib/quickbeam/vm/builtin/registry.ex +++ b/lib/quickbeam/vm/builtin/registry.ex @@ -8,7 +8,9 @@ defmodule QuickBEAM.VM.Builtin.Registry do @core [ QuickBEAM.VM.Builtins.Math, - QuickBEAM.VM.Builtins.Array + QuickBEAM.VM.Builtins.Array, + QuickBEAM.VM.Builtins.String, + QuickBEAM.VM.Builtins.Object ] @doc "Returns builtin modules installed for a profile in dependency order." diff --git a/lib/quickbeam/vm/builtins.ex b/lib/quickbeam/vm/builtins.ex index 85953683b..f88281108 100644 --- a/lib/quickbeam/vm/builtins.ex +++ b/lib/quickbeam/vm/builtins.ex @@ -21,22 +21,13 @@ defmodule QuickBEAM.VM.Builtins do @constructors %{ "Array" => [], "Boolean" => [], - "Object" => [ - "assign", - "create", - "defineProperty", - "getOwnPropertyDescriptor", - "getOwnPropertyNames", - "getPrototypeOf", - "keys", - "setPrototypeOf" - ], + "Object" => ["defineProperty", "getOwnPropertyDescriptor"], "EvalError" => [], "Function" => [], "Number" => [], "RangeError" => [], "ReferenceError" => [], - "String" => ["fromCharCode"], + "String" => [], "SyntaxError" => [], "TypeError" => [], "URIError" => [], @@ -102,25 +93,6 @@ defmodule QuickBEAM.VM.Builtins do @spec call(term(), term(), [term()], Execution.t()) :: {:ok, term(), Execution.t()} | {:error, term(), Execution.t()} - def call({:builtin_method, "Object", "keys"}, _this, [value], execution) do - with {:ok, keys} <- own_keys(value, execution) do - keys = Enum.map(keys, &Value.to_string_value/1) - {array, execution} = array_from(keys, execution) - {:ok, array, execution} - else - {:error, reason} -> {:error, reason, execution} - end - end - - def call({:builtin_method, "Object", "create"}, _this, [prototype], execution) - when is_nil(prototype) or is_struct(prototype, Reference) do - {object, execution} = Heap.allocate(execution, :ordinary, prototype: prototype) - {:ok, object, execution} - end - - def call({:builtin_method, "Object", "create"}, _this, [_prototype], execution), - do: {:error, :invalid_prototype, execution} - def call( {:builtin_method, "Object", "defineProperty"}, _this, @@ -155,61 +127,6 @@ defmodule QuickBEAM.VM.Builtins do end end - def call( - {:builtin_method, "Object", "getOwnPropertyNames"}, - _this, - [%Reference{} = target | _], - execution - ) do - case Properties.own_property_names(target, execution) do - {:ok, keys} -> - {array, execution} = array_from(keys, execution) - {:ok, array, execution} - - {:error, reason} -> - {:error, reason, execution} - end - end - - def call( - {:builtin_method, "Object", "getPrototypeOf"}, - _this, - [%Reference{} = target | _], - execution - ) do - case Properties.prototype(target, execution) do - {:ok, prototype} -> {:ok, prototype, execution} - {:error, reason} -> {:error, reason, execution} - end - end - - def call( - {:builtin_method, "Object", "setPrototypeOf"}, - _this, - [%Reference{} = target, prototype | _], - execution - ) - when is_nil(prototype) or is_struct(prototype, Reference) do - case Properties.set_prototype(target, prototype, execution) do - {:ok, execution} -> {:ok, target, execution} - {:error, reason} -> {:error, reason, execution} - end - end - - def call({:builtin_method, "Object", "assign"}, _this, [target | sources], execution) do - Enum.reduce_while(sources, {:ok, target, execution}, fn source, {:ok, target, execution} -> - case assign(target, source, execution) do - {:ok, execution} -> {:cont, {:ok, target, execution}} - {:error, reason} -> {:halt, {:error, reason, execution}} - end - end) - end - - def call({:builtin_method, "String", "fromCharCode"}, _this, values, execution) do - string = Value.string_from_char_codes(values) - {:ok, string, execution} - end - def call({:builtin_method, "Promise", method}, _this, [iterable | _], execution) when method in ["all", "allSettled", "any", "race"] do case array_values(iterable, execution) do @@ -670,7 +587,7 @@ defmodule QuickBEAM.VM.Builtins do install_primitive_prototype( constructor, :array, - ["concat", "filter", "forEach", "join", "map", "push", "reduce", "slice", "some"], + ["concat", "join", "push", "slice"], execution ) end @@ -912,45 +829,6 @@ defmodule QuickBEAM.VM.Builtins do {array, execution} end - defp own_keys(%Reference{} = reference, execution), - do: Properties.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, []} - - defp assign(%Reference{} = target, source, execution) do - with {:ok, keys} <- own_keys(source, execution) do - Enum.reduce_while(keys, {:ok, execution}, fn key, {:ok, execution} -> - with {:ok, value} <- property(source, key, execution), - {:ok, execution} <- Properties.put(target, key, value, execution) do - {:cont, {:ok, execution}} - else - {:error, reason} -> {:halt, {:error, reason}} - end - end) - end - end - - defp assign(_target, _source, _execution), do: {:error, :not_an_object} - - defp property(%Reference{} = reference, key, execution) do - case Properties.get(reference, key, execution) do - {:ok, {:accessor, _getter, _receiver}} -> {:error, :accessor_in_object_assign} - result -> result - end - end - - defp property(value, key, _execution) when is_map(value), - do: {:ok, Map.get(value, key, :undefined)} - - defp property(value, key, _execution) when is_list(value), - do: {:ok, Enum.at(value, key, :undefined)} - defp array_values(value, _execution) when is_list(value), do: {:ok, value} defp array_values(%Reference{} = reference, execution) do diff --git a/lib/quickbeam/vm/builtins/array.ex b/lib/quickbeam/vm/builtins/array.ex index de7d51ff5..a3c503880 100644 --- a/lib/quickbeam/vm/builtins/array.ex +++ b/lib/quickbeam/vm/builtins/array.ex @@ -8,6 +8,14 @@ defmodule QuickBEAM.VM.Builtins.Array do builtin "Array", kind: :extension do static("isArray", :is_array, length: 1) + + prototype do + method("filter", :filter, length: 1) + method("forEach", :for_each, length: 1) + method("map", :map, length: 1) + method("reduce", :reduce, length: 1) + method("some", :some, length: 1) + end end @doc "Implements `Array.isArray`." @@ -22,4 +30,29 @@ defmodule QuickBEAM.VM.Builtins.Array do {:ok, result, execution} 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) + + defp iteration_action(method, %Call{ + arguments: arguments, + this: receiver, + caller: caller, + tail?: tail?, + execution: execution + }) do + {:action, {:array_iteration, method, receiver, arguments, caller, execution, tail?}} + end end diff --git a/lib/quickbeam/vm/builtins/object.ex b/lib/quickbeam/vm/builtins/object.ex new file mode 100644 index 000000000..088b5e53b --- /dev/null +++ b/lib/quickbeam/vm/builtins/object.ex @@ -0,0 +1,118 @@ +defmodule QuickBEAM.VM.Builtins.Object do + @moduledoc "Defines declarative low-risk and resumable `Object` static methods." + + use QuickBEAM.VM.Builtin + + alias QuickBEAM.VM.Builtin.Call + alias QuickBEAM.VM.{Heap, Properties, Reference, Value} + + builtin "Object", kind: :extension do + static("assign", :assign, length: 2) + static("create", :create, length: 2) + static("getOwnPropertyNames", :get_own_property_names, length: 1) + static("getPrototypeOf", :get_prototype_of, length: 1) + static("keys", :keys, length: 1) + static("setPrototypeOf", :set_prototype_of, length: 2) + end + + @doc "Plans resumable `Object.assign` property reads and writes." + def assign(%Call{ + arguments: [%Reference{} = target | sources], + caller: caller, + tail?: tail?, + execution: execution + }), + do: {: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.getOwnPropertyNames`." + def get_own_property_names(%Call{ + arguments: [%Reference{} = target | _], + execution: execution + }) do + case Properties.own_property_names(target, execution) do + {:ok, keys} -> + {array, execution} = array_from(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 Properties.prototype(target, execution) do + {:ok, prototype} -> {:ok, prototype, execution} + {:error, reason} -> {:error, reason, execution} + end + 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 + with {:ok, keys} <- own_keys(value, execution) do + keys = Enum.map(keys, &Value.to_string_value/1) + {array, execution} = array_from(keys, execution) + {:ok, array, execution} + else + {: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 Properties.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 own_keys(%Reference{} = reference, execution), + do: Properties.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, []} + + defp array_from(values, execution) do + {array, execution} = Heap.allocate(execution, :array) + + execution = + values + |> Enum.with_index() + |> Enum.reduce(execution, fn {value, index}, execution -> + {:ok, execution} = Properties.define(array, index, value, execution) + execution + end) + + {array, execution} + end +end diff --git a/lib/quickbeam/vm/builtins/string.ex b/lib/quickbeam/vm/builtins/string.ex new file mode 100644 index 000000000..d1c6bdf33 --- /dev/null +++ b/lib/quickbeam/vm/builtins/string.ex @@ -0,0 +1,16 @@ +defmodule QuickBEAM.VM.Builtins.String do + @moduledoc "Defines declarative additions to the core `String` constructor." + + use QuickBEAM.VM.Builtin + + alias QuickBEAM.VM.Builtin.Call + alias QuickBEAM.VM.Value + + builtin "String", kind: :extension do + static("fromCharCode", :from_char_code, length: 1) + end + + @doc "Implements `String.fromCharCode`." + def from_char_code(%Call{arguments: values, execution: execution}), + do: {:ok, Value.string_from_char_codes(values), execution} +end diff --git a/lib/quickbeam/vm/invocation.ex b/lib/quickbeam/vm/invocation.ex index e1b1b3f3f..2bbee2766 100644 --- a/lib/quickbeam/vm/invocation.ex +++ b/lib/quickbeam/vm/invocation.ex @@ -210,19 +210,6 @@ defmodule QuickBEAM.VM.Invocation do end end - def plan( - {:builtin_method, "Object", "assign"}, - [%Reference{} = target | sources], - _this, - caller, - execution, - tail? - ), - do: {:object_assign, target, sources, caller, execution, tail?} - - def plan({:builtin_method, "Object", "assign"}, _arguments, _this, caller, execution, _tail?), - do: {:error, {:type_error, :not_an_object}, caller, execution} - def plan( {:primitive_method, :array, method}, arguments, diff --git a/test/vm/builtin_dsl_test.exs b/test/vm/builtin_dsl_test.exs index f6949ca4d..65166e3dd 100644 --- a/test/vm/builtin_dsl_test.exs +++ b/test/vm/builtin_dsl_test.exs @@ -16,11 +16,19 @@ defmodule QuickBEAM.VM.BuiltinDSLTest do assert array.name == "Array" assert array.kind == :extension assert [%FunctionSpec{key: "isArray", handler: :is_array, length: 1}] = array.statics + assert Enum.map(array.prototype, & &1.key) == ~w(filter forEach map reduce some) assert Registry.modules(:core) == [ QuickBEAM.VM.Builtins.Math, - QuickBEAM.VM.Builtins.Array + QuickBEAM.VM.Builtins.Array, + QuickBEAM.VM.Builtins.String, + QuickBEAM.VM.Builtins.Object ] + + assert QuickBEAM.VM.Builtins.String.builtin_spec().kind == :extension + + assert Enum.map(QuickBEAM.VM.Builtins.Object.builtin_spec().statics, & &1.key) == + ~w(assign create getOwnPropertyNames getPrototypeOf keys setPrototypeOf) end test "installs real function objects with stable names, lengths, and descriptors" do @@ -33,14 +41,50 @@ defmodule QuickBEAM.VM.BuiltinDSLTest do Array.isArray.name, Array.isArray.length, Array.isArray([]), - Array.isArray({}) + Array.isArray({}), + String.fromCharCode.name, + String.fromCharCode.length, + Object.assign.name, + Object.assign.length, + Array.prototype.map.name, + Array.prototype.map.length ] """ assert {:ok, program} = QuickBEAM.VM.compile(source) - assert {:ok, ["object", "floor", 1, 0, "isArray", 1, true, false]} = - QuickBEAM.VM.eval(program) + assert {:ok, + [ + "object", + "floor", + 1, + 0, + "isArray", + 1, + true, + false, + "fromCharCode", + 1, + "assign", + 2, + "map", + 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 handlers through the canonical invocation planner" do diff --git a/test/vm/memory_limit_test.exs b/test/vm/memory_limit_test.exs index be968b22d..7eb17db20 100644 --- a/test/vm/memory_limit_test.exs +++ b/test/vm/memory_limit_test.exs @@ -55,10 +55,10 @@ defmodule QuickBEAM.VM.MemoryLimitTest do Enum.to_list(1..5_000) end - assert {:error, {:limit_exceeded, :memory_bytes, 40_000}} = + assert {:error, {:limit_exceeded, :memory_bytes, 60_000}} = QuickBEAM.VM.eval(program, handlers: %{"large_result" => handler}, - memory_limit: 40_000, + memory_limit: 60_000, timeout: 5_000 ) From c8e5ff72d864604b647b5e49d4f91512325639d7 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 13 Jul 2026 13:47:37 +0200 Subject: [PATCH 33/87] Migrate Object and Array builtins to DSL --- docs/beam-interpreter-architecture.md | 10 +- docs/prototype-delta-audit.md | 18 +- lib/quickbeam/vm/builtins.ex | 248 ++------------------------ lib/quickbeam/vm/builtins/array.ex | 154 +++++++++++++++- lib/quickbeam/vm/builtins/object.ex | 158 +++++++++++++++- test/vm/builtin_dsl_test.exs | 6 +- test/vm/object_model_test.exs | 4 +- 7 files changed, 350 insertions(+), 248 deletions(-) diff --git a/docs/beam-interpreter-architecture.md b/docs/beam-interpreter-architecture.md index 02b0a1d0c..925d66997 100644 --- a/docs/beam-interpreter-architecture.md +++ b/docs/beam-interpreter-architecture.md @@ -425,9 +425,13 @@ module/handler tokens, not captured closures. Calls receive an explicit and execution state, and are dispatched through the canonical invocation planner. Compile-time checks reject missing handlers, invalid lengths, unknown builtin kinds, and duplicate keys. The migrated slice now includes `Math`, -`String.fromCharCode`, `Array.isArray`, resumable Array callback methods, and -low-risk plus resumable Object statics. The legacy dispatcher remains only for -builtins not yet migrated. Real function metadata increases the intrinsic heap +`String.fromCharCode`, every currently supported Array prototype method, +`Array.isArray`, and all currently supported Object statics, including +resumable callbacks, `Object.assign`, and descriptor validation. A narrow +primitive-array compatibility adapter remains for internal BEAM lists until all +JavaScript-visible aggregate results use heap arrays. The legacy dispatcher +otherwise remains only for builtins not yet migrated. Real function metadata +increases the intrinsic heap baseline, which remains included in logical memory accounting. ## ECMAScript and host profiles diff --git a/docs/prototype-delta-audit.md b/docs/prototype-delta-audit.md index 6a90326a8..d99af48a3 100644 --- a/docs/prototype-delta-audit.md +++ b/docs/prototype-delta-audit.md @@ -382,13 +382,15 @@ High-value test groups to adapt next: an explicit deterministic profile registry. - Handlers use stable module/function tokens and explicit call contexts through canonical invocation semantics. -- Migrated `Math`, `String.fromCharCode`, `Array.isArray`, Array callback - methods, and selected Object statics with real function objects and descriptor, - `name`, and `length` metadata. +- Migrated `Math`, `String.fromCharCode`, `Array.isArray`, all currently + supported Array prototype methods, and all currently supported Object statics + with real function objects and descriptor, `name`, and `length` metadata. - `Object.assign` and Array callback methods prove that DSL handlers can return canonical resumable actions without recursively invoking JavaScript. -- Continue with the remaining descriptor-heavy Object methods and low-risk - primitive methods before Promise constructors and combinators. +- Descriptor-heavy Object methods now share canonical descriptor validation; + sparse `slice`/`concat` preserve holes and `join` handles holes/nullish values. +- Continue with low-risk String and Number primitive methods before Promise + constructors and combinators. ### Phase C — expand conformance by profile demand @@ -415,6 +417,6 @@ High-value test groups to adapt next: ## Immediate next action -Continue the declarative builtin migration with descriptor-heavy Object -methods and low-risk primitive methods, then migrate Promise construction and -combinators onto general resumable iterator semantics. +Continue the declarative builtin migration with String and Number primitive +methods, then migrate Promise construction and combinators onto general +resumable iterator semantics. diff --git a/lib/quickbeam/vm/builtins.ex b/lib/quickbeam/vm/builtins.ex index f88281108..66498c282 100644 --- a/lib/quickbeam/vm/builtins.ex +++ b/lib/quickbeam/vm/builtins.ex @@ -14,14 +14,14 @@ defmodule QuickBEAM.VM.Builtins do Value } - alias QuickBEAM.VM.Builtin.{Installer, Registry} + alias QuickBEAM.VM.Builtin.{Call, Installer, Registry} @error_types ~w(Error EvalError RangeError ReferenceError SyntaxError TypeError URIError) @constructors %{ "Array" => [], "Boolean" => [], - "Object" => ["defineProperty", "getOwnPropertyDescriptor"], + "Object" => [], "EvalError" => [], "Function" => [], "Number" => [], @@ -93,40 +93,6 @@ defmodule QuickBEAM.VM.Builtins do @spec call(term(), term(), [term()], Execution.t()) :: {:ok, term(), Execution.t()} | {:error, term(), Execution.t()} - def call( - {:builtin_method, "Object", "defineProperty"}, - _this, - [%Reference{} = target, key, descriptor | _], - execution - ) do - with {:ok, current} <- Properties.own_property(target, key, execution), - {:ok, definition} <- descriptor_definition(descriptor, current, execution), - {:ok, execution} <- define_property(execution, target, key, definition) do - {:ok, target, execution} - else - {:error, reason} -> {:error, reason, execution} - end - end - - def call( - {:builtin_method, "Object", "getOwnPropertyDescriptor"}, - _this, - [%Reference{} = target, key | _], - execution - ) do - case Properties.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 call({:builtin_method, "Promise", method}, _this, [iterable | _], execution) when method in ["all", "allSettled", "any", "race"] do case array_values(iterable, execution) do @@ -287,64 +253,23 @@ defmodule QuickBEAM.VM.Builtins do {:ok, array, execution} end - def call({:primitive_method, :regexp, "test"}, %RegExp{} = regexp, [value | _], execution), - do: {:ok, regex_match?(regexp, Value.to_string_value(value)), execution} - - def call({:primitive_method, :array, "push"}, %Reference{} = array, values, 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} = Properties.put(array, index, value, execution) - execution - end) - - {:ok, length + Kernel.length(values), execution} - - _not_array -> - {:error, :not_an_array, execution} - end - end - - def call({:primitive_method, :array, "join"}, value, arguments, execution) do - separator = - case arguments do - [separator | _] -> Value.to_string_value(separator) - [] -> "," - end + def call({:primitive_method, :array, method}, receiver, arguments, execution) + when method in ["concat", "join", "push", "slice"] do + handler = %{"concat" => :concat, "join" => :join, "push" => :push, "slice" => :slice}[method] - with {:ok, values} <- array_values(value, execution) do - {:ok, Enum.map_join(values, separator, &Value.to_string_value/1), execution} - else - {:error, reason} -> {:error, reason, execution} - end - end + call = %Call{ + arguments: arguments, + this: receiver, + caller: nil, + tail?: false, + execution: execution + } - def call({:primitive_method, :array, "slice"}, value, arguments, execution) do - with {:ok, values} <- array_values(value, execution) do - {start, length} = slice_range(length(values), arguments) - {array, execution} = array_from(Enum.slice(values, start, length), execution) - {:ok, array, execution} - else - {:error, reason} -> {:error, reason, execution} - end + apply(QuickBEAM.VM.Builtins.Array, handler, [call]) end - def call({:primitive_method, :array, "concat"}, value, arguments, execution) do - with {:ok, values} <- array_values(value, execution) do - values = - Enum.reduce(arguments, values, fn item, values -> - values ++ concat_values(item, execution) - end) - - {array, execution} = array_from(values, execution) - {:ok, array, execution} - else - {:error, reason} -> {:error, reason, 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({:builtin, "Boolean"}, %Reference{} = receiver, values, execution) do value = values |> List.first() |> Value.truthy?() @@ -587,7 +512,7 @@ defmodule QuickBEAM.VM.Builtins do install_primitive_prototype( constructor, :array, - ["concat", "join", "push", "slice"], + [], execution ) end @@ -652,140 +577,6 @@ defmodule QuickBEAM.VM.Builtins do 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 || %Property{writable: false, enumerable: false, configurable: false} - accessor? = getter? or setter? or (not value? and not writable? and accessor?(current)) - - {:ok, - if accessor? do - %Property{ - kind: :accessor, - value: :undefined, - writable: false, - enumerable: if(enumerable?, do: Value.truthy?(enumerable), else: current.enumerable), - configurable: - if(configurable?, do: Value.truthy?(configurable), else: current.configurable), - getter: if(getter?, do: getter, else: current.getter), - setter: if(setter?, do: setter, else: current.setter) - } - else - %Property{ - value: if(value?, do: value, else: current.value), - writable: if(writable?, do: Value.truthy?(writable), else: current.writable), - enumerable: if(enumerable?, do: Value.truthy?(enumerable), else: current.enumerable), - configurable: - if(configurable?, do: Value.truthy?(configurable), else: current.configurable) - } - end} - else - {:error, reason} -> {:error, reason} - end - end - - 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 callable_value?(value, execution), - do: {:ok, value}, - else: {:error, :accessor_not_callable} - end - - defp callable_value?(%Reference{} = reference, execution), - do: not is_nil(callable(execution, reference)) - - defp callable_value?(value, _execution) when is_tuple(value), - do: - elem(value, 0) in [ - :bound_function, - :builtin, - :builtin_method, - :host_function, - :primitive_method, - :promise_method, - :promise_resolver - ] - - defp callable_value?(_value, _execution), do: false - - defp accessor?(%Property{kind: :accessor}), do: true - defp accessor?(_property), do: false - - defp define_property(execution, target, key, %Property{} = property) do - if accessor?(property) do - Properties.define_descriptor(target, key, property, execution) - else - Properties.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 Properties.has_property?(descriptor, key, execution) do - case Properties.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 = - fields - |> Enum.reduce(execution, fn {key, value}, execution -> - {:ok, execution} = Properties.define(descriptor, key, value, execution) - execution - end) - - {descriptor, execution} - end - defp constructor_instance?(%Reference{} = receiver, execution) do match?( {:ok, %Object{internal: :constructor_instance}}, @@ -848,13 +639,6 @@ defmodule QuickBEAM.VM.Builtins do defp array_values(_value, _execution), do: {:error, :not_an_array} - defp concat_values(value, execution) do - case array_values(value, execution) do - {:ok, values} -> values - {:error, _} -> [value] - end - end - defp property_value(properties, index) do case Map.get(properties, index) do %Property{value: value} -> value diff --git a/lib/quickbeam/vm/builtins/array.ex b/lib/quickbeam/vm/builtins/array.ex index a3c503880..d0258debe 100644 --- a/lib/quickbeam/vm/builtins/array.ex +++ b/lib/quickbeam/vm/builtins/array.ex @@ -4,16 +4,20 @@ defmodule QuickBEAM.VM.Builtins.Array do use QuickBEAM.VM.Builtin alias QuickBEAM.VM.Builtin.Call - alias QuickBEAM.VM.{Properties, Reference} + alias QuickBEAM.VM.{Heap, Object, Properties, Property, Reference, Value} builtin "Array", kind: :extension do static("isArray", :is_array, length: 1) prototype do + method("concat", :concat, length: 1) method("filter", :filter, length: 1) method("forEach", :for_each, length: 1) + method("join", :join, length: 1) method("map", :map, length: 1) + method("push", :push, length: 1) method("reduce", :reduce, length: 1) + method("slice", :slice, length: 2) method("some", :some, length: 1) end end @@ -31,6 +35,72 @@ defmodule QuickBEAM.VM.Builtins.Array do {:ok, result, execution} end + @doc "Implements sparse `Array.prototype.concat`." + def concat(%Call{this: receiver, arguments: arguments, execution: execution}) do + with {:ok, entries} <- array_entries(receiver, execution) do + entries = Enum.reduce(arguments, entries, &(&2 ++ concat_entries(&1, execution))) + {array, execution} = array_from_entries(entries, execution) + {:ok, array, execution} + else + {: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 + + with {:ok, entries} <- array_entries(receiver, execution) do + 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} + else + {: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} = Properties.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 + with {:ok, entries} <- array_entries(receiver, execution) do + {start, length} = slice_range(length(entries), arguments) + {array, execution} = array_from_entries(Enum.slice(entries, start, length), execution) + {:ok, array, execution} + else + {:error, reason} -> {:error, reason, execution} + end + end + @doc "Plans resumable `Array.prototype.filter` iteration." def filter(%Call{} = call), do: iteration_action("filter", call) @@ -46,6 +116,88 @@ defmodule QuickBEAM.VM.Builtins.Array do @doc "Plans resumable `Array.prototype.some` iteration." def some(%Call{} = call), do: iteration_action("some", call) + 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, length: length, properties: properties}} -> + entries = + if length == 0 do + [] + else + for index <- 0..(length - 1) do + case Map.get(properties, index) do + %Property{value: value} -> {:present, value} + nil -> :hole + end + end + end + + {:ok, entries} + + _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 + + defp array_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} = Properties.define(array, index, value, execution) + execution + + {:hole, _index}, execution -> + execution + end) + + {:ok, execution} = Properties.define(array, "length", length(entries), execution) + {array, execution} + end + + defp slice_range(size, arguments) do + start = + case arguments do + [start | _] -> normalize_slice_index(start, size) + [] -> 0 + end + + finish = + case arguments do + [_start, finish | _] -> normalize_slice_index(finish, size) + _ -> size + end + + {start, max(finish - start, 0)} + end + + defp normalize_slice_index(value, size) do + case Value.to_number(value) do + :infinity -> size + :neg_infinity -> 0 + :nan -> 0 + index when is_number(index) -> normalize_index(trunc(index), size) + _value -> 0 + end + end + + defp normalize_index(index, size) when index < 0, do: max(size + index, 0) + defp normalize_index(index, size), do: min(index, size) + defp iteration_action(method, %Call{ arguments: arguments, this: receiver, diff --git a/lib/quickbeam/vm/builtins/object.ex b/lib/quickbeam/vm/builtins/object.ex index 088b5e53b..01ae542f7 100644 --- a/lib/quickbeam/vm/builtins/object.ex +++ b/lib/quickbeam/vm/builtins/object.ex @@ -4,11 +4,13 @@ defmodule QuickBEAM.VM.Builtins.Object do use QuickBEAM.VM.Builtin alias QuickBEAM.VM.Builtin.Call - alias QuickBEAM.VM.{Heap, Properties, Reference, Value} + alias QuickBEAM.VM.{Heap, Invocation, Properties, Property, Reference, Value} builtin "Object", kind: :extension do static("assign", :assign, length: 2) static("create", :create, length: 2) + static("defineProperty", :define_property, length: 3) + static("getOwnPropertyDescriptor", :get_own_property_descriptor, length: 2) static("getOwnPropertyNames", :get_own_property_names, length: 1) static("getPrototypeOf", :get_prototype_of, length: 1) static("keys", :keys, length: 1) @@ -35,6 +37,44 @@ defmodule QuickBEAM.VM.Builtins.Object do 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} <- Properties.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.getOwnPropertyDescriptor`." + def get_own_property_descriptor(%Call{ + arguments: [%Reference{} = target, key | _], + execution: execution + }) do + case Properties.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 | _], @@ -91,6 +131,122 @@ defmodule QuickBEAM.VM.Builtins.Object do def set_prototype_of(%Call{execution: execution}), do: {:error, :invalid_prototype, execution} + 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 || %Property{writable: false, enumerable: false, configurable: false} + accessor? = getter? or setter? or (not value? and not writable? and accessor?(current)) + + {:ok, + if accessor? do + %Property{ + kind: :accessor, + value: :undefined, + writable: false, + enumerable: if(enumerable?, do: Value.truthy?(enumerable), else: current.enumerable), + configurable: + if(configurable?, do: Value.truthy?(configurable), else: current.configurable), + getter: if(getter?, do: getter, else: current.getter), + setter: if(setter?, do: setter, else: current.setter) + } + else + %Property{ + value: if(value?, do: value, else: current.value), + writable: if(writable?, do: Value.truthy?(writable), else: current.writable), + enumerable: if(enumerable?, do: Value.truthy?(enumerable), else: current.enumerable), + configurable: + if(configurable?, do: Value.truthy?(configurable), else: current.configurable) + } + end} + else + {:error, reason} -> {:error, reason} + end + end + + 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?(%Property{kind: :accessor}), do: true + defp accessor?(_property), do: false + + defp apply_property_definition(execution, target, key, %Property{} = property) do + if accessor?(property) do + Properties.define_descriptor(target, key, property, execution) + else + Properties.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 Properties.has_property?(descriptor, key, execution) do + case Properties.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} = Properties.define(descriptor, key, value, execution) + execution + end) + + {descriptor, execution} + end + defp own_keys(%Reference{} = reference, execution), do: Properties.enumerable_keys(reference, execution) diff --git a/test/vm/builtin_dsl_test.exs b/test/vm/builtin_dsl_test.exs index 65166e3dd..ef43954fd 100644 --- a/test/vm/builtin_dsl_test.exs +++ b/test/vm/builtin_dsl_test.exs @@ -16,7 +16,9 @@ defmodule QuickBEAM.VM.BuiltinDSLTest do assert array.name == "Array" assert array.kind == :extension assert [%FunctionSpec{key: "isArray", handler: :is_array, length: 1}] = array.statics - assert Enum.map(array.prototype, & &1.key) == ~w(filter forEach map reduce some) + + assert Enum.map(array.prototype, & &1.key) == + ~w(concat filter forEach join map push reduce slice some) assert Registry.modules(:core) == [ QuickBEAM.VM.Builtins.Math, @@ -28,7 +30,7 @@ defmodule QuickBEAM.VM.BuiltinDSLTest do assert QuickBEAM.VM.Builtins.String.builtin_spec().kind == :extension assert Enum.map(QuickBEAM.VM.Builtins.Object.builtin_spec().statics, & &1.key) == - ~w(assign create getOwnPropertyNames getPrototypeOf keys setPrototypeOf) + ~w(assign create defineProperty getOwnPropertyDescriptor getOwnPropertyNames getPrototypeOf keys setPrototypeOf) end test "installs real function objects with stable names, lengths, and descriptors" do diff --git a/test/vm/object_model_test.exs b/test/vm/object_model_test.exs index d2a661240..13cc579a9 100644 --- a/test/vm/object_model_test.exs +++ b/test/vm/object_model_test.exs @@ -36,7 +36,9 @@ defmodule QuickBEAM.VM.ObjectModelTest do "(()=>{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(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 From 4355e1c2d78c9dea6011a6e8df2a9cb79b4b2b6b Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 13 Jul 2026 14:05:35 +0200 Subject: [PATCH 34/87] Migrate String and Number builtins to DSL --- docs/beam-interpreter-architecture.md | 9 +- docs/prototype-delta-audit.md | 13 ++- lib/quickbeam/vm/builtin/registry.ex | 1 + lib/quickbeam/vm/builtins.ex | 156 +------------------------- lib/quickbeam/vm/builtins/number.ex | 70 ++++++++++++ lib/quickbeam/vm/builtins/string.ex | 153 ++++++++++++++++++++++++- lib/quickbeam/vm/properties.ex | 21 +++- test/vm/builtin_dsl_test.exs | 30 ++++- test/vm/memory_limit_test.exs | 4 +- test/vm/properties_test.exs | 9 +- 10 files changed, 292 insertions(+), 174 deletions(-) create mode 100644 lib/quickbeam/vm/builtins/number.ex diff --git a/docs/beam-interpreter-architecture.md b/docs/beam-interpreter-architecture.md index 925d66997..6893d64f0 100644 --- a/docs/beam-interpreter-architecture.md +++ b/docs/beam-interpreter-architecture.md @@ -427,10 +427,11 @@ planner. Compile-time checks reject missing handlers, invalid lengths, unknown builtin kinds, and duplicate keys. The migrated slice now includes `Math`, `String.fromCharCode`, every currently supported Array prototype method, `Array.isArray`, and all currently supported Object statics, including -resumable callbacks, `Object.assign`, and descriptor validation. A narrow -primitive-array compatibility adapter remains for internal BEAM lists until all -JavaScript-visible aggregate results use heap arrays. The legacy dispatcher -otherwise remains only for builtins not yet migrated. Real function metadata +resumable callbacks, `Object.assign`, descriptor validation, and all currently +supported String and Number prototype methods. Primitive strings, numbers, and +internal BEAM lists resolve methods from the same installed intrinsic prototype +objects, eliminating parallel pseudo-method implementations. The legacy +dispatcher remains only for builtins not yet migrated. Real function metadata increases the intrinsic heap baseline, which remains included in logical memory accounting. diff --git a/docs/prototype-delta-audit.md b/docs/prototype-delta-audit.md index d99af48a3..6f202305d 100644 --- a/docs/prototype-delta-audit.md +++ b/docs/prototype-delta-audit.md @@ -389,8 +389,11 @@ High-value test groups to adapt next: canonical resumable actions without recursively invoking JavaScript. - Descriptor-heavy Object methods now share canonical descriptor validation; sparse `slice`/`concat` preserve holes and `join` handles holes/nullish values. -- Continue with low-risk String and Number primitive methods before Promise - constructors and combinators. +- String and Number primitive methods now resolve through their installed DSL + prototype objects for both primitives and boxed values; numeric radix output + is normalized to JavaScript lowercase form. +- Continue with Promise constructors and combinators, then object/error + prototype methods that remain in the legacy dispatcher. ### Phase C — expand conformance by profile demand @@ -417,6 +420,6 @@ High-value test groups to adapt next: ## Immediate next action -Continue the declarative builtin migration with String and Number primitive -methods, then migrate Promise construction and combinators onto general -resumable iterator semantics. +Migrate Promise construction, static combinators, and prototype reactions to +DSL specs while replacing array-only combinator inputs with general resumable +iterator semantics. diff --git a/lib/quickbeam/vm/builtin/registry.ex b/lib/quickbeam/vm/builtin/registry.ex index cc39824a5..0cddcc4d2 100644 --- a/lib/quickbeam/vm/builtin/registry.ex +++ b/lib/quickbeam/vm/builtin/registry.ex @@ -10,6 +10,7 @@ defmodule QuickBEAM.VM.Builtin.Registry do QuickBEAM.VM.Builtins.Math, QuickBEAM.VM.Builtins.Array, QuickBEAM.VM.Builtins.String, + QuickBEAM.VM.Builtins.Number, QuickBEAM.VM.Builtins.Object ] diff --git a/lib/quickbeam/vm/builtins.ex b/lib/quickbeam/vm/builtins.ex index 66498c282..ce0b798ce 100644 --- a/lib/quickbeam/vm/builtins.ex +++ b/lib/quickbeam/vm/builtins.ex @@ -14,7 +14,7 @@ defmodule QuickBEAM.VM.Builtins do Value } - alias QuickBEAM.VM.Builtin.{Call, Installer, Registry} + alias QuickBEAM.VM.Builtin.{Installer, Registry} @error_types ~w(Error EvalError RangeError ReferenceError SyntaxError TypeError URIError) @@ -189,85 +189,6 @@ defmodule QuickBEAM.VM.Builtins do end end - def call({:primitive_method, :number, "toString"}, value, arguments, execution) do - radix = - case arguments do - [radix | _] -> Value.to_int32(radix) - [] -> 10 - end - - {:ok, number_to_string(value, radix), execution} - end - - def call({:primitive_method, :number, "toFixed"}, value, arguments, execution) do - digits = - case arguments do - [digits | _] -> Value.to_int32(digits) - [] -> 0 - end - - {:ok, :erlang.float_to_binary(value / 1, decimals: digits), execution} - end - - def call({:primitive_method, :string, method}, %Reference{} = receiver, arguments, execution) do - case primitive_value(receiver, :string, execution) do - {:ok, value} -> call({:primitive_method, :string, method}, value, arguments, execution) - :error -> {:error, :incompatible_string_receiver, execution} - end - end - - def call({:primitive_method, :string, "toString"}, value, _arguments, execution), - do: {:ok, value, execution} - - def call({:primitive_method, :string, "toLowerCase"}, value, _arguments, execution), - do: {:ok, String.downcase(value), execution} - - def call({:primitive_method, :string, "startsWith"}, value, [prefix | _], execution), - do: {:ok, String.starts_with?(value, Value.to_string_value(prefix)), execution} - - def call({:primitive_method, :string, "includes"}, value, [part | _], execution), - do: {:ok, String.contains?(value, Value.to_string_value(part)), execution} - - def call({:primitive_method, :string, "charCodeAt"}, value, [index | _], execution) do - result = Value.string_char_code_at(value, Value.to_int32(index)) - {:ok, result, execution} - end - - def call({:primitive_method, :string, "slice"}, value, arguments, execution) do - {start, length} = slice_range(Value.string_length(value), arguments) - {:ok, Value.string_slice(value, start, length), execution} - end - - def call({:primitive_method, :string, "replace"}, value, [pattern, replacement | _], execution) do - {:ok, replace_string(value, pattern, Value.to_string_value(replacement)), execution} - end - - def call({:primitive_method, :string, "split"}, value, arguments, execution) do - parts = - case arguments do - [] -> [value] - [separator | _] -> String.split(value, Value.to_string_value(separator)) - end - - {array, execution} = array_from(parts, execution) - {:ok, array, execution} - end - - def call({:primitive_method, :array, method}, receiver, arguments, execution) - when method in ["concat", "join", "push", "slice"] do - handler = %{"concat" => :concat, "join" => :join, "push" => :push, "slice" => :slice}[method] - - call = %Call{ - arguments: arguments, - this: receiver, - caller: nil, - tail?: false, - execution: execution - } - - apply(QuickBEAM.VM.Builtins.Array, handler, [call]) - end - def call({:primitive_method, :regexp, "test"}, %RegExp{} = regexp, [value | _], execution), do: {:ok, regex_match?(regexp, Value.to_string_value(value)), execution} @@ -517,23 +438,11 @@ defmodule QuickBEAM.VM.Builtins do ) end - defp maybe_install_prototype("String", constructor, execution) do - install_primitive_prototype( - constructor, - :string, - [ - "charCodeAt", - "includes", - "replace", - "slice", - "split", - "startsWith", - "toLowerCase", - "toString" - ], - execution - ) - end + defp maybe_install_prototype("String", constructor, execution), + do: install_primitive_prototype(constructor, :string, [], execution) + + defp maybe_install_prototype("Number", constructor, execution), + do: install_primitive_prototype(constructor, :number, [], execution) defp maybe_install_prototype("Promise", constructor, execution) do {prototype, execution} = Heap.allocate(execution) @@ -599,13 +508,6 @@ defmodule QuickBEAM.VM.Builtins do end end - defp primitive_value(reference, kind, execution) do - case Heap.fetch_object(execution, reference) do - {:ok, %Object{internal: {:primitive, ^kind, value}}} -> {:ok, value} - _other -> :error - end - end - defp array_from(values, execution) do {array, execution} = Heap.allocate(execution, :array) @@ -646,56 +548,10 @@ defmodule QuickBEAM.VM.Builtins do end end - defp slice_range(size, arguments) do - start = - case arguments do - [start | _] -> normalize_slice_index(start, size) - [] -> 0 - end - - finish = - case arguments do - [_start, finish | _] -> normalize_slice_index(finish, size) - _ -> size - end - - {start, max(finish - start, 0)} - end - - defp normalize_slice_index(value, size) do - case Value.to_number(value) do - :infinity -> size - :neg_infinity -> 0 - :nan -> 0 - index when is_number(index) -> normalize_index(trunc(index), size) - _value -> 0 - end - end - - defp normalize_index(index, size) when index < 0, do: max(size + index, 0) - defp normalize_index(index, size), do: min(index, size) - - 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: Integer.to_string(value, radix) - - defp number_to_string(value, _radix), do: Value.to_string_value(value) - defp regex_match?(%RegExp{source: source}, value) do case Regex.compile(source) do {:ok, regex} -> Regex.match?(regex, value) {:error, _} -> false end end - - defp replace_string(value, %RegExp{source: source}, replacement) do - case Regex.compile(source) do - {:ok, regex} -> Regex.replace(regex, value, replacement) - {:error, _} -> 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/builtins/number.ex b/lib/quickbeam/vm/builtins/number.ex new file mode 100644 index 000000000..fe5b0a573 --- /dev/null +++ b/lib/quickbeam/vm/builtins/number.ex @@ -0,0 +1,70 @@ +defmodule QuickBEAM.VM.Builtins.Number do + @moduledoc "Defines declarative `Number` prototype methods." + + use QuickBEAM.VM.Builtin + + alias QuickBEAM.VM.Builtin.Call + alias QuickBEAM.VM.{Heap, Object, Reference, Value} + + builtin "Number", kind: :extension do + prototype do + method("toFixed", :to_fixed, length: 1) + method("toString", :to_string_method, length: 1) + end + 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/builtins/string.ex b/lib/quickbeam/vm/builtins/string.ex index d1c6bdf33..b0633d039 100644 --- a/lib/quickbeam/vm/builtins/string.ex +++ b/lib/quickbeam/vm/builtins/string.ex @@ -1,16 +1,165 @@ defmodule QuickBEAM.VM.Builtins.String do - @moduledoc "Defines declarative additions to the core `String` constructor." + @moduledoc "Defines the declarative core `String` static and prototype methods." use QuickBEAM.VM.Builtin alias QuickBEAM.VM.Builtin.Call - alias QuickBEAM.VM.Value + alias QuickBEAM.VM.{Heap, Object, Properties, Reference, RegExp, Value} builtin "String", kind: :extension do static("fromCharCode", :from_char_code, length: 1) + + prototype do + method("charCodeAt", :char_code_at, length: 1) + method("includes", :includes, length: 1) + method("replace", :replace, length: 2) + method("slice", :slice, length: 2) + method("split", :split, length: 2) + method("startsWith", :starts_with, length: 1) + method("toLowerCase", :to_lower_case, length: 0) + method("toString", :to_string_method, length: 0) + end 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.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.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.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} = 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} = array_from(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 `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.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 array_from(values, execution) do + {array, execution} = Heap.allocate(execution, :array) + + execution = + values + |> Enum.with_index() + |> Enum.reduce(execution, fn {value, index}, execution -> + {:ok, execution} = Properties.define(array, index, value, execution) + execution + end) + + {array, execution} + end + + defp slice_range(size, arguments) do + start = + case arguments do + [start | _] -> normalize_slice_index(start, size) + [] -> 0 + end + + finish = + case arguments do + [_start, finish | _] -> normalize_slice_index(finish, size) + _ -> size + end + + {start, max(finish - start, 0)} + end + + defp normalize_slice_index(value, size) do + case Value.to_number(value) do + :infinity -> size + :neg_infinity -> 0 + :nan -> 0 + index when is_number(index) -> normalize_index(trunc(index), size) + _value -> 0 + end + end + + defp normalize_index(index, size) when index < 0, do: max(size + index, 0) + defp normalize_index(index, size), do: min(index, size) + + 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/properties.ex b/lib/quickbeam/vm/properties.ex index ded9546fa..12d07beaa 100644 --- a/lib/quickbeam/vm/properties.ex +++ b/lib/quickbeam/vm/properties.ex @@ -82,19 +82,19 @@ defmodule QuickBEAM.VM.Properties do 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: {:ok, {:primitive_method, :string, 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: {:ok, {:primitive_method, :array, key}} + 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: {:ok, {:primitive_method, :number, 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} @@ -205,6 +205,15 @@ defmodule QuickBEAM.VM.Properties do 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 diff --git a/test/vm/builtin_dsl_test.exs b/test/vm/builtin_dsl_test.exs index ef43954fd..1cc064a7e 100644 --- a/test/vm/builtin_dsl_test.exs +++ b/test/vm/builtin_dsl_test.exs @@ -24,6 +24,7 @@ defmodule QuickBEAM.VM.BuiltinDSLTest do QuickBEAM.VM.Builtins.Math, QuickBEAM.VM.Builtins.Array, QuickBEAM.VM.Builtins.String, + QuickBEAM.VM.Builtins.Number, QuickBEAM.VM.Builtins.Object ] @@ -49,7 +50,11 @@ defmodule QuickBEAM.VM.BuiltinDSLTest do Object.assign.name, Object.assign.length, Array.prototype.map.name, - Array.prototype.map.length + Array.prototype.map.length, + String.prototype.slice.name, + String.prototype.slice.length, + Number.prototype.toString.name, + Number.prototype.toString.length ] """ @@ -70,6 +75,10 @@ defmodule QuickBEAM.VM.BuiltinDSLTest do "assign", 2, "map", + 1, + "slice", + 2, + "toString", 1 ]} = QuickBEAM.VM.eval(program) end @@ -89,6 +98,25 @@ defmodule QuickBEAM.VM.BuiltinDSLTest do 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) diff --git a/test/vm/memory_limit_test.exs b/test/vm/memory_limit_test.exs index 7eb17db20..2724eed72 100644 --- a/test/vm/memory_limit_test.exs +++ b/test/vm/memory_limit_test.exs @@ -55,10 +55,10 @@ defmodule QuickBEAM.VM.MemoryLimitTest do Enum.to_list(1..5_000) end - assert {:error, {:limit_exceeded, :memory_bytes, 60_000}} = + assert {:error, {:limit_exceeded, :memory_bytes, 80_000}} = QuickBEAM.VM.eval(program, handlers: %{"large_result" => handler}, - memory_limit: 60_000, + memory_limit: 80_000, timeout: 5_000 ) diff --git a/test/vm/properties_test.exs b/test/vm/properties_test.exs index 53377d7bc..799c5d4ea 100644 --- a/test/vm/properties_test.exs +++ b/test/vm/properties_test.exs @@ -1,7 +1,7 @@ defmodule QuickBEAM.VM.PropertiesTest do use ExUnit.Case, async: true - alias QuickBEAM.VM.{Execution, Heap, Properties} + alias QuickBEAM.VM.{Builtins, Execution, Heap, Invocation, Properties, Reference} test "returns explicit getter and setter actions with the original receiver" do execution = execution() @@ -21,15 +21,16 @@ defmodule QuickBEAM.VM.PropertiesTest do end test "provides primitive, Promise, and callable pseudo-properties" do - execution = execution() + execution = Builtins.install(execution()) {callable, execution} = Heap.allocate(execution, :function, callable: {:builtin, "callable"}) assert {:ok, {:function_method, "bind"}} = Properties.get(callable, "bind", execution) assert {:ok, 2} = Properties.get("😀", "length", execution) assert {:ok, <<0xED, 0xA0, 0xBD>>} = Properties.get("😀", 0, execution) - assert {:ok, {:primitive_method, :number, "toString"}} = - Properties.get(42, "toString", execution) + assert {:ok, %Reference{} = to_string} = Properties.get(42, "toString", execution) + assert Invocation.callable?(to_string, execution) + assert {:ok, "toString"} = Properties.get(to_string, "name", execution) end test "centralizes descriptors, enumeration, and prototype operations" do From e6ebf2b89511be8887f39f6a685c9577e4059277 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 13 Jul 2026 14:29:46 +0200 Subject: [PATCH 35/87] Polish declarative builtin DSL --- .formatter.exs | 19 ++- docs/beam-interpreter-architecture.md | 39 +++-- docs/prototype-delta-audit.md | 6 +- lib/quickbeam/vm/builtin.ex | 223 +++----------------------- lib/quickbeam/vm/builtin/action.ex | 19 +++ lib/quickbeam/vm/builtin/dsl.ex | 175 ++++++++++++++++++++ lib/quickbeam/vm/builtin/installer.ex | 60 ++++++- lib/quickbeam/vm/builtin/spec.ex | 31 +++- lib/quickbeam/vm/builtin/validator.ex | 101 ++++++++++++ lib/quickbeam/vm/builtins/array.ex | 25 +-- lib/quickbeam/vm/builtins/math.ex | 17 +- lib/quickbeam/vm/builtins/number.ex | 6 +- lib/quickbeam/vm/builtins/object.ex | 21 +-- lib/quickbeam/vm/builtins/string.ex | 20 +-- lib/quickbeam/vm/invocation.ex | 4 +- test/vm/builtin_dsl_test.exs | 109 ++++++++++++- 16 files changed, 602 insertions(+), 273 deletions(-) create mode 100644 lib/quickbeam/vm/builtin/action.ex create mode 100644 lib/quickbeam/vm/builtin/dsl.ex create mode 100644 lib/quickbeam/vm/builtin/validator.ex diff --git a/.formatter.exs b/.formatter.exs index 682d585db..64a41319c 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -1,4 +1,21 @@ [ 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, + 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/docs/beam-interpreter-architecture.md b/docs/beam-interpreter-architecture.md index 6893d64f0..ffd86b72f 100644 --- a/docs/beam-interpreter-architecture.md +++ b/docs/beam-interpreter-architecture.md @@ -413,18 +413,39 @@ synchronously and queues invocation of returned then functions as microtasks. ## Declarative builtins -Builtin modules use `QuickBEAM.VM.Builtin` to compile constructor, object, -extension, static, prototype, function-metadata, and descriptor declarations -into immutable specs. An explicit profile registry installs those specs in -dependency order into each owner-local execution through the canonical heap and -property layers. Runtime application-module discovery is forbidden. +Builtin modules use `QuickBEAM.VM.Builtin` to compile constructor, namespace, +intrinsic, static, prototype, function, data-property, constant, and accessor +declarations into immutable specs. Handler atoms are primary and JavaScript +names are inferred unless an explicit camelCase `js:` name is required: + +```elixir +builtin "Array", kind: :intrinsic do + static :is_array, js: "isArray", length: 1 + + prototype do + method :map, length: 1 + method :for_each, js: "forEach", length: 1 + end +end +``` + +The formatter preserves this parenthesis-free syntax. Macro compilation, +validation, installation, and runtime dispatch are separate modules. Constants +are evaluated in the declaring module, descriptors and accessors have typed +specs, and each builtin declares one or more profiles plus explicit intrinsic +dependencies. An explicit profile registry installs specs in validated dependency +order into each owner-local execution. Runtime application-module discovery is +forbidden. Installed functions are real owner-local function objects carrying stable module/handler tokens, not captured closures. Calls receive an explicit `QuickBEAM.VM.Builtin.Call` containing arguments, receiver, caller, tail mode, and execution state, and are dispatched through the canonical invocation -planner. Compile-time checks reject missing handlers, invalid lengths, unknown -builtin kinds, and duplicate keys. The migrated slice now includes `Math`, +planner. Resumable results use a typed `QuickBEAM.VM.Builtin.Action`; malformed +handler results raise an infrastructure contract error. Compile-time checks +reject missing handlers, invalid lengths and descriptors, unknown builtin kinds, +empty profiles, malformed dependencies, duplicate declarations, and duplicate +keys. The migrated slice now includes `Math`, `String.fromCharCode`, every currently supported Array prototype method, `Array.isArray`, and all currently supported Object statics, including resumable callbacks, `Object.assign`, descriptor validation, and all currently @@ -432,8 +453,8 @@ supported String and Number prototype methods. Primitive strings, numbers, and internal BEAM lists resolve methods from the same installed intrinsic prototype objects, eliminating parallel pseudo-method implementations. The legacy dispatcher remains only for builtins not yet migrated. Real function metadata -increases the intrinsic heap -baseline, which remains included in logical memory accounting. +increases the intrinsic heap baseline, which remains included in logical memory +accounting. ## ECMAScript and host profiles diff --git a/docs/prototype-delta-audit.md b/docs/prototype-delta-audit.md index 6f202305d..326d679e8 100644 --- a/docs/prototype-delta-audit.md +++ b/docs/prototype-delta-audit.md @@ -380,8 +380,12 @@ High-value test groups to adapt next: fallback dispatch. - Builtin declarations now compile to immutable validated specs and install from an explicit deterministic profile registry. +- DSL v2 uses parenthesis-free handler-first declarations with inferred JS + names, typed constants/data/accessors, profile and dependency metadata, and + separate compiler, validator, installer, and runtime-contract modules. - Handlers use stable module/function tokens and explicit call contexts through - canonical invocation semantics. + canonical invocation semantics; resumable actions are typed and malformed + results fail as infrastructure contract errors. - Migrated `Math`, `String.fromCharCode`, `Array.isArray`, all currently supported Array prototype methods, and all currently supported Object statics with real function objects and descriptor, `name`, and `length` metadata. diff --git a/lib/quickbeam/vm/builtin.ex b/lib/quickbeam/vm/builtin.ex index b22545667..ee1cf90d1 100644 --- a/lib/quickbeam/vm/builtin.ex +++ b/lib/quickbeam/vm/builtin.ex @@ -1,102 +1,32 @@ defmodule QuickBEAM.VM.Builtin do @moduledoc """ - Provides the declarative DSL and runtime dispatcher for JavaScript builtins. + Provides the runtime contract and `use` entry point for declarative builtins. - Declarations compile into immutable `QuickBEAM.VM.Builtin.Spec` values. An - explicit registry installs those specs into each execution; no application - module discovery or process-global mutable state is used. - - defmodule MathBuiltin do - use QuickBEAM.VM.Builtin - - builtin "Math", kind: :object do - static "floor", :floor, length: 1 - end - - def floor(%QuickBEAM.VM.Builtin.Call{} = call), do: ... - end + `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.{Call, FunctionSpec, PropertySpec, Spec} + alias QuickBEAM.VM.Builtin.{Action, Call, ContractError} + alias QuickBEAM.VM.Execution @type handler_result :: - {:ok, term(), QuickBEAM.VM.Execution.t()} - | {:error, term(), QuickBEAM.VM.Execution.t()} - | {:action, term()} - - @doc "Installs the builtin declaration macros in a module." - defmacro __using__(_opts) do - quote do - import QuickBEAM.VM.Builtin, - only: [ - builtin: 2, - builtin: 3, - prototype: 1, - static: 2, - static: 3, - method: 2, - method: 3, - static_value: 2, - static_value: 3, - prototype_value: 2, - prototype_value: 3 - ] - - Module.register_attribute(__MODULE__, :quickbeam_builtin_name, persist: false) - Module.register_attribute(__MODULE__, :quickbeam_builtin_options, persist: false) - Module.register_attribute(__MODULE__, :quickbeam_builtin_statics, accumulate: true) - Module.register_attribute(__MODULE__, :quickbeam_builtin_prototype, accumulate: true) - @before_compile QuickBEAM.VM.Builtin - end - end + {:ok, term(), Execution.t()} + | {:error, term(), Execution.t()} + | Action.t() - @doc "Declares a builtin and its static or prototype entries." - defmacro builtin(name, opts \\ [], do: block) do + @doc "Installs the declarative builtin DSL in a module." + defmacro __using__(opts) do quote do - @quickbeam_builtin_name unquote(name) - @quickbeam_builtin_options unquote(opts) - unquote(block) + use QuickBEAM.VM.Builtin.DSL, unquote(opts) end end - @doc "Groups prototype method or value declarations." - defmacro prototype(do: block), do: block - - @doc "Declares a static builtin function." - defmacro static(key, handler, opts \\ []) do - spec = function_spec(key, handler, opts) - - quote do - @quickbeam_builtin_statics unquote(Macro.escape(spec)) - end - end - - @doc "Declares a prototype builtin function." - defmacro method(key, handler, opts \\ []) do - spec = function_spec(key, handler, opts) - - quote do - @quickbeam_builtin_prototype unquote(Macro.escape(spec)) - end - end - - @doc "Declares a static data property." - defmacro static_value(key, value, opts \\ []) do - spec = property_spec(key, value, opts) - - quote do - @quickbeam_builtin_statics unquote(Macro.escape(spec)) - end - end - - @doc "Declares a prototype data property." - defmacro prototype_value(key, value, opts \\ []) do - spec = property_spec(key, value, opts) - - quote do - @quickbeam_builtin_prototype unquote(Macro.escape(spec)) - 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() @@ -105,119 +35,16 @@ defmodule QuickBEAM.VM.Builtin do spec.kind == :constructor and spec.constructor == handler end - @doc "Dispatches a stable declarative builtin token to its module handler." + @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: apply(module, handler, [call]) - - @doc "Emits and validates a module's immutable builtin specification." - defmacro __before_compile__(env) do - 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() + def invoke({:declared_builtin, module, handler}, %Call{} = call) do + result = apply(module, handler, [call]) - spec = %Spec{ - name: name, - module: env.module, - kind: Keyword.get(opts, :kind, :object), - constructor: Keyword.get(opts, :constructor), - profile: Keyword.get(opts, :profile, :core), - length: Keyword.get(opts, :length, 0), - statics: statics, - prototype: prototype - } - - 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)) + case result do + {:ok, _value, %Execution{}} -> result + {:error, _reason, %Execution{}} -> result + %Action{} -> result + invalid -> raise ContractError, module: module, handler: handler, result: invalid end end - - defp function_spec(key, handler, opts) do - %FunctionSpec{ - key: key, - 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 property_spec(key, value, opts) do - %PropertySpec{ - key: key, - value: value, - writable: Keyword.get(opts, :writable, false), - enumerable: Keyword.get(opts, :enumerable, false), - configurable: Keyword.get(opts, :configurable, false) - } - end - - defp validate!(%Spec{} = spec, env) do - unless is_binary(spec.name) and spec.name != "" do - compile_error!(env, "builtin name must be a non-empty string") - end - - unless spec.kind in [:object, :constructor, :extension] do - compile_error!(env, "unsupported builtin kind: #{inspect(spec.kind)}") - end - - unless is_integer(spec.length) and spec.length >= 0 do - compile_error!(env, "builtin length must be a non-negative integer") - end - - if spec.kind == :constructor and not is_atom(spec.constructor) do - compile_error!(env, "constructor builtins require a :constructor handler") - end - - entries = spec.statics ++ spec.prototype - duplicate_keys!(spec.statics, :static, env) - duplicate_keys!(spec.prototype, :prototype, env) - - handlers = - entries - |> Enum.filter(&is_struct(&1, FunctionSpec)) - |> Enum.map(& &1.handler) - |> then(fn handlers -> - if spec.constructor, do: [spec.constructor | handlers], else: handlers - end) - - Enum.each(handlers, fn handler -> - unless is_atom(handler) and Module.defines?(env.module, {handler, 1}, :def) do - compile_error!(env, "builtin handler #{inspect(handler)}/1 must be a public function") - end - end) - - Enum.each(entries, fn - %FunctionSpec{length: length} when is_integer(length) and length >= 0 -> - :ok - - %FunctionSpec{key: key} -> - compile_error!(env, "invalid function length for #{inspect(key)}") - - %PropertySpec{} -> - :ok - 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 compile_error!(env, description) do - raise CompileError, file: env.file, line: env.line, description: description - end end diff --git a/lib/quickbeam/vm/builtin/action.ex b/lib/quickbeam/vm/builtin/action.ex new file mode 100644 index 000000000..799d986f1 --- /dev/null +++ b/lib/quickbeam/vm/builtin/action.ex @@ -0,0 +1,19 @@ +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 + +defmodule QuickBEAM.VM.Builtin.ContractError do + @moduledoc "Raised when a builtin handler violates the runtime result contract." + + defexception [:module, :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..42ea403e7 --- /dev/null +++ b/lib/quickbeam/vm/builtin/dsl.ex @@ -0,0 +1,175 @@ +defmodule QuickBEAM.VM.Builtin.DSL do + @moduledoc "Compiles the declarative builtin syntax into immutable validated specs." + + alias QuickBEAM.VM.Builtin.{AccessorSpec, FunctionSpec, Spec, 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) + @quickbeam_builtin_count 0 + @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 "Groups prototype declarations." + defmacro prototype(do: block), do: block + + @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.PropertySpec{ + key: key, + value: value, + writable: Keyword.get(opts, :writable, false), + enumerable: Keyword.get(opts, :enumerable, false), + configurable: Keyword.get(opts, :configurable, false) + } + 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.PropertySpec{ + key: key, + value: value, + writable: false, + enumerable: false, + configurable: false + } + 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.PropertySpec{ + key: key, + value: value, + writable: Keyword.get(opts, :writable, false), + enumerable: Keyword.get(opts, :enumerable, false), + configurable: Keyword.get(opts, :configurable, false) + } + 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() + + spec = %Spec{ + name: name, + module: env.module, + kind: Keyword.get(opts, :kind, :namespace), + constructor: Keyword.get(opts, :constructor), + 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 + + 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/installer.ex b/lib/quickbeam/vm/builtin/installer.ex index 42e1491d6..93cb20a13 100644 --- a/lib/quickbeam/vm/builtin/installer.ex +++ b/lib/quickbeam/vm/builtin/installer.ex @@ -7,7 +7,7 @@ defmodule QuickBEAM.VM.Builtin.Installer do stable module/handler tokens rather than captured closures. """ - alias QuickBEAM.VM.Builtin.{FunctionSpec, PropertySpec, Spec} + alias QuickBEAM.VM.Builtin.{AccessorSpec, FunctionSpec, PropertySpec, Spec} alias QuickBEAM.VM.{Execution, Heap, Properties, Reference} @doc "Installs registered builtin modules for the selected profile." @@ -16,21 +16,21 @@ defmodule QuickBEAM.VM.Builtin.Installer do specs = modules |> Enum.map(& &1.builtin_spec()) - |> Enum.filter(&(&1.profile == profile)) + |> Enum.filter(&(profile in &1.profiles)) - validate_registry!(specs) + validate_registry!(specs, execution) Enum.reduce(specs, execution, &install(&2, &1)) end @doc "Installs one immutable builtin specification." @spec install(Execution.t(), Spec.t()) :: Execution.t() - def install(execution, %Spec{kind: :object} = spec) do + 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: :extension} = spec) do + 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) @@ -92,6 +92,25 @@ defmodule QuickBEAM.VM.Builtin.Installer do 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} = + Properties.define_accessor(target, spec.key, :getter, getter, execution, + enumerable: spec.enumerable, + configurable: spec.configurable + ) + + {:ok, execution} = + Properties.define_accessor(target, spec.key, :setter, setter, execution, + enumerable: spec.enumerable, + configurable: spec.configurable + ) + + execution + end + defp install_entry(execution, target, _module, %PropertySpec{} = spec) do {:ok, execution} = Properties.define(target, spec.key, spec.value, execution, @@ -123,10 +142,18 @@ defmodule QuickBEAM.VM.Builtin.Installer do {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 put_global(execution, name, value), do: %{execution | globals: Map.put(execution.globals, name, value)} - defp validate_registry!(specs) do + defp validate_registry!(specs, execution) do names = Enum.map(specs, & &1.name) case names -- Enum.uniq(names) do @@ -136,5 +163,26 @@ defmodule QuickBEAM.VM.Builtin.Installer do duplicates -> raise ArgumentError, "duplicate builtin specs: #{inspect(Enum.uniq(duplicates))}" end + + Enum.reduce(specs, MapSet.new(Map.keys(execution.globals)), fn spec, available -> + 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, :constructor] and MapSet.member?(available, spec.name) do + raise ArgumentError, "builtin #{spec.name} conflicts with an installed global" + end + + MapSet.put(available, spec.name) + end) + + :ok end end diff --git a/lib/quickbeam/vm/builtin/spec.ex b/lib/quickbeam/vm/builtin/spec.ex index 6573f88ae..996bf0fc6 100644 --- a/lib/quickbeam/vm/builtin/spec.ex +++ b/lib/quickbeam/vm/builtin/spec.ex @@ -6,30 +6,32 @@ defmodule QuickBEAM.VM.Builtin.Spec do not contain heap references or captured functions. """ - alias QuickBEAM.VM.Builtin.{FunctionSpec, PropertySpec} + alias QuickBEAM.VM.Builtin.{AccessorSpec, FunctionSpec, PropertySpec} @enforce_keys [:name, :module, :kind] defstruct [ :name, :module, :constructor, - :profile, - kind: :object, + profiles: [:core], + depends_on: [], + kind: :namespace, length: 0, statics: [], prototype: [] ] - @type kind :: :object | :constructor | :extension + @type kind :: :namespace | :constructor | :intrinsic @type t :: %__MODULE__{ name: String.t(), module: module(), kind: kind(), constructor: atom() | nil, - profile: atom(), + profiles: [atom()], + depends_on: [String.t()], length: non_neg_integer(), - statics: [FunctionSpec.t() | PropertySpec.t()], - prototype: [FunctionSpec.t() | PropertySpec.t()] + statics: [FunctionSpec.t() | PropertySpec.t() | AccessorSpec.t()], + prototype: [FunctionSpec.t() | PropertySpec.t() | AccessorSpec.t()] } end @@ -49,6 +51,21 @@ defmodule QuickBEAM.VM.Builtin.FunctionSpec do } end +defmodule QuickBEAM.VM.Builtin.AccessorSpec 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 + defmodule QuickBEAM.VM.Builtin.PropertySpec do @moduledoc "Defines a declarative JavaScript builtin data property." diff --git a/lib/quickbeam/vm/builtin/validator.ex b/lib/quickbeam/vm/builtin/validator.ex new file mode 100644 index 000000000..4d1207391 --- /dev/null +++ b/lib/quickbeam/vm/builtin/validator.ex @@ -0,0 +1,101 @@ +defmodule QuickBEAM.VM.Builtin.Validator do + @moduledoc "Validates builtin declarations and handler contracts at compile time." + + alias QuickBEAM.VM.Builtin.{AccessorSpec, FunctionSpec, PropertySpec, Spec} + + @doc "Validates a compiled builtin spec against its declaring module." + @spec validate!(Spec.t(), Macro.Env.t()) :: :ok + def validate!(%Spec{} = spec, env) do + unless is_binary(spec.name) and spec.name != "" do + compile_error!(env, "builtin name must be a non-empty string") + end + + unless spec.kind in [:namespace, :constructor, :intrinsic] do + compile_error!(env, "unsupported builtin kind: #{inspect(spec.kind)}") + end + + unless is_integer(spec.length) and spec.length >= 0 do + compile_error!(env, "builtin length must be a non-negative integer") + end + + unless spec.profiles != [] and Enum.all?(spec.profiles, &is_atom/1) do + compile_error!(env, "builtin profiles must be a non-empty atom list") + end + + unless Enum.all?(spec.depends_on, &(is_binary(&1) and &1 != "")) do + compile_error!(env, "builtin dependencies must be non-empty strings") + end + + if spec.kind == :constructor and not is_atom(spec.constructor) do + compile_error!(env, "constructor builtins require a :constructor handler") + end + + entries = spec.statics ++ spec.prototype + duplicate_keys!(spec.statics, :static, env) + duplicate_keys!(spec.prototype, :prototype, env) + + handlers = + entries + |> Enum.flat_map(&entry_handlers/1) + |> then(fn handlers -> + if spec.constructor, do: [spec.constructor | handlers], else: handlers + end) + |> Enum.uniq() + + Enum.each(handlers, fn handler -> + unless is_atom(handler) and Module.defines?(env.module, {handler, 1}, :def) do + compile_error!(env, "builtin handler #{inspect(handler)}/1 must be a public function") + end + end) + + Enum.each(entries, &validate_entry!(&1, env)) + end + + 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 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_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 compile_error!(env, description) do + raise CompileError, file: env.file, line: env.line, description: description + end +end diff --git a/lib/quickbeam/vm/builtins/array.ex b/lib/quickbeam/vm/builtins/array.ex index d0258debe..8a3f09fb7 100644 --- a/lib/quickbeam/vm/builtins/array.ex +++ b/lib/quickbeam/vm/builtins/array.ex @@ -3,22 +3,23 @@ defmodule QuickBEAM.VM.Builtins.Array do use QuickBEAM.VM.Builtin + alias QuickBEAM.VM.Builtin alias QuickBEAM.VM.Builtin.Call alias QuickBEAM.VM.{Heap, Object, Properties, Property, Reference, Value} - builtin "Array", kind: :extension do - static("isArray", :is_array, length: 1) + builtin "Array", kind: :intrinsic do + static :is_array, js: "isArray", length: 1 prototype do - method("concat", :concat, length: 1) - method("filter", :filter, length: 1) - method("forEach", :for_each, length: 1) - method("join", :join, length: 1) - method("map", :map, length: 1) - method("push", :push, length: 1) - method("reduce", :reduce, length: 1) - method("slice", :slice, length: 2) - method("some", :some, length: 1) + method :concat, length: 1 + method :filter, length: 1 + method :for_each, js: "forEach", 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 end end @@ -205,6 +206,6 @@ defmodule QuickBEAM.VM.Builtins.Array do tail?: tail?, execution: execution }) do - {:action, {:array_iteration, method, receiver, arguments, caller, execution, tail?}} + Builtin.action({:array_iteration, method, receiver, arguments, caller, execution, tail?}) end end diff --git a/lib/quickbeam/vm/builtins/math.ex b/lib/quickbeam/vm/builtins/math.ex index 9c6ceb83f..e6a5b802e 100644 --- a/lib/quickbeam/vm/builtins/math.ex +++ b/lib/quickbeam/vm/builtins/math.ex @@ -6,13 +6,16 @@ defmodule QuickBEAM.VM.Builtins.Math do alias QuickBEAM.VM.Builtin.Call alias QuickBEAM.VM.Value - builtin "Math", kind: :object do - static("floor", :floor_value, length: 1) - static("max", :max_value, length: 2) - static("min", :min_value, length: 2) - static("pow", :pow_value, length: 2) - static("random", :random_value, length: 0) - static("round", :round_value, length: 1) + builtin "Math", kind: :namespace 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`." diff --git a/lib/quickbeam/vm/builtins/number.ex b/lib/quickbeam/vm/builtins/number.ex index fe5b0a573..609a078c8 100644 --- a/lib/quickbeam/vm/builtins/number.ex +++ b/lib/quickbeam/vm/builtins/number.ex @@ -6,10 +6,10 @@ defmodule QuickBEAM.VM.Builtins.Number do alias QuickBEAM.VM.Builtin.Call alias QuickBEAM.VM.{Heap, Object, Reference, Value} - builtin "Number", kind: :extension do + builtin "Number", kind: :intrinsic do prototype do - method("toFixed", :to_fixed, length: 1) - method("toString", :to_string_method, length: 1) + method :to_fixed, js: "toFixed", length: 1 + method :to_string_method, js: "toString", length: 1 end end diff --git a/lib/quickbeam/vm/builtins/object.ex b/lib/quickbeam/vm/builtins/object.ex index 01ae542f7..dc433bb4d 100644 --- a/lib/quickbeam/vm/builtins/object.ex +++ b/lib/quickbeam/vm/builtins/object.ex @@ -3,18 +3,19 @@ defmodule QuickBEAM.VM.Builtins.Object do use QuickBEAM.VM.Builtin + alias QuickBEAM.VM.Builtin alias QuickBEAM.VM.Builtin.Call alias QuickBEAM.VM.{Heap, Invocation, Properties, Property, Reference, Value} - builtin "Object", kind: :extension do - static("assign", :assign, length: 2) - static("create", :create, length: 2) - static("defineProperty", :define_property, length: 3) - static("getOwnPropertyDescriptor", :get_own_property_descriptor, length: 2) - static("getOwnPropertyNames", :get_own_property_names, length: 1) - static("getPrototypeOf", :get_prototype_of, length: 1) - static("keys", :keys, length: 1) - static("setPrototypeOf", :set_prototype_of, length: 2) + builtin "Object", kind: :intrinsic do + static :assign, length: 2 + static :create, length: 2 + static :define_property, js: "defineProperty", length: 3 + 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 end @doc "Plans resumable `Object.assign` property reads and writes." @@ -24,7 +25,7 @@ defmodule QuickBEAM.VM.Builtins.Object do tail?: tail?, execution: execution }), - do: {:action, {:object_assign, target, sources, caller, execution, tail?}} + do: Builtin.action({:object_assign, target, sources, caller, execution, tail?}) def assign(%Call{execution: execution}), do: {:error, :not_an_object, execution} diff --git a/lib/quickbeam/vm/builtins/string.ex b/lib/quickbeam/vm/builtins/string.ex index b0633d039..5b3cfadfa 100644 --- a/lib/quickbeam/vm/builtins/string.ex +++ b/lib/quickbeam/vm/builtins/string.ex @@ -6,18 +6,18 @@ defmodule QuickBEAM.VM.Builtins.String do alias QuickBEAM.VM.Builtin.Call alias QuickBEAM.VM.{Heap, Object, Properties, Reference, RegExp, Value} - builtin "String", kind: :extension do - static("fromCharCode", :from_char_code, length: 1) + builtin "String", kind: :intrinsic do + static :from_char_code, js: "fromCharCode", length: 1 prototype do - method("charCodeAt", :char_code_at, length: 1) - method("includes", :includes, length: 1) - method("replace", :replace, length: 2) - method("slice", :slice, length: 2) - method("split", :split, length: 2) - method("startsWith", :starts_with, length: 1) - method("toLowerCase", :to_lower_case, length: 0) - method("toString", :to_string_method, length: 0) + method :char_code_at, js: "charCodeAt", length: 1 + method :includes, length: 1 + method :replace, length: 2 + method :slice, length: 2 + method :split, length: 2 + method :starts_with, js: "startsWith", length: 1 + method :to_lower_case, js: "toLowerCase", length: 0 + method :to_string_method, js: "toString", length: 0 end end diff --git a/lib/quickbeam/vm/invocation.ex b/lib/quickbeam/vm/invocation.ex index 2bbee2766..bf2e62aaa 100644 --- a/lib/quickbeam/vm/invocation.ex +++ b/lib/quickbeam/vm/invocation.ex @@ -23,7 +23,7 @@ defmodule QuickBEAM.VM.Invocation do Value } - alias QuickBEAM.VM.Builtin.Call + alias QuickBEAM.VM.Builtin.{Action, Call} @builtin_tags [:builtin, :builtin_method, :declared_builtin, :primitive_method] @error_constructors ~w(Error EvalError RangeError ReferenceError SyntaxError TypeError URIError) @@ -64,7 +64,7 @@ defmodule QuickBEAM.VM.Invocation do case Builtin.invoke(callable, call) do {:ok, value, execution} -> {:complete, value, caller, execution, tail?} {:error, reason, execution} -> {:error, {:type_error, reason}, caller, execution} - {:action, action} -> action + %Action{value: action} -> action end end diff --git a/test/vm/builtin_dsl_test.exs b/test/vm/builtin_dsl_test.exs index 1cc064a7e..0572d965f 100644 --- a/test/vm/builtin_dsl_test.exs +++ b/test/vm/builtin_dsl_test.exs @@ -1,20 +1,107 @@ defmodule QuickBEAM.VM.BuiltinDSLTest do use ExUnit.Case, async: true - alias QuickBEAM.VM.Builtin.{FunctionSpec, Registry} + alias QuickBEAM.VM.Builtin.{ + AccessorSpec, + Call, + ContractError, + FunctionSpec, + Installer, + PropertySpec, + Registry, + Spec, + Validator + } + + alias QuickBEAM.VM.{Builtin, Execution, Invocation, Properties, Reference} + + 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} = Properties.get(fixture, "answer", execution) + assert {:ok, %Reference{} = echo} = Properties.get(fixture, "echo", execution) + assert {:ok, "echo"} = Properties.get(echo, "name", execution) + + assert {:ok, {:accessor, %Reference{} = getter, ^fixture}} = + Properties.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 = QuickBEAM.VM.Builtins.Math.builtin_spec() array = QuickBEAM.VM.Builtins.Array.builtin_spec() assert math.name == "Math" - assert math.kind == :object - assert math.profile == :core - assert Enum.map(math.statics, & &1.key) == ~w(floor max min pow random round) - assert Enum.all?(math.statics, &match?(%FunctionSpec{}, &1)) + 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 == :extension + assert array.kind == :intrinsic assert [%FunctionSpec{key: "isArray", handler: :is_array, length: 1}] = array.statics assert Enum.map(array.prototype, & &1.key) == @@ -28,7 +115,7 @@ defmodule QuickBEAM.VM.BuiltinDSLTest do QuickBEAM.VM.Builtins.Object ] - assert QuickBEAM.VM.Builtins.String.builtin_spec().kind == :extension + assert QuickBEAM.VM.Builtins.String.builtin_spec().kind == :intrinsic assert Enum.map(QuickBEAM.VM.Builtins.Object.builtin_spec().statics, & &1.key) == ~w(assign create defineProperty getOwnPropertyDescriptor getOwnPropertyNames getPrototypeOf keys setPrototypeOf) @@ -41,6 +128,8 @@ defmodule QuickBEAM.VM.BuiltinDSLTest do 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([]), @@ -66,6 +155,8 @@ defmodule QuickBEAM.VM.BuiltinDSLTest do "floor", 1, 0, + true, + true, "isArray", 1, true, @@ -122,4 +213,8 @@ defmodule QuickBEAM.VM.BuiltinDSLTest do assert {:ok, program} = QuickBEAM.VM.compile(source) assert {:ok, [2, 2, 2, 4, 8.0]} = QuickBEAM.VM.eval(program) end + + defp execution do + %Execution{atoms: {}, max_stack_depth: 10, remaining_steps: 100, step_limit: 100} + end end From 097b6abf6ab64d0fd8661e631ba62f305d3fb217 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 13 Jul 2026 15:14:14 +0200 Subject: [PATCH 36/87] Migrate Promise builtins to declarative DSL --- docs/beam-interpreter-architecture.md | 18 ++- docs/prototype-delta-audit.md | 8 +- lib/quickbeam/vm/builtin/registry.ex | 3 +- lib/quickbeam/vm/builtins.ex | 78 ++----------- lib/quickbeam/vm/builtins/promise.ex | 153 ++++++++++++++++++++++++++ lib/quickbeam/vm/invocation.ex | 78 ------------- lib/quickbeam/vm/iterator.ex | 46 ++++++++ lib/quickbeam/vm/promise.ex | 1 - lib/quickbeam/vm/properties.ex | 8 +- test/vm/builtin_dsl_test.exs | 8 +- test/vm/promise_test.exs | 8 +- 11 files changed, 248 insertions(+), 161 deletions(-) create mode 100644 lib/quickbeam/vm/builtins/promise.ex create mode 100644 lib/quickbeam/vm/iterator.ex diff --git a/docs/beam-interpreter-architecture.md b/docs/beam-interpreter-architecture.md index ffd86b72f..fc500ec94 100644 --- a/docs/beam-interpreter-architecture.md +++ b/docs/beam-interpreter-architecture.md @@ -538,11 +538,19 @@ so several suspended async functions and host operations can coexist. Awaiting an already-settled Promise still enqueues resumption as a microtask rather than resuming inline. -The Promise runtime provides FIFO `.then`, `.catch`, and `.finally` reactions, -resolution and rejection propagation, Promise and thenable assimilation, -self-resolution protection, idempotent resolver functions, and `all`, -`allSettled`, `any`, and `race`. Callback results are assimilated before their -reaction Promise settles, including thenables returned from `.finally`. +The Promise constructor, static combinators, and `.then`, `.catch`, and +`.finally` methods are declarative DSL builtins. The runtime provides FIFO +reactions, resolution and rejection propagation, Promise and thenable +assimilation, self-resolution protection, idempotent resolver functions, and +`all`, `allSettled`, `any`, and `race`. Callback results are assimilated before +their reaction Promise settles, including thenables returned from `.finally`. + +Combinators consume `QuickBEAM.VM.Iterator`, the canonical iterable-value +boundary. The core profile currently supports sparse arrays (holes yield +`undefined`), sets, strings, and internal BEAM lists; non-iterables produce a +rejected TypeError Promise. Custom `Symbol.iterator` objects remain an explicit +next extension and must use resumable invocation actions rather than a separate +recursive execution path. ```text Evaluation owner Host Task Supervisor diff --git a/docs/prototype-delta-audit.md b/docs/prototype-delta-audit.md index 326d679e8..f6e3c5846 100644 --- a/docs/prototype-delta-audit.md +++ b/docs/prototype-delta-audit.md @@ -396,8 +396,12 @@ High-value test groups to adapt next: - String and Number primitive methods now resolve through their installed DSL prototype objects for both primitives and boxed values; numeric radix output is normalized to JavaScript lowercase form. -- Continue with Promise constructors and combinators, then object/error - prototype methods that remain in the legacy dispatcher. +- Promise construction, static combinators, and reaction methods now use full + constructor DSL topology. Combinators share a canonical iterable boundary + for sparse arrays, sets, strings, and internal lists; custom Symbol iterators + remain the next resumable extension. +- Continue with object/error prototype methods that remain in the legacy + dispatcher. ### Phase C — expand conformance by profile demand diff --git a/lib/quickbeam/vm/builtin/registry.ex b/lib/quickbeam/vm/builtin/registry.ex index 0cddcc4d2..233cc1f6f 100644 --- a/lib/quickbeam/vm/builtin/registry.ex +++ b/lib/quickbeam/vm/builtin/registry.ex @@ -11,7 +11,8 @@ defmodule QuickBEAM.VM.Builtin.Registry do QuickBEAM.VM.Builtins.Array, QuickBEAM.VM.Builtins.String, QuickBEAM.VM.Builtins.Number, - QuickBEAM.VM.Builtins.Object + QuickBEAM.VM.Builtins.Object, + QuickBEAM.VM.Builtins.Promise ] @doc "Returns builtin modules installed for a profile in dependency order." diff --git a/lib/quickbeam/vm/builtins.ex b/lib/quickbeam/vm/builtins.ex index ce0b798ce..a93bd02b9 100644 --- a/lib/quickbeam/vm/builtins.ex +++ b/lib/quickbeam/vm/builtins.ex @@ -32,7 +32,6 @@ defmodule QuickBEAM.VM.Builtins do "TypeError" => [], "URIError" => [], "Error" => [], - "Promise" => ["all", "allSettled", "any", "race", "reject", "resolve"], "Set" => [] } @@ -93,55 +92,6 @@ defmodule QuickBEAM.VM.Builtins do @spec call(term(), term(), [term()], Execution.t()) :: {:ok, term(), Execution.t()} | {:error, term(), Execution.t()} - def call({:builtin_method, "Promise", method}, _this, [iterable | _], execution) - when method in ["all", "allSettled", "any", "race"] do - case array_values(iterable, execution) do - {:ok, values} -> - kind = - %{"all" => :all, "allSettled" => :all_settled, "any" => :any, "race" => :race}[method] - - {promise, execution} = QuickBEAM.VM.Promise.aggregate(execution, kind, values) - {:ok, promise, execution} - - {:error, reason} -> - {:error, reason, execution} - end - end - - def call( - {:builtin_method, "Promise", "resolve"}, - _this, - [%QuickBEAM.VM.PromiseReference{} = promise | _], - execution - ), - do: {:ok, promise, execution} - - def call({:builtin_method, "Promise", "resolve"}, _this, values, execution) do - {promise, execution} = QuickBEAM.VM.Promise.new(execution) - - value = - case values do - [value | _] -> value - [] -> :undefined - end - - execution = QuickBEAM.VM.Promise.settle(execution, promise, {:ok, value}) - {:ok, promise, execution} - end - - def call({:builtin_method, "Promise", "reject"}, _this, values, execution) do - {promise, execution} = QuickBEAM.VM.Promise.new(execution) - - reason = - case values do - [reason | _] -> reason - [] -> :undefined - end - - execution = QuickBEAM.VM.Promise.settle(execution, promise, {:error, reason}) - {:ok, promise, execution} - end - def call( {:primitive_method, :object, "hasOwnProperty"}, %Reference{} = object, @@ -243,13 +193,15 @@ defmodule QuickBEAM.VM.Builtins do [] end - {set, execution} = Heap.allocate(execution, :set, internal: MapSet.new(entries)) + entries = Enum.uniq(entries) + internal = %{values: entries, index: MapSet.new(entries)} + {set, execution} = Heap.allocate(execution, :set, internal: internal) {:ok, set, execution} end def call({:primitive_method, :set, "has"}, %Reference{} = set, [value | _], execution) do case Heap.fetch_object(execution, set) do - {:ok, %Object{kind: :set, internal: entries}} -> + {:ok, %Object{kind: :set, internal: %{index: entries}}} -> {:ok, MapSet.member?(entries, value), execution} _ -> @@ -259,7 +211,13 @@ defmodule QuickBEAM.VM.Builtins do def call({:primitive_method, :set, "add"}, %Reference{} = set, [value | _], execution) do case Heap.update_object(execution, set, fn object -> - %{object | internal: MapSet.put(object.internal, value)} + %{values: values, index: index} = object.internal + + if MapSet.member?(index, value) do + object + else + %{object | internal: %{values: values ++ [value], index: MapSet.put(index, value)}} + end end) do {:ok, execution} -> {:ok, set, execution} {:error, reason} -> {:error, reason, execution} @@ -444,20 +402,6 @@ defmodule QuickBEAM.VM.Builtins do defp maybe_install_prototype("Number", constructor, execution), do: install_primitive_prototype(constructor, :number, [], execution) - defp maybe_install_prototype("Promise", constructor, execution) do - {prototype, execution} = Heap.allocate(execution) - - {:ok, execution} = - Properties.define(prototype, "then", {:promise_method, "then"}, execution, - enumerable: false - ) - - {:ok, execution} = - Properties.define(constructor, "prototype", prototype, execution, enumerable: false) - - execution - end - defp maybe_install_prototype(_name, _constructor, execution), do: execution defp install_primitive_prototype(constructor, kind, methods, execution) do diff --git a/lib/quickbeam/vm/builtins/promise.ex b/lib/quickbeam/vm/builtins/promise.ex new file mode 100644 index 000000000..6affb3e38 --- /dev/null +++ b/lib/quickbeam/vm/builtins/promise.ex @@ -0,0 +1,153 @@ +defmodule QuickBEAM.VM.Builtins.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.{ + ConstructorBoundary, + Exceptions, + Invocation, + Iterator, + Promise, + PromiseExecutorBoundary, + PromiseReference + } + + builtin "Promise", + kind: :constructor, + constructor: :construct, + length: 1, + depends_on: ["Object", "Function"] 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: %ConstructorBoundary{} = caller, + execution: execution + }) do + if Invocation.callable?(executor, execution) do + {promise, execution} = Promise.new(execution) + + boundary = %PromiseExecutorBoundary{ + 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: %ConstructorBoundary{}, 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}) do + iterable = List.first(arguments, :undefined) + + case Iterator.values(iterable, execution) do + {:ok, values} -> + {promise, execution} = Promise.aggregate(execution, kind, values) + {:ok, promise, execution} + + {:error, :not_iterable} -> + {reason, execution} = Exceptions.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/invocation.ex b/lib/quickbeam/vm/invocation.ex index bf2e62aaa..882ee0c53 100644 --- a/lib/quickbeam/vm/invocation.ex +++ b/lib/quickbeam/vm/invocation.ex @@ -16,8 +16,6 @@ defmodule QuickBEAM.VM.Invocation do Frame, Function, Promise, - PromiseExecutorBoundary, - PromiseReference, Properties, Reference, Value @@ -68,41 +66,6 @@ defmodule QuickBEAM.VM.Invocation do end end - def plan({:builtin, "Promise"}, [executor | _], _this, caller, execution, tail?) do - {promise, execution} = Promise.new(execution) - - boundary = %PromiseExecutorBoundary{ - promise: promise, - caller: caller, - depth: execution.depth, - tail?: tail? - } - - if typeof(executor, execution) == "function" do - resolve = {:promise_resolver, promise, :resolve} - reject = {:promise_resolver, promise, :reject} - {:dispatch, executor, [resolve, reject], :undefined, boundary, execution, false} - else - execution = - Promise.settle( - execution, - promise, - {:error, {:type_error, :promise_executor_not_callable}} - ) - - {:complete, promise, caller, execution, tail?} - end - end - - def plan({:builtin, "Promise"}, _arguments, _this, caller, execution, tail?) do - {promise, execution} = Promise.new(execution) - - execution = - Promise.settle(execution, promise, {:error, {:type_error, :missing_promise_executor}}) - - {:complete, promise, caller, execution, tail?} - 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} @@ -157,46 +120,6 @@ defmodule QuickBEAM.VM.Invocation do {:dispatch, target, arguments, this, caller, execution, tail?} end - def plan( - {:promise_method, "then"}, - arguments, - %PromiseReference{} = promise, - caller, - execution, - tail? - ) do - on_fulfilled = Enum.at(arguments, 0, :undefined) - on_rejected = Enum.at(arguments, 1, :undefined) - {result, execution} = Promise.react(execution, promise, on_fulfilled, on_rejected) - {:complete, result, caller, execution, tail?} - end - - def plan( - {:promise_method, "catch"}, - arguments, - %PromiseReference{} = promise, - caller, - execution, - tail? - ) do - on_rejected = Enum.at(arguments, 0, :undefined) - {result, execution} = Promise.react(execution, promise, :undefined, on_rejected) - {:complete, result, caller, execution, tail?} - end - - def plan( - {:promise_method, "finally"}, - arguments, - %PromiseReference{} = promise, - caller, - execution, - tail? - ) do - callback = Enum.at(arguments, 0, :undefined) - {result, execution} = Promise.finally(execution, promise, callback) - {:complete, result, caller, execution, tail?} - end - def plan(%Reference{} = reference, arguments, this, caller, execution, tail?) do case Builtins.callable(execution, reference) do nil -> @@ -299,7 +222,6 @@ defmodule QuickBEAM.VM.Invocation do :function_method, :host_function, :primitive_method, - :promise_method, :promise_resolver ], do: "function" diff --git a/lib/quickbeam/vm/iterator.ex b/lib/quickbeam/vm/iterator.ex new file mode 100644 index 000000000..13443030e --- /dev/null +++ b/lib/quickbeam/vm/iterator.ex @@ -0,0 +1,46 @@ +defmodule QuickBEAM.VM.Iterator do + @moduledoc """ + Defines the canonical iterable-value boundary used by Promise combinators. + + The current core profile supports owner-local arrays and sets, internal BEAM + lists, and JavaScript strings. Array holes yield `undefined`, matching array + iterator behavior. Custom Symbol iterators will extend this boundary with + resumable invocation actions rather than adding another combinator path. + """ + + alias QuickBEAM.VM.{Execution, Heap, Object, Property, Reference} + + @doc "Collects the values produced by a supported iterable in iteration order." + @spec values(term(), Execution.t()) :: {:ok, [term()]} | {: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, length: length, properties: properties}} -> + values = + if length == 0 do + [] + else + for index <- 0..(length - 1), do: property_value(properties, index) + end + + {:ok, values} + + {:ok, %Object{kind: :set, internal: %{values: values}}} -> + {:ok, values} + + _other -> + {:error, :not_iterable} + end + end + + def values(_value, _execution), do: {:error, :not_iterable} + + defp property_value(properties, index) do + case Map.get(properties, index) do + %Property{value: value} -> value + nil -> :undefined + end + end +end diff --git a/lib/quickbeam/vm/promise.ex b/lib/quickbeam/vm/promise.ex index f57f59bf8..cf4d3d9af 100644 --- a/lib/quickbeam/vm/promise.ex +++ b/lib/quickbeam/vm/promise.ex @@ -298,7 +298,6 @@ defmodule QuickBEAM.VM.Promise do :declared_builtin, :host_function, :primitive_method, - :promise_method, :promise_resolver ] -> {:ok, callable} diff --git a/lib/quickbeam/vm/properties.ex b/lib/quickbeam/vm/properties.ex index 12d07beaa..e840ba65f 100644 --- a/lib/quickbeam/vm/properties.ex +++ b/lib/quickbeam/vm/properties.ex @@ -27,7 +27,6 @@ defmodule QuickBEAM.VM.Properties do :function_method, :host_function, :primitive_method, - :promise_method, :promise_resolver ] @@ -46,7 +45,7 @@ defmodule QuickBEAM.VM.Properties do key in ["bind", "call"] and not is_nil(Builtins.callable(execution, object)) -> {:ok, {:function_method, key}} - kind in [:array, :set] and is_binary(key) -> + kind == :set and is_binary(key) -> {:ok, {:primitive_method, kind, key}} true -> @@ -58,9 +57,8 @@ defmodule QuickBEAM.VM.Properties do end end - def get(%PromiseReference{}, method, _execution) - when method in ["catch", "finally", "then"], - do: {:ok, {:promise_method, method}} + 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}} diff --git a/test/vm/builtin_dsl_test.exs b/test/vm/builtin_dsl_test.exs index 0572d965f..76826f7e8 100644 --- a/test/vm/builtin_dsl_test.exs +++ b/test/vm/builtin_dsl_test.exs @@ -112,11 +112,17 @@ defmodule QuickBEAM.VM.BuiltinDSLTest do QuickBEAM.VM.Builtins.Array, QuickBEAM.VM.Builtins.String, QuickBEAM.VM.Builtins.Number, - QuickBEAM.VM.Builtins.Object + QuickBEAM.VM.Builtins.Object, + QuickBEAM.VM.Builtins.Promise ] assert QuickBEAM.VM.Builtins.String.builtin_spec().kind == :intrinsic + promise = QuickBEAM.VM.Builtins.Promise.builtin_spec() + assert promise.kind == :constructor + assert promise.constructor == :construct + assert promise.depends_on == ["Object", "Function"] + assert Enum.map(QuickBEAM.VM.Builtins.Object.builtin_spec().statics, & &1.key) == ~w(assign create defineProperty getOwnPropertyDescriptor getOwnPropertyNames getPrototypeOf keys setPrototypeOf) end diff --git a/test/vm/promise_test.exs b/test/vm/promise_test.exs index e358e6cab..d125a8178 100644 --- a/test/vm/promise_test.exs +++ b/test/vm/promise_test.exs @@ -40,6 +40,8 @@ defmodule QuickBEAM.VM.PromiseTest do "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})()" ] @@ -67,7 +69,11 @@ defmodule QuickBEAM.VM.PromiseTest do "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.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)" ] for source <- sources do From c4658ac3108e1e603a2ebc1c92712582664ff1f9 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 13 Jul 2026 15:35:39 +0200 Subject: [PATCH 37/87] Add resumable Symbol iterator semantics --- docs/beam-interpreter-architecture.md | 17 +- docs/prototype-delta-audit.md | 7 +- lib/quickbeam/vm/async.ex | 12 +- lib/quickbeam/vm/builtin/registry.ex | 1 + lib/quickbeam/vm/builtins/promise.ex | 7 +- lib/quickbeam/vm/builtins/symbol.ex | 11 ++ lib/quickbeam/vm/exceptions.ex | 14 ++ lib/quickbeam/vm/export.ex | 6 +- lib/quickbeam/vm/heap.ex | 25 ++- lib/quickbeam/vm/interpreter.ex | 49 +++++- lib/quickbeam/vm/invocation.ex | 2 + lib/quickbeam/vm/iterator.ex | 200 ++++++++++++++++++++++- lib/quickbeam/vm/iterator_boundary.ex | 40 +++++ lib/quickbeam/vm/opcodes/objects.ex | 34 ++++ lib/quickbeam/vm/promise.ex | 20 ++- lib/quickbeam/vm/properties.ex | 7 + lib/quickbeam/vm/symbol.ex | 12 ++ lib/quickbeam/vm/then_getter_boundary.ex | 2 +- lib/quickbeam/vm/value.ex | 1 + test/vm/builtin_dsl_test.exs | 7 +- test/vm/object_model_test.exs | 4 +- test/vm/promise_test.exs | 24 ++- test/vm/value_test.exs | 4 +- 23 files changed, 475 insertions(+), 31 deletions(-) create mode 100644 lib/quickbeam/vm/builtins/symbol.ex create mode 100644 lib/quickbeam/vm/iterator_boundary.ex create mode 100644 lib/quickbeam/vm/symbol.ex diff --git a/docs/beam-interpreter-architecture.md b/docs/beam-interpreter-architecture.md index fc500ec94..ec33412bb 100644 --- a/docs/beam-interpreter-architecture.md +++ b/docs/beam-interpreter-architecture.md @@ -546,11 +546,18 @@ assimilation, self-resolution protection, idempotent resolver functions, and their reaction Promise settles, including thenables returned from `.finally`. Combinators consume `QuickBEAM.VM.Iterator`, the canonical iterable-value -boundary. The core profile currently supports sparse arrays (holes yield -`undefined`), sets, strings, and internal BEAM lists; non-iterables produce a -rejected TypeError Promise. Custom `Symbol.iterator` objects remain an explicit -next extension and must use resumable invocation actions rather than a separate -recursive execution path. +boundary. The core profile supports sparse arrays (holes yield `undefined`), +insertion-ordered sets, strings, internal BEAM lists, and custom +`Symbol.iterator` objects; non-iterables produce a rejected TypeError Promise. +`Symbol.iterator` is an immutable well-known Symbol value and computed Symbol +property keys remain distinct from strings and enumeration names. + +Custom iterators run as an explicit resumable state machine. Iterator getters, +the iterator factory, the cached `next` method, and accessor-backed `done` and +`value` reads all dispatch through canonical invocation without recursive +JavaScript execution. Throws become rejected combinator Promises with async +stack information preserved. Each resumed JavaScript call remains subject to +the evaluation's shared step, stack, memory, and timeout limits. ```text Evaluation owner Host Task Supervisor diff --git a/docs/prototype-delta-audit.md b/docs/prototype-delta-audit.md index f6e3c5846..9cf15f623 100644 --- a/docs/prototype-delta-audit.md +++ b/docs/prototype-delta-audit.md @@ -398,8 +398,11 @@ High-value test groups to adapt next: is normalized to JavaScript lowercase form. - Promise construction, static combinators, and reaction methods now use full constructor DSL topology. Combinators share a canonical iterable boundary - for sparse arrays, sets, strings, and internal lists; custom Symbol iterators - remain the next resumable extension. + for sparse arrays, insertion-ordered sets, strings, internal lists, and custom + `Symbol.iterator` objects. +- Custom iterator getters, factories, cached `next` methods, and accessor-backed + `done`/`value` reads resume through explicit boundaries; computed Symbol + methods and fields are supported without recursive JavaScript execution. - Continue with object/error prototype methods that remain in the legacy dispatcher. diff --git a/lib/quickbeam/vm/async.ex b/lib/quickbeam/vm/async.ex index 323bbf9b0..19f271b99 100644 --- a/lib/quickbeam/vm/async.ex +++ b/lib/quickbeam/vm/async.ex @@ -15,6 +15,7 @@ defmodule QuickBEAM.VM.Async do Execution, Frame, Invocation, + IteratorBoundary, Memory, Promise, PromiseExecutorBoundary, @@ -32,6 +33,7 @@ defmodule QuickBEAM.VM.Async do | {:invoke, term(), [term()], term(), term(), Execution.t(), boolean()} | {:complete, term(), term(), Execution.t(), boolean()} | {:return, term(), Execution.t()} + | {:continue_iterator, IteratorBoundary.t(), Execution.t()} | {:idle, Execution.t()} | {:suspended, Continuation.t()} | {:error, term(), Execution.t()} @@ -136,8 +138,13 @@ defmodule QuickBEAM.VM.Async do end @doc "Plans invocation of an accessor-backed thenable property." - @spec read_thenable(PromiseReference.t(), term(), term(), Frame.t() | nil, Execution.t()) :: - result() + @spec read_thenable( + PromiseReference.t(), + term(), + term(), + Frame.t() | IteratorBoundary.t() | nil, + Execution.t() + ) :: result() def read_thenable(promise, thenable, getter, continuation, execution) do boundary = %ThenGetterBoundary{ promise: promise, @@ -194,6 +201,7 @@ defmodule QuickBEAM.VM.Async do case boundary.continuation do %Frame{} = frame -> {:run, frame, execution} + %IteratorBoundary{} = iterator -> {:continue_iterator, iterator, execution} nil -> {:idle, execution} end end diff --git a/lib/quickbeam/vm/builtin/registry.ex b/lib/quickbeam/vm/builtin/registry.ex index 233cc1f6f..06b6174e0 100644 --- a/lib/quickbeam/vm/builtin/registry.ex +++ b/lib/quickbeam/vm/builtin/registry.ex @@ -12,6 +12,7 @@ defmodule QuickBEAM.VM.Builtin.Registry do QuickBEAM.VM.Builtins.String, QuickBEAM.VM.Builtins.Number, QuickBEAM.VM.Builtins.Object, + QuickBEAM.VM.Builtins.Symbol, QuickBEAM.VM.Builtins.Promise ] diff --git a/lib/quickbeam/vm/builtins/promise.ex b/lib/quickbeam/vm/builtins/promise.ex index 6affb3e38..53abea9ec 100644 --- a/lib/quickbeam/vm/builtins/promise.ex +++ b/lib/quickbeam/vm/builtins/promise.ex @@ -20,7 +20,7 @@ defmodule QuickBEAM.VM.Builtins.Promise do kind: :constructor, constructor: :construct, length: 1, - depends_on: ["Object", "Function"] do + depends_on: ["Object", "Function", "Symbol"] do static :all, length: 1 static :all_settled, js: "allSettled", length: 1 static :any, length: 1 @@ -135,7 +135,7 @@ defmodule QuickBEAM.VM.Builtins.Promise do def finally_method(%Call{execution: execution}), do: {:error, :incompatible_promise_receiver, execution} - defp aggregate(kind, %Call{arguments: arguments, execution: execution}) do + defp aggregate(kind, %Call{arguments: arguments, execution: execution} = call) do iterable = List.first(arguments, :undefined) case Iterator.values(iterable, execution) do @@ -143,6 +143,9 @@ defmodule QuickBEAM.VM.Builtins.Promise do {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} = Exceptions.materialize({:type_error, :not_iterable}, execution) {promise, execution} = Promise.new(execution) diff --git a/lib/quickbeam/vm/builtins/symbol.ex b/lib/quickbeam/vm/builtins/symbol.ex new file mode 100644 index 000000000..34705cbfe --- /dev/null +++ b/lib/quickbeam/vm/builtins/symbol.ex @@ -0,0 +1,11 @@ +defmodule QuickBEAM.VM.Builtins.Symbol do + @moduledoc "Defines the well-known symbols exposed by the core VM profile." + + use QuickBEAM.VM.Builtin + + alias QuickBEAM.VM.Symbol + + builtin "Symbol", kind: :namespace do + constant "iterator", Symbol.iterator() + end +end diff --git a/lib/quickbeam/vm/exceptions.ex b/lib/quickbeam/vm/exceptions.ex index ac2e4d91f..b90e76ec8 100644 --- a/lib/quickbeam/vm/exceptions.ex +++ b/lib/quickbeam/vm/exceptions.ex @@ -16,6 +16,8 @@ defmodule QuickBEAM.VM.Exceptions do Frame, Function, Heap, + Iterator, + IteratorBoundary, NativeFrame, Object, ObjectAssignBoundary, @@ -147,6 +149,9 @@ defmodule QuickBEAM.VM.Exceptions do {:complete, boundary.promise, boundary.caller, execution, boundary.tail?} end + defp throw_from_boundary(reason, %IteratorBoundary{} = boundary, execution, trace), + do: Iterator.fail(boundary, thrown(reason, trace), execution) + defp throw_from_boundary(reason, %ReactionBoundary{} = boundary, execution, trace) do execution = Promise.settle(execution, boundary.promise, {:error, thrown(reason, trace)}) {:idle, execution} @@ -228,6 +233,15 @@ defmodule QuickBEAM.VM.Exceptions do throw_from_boundary(reason, boundary, execution, trace) end + defp unwind_caller( + reason, + %Execution{callers: [%IteratorBoundary{} = boundary | callers]} = execution, + trace + ) do + execution = %{execution | callers: callers, depth: boundary.depth} + throw_from_boundary(reason, boundary, execution, trace) + end + defp unwind_caller( reason, %Execution{callers: [%ReactionBoundary{} = boundary | callers]} = execution, diff --git a/lib/quickbeam/vm/export.ex b/lib/quickbeam/vm/export.ex index 2b361b9e2..866e417be 100644 --- a/lib/quickbeam/vm/export.ex +++ b/lib/quickbeam/vm/export.ex @@ -14,7 +14,8 @@ defmodule QuickBEAM.VM.Export do Promise, PromiseReference, Property, - Reference + Reference, + Symbol } @spec value(term(), Execution.t()) :: {:ok, term()} | {:error, term()} @@ -43,6 +44,7 @@ defmodule QuickBEAM.VM.Export do do: {:error, :function_result} defp convert(%QuickBEAM.VM.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, []) @@ -79,7 +81,7 @@ defmodule QuickBEAM.VM.Export do defp convert_object(%Object{properties: properties}, execution, seen) do properties - |> Enum.filter(fn {_key, property} -> property.enumerable end) + |> Enum.filter(fn {key, property} -> property.enumerable and not is_struct(key, Symbol) end) |> Enum.reduce_while({:ok, %{}}, fn {key, %Property{value: value}}, {:ok, result} -> case convert(value, execution, seen) do {:ok, value} -> {:cont, {:ok, Map.put(result, key, value)}} diff --git a/lib/quickbeam/vm/heap.ex b/lib/quickbeam/vm/heap.ex index b854f84a7..a05f0d1a4 100644 --- a/lib/quickbeam/vm/heap.ex +++ b/lib/quickbeam/vm/heap.ex @@ -34,6 +34,29 @@ defmodule QuickBEAM.VM.Heap do {reference, execution} end + @doc "Returns enumerable own string and Symbol keys in ECMAScript order." + @spec assignable_keys(Execution.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 end) + |> Enum.map(&elem(&1, 0)) + |> Enum.sort() + + other_keys = + Enum.filter(object.property_order, fn key -> + not is_integer(key) and match?(%Property{enumerable: true}, object.properties[key]) + end) + + {:ok, integer_keys ++ other_keys} + + :error -> + {:error, {:invalid_reference, id}} + end + end + @spec fetch_object(Execution.t(), Reference.t()) :: {:ok, Object.t()} | :error def fetch_object(%Execution{} = execution, %Reference{id: id}), do: Map.fetch(execution.heap, id) @@ -271,7 +294,7 @@ defmodule QuickBEAM.VM.Heap do string_keys = Enum.filter(object.property_order, fn key -> - not is_integer(key) and match?(%Property{enumerable: true}, object.properties[key]) + is_binary(key) and match?(%Property{enumerable: true}, object.properties[key]) end) {:ok, integer_keys ++ string_keys} diff --git a/lib/quickbeam/vm/interpreter.ex b/lib/quickbeam/vm/interpreter.ex index c3b041ed1..9de6a6462 100644 --- a/lib/quickbeam/vm/interpreter.ex +++ b/lib/quickbeam/vm/interpreter.ex @@ -21,12 +21,15 @@ defmodule QuickBEAM.VM.Interpreter do Frame, Heap, Invocation, + Iterator, + IteratorBoundary, Memory, NativeFrame, ObjectAssignBoundary, Opcodes, Program, Properties, + Promise, PromiseExecutorBoundary, PromiseReference, Reaction, @@ -201,6 +204,10 @@ defmodule QuickBEAM.VM.Interpreter do 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} @@ -309,6 +316,18 @@ defmodule QuickBEAM.VM.Interpreter do ), 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({:iterator_value, value, boundary, execution}) do + {source, execution} = Promise.from_value(execution, value) + boundary = %{boundary | values: [source | boundary.values]} + continue_iterator_sync(boundary, execution) + end + + defp complete_call_result(value, %IteratorBoundary{} = boundary, execution, _tail?), + do: value |> Iterator.resume(boundary, execution) |> execute_invocation() + defp complete_call_result(value, %ReactionBoundary{} = boundary, execution, _tail?), do: complete_reaction(boundary, value, execution) @@ -358,9 +377,29 @@ defmodule QuickBEAM.VM.Interpreter do defp continue_after_then_getter(%ThenGetterBoundary{continuation: %Frame{} = frame}, execution), do: run(frame, execution) + defp continue_after_then_getter( + %ThenGetterBoundary{continuation: %IteratorBoundary{} = boundary}, + execution + ), + do: continue_iterator_sync(boundary, execution) + defp continue_after_then_getter(%ThenGetterBoundary{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 = %{execution | sync_jobs: sync_jobs} + + promise + |> Async.read_thenable(thenable, getter, boundary, execution) + |> execute_async() + + {:empty, _sync_jobs} -> + boundary |> Iterator.continue(%{execution | sync_jobs: {[], []}}) |> execute_invocation() + end + end + defp complete_accessor(value, %AccessorBoundary{mode: :get} = boundary, execution), do: run(%{boundary.caller | stack: [value | boundary.caller.stack]}, execution) @@ -406,7 +445,7 @@ defmodule QuickBEAM.VM.Interpreter do %ObjectAssignBoundary{keys: [], sources: [source | sources]} = boundary, execution ) do - case Properties.enumerable_keys(source, execution) do + case Properties.assignable_keys(source, execution) do {:ok, keys} -> continue_object_assign( %{boundary | source: source, sources: sources, keys: keys, phase: nil, key: nil}, @@ -799,6 +838,14 @@ defmodule QuickBEAM.VM.Interpreter do complete_constructor(value, boundary, execution) end + defp return_value( + value, + %Execution{callers: [%IteratorBoundary{} = boundary | callers]} = execution + ) do + execution = %{execution | callers: callers, depth: boundary.depth} + value |> Iterator.resume(boundary, execution) |> execute_invocation() + end + defp return_value( value, %Execution{callers: [%ReactionBoundary{} = boundary | callers]} = execution diff --git a/lib/quickbeam/vm/invocation.ex b/lib/quickbeam/vm/invocation.ex index 882ee0c53..70341a397 100644 --- a/lib/quickbeam/vm/invocation.ex +++ b/lib/quickbeam/vm/invocation.ex @@ -35,6 +35,8 @@ defmodule QuickBEAM.VM.Invocation do | {:host_call, [term()], term(), Execution.t(), boolean()} | {:object_assign, Reference.t(), [term()], term(), Execution.t(), boolean()} | {:array_iteration, String.t(), term(), [term()], term(), Execution.t(), boolean()} + | {:promise_iterate, atom(), term(), term(), Execution.t(), boolean()} + | {:iterator_value, term(), QuickBEAM.VM.IteratorBoundary.t(), Execution.t()} @doc "Plans one invocation without executing interpreter frames." @spec plan(term(), [term()], term(), term(), Execution.t(), boolean()) :: action() diff --git a/lib/quickbeam/vm/iterator.ex b/lib/quickbeam/vm/iterator.ex index 13443030e..ed332489f 100644 --- a/lib/quickbeam/vm/iterator.ex +++ b/lib/quickbeam/vm/iterator.ex @@ -1,17 +1,31 @@ defmodule QuickBEAM.VM.Iterator do @moduledoc """ - Defines the canonical iterable-value boundary used by Promise combinators. + Implements the canonical iterable-value boundary used by Promise combinators. - The current core profile supports owner-local arrays and sets, internal BEAM - lists, and JavaScript strings. Array holes yield `undefined`, matching array - iterator behavior. Custom Symbol iterators will extend this boundary with - resumable invocation actions rather than adding another combinator path. + 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.{Execution, Heap, Object, Property, Reference} + alias QuickBEAM.VM.{ + Exceptions, + Execution, + Heap, + Invocation, + IteratorBoundary, + Object, + Promise, + Property, + Reference, + Symbol, + Value + } - @doc "Collects the values produced by a supported iterable in iteration order." - @spec values(term(), Execution.t()) :: {:ok, [term()]} | {:error, :not_iterable} + @type action :: Invocation.action() + + @doc "Collects values from an iterable whose protocol requires no JavaScript calls." + @spec values(term(), Execution.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)} @@ -30,6 +44,9 @@ defmodule QuickBEAM.VM.Iterator do {:ok, %Object{kind: :set, internal: %{values: values}}} -> {:ok, values} + {:ok, %Object{}} -> + {:resumable} + _other -> {:error, :not_iterable} end @@ -37,6 +54,173 @@ defmodule QuickBEAM.VM.Iterator do 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(), Execution.t(), boolean()) :: action() + def start(kind, iterable, caller, execution, tail?) do + {promise, execution} = Promise.new(execution) + + boundary = %IteratorBoundary{ + kind: kind, + promise: promise, + 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(), IteratorBoundary.t(), Execution.t()) :: action() + def resume(value, %IteratorBoundary{phase: :iterator_getter} = boundary, execution), + do: invoke_iterator_factory(%{boundary | phase: nil}, value, execution) + + def resume(iterator, %IteratorBoundary{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, %IteratorBoundary{phase: :next_getter} = boundary, execution), + do: invoke_next(%{boundary | phase: nil}, value, execution) + + def resume(result, %IteratorBoundary{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, %IteratorBoundary{phase: :done_getter} = boundary, execution), + do: after_done(%{boundary | phase: nil}, done, execution) + + def resume(value, %IteratorBoundary{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(IteratorBoundary.t(), Execution.t()) :: action() + def continue(%IteratorBoundary{} = boundary, execution), + do: next_iteration(boundary, execution) + + @doc "Rejects the combinator Promise and returns it to the original caller." + @spec reject(IteratorBoundary.t(), term(), Execution.t()) :: action() + def reject(%IteratorBoundary{} = boundary, reason, execution), + do: reject_now(boundary, reason, execution) + + @doc "Handles a JavaScript throw raised while an iterator boundary is active." + @spec fail(IteratorBoundary.t(), term(), Execution.t()) :: action() + def fail(%IteratorBoundary{} = boundary, reason, execution), + do: reject_now(boundary, reason, execution) + + defp read_iterator_method(boundary, execution) do + case QuickBEAM.VM.Properties.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 QuickBEAM.VM.Properties.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 QuickBEAM.VM.Properties.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 + values = Enum.reverse(boundary.values) + execution = Promise.aggregate_into(execution, boundary.promise, boundary.kind, values) + complete(boundary, execution) + else + read_value(boundary, execution) + end + end + + defp read_value(boundary, execution) do + case QuickBEAM.VM.Properties.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_now(boundary, reason, execution) do + {reason, execution} = Exceptions.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 + defp property_value(properties, index) do case Map.get(properties, index) do %Property{value: value} -> value diff --git a/lib/quickbeam/vm/iterator_boundary.ex b/lib/quickbeam/vm/iterator_boundary.ex new file mode 100644 index 000000000..df4a573f6 --- /dev/null +++ b/lib/quickbeam/vm/iterator_boundary.ex @@ -0,0 +1,40 @@ +defmodule QuickBEAM.VM.IteratorBoundary do + @moduledoc "Defines resumable state while a Promise combinator consumes an iterator." + + @enforce_keys [:kind, :promise, :iterable, :caller, :depth] + defstruct [ + :kind, + :promise, + :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__{ + kind: :all | :all_settled | :any | :race, + promise: QuickBEAM.VM.PromiseReference.t(), + 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/opcodes/objects.ex b/lib/quickbeam/vm/opcodes/objects.ex index a4a02bd9c..ea38d0bf6 100644 --- a/lib/quickbeam/vm/opcodes/objects.ex +++ b/lib/quickbeam/vm/opcodes/objects.ex @@ -16,7 +16,9 @@ defmodule QuickBEAM.VM.Opcodes.Objects do :object, :array_from, :define_method, + :define_method_computed, :define_field, + :define_array_el, :get_field, :get_field2, :get_array_el, @@ -109,6 +111,26 @@ defmodule QuickBEAM.VM.Opcodes.Objects do end end + def execute( + :define_method_computed, + [kind], + %{stack: [callable, key, %Reference{} = object | stack]} = frame, + execution + ) do + result = + case kind do + 4 -> Properties.define(object, key, callable, execution) + 5 -> Properties.define_accessor(object, key, :getter, callable, execution) + 6 -> Properties.define_accessor(object, key, :setter, callable, execution) + _kind -> {:error, {:unsupported_method_kind, kind}} + end + + 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], @@ -123,6 +145,18 @@ defmodule QuickBEAM.VM.Opcodes.Objects do end end + def execute( + :define_array_el, + [], + %{stack: [value, key, %Reference{} = object | stack]} = frame, + execution + ) do + case Properties.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(:get_field, [atom], %{stack: [object | stack]} = frame, execution), do: get(object, Locals.resolve_atom(atom, execution), stack, frame, execution) diff --git a/lib/quickbeam/vm/promise.ex b/lib/quickbeam/vm/promise.ex index cf4d3d9af..c9900b009 100644 --- a/lib/quickbeam/vm/promise.ex +++ b/lib/quickbeam/vm/promise.ex @@ -72,11 +72,26 @@ defmodule QuickBEAM.VM.Promise do {result_promise, execution} end + @doc "Creates or reuses the Promise produced by resolving one value." + @spec from_value(Execution.t(), term()) :: {PromiseReference.t(), Execution.t()} + def from_value(execution, value), do: promise_from_value(execution, value) + @spec aggregate(Execution.t(), :all | :all_settled | :any | :race, [term()]) :: {PromiseReference.t(), Execution.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( + Execution.t(), + PromiseReference.t(), + :all | :all_settled | :any | :race, + [term()] + ) :: Execution.t() + def aggregate_into(execution, result_promise, kind, values) do if values == [] do result = case kind do @@ -86,8 +101,7 @@ defmodule QuickBEAM.VM.Promise do :race -> nil end - execution = if result, do: settle(execution, result_promise, result), else: execution - {result_promise, execution} + if result, do: settle(execution, result_promise, result), else: execution else id = make_ref() @@ -111,7 +125,7 @@ defmodule QuickBEAM.VM.Promise do add_aggregate_waiter(execution, source, id, index) end) - {result_promise, execution} + execution end end diff --git a/lib/quickbeam/vm/properties.ex b/lib/quickbeam/vm/properties.ex index e840ba65f..cc387f687 100644 --- a/lib/quickbeam/vm/properties.ex +++ b/lib/quickbeam/vm/properties.ex @@ -151,6 +151,13 @@ defmodule QuickBEAM.VM.Properties do 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(), Execution.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(), Execution.t()) :: boolean() def has_property?(%Reference{} = reference, key, execution), diff --git a/lib/quickbeam/vm/symbol.ex b/lib/quickbeam/vm/symbol.ex new file mode 100644 index 000000000..e9298a73a --- /dev/null +++ b/lib/quickbeam/vm/symbol.ex @@ -0,0 +1,12 @@ +defmodule QuickBEAM.VM.Symbol do + @moduledoc "Defines an owner-independent well-known JavaScript Symbol value." + + @enforce_keys [:id, :description] + defstruct [:id, :description] + + @type t :: %__MODULE__{id: atom(), 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/then_getter_boundary.ex b/lib/quickbeam/vm/then_getter_boundary.ex index 45a319d02..6dfb04aa5 100644 --- a/lib/quickbeam/vm/then_getter_boundary.ex +++ b/lib/quickbeam/vm/then_getter_boundary.ex @@ -13,6 +13,6 @@ defmodule QuickBEAM.VM.ThenGetterBoundary do promise: QuickBEAM.VM.PromiseReference.t(), thenable: QuickBEAM.VM.Reference.t(), depth: non_neg_integer(), - continuation: QuickBEAM.VM.Frame.t() | nil + continuation: QuickBEAM.VM.Frame.t() | QuickBEAM.VM.IteratorBoundary.t() | nil } end diff --git a/lib/quickbeam/vm/value.ex b/lib/quickbeam/vm/value.ex index e02523115..ddd86c696 100644 --- a/lib/quickbeam/vm/value.ex +++ b/lib/quickbeam/vm/value.ex @@ -185,6 +185,7 @@ defmodule QuickBEAM.VM.Value do do: "number" def typeof(value) when is_binary(value), do: "string" + def typeof(%QuickBEAM.VM.Symbol{}), do: "symbol" def typeof(%QuickBEAM.VM.Function{}), do: "function" def typeof(%QuickBEAM.VM.Reference{}), do: "object" def typeof(%QuickBEAM.VM.PromiseReference{}), do: "object" diff --git a/test/vm/builtin_dsl_test.exs b/test/vm/builtin_dsl_test.exs index 76826f7e8..ff11b7ddd 100644 --- a/test/vm/builtin_dsl_test.exs +++ b/test/vm/builtin_dsl_test.exs @@ -113,6 +113,7 @@ defmodule QuickBEAM.VM.BuiltinDSLTest do QuickBEAM.VM.Builtins.String, QuickBEAM.VM.Builtins.Number, QuickBEAM.VM.Builtins.Object, + QuickBEAM.VM.Builtins.Symbol, QuickBEAM.VM.Builtins.Promise ] @@ -121,7 +122,11 @@ defmodule QuickBEAM.VM.BuiltinDSLTest do promise = QuickBEAM.VM.Builtins.Promise.builtin_spec() assert promise.kind == :constructor assert promise.constructor == :construct - assert promise.depends_on == ["Object", "Function"] + assert promise.depends_on == ["Object", "Function", "Symbol"] + + symbol = QuickBEAM.VM.Builtins.Symbol.builtin_spec() + assert symbol.kind == :namespace + assert [%{key: "iterator", value: %QuickBEAM.VM.Symbol{id: :iterator}}] = symbol.statics assert Enum.map(QuickBEAM.VM.Builtins.Object.builtin_spec().statics, & &1.key) == ~w(assign create defineProperty getOwnPropertyDescriptor getOwnPropertyNames getPrototypeOf keys setPrototypeOf) diff --git a/test/vm/object_model_test.exs b/test/vm/object_model_test.exs index 13cc579a9..6e407166a 100644 --- a/test/vm/object_model_test.exs +++ b/test/vm/object_model_test.exs @@ -89,7 +89,9 @@ defmodule QuickBEAM.VM.ObjectModelTest 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 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 diff --git a/test/vm/promise_test.exs b/test/vm/promise_test.exs index d125a8178..d10005151 100644 --- a/test/vm/promise_test.exs +++ b/test/vm/promise_test.exs @@ -73,7 +73,19 @@ defmodule QuickBEAM.VM.PromiseTest do "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(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 @@ -181,6 +193,16 @@ defmodule QuickBEAM.VM.PromiseTest do 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})") diff --git a/test/vm/value_test.exs b/test/vm/value_test.exs index e5abf97c8..d9ac44c7b 100644 --- a/test/vm/value_test.exs +++ b/test/vm/value_test.exs @@ -1,7 +1,7 @@ defmodule QuickBEAM.VM.ValueTest do use ExUnit.Case, async: true - alias QuickBEAM.VM.Value + alias QuickBEAM.VM.{Symbol, Value} test "centralizes JavaScript truthiness and primitive equality" do for value <- [nil, :undefined, :nan, false, 0, 0.0, ""] do @@ -18,6 +18,8 @@ defmodule QuickBEAM.VM.ValueTest do assert Value.abstract_equal?(true, 1) assert Value.abstract_equal?(42, "42") refute Value.abstract_equal?(0, nil) + assert Value.typeof(Symbol.iterator()) == "symbol" + assert Value.strict_equal?(Symbol.iterator(), Symbol.iterator()) end test "dispatches canonical unary and binary arithmetic operations" do From 46132324ebbaedbd5f1d3f4884a1f95da7638587 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 13 Jul 2026 15:58:14 +0200 Subject: [PATCH 38/87] Retire legacy builtin method dispatch --- docs/beam-interpreter-architecture.md | 46 ++--- docs/prototype-delta-audit.md | 17 +- lib/quickbeam/vm/builtin/dsl.ex | 28 +++ lib/quickbeam/vm/builtin/installer.ex | 52 +++++- lib/quickbeam/vm/builtin/registry.ex | 9 + lib/quickbeam/vm/builtin/spec.ex | 25 ++- lib/quickbeam/vm/builtin/validator.ex | 14 +- lib/quickbeam/vm/builtins.ex | 247 ++------------------------ lib/quickbeam/vm/builtins/errors.ex | 167 +++++++++++++++++ lib/quickbeam/vm/builtins/function.ex | 52 ++++++ lib/quickbeam/vm/builtins/object.ex | 45 +++++ lib/quickbeam/vm/builtins/set.ex | 196 ++++++++++++++++++++ lib/quickbeam/vm/exceptions.ex | 17 +- lib/quickbeam/vm/interpreter.ex | 14 +- lib/quickbeam/vm/invocation.ex | 24 +-- lib/quickbeam/vm/iterator.ex | 49 ++++- lib/quickbeam/vm/iterator_boundary.ex | 10 +- lib/quickbeam/vm/properties.ex | 21 +-- test/vm/builtin_dsl_test.exs | 17 ++ test/vm/error_test.exs | 10 ++ test/vm/invocation_test.exs | 38 ++-- test/vm/memory_limit_test.exs | 4 +- test/vm/object_model_test.exs | 28 +++ test/vm/properties_test.exs | 6 +- 24 files changed, 794 insertions(+), 342 deletions(-) create mode 100644 lib/quickbeam/vm/builtins/errors.ex create mode 100644 lib/quickbeam/vm/builtins/function.ex create mode 100644 lib/quickbeam/vm/builtins/set.ex diff --git a/docs/beam-interpreter-architecture.md b/docs/beam-interpreter-architecture.md index ec33412bb..183792227 100644 --- a/docs/beam-interpreter-architecture.md +++ b/docs/beam-interpreter-architecture.md @@ -414,8 +414,8 @@ synchronously and queues invocation of returned then functions as microtasks. ## Declarative builtins Builtin modules use `QuickBEAM.VM.Builtin` to compile constructor, namespace, -intrinsic, static, prototype, function, data-property, constant, and accessor -declarations into immutable specs. Handler atoms are primary and JavaScript +intrinsic, static, prototype, function, data-property, constant, accessor, and +property-alias declarations into immutable specs. Handler atoms are primary and JavaScript names are inferred unless an explicit camelCase `js:` name is required: ```elixir @@ -433,9 +433,10 @@ The formatter preserves this parenthesis-free syntax. Macro compilation, validation, installation, and runtime dispatch are separate modules. Constants are evaluated in the declaring module, descriptors and accessors have typed specs, and each builtin declares one or more profiles plus explicit intrinsic -dependencies. An explicit profile registry installs specs in validated dependency -order into each owner-local execution. Runtime application-module discovery is -forbidden. +dependencies. Constructor specs can declare prototype parents and semantic roles +for default or Error prototypes. An explicit profile registry installs specs in +validated dependency order into each owner-local execution. Runtime +application-module discovery is forbidden. Installed functions are real owner-local function objects carrying stable module/handler tokens, not captured closures. Calls receive an explicit @@ -445,16 +446,18 @@ planner. Resumable results use a typed `QuickBEAM.VM.Builtin.Action`; malformed handler results raise an infrastructure contract error. Compile-time checks reject missing handlers, invalid lengths and descriptors, unknown builtin kinds, empty profiles, malformed dependencies, duplicate declarations, and duplicate -keys. The migrated slice now includes `Math`, -`String.fromCharCode`, every currently supported Array prototype method, -`Array.isArray`, and all currently supported Object statics, including -resumable callbacks, `Object.assign`, descriptor validation, and all currently -supported String and Number prototype methods. Primitive strings, numbers, and -internal BEAM lists resolve methods from the same installed intrinsic prototype -objects, eliminating parallel pseudo-method implementations. The legacy -dispatcher remains only for builtins not yet migrated. Real function metadata -increases the intrinsic heap baseline, which remains included in logical memory -accounting. +keys. The migrated slice includes `Math`, Promise, Symbol, Set, the complete +Error hierarchy, `Function.prototype.call`/`bind`, all currently supported +Object statics and prototype methods, every currently supported Array prototype +method, `Array.isArray`, `String.fromCharCode`, and all currently supported +String and Number prototype methods. Resumable callbacks, `Object.assign`, +descriptor validation, Symbol aliases, and intrinsic prototype topology all use +the same installation and invocation paths. Primitive strings, numbers, +internal BEAM lists, functions, and owner-local Set values resolve methods from +installed intrinsic prototypes, eliminating their parallel pseudo-method +implementations. The bootstrap dispatcher remains only for constructors and +builtins not yet migrated. Real function metadata increases the intrinsic heap +baseline, which remains included in logical memory accounting. ## ECMAScript and host profiles @@ -552,12 +555,13 @@ insertion-ordered sets, strings, internal BEAM lists, and custom `Symbol.iterator` is an immutable well-known Symbol value and computed Symbol property keys remain distinct from strings and enumeration names. -Custom iterators run as an explicit resumable state machine. Iterator getters, -the iterator factory, the cached `next` method, and accessor-backed `done` and -`value` reads all dispatch through canonical invocation without recursive -JavaScript execution. Throws become rejected combinator Promises with async -stack information preserved. Each resumed JavaScript call remains subject to -the evaluation's shared step, stack, memory, and timeout limits. +Custom iterators run as an explicit resumable state machine shared by Promise +combinators and the Set constructor. Iterator getters, the iterator factory, the +cached `next` method, and accessor-backed `done` and `value` reads all dispatch +through canonical invocation without recursive JavaScript execution. Throws +become rejected combinator Promises or synchronous Set-construction exceptions +as required. Each resumed JavaScript call remains subject to the evaluation's +shared step, stack, memory, and timeout limits. ```text Evaluation owner Host Task Supervisor diff --git a/docs/prototype-delta-audit.md b/docs/prototype-delta-audit.md index 9cf15f623..fa9d01dce 100644 --- a/docs/prototype-delta-audit.md +++ b/docs/prototype-delta-audit.md @@ -386,9 +386,11 @@ High-value test groups to adapt next: - Handlers use stable module/function tokens and explicit call contexts through canonical invocation semantics; resumable actions are typed and malformed results fail as infrastructure contract errors. -- Migrated `Math`, `String.fromCharCode`, `Array.isArray`, all currently - supported Array prototype methods, and all currently supported Object statics - with real function objects and descriptor, `name`, and `length` metadata. +- Migrated `Math`, Promise, Symbol, Set, the Error hierarchy, shared Function + methods, `String.fromCharCode`, `Array.isArray`, all currently supported Array + prototype methods, and all currently supported Object statics and prototype + methods with real function objects and descriptor, `name`, and `length` + metadata. - `Object.assign` and Array callback methods prove that DSL handlers can return canonical resumable actions without recursively invoking JavaScript. - Descriptor-heavy Object methods now share canonical descriptor validation; @@ -403,15 +405,18 @@ High-value test groups to adapt next: - Custom iterator getters, factories, cached `next` methods, and accessor-backed `done`/`value` reads resume through explicit boundaries; computed Symbol methods and fields are supported without recursive JavaScript execution. -- Continue with object/error prototype methods that remain in the legacy - dispatcher. +- Constructor prototype parents, Error prototype registration, and Symbol-keyed + aliases are explicit installer topology. Legacy object/error/set/function + pseudo-method tokens have been removed; the remaining dispatcher is limited + to bootstrap constructors and unmigrated builtins. ### Phase C — expand conformance by profile demand - Added differential sparse-array tests and hole-aware native callback frames; callbacks skip holes, `map` preserves them, and `reduce` finds the first present element when no initial value is supplied. -- Add Symbol/iterator foundations and iterable Promise combinators. +- Expand Symbol APIs and iterator consumers only when required by the selected + compatibility profile. - Expand the pinned Test262 manifest with exact classified thresholds. - Add decoder mutation fuzzing. diff --git a/lib/quickbeam/vm/builtin/dsl.ex b/lib/quickbeam/vm/builtin/dsl.ex index 42ea403e7..7dce709c8 100644 --- a/lib/quickbeam/vm/builtin/dsl.ex +++ b/lib/quickbeam/vm/builtin/dsl.ex @@ -88,6 +88,32 @@ defmodule QuickBEAM.VM.Builtin.DSL do 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.AliasSpec{ + 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 + 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.AliasSpec{ + 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 + end + @doc "Declares a prototype getter." defmacro getter(handler, opts \\ []) do spec = accessor_spec(handler, Keyword.put(opts, :get, handler)) @@ -136,6 +162,8 @@ defmodule QuickBEAM.VM.Builtin.DSL do module: env.module, kind: Keyword.get(opts, :kind, :namespace), constructor: Keyword.get(opts, :constructor), + prototype_parent: Keyword.get(opts, :prototype_parent), + prototype_role: Keyword.get(opts, :prototype_role), profiles: Keyword.get(opts, :profiles, [:core]), depends_on: Keyword.get(opts, :depends_on, []), length: Keyword.get(opts, :length, 0), diff --git a/lib/quickbeam/vm/builtin/installer.ex b/lib/quickbeam/vm/builtin/installer.ex index 93cb20a13..247eb770b 100644 --- a/lib/quickbeam/vm/builtin/installer.ex +++ b/lib/quickbeam/vm/builtin/installer.ex @@ -7,7 +7,7 @@ defmodule QuickBEAM.VM.Builtin.Installer do stable module/handler tokens rather than captured closures. """ - alias QuickBEAM.VM.Builtin.{AccessorSpec, FunctionSpec, PropertySpec, Spec} + alias QuickBEAM.VM.Builtin.{AccessorSpec, AliasSpec, FunctionSpec, PropertySpec, Spec} alias QuickBEAM.VM.{Execution, Heap, Properties, Reference} @doc "Installs registered builtin modules for the selected profile." @@ -39,7 +39,8 @@ defmodule QuickBEAM.VM.Builtin.Installer do 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) - {prototype, execution} = Heap.allocate(execution) + parent = resolve_prototype_parent(execution, spec.prototype_parent) + {prototype, execution} = Heap.allocate(execution, :ordinary, prototype: parent) {:ok, execution} = Properties.define(prototype, "constructor", constructor, execution, @@ -57,6 +58,7 @@ defmodule QuickBEAM.VM.Builtin.Installer do execution = install_entries(execution, constructor, spec.module, spec.statics) execution = install_entries(execution, prototype, spec.module, spec.prototype) + execution = register_prototype_role(execution, prototype, spec.prototype_role) put_global(execution, spec.name, constructor) end @@ -111,6 +113,24 @@ defmodule QuickBEAM.VM.Builtin.Installer do execution end + defp install_entry(execution, target, _module, %AliasSpec{} = spec) do + {:ok, value} = Properties.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} = + Properties.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} = Properties.define(target, spec.key, spec.value, execution, @@ -150,6 +170,34 @@ defmodule QuickBEAM.VM.Builtin.Installer do allocate_function(execution, to_string(key), 0, token) end + defp resolve_prototype_parent(execution, nil), + do: Map.get(execution.default_prototypes, :ordinary) + + defp resolve_prototype_parent(_execution, :null), do: nil + + defp resolve_prototype_parent(execution, parent_name) do + constructor = Map.fetch!(execution.globals, parent_name) + {:ok, %Reference{} = prototype} = Properties.get(constructor, "prototype", execution) + prototype + end + + defp register_prototype_role(execution, _prototype, nil), do: execution + + defp register_prototype_role(execution, prototype, :ordinary), + do: %{ + execution + | default_prototypes: Map.put(execution.default_prototypes, :ordinary, prototype) + } + + defp register_prototype_role(execution, prototype, :function), + do: %{ + execution + | default_prototypes: Map.put(execution.default_prototypes, :function, prototype) + } + + defp register_prototype_role(execution, prototype, {:error, name}), + do: %{execution | error_prototypes: Map.put(execution.error_prototypes, name, prototype)} + defp put_global(execution, name, value), do: %{execution | globals: Map.put(execution.globals, name, value)} diff --git a/lib/quickbeam/vm/builtin/registry.ex b/lib/quickbeam/vm/builtin/registry.ex index 06b6174e0..4a91bd853 100644 --- a/lib/quickbeam/vm/builtin/registry.ex +++ b/lib/quickbeam/vm/builtin/registry.ex @@ -12,7 +12,16 @@ defmodule QuickBEAM.VM.Builtin.Registry do QuickBEAM.VM.Builtins.String, QuickBEAM.VM.Builtins.Number, QuickBEAM.VM.Builtins.Object, + QuickBEAM.VM.Builtins.Function, + QuickBEAM.VM.Builtins.Error, + QuickBEAM.VM.Builtins.EvalError, + QuickBEAM.VM.Builtins.RangeError, + QuickBEAM.VM.Builtins.ReferenceError, + QuickBEAM.VM.Builtins.SyntaxError, + QuickBEAM.VM.Builtins.TypeError, + QuickBEAM.VM.Builtins.URIError, QuickBEAM.VM.Builtins.Symbol, + QuickBEAM.VM.Builtins.Set, QuickBEAM.VM.Builtins.Promise ] diff --git a/lib/quickbeam/vm/builtin/spec.ex b/lib/quickbeam/vm/builtin/spec.ex index 996bf0fc6..500cdd31a 100644 --- a/lib/quickbeam/vm/builtin/spec.ex +++ b/lib/quickbeam/vm/builtin/spec.ex @@ -6,13 +6,15 @@ defmodule QuickBEAM.VM.Builtin.Spec do not contain heap references or captured functions. """ - alias QuickBEAM.VM.Builtin.{AccessorSpec, FunctionSpec, PropertySpec} + alias QuickBEAM.VM.Builtin.{AccessorSpec, AliasSpec, FunctionSpec, PropertySpec} @enforce_keys [:name, :module, :kind] defstruct [ :name, :module, :constructor, + :prototype_parent, + :prototype_role, profiles: [:core], depends_on: [], kind: :namespace, @@ -27,11 +29,13 @@ defmodule QuickBEAM.VM.Builtin.Spec do module: module(), kind: kind(), constructor: atom() | nil, + prototype_parent: String.t() | :null | nil, + prototype_role: :ordinary | :function | {:error, String.t()} | nil, profiles: [atom()], depends_on: [String.t()], length: non_neg_integer(), - statics: [FunctionSpec.t() | PropertySpec.t() | AccessorSpec.t()], - prototype: [FunctionSpec.t() | PropertySpec.t() | AccessorSpec.t()] + statics: [FunctionSpec.t() | PropertySpec.t() | AccessorSpec.t() | AliasSpec.t()], + prototype: [FunctionSpec.t() | PropertySpec.t() | AccessorSpec.t() | AliasSpec.t()] } end @@ -66,6 +70,21 @@ defmodule QuickBEAM.VM.Builtin.AccessorSpec do } end +defmodule QuickBEAM.VM.Builtin.AliasSpec 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 + defmodule QuickBEAM.VM.Builtin.PropertySpec do @moduledoc "Defines a declarative JavaScript builtin data property." diff --git a/lib/quickbeam/vm/builtin/validator.ex b/lib/quickbeam/vm/builtin/validator.ex index 4d1207391..d38108395 100644 --- a/lib/quickbeam/vm/builtin/validator.ex +++ b/lib/quickbeam/vm/builtin/validator.ex @@ -1,7 +1,7 @@ defmodule QuickBEAM.VM.Builtin.Validator do @moduledoc "Validates builtin declarations and handler contracts at compile time." - alias QuickBEAM.VM.Builtin.{AccessorSpec, FunctionSpec, PropertySpec, Spec} + alias QuickBEAM.VM.Builtin.{AccessorSpec, AliasSpec, FunctionSpec, PropertySpec, Spec} @doc "Validates a compiled builtin spec against its declaring module." @spec validate!(Spec.t(), Macro.Env.t()) :: :ok @@ -30,6 +30,16 @@ defmodule QuickBEAM.VM.Builtin.Validator do compile_error!(env, "constructor builtins require a :constructor handler") end + unless is_nil(spec.prototype_parent) or spec.prototype_parent == :null or + (is_binary(spec.prototype_parent) and spec.prototype_parent in spec.depends_on) do + compile_error!(env, "prototype parent must be :null or a declared dependency") + end + + unless is_nil(spec.prototype_role) or spec.prototype_role in [:ordinary, :function] or + match?({:error, name} when is_binary(name), spec.prototype_role) do + compile_error!(env, "unsupported prototype role: #{inspect(spec.prototype_role)}") + end + entries = spec.statics ++ spec.prototype duplicate_keys!(spec.statics, :static, env) duplicate_keys!(spec.prototype, :prototype, env) @@ -57,6 +67,7 @@ defmodule QuickBEAM.VM.Builtin.Validator do 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 @@ -71,6 +82,7 @@ defmodule QuickBEAM.VM.Builtin.Validator do 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 = diff --git a/lib/quickbeam/vm/builtins.ex b/lib/quickbeam/vm/builtins.ex index a93bd02b9..26d4959ce 100644 --- a/lib/quickbeam/vm/builtins.ex +++ b/lib/quickbeam/vm/builtins.ex @@ -8,7 +8,6 @@ defmodule QuickBEAM.VM.Builtins do Heap, Object, Properties, - Property, Reference, RegExp, Value @@ -16,23 +15,13 @@ defmodule QuickBEAM.VM.Builtins do alias QuickBEAM.VM.Builtin.{Installer, Registry} - @error_types ~w(Error EvalError RangeError ReferenceError SyntaxError TypeError URIError) - @constructors %{ "Array" => [], "Boolean" => [], "Object" => [], - "EvalError" => [], "Function" => [], "Number" => [], - "RangeError" => [], - "ReferenceError" => [], - "String" => [], - "SyntaxError" => [], - "TypeError" => [], - "URIError" => [], - "Error" => [], - "Set" => [] + "String" => [] } @spec install(Execution.t()) :: Execution.t() @@ -72,7 +61,7 @@ defmodule QuickBEAM.VM.Builtins do end @doc "Allocates a catchable JavaScript error object in the current evaluation heap." - @spec new_error(Execution.t(), String.t(), String.t()) :: {Reference.t(), Execution.t()} + @spec new_error(Execution.t(), String.t(), String.t() | nil) :: {Reference.t(), Execution.t()} def new_error(execution, name, message) do prototype = Map.get(execution.error_prototypes, name) || Map.get(execution.error_prototypes, "Error") @@ -80,65 +69,25 @@ defmodule QuickBEAM.VM.Builtins do {error, execution} = Heap.allocate(execution, :ordinary, prototype: prototype, internal: {:error, name}) - {:ok, execution} = - Properties.define(error, "message", message, execution, - enumerable: false, - configurable: true, - writable: true - ) - - {error, execution} - end - - @spec call(term(), term(), [term()], Execution.t()) :: - {:ok, term(), Execution.t()} | {:error, term(), Execution.t()} - def call( - {:primitive_method, :object, "hasOwnProperty"}, - %Reference{} = object, - [key | _], + execution = + if is_nil(message) do execution - ) do - case Properties.own_property(object, key, execution) do - {:ok, property} -> {:ok, not is_nil(property), execution} - {:error, reason} -> {:error, reason, execution} - end - end + else + {:ok, execution} = + Properties.define(error, "message", message, execution, + enumerable: false, + configurable: true, + writable: true + ) - def call( - {:primitive_method, :object, "propertyIsEnumerable"}, - %Reference{} = object, - [key | _], execution - ) do - case Properties.own_property(object, key, execution) do - {:ok, %Property{enumerable: enumerable}} -> {:ok, enumerable, execution} - {:ok, nil} -> {:ok, false, execution} - {:error, reason} -> {:error, reason, execution} - end - end - - def call({:primitive_method, :object, "toString"}, _object, _arguments, execution), - do: {:ok, "[object Object]", execution} - - def call({:primitive_method, :error, "toString"}, %Reference{} = error, _arguments, execution) do - with {:ok, name} <- Properties.get(error, "name", execution), - {:ok, message} <- Properties.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 + end - {:ok, result, execution} - else - {:error, reason} -> {:error, reason, execution} - end + {error, execution} end + @spec call(term(), term(), [term()], Execution.t()) :: + {:ok, term(), Execution.t()} | {:error, term(), Execution.t()} def call({:primitive_method, :regexp, "test"}, %RegExp{} = regexp, [value | _], execution), do: {:ok, regex_match?(regexp, Value.to_string_value(value)), execution} @@ -180,50 +129,6 @@ defmodule QuickBEAM.VM.Builtins do def call({:builtin, "String"}, _this, [], execution), do: {:ok, "", execution} - def call({:builtin, "Set"}, _this, values, execution) do - entries = - case values do - [value | _] -> - case array_values(value, execution) do - {:ok, entries} -> entries - _ -> [] - end - - [] -> - [] - end - - entries = Enum.uniq(entries) - internal = %{values: entries, index: MapSet.new(entries)} - {set, execution} = Heap.allocate(execution, :set, internal: internal) - {:ok, set, execution} - end - - def call({:primitive_method, :set, "has"}, %Reference{} = set, [value | _], execution) do - case Heap.fetch_object(execution, set) do - {:ok, %Object{kind: :set, internal: %{index: entries}}} -> - {:ok, MapSet.member?(entries, value), execution} - - _ -> - {:error, :not_a_set, execution} - end - end - - def call({:primitive_method, :set, "add"}, %Reference{} = set, [value | _], execution) do - case Heap.update_object(execution, set, fn object -> - %{values: values, index: index} = object.internal - - if MapSet.member?(index, value) do - object - else - %{object | internal: %{values: values ++ [value], index: MapSet.put(index, value)}} - end - end) do - {:ok, execution} -> {:ok, set, execution} - {:error, reason} -> {:error, reason, execution} - end - end - def call({:builtin, "FunctionPrototype"}, _this, _arguments, execution), do: {:ok, :undefined, execution} @@ -249,38 +154,6 @@ defmodule QuickBEAM.VM.Builtins do {:ok, object, execution} end - def call({:builtin, name}, this, values, execution) - when name in @error_types do - message = - case values do - [value | _] -> Value.to_string_value(value) - [] -> "" - end - - case constructor_instance?(this, execution) do - true -> - prototype = Map.get(execution.error_prototypes, name) - - {:ok, execution} = - Heap.update_object(execution, this, fn object -> - %{object | prototype: prototype, internal: {:error, name}} - end) - - {:ok, execution} = - Properties.define(this, "message", message, execution, - enumerable: false, - configurable: true, - writable: true - ) - - {:ok, this, execution} - - false -> - {error, execution} = new_error(execution, name, message) - {:ok, error, execution} - end - end - def call(callable, _this, _arguments, execution), do: {:error, {:unsupported_builtin, callable}, execution} @@ -304,17 +177,6 @@ defmodule QuickBEAM.VM.Builtins do defp maybe_install_prototype("Object", constructor, execution) do {prototype, execution} = Heap.allocate(execution, :ordinary, prototype: nil) - execution = - Enum.reduce(["hasOwnProperty", "propertyIsEnumerable", "toString"], execution, fn method, - execution -> - {:ok, execution} = - Properties.define(prototype, method, {:primitive_method, :object, method}, execution, - enumerable: false - ) - - execution - end) - {:ok, execution} = Properties.define(prototype, "constructor", constructor, execution, enumerable: false) @@ -327,50 +189,6 @@ defmodule QuickBEAM.VM.Builtins do } end - defp maybe_install_prototype("Error", constructor, execution) do - object_prototype = Map.get(execution.default_prototypes, :ordinary) - {prototype, execution} = Heap.allocate(execution, :ordinary, prototype: object_prototype) - - execution = - [ - {"name", "Error"}, - {"message", ""}, - {"constructor", constructor}, - {"toString", {:primitive_method, :error, "toString"}} - ] - |> Enum.reduce(execution, fn {key, value}, execution -> - {:ok, execution} = - Properties.define(prototype, key, value, execution, enumerable: false) - - execution - end) - - {:ok, execution} = - Properties.define(constructor, "prototype", prototype, execution, enumerable: false) - - %{execution | error_prototypes: Map.put(execution.error_prototypes, "Error", prototype)} - end - - defp maybe_install_prototype(name, constructor, execution) - when name in @error_types do - error_prototype = Map.fetch!(execution.error_prototypes, "Error") - {prototype, execution} = Heap.allocate(execution, :ordinary, prototype: error_prototype) - - execution = - [{"name", name}, {"message", ""}, {"constructor", constructor}] - |> Enum.reduce(execution, fn {key, value}, execution -> - {:ok, execution} = - Properties.define(prototype, key, value, execution, enumerable: false) - - execution - end) - - {:ok, execution} = - Properties.define(constructor, "prototype", prototype, execution, enumerable: false) - - %{execution | error_prototypes: Map.put(execution.error_prototypes, name, prototype)} - end - defp maybe_install_prototype("Function", constructor, execution) do {prototype, execution} = Heap.allocate(execution, :function, @@ -430,15 +248,6 @@ defmodule QuickBEAM.VM.Builtins do end end - 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 maybe_box_primitive(receiver, kind, value, execution) do case Heap.fetch_object(execution, receiver) do {:ok, %Object{internal: :constructor_instance}} -> @@ -466,32 +275,6 @@ defmodule QuickBEAM.VM.Builtins do {array, execution} end - defp array_values(value, _execution) when is_list(value), do: {:ok, value} - - defp array_values(%Reference{} = reference, execution) do - case Heap.fetch_object(execution, reference) do - {:ok, %Object{kind: :array, length: length, properties: properties}} -> - values = - if length == 0, - do: [], - else: for(index <- 0..(length - 1), do: property_value(properties, index)) - - {:ok, values} - - _ -> - {:error, :not_an_array} - end - end - - defp array_values(_value, _execution), do: {:error, :not_an_array} - - defp property_value(properties, index) do - case Map.get(properties, index) do - %Property{value: value} -> value - nil -> :undefined - end - end - defp regex_match?(%RegExp{source: source}, value) do case Regex.compile(source) do {:ok, regex} -> Regex.match?(regex, value) diff --git a/lib/quickbeam/vm/builtins/errors.ex b/lib/quickbeam/vm/builtins/errors.ex new file mode 100644 index 000000000..6ed0f70cb --- /dev/null +++ b/lib/quickbeam/vm/builtins/errors.ex @@ -0,0 +1,167 @@ +defmodule QuickBEAM.VM.Builtins.ErrorSupport do + @moduledoc "Provides shared declarative Error constructor and formatting semantics." + + alias QuickBEAM.VM.Builtin.Call + alias QuickBEAM.VM.{Builtins, Heap, Object, Properties, 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} = Builtins.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} <- Properties.get(error, "name", execution), + {:ok, message} <- Properties.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} = + Properties.define(error, "message", message, execution, + enumerable: false, + configurable: true, + writable: true + ) + + execution + end +end + +defmodule QuickBEAM.VM.Builtins.Error do + @moduledoc "Defines the declarative JavaScript Error constructor and prototype." + + use QuickBEAM.VM.Builtin + + alias QuickBEAM.VM.Builtin.Call + alias QuickBEAM.VM.Builtins.ErrorSupport + + builtin "Error", + kind: :constructor, + constructor: :construct, + length: 1, + depends_on: ["Object", "Function"], + prototype_parent: "Object", + prototype_role: {:error, "Error"} do + prototype 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: ErrorSupport.construct("Error", call) + + @doc "Formats an Error value." + def to_string_method(%Call{} = call), do: ErrorSupport.to_string(call) +end + +defmodule QuickBEAM.VM.Builtins.ErrorSubclass do + @moduledoc "Generates one declarative native Error subclass builtin." + + defmacro __using__(opts) do + name = Keyword.fetch!(opts, :name) + + quote do + use QuickBEAM.VM.Builtin + + alias QuickBEAM.VM.Builtin.Call + alias QuickBEAM.VM.Builtins.ErrorSupport + + @error_name unquote(name) + + builtin unquote(name), + kind: :constructor, + constructor: :construct, + length: 1, + depends_on: ["Error", "Function"], + prototype_parent: "Error", + prototype_role: {:error, unquote(name)} do + prototype 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: ErrorSupport.construct(@error_name, call) + end + end +end + +defmodule QuickBEAM.VM.Builtins.EvalError do + @moduledoc "Defines the declarative EvalError constructor." + use QuickBEAM.VM.Builtins.ErrorSubclass, name: "EvalError" +end + +defmodule QuickBEAM.VM.Builtins.RangeError do + @moduledoc "Defines the declarative RangeError constructor." + use QuickBEAM.VM.Builtins.ErrorSubclass, name: "RangeError" +end + +defmodule QuickBEAM.VM.Builtins.ReferenceError do + @moduledoc "Defines the declarative ReferenceError constructor." + use QuickBEAM.VM.Builtins.ErrorSubclass, name: "ReferenceError" +end + +defmodule QuickBEAM.VM.Builtins.SyntaxError do + @moduledoc "Defines the declarative SyntaxError constructor." + use QuickBEAM.VM.Builtins.ErrorSubclass, name: "SyntaxError" +end + +defmodule QuickBEAM.VM.Builtins.TypeError do + @moduledoc "Defines the declarative TypeError constructor." + use QuickBEAM.VM.Builtins.ErrorSubclass, name: "TypeError" +end + +defmodule QuickBEAM.VM.Builtins.URIError do + @moduledoc "Defines the declarative URIError constructor." + use QuickBEAM.VM.Builtins.ErrorSubclass, name: "URIError" +end diff --git a/lib/quickbeam/vm/builtins/function.ex b/lib/quickbeam/vm/builtins/function.ex new file mode 100644 index 000000000..4d4038dbb --- /dev/null +++ b/lib/quickbeam/vm/builtins/function.ex @@ -0,0 +1,52 @@ +defmodule QuickBEAM.VM.Builtins.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.Invocation + + builtin "Function", kind: :intrinsic, depends_on: ["Object"] do + prototype do + method :bind, length: 1 + method :call, length: 1 + end + end + + @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/builtins/object.ex b/lib/quickbeam/vm/builtins/object.ex index dc433bb4d..65d9f4062 100644 --- a/lib/quickbeam/vm/builtins/object.ex +++ b/lib/quickbeam/vm/builtins/object.ex @@ -16,8 +16,53 @@ defmodule QuickBEAM.VM.Builtins.Object do static :get_prototype_of, js: "getPrototypeOf", length: 1 static :keys, length: 1 static :set_prototype_of, js: "setPrototypeOf", length: 2 + + prototype 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.prototype.hasOwnProperty`." + def has_own_property(%Call{ + this: %Reference{} = object, + arguments: arguments, + execution: execution + }) do + key = List.first(arguments, :undefined) + + case Properties.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 Properties.own_property(object, key, execution) do + {:ok, %Property{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], diff --git a/lib/quickbeam/vm/builtins/set.ex b/lib/quickbeam/vm/builtins/set.ex new file mode 100644 index 000000000..6ce90950c --- /dev/null +++ b/lib/quickbeam/vm/builtins/set.ex @@ -0,0 +1,196 @@ +defmodule QuickBEAM.VM.Builtins.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.{ + ConstructorBoundary, + Heap, + Iterator, + Object, + Properties, + Reference, + Symbol + } + + builtin "Set", + kind: :constructor, + constructor: :construct, + length: 0, + depends_on: ["Object", "Function", "Symbol"] do + prototype do + method :add, 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: %ConstructorBoundary{}, + 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, fn + %Object{kind: :set, internal: %{values: values, index: index}} = object -> + if MapSet.member?(index, value) do + object + else + %{object | internal: %{values: values ++ [value], index: MapSet.put(index, value)}} + end + + object -> + object + end) 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 "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, 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} = + Properties.define(next, "name", "next", execution, + writable: false, + enumerable: false, + configurable: true + ) + + {:ok, execution} = + Properties.define(next, "length", 0, execution, + writable: false, + enumerable: false, + configurable: true + ) + + {:ok, execution} = + Properties.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} = Properties.define(result, "value", value, execution) + {:ok, execution} = Properties.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 + values = Enum.uniq(values) + + Heap.update_object(execution, receiver, fn object -> + %{object | kind: :set, internal: %{values: values, index: MapSet.new(values)}} + end) + end + + defp declared(handler), do: {:declared_builtin, __MODULE__, handler} +end diff --git a/lib/quickbeam/vm/exceptions.ex b/lib/quickbeam/vm/exceptions.ex index b90e76ec8..4e0db65f5 100644 --- a/lib/quickbeam/vm/exceptions.ex +++ b/lib/quickbeam/vm/exceptions.ex @@ -149,8 +149,21 @@ defmodule QuickBEAM.VM.Exceptions do {:complete, boundary.promise, boundary.caller, execution, boundary.tail?} end - defp throw_from_boundary(reason, %IteratorBoundary{} = boundary, execution, trace), - do: Iterator.fail(boundary, thrown(reason, trace), execution) + defp throw_from_boundary( + reason, + %IteratorBoundary{consumer: :promise} = boundary, + execution, + trace + ), + do: Iterator.fail(boundary, thrown(reason, trace), execution) + + defp throw_from_boundary( + reason, + %IteratorBoundary{consumer: :set, caller: %ConstructorBoundary{} = constructor}, + execution, + trace + ), + do: do_throw(reason, constructor.caller, execution, trace, true) defp throw_from_boundary(reason, %ReactionBoundary{} = boundary, execution, trace) do execution = Promise.settle(execution, boundary.promise, {:error, thrown(reason, trace)}) diff --git a/lib/quickbeam/vm/interpreter.ex b/lib/quickbeam/vm/interpreter.ex index 9de6a6462..347537785 100644 --- a/lib/quickbeam/vm/interpreter.ex +++ b/lib/quickbeam/vm/interpreter.ex @@ -319,12 +319,24 @@ defmodule QuickBEAM.VM.Interpreter do defp execute_invocation({:promise_iterate, kind, iterable, caller, execution, tail?}), do: kind |> Iterator.start(iterable, caller, execution, tail?) |> execute_invocation() - defp execute_invocation({:iterator_value, value, boundary, execution}) do + defp execute_invocation({:set_iterate, target, iterable, caller, execution, tail?}), + do: target |> Iterator.start_set(iterable, caller, execution, tail?) |> execute_invocation() + + defp execute_invocation( + {:iterator_value, value, %IteratorBoundary{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, %IteratorBoundary{consumer: :set} = boundary, execution} + ) do + boundary = %{boundary | values: [value | boundary.values]} + boundary |> Iterator.continue(execution) |> execute_invocation() + end + defp complete_call_result(value, %IteratorBoundary{} = boundary, execution, _tail?), do: value |> Iterator.resume(boundary, execution) |> execute_invocation() diff --git a/lib/quickbeam/vm/invocation.ex b/lib/quickbeam/vm/invocation.ex index 70341a397..97ae91960 100644 --- a/lib/quickbeam/vm/invocation.ex +++ b/lib/quickbeam/vm/invocation.ex @@ -36,6 +36,7 @@ defmodule QuickBEAM.VM.Invocation do | {:object_assign, Reference.t(), [term()], term(), Execution.t(), boolean()} | {:array_iteration, String.t(), term(), [term()], term(), Execution.t(), boolean()} | {:promise_iterate, atom(), term(), term(), Execution.t(), boolean()} + | {:set_iterate, Reference.t(), term(), term(), Execution.t(), boolean()} | {:iterator_value, term(), QuickBEAM.VM.IteratorBoundary.t(), Execution.t()} @doc "Plans one invocation without executing interpreter frames." @@ -100,28 +101,6 @@ defmodule QuickBEAM.VM.Invocation do ), do: {:dispatch, target, bound_arguments ++ arguments, bound_this, caller, execution, tail?} - def plan( - {:function_method, "bind"}, - [bound_this | bound_arguments], - target, - caller, - execution, - false - ), - do: - {:complete, {:bound_function, target, bound_this, bound_arguments}, caller, execution, - false} - - def plan({:function_method, "call"}, arguments, target, caller, execution, tail?) do - {this, arguments} = - case arguments do - [this | rest] -> {this, rest} - [] -> {:undefined, []} - end - - {:dispatch, target, arguments, this, caller, execution, tail?} - end - def plan(%Reference{} = reference, arguments, this, caller, execution, tail?) do case Builtins.callable(execution, reference) do nil -> @@ -221,7 +200,6 @@ defmodule QuickBEAM.VM.Invocation do :builtin_method, :declared_builtin, :bound_function, - :function_method, :host_function, :primitive_method, :promise_resolver diff --git a/lib/quickbeam/vm/iterator.ex b/lib/quickbeam/vm/iterator.ex index ed332489f..f2450b4ef 100644 --- a/lib/quickbeam/vm/iterator.ex +++ b/lib/quickbeam/vm/iterator.ex @@ -60,6 +60,7 @@ defmodule QuickBEAM.VM.Iterator do {promise, execution} = Promise.new(execution) boundary = %IteratorBoundary{ + consumer: :promise, kind: kind, promise: promise, iterable: iterable, @@ -71,6 +72,21 @@ defmodule QuickBEAM.VM.Iterator do read_iterator_method(boundary, execution) end + @doc "Starts resumable consumption for a Set constructor." + @spec start_set(Reference.t(), term(), term(), Execution.t(), boolean()) :: action() + def start_set(target, iterable, caller, execution, tail?) do + boundary = %IteratorBoundary{ + 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(), IteratorBoundary.t(), Execution.t()) :: action() def resume(value, %IteratorBoundary{phase: :iterator_getter} = boundary, execution), @@ -108,13 +124,16 @@ defmodule QuickBEAM.VM.Iterator do @doc "Rejects the combinator Promise and returns it to the original caller." @spec reject(IteratorBoundary.t(), term(), Execution.t()) :: action() - def reject(%IteratorBoundary{} = boundary, reason, execution), - do: reject_now(boundary, reason, execution) + def reject(%IteratorBoundary{consumer: :promise} = boundary, reason, execution), + do: reject_promise(boundary, reason, execution) + + def reject(%IteratorBoundary{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(IteratorBoundary.t(), term(), Execution.t()) :: action() def fail(%IteratorBoundary{} = boundary, reason, execution), - do: reject_now(boundary, reason, execution) + do: reject(boundary, reason, execution) defp read_iterator_method(boundary, execution) do case QuickBEAM.VM.Properties.get(boundary.iterable, Symbol.iterator(), execution) do @@ -176,14 +195,30 @@ defmodule QuickBEAM.VM.Iterator do defp after_done(boundary, done, execution) do if Value.truthy?(done) do - values = Enum.reverse(boundary.values) - execution = Promise.aggregate_into(execution, boundary.promise, boundary.kind, values) - complete(boundary, execution) + finish(boundary, execution) else read_value(boundary, execution) end end + defp finish(%IteratorBoundary{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(%IteratorBoundary{consumer: :set} = boundary, execution) do + values = Enum.reverse(boundary.values) + + case QuickBEAM.VM.Builtins.Set.initialize(boundary.target, values, execution) do + {:ok, execution} -> + {:complete, boundary.target, boundary.caller, execution, boundary.tail?} + + {:error, reason} -> + {:error, reason, boundary.caller, execution} + end + end + defp read_value(boundary, execution) do case QuickBEAM.VM.Properties.get(boundary.result, "value", execution) do {:ok, {:accessor, getter, receiver}} -> @@ -197,7 +232,7 @@ defmodule QuickBEAM.VM.Iterator do end end - defp reject_now(boundary, reason, execution) do + defp reject_promise(boundary, reason, execution) do {reason, execution} = Exceptions.materialize(reason, execution) execution = Promise.settle(execution, boundary.promise, {:error, reason}) complete(boundary, execution) diff --git a/lib/quickbeam/vm/iterator_boundary.ex b/lib/quickbeam/vm/iterator_boundary.ex index df4a573f6..e09f55bd3 100644 --- a/lib/quickbeam/vm/iterator_boundary.ex +++ b/lib/quickbeam/vm/iterator_boundary.ex @@ -1,10 +1,12 @@ defmodule QuickBEAM.VM.IteratorBoundary do @moduledoc "Defines resumable state while a Promise combinator consumes an iterator." - @enforce_keys [:kind, :promise, :iterable, :caller, :depth] + @enforce_keys [:consumer, :iterable, :caller, :depth] defstruct [ + :consumer, :kind, :promise, + :target, :iterable, :iterator, :next, @@ -25,8 +27,10 @@ defmodule QuickBEAM.VM.IteratorBoundary do | :value_getter @type t :: %__MODULE__{ - kind: :all | :all_settled | :any | :race, - promise: QuickBEAM.VM.PromiseReference.t(), + consumer: :promise | :set, + kind: :all | :all_settled | :any | :race | nil, + promise: QuickBEAM.VM.PromiseReference.t() | nil, + target: QuickBEAM.VM.Reference.t() | nil, iterable: term(), iterator: term() | nil, next: term() | nil, diff --git a/lib/quickbeam/vm/properties.ex b/lib/quickbeam/vm/properties.ex index cc387f687..fa831e228 100644 --- a/lib/quickbeam/vm/properties.ex +++ b/lib/quickbeam/vm/properties.ex @@ -9,7 +9,6 @@ defmodule QuickBEAM.VM.Properties do """ alias QuickBEAM.VM.{ - Builtins, Execution, Heap, PromiseReference, @@ -24,7 +23,6 @@ defmodule QuickBEAM.VM.Properties do :builtin_method, :declared_builtin, :bound_function, - :function_method, :host_function, :primitive_method, :promise_resolver @@ -39,18 +37,7 @@ defmodule QuickBEAM.VM.Properties do def get(%Reference{} = object, key, execution) do case Heap.get(execution, object, key) do {:ok, :undefined} = missing -> - kind = kind(object, execution) - - cond do - key in ["bind", "call"] and not is_nil(Builtins.callable(execution, object)) -> - {:ok, {:function_method, key}} - - kind == :set and is_binary(key) -> - {:ok, {:primitive_method, kind, key}} - - true -> - missing - end + missing result -> result @@ -63,9 +50,9 @@ defmodule QuickBEAM.VM.Properties do 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 key in ["bind", "call"] and elem(object, 0) in @function_tags, - do: {:ok, {:function_method, 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 diff --git a/test/vm/builtin_dsl_test.exs b/test/vm/builtin_dsl_test.exs index ff11b7ddd..3adec20cd 100644 --- a/test/vm/builtin_dsl_test.exs +++ b/test/vm/builtin_dsl_test.exs @@ -113,7 +113,16 @@ defmodule QuickBEAM.VM.BuiltinDSLTest do QuickBEAM.VM.Builtins.String, QuickBEAM.VM.Builtins.Number, QuickBEAM.VM.Builtins.Object, + QuickBEAM.VM.Builtins.Function, + QuickBEAM.VM.Builtins.Error, + QuickBEAM.VM.Builtins.EvalError, + QuickBEAM.VM.Builtins.RangeError, + QuickBEAM.VM.Builtins.ReferenceError, + QuickBEAM.VM.Builtins.SyntaxError, + QuickBEAM.VM.Builtins.TypeError, + QuickBEAM.VM.Builtins.URIError, QuickBEAM.VM.Builtins.Symbol, + QuickBEAM.VM.Builtins.Set, QuickBEAM.VM.Builtins.Promise ] @@ -124,6 +133,14 @@ defmodule QuickBEAM.VM.BuiltinDSLTest do assert promise.constructor == :construct assert promise.depends_on == ["Object", "Function", "Symbol"] + error = QuickBEAM.VM.Builtins.TypeError.builtin_spec() + assert error.prototype_parent == "Error" + assert error.prototype_role == {:error, "TypeError"} + + set = QuickBEAM.VM.Builtins.Set.builtin_spec() + assert set.kind == :constructor + assert Enum.any?(set.prototype, &match?(%QuickBEAM.VM.Builtin.AliasSpec{}, &1)) + symbol = QuickBEAM.VM.Builtins.Symbol.builtin_spec() assert symbol.kind == :namespace assert [%{key: "iterator", value: %QuickBEAM.VM.Symbol{id: :iterator}}] = symbol.statics diff --git a/test/vm/error_test.exs b/test/vm/error_test.exs index 70be3eaf8..9dbe0eb73 100644 --- a/test/vm/error_test.exs +++ b/test/vm/error_test.exs @@ -74,6 +74,16 @@ defmodule QuickBEAM.VM.ErrorTest do 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") diff --git a/test/vm/invocation_test.exs b/test/vm/invocation_test.exs index 7ab9d403b..fc8999856 100644 --- a/test/vm/invocation_test.exs +++ b/test/vm/invocation_test.exs @@ -1,6 +1,7 @@ defmodule QuickBEAM.VM.InvocationTest do use ExUnit.Case, async: true + alias QuickBEAM.VM.Builtin.{Action, Call} alias QuickBEAM.VM.{ConstructorBoundary, Execution, Frame, Function, Heap, Invocation} test "plans ordinary and closure calls as explicit frame entries" do @@ -49,28 +50,25 @@ defmodule QuickBEAM.VM.InvocationTest do assert Invocation.constructable?(reference, execution) end - test "plans Function bind and call without entering interpreter frames" do + test "plans declarative Function bind and call without entering interpreter frames" do execution = execution() caller = frame() - - assert {:complete, {:bound_function, :target, :receiver, [1, 2]}, ^caller, ^execution, false} = - Invocation.plan( - {:function_method, "bind"}, - [:receiver, 1, 2], - :target, - caller, - execution - ) - - assert {:dispatch, :target, [1, 2], :receiver, ^caller, ^execution, true} = - Invocation.plan( - {:function_method, "call"}, - [:receiver, 1, 2], - :target, - caller, - execution, - true - ) + 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.Builtins.Function.bind(call) + + assert %Action{ + value: {:dispatch, ^target, [1, 2], :receiver, ^caller, ^execution, true} + } = QuickBEAM.VM.Builtins.Function.call(%{call | tail?: true}) end defp execution do diff --git a/test/vm/memory_limit_test.exs b/test/vm/memory_limit_test.exs index 2724eed72..b9df4a715 100644 --- a/test/vm/memory_limit_test.exs +++ b/test/vm/memory_limit_test.exs @@ -55,10 +55,10 @@ defmodule QuickBEAM.VM.MemoryLimitTest do Enum.to_list(1..5_000) end - assert {:error, {:limit_exceeded, :memory_bytes, 80_000}} = + assert {:error, {:limit_exceeded, :memory_bytes, 100_000}} = QuickBEAM.VM.eval(program, handlers: %{"large_result" => handler}, - memory_limit: 80_000, + memory_limit: 100_000, timeout: 5_000 ) diff --git a/test/vm/object_model_test.exs b/test/vm/object_model_test.exs index 6e407166a..1f50c803c 100644 --- a/test/vm/object_model_test.exs +++ b/test/vm/object_model_test.exs @@ -99,6 +99,34 @@ defmodule QuickBEAM.VM.ObjectModelTest do end 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 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 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]})()", diff --git a/test/vm/properties_test.exs b/test/vm/properties_test.exs index 799c5d4ea..3d4116d41 100644 --- a/test/vm/properties_test.exs +++ b/test/vm/properties_test.exs @@ -20,11 +20,13 @@ defmodule QuickBEAM.VM.PropertiesTest do assert {:error, {:invoke_setter, ^setter}} = Properties.put(object, "value", 42, execution) end - test "provides primitive, Promise, and callable pseudo-properties" do + test "resolves primitive and callable properties through intrinsic prototypes" do execution = Builtins.install(execution()) {callable, execution} = Heap.allocate(execution, :function, callable: {:builtin, "callable"}) - assert {:ok, {:function_method, "bind"}} = Properties.get(callable, "bind", execution) + assert {:ok, %Reference{} = bind} = Properties.get(callable, "bind", execution) + assert Invocation.callable?(bind, execution) + assert {:ok, "bind"} = Properties.get(bind, "name", execution) assert {:ok, 2} = Properties.get("😀", "length", execution) assert {:ok, <<0xED, 0xA0, 0xBD>>} = Properties.get("😀", 0, execution) From 6e2065b8f036a2f05fcf9fd88cb6d9b67d05305f Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 13 Jul 2026 16:53:25 +0200 Subject: [PATCH 39/87] Make constructor bootstrap declarative --- .formatter.exs | 2 + docs/beam-interpreter-architecture.md | 23 ++- docs/prototype-delta-audit.md | 9 +- lib/quickbeam/vm/builtin/dsl.ex | 37 ++++- lib/quickbeam/vm/builtin/installer.ex | 77 +++++++-- lib/quickbeam/vm/builtin/registry.ex | 5 +- lib/quickbeam/vm/builtin/spec.ex | 34 +++- lib/quickbeam/vm/builtin/validator.ex | 42 ++++- lib/quickbeam/vm/builtins.ex | 217 +------------------------- lib/quickbeam/vm/builtins/array.ex | 21 ++- lib/quickbeam/vm/builtins/boolean.ex | 38 +++++ lib/quickbeam/vm/builtins/errors.ex | 12 +- lib/quickbeam/vm/builtins/function.ex | 20 ++- lib/quickbeam/vm/builtins/number.ex | 32 +++- lib/quickbeam/vm/builtins/object.ex | 56 ++++++- lib/quickbeam/vm/builtins/string.ex | 32 +++- lib/quickbeam/vm/invocation.ex | 9 +- lib/quickbeam/vm/promise.ex | 1 - lib/quickbeam/vm/properties.ex | 1 - test/vm/builtin_dsl_test.exs | 25 ++- test/vm/object_model_test.exs | 27 ++++ 21 files changed, 420 insertions(+), 300 deletions(-) create mode 100644 lib/quickbeam/vm/builtins/boolean.ex diff --git a/.formatter.exs b/.formatter.exs index 64a41319c..ad151d470 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -8,6 +8,8 @@ static: 2, method: 1, method: 2, + prototype: 1, + prototype: 2, static_value: 2, static_value: 3, prototype_value: 2, diff --git a/docs/beam-interpreter-architecture.md b/docs/beam-interpreter-architecture.md index 183792227..d6404afd2 100644 --- a/docs/beam-interpreter-architecture.md +++ b/docs/beam-interpreter-architecture.md @@ -419,10 +419,13 @@ property-alias declarations into immutable specs. Handler atoms are primary and names are inferred unless an explicit camelCase `js:` name is required: ```elixir -builtin "Array", kind: :intrinsic do +builtin "Array", + kind: :constructor, + constructor: :construct, + depends_on: ["Object", "Function"] do static :is_array, js: "isArray", length: 1 - prototype do + prototype kind: :array, extends: "Object", default_for: :array do method :map, length: 1 method :for_each, js: "forEach", length: 1 end @@ -433,9 +436,13 @@ The formatter preserves this parenthesis-free syntax. Macro compilation, validation, installation, and runtime dispatch are separate modules. Constants are evaluated in the declaring module, descriptors and accessors have typed specs, and each builtin declares one or more profiles plus explicit intrinsic -dependencies. Constructor specs can declare prototype parents and semantic roles -for default or Error prototypes. An explicit profile registry installs specs in -validated dependency order into each owner-local execution. Runtime +dependencies. A typed prototype spec is compiled from the nested semantic +`prototype` declaration: `extends`, `kind`, `default_for`, `callable`, +`primitive`, and `error_type` describe JavaScript topology without exposing +installer field names. This models the null-rooted Object prototype, callable +Function prototype, constructor cycles, boxed primitives, and Array defaults +without a bootstrap constructor table. An explicit profile registry installs +specs in validated dependency order into each owner-local execution. Runtime application-module discovery is forbidden. Installed functions are real owner-local function objects carrying stable @@ -455,8 +462,10 @@ descriptor validation, Symbol aliases, and intrinsic prototype topology all use the same installation and invocation paths. Primitive strings, numbers, internal BEAM lists, functions, and owner-local Set values resolve methods from installed intrinsic prototypes, eliminating their parallel pseudo-method -implementations. The bootstrap dispatcher remains only for constructors and -builtins not yet migrated. Real function metadata increases the intrinsic heap +implementations. Object, Function, Array, Boolean, Number, and String call and +construction semantics are declarative, and no hard-coded constructor table +remains. The small legacy dispatcher is limited to genuinely unmigrated helpers +such as RegExp methods. Real function metadata increases the intrinsic heap baseline, which remains included in logical memory accounting. ## ECMAScript and host profiles diff --git a/docs/prototype-delta-audit.md b/docs/prototype-delta-audit.md index fa9d01dce..b8a043706 100644 --- a/docs/prototype-delta-audit.md +++ b/docs/prototype-delta-audit.md @@ -405,10 +405,11 @@ High-value test groups to adapt next: - Custom iterator getters, factories, cached `next` methods, and accessor-backed `done`/`value` reads resume through explicit boundaries; computed Symbol methods and fields are supported without recursive JavaScript execution. -- Constructor prototype parents, Error prototype registration, and Symbol-keyed - aliases are explicit installer topology. Legacy object/error/set/function - pseudo-method tokens have been removed; the remaining dispatcher is limited - to bootstrap constructors and unmigrated builtins. +- Constructor prototype parents, prototype kind/callability, default and Error + prototype registration, and Symbol-keyed aliases are explicit installer + topology. Object/Function bootstrap cycles and primitive constructors are + declarative; the hard-coded constructor table and legacy object/error/set/ + function pseudo-method tokens have been removed. ### Phase C — expand conformance by profile demand diff --git a/lib/quickbeam/vm/builtin/dsl.ex b/lib/quickbeam/vm/builtin/dsl.ex index 7dce709c8..7256e1a78 100644 --- a/lib/quickbeam/vm/builtin/dsl.ex +++ b/lib/quickbeam/vm/builtin/dsl.ex @@ -1,7 +1,7 @@ defmodule QuickBEAM.VM.Builtin.DSL do @moduledoc "Compiles the declarative builtin syntax into immutable validated specs." - alias QuickBEAM.VM.Builtin.{AccessorSpec, FunctionSpec, Spec, Validator} + alias QuickBEAM.VM.Builtin.{AccessorSpec, FunctionSpec, PrototypeSpec, Spec, Validator} @doc "Installs builtin declaration attributes and macros." defmacro __using__(_opts) do @@ -13,7 +13,9 @@ defmodule QuickBEAM.VM.Builtin.DSL do 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 @@ -28,9 +30,23 @@ defmodule QuickBEAM.VM.Builtin.DSL do end end - @doc "Groups prototype declarations." + @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) @@ -156,14 +172,27 @@ defmodule QuickBEAM.VM.Builtin.DSL do 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_parent: Keyword.get(opts, :prototype_parent), - prototype_role: Keyword.get(opts, :prototype_role), + prototype_spec: prototype_spec, profiles: Keyword.get(opts, :profiles, [:core]), depends_on: Keyword.get(opts, :depends_on, []), length: Keyword.get(opts, :length, 0), diff --git a/lib/quickbeam/vm/builtin/installer.ex b/lib/quickbeam/vm/builtin/installer.ex index 247eb770b..2c3f9dcd9 100644 --- a/lib/quickbeam/vm/builtin/installer.ex +++ b/lib/quickbeam/vm/builtin/installer.ex @@ -7,7 +7,15 @@ defmodule QuickBEAM.VM.Builtin.Installer do stable module/handler tokens rather than captured closures. """ - alias QuickBEAM.VM.Builtin.{AccessorSpec, AliasSpec, FunctionSpec, PropertySpec, Spec} + alias QuickBEAM.VM.Builtin.{ + AccessorSpec, + AliasSpec, + FunctionSpec, + PropertySpec, + PrototypeSpec, + Spec + } + alias QuickBEAM.VM.{Execution, Heap, Properties, Reference} @doc "Installs registered builtin modules for the selected profile." @@ -39,8 +47,26 @@ defmodule QuickBEAM.VM.Builtin.Installer do 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) - parent = resolve_prototype_parent(execution, spec.prototype_parent) - {prototype, execution} = Heap.allocate(execution, :ordinary, prototype: parent) + 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} = Properties.define(prototype, "constructor", constructor, execution, @@ -58,8 +84,8 @@ defmodule QuickBEAM.VM.Builtin.Installer do execution = install_entries(execution, constructor, spec.module, spec.statics) execution = install_entries(execution, prototype, spec.module, spec.prototype) - execution = register_prototype_role(execution, prototype, spec.prototype_role) - put_global(execution, spec.name, constructor) + execution = put_global(execution, spec.name, constructor) + register_prototype(execution, prototype, topology) end defp install_prototype_entries(execution, _target, %Spec{prototype: []}), do: execution @@ -170,10 +196,10 @@ defmodule QuickBEAM.VM.Builtin.Installer do allocate_function(execution, to_string(key), 0, token) end - defp resolve_prototype_parent(execution, nil), + defp resolve_prototype_parent(execution, :default), do: Map.get(execution.default_prototypes, :ordinary) - defp resolve_prototype_parent(_execution, :null), do: nil + defp resolve_prototype_parent(_execution, nil), do: nil defp resolve_prototype_parent(execution, parent_name) do constructor = Map.fetch!(execution.globals, parent_name) @@ -181,22 +207,39 @@ defmodule QuickBEAM.VM.Builtin.Installer do prototype end - defp register_prototype_role(execution, _prototype, nil), do: execution + defp register_prototype(execution, prototype, %PrototypeSpec{} = topology) do + execution = register_default_prototype(execution, prototype, topology.default_for) - defp register_prototype_role(execution, prototype, :ordinary), - do: %{ - execution - | default_prototypes: Map.put(execution.default_prototypes, :ordinary, prototype) - } + if topology.error_type, + do: %{ + execution + | error_prototypes: Map.put(execution.error_prototypes, topology.error_type, prototype) + }, + else: execution + end - defp register_prototype_role(execution, prototype, :function), - do: %{ + 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) } - defp register_prototype_role(execution, prototype, {:error, name}), - do: %{execution | error_prototypes: Map.put(execution.error_prototypes, name, prototype)} + Enum.reduce(execution.heap, execution, fn + {id, %{kind: :function, prototype: nil}}, execution -> + {:ok, execution} = + Properties.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)} diff --git a/lib/quickbeam/vm/builtin/registry.ex b/lib/quickbeam/vm/builtin/registry.ex index 4a91bd853..087282103 100644 --- a/lib/quickbeam/vm/builtin/registry.ex +++ b/lib/quickbeam/vm/builtin/registry.ex @@ -7,12 +7,13 @@ defmodule QuickBEAM.VM.Builtin.Registry do """ @core [ + QuickBEAM.VM.Builtins.Object, + QuickBEAM.VM.Builtins.Function, QuickBEAM.VM.Builtins.Math, QuickBEAM.VM.Builtins.Array, QuickBEAM.VM.Builtins.String, QuickBEAM.VM.Builtins.Number, - QuickBEAM.VM.Builtins.Object, - QuickBEAM.VM.Builtins.Function, + QuickBEAM.VM.Builtins.Boolean, QuickBEAM.VM.Builtins.Error, QuickBEAM.VM.Builtins.EvalError, QuickBEAM.VM.Builtins.RangeError, diff --git a/lib/quickbeam/vm/builtin/spec.ex b/lib/quickbeam/vm/builtin/spec.ex index 500cdd31a..61386a53a 100644 --- a/lib/quickbeam/vm/builtin/spec.ex +++ b/lib/quickbeam/vm/builtin/spec.ex @@ -1,3 +1,23 @@ +defmodule QuickBEAM.VM.Builtin.PrototypeSpec 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 + defmodule QuickBEAM.VM.Builtin.Spec do @moduledoc """ Defines immutable, compile-time-validated metadata for one JavaScript builtin. @@ -6,15 +26,20 @@ defmodule QuickBEAM.VM.Builtin.Spec do not contain heap references or captured functions. """ - alias QuickBEAM.VM.Builtin.{AccessorSpec, AliasSpec, FunctionSpec, PropertySpec} + alias QuickBEAM.VM.Builtin.{ + AccessorSpec, + AliasSpec, + FunctionSpec, + PropertySpec, + PrototypeSpec + } @enforce_keys [:name, :module, :kind] defstruct [ :name, :module, :constructor, - :prototype_parent, - :prototype_role, + prototype_spec: %PrototypeSpec{}, profiles: [:core], depends_on: [], kind: :namespace, @@ -29,8 +54,7 @@ defmodule QuickBEAM.VM.Builtin.Spec do module: module(), kind: kind(), constructor: atom() | nil, - prototype_parent: String.t() | :null | nil, - prototype_role: :ordinary | :function | {:error, String.t()} | nil, + prototype_spec: PrototypeSpec.t(), profiles: [atom()], depends_on: [String.t()], length: non_neg_integer(), diff --git a/lib/quickbeam/vm/builtin/validator.ex b/lib/quickbeam/vm/builtin/validator.ex index d38108395..81c8b7ba2 100644 --- a/lib/quickbeam/vm/builtin/validator.ex +++ b/lib/quickbeam/vm/builtin/validator.ex @@ -1,7 +1,14 @@ defmodule QuickBEAM.VM.Builtin.Validator do @moduledoc "Validates builtin declarations and handler contracts at compile time." - alias QuickBEAM.VM.Builtin.{AccessorSpec, AliasSpec, FunctionSpec, PropertySpec, Spec} + alias QuickBEAM.VM.Builtin.{ + AccessorSpec, + AliasSpec, + FunctionSpec, + PropertySpec, + PrototypeSpec, + Spec + } @doc "Validates a compiled builtin spec against its declaring module." @spec validate!(Spec.t(), Macro.Env.t()) :: :ok @@ -30,14 +37,32 @@ defmodule QuickBEAM.VM.Builtin.Validator do compile_error!(env, "constructor builtins require a :constructor handler") end - unless is_nil(spec.prototype_parent) or spec.prototype_parent == :null or - (is_binary(spec.prototype_parent) and spec.prototype_parent in spec.depends_on) do - compile_error!(env, "prototype parent must be :null or a declared dependency") + %PrototypeSpec{} = prototype = spec.prototype_spec + + unless prototype.extends in [:default, nil] or + (is_binary(prototype.extends) and prototype.extends in spec.depends_on) do + compile_error!(env, "prototype :extends must name a declared dependency or be nil") + end + + unless prototype.kind in [:ordinary, :array, :function] do + compile_error!(env, "unsupported prototype kind: #{inspect(prototype.kind)}") + end + + unless is_nil(prototype.default_for) or is_atom(prototype.default_for) do + compile_error!(env, "prototype :default_for must be an atom") + end + + unless is_nil(prototype.error_type) or is_binary(prototype.error_type) do + compile_error!(env, "prototype :error_type must be a string") + end + + unless is_nil(prototype.primitive) or + match?({kind, _value} when is_atom(kind), prototype.primitive) do + compile_error!(env, "prototype :primitive must be a {kind, value} pair") end - unless is_nil(spec.prototype_role) or spec.prototype_role in [:ordinary, :function] or - match?({:error, name} when is_binary(name), spec.prototype_role) do - compile_error!(env, "unsupported prototype role: #{inspect(spec.prototype_role)}") + if prototype.kind == :function and not is_atom(prototype.callable) do + compile_error!(env, "function prototypes require a :callable handler") end entries = spec.statics ++ spec.prototype @@ -48,7 +73,8 @@ defmodule QuickBEAM.VM.Builtin.Validator do entries |> Enum.flat_map(&entry_handlers/1) |> then(fn handlers -> - if spec.constructor, do: [spec.constructor | handlers], else: handlers + handlers = if spec.constructor, do: [spec.constructor | handlers], else: handlers + if prototype.callable, do: [prototype.callable | handlers], else: handlers end) |> Enum.uniq() diff --git a/lib/quickbeam/vm/builtins.ex b/lib/quickbeam/vm/builtins.ex index 26d4959ce..2bdd097b1 100644 --- a/lib/quickbeam/vm/builtins.ex +++ b/lib/quickbeam/vm/builtins.ex @@ -15,42 +15,8 @@ defmodule QuickBEAM.VM.Builtins do alias QuickBEAM.VM.Builtin.{Installer, Registry} - @constructors %{ - "Array" => [], - "Boolean" => [], - "Object" => [], - "Function" => [], - "Number" => [], - "String" => [] - } - @spec install(Execution.t()) :: Execution.t() - def install(execution) do - execution = - @constructors - |> Enum.sort_by(fn {name, _methods} -> - if name == "Object", do: {0, name}, else: {1, name} - end) - |> Enum.reduce(execution, fn {name, methods}, execution -> - {object, execution} = Heap.allocate(execution, :function, callable: {:builtin, name}) - - execution = - Enum.reduce(methods, execution, fn method, execution -> - {:ok, execution} = - Properties.define(object, method, {:builtin_method, name, method}, execution, - enumerable: false - ) - - execution - end) - - execution = maybe_install_prototype(name, object, execution) - %{execution | globals: Map.put_new(execution.globals, name, object)} - end) - |> link_constructor_prototypes() - - Installer.install_all(execution, Registry.modules(:core)) - end + def install(execution), do: Installer.install_all(execution, Registry.modules(:core)) @spec callable(Execution.t(), Reference.t()) :: term() | nil def callable(execution, %Reference{} = reference) do @@ -91,190 +57,9 @@ defmodule QuickBEAM.VM.Builtins do def call({:primitive_method, :regexp, "test"}, %RegExp{} = regexp, [value | _], execution), do: {:ok, regex_match?(regexp, Value.to_string_value(value)), execution} - def call({:builtin, "Boolean"}, %Reference{} = receiver, values, execution) do - value = values |> List.first() |> Value.truthy?() - maybe_box_primitive(receiver, :boolean, value, execution) - end - - def call({:builtin, "Boolean"}, _this, values, execution), - do: {:ok, values |> List.first() |> Value.truthy?(), execution} - - def call({:builtin, "Number"}, %Reference{} = receiver, values, execution) do - value = - case values do - [value | _] -> Value.to_number(value) - [] -> 0 - end - - maybe_box_primitive(receiver, :number, value, execution) - end - - def call({:builtin, "Number"}, _this, [value], execution), - do: {:ok, Value.to_number(value), execution} - - def call({:builtin, "Number"}, _this, [], execution), do: {:ok, 0, execution} - - def call({:builtin, "String"}, %Reference{} = receiver, values, execution) do - value = - case values do - [value | _] -> Value.to_string_value(value) - [] -> "" - end - - maybe_box_primitive(receiver, :string, value, execution) - end - - def call({:builtin, "String"}, _this, [value], execution), - do: {:ok, Value.to_string_value(value), execution} - - def call({:builtin, "String"}, _this, [], execution), do: {:ok, "", execution} - - def call({:builtin, "FunctionPrototype"}, _this, _arguments, execution), - do: {:ok, :undefined, execution} - - def call({:builtin, "Function"}, _this, _arguments, execution), - do: {:error, :dynamic_function_unsupported, execution} - - def call({:builtin, "Array"}, _this, [length], execution) - when is_integer(length) and length >= 0 do - {array, execution} = Heap.allocate(execution, :array, length: length) - {:ok, array, execution} - end - - def call({:builtin, "Array"}, _this, values, execution) do - {array, execution} = array_from(values, execution) - {:ok, array, execution} - end - - def call({:builtin, "Object"}, _this, [value], execution) when value not in [nil, :undefined], - do: {:ok, value, execution} - - def call({:builtin, "Object"}, _this, _values, execution) do - {object, execution} = Heap.allocate(execution) - {:ok, object, execution} - end - def call(callable, _this, _arguments, execution), do: {:error, {:unsupported_builtin, callable}, execution} - defp link_constructor_prototypes(execution) do - case execution.default_prototypes do - %{function: function_prototype} -> - Enum.reduce(Map.keys(@constructors), execution, fn name, execution -> - constructor = Map.fetch!(execution.globals, name) - - {:ok, execution} = - Properties.set_prototype(constructor, function_prototype, execution) - - execution - end) - - _no_function_prototype -> - execution - end - end - - defp maybe_install_prototype("Object", constructor, execution) do - {prototype, execution} = Heap.allocate(execution, :ordinary, prototype: nil) - - {:ok, execution} = - Properties.define(prototype, "constructor", constructor, execution, enumerable: false) - - {:ok, execution} = - Properties.define(constructor, "prototype", prototype, execution, enumerable: false) - - %{ - execution - | default_prototypes: Map.put(execution.default_prototypes, :ordinary, prototype) - } - end - - defp maybe_install_prototype("Function", constructor, execution) do - {prototype, execution} = - Heap.allocate(execution, :function, - callable: {:builtin, "FunctionPrototype"}, - prototype: Map.get(execution.default_prototypes, :ordinary) - ) - - {:ok, execution} = - Properties.define(constructor, "prototype", prototype, execution, enumerable: false) - - %{ - execution - | default_prototypes: Map.put(execution.default_prototypes, :function, prototype) - } - end - - defp maybe_install_prototype("Array", constructor, execution) do - install_primitive_prototype( - constructor, - :array, - [], - execution - ) - end - - defp maybe_install_prototype("String", constructor, execution), - do: install_primitive_prototype(constructor, :string, [], execution) - - defp maybe_install_prototype("Number", constructor, execution), - do: install_primitive_prototype(constructor, :number, [], execution) - - defp maybe_install_prototype(_name, _constructor, execution), do: execution - - defp install_primitive_prototype(constructor, kind, methods, execution) do - {prototype, execution} = Heap.allocate(execution) - - execution = - Enum.reduce(methods, execution, fn method, execution -> - {:ok, execution} = - Properties.define(prototype, method, {:primitive_method, kind, method}, execution, - enumerable: false - ) - - execution - end) - - {:ok, execution} = - Properties.define(constructor, "prototype", prototype, execution, enumerable: false) - - if kind == :array do - %{ - execution - | default_prototypes: Map.put(execution.default_prototypes, :array, prototype) - } - else - execution - end - end - - defp maybe_box_primitive(receiver, kind, value, execution) do - case Heap.fetch_object(execution, receiver) do - {:ok, %Object{internal: :constructor_instance}} -> - {:ok, execution} = - Heap.update_object(execution, receiver, &%{&1 | internal: {:primitive, kind, value}}) - - {:ok, receiver, execution} - - _not_constructor -> - {:ok, value, execution} - end - end - - defp array_from(values, execution) do - {array, execution} = Heap.allocate(execution, :array) - - execution = - values - |> Enum.with_index() - |> Enum.reduce(execution, fn {value, index}, execution -> - {:ok, execution} = Properties.define(array, index, value, execution) - execution - end) - - {array, execution} - end - defp regex_match?(%RegExp{source: source}, value) do case Regex.compile(source) do {:ok, regex} -> Regex.match?(regex, value) diff --git a/lib/quickbeam/vm/builtins/array.ex b/lib/quickbeam/vm/builtins/array.ex index 8a3f09fb7..a7511dc9d 100644 --- a/lib/quickbeam/vm/builtins/array.ex +++ b/lib/quickbeam/vm/builtins/array.ex @@ -7,10 +7,14 @@ defmodule QuickBEAM.VM.Builtins.Array do alias QuickBEAM.VM.Builtin.Call alias QuickBEAM.VM.{Heap, Object, Properties, Property, Reference, Value} - builtin "Array", kind: :intrinsic do + builtin "Array", + kind: :constructor, + constructor: :construct, + length: 1, + depends_on: ["Object", "Function"] do static :is_array, js: "isArray", length: 1 - prototype do + prototype kind: :array, extends: "Object", default_for: :array do method :concat, length: 1 method :filter, length: 1 method :for_each, js: "forEach", length: 1 @@ -23,6 +27,19 @@ defmodule QuickBEAM.VM.Builtins.Array do 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 >= 0 do + {array, execution} = Heap.allocate(execution, :array, length: length) + {:ok, array, execution} + end + + def construct(%Call{arguments: arguments, execution: execution}) do + entries = Enum.map(arguments, &{:present, &1}) + {array, execution} = array_from_entries(entries, execution) + {:ok, array, execution} + end + @doc "Implements `Array.isArray`." def is_array(%Call{arguments: arguments, execution: execution}) do value = List.first(arguments, :undefined) diff --git a/lib/quickbeam/vm/builtins/boolean.ex b/lib/quickbeam/vm/builtins/boolean.ex new file mode 100644 index 000000000..2c07cbf1f --- /dev/null +++ b/lib/quickbeam/vm/builtins/boolean.ex @@ -0,0 +1,38 @@ +defmodule QuickBEAM.VM.Builtins.Boolean do + @moduledoc "Defines declarative Boolean call and construction semantics." + + use QuickBEAM.VM.Builtin + + alias QuickBEAM.VM.Builtin.Call + alias QuickBEAM.VM.{ConstructorBoundary, Heap, Reference, 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: %ConstructorBoundary{}, + 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/builtins/errors.ex b/lib/quickbeam/vm/builtins/errors.ex index 6ed0f70cb..8de4da224 100644 --- a/lib/quickbeam/vm/builtins/errors.ex +++ b/lib/quickbeam/vm/builtins/errors.ex @@ -86,10 +86,8 @@ defmodule QuickBEAM.VM.Builtins.Error do kind: :constructor, constructor: :construct, length: 1, - depends_on: ["Object", "Function"], - prototype_parent: "Object", - prototype_role: {:error, "Error"} do - prototype do + 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 @@ -121,10 +119,8 @@ defmodule QuickBEAM.VM.Builtins.ErrorSubclass do kind: :constructor, constructor: :construct, length: 1, - depends_on: ["Error", "Function"], - prototype_parent: "Error", - prototype_role: {:error, unquote(name)} do - prototype do + 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 diff --git a/lib/quickbeam/vm/builtins/function.ex b/lib/quickbeam/vm/builtins/function.ex index 4d4038dbb..7f1995de0 100644 --- a/lib/quickbeam/vm/builtins/function.ex +++ b/lib/quickbeam/vm/builtins/function.ex @@ -7,13 +7,29 @@ defmodule QuickBEAM.VM.Builtins.Function do alias QuickBEAM.VM.Builtin.Call alias QuickBEAM.VM.Invocation - builtin "Function", kind: :intrinsic, depends_on: ["Object"] do - prototype do + 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 diff --git a/lib/quickbeam/vm/builtins/number.ex b/lib/quickbeam/vm/builtins/number.ex index 609a078c8..1af5acea0 100644 --- a/lib/quickbeam/vm/builtins/number.ex +++ b/lib/quickbeam/vm/builtins/number.ex @@ -4,15 +4,41 @@ defmodule QuickBEAM.VM.Builtins.Number do use QuickBEAM.VM.Builtin alias QuickBEAM.VM.Builtin.Call - alias QuickBEAM.VM.{Heap, Object, Reference, Value} + alias QuickBEAM.VM.{ConstructorBoundary, Heap, Object, Reference, Value} - builtin "Number", kind: :intrinsic do - prototype do + builtin "Number", + kind: :constructor, + constructor: :construct, + length: 1, + depends_on: ["Object", "Function"] do + 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: %ConstructorBoundary{}, + 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 -> diff --git a/lib/quickbeam/vm/builtins/object.ex b/lib/quickbeam/vm/builtins/object.ex index 65d9f4062..6469b4be9 100644 --- a/lib/quickbeam/vm/builtins/object.ex +++ b/lib/quickbeam/vm/builtins/object.ex @@ -5,9 +5,18 @@ defmodule QuickBEAM.VM.Builtins.Object do alias QuickBEAM.VM.Builtin alias QuickBEAM.VM.Builtin.Call - alias QuickBEAM.VM.{Heap, Invocation, Properties, Property, Reference, Value} - builtin "Object", kind: :intrinsic do + alias QuickBEAM.VM.{ + ConstructorBoundary, + Heap, + Invocation, + Properties, + Property, + Reference, + 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 @@ -17,13 +26,54 @@ defmodule QuickBEAM.VM.Builtins.Object do static :keys, length: 1 static :set_prototype_of, js: "setPrototypeOf", length: 2 - prototype do + 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} = Properties.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: %ConstructorBoundary{}, + 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, diff --git a/lib/quickbeam/vm/builtins/string.ex b/lib/quickbeam/vm/builtins/string.ex index 5b3cfadfa..8f40d87f8 100644 --- a/lib/quickbeam/vm/builtins/string.ex +++ b/lib/quickbeam/vm/builtins/string.ex @@ -4,12 +4,16 @@ defmodule QuickBEAM.VM.Builtins.String do use QuickBEAM.VM.Builtin alias QuickBEAM.VM.Builtin.Call - alias QuickBEAM.VM.{Heap, Object, Properties, Reference, RegExp, Value} + alias QuickBEAM.VM.{ConstructorBoundary, Heap, Object, Properties, Reference, RegExp, Value} - builtin "String", kind: :intrinsic do + builtin "String", + kind: :constructor, + constructor: :construct, + length: 1, + depends_on: ["Object", "Function"] do static :from_char_code, js: "fromCharCode", length: 1 - prototype do + prototype extends: "Object", primitive: {:string, ""} do method :char_code_at, js: "charCodeAt", length: 1 method :includes, length: 1 method :replace, length: 2 @@ -21,6 +25,28 @@ defmodule QuickBEAM.VM.Builtins.String do end end + @doc "Implements String call and boxed-construction semantics." + def construct(%Call{ + this: %Reference{} = receiver, + caller: %ConstructorBoundary{}, + 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} diff --git a/lib/quickbeam/vm/invocation.ex b/lib/quickbeam/vm/invocation.ex index 97ae91960..4f7475876 100644 --- a/lib/quickbeam/vm/invocation.ex +++ b/lib/quickbeam/vm/invocation.ex @@ -23,8 +23,7 @@ defmodule QuickBEAM.VM.Invocation do alias QuickBEAM.VM.Builtin.{Action, Call} - @builtin_tags [:builtin, :builtin_method, :declared_builtin, :primitive_method] - @error_constructors ~w(Error EvalError RangeError ReferenceError SyntaxError TypeError URIError) + @builtin_tags [:builtin, :declared_builtin, :primitive_method] @type action :: {:dispatch, term(), [term()], term(), term(), Execution.t(), boolean()} @@ -156,11 +155,6 @@ defmodule QuickBEAM.VM.Invocation do def constructable?({:declared_builtin, _module, _handler} = callable, _execution), do: Builtin.constructable?(callable) - def constructable?({:builtin, name}, _execution), - do: - name in (["Array", "Boolean", "Number", "Object", "Promise", "Set", "String"] ++ - @error_constructors) - def constructable?(_constructor, _execution), do: false @doc "Returns the object prototype used for a constructor allocation." @@ -197,7 +191,6 @@ defmodule QuickBEAM.VM.Invocation do when is_tuple(value) and elem(value, 0) in [ :builtin, - :builtin_method, :declared_builtin, :bound_function, :host_function, diff --git a/lib/quickbeam/vm/promise.ex b/lib/quickbeam/vm/promise.ex index c9900b009..af8a85599 100644 --- a/lib/quickbeam/vm/promise.ex +++ b/lib/quickbeam/vm/promise.ex @@ -308,7 +308,6 @@ defmodule QuickBEAM.VM.Promise do elem(callable, 0) in [ :bound_function, :builtin, - :builtin_method, :declared_builtin, :host_function, :primitive_method, diff --git a/lib/quickbeam/vm/properties.ex b/lib/quickbeam/vm/properties.ex index fa831e228..2221ac13f 100644 --- a/lib/quickbeam/vm/properties.ex +++ b/lib/quickbeam/vm/properties.ex @@ -20,7 +20,6 @@ defmodule QuickBEAM.VM.Properties do @function_tags [ :builtin, - :builtin_method, :declared_builtin, :bound_function, :host_function, diff --git a/test/vm/builtin_dsl_test.exs b/test/vm/builtin_dsl_test.exs index 3adec20cd..fef54dd59 100644 --- a/test/vm/builtin_dsl_test.exs +++ b/test/vm/builtin_dsl_test.exs @@ -101,19 +101,22 @@ defmodule QuickBEAM.VM.BuiltinDSLTest do ) assert array.name == "Array" - assert array.kind == :intrinsic + assert array.kind == :constructor + assert array.prototype_spec.kind == :array + assert array.prototype_spec.default_for == :array assert [%FunctionSpec{key: "isArray", handler: :is_array, length: 1}] = array.statics assert Enum.map(array.prototype, & &1.key) == ~w(concat filter forEach join map push reduce slice some) assert Registry.modules(:core) == [ + QuickBEAM.VM.Builtins.Object, + QuickBEAM.VM.Builtins.Function, QuickBEAM.VM.Builtins.Math, QuickBEAM.VM.Builtins.Array, QuickBEAM.VM.Builtins.String, QuickBEAM.VM.Builtins.Number, - QuickBEAM.VM.Builtins.Object, - QuickBEAM.VM.Builtins.Function, + QuickBEAM.VM.Builtins.Boolean, QuickBEAM.VM.Builtins.Error, QuickBEAM.VM.Builtins.EvalError, QuickBEAM.VM.Builtins.RangeError, @@ -126,7 +129,17 @@ defmodule QuickBEAM.VM.BuiltinDSLTest do QuickBEAM.VM.Builtins.Promise ] - assert QuickBEAM.VM.Builtins.String.builtin_spec().kind == :intrinsic + assert QuickBEAM.VM.Builtins.String.builtin_spec().kind == :constructor + + object = QuickBEAM.VM.Builtins.Object.builtin_spec() + assert object.prototype_spec.extends == nil + assert object.prototype_spec.default_for == :ordinary + + function = QuickBEAM.VM.Builtins.Function.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 = QuickBEAM.VM.Builtins.Promise.builtin_spec() assert promise.kind == :constructor @@ -134,8 +147,8 @@ defmodule QuickBEAM.VM.BuiltinDSLTest do assert promise.depends_on == ["Object", "Function", "Symbol"] error = QuickBEAM.VM.Builtins.TypeError.builtin_spec() - assert error.prototype_parent == "Error" - assert error.prototype_role == {:error, "TypeError"} + assert error.prototype_spec.extends == "Error" + assert error.prototype_spec.error_type == "TypeError" set = QuickBEAM.VM.Builtins.Set.builtin_spec() assert set.kind == :constructor diff --git a/test/vm/object_model_test.exs b/test/vm/object_model_test.exs index 1f50c803c..66c3636dd 100644 --- a/test/vm/object_model_test.exs +++ b/test/vm/object_model_test.exs @@ -99,6 +99,33 @@ defmodule QuickBEAM.VM.ObjectModelTest do 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 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)})()", From e7c631584fc45f659257f70e1280b51b62f125e6 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 13 Jul 2026 17:04:16 +0200 Subject: [PATCH 40/87] Expand pinned ECMAScript conformance gate --- docs/beam-interpreter-architecture.md | 19 +++++++++++ docs/test262-conformance.md | 21 ++++++++---- lib/quickbeam/js_error.ex | 2 ++ lib/quickbeam/vm/builtin/installer.ex | 10 +++++- lib/quickbeam/vm/builtin/spec.ex | 2 +- lib/quickbeam/vm/builtin/validator.ex | 3 +- lib/quickbeam/vm/builtins/array.ex | 14 +++++++- lib/quickbeam/vm/builtins/number.ex | 5 +++ lib/quickbeam/vm/builtins/symbol.ex | 19 +++++++++-- lib/quickbeam/vm/execution.ex | 2 ++ lib/quickbeam/vm/symbol.ex | 2 +- test/test262/manifest.exs | 49 ++++++++++++++++++++++++++- test/vm/builtin_dsl_test.exs | 11 +++++- test/vm/object_model_test.exs | 1 + 14 files changed, 145 insertions(+), 15 deletions(-) diff --git a/docs/beam-interpreter-architecture.md b/docs/beam-interpreter-architecture.md index d6404afd2..3c176ad3f 100644 --- a/docs/beam-interpreter-architecture.md +++ b/docs/beam-interpreter-architecture.md @@ -468,6 +468,25 @@ remains. The small legacy dispatcher is limited to genuinely unmigrated helpers such as RegExp methods. Real function metadata increases the intrinsic heap baseline, which remains included in logical memory accounting. +## Semantic runtime contract + +The interpreter and a future compiler share one internal semantic contract: + +- `Properties` owns property, descriptor, prototype, and accessor outcomes; +- `Invocation` owns callable classification and explicit invocation actions; +- `Value` owns coercion, equality, arithmetic, and UTF-16 value operations; +- `Async` and `Promise` own suspension, jobs, reactions, and settlement; +- `Exceptions` owns materialization and boundary-aware unwinding; +- typed builtin actions and explicit boundary structs are the only resumable + extension mechanism. + +Compiler extraction must target these layers rather than duplicate their logic +or call the interpreter recursively. Changes to action shapes, boundary fields, +owner-local reference rules, or exception results are runtime-contract changes +and require focused contract tests plus the pinned conformance gate. Heap +references, continuations, and mutable execution state remain evaluation-owner +local under both engines. + ## ECMAScript and host profiles The first production profile should be explicit rather than implying browser diff --git a/docs/test262-conformance.md b/docs/test262-conformance.md index ce012fdae..d7085bfdd 100644 --- a/docs/test262-conformance.md +++ b/docs/test262-conformance.md @@ -6,10 +6,10 @@ gate for `QuickBEAM.VM`. Full Test262 compliance is not claimed. ## Baseline - Test262 revision: `d1d583db95a521218f3eb8341a887fd63eda8ff1` -- Selected tests: 22 +- Selected tests: 69 - Explicitly unsupported by flags: 4 asynchronous tests -- Supported tests: 18 -- Passing: 18 +- Supported tests: 65 +- Passing: 65 - Known failures: 0 - Supported-test pass rate: **100%** - Required pass rate: **100%** @@ -18,9 +18,11 @@ The exact paths and classified known failures live in `test/test262/manifest.exs`. A newly passing known failure and an unclassified new failure both fail the gate, so the manifest cannot silently hide changes. -The selected supported set currently has no known failures. The previous -`propertyHelper.js` dependency gap and generated `ReferenceError` constructor -identity failure are covered by the passing manifest. +The selected supported set currently has no known failures. It covers the +previous `propertyHelper.js` dependency gap and generated `ReferenceError` +constructor identity failure, plus declarative constructor/prototype metadata, +Function calls, Error descriptors, Symbol keys, Set insertion and identity +semantics, and Promise resolution and iterator behavior. ## Running the gate @@ -54,6 +56,13 @@ BEAM VM evaluation. Results are classified as: There is no automatic native fallback during BEAM evaluation. The native result is used only as a differential oracle. +The selected SSR profile does not currently claim generator or async-generator +opcodes, Proxy/Reflect semantics, weak collections, the global Symbol registry, +constructor species/subclassing, or dynamic `Function` source compilation. +Those features remain explicit profile exclusions until pinned tests and +resource-bounded implementations are added; a passing test carrying a broad +feature tag does not imply blanket support for that feature. + Test262 YAML front matter is parsed by `YamlElixir` and decoded into strict, typed metadata structs by `JSONCodec`. Flags, includes, features, negative-test shape, and the finite phase enum therefore have one explicit boundary contract; diff --git a/lib/quickbeam/js_error.ex b/lib/quickbeam/js_error.ex index cf9eb976d..919a24317 100644 --- a/lib/quickbeam/js_error.ex +++ b/lib/quickbeam/js_error.ex @@ -57,6 +57,7 @@ defmodule QuickBEAM.JSError do elem(reason, 0) in [ :handler_exception, :not_callable, + :range_error, :reference_error, :type_error, :unknown_handler @@ -97,6 +98,7 @@ defmodule QuickBEAM.JSError do 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"} diff --git a/lib/quickbeam/vm/builtin/installer.ex b/lib/quickbeam/vm/builtin/installer.ex index 2c3f9dcd9..4d9d51639 100644 --- a/lib/quickbeam/vm/builtin/installer.ex +++ b/lib/quickbeam/vm/builtin/installer.ex @@ -38,6 +38,13 @@ defmodule QuickBEAM.VM.Builtin.Installer do 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) @@ -267,7 +274,8 @@ defmodule QuickBEAM.VM.Builtin.Installer do raise ArgumentError, "builtin intrinsic #{spec.name} is not installed" end - if spec.kind in [:namespace, :constructor] and MapSet.member?(available, spec.name) do + 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 diff --git a/lib/quickbeam/vm/builtin/spec.ex b/lib/quickbeam/vm/builtin/spec.ex index 61386a53a..74f3b4221 100644 --- a/lib/quickbeam/vm/builtin/spec.ex +++ b/lib/quickbeam/vm/builtin/spec.ex @@ -48,7 +48,7 @@ defmodule QuickBEAM.VM.Builtin.Spec do prototype: [] ] - @type kind :: :namespace | :constructor | :intrinsic + @type kind :: :namespace | :function | :constructor | :intrinsic @type t :: %__MODULE__{ name: String.t(), module: module(), diff --git a/lib/quickbeam/vm/builtin/validator.ex b/lib/quickbeam/vm/builtin/validator.ex index 81c8b7ba2..4e0512082 100644 --- a/lib/quickbeam/vm/builtin/validator.ex +++ b/lib/quickbeam/vm/builtin/validator.ex @@ -17,7 +17,7 @@ defmodule QuickBEAM.VM.Builtin.Validator do compile_error!(env, "builtin name must be a non-empty string") end - unless spec.kind in [:namespace, :constructor, :intrinsic] do + unless spec.kind in [:namespace, :function, :constructor, :intrinsic] do compile_error!(env, "unsupported builtin kind: #{inspect(spec.kind)}") end @@ -74,6 +74,7 @@ defmodule QuickBEAM.VM.Builtin.Validator do |> Enum.flat_map(&entry_handlers/1) |> then(fn handlers -> handlers = if spec.constructor, do: [spec.constructor | handlers], else: handlers + handlers = if spec.kind == :function, do: [:call | handlers], else: handlers if prototype.callable, do: [prototype.callable | handlers], else: handlers end) |> Enum.uniq() diff --git a/lib/quickbeam/vm/builtins/array.ex b/lib/quickbeam/vm/builtins/array.ex index a7511dc9d..adda7a793 100644 --- a/lib/quickbeam/vm/builtins/array.ex +++ b/lib/quickbeam/vm/builtins/array.ex @@ -29,11 +29,23 @@ defmodule QuickBEAM.VM.Builtins.Array do @doc "Constructs an Array from a length or argument list." def construct(%Call{arguments: [length], execution: execution}) - when is_integer(length) and length >= 0 do + 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} = array_from_entries(entries, execution) diff --git a/lib/quickbeam/vm/builtins/number.ex b/lib/quickbeam/vm/builtins/number.ex index 1af5acea0..24ff9bd4d 100644 --- a/lib/quickbeam/vm/builtins/number.ex +++ b/lib/quickbeam/vm/builtins/number.ex @@ -11,6 +11,11 @@ defmodule QuickBEAM.VM.Builtins.Number do constructor: :construct, length: 1, depends_on: ["Object", "Function"] do + constant "MAX_VALUE", 1.7976931348623157e308 + 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 diff --git a/lib/quickbeam/vm/builtins/symbol.ex b/lib/quickbeam/vm/builtins/symbol.ex index 34705cbfe..6c3819b15 100644 --- a/lib/quickbeam/vm/builtins/symbol.ex +++ b/lib/quickbeam/vm/builtins/symbol.ex @@ -3,9 +3,24 @@ defmodule QuickBEAM.VM.Builtins.Symbol do use QuickBEAM.VM.Builtin - alias QuickBEAM.VM.Symbol + alias QuickBEAM.VM.Builtin.Call + alias QuickBEAM.VM.{Symbol, Value} - builtin "Symbol", kind: :namespace do + builtin "Symbol", kind: :function, length: 0 do constant "iterator", Symbol.iterator() 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/execution.ex b/lib/quickbeam/vm/execution.ex index 96eb4d381..8d9818c31 100644 --- a/lib/quickbeam/vm/execution.ex +++ b/lib/quickbeam/vm/execution.ex @@ -27,6 +27,7 @@ defmodule QuickBEAM.VM.Execution do next_cell_id: 0, next_object_id: 0, next_promise_id: 0, + next_symbol_id: 0, operations: %{}, promise_waiters: %{}, promise_aggregates: %{}, @@ -66,6 +67,7 @@ defmodule QuickBEAM.VM.Execution do 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.PromiseReference.t(), pid()} }, diff --git a/lib/quickbeam/vm/symbol.ex b/lib/quickbeam/vm/symbol.ex index e9298a73a..f4ab1fb71 100644 --- a/lib/quickbeam/vm/symbol.ex +++ b/lib/quickbeam/vm/symbol.ex @@ -4,7 +4,7 @@ defmodule QuickBEAM.VM.Symbol do @enforce_keys [:id, :description] defstruct [:id, :description] - @type t :: %__MODULE__{id: atom(), description: String.t()} + @type t :: %__MODULE__{id: atom() | {:local, non_neg_integer()}, description: String.t()} @doc "Returns the stable `Symbol.iterator` value used as a property key." @spec iterator() :: t() diff --git a/test/test262/manifest.exs b/test/test262/manifest.exs index 13af3b6cd..8cafdf66b 100644 --- a/test/test262/manifest.exs +++ b/test/test262/manifest.exs @@ -24,6 +24,53 @@ "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/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/vm/builtin_dsl_test.exs b/test/vm/builtin_dsl_test.exs index fef54dd59..54b43233d 100644 --- a/test/vm/builtin_dsl_test.exs +++ b/test/vm/builtin_dsl_test.exs @@ -155,13 +155,22 @@ defmodule QuickBEAM.VM.BuiltinDSLTest do assert Enum.any?(set.prototype, &match?(%QuickBEAM.VM.Builtin.AliasSpec{}, &1)) symbol = QuickBEAM.VM.Builtins.Symbol.builtin_spec() - assert symbol.kind == :namespace + assert symbol.kind == :function + assert symbol.constructor == nil assert [%{key: "iterator", value: %QuickBEAM.VM.Symbol{id: :iterator}}] = symbol.statics assert Enum.map(QuickBEAM.VM.Builtins.Object.builtin_spec().statics, & &1.key) == ~w(assign create defineProperty 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 = """ [ diff --git a/test/vm/object_model_test.exs b/test/vm/object_model_test.exs index 66c3636dd..540e82534 100644 --- a/test/vm/object_model_test.exs +++ b/test/vm/object_model_test.exs @@ -104,6 +104,7 @@ defmodule QuickBEAM.VM.ObjectModelTest do } 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]})()", From cb1e0e5a68466ab4eb258a0e50c34c772b7a1c7c Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 13 Jul 2026 17:28:35 +0200 Subject: [PATCH 41/87] Serialize native resource shutdown --- docs/beam-interpreter-architecture.md | 17 ++ docs/prototype-delta-audit.md | 20 ++- lib/quickbeam/context_types.zig | 3 + lib/quickbeam/context_worker.zig | 29 +++- lib/quickbeam/quickbeam.zig | 239 +++++++++++++++----------- lib/quickbeam/types.zig | 9 + lib/quickbeam/worker.zig | 7 +- test/native_lifecycle_test.exs | 80 +++++++++ test/vm/async_test.exs | 4 +- 9 files changed, 283 insertions(+), 125 deletions(-) create mode 100644 test/native_lifecycle_test.exs diff --git a/docs/beam-interpreter-architecture.md b/docs/beam-interpreter-architecture.md index 3c176ad3f..4929a809b 100644 --- a/docs/beam-interpreter-architecture.md +++ b/docs/beam-interpreter-architecture.md @@ -349,6 +349,23 @@ state that is not represented in the JavaScript object-store counters. JavaScript call depth is tracked explicitly. It must not rely on exhausting the BEAM process stack or native C stack. +### Native resource lifecycle + +The native QuickJS engine remains a separate supported execution engine and its +NIF resources must be safe under concurrent scheduler shutdown. Runtime and +context-pool resources use a serialized `running → stopping → joined` lifecycle; +only the transition owner takes and joins the worker thread, while destructors +perform idempotent shutdown before freeing resource data. + +Synchronous host-call slots are stack-owned by worker calls. Result publishers +hold the slot mutex through writing the result and signaling completion, so the +waiter cannot remove the slot while another scheduler still writes through its +pointer. Context pools likewise hold their RuntimeData registry mutex through +slot publication and remove all externally visible pointers before destroying +contexts. Stress coverage includes concurrent explicit stops, owner exit without +stop, callSync shutdown, pool shutdown, UBSan builds, and ordinary parallel test +execution. + ## JavaScript values and heap Keep `null` and `undefined` distinct. Common scalar values may use native BEAM diff --git a/docs/prototype-delta-audit.md b/docs/prototype-delta-audit.md index b8a043706..fb64172bb 100644 --- a/docs/prototype-delta-audit.md +++ b/docs/prototype-delta-audit.md @@ -418,13 +418,21 @@ High-value test groups to adapt next: present element when no initial value is supplied. - Expand Symbol APIs and iterator consumers only when required by the selected compatibility profile. -- Expand the pinned Test262 manifest with exact classified thresholds. +- Expanded the pinned Test262 manifest to 69 selected tests with 65/65 + supported cases passing and four explicit asynchronous skips. - Add decoder mutation fuzzing. ### Phase D — production hardening -- Resolve the parallel native-NIF finalizer race. -- Audit cancellation, worker shutdown, and owner-local cleanup. +- Runtime and context-pool shutdown now use serialized running → stopping → + joined lifecycle states, and resource creation cannot leave a destructor + pointing at freed startup data. +- Sync-call result publication remains under its slot mutex through wakeup; + pool RuntimeData pointers are removed under the registry mutex before context + destruction. Repeated concurrent lifecycle and callSync shutdown stress tests + pass without crashes. +- Continue auditing cancellation, queued-payload cleanup, and owner-local + shutdown. - Publish scheduler, memory, timeout, and performance results. - Freeze the supported SSR compatibility matrix. @@ -437,6 +445,6 @@ High-value test groups to adapt next: ## Immediate next action -Migrate Promise construction, static combinators, and prototype reactions to -DSL specs while replacing array-only combinator inputs with general resumable -iterator semantics. +Add bounded mutation fuzzing for bytecode decoding and verification, then +publish scheduler, timeout, memory, and Preact SSR measurements for the frozen +SSR compatibility profile. 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..f48524314 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(); } @@ -238,8 +249,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 +257,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 +280,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/quickbeam.zig b/lib/quickbeam/quickbeam.zig index b0bd813cd..2888be101 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 { @@ -280,24 +307,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 +315,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 +333,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 +351,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 +372,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 +561,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 +634,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 +641,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 +865,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 +900,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/types.zig b/lib/quickbeam/types.zig index 3aad9ab43..74f71c4aa 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 diff --git a/lib/quickbeam/worker.zig b/lib/quickbeam/worker.zig index d04326371..bd9f15400 100644 --- a/lib/quickbeam/worker.zig +++ b/lib/quickbeam/worker.zig @@ -720,14 +720,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); 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/vm/async_test.exs b/test/vm/async_test.exs index dc7b8185b..d31a2b6c1 100644 --- a/test/vm/async_test.exs +++ b/test/vm/async_test.exs @@ -94,8 +94,8 @@ defmodule QuickBEAM.VM.AsyncTest do Process.sleep(:infinity) end - assert {:error, {:limit_exceeded, :timeout, 100}} = - QuickBEAM.VM.eval(program, handlers: %{"wait" => handler}, timeout: 100) + 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) From c1bbcc35303a96c1dfafb910fbec0875e6c20860 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 13 Jul 2026 21:15:45 +0200 Subject: [PATCH 42/87] Add Vue and Svelte SSR acceptance --- docs/beam-interpreter-architecture.md | 28 +- docs/prototype-delta-audit.md | 2 +- lib/quickbeam/js/package_resolver.ex | 23 +- lib/quickbeam/vm.ex | 10 +- lib/quickbeam/vm/builtin/registry.ex | 10 +- lib/quickbeam/vm/builtins.ex | 92 ++++- lib/quickbeam/vm/builtins/array.ex | 58 +++ lib/quickbeam/vm/builtins/console.ex | 26 ++ lib/quickbeam/vm/builtins/map.ex | 297 +++++++++++++++ lib/quickbeam/vm/builtins/object.ex | 52 +++ lib/quickbeam/vm/builtins/set.ex | 32 +- lib/quickbeam/vm/builtins/string.ex | 78 ++++ lib/quickbeam/vm/builtins/symbol.ex | 7 + lib/quickbeam/vm/builtins/uint8_array.ex | 41 ++ lib/quickbeam/vm/builtins/weak_map.ex | 39 ++ lib/quickbeam/vm/builtins/weak_set.ex | 35 ++ lib/quickbeam/vm/frame.ex | 13 +- lib/quickbeam/vm/interpreter.ex | 22 +- lib/quickbeam/vm/invocation.ex | 16 +- lib/quickbeam/vm/iterator.ex | 3 + lib/quickbeam/vm/object.ex | 2 +- lib/quickbeam/vm/opcodes/invocation.ex | 82 +++- lib/quickbeam/vm/opcodes/locals.ex | 79 +++- lib/quickbeam/vm/opcodes/objects.ex | 445 +++++++++++++++++++++- lib/quickbeam/vm/opcodes/values.ex | 7 + lib/quickbeam/vm/properties.ex | 14 +- lib/quickbeam/vm/symbol.ex | 5 +- package-lock.json | 436 ++++++++++++++++++++- package.json | 6 +- scripts/generate-svelte-ssr-fixture.mjs | 16 + test/fixtures/vm/svelte_catalog.server.js | 28 ++ test/fixtures/vm/svelte_catalog.svelte | 20 + test/fixtures/vm/svelte_ssr.js | 37 ++ test/fixtures/vm/vue_ssr.js | 30 ++ test/toolchain/bundler_test.exs | 20 + test/vm/builtin_dsl_test.exs | 14 +- test/vm/error_test.exs | 5 +- test/vm/interpreter_test.exs | 54 ++- test/vm/memory_limit_test.exs | 4 +- test/vm/svelte_ssr_test.exs | 84 ++++ test/vm/vue_ssr_test.exs | 88 +++++ 41 files changed, 2263 insertions(+), 97 deletions(-) create mode 100644 lib/quickbeam/vm/builtins/console.ex create mode 100644 lib/quickbeam/vm/builtins/map.ex create mode 100644 lib/quickbeam/vm/builtins/uint8_array.ex create mode 100644 lib/quickbeam/vm/builtins/weak_map.ex create mode 100644 lib/quickbeam/vm/builtins/weak_set.ex create mode 100644 scripts/generate-svelte-ssr-fixture.mjs create mode 100644 test/fixtures/vm/svelte_catalog.server.js create mode 100644 test/fixtures/vm/svelte_catalog.svelte create mode 100644 test/fixtures/vm/svelte_ssr.js create mode 100644 test/fixtures/vm/vue_ssr.js create mode 100644 test/vm/svelte_ssr_test.exs create mode 100644 test/vm/vue_ssr_test.exs diff --git a/docs/beam-interpreter-architecture.md b/docs/beam-interpreter-architecture.md index 4929a809b..a42353ad7 100644 --- a/docs/beam-interpreter-architecture.md +++ b/docs/beam-interpreter-architecture.md @@ -6,8 +6,8 @@ Implemented on the development branch: version-locked decoding and verification, process-isolated evaluation, explicit frames and detached async continuations, closures, exceptions, owner-local objects, Promise reactions and combinators, asynchronous `Beam.call`, logical and process memory containment, stable -JavaScript errors, and a pinned Preact SSR acceptance fixture. Broader ECMAScript -conformance, object-model hardening, garbage collection, and release hardening +JavaScript errors, and pinned Preact, Vue, and Svelte SSR acceptance fixtures. +Broader ECMAScript conformance, object-model hardening, garbage collection, and release hardening remain in progress. ## Summary @@ -510,18 +510,22 @@ The first production profile should be explicit rather than implying browser parity: ```elixir -QuickBEAM.VM.compile(source, profile: :ssr) +QuickBEAM.VM.eval(program, profile: :ssr) ``` -The `:ssr` profile should contain only the APIs demonstrated as necessary by the -chosen SSR acceptance fixture. Likely candidates are core ECMAScript built-ins, -`console`, text encoding, URL support, and selected deterministic helpers. - -The recommended first fixture is a pinned `preact` plus -`preact-render-to-string` bundle that returns real HTML from request props. The -prototype branch's Preact benchmark only walks and summarizes a VNode tree, and -the current `examples/ssr` application renders through the native lexbor DOM; -neither by itself demonstrates end-to-end SSR inside the BEAM interpreter. +The `:ssr` profile contains core ECMAScript built-ins plus the inert, evaluation-local +`console` namespace currently demonstrated by the acceptance fixtures. Text encoding, +URL support, timers, streams, Abort APIs, and networking are not implied by this profile +and require separate fixture-driven additions. + +The pinned compatibility matrix currently covers Preact 10.29.7 with +`preact-render-to-string` 6.7.0, a production-defined Vue 3.5.39 functional component, +and a server-compiled Svelte 5.56.4 component. Each fixture returns real HTML after an +asynchronous `Beam.call`, matches the vendored native QuickJS engine, and reuses one +immutable program across isolated concurrent renders. The Svelte fixture invokes current +compiler output through a minimal fixture renderer; it covers synchronous body and head +generation but does not yet claim compatibility with the full `svelte/server` asynchronous +renderer or streaming support. None of these renderers requires a DOM. DOM, fetch, sockets, workers, N-API, and WebAssembly remain unsupported unless added through separately reviewed profiles. Unsupported features fail clearly; diff --git a/docs/prototype-delta-audit.md b/docs/prototype-delta-audit.md index fb64172bb..9b560eff5 100644 --- a/docs/prototype-delta-audit.md +++ b/docs/prototype-delta-audit.md @@ -24,7 +24,7 @@ QuickJS v26 ABI, resource limits, and resumable invocation machinery. | Promises | Reject core implementation; adapt combinator tests/algorithms | Current detached async frames and reaction scheduler supersede the prototype Promise store. Iterable combinator logic remains useful. | | Coercion/arithmetic | Adapt pure semantic functions and tests | The split into arithmetic, comparison, equality, and coercion is a good target, but object coercion and throws use prototype representations. | | Test262 support | Superseded harness; extract test ideas | Current runner is pinned, differential, typed with JSONCodec, and classifies failures. Prototype paths and skip rationale remain useful input. | -| Preact SSR | Superseded | Current fixture performs real async rendering, native parity, and concurrent isolated reuse. | +| SSR fixtures | Superseded | Pinned Preact, Vue, and server-compiled Svelte fixtures perform real async rendering, native parity, and concurrent isolated reuse. | | Web/Node APIs | Defer | Outside the first explicit SSR profile. | | BEAM compiler | Quarantine for later extraction | It targets the prototype runtime ABI, creates dynamic module atoms, and relies on interpreter fallback and process-local runtime state. | diff --git a/lib/quickbeam/js/package_resolver.ex b/lib/quickbeam/js/package_resolver.ex index ea36e3001..33c1a204f 100644 --- a/lib/quickbeam/js/package_resolver.ex +++ b/lib/quickbeam/js/package_resolver.ex @@ -120,15 +120,26 @@ 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("/", package_name) do + package_dir = Path.join(["/", "node_modules", package_name]) + if File.dir?(package_dir), do: {:ok, package_dir}, else: :error + end + + defp find_package_root(dir, package_name) do + package_dir = Path.join([dir, "node_modules", package_name]) + + if File.dir?(package_dir) do + {:ok, package_dir} + else + find_package_root(Path.dirname(dir), package_name) end end diff --git a/lib/quickbeam/vm.ex b/lib/quickbeam/vm.ex index cb3ebc353..78170f556 100644 --- a/lib/quickbeam/vm.ex +++ b/lib/quickbeam/vm.ex @@ -89,8 +89,8 @@ defmodule QuickBEAM.VM do @doc """ Evaluates a verified program in an isolated BEAM process. - Supported options include `:vars`, asynchronous `:handlers`, `:timeout`, - `:max_steps`, `:max_stack_depth`, and the JavaScript allocation budget + Supported options include `:vars`, asynchronous `:handlers`, the builtin + `:profile` (`:core` or `:ssr`), `:timeout`, `:max_steps`, `:max_stack_depth`, and the JavaScript allocation budget `:memory_limit`. Isolated workers also receive a BEAM process heap ceiling. `isolation: :caller` is available for trusted diagnostics. @@ -113,6 +113,7 @@ defmodule QuickBEAM.VM do :max_stack_depth, :max_steps, :memory_limit, + :profile, :timeout, :vars ] @@ -129,6 +130,7 @@ defmodule QuickBEAM.VM do 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, %{}) @@ -148,6 +150,9 @@ defmodule QuickBEAM.VM do memory_limit != :infinity and (not is_integer(memory_limit) or memory_limit <= 0) -> {:error, {:invalid_option, :memory_limit, memory_limit}} + profile not in [:core, :ssr] -> + {:error, {:invalid_option, :profile, profile}} + not is_map(vars) -> {:error, {:invalid_option, :vars, vars}} @@ -168,6 +173,7 @@ defmodule QuickBEAM.VM do max_steps: max_steps, max_stack_depth: max_stack_depth, memory_limit: memory_limit, + profile: profile, vars: vars } }} diff --git a/lib/quickbeam/vm/builtin/registry.ex b/lib/quickbeam/vm/builtin/registry.ex index 087282103..f967d3905 100644 --- a/lib/quickbeam/vm/builtin/registry.ex +++ b/lib/quickbeam/vm/builtin/registry.ex @@ -22,12 +22,18 @@ defmodule QuickBEAM.VM.Builtin.Registry do QuickBEAM.VM.Builtins.TypeError, QuickBEAM.VM.Builtins.URIError, QuickBEAM.VM.Builtins.Symbol, + QuickBEAM.VM.Builtins.Uint8Array, + QuickBEAM.VM.Builtins.Map, + QuickBEAM.VM.Builtins.WeakMap, QuickBEAM.VM.Builtins.Set, + QuickBEAM.VM.Builtins.WeakSet, QuickBEAM.VM.Builtins.Promise ] + @ssr @core ++ [QuickBEAM.VM.Builtins.Console] + @doc "Returns builtin modules installed for a profile in dependency order." - @spec modules(atom()) :: [module()] + @spec modules(:core | :ssr) :: [module()] def modules(:core), do: @core - def modules(_profile), do: [] + def modules(:ssr), do: @ssr end diff --git a/lib/quickbeam/vm/builtins.ex b/lib/quickbeam/vm/builtins.ex index 2bdd097b1..e2cba2560 100644 --- a/lib/quickbeam/vm/builtins.ex +++ b/lib/quickbeam/vm/builtins.ex @@ -15,8 +15,9 @@ defmodule QuickBEAM.VM.Builtins do alias QuickBEAM.VM.Builtin.{Installer, Registry} - @spec install(Execution.t()) :: Execution.t() - def install(execution), do: Installer.install_all(execution, Registry.modules(:core)) + @spec install(Execution.t(), :core | :ssr) :: Execution.t() + def install(execution, profile \\ :core), + do: Installer.install_all(execution, Registry.modules(profile)) @spec callable(Execution.t(), Reference.t()) :: term() | nil def callable(execution, %Reference{} = reference) do @@ -54,16 +55,99 @@ defmodule QuickBEAM.VM.Builtins do @spec call(term(), term(), [term()], Execution.t()) :: {:ok, term(), Execution.t()} | {:error, term(), Execution.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} = Properties.define(result, 0, matched, execution) + {:ok, execution} = Properties.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} <- Properties.get(reference, "lastIndex", execution) do + {matched?, next_index} = + regex_match_from(regexp, Value.to_string_value(value), last_index) + + {:ok, execution} = Properties.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?(%RegExp{source: source}, value) do - case Regex.compile(source) do + defp regex_match_from(%RegExp{} = regexp, value, last_index) do + stateful? = regexp_flag?(regexp, 1) or regexp_flag?(regexp, 32) + current_index = if is_integer(last_index) and last_index >= 0, do: last_index, else: 0 + start = if stateful?, do: current_index, else: 0 + + case compile_regexp(regexp) do + {:ok, regex} -> + 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, if(stateful?, do: 0, else: current_index)} + end + + {:error, _reason} -> + {false, if(stateful?, do: 0, else: current_index)} + end + end + + 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/builtins/array.ex b/lib/quickbeam/vm/builtins/array.ex index adda7a793..3940028be 100644 --- a/lib/quickbeam/vm/builtins/array.ex +++ b/lib/quickbeam/vm/builtins/array.ex @@ -18,12 +18,14 @@ defmodule QuickBEAM.VM.Builtins.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 @@ -76,6 +78,24 @@ defmodule QuickBEAM.VM.Builtins.Array do 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) + + with {:ok, entries} <- array_entries(receiver, execution) do + included? = + Enum.any?(entries, fn + :hole -> searched == :undefined + {:present, :nan} -> searched == :nan + {:present, value} -> Value.strict_equal?(value, searched) + end) + + {:ok, included?, execution} + else + {: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 = @@ -146,6 +166,44 @@ defmodule QuickBEAM.VM.Builtins.Array do @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 + comparator = List.first(arguments, :undefined) + + if comparator != :undefined do + {:error, :unsupported_array_sort_comparator, execution} + else + with {:ok, entries} <- array_entries(array, execution) do + 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, fn object -> + %{object | properties: %{}, property_order: [], length: 0} + end) + + execution = + values + |> Enum.with_index() + |> Enum.reduce(execution, fn {value, index}, execution -> + {:ok, execution} = Properties.define(array, index, value, execution) + execution + end) + + {:ok, array, execution} + else + {:error, reason} -> {:error, reason, execution} + end + end + end + + def sort(%Call{execution: execution}), do: {:error, :not_an_array, execution} + defp array_entries(value, _execution) when is_list(value), do: {:ok, Enum.map(value, &{:present, &1})} diff --git a/lib/quickbeam/vm/builtins/console.ex b/lib/quickbeam/vm/builtins/console.ex new file mode 100644 index 000000000..a53317f4f --- /dev/null +++ b/lib/quickbeam/vm/builtins/console.ex @@ -0,0 +1,26 @@ +defmodule QuickBEAM.VM.Builtins.Console do + @moduledoc "Defines the minimal SSR console namespace." + + use QuickBEAM.VM.Builtin + + alias QuickBEAM.VM.Builtin.Call + + builtin "console", kind: :namespace 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/builtins/map.ex b/lib/quickbeam/vm/builtins/map.ex new file mode 100644 index 000000000..79555d7cc --- /dev/null +++ b/lib/quickbeam/vm/builtins/map.ex @@ -0,0 +1,297 @@ +defmodule QuickBEAM.VM.Builtins.Map do + @moduledoc "Defines the declarative Map constructor and core methods." + + use QuickBEAM.VM.Builtin + + alias QuickBEAM.VM.Builtin.Call + + alias QuickBEAM.VM.{ + ConstructorBoundary, + Heap, + Iterator, + Object, + Properties, + Reference, + Symbol, + 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: %ConstructorBoundary{}, + 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) + + with {:ok, entries} <- entries(map, execution) do + 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?) + else + :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} -> + value = + Enum.find_value(entries, :undefined, fn {candidate, value} -> + if same_key?(candidate, key), do: {:found, value} + end) + + value = + case value do + {:found, found} -> found + other -> other + end + + {:ok, value, 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 -> + 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, + 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}}} -> + done? = index >= length(entries) + + {value, execution} = + if done? do + {:undefined, execution} + else + {key, value} = Enum.at(entries, index) + + case kind do + :keys -> {key, execution} + :values -> {value, execution} + :entries -> map_entry_array(key, value, execution) + end + end + + {:ok, execution} = + Heap.update_object(execution, iterator, fn object -> + %{ + object + | internal: {:map_iterator, entries, if(done?, do: index, else: index + 1), kind} + } + end) + + {result, execution} = Heap.allocate(execution) + {:ok, execution} = Properties.define(result, "value", value, execution) + {:ok, execution} = Properties.define(result, "done", done?, execution) + {:ok, result, 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 = + Enum.reduce(entries, [], fn {key, value}, accumulated -> + case Enum.find_index(accumulated, fn {candidate, _value} -> same_key?(candidate, key) end) do + nil -> accumulated ++ [{key, value}] + index -> List.replace_at(accumulated, index, {key, value}) + end + end) + + 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 + Enum.reduce_while(values, {:ok, []}, fn pair, {:ok, entries} -> + with {:ok, key} <- Properties.get(pair, 0, execution), + {:ok, value} <- Properties.get(pair, 1, execution) do + {:cont, {:ok, entries ++ [{key, value}]}} + else + _error -> {:halt, {:error, :invalid_map_entry}} + end + end) + end + + defp pair_entries(other, _execution), do: other + + 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} = Properties.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} = Properties.define(entry, 0, key, execution) + {:ok, execution} = Properties.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 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/builtins/object.ex b/lib/quickbeam/vm/builtins/object.ex index 6469b4be9..e3515ebfc 100644 --- a/lib/quickbeam/vm/builtins/object.ex +++ b/lib/quickbeam/vm/builtins/object.ex @@ -20,6 +20,8 @@ defmodule QuickBEAM.VM.Builtins.Object 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 @@ -150,6 +152,35 @@ defmodule QuickBEAM.VM.Builtins.Object do 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} <- Properties.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 | _], @@ -197,6 +228,12 @@ defmodule QuickBEAM.VM.Builtins.Object do 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." @@ -227,6 +264,21 @@ defmodule QuickBEAM.VM.Builtins.Object do 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) <- + Properties.get(descriptors, key, execution), + {:ok, current} <- Properties.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), diff --git a/lib/quickbeam/vm/builtins/set.ex b/lib/quickbeam/vm/builtins/set.ex index 6ce90950c..7dde6b7dd 100644 --- a/lib/quickbeam/vm/builtins/set.ex +++ b/lib/quickbeam/vm/builtins/set.ex @@ -13,7 +13,8 @@ defmodule QuickBEAM.VM.Builtins.Set do Object, Properties, Reference, - Symbol + Symbol, + Value } builtin "Set", @@ -23,6 +24,7 @@ defmodule QuickBEAM.VM.Builtins.Set do 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 @@ -87,6 +89,34 @@ defmodule QuickBEAM.VM.Builtins.Set do 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}}} -> + found? = MapSet.member?(index, value) + + {:ok, execution} = + Heap.update_object(execution, set, fn object -> + %{ + object + | internal: %{ + values: Enum.reject(values, &Value.strict_equal?(&1, value)), + index: MapSet.delete(index, value) + } + } + 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) diff --git a/lib/quickbeam/vm/builtins/string.ex b/lib/quickbeam/vm/builtins/string.ex index 8f40d87f8..2868f8506 100644 --- a/lib/quickbeam/vm/builtins/string.ex +++ b/lib/quickbeam/vm/builtins/string.ex @@ -14,14 +14,20 @@ defmodule QuickBEAM.VM.Builtins.String 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 @@ -51,6 +57,21 @@ defmodule QuickBEAM.VM.Builtins.String do 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 -> @@ -59,6 +80,22 @@ defmodule QuickBEAM.VM.Builtins.String do 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 -> @@ -67,6 +104,21 @@ defmodule QuickBEAM.VM.Builtins.String do 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 -> @@ -107,6 +159,17 @@ defmodule QuickBEAM.VM.Builtins.String do 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: @@ -114,6 +177,13 @@ defmodule QuickBEAM.VM.Builtins.String do {: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) @@ -166,6 +236,14 @@ defmodule QuickBEAM.VM.Builtins.String do {start, max(finish - start, 0)} end + 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 normalize_slice_index(value, size) do case Value.to_number(value) do :infinity -> size diff --git a/lib/quickbeam/vm/builtins/symbol.ex b/lib/quickbeam/vm/builtins/symbol.ex index 6c3819b15..efe77997e 100644 --- a/lib/quickbeam/vm/builtins/symbol.ex +++ b/lib/quickbeam/vm/builtins/symbol.ex @@ -7,9 +7,16 @@ defmodule QuickBEAM.VM.Builtins.Symbol do alias QuickBEAM.VM.{Symbol, Value} builtin "Symbol", kind: :function, length: 0 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 diff --git a/lib/quickbeam/vm/builtins/uint8_array.ex b/lib/quickbeam/vm/builtins/uint8_array.ex new file mode 100644 index 000000000..f7e3f3a40 --- /dev/null +++ b/lib/quickbeam/vm/builtins/uint8_array.ex @@ -0,0 +1,41 @@ +defmodule QuickBEAM.VM.Builtins.Uint8Array do + @moduledoc "Defines the minimal Uint8Array constructor required by server bundles." + + use QuickBEAM.VM.Builtin + + alias QuickBEAM.VM.Builtin.Call + alias QuickBEAM.VM.{Heap, Properties} + + 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} = Properties.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/builtins/weak_map.ex b/lib/quickbeam/vm/builtins/weak_map.ex new file mode 100644 index 000000000..863dd5654 --- /dev/null +++ b/lib/quickbeam/vm/builtins/weak_map.ex @@ -0,0 +1,39 @@ +defmodule QuickBEAM.VM.Builtins.WeakMap do + @moduledoc "Defines evaluation-local WeakMap semantics for object keys." + + use QuickBEAM.VM.Builtin + + alias QuickBEAM.VM.Builtin.Call + alias QuickBEAM.VM.Builtins.Map, as: MapBuiltin + alias QuickBEAM.VM.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: MapBuiltin.construct(call) + + @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, :invalid_weak_map_key, execution} +end diff --git a/lib/quickbeam/vm/builtins/weak_set.ex b/lib/quickbeam/vm/builtins/weak_set.ex new file mode 100644 index 000000000..c81a4487c --- /dev/null +++ b/lib/quickbeam/vm/builtins/weak_set.ex @@ -0,0 +1,35 @@ +defmodule QuickBEAM.VM.Builtins.WeakSet do + @moduledoc "Defines evaluation-local WeakSet semantics for object values." + + use QuickBEAM.VM.Builtin + + alias QuickBEAM.VM.Builtin.Call + alias QuickBEAM.VM.Builtins.Set, as: SetBuiltin + alias QuickBEAM.VM.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: SetBuiltin.construct(call) + + @doc "Adds a WeakSet value." + def add(%Call{arguments: [%Reference{} | _]} = call), do: SetBuiltin.add(call) + + def add(%Call{execution: execution}), do: {: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) +end diff --git a/lib/quickbeam/vm/frame.ex b/lib/quickbeam/vm/frame.ex index 621e77829..083d8574f 100644 --- a/lib/quickbeam/vm/frame.ex +++ b/lib/quickbeam/vm/frame.ex @@ -2,7 +2,17 @@ defmodule QuickBEAM.VM.Frame do @moduledoc "Defines an explicit JavaScript bytecode call frame." @enforce_keys [:function, :callable, :locals, :args] - defstruct [:function, :callable, :locals, :args, :this, closure_refs: {}, pc: 0, stack: []] + defstruct [ + :function, + :callable, + :locals, + :args, + :this, + actual_arg_count: 0, + closure_refs: {}, + pc: 0, + stack: [] + ] @type t :: %__MODULE__{ function: QuickBEAM.VM.Function.t(), @@ -10,6 +20,7 @@ defmodule QuickBEAM.VM.Frame do closure_refs: tuple(), locals: tuple(), args: tuple(), + actual_arg_count: non_neg_integer(), this: term(), pc: non_neg_integer(), stack: [term()] diff --git a/lib/quickbeam/vm/interpreter.ex b/lib/quickbeam/vm/interpreter.ex index 347537785..dc858f4ec 100644 --- a/lib/quickbeam/vm/interpreter.ex +++ b/lib/quickbeam/vm/interpreter.ex @@ -83,7 +83,7 @@ defmodule QuickBEAM.VM.Interpreter do } execution = Memory.charge(execution, Memory.estimate(vars)) - execution = install_host_globals(execution) + execution = install_host_globals(execution, Keyword.get(opts, :profile, :core)) frame = Invocation.new_frame(program.root, program.root, [], :undefined, {}) run(frame, execution) end @@ -260,7 +260,7 @@ defmodule QuickBEAM.VM.Interpreter do do: name |> ValueOpcodes.execute(operands, frame, execution) |> execute_opcode() defp execute(name, _operands, frame, execution) - when name in [:nop, :set_name, :set_name_computed, :check_ctor], + when name in [:nop, :set_name, :set_name_computed], do: continue(frame, execution) defp execute(name, operands, _frame, execution), @@ -690,6 +690,18 @@ defmodule QuickBEAM.VM.Interpreter do dispatch_call(constructor, arguments, instance, boundary, execution, false) end + defp execute_opcode( + {:invoke_super_constructor, constructor, arguments, instance, caller, execution} + ) do + boundary = %ConstructorBoundary{ + 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 @@ -757,14 +769,14 @@ defmodule QuickBEAM.VM.Interpreter do defp suspend_microtask(result, frame, execution), do: frame |> next_frame() |> Async.suspend_microtask(execution, result) |> execute_async() - defp install_host_globals(execution) do - execution = Builtins.install(execution) + defp install_host_globals(execution, profile) do + execution = Builtins.install(execution, profile) {beam, execution} = Heap.allocate(execution) {:ok, execution} = Properties.define(beam, "call", {:host_function, :beam_call}, execution) - {global_this, execution} = Heap.allocate(execution) + {global_this, execution} = Heap.allocate(execution, :ordinary, internal: :global_object) globals = execution.globals diff --git a/lib/quickbeam/vm/invocation.ex b/lib/quickbeam/vm/invocation.ex index 4f7475876..bfb28a8de 100644 --- a/lib/quickbeam/vm/invocation.ex +++ b/lib/quickbeam/vm/invocation.ex @@ -138,9 +138,15 @@ defmodule QuickBEAM.VM.Invocation do @doc "Returns whether a value can be used as a JavaScript constructor." @spec constructable?(term(), Execution.t()) :: boolean() def constructable?(%Reference{} = constructor, execution) do - case Builtins.callable(execution, constructor) do - nil -> false - callable -> constructable?(callable, execution) + case QuickBEAM.VM.Heap.fetch_object(execution, constructor) do + {:ok, %{internal: :class_constructor}} -> + true + + _other -> + case Builtins.callable(execution, constructor) do + nil -> false + callable -> constructable?(callable, execution) + end end end @@ -205,6 +211,9 @@ defmodule QuickBEAM.VM.Invocation do @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, @@ -212,6 +221,7 @@ defmodule QuickBEAM.VM.Invocation do 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 diff --git a/lib/quickbeam/vm/iterator.ex b/lib/quickbeam/vm/iterator.ex index f2450b4ef..a6fcdf1e2 100644 --- a/lib/quickbeam/vm/iterator.ex +++ b/lib/quickbeam/vm/iterator.ex @@ -41,6 +41,9 @@ defmodule QuickBEAM.VM.Iterator do {: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} diff --git a/lib/quickbeam/vm/object.ex b/lib/quickbeam/vm/object.ex index 17efda5e9..84338cadd 100644 --- a/lib/quickbeam/vm/object.ex +++ b/lib/quickbeam/vm/object.ex @@ -11,7 +11,7 @@ defmodule QuickBEAM.VM.Object do callable: nil, internal: nil - @type kind :: :ordinary | :array | :function | :promise | :set + @type kind :: :ordinary | :array | :function | :map | :promise | :regexp | :set @type t :: %__MODULE__{ kind: kind(), prototype: QuickBEAM.VM.Reference.t() | nil, diff --git a/lib/quickbeam/vm/opcodes/invocation.ex b/lib/quickbeam/vm/opcodes/invocation.ex index 9eee9e931..702004935 100644 --- a/lib/quickbeam/vm/opcodes/invocation.ex +++ b/lib/quickbeam/vm/opcodes/invocation.ex @@ -8,13 +8,23 @@ defmodule QuickBEAM.VM.Opcodes.Invocation do continuation frames and executing invocation actions. """ - alias QuickBEAM.VM.{Execution, Frame, Heap, Invocation} - - @opcodes [:call, :tail_call, :call_method, :tail_call_method, :call_constructor] + alias QuickBEAM.VM.{Execution, Frame, Heap, Invocation, Iterator} + + @opcodes [ + :apply, + :call, + :tail_call, + :call_method, + :tail_call_method, + :call_constructor, + :check_ctor, + :init_ctor + ] @type action :: {:invoke, term(), [term()], term(), Frame.t(), Execution.t(), boolean()} | {:invoke_constructor, term(), [term()], term(), Frame.t(), Execution.t()} + | {:invoke_super_constructor, term(), [term()], term(), Frame.t(), Execution.t()} | {:throw, term(), Frame.t(), Execution.t()} | {:error, term(), Execution.t()} @@ -24,6 +34,50 @@ defmodule QuickBEAM.VM.Opcodes.Invocation do @doc "Executes one supported invocation opcode." @spec execute(atom(), [term()], Frame.t(), Execution.t()) :: action() + def execute(:check_ctor, [], %Frame{callable: callable, this: this} = frame, execution) do + with %QuickBEAM.VM.Reference{} <- callable, + {:ok, %{internal: :class_constructor}} <- Heap.fetch_object(execution, callable), + %QuickBEAM.VM.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) @@ -54,6 +108,28 @@ defmodule QuickBEAM.VM.Opcodes.Invocation do end end + def execute( + :init_ctor, + [], + %Frame{callable: %QuickBEAM.VM.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) diff --git a/lib/quickbeam/vm/opcodes/locals.ex b/lib/quickbeam/vm/opcodes/locals.ex index 76fc7e6c3..3b971b703 100644 --- a/lib/quickbeam/vm/opcodes/locals.ex +++ b/lib/quickbeam/vm/opcodes/locals.ex @@ -38,6 +38,7 @@ defmodule QuickBEAM.VM.Opcodes.Locals do @opcodes [ :push_atom_value, + :rest, :get_arg, :put_arg, :set_arg, @@ -78,6 +79,23 @@ defmodule QuickBEAM.VM.Opcodes.Locals do 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} = Properties.define(array, index, value, execution) + execution + end) + + push(frame, execution, array) + end + def execute(:get_arg, [index], frame, execution), do: push(frame, execution, read_slot(tuple_get(frame.args, index), execution)) @@ -164,7 +182,7 @@ defmodule QuickBEAM.VM.Opcodes.Locals do def execute(:get_var, [atom], frame, execution) do name = resolve_atom(atom, execution) - case Map.fetch(execution.globals, name) do + case global_value(execution, name) do {:ok, value} -> push(frame, execution, value) :error -> {:throw, {:reference_error, name}, frame, execution} end @@ -172,13 +190,20 @@ defmodule QuickBEAM.VM.Opcodes.Locals do def execute(:get_var_undef, [atom], frame, execution) do name = resolve_atom(atom, execution) - push(frame, execution, Map.get(execution.globals, name, :undefined)) + + value = + case global_value(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 = %{execution | globals: Map.put(execution.globals, name, value)} + execution = put_global(execution, name, value) next(%{frame | stack: stack}, execution) end @@ -188,11 +213,26 @@ defmodule QuickBEAM.VM.Opcodes.Locals do def execute(name, [index], frame, execution) when name in [:fclosure, :fclosure8] do function = Enum.at(frame.function.constants, index) - {callable, frame, execution} = capture_closure(function, frame, execution) - {reference, execution} = allocate_function(callable, function, execution) + {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(), Execution.t(), keyword()) :: + {Reference.t(), Frame.t(), Execution.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 "Reads a direct value or owner-local cell/global slot." @spec read_slot(term(), Execution.t()) :: term() def read_slot({:cell, _id} = reference, execution), do: read_reference(reference, execution) @@ -215,6 +255,35 @@ defmodule QuickBEAM.VM.Opcodes.Locals do def resolve_atom(value, _execution), do: value + defp global_value(execution, name) do + case Map.get(execution.globals, "globalThis") do + %QuickBEAM.VM.Reference{} = global_this -> + case Properties.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 + + defp put_global(execution, name, value) do + execution = %{execution | globals: Map.put(execution.globals, name, value)} + + case Map.get(execution.globals, "globalThis") do + %QuickBEAM.VM.Reference{} = global_this -> + case Properties.put(global_this, name, value, execution) do + {:ok, execution} -> execution + {:error, _reason} -> execution + end + + _other -> + execution + end + end + defp update_local(frame, execution, index, operation) do value = read_slot(elem(frame.locals, index), execution) {locals, execution} = write_tuple_slot(frame.locals, index, operation.(value), execution) diff --git a/lib/quickbeam/vm/opcodes/objects.ex b/lib/quickbeam/vm/opcodes/objects.ex index ea38d0bf6..708d6e12b 100644 --- a/lib/quickbeam/vm/opcodes/objects.ex +++ b/lib/quickbeam/vm/opcodes/objects.ex @@ -7,18 +7,46 @@ defmodule QuickBEAM.VM.Opcodes.Objects do resumable frames and JavaScript exception boundaries. """ - alias QuickBEAM.VM.{Execution, Frame, Heap, Properties, Reference, RegExp} + import Bitwise + + alias QuickBEAM.VM.{ + Execution, + Frame, + Function, + Heap, + Invocation, + Iterator, + Properties, + Property, + Reference, + RegExp, + Symbol, + Value + } + alias QuickBEAM.VM.Opcodes.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, @@ -27,7 +55,10 @@ defmodule QuickBEAM.VM.Opcodes.Objects do :put_array_el, :delete, :for_in_start, - :for_in_next + :for_in_next, + :for_of_start, + :for_of_next, + :iterator_close ] @type action :: @@ -42,8 +73,134 @@ defmodule QuickBEAM.VM.Opcodes.Objects do @doc "Executes one supported object or property opcode." @spec execute(atom(), [term()], Frame.t(), Execution.t()) :: action() - def execute(:regexp, [], %{stack: [bytecode, source | stack]} = frame, execution), - do: push(%{frame | stack: stack}, execution, %RegExp{source: source, bytecode: bytecode}) + def execute(:regexp, [], %{stack: [bytecode, source | stack]} = frame, execution) do + {regexp, execution} = + Heap.allocate(execution, :regexp, internal: %RegExp{source: source, bytecode: bytecode}) + + {:ok, execution} = Properties.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, %Property{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, %Property{}} -> + case Properties.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} -> + case Properties.define(object, key, value, execution) do + {:ok, execution} -> next(%{frame | stack: [object | stack]}, execution) + {:error, reason} -> {:throw, {:type_error, reason}, frame, execution} + end + + {:ok, %Property{}} -> + {: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 Properties.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{} -> Properties.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, %Property{value: %Reference{} = home}} <- + Heap.own_property(execution, function, :home_object), + {:ok, %Property{value: %Symbol{} = brand}} <- + Heap.own_property(execution, home, :private_brand_token), + {:ok, %Property{}} <- + 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) @@ -65,6 +222,30 @@ defmodule QuickBEAM.VM.Opcodes.Objects do 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, %Property{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) @@ -89,6 +270,38 @@ defmodule QuickBEAM.VM.Opcodes.Objects do 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], @@ -96,14 +309,7 @@ defmodule QuickBEAM.VM.Opcodes.Objects do execution ) do key = Locals.resolve_atom(atom, execution) - - result = - case kind do - 4 -> Properties.define(object, key, callable, execution) - 5 -> Properties.define_accessor(object, key, :getter, callable, execution) - 6 -> Properties.define_accessor(object, key, :setter, callable, execution) - _kind -> {:error, {:unsupported_method_kind, kind}} - end + result = define_method_property(object, key, callable, kind, execution) case result do {:ok, execution} -> next(%{frame | stack: [object | stack]}, execution) @@ -117,13 +323,7 @@ defmodule QuickBEAM.VM.Opcodes.Objects do %{stack: [callable, key, %Reference{} = object | stack]} = frame, execution ) do - result = - case kind do - 4 -> Properties.define(object, key, callable, execution) - 5 -> Properties.define_accessor(object, key, :getter, callable, execution) - 6 -> Properties.define_accessor(object, key, :setter, callable, execution) - _kind -> {:error, {:unsupported_method_kind, kind}} - end + result = define_method_property(object, key, callable, kind, execution) case result do {:ok, execution} -> next(%{frame | stack: [object | stack]}, execution) @@ -157,6 +357,51 @@ defmodule QuickBEAM.VM.Opcodes.Objects do 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} = Properties.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} <- Properties.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) @@ -206,6 +451,168 @@ defmodule QuickBEAM.VM.Opcodes.Objects do 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 copy_excluded_keys(value, _execution) when value in [:undefined, nil], do: {:ok, []} + defp copy_excluded_keys(value, execution), do: Properties.enumerable_keys(value, execution) + + defp copy_data_properties(keys, target, source, execution) do + Enum.reduce_while(keys, {:ok, execution}, fn key, {:ok, execution} -> + case Properties.get(source, key, execution) do + {:ok, {:accessor, _getter, _receiver}} -> + {:halt, {:error, :unsupported_copy_accessor}} + + {:ok, value} -> + case Properties.put(target, key, value, execution) do + {:ok, execution} -> {:cont, {:ok, execution}} + {:error, reason} -> {:halt, {:error, reason}} + end + + {:error, reason} -> + {:halt, {:error, reason}} + end + end) + end + + defp private_brand(home, execution) do + case Heap.own_property(execution, home, :private_brand_token) do + {:ok, %Property{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} = Properties.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 -> Properties.define(object, key, callable, execution, opts) + 1 -> Properties.define_accessor(object, key, :getter, callable, execution, opts) + 2 -> Properties.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, prototype: parent_prototype) + + {:ok, execution} = + Properties.define(prototype, "constructor", constructor, execution, + writable: true, + enumerable: false, + configurable: true + ) + + {:ok, execution} = + Properties.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} = + Properties.define(constructor, "name", name, execution, + writable: false, + enumerable: false, + configurable: true + ) + + execution = + case parent do + %Reference{} = parent -> + case Heap.set_prototype(execution, constructor, parent) do + {:ok, execution} -> execution + {:error, _reason} -> execution + end + + _other -> + execution + end + + next(%{frame | stack: [prototype, constructor | stack]}, execution) + else + {:error, reason} -> {:throw, {:type_error, reason}, frame, execution} + end + end + + 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 Properties.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} diff --git a/lib/quickbeam/vm/opcodes/values.ex b/lib/quickbeam/vm/opcodes/values.ex index bf67c354a..672ad6b98 100644 --- a/lib/quickbeam/vm/opcodes/values.ex +++ b/lib/quickbeam/vm/opcodes/values.ex @@ -50,6 +50,7 @@ defmodule QuickBEAM.VM.Opcodes.Values do :post_inc, :post_dec, :to_propkey, + :to_propkey2, :to_object, :is_function, :typeof, @@ -87,6 +88,12 @@ defmodule QuickBEAM.VM.Opcodes.Values do 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} diff --git a/lib/quickbeam/vm/properties.ex b/lib/quickbeam/vm/properties.ex index 2221ac13f..0b2200376 100644 --- a/lib/quickbeam/vm/properties.ex +++ b/lib/quickbeam/vm/properties.ex @@ -36,7 +36,19 @@ defmodule QuickBEAM.VM.Properties do def get(%Reference{} = object, key, execution) do case Heap.get(execution, object, key) do {:ok, :undefined} = missing -> - missing + case Heap.fetch_object(execution, object) do + {:ok, %{internal: :global_object}} -> + case Map.fetch(execution.globals, key) do + {:ok, value} -> {:ok, value} + :error -> missing + end + + {:ok, %{kind: :regexp}} when key in ["exec", "test"] -> + {:ok, {:primitive_method, :regexp, key}} + + _other -> + missing + end result -> result diff --git a/lib/quickbeam/vm/symbol.ex b/lib/quickbeam/vm/symbol.ex index f4ab1fb71..402fb851e 100644 --- a/lib/quickbeam/vm/symbol.ex +++ b/lib/quickbeam/vm/symbol.ex @@ -4,7 +4,10 @@ defmodule QuickBEAM.VM.Symbol do @enforce_keys [:id, :description] defstruct [:id, :description] - @type t :: %__MODULE__{id: atom() | {:local, non_neg_integer()}, description: String.t()} + @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() diff --git a/package-lock.json b/package-lock.json index 135d0cbd4..693f59d44 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,12 @@ "@node-rs/argon2": "^2.0.2", "@node-rs/bcrypt": "^1.10.7", "@node-rs/crc32": "^1.10.6", + "@vue/server-renderer": "3.5.39", "preact": "10.29.7", "preact-render-to-string": "6.7.0", - "sqlite-napi": "1.2.0" + "sqlite-napi": "1.2.0", + "svelte": "5.56.4", + "vue": "3.5.39" }, "devDependencies": { "jscpd": "4.0.9", @@ -21,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" @@ -57,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" @@ -106,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", @@ -1862,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", @@ -1869,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", @@ -1892,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", @@ -1906,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", @@ -2053,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", @@ -2097,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", @@ -2136,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", @@ -2169,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", @@ -2468,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", @@ -2571,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", @@ -2632,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", @@ -2815,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, @@ -2826,6 +3110,34 @@ "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", @@ -3129,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", @@ -3200,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, @@ -3251,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", @@ -3289,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 45317f977..a8bf6a4d6 100644 --- a/package.json +++ b/package.json @@ -3,9 +3,12 @@ "@node-rs/argon2": "^2.0.2", "@node-rs/bcrypt": "^1.10.7", "@node-rs/crc32": "^1.10.6", + "@vue/server-renderer": "3.5.39", "preact": "10.29.7", "preact-render-to-string": "6.7.0", - "sqlite-napi": "1.2.0" + "sqlite-napi": "1.2.0", + "svelte": "5.56.4", + "vue": "3.5.39" }, "devDependencies": { "jscpd": "4.0.9", @@ -16,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/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/toolchain/bundler_test.exs b/test/toolchain/bundler_test.exs index 49489775a..bc7502225 100644 --- a/test/toolchain/bundler_test.exs +++ b/test/toolchain/bundler_test.exs @@ -89,6 +89,26 @@ defmodule QuickBEAM.Toolchain.BundlerTest do 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/builtin_dsl_test.exs b/test/vm/builtin_dsl_test.exs index 54b43233d..ec813faea 100644 --- a/test/vm/builtin_dsl_test.exs +++ b/test/vm/builtin_dsl_test.exs @@ -107,7 +107,7 @@ defmodule QuickBEAM.VM.BuiltinDSLTest do assert [%FunctionSpec{key: "isArray", handler: :is_array, length: 1}] = array.statics assert Enum.map(array.prototype, & &1.key) == - ~w(concat filter forEach join map push reduce slice some) + ~w(concat filter forEach includes join map push reduce slice some sort) assert Registry.modules(:core) == [ QuickBEAM.VM.Builtins.Object, @@ -125,7 +125,11 @@ defmodule QuickBEAM.VM.BuiltinDSLTest do QuickBEAM.VM.Builtins.TypeError, QuickBEAM.VM.Builtins.URIError, QuickBEAM.VM.Builtins.Symbol, + QuickBEAM.VM.Builtins.Uint8Array, + QuickBEAM.VM.Builtins.Map, + QuickBEAM.VM.Builtins.WeakMap, QuickBEAM.VM.Builtins.Set, + QuickBEAM.VM.Builtins.WeakSet, QuickBEAM.VM.Builtins.Promise ] @@ -157,10 +161,14 @@ defmodule QuickBEAM.VM.BuiltinDSLTest do symbol = QuickBEAM.VM.Builtins.Symbol.builtin_spec() assert symbol.kind == :function assert symbol.constructor == nil - assert [%{key: "iterator", value: %QuickBEAM.VM.Symbol{id: :iterator}}] = symbol.statics + + assert Enum.any?( + symbol.statics, + &match?(%{key: "iterator", value: %QuickBEAM.VM.Symbol{id: :iterator}}, &1) + ) assert Enum.map(QuickBEAM.VM.Builtins.Object.builtin_spec().statics, & &1.key) == - ~w(assign create defineProperty getOwnPropertyDescriptor getOwnPropertyNames getPrototypeOf keys setPrototypeOf) + ~w(assign create defineProperty defineProperties freeze getOwnPropertyDescriptor getOwnPropertyNames getPrototypeOf keys setPrototypeOf) end test "installs callable but non-constructable Symbol semantics" do diff --git a/test/vm/error_test.exs b/test/vm/error_test.exs index 9dbe0eb73..943d7ccbe 100644 --- a/test/vm/error_test.exs +++ b/test/vm/error_test.exs @@ -116,9 +116,10 @@ defmodule QuickBEAM.VM.ErrorTest do assert {:error, {:limit_exceeded, :steps, 10}} = QuickBEAM.VM.eval(loop, max_steps: 10) - assert {:ok, unsupported} = QuickBEAM.VM.compile("class Unsupported {}") + assert {:ok, unsupported} = + QuickBEAM.VM.compile("function* unsupported(){yield 1} unsupported()") - assert {:error, {:unsupported_opcode, :define_class, _operands}} = + assert {:error, {:unsupported_opcode, :initial_yield, _operands}} = QuickBEAM.VM.eval(unsupported) end end diff --git a/test/vm/interpreter_test.exs b/test/vm/interpreter_test.exs index 6a54ad76d..4263df351 100644 --- a/test/vm/interpreter_test.exs +++ b/test/vm/interpreter_test.exs @@ -53,6 +53,49 @@ defmodule QuickBEAM.VM.InterpreterTest do 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) @@ -64,6 +107,11 @@ defmodule QuickBEAM.VM.InterpreterTest do 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}" @@ -197,8 +245,10 @@ defmodule QuickBEAM.VM.InterpreterTest do end test "reports unsupported opcodes without crashing the caller" do - assert {:ok, program} = QuickBEAM.VM.compile("class UnsupportedClass {}") - assert {:error, {:unsupported_opcode, _opcode, _operands}} = QuickBEAM.VM.eval(program) + 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/memory_limit_test.exs b/test/vm/memory_limit_test.exs index b9df4a715..bf69121a2 100644 --- a/test/vm/memory_limit_test.exs +++ b/test/vm/memory_limit_test.exs @@ -55,10 +55,10 @@ defmodule QuickBEAM.VM.MemoryLimitTest do Enum.to_list(1..5_000) end - assert {:error, {:limit_exceeded, :memory_bytes, 100_000}} = + assert {:error, {:limit_exceeded, :memory_bytes, 150_000}} = QuickBEAM.VM.eval(program, handlers: %{"large_result" => handler}, - memory_limit: 100_000, + memory_limit: 150_000, timeout: 5_000 ) diff --git a/test/vm/svelte_ssr_test.exs b/test/vm/svelte_ssr_test.exs new file mode 100644 index 000000000..d73045112 --- /dev/null +++ b/test/vm/svelte_ssr_test.exs @@ -0,0 +1,84 @@ +defmodule QuickBEAM.VM.SvelteSSRTest do + use ExUnit.Case, async: false + + @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 + 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} = QuickBEAM.VM.eval(program, [handlers: handlers] ++ @eval_opts) + assert beam_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 + + QuickBEAM.VM.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/vue_ssr_test.exs b/test/vm/vue_ssr_test.exs new file mode 100644 index 000000000..6074d57da --- /dev/null +++ b/test/vm/vue_ssr_test.exs @@ -0,0 +1,88 @@ +defmodule QuickBEAM.VM.VueSSRTest do + use ExUnit.Case, async: false + + @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 + assert {:ok, source} = QuickBEAM.JS.bundle_file(@fixture, @bundle_opts) + assert {:ok, program} = QuickBEAM.VM.compile(source, filename: @fixture) + {:ok, source: source, program: 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, 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} = QuickBEAM.VM.eval(program, [handlers: handlers] ++ @eval_opts) + assert beam_html == native_html + after + QuickBEAM.stop(runtime) + end + end + + test "shares one program across isolated concurrent Vue renders", %{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 + + QuickBEAM.VM.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 From d49e0ab46b97a272dd821c057bdd15649bef926e Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 13 Jul 2026 21:15:45 +0200 Subject: [PATCH 43/87] Serialize third-party addon lifecycle tests --- test/napi_test.exs | 38 +++++++++++++------------------------- 1 file changed, 13 insertions(+), 25 deletions(-) diff --git a/test/napi_test.exs b/test/napi_test.exs index 83156a5c6..b4050bab2 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__) @@ -310,7 +312,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,29 +321,15 @@ 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')"); - "ok" - """) - - 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, rt} = QuickBEAM.start() - {:ok, _} = QuickBEAM.load_addon(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"); + 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" """) From f40a698c55aa8c3edf57d1f618900e1c45f394c2 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 13 Jul 2026 22:11:47 +0200 Subject: [PATCH 44/87] Discover builtin registry from app manifest --- docs/beam-interpreter-architecture.md | 9 +- docs/prototype-delta-audit.md | 7 +- lib/quickbeam/vm/builtin/installer.ex | 2 +- lib/quickbeam/vm/builtin/registry.ex | 135 ++++++++++++++++++++------ lib/quickbeam/vm/builtins.ex | 2 +- lib/quickbeam/vm/builtins/console.ex | 5 +- lib/quickbeam/vm/builtins/math.ex | 2 +- lib/quickbeam/vm/builtins/symbol.ex | 2 +- test/vm/builtin_dsl_test.exs | 24 +++-- 9 files changed, 135 insertions(+), 53 deletions(-) diff --git a/docs/beam-interpreter-architecture.md b/docs/beam-interpreter-architecture.md index a42353ad7..6443dc381 100644 --- a/docs/beam-interpreter-architecture.md +++ b/docs/beam-interpreter-architecture.md @@ -458,9 +458,12 @@ dependencies. A typed prototype spec is compiled from the nested semantic `primitive`, and `error_type` describe JavaScript topology without exposing installer field names. This models the null-rooted Object prototype, callable Function prototype, constructor cycles, boxed primitives, and Array defaults -without a bootstrap constructor table. An explicit profile registry installs -specs in validated dependency order into each owner-local execution. Runtime -application-module discovery is forbidden. +without a bootstrap constructor table. Mix records the complete QuickBEAM +module inventory in the compiled application manifest. The profile registry +filters that inventory for immutable builtin specs, orders them by declared +JavaScript dependencies, caches the result, and installs it into each owner-local +execution. This avoids both a manually duplicated module list and +code-loading-order-dependent runtime discovery. Installed functions are real owner-local function objects carrying stable module/handler tokens, not captured closures. Calls receive an explicit diff --git a/docs/prototype-delta-audit.md b/docs/prototype-delta-audit.md index 9b560eff5..db84a147d 100644 --- a/docs/prototype-delta-audit.md +++ b/docs/prototype-delta-audit.md @@ -376,10 +376,11 @@ High-value test groups to adapt next: ### Declarative builtin migration (in progress) - Adapted the prototype's useful spec/installer idea without its process-local - heap, loaded-module discovery, giant multi-form macro surface, or generated + heap, code-loading-order discovery, giant multi-form macro surface, or generated fallback dispatch. -- Builtin declarations now compile to immutable validated specs and install from - an explicit deterministic profile registry. +- Builtin declarations compile to immutable validated specs. The deterministic + profile registry discovers them from Mix's compiled application module + manifest and topologically orders their declared dependencies. - DSL v2 uses parenthesis-free handler-first declarations with inferred JS names, typed constants/data/accessors, profile and dependency metadata, and separate compiler, validator, installer, and runtime-contract modules. diff --git a/lib/quickbeam/vm/builtin/installer.ex b/lib/quickbeam/vm/builtin/installer.ex index 4d9d51639..7bf26822c 100644 --- a/lib/quickbeam/vm/builtin/installer.ex +++ b/lib/quickbeam/vm/builtin/installer.ex @@ -24,7 +24,7 @@ defmodule QuickBEAM.VM.Builtin.Installer do specs = modules |> Enum.map(& &1.builtin_spec()) - |> Enum.filter(&(profile in &1.profiles)) + |> Enum.filter(&(:core in &1.profiles or profile in &1.profiles)) validate_registry!(specs, execution) Enum.reduce(specs, execution, &install(&2, &1)) diff --git a/lib/quickbeam/vm/builtin/registry.ex b/lib/quickbeam/vm/builtin/registry.ex index f967d3905..a15c391ac 100644 --- a/lib/quickbeam/vm/builtin/registry.ex +++ b/lib/quickbeam/vm/builtin/registry.ex @@ -1,39 +1,110 @@ defmodule QuickBEAM.VM.Builtin.Registry do @moduledoc """ - Defines the explicit deterministic builtin registry for each VM profile. + Discovers builtin modules from QuickBEAM's compiled application manifest. - Modules are listed deliberately; runtime application-module discovery is not - used because it makes installation dependent on code-loading order. + 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` because every + isolated evaluation installs the same immutable builtin topology. """ - @core [ - QuickBEAM.VM.Builtins.Object, - QuickBEAM.VM.Builtins.Function, - QuickBEAM.VM.Builtins.Math, - QuickBEAM.VM.Builtins.Array, - QuickBEAM.VM.Builtins.String, - QuickBEAM.VM.Builtins.Number, - QuickBEAM.VM.Builtins.Boolean, - QuickBEAM.VM.Builtins.Error, - QuickBEAM.VM.Builtins.EvalError, - QuickBEAM.VM.Builtins.RangeError, - QuickBEAM.VM.Builtins.ReferenceError, - QuickBEAM.VM.Builtins.SyntaxError, - QuickBEAM.VM.Builtins.TypeError, - QuickBEAM.VM.Builtins.URIError, - QuickBEAM.VM.Builtins.Symbol, - QuickBEAM.VM.Builtins.Uint8Array, - QuickBEAM.VM.Builtins.Map, - QuickBEAM.VM.Builtins.WeakMap, - QuickBEAM.VM.Builtins.Set, - QuickBEAM.VM.Builtins.WeakSet, - QuickBEAM.VM.Builtins.Promise - ] - - @ssr @core ++ [QuickBEAM.VM.Builtins.Console] - - @doc "Returns builtin modules installed for a profile in dependency order." + alias QuickBEAM.VM.Builtin.Spec + + @application :quickbeam + @cache_key {__MODULE__, :profiles} + @profiles [:core, :ssr] + + @doc "Returns discovered builtin modules in deterministic dependency order." @spec modules(:core | :ssr) :: [module()] - def modules(:core), do: @core - def modules(:ssr), do: @ssr + def modules(profile) when profile in @profiles do + registry() + |> Map.fetch!(profile) + |> Enum.map(& &1.module) + 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) + + :persistent_term.put(@cache_key, registry) + 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/builtins.ex b/lib/quickbeam/vm/builtins.ex index e2cba2560..5a3f091a1 100644 --- a/lib/quickbeam/vm/builtins.ex +++ b/lib/quickbeam/vm/builtins.ex @@ -17,7 +17,7 @@ defmodule QuickBEAM.VM.Builtins do @spec install(Execution.t(), :core | :ssr) :: Execution.t() def install(execution, profile \\ :core), - do: Installer.install_all(execution, Registry.modules(profile)) + do: Installer.install_all(execution, Registry.modules(profile), profile) @spec callable(Execution.t(), Reference.t()) :: term() | nil def callable(execution, %Reference{} = reference) do diff --git a/lib/quickbeam/vm/builtins/console.ex b/lib/quickbeam/vm/builtins/console.ex index a53317f4f..6b9725e7c 100644 --- a/lib/quickbeam/vm/builtins/console.ex +++ b/lib/quickbeam/vm/builtins/console.ex @@ -5,7 +5,10 @@ defmodule QuickBEAM.VM.Builtins.Console do alias QuickBEAM.VM.Builtin.Call - builtin "console", kind: :namespace do + builtin "console", + kind: :namespace, + profiles: [:ssr], + depends_on: ["Object", "Function"] do static :error, length: 1 static :info, length: 1 static :log, length: 1 diff --git a/lib/quickbeam/vm/builtins/math.ex b/lib/quickbeam/vm/builtins/math.ex index e6a5b802e..4b3288399 100644 --- a/lib/quickbeam/vm/builtins/math.ex +++ b/lib/quickbeam/vm/builtins/math.ex @@ -6,7 +6,7 @@ defmodule QuickBEAM.VM.Builtins.Math do alias QuickBEAM.VM.Builtin.Call alias QuickBEAM.VM.Value - builtin "Math", kind: :namespace do + builtin "Math", kind: :namespace, depends_on: ["Object", "Function"] do constant "E", :math.exp(1) constant "PI", :math.pi() diff --git a/lib/quickbeam/vm/builtins/symbol.ex b/lib/quickbeam/vm/builtins/symbol.ex index efe77997e..9e2b17ca4 100644 --- a/lib/quickbeam/vm/builtins/symbol.ex +++ b/lib/quickbeam/vm/builtins/symbol.ex @@ -6,7 +6,7 @@ defmodule QuickBEAM.VM.Builtins.Symbol do alias QuickBEAM.VM.Builtin.Call alias QuickBEAM.VM.{Symbol, Value} - builtin "Symbol", kind: :function, length: 0 do + builtin "Symbol", kind: :function, length: 0, depends_on: ["Object", "Function"] do static :for_symbol, js: "for", length: 1 constant "iterator", Symbol.iterator() end diff --git a/test/vm/builtin_dsl_test.exs b/test/vm/builtin_dsl_test.exs index ec813faea..52d26e9ff 100644 --- a/test/vm/builtin_dsl_test.exs +++ b/test/vm/builtin_dsl_test.exs @@ -112,27 +112,31 @@ defmodule QuickBEAM.VM.BuiltinDSLTest do assert Registry.modules(:core) == [ QuickBEAM.VM.Builtins.Object, QuickBEAM.VM.Builtins.Function, - QuickBEAM.VM.Builtins.Math, QuickBEAM.VM.Builtins.Array, - QuickBEAM.VM.Builtins.String, - QuickBEAM.VM.Builtins.Number, QuickBEAM.VM.Builtins.Boolean, QuickBEAM.VM.Builtins.Error, + QuickBEAM.VM.Builtins.Math, + QuickBEAM.VM.Builtins.Number, + QuickBEAM.VM.Builtins.String, + QuickBEAM.VM.Builtins.Symbol, + QuickBEAM.VM.Builtins.Uint8Array, + QuickBEAM.VM.Builtins.WeakMap, QuickBEAM.VM.Builtins.EvalError, + QuickBEAM.VM.Builtins.Map, + QuickBEAM.VM.Builtins.Promise, QuickBEAM.VM.Builtins.RangeError, QuickBEAM.VM.Builtins.ReferenceError, + QuickBEAM.VM.Builtins.Set, QuickBEAM.VM.Builtins.SyntaxError, QuickBEAM.VM.Builtins.TypeError, QuickBEAM.VM.Builtins.URIError, - QuickBEAM.VM.Builtins.Symbol, - QuickBEAM.VM.Builtins.Uint8Array, - QuickBEAM.VM.Builtins.Map, - QuickBEAM.VM.Builtins.WeakMap, - QuickBEAM.VM.Builtins.Set, - QuickBEAM.VM.Builtins.WeakSet, - QuickBEAM.VM.Builtins.Promise + QuickBEAM.VM.Builtins.WeakSet ] + refute QuickBEAM.VM.Builtins.Console in Registry.modules(:core) + assert QuickBEAM.VM.Builtins.Console in Registry.modules(:ssr) + assert Registry.modules(:core) == Enum.map(Registry.refresh()[:core], & &1.module) + assert QuickBEAM.VM.Builtins.String.builtin_spec().kind == :constructor object = QuickBEAM.VM.Builtins.Object.builtin_spec() From 2366a9fbf3136985795a4d8b5fb90045652e3607 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 13 Jul 2026 22:48:18 +0200 Subject: [PATCH 45/87] Add bounded VM mutation corpus --- .github/workflows/vm-fuzz.yml | 34 + docs/beam-interpreter-architecture.md | 36 +- docs/prototype-delta-audit.md | 11 +- lib/mix/tasks/quickbeam.vm.fuzz.ex | 89 ++ lib/quickbeam/vm/decoder.ex | 2 +- lib/quickbeam/vm/fuzz.ex | 847 ++++++++++++++++++ lib/quickbeam/vm/stack_verifier.ex | 184 ++++ lib/quickbeam/vm/verifier.ex | 48 +- test/fixtures/vm/fuzz/regressions/README.md | 9 + .../regressions/truncated-vardef-flags.bin | Bin 0 -> 23 bytes .../regressions/truncated-vardef-flags.txt | 3 + test/vm/decoder_test.exs | 44 + test/vm/fuzz_test.exs | 119 +++ 13 files changed, 1405 insertions(+), 21 deletions(-) create mode 100644 .github/workflows/vm-fuzz.yml create mode 100644 lib/mix/tasks/quickbeam.vm.fuzz.ex create mode 100644 lib/quickbeam/vm/fuzz.ex create mode 100644 lib/quickbeam/vm/stack_verifier.ex create mode 100644 test/fixtures/vm/fuzz/regressions/README.md create mode 100644 test/fixtures/vm/fuzz/regressions/truncated-vardef-flags.bin create mode 100644 test/fixtures/vm/fuzz/regressions/truncated-vardef-flags.txt create mode 100644 test/vm/fuzz_test.exs diff --git a/.github/workflows/vm-fuzz.yml b/.github/workflows/vm-fuzz.yml new file mode 100644 index 000000000..2dca753d2 --- /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.0' + elixir-version: '1.18' + + - name: Cache dependencies + uses: actions/cache@v4 + with: + path: | + deps + _build + key: ${{ runner.os }}-vm-fuzz-27.0-1.18-${{ hashFiles('mix.lock') }} + restore-keys: ${{ runner.os }}-vm-fuzz-27.0-1.18- + + - run: mix deps.get + - run: mix compile --warnings-as-errors + - run: mix quickbeam.vm.fuzz --iterations 100000 --seed 5325389 diff --git a/docs/beam-interpreter-architecture.md b/docs/beam-interpreter-architecture.md index 6443dc381..7942b63dd 100644 --- a/docs/beam-interpreter-architecture.md +++ b/docs/beam-interpreter-architecture.md @@ -6,8 +6,9 @@ Implemented on the development branch: version-locked decoding and verification, process-isolated evaluation, explicit frames and detached async continuations, closures, exceptions, owner-local objects, Promise reactions and combinators, asynchronous `Beam.call`, logical and process memory containment, stable -JavaScript errors, and pinned Preact, Vue, and Svelte SSR acceptance fixtures. -Broader ECMAScript conformance, object-model hardening, garbage collection, and release hardening +JavaScript errors, pinned Preact, Vue, and Svelte SSR acceptance fixtures, and +bounded deterministic decoder/verifier mutation fuzzing. Broader ECMAScript +conformance, object-model hardening, garbage collection, and release hardening remain in progress. ## Summary @@ -260,10 +261,33 @@ Required checks include: - no trailing serialized data; - total decoded instruction limit. -Decoder failures must return errors rather than raising or partially installing +Decoder failures return errors rather than raising or partially installing state. Unsigned and signed LEB128 fields use the `varint` package, wrapped with -QuickJS's 32-bit width and encoded-length limits. The decoder should have -mutation fuzzing and corpus tests for each supported QuickJS release. +QuickJS's 32-bit width and encoded-length limits. + +`QuickBEAM.VM.Fuzz` runs deterministic checksum-aware mutations over +representative compiled artifacts. Each case executes twice in a monitored +process with a strict wall-clock timeout and BEAM maximum-heap limit. The normal +test suite runs 2,000 decoder mutations and 1,000 deliberately invalid verifier +mutations. The local and scheduled corpus uses: + +```sh +mix quickbeam.vm.fuzz --iterations 10000 --seed 5325389 +``` + +The corpus covers truncation, insertion, deletion, duplication, bit flips, +malformed and overflowing LEB128 fields, oversized counts, unknown bytes, +trailing data, checksum/version corruption, invalid references and opcodes, +branch and exception targets, captures, stack underflow and declared stack +sizes, and structural metadata. Outcomes use +stable coarse classifications while retaining the exact typed rejection. +Crashes, timeouts, nondeterminism, and accepted invalid verifier programs are +findings. The first 10,000-case run found and fixed an untyped truncation path +in variable-definition decoding; its 23-byte input is retained as a regression +fixture. Findings can be deletion-minimized with `QuickBEAM.VM.Fuzz.minimize/2` +and persisted as `.bin` plus replay metadata with +`QuickBEAM.VM.Fuzz.persist/2`; regression fixtures are replayed under the same +bounds. ## Evaluation process and ownership @@ -784,7 +808,7 @@ operationally observable. ### Milestone 4 — hardening and release -- Fuzz decoder and verifier. +- Keep deterministic bounded decoder/verifier mutation corpora and minimized regressions green. - Audit process-dictionary ownership and cleanup. - Test malformed programs, handler failures, cancellation, and supervisor exits. - Document unsupported features and migration guidance. diff --git a/docs/prototype-delta-audit.md b/docs/prototype-delta-audit.md index db84a147d..f32696e66 100644 --- a/docs/prototype-delta-audit.md +++ b/docs/prototype-delta-audit.md @@ -421,7 +421,10 @@ High-value test groups to adapt next: compatibility profile. - Expanded the pinned Test262 manifest to 69 selected tests with 65/65 supported cases passing and four explicit asynchronous skips. -- Add decoder mutation fuzzing. +- Added deterministic checksum-aware decoder mutation fuzzing and deliberately + invalid verifier mutations. Normal CI runs 3,000 heap- and timeout-bounded + cases; the scheduled/local harness runs a larger reproducible corpus and + persists minimized regressions. ### Phase D — production hardening @@ -446,6 +449,6 @@ High-value test groups to adapt next: ## Immediate next action -Add bounded mutation fuzzing for bytecode decoding and verification, then -publish scheduler, timeout, memory, and Preact SSR measurements for the frozen -SSR compatibility profile. +Define and enforce safe repeated native-addon loading behavior, then publish +scheduler, timeout, memory, and SSR measurements for the frozen compatibility +profile before beginning compiler extraction. diff --git a/lib/mix/tasks/quickbeam.vm.fuzz.ex b/lib/mix/tasks/quickbeam.vm.fuzz.ex new file mode 100644 index 000000000..ca985dbfe --- /dev/null +++ b/lib/mix/tasks/quickbeam.vm.fuzz.ex @@ -0,0 +1,89 @@ +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} -> + case QuickBEAM.compile(runtime, source) do + {:ok, bytecode} -> {name, bytecode} + {:error, reason} -> Mix.raise("failed to compile #{name}: #{inspect(reason)}") + end + 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/vm/decoder.ex b/lib/quickbeam/vm/decoder.ex index 53cd98fcb..0101eb9c1 100644 --- a/lib/quickbeam/vm/decoder.ex +++ b/lib/quickbeam/vm/decoder.ex @@ -525,7 +525,7 @@ defmodule QuickBEAM.VM.Decoder 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), - <> <- rest do + {: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 diff --git a/lib/quickbeam/vm/fuzz.ex b/lib/quickbeam/vm/fuzz.ex new file mode 100644 index 000000000..90cefbbc0 --- /dev/null +++ b/lib/quickbeam/vm/fuzz.ex @@ -0,0 +1,847 @@ +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 + +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 + +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 + +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.Checksum + alias QuickBEAM.VM.Fuzz.Finding + alias QuickBEAM.VM.Fuzz.Mutation + alias QuickBEAM.VM.Fuzz.Summary + alias QuickBEAM.VM.Opcodes + alias QuickBEAM.VM.Program + alias QuickBEAM.VM.Verifier + + @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, {Opcodes.num(:push_i32), [:not_an_integer]}) + + defp mutate_function(function, :invalid_constant, _atoms, _state) do + instruction = {Opcodes.num(:push_const), [length(function.constants)]} + replace_instruction(function, instruction) + end + + defp mutate_function(function, :invalid_atom, atoms, _state) do + instruction = {Opcodes.num(:get_var), [tuple_size(atoms)]} + replace_instruction(function, instruction) + end + + defp mutate_function(function, :invalid_jump, _atoms, _state) do + instruction = {Opcodes.num(:goto), [tuple_size(function.instructions)]} + replace_instruction(function, instruction) + end + + defp mutate_function(function, :invalid_exception_target, _atoms, _state) do + instruction = {Opcodes.num(:catch), [tuple_size(function.instructions)]} + replace_instruction(function, instruction) + end + + defp mutate_function(function, :stack_underflow, _atoms, _state), + do: replace_instruction(function, {Opcodes.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, + {Opcodes.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.replace(~r/[^a-zA-Z0-9_-]+/, "-") + |> String.trim("-") + |> case do + "" -> "corpus" + safe -> safe + end + end + + 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/stack_verifier.ex b/lib/quickbeam/vm/stack_verifier.ex new file mode 100644 index 000000000..0f3025e37 --- /dev/null +++ b/lib/quickbeam/vm/stack_verifier.ex @@ -0,0 +1,184 @@ +defmodule QuickBEAM.VM.StackVerifier 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.{Function, Opcodes} + + @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{instructions: instructions, stack_size: declared} = 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), + true <- state.maximum == declared do + :ok + else + false -> {:error, {:stack_size_mismatch, declared}} + {:error, _reason} = error -> error + end + end + + def verify(%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} = Opcodes.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} = Opcodes.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/verifier.ex b/lib/quickbeam/vm/verifier.ex index 871910948..bbee53771 100644 --- a/lib/quickbeam/vm/verifier.ex +++ b/lib/quickbeam/vm/verifier.ex @@ -6,7 +6,7 @@ defmodule QuickBEAM.VM.Verifier do behavior before untrusted bytecode reaches mutable evaluation state. """ - alias QuickBEAM.VM.{ABI, Function, Opcodes, Program} + alias QuickBEAM.VM.{ABI, Function, Opcodes, Program, StackVerifier} @js_atom_end Opcodes.js_atom_end() @@ -63,13 +63,9 @@ defmodule QuickBEAM.VM.Verifier do :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 - Enum.reduce_while(function.constants, {:ok, counts}, fn constant, {:ok, counts} -> - case verify_value(constant, atoms, limits, depth + 1, counts) do - {:ok, counts} -> {:cont, {:ok, counts}} - {:error, _} = error -> {:halt, error} - end - end) + verify_values(function.constants, atoms, limits, depth + 1, counts) end end @@ -169,7 +165,8 @@ defmodule QuickBEAM.VM.Verifier do {:error, {:unknown_opcode, opcode}} {name, _size, _pops, _pushes, format} -> - with :ok <- verify_operand_count(format, operands) do + with :ok <- verify_operand_count(format, operands), + :ok <- verify_operand_types(format, operands) do verify_operands(name, format, operands, function, atoms) end end @@ -178,6 +175,13 @@ defmodule QuickBEAM.VM.Verifier do defp verify_instruction(instruction, _function, _atoms), do: {:error, {:invalid_shape, instruction}} + defp verify_stack(function) do + case StackVerifier.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 @@ -194,6 +198,16 @@ defmodule QuickBEAM.VM.Verifier do 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) @@ -210,11 +224,16 @@ defmodule QuickBEAM.VM.Verifier do 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) do - verify_secondary_operand(name, operands, function) + 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), @@ -241,6 +260,15 @@ defmodule QuickBEAM.VM.Verifier do 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 diff --git a/test/fixtures/vm/fuzz/regressions/README.md b/test/fixtures/vm/fuzz/regressions/README.md new file mode 100644 index 000000000..3736b6f2c --- /dev/null +++ b/test/fixtures/vm/fuzz/regressions/README.md @@ -0,0 +1,9 @@ +# VM bytecode fuzzing regressions + +Persist minimized decoder safety findings here as paired `.bin` and `.txt` +files using `QuickBEAM.VM.Fuzz.persist/2`. The test suite replays every `.bin` +file twice under strict timeout and BEAM heap bounds and requires a stable typed +rejection. + +The `.txt` sidecar records the corpus name, seed, iteration, mutation operation, +original outcome, and SHA-256 digest needed to reproduce and audit the input. 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 0000000000000000000000000000000000000000..694eeae63611e32cdb429fc054e3dfc0e1cfb0e8 GIT binary patch literal 23 Xcmb1Q 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) @@ -149,6 +154,45 @@ defmodule QuickBEAM.VM.DecoderTest do 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, {Opcodes.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: {{Opcodes.num(:drop), []}, {Opcodes.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, {Opcodes.num(:push_i32), [:not_an_integer]}) + } + + assert {:error, {:invalid_instruction, 0, 0, :invalid_operand_type}} = + Verifier.verify(%{program | root: invalid_operand}) + end + test "instruction decoder rejects labels inside an instruction" do goto = Opcodes.num(:goto) 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 From 548fec892be46381305add2265cf7c9287924d5d Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 13 Jul 2026 23:54:34 +0200 Subject: [PATCH 46/87] Guard native addon reinitialization --- CHANGELOG.md | 2 + docs/beam-interpreter-architecture.md | 9 ++ docs/prototype-delta-audit.md | 9 +- lib/quickbeam.ex | 10 ++ lib/quickbeam/context_worker.zig | 1 + lib/quickbeam/native.ex | 2 +- lib/quickbeam/quickbeam.zig | 3 +- lib/quickbeam/runtime.ex | 49 +++++++- lib/quickbeam/types.zig | 1 + lib/quickbeam/worker.zig | 154 ++++++++++++++++++++++---- test/napi_test.exs | 149 ++++++++++++++++++++----- 11 files changed, 329 insertions(+), 60 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1baeef366..98ab77c64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Serialize native addon initialization, reuse cached exports for aliases in one runtime, and reject implicit cross-runtime or post-reset reinitialization with a typed error. Add `allow_reinitialization: true` as an explicit compatibility escape hatch for addons that support multiple environments. + ## 0.10.19 - Update QuickJS-NG to 0.15.1. diff --git a/docs/beam-interpreter-architecture.md b/docs/beam-interpreter-architecture.md index 7942b63dd..69df4a1c7 100644 --- a/docs/beam-interpreter-architecture.md +++ b/docs/beam-interpreter-architecture.md @@ -390,6 +390,15 @@ contexts. Stress coverage includes concurrent explicit stops, owner exit without stop, callSync shutdown, pool shutdown, UBSan builds, and ordinary parallel test execution. +Native addon initialization is also serialized process-wide. A canonical addon +path is initialized once per BEAM instance; additional aliases in the same +runtime reuse owner-local cached exports without invoking native registration +again. Another runtime, or a runtime reset that creates a new JavaScript +context, receives `{:addon_already_initialized, canonical_path}`. The explicit +`allow_reinitialization: true` escape hatch exists only for addons that document +multi-environment initialization support. Registry growth is capped at 256 +canonical native libraries. + ## JavaScript values and heap Keep `null` and `undefined` distinct. Common scalar values may use native BEAM diff --git a/docs/prototype-delta-audit.md b/docs/prototype-delta-audit.md index f32696e66..75ce347ee 100644 --- a/docs/prototype-delta-audit.md +++ b/docs/prototype-delta-audit.md @@ -435,6 +435,10 @@ High-value test groups to adapt next: pool RuntimeData pointers are removed under the registry mutex before context destruction. Repeated concurrent lifecycle and callSync shutdown stress tests pass without crashes. +- Native addon initialization is now serialized and canonical-path keyed. + Same-runtime aliases reuse cached exports; cross-runtime and post-reset loads + return a typed rejection unless the caller explicitly opts into documented + multi-environment addon support. The registry is bounded to 256 libraries. - Continue auditing cancellation, queued-payload cleanup, and owner-local shutdown. - Publish scheduler, memory, timeout, and performance results. @@ -449,6 +453,5 @@ High-value test groups to adapt next: ## Immediate next action -Define and enforce safe repeated native-addon loading behavior, then publish -scheduler, timeout, memory, and SSR measurements for the frozen compatibility -profile before beginning compiler extraction. +Publish scheduler, timeout, memory, and SSR measurements for the frozen +compatibility profile before beginning compiler extraction. 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/context_worker.zig b/lib/quickbeam/context_worker.zig index f48524314..ce4004d5c 100644 --- a/lib/quickbeam/context_worker.zig +++ b/lib/quickbeam/context_worker.zig @@ -232,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, }; 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 2888be101..16ab7f5c4 100644 --- a/lib/quickbeam/quickbeam.zig +++ b/lib/quickbeam/quickbeam.zig @@ -278,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" }, .{}); @@ -298,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, 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 74f71c4aa..a48253d14 100644 --- a/lib/quickbeam/types.zig +++ b/lib/quickbeam/types.zig @@ -185,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/worker.zig b/lib/quickbeam/worker.zig index bd9f15400..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(); @@ -781,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) { @@ -789,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 { @@ -837,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(); } @@ -939,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, }; @@ -1107,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/test/napi_test.exs b/test/napi_test.exs index b4050bab2..33fb202c4 100644 --- a/test/napi_test.exs +++ b/test/napi_test.exs @@ -34,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") @@ -51,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)") @@ -68,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) @@ -76,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)") @@ -89,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")]) @@ -99,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()") @@ -125,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) @@ -133,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) @@ -141,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()") @@ -155,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()") @@ -165,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 @@ -196,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) @@ -207,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 @@ -217,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) @@ -228,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 @@ -242,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} @@ -254,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") @@ -274,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 @@ -286,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") @@ -302,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) @@ -333,7 +418,19 @@ defmodule QuickBEAM.NapiTest do "ok" """) + assert {:ok, _exports} = QuickBEAM.load_addon(rt, path, as: "sqliteAlias") + assert {:ok, "function"} = QuickBEAM.eval(rt, "typeof sqliteAlias.Database") QuickBEAM.stop(rt) + + {:ok, other_rt} = QuickBEAM.start() + + assert {:error, {:addon_already_initialized, rejected_path}} = + QuickBEAM.load_addon(other_rt, path, as: "sqlite") + + assert Path.basename(rejected_path) == Path.basename(path) + + assert {:ok, 42} = QuickBEAM.eval(other_rt, "42") + QuickBEAM.stop(other_rt) end end end From b8132d020c9b27843cddca789aa806a76e6db442 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Tue, 14 Jul 2026 00:18:14 +0200 Subject: [PATCH 47/87] Publish BEAM VM SSR measurements --- CHANGELOG.md | 1 + bench/README.md | 16 + bench/vm_scheduler_probe.exs | 277 ++++++++++++++ bench/vm_ssr.exs | 525 ++++++++++++++++++++++++++ docs/beam-interpreter-architecture.md | 23 +- docs/beam-scheduler-measurements.md | 35 ++ docs/beam-ssr-measurements.md | 76 ++++ docs/prototype-delta-audit.md | 9 +- lib/quickbeam/vm.ex | 75 +++- lib/quickbeam/vm/evaluator.ex | 71 +++- lib/quickbeam/vm/execution.ex | 2 + lib/quickbeam/vm/interpreter.ex | 1 + lib/quickbeam/vm/measurement.ex | 29 ++ mix.exs | 27 ++ test/vm/measurement_test.exs | 77 ++++ 15 files changed, 1222 insertions(+), 22 deletions(-) create mode 100644 bench/vm_scheduler_probe.exs create mode 100644 bench/vm_ssr.exs create mode 100644 docs/beam-scheduler-measurements.md create mode 100644 docs/beam-ssr-measurements.md create mode 100644 lib/quickbeam/vm/measurement.ex create mode 100644 test/vm/measurement_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index 98ab77c64..f2d186cce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Add `QuickBEAM.VM.measure/2` with deterministic step/logical-memory counters and endpoint process observations, plus reproducible pinned SSR concurrency, timeout, cancellation, reclamation, and single-scheduler reports. - Serialize native addon initialization, reuse cached exports for aliases in one runtime, and reject implicit cross-runtime or post-reset reinitialization with a typed error. Add `allow_reinitialization: true` as an explicit compatibility escape hatch for addons that support multiple environments. ## 0.10.19 diff --git a/bench/README.md b/bench/README.md index fd40a318f..4e845e04b 100644 --- a/bench/README.md +++ b/bench/README.md @@ -19,8 +19,24 @@ 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 + +# Reproduce the pinned BEAM VM SSR report +MIX_ENV=bench mix run bench/vm_ssr.exs \ + --output docs/beam-ssr-measurements.md + +# Reproduce the single-scheduler fairness/timeout probe +ERL_FLAGS='+S 1:1' MIX_ENV=bench mix run bench/vm_scheduler_probe.exs \ + --output docs/beam-scheduler-measurements.md ``` +The SSR runner accepts `--samples`, `--warmup`, and a comma-separated +`--concurrency` list. It reports deterministic VM steps and logical allocation, +endpoint BEAM process observations, sequential latency, concurrent throughput, +and timeout/cancellation behavior for the pinned Preact, Vue, and Svelte +fixtures. Published results are in +[`docs/beam-ssr-measurements.md`](../docs/beam-ssr-measurements.md) and +[`docs/beam-scheduler-measurements.md`](../docs/beam-scheduler-measurements.md). + ## Results Apple M1 Pro, Elixir 1.18.4, OTP 27, Zig 0.15.2 (ReleaseFast). diff --git a/bench/vm_scheduler_probe.exs b/bench/vm_scheduler_probe.exs new file mode 100644 index 000000000..5c17806e9 --- /dev/null +++ b/bench/vm_scheduler_probe.exs @@ -0,0 +1,277 @@ +defmodule QuickBEAM.Bench.VMSchedulerProbe do + @moduledoc "Single-scheduler fairness and timeout probe for the BEAM VM." + + @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: [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) + fixture = compile_fixture!() + + Enum.each(1..2, fn _iteration -> render!(fixture) end) + + render_observations = + Enum.map(1..samples, fn _iteration -> observe_ticker(fn -> render!(fixture) 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} = + QuickBEAM.VM.measure(timeout_program, + 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(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! do + {:ok, source} = QuickBEAM.JS.bundle_file(@fixture, @bundle_opts) + {:ok, program} = QuickBEAM.VM.compile(source, filename: @fixture) + + %{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) do + handler = fn [] -> fixture.props end + + {:ok, measurement} = + QuickBEAM.VM.measure(fixture.program, [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(samples, baseline_ms, render, baseline, timeout) do + """ + # BEAM VM single-scheduler probe + + 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 interpreter work. + + - 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 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..75af7e5ea --- /dev/null +++ b/bench/vm_ssr.exs @@ -0,0 +1,525 @@ +defmodule QuickBEAM.Bench.VMSSR do + @moduledoc """ + Reproducible fixture-specific measurements for the isolated BEAM VM SSR path. + """ + + @default_samples 30 + @default_warmup 3 + @default_concurrency [1, 4, 8] + + def run(args) do + {opts, positional, invalid} = + OptionParser.parse(args, + strict: [samples: :integer, warmup: :integer, concurrency: :string, output: :string] + ) + + 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) + + concurrency = + concurrency!(Keyword.get(opts, :concurrency, Enum.join(@default_concurrency, ","))) + + fixtures = Enum.map(fixture_specs(), &compile_fixture!/1) + + 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(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) do + {:ok, source} = QuickBEAM.JS.bundle_file(spec.fixture, spec.bundle_opts) + {:ok, program} = QuickBEAM.VM.compile(source, filename: spec.fixture) + Map.put(spec, :program, program) + 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) + } + 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} = QuickBEAM.VM.measure(fixture.program, opts) + + handler_pid = + receive do + {:vm_ssr_handler_started, pid} -> pid + after + 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} = QuickBEAM.VM.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(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(results, isolation, samples, warmup, concurrency) do + metadata = metadata() + + """ + # BEAM VM SSR measurements + + 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. The + single-scheduler fairness and timeout gate is published separately in + [`beam-scheduler-measurements.md`](beam-scheduler-measurements.md). + + ## Environment + + - 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. + + ## 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 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 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/docs/beam-interpreter-architecture.md b/docs/beam-interpreter-architecture.md index 69df4a1c7..0e07f2f34 100644 --- a/docs/beam-interpreter-architecture.md +++ b/docs/beam-interpreter-architecture.md @@ -101,6 +101,9 @@ would still be scheduled independently. @spec QuickBEAM.VM.decode(binary(), keyword()) :: {:ok, QuickBEAM.VM.Program.t()} | {:error, term()} + +@spec QuickBEAM.VM.measure(QuickBEAM.VM.Program.t(), keyword()) :: + {:ok, QuickBEAM.VM.Measurement.t()} | {:error, term()} ``` Suggested compile options: @@ -116,7 +119,9 @@ it, verifies it, and returns an immutable program. Compilation may use a native compiler pool, but evaluation must not require a QuickJS execution context. `decode/2` is an advanced API. It accepts only bytecode matching the running -QuickBEAM build fingerprint. +QuickBEAM build fingerprint. `measure/2` runs the same isolated evaluation as +`eval/2` while retaining deterministic step/logical-memory counters, endpoint +process observations, and end-to-end wall time. ### Evaluate @@ -747,9 +752,16 @@ Minimum scheduler acceptance scenarios: - evaluation process memory is reclaimed after completion; - a compiled program can be shared without copying mutable runtime state. -A reasonable first performance gate is an agreed maximum slowdown on the pinned -SSR workload, measured end to end. It should be set from reproducible benchmark -data rather than early arithmetic microbenchmarks. +The reproducible fixture measurements are published in +[`beam-ssr-measurements.md`](beam-ssr-measurements.md), with the `+S 1:1` +fairness and timeout gate in +[`beam-scheduler-measurements.md`](beam-scheduler-measurements.md). The reports +separate deterministic VM steps/logical allocation from endpoint BEAM process +observations, wall latency, concurrent throughput, and cancellation. Current +single-scheduler acceptance bounds are a maximum 75 ms ticker gap during the +pinned Vue render and timeout p95 no greater than 60 ms for a 50 ms limit. +Future performance gates should continue to use pinned end-to-end SSR fixtures, +not arithmetic microbenchmarks. ## Rollout @@ -810,7 +822,8 @@ for fulfillment, rejection, ordering, and nested awaits. real HTML after the handler Promise settles. - Render concurrently with independent request state. - Compare output, Promise ordering, and errors with native QuickJS. -- Publish scheduler, memory, cancellation, and performance benchmark results. +- Publish scheduler, memory, cancellation, and performance benchmark results + (complete; see the pinned measurement reports). Exit gate: the async SSR fixture is correct, isolated, bounded, cancellable, and operationally observable. diff --git a/docs/beam-scheduler-measurements.md b/docs/beam-scheduler-measurements.md new file mode 100644 index 000000000..07be0f32a --- /dev/null +++ b/docs/beam-scheduler-measurements.md @@ -0,0 +1,35 @@ +# BEAM VM single-scheduler probe + +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 interpreter work. + +- Git base: `548fec89` +- Working tree at measurement: modified +- Generated: 2026-07-13T22:08:17Z +- Elixir: 1.20.2 +- OTP: 29 +- ERTS: 17.0.2 +- OS: Linux 7.0.0-27-generic +- Architecture: x86_64-pc-linux-gnu +- CPU: AMD Ryzen 9 9950X 16-Core Processor +- Online schedulers: 1 +- Vue probe memory limit: 512 MB +- Samples: 10 + +| workload | wall median | wall p95 | ticker gap median | ticker gap p95 | ticker gap max | ticks median | +|---|---:|---:|---:|---:|---:|---:| +| Vue SSR | 263.25 ms | 305.47 ms | 2.0 ms | 5.76 ms | 37.83 ms | 94 | +| sleep baseline (263 ms target) | 263.93 ms | 263.95 ms | 2.0 ms | 2.01 ms | 4.94 ms | 132 | + +Acceptance bound: Vue SSR ticker gap ≤ 75.0 ms. + +## 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 | 50.98 ms | 51.0 ms | 51.0 ms | 979 µs | + +Acceptance bound: timeout p95 ≤ 60.0 ms. diff --git a/docs/beam-ssr-measurements.md b/docs/beam-ssr-measurements.md new file mode 100644 index 000000000..9e670b6f7 --- /dev/null +++ b/docs/beam-ssr-measurements.md @@ -0,0 +1,76 @@ +# BEAM VM SSR measurements + +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. The +single-scheduler fairness and timeout gate is published separately in +[`beam-scheduler-measurements.md`](beam-scheduler-measurements.md). + +## Environment + +- Git base: `548fec89` +- Working tree at measurement: modified +- Generated: 2026-07-13T22:08:37Z +- Elixir: 1.20.2 +- OTP: 29 +- ERTS: 17.0.2 +- OS: Linux 7.0.0-27-generic +- Architecture: x86_64-pc-linux-gnu +- CPU: AMD Ryzen 9 9950X 16-Core Processor +- Logical schedulers: 32 +- Mix environment: `bench` +- Samples per fixture: 30 after 3 warmups +- Concurrency levels: 1, 4, 8 + +## Sequential isolated renders + +| Fixture | wall median | wall p95 | VM steps | logical memory | endpoint process memory | reductions median | +|---|---:|---:|---:|---:|---:|---:| +| Preact 10.29.7 | 8.49 ms | 9.47 ms | 3651 | 266.2 KiB | 4.5 MiB | 194939 | +| Vue 3.5.39 | 48.55 ms | 50.4 ms | 11957 | 992.3 KiB | 92.58 MiB | 838301 | +| Svelte 5.56.4 | 12.36 ms | 13.24 ms | 1777 | 397.3 KiB | 12.48 MiB | 204614 | + +`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. + +## Concurrent isolated renders + +| Fixture | concurrency | renders | throughput | per-render wall median | per-render wall p95 | +|---|---:|---:|---:|---:|---:| +| Preact 10.29.7 | 1 | 30 | 89.0 renders/s | 8.47 ms | 9.33 ms | +| Preact 10.29.7 | 4 | 30 | 270.1 renders/s | 9.79 ms | 10.68 ms | +| Preact 10.29.7 | 8 | 30 | 448.4 renders/s | 10.51 ms | 12.19 ms | +| Vue 3.5.39 | 1 | 30 | 9.3 renders/s | 58.7 ms | 60.37 ms | +| Vue 3.5.39 | 4 | 30 | 19.3 renders/s | 99.46 ms | 125.37 ms | +| Vue 3.5.39 | 8 | 30 | 20.3 renders/s | 182.81 ms | 227.28 ms | +| Svelte 5.56.4 | 1 | 30 | 43.8 renders/s | 14.08 ms | 15.46 ms | +| Svelte 5.56.4 | 4 | 30 | 114.7 renders/s | 18.82 ms | 21.83 ms | +| Svelte 5.56.4 | 8 | 30 | 133.9 renders/s | 29.37 ms | 35.65 ms | + +## 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 | +|---:|---:|---:|---:| +| 100/100 | 480.1 renders/s | -941.8 KiB | 0 | + +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 | +|---|---:|---:|---:|---:|---:| +| Preact 10.29.7 | limit:steps at 3650 | limit:memory_bytes at 133.1 KiB | limit:timeout at 200 ms | 201.19 ms | 34 µs | +| Vue 3.5.39 | limit:steps at 11956 | limit:memory_bytes at 496.1 KiB | limit:timeout at 200 ms | 212.69 ms | 25 µs | +| Svelte 5.56.4 | limit:steps at 1776 | limit:memory_bytes at 198.7 KiB | limit:timeout at 200 ms | 202.65 ms | 24 µs | + +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. diff --git a/docs/prototype-delta-audit.md b/docs/prototype-delta-audit.md index 75ce347ee..f82488f1e 100644 --- a/docs/prototype-delta-audit.md +++ b/docs/prototype-delta-audit.md @@ -441,7 +441,9 @@ High-value test groups to adapt next: multi-environment addon support. The registry is bounded to 256 libraries. - Continue auditing cancellation, queued-payload cleanup, and owner-local shutdown. -- Publish scheduler, memory, timeout, and performance results. +- Published reproducible pinned SSR step, logical-memory, endpoint process, + latency, concurrency, timeout, cancellation, and single-scheduler fairness + measurements. - Freeze the supported SSR compatibility matrix. ### Phase E — optional compiler extraction @@ -453,5 +455,6 @@ High-value test groups to adapt next: ## Immediate next action -Publish scheduler, timeout, memory, and SSR measurements for the frozen -compatibility profile before beginning compiler extraction. +Specify the bounded module pool, explicit deoptimization states, and compiler +cache lifecycle against the canonical runtime contract before extracting any +prototype compiler code. diff --git a/lib/quickbeam/vm.ex b/lib/quickbeam/vm.ex index 78170f556..ca1828c4c 100644 --- a/lib/quickbeam/vm.ex +++ b/lib/quickbeam/vm.ex @@ -6,7 +6,7 @@ defmodule QuickBEAM.VM do heap, Promise state, host operations, and resource limits. """ - alias QuickBEAM.VM.{ABI, Decoder, Evaluator, Function, Program, Verifier} + alias QuickBEAM.VM.{ABI, Decoder, Evaluator, Function, Measurement, Program, Verifier} @type program :: QuickBEAM.VM.Program.t() @@ -106,6 +106,33 @@ defmodule QuickBEAM.VM do end end + @doc """ + Evaluates a program with the same isolation and limits as `eval/2`, returning + its result together with deterministic step/logical-memory counters and + endpoint process observations. + + Evaluation failures, including resource limits, are stored in + `measurement.result`. Invalid programs or options are returned directly as + `{:error, reason}` because no evaluation was started. + """ + @spec measure(Program.t(), keyword()) :: {:ok, Measurement.t()} | {:error, term()} + def measure(%Program{} = program, opts \\ []) when is_list(opts) do + with :ok <- Verifier.verify(program), + {:ok, options} <- evaluation_options(opts) do + started = System.monotonic_time() + + payload = + case options.isolation do + :caller -> safe_measure(program, options.interpreter) + :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 evaluation_options(opts) do allowed = [ :handlers, @@ -205,6 +232,52 @@ defmodule QuickBEAM.VM do kind, reason -> {:error, {:interpreter_crash, {kind, reason}, __STACKTRACE__}} end + defp measure_isolated(program, options) do + caller = self() + reply_ref = make_ref() + + worker = fn -> + payload = safe_measure(program, options.interpreter) + send(caller, {reply_ref, payload}) + end + + {pid, monitor_ref} = :erlang.spawn_opt(worker, worker_spawn_options(options.memory_limit)) + + 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, options) do + {result, metrics} = Evaluator.eval_with_metrics(program, Map.to_list(options)) + + result = + if match?({:suspended, _continuation}, result), + do: {:error, {:unsupported, :async_wait}}, + else: result + + {:measured, result, metrics} + rescue + exception -> {:measured, {:error, {:interpreter_crash, exception, __STACKTRACE__}}, nil} + catch + kind, reason -> + {:measured, {:error, {:interpreter_crash, {kind, reason}, __STACKTRACE__}}, nil} + end + + 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), + process_memory_bytes: Map.get(metrics, :process_memory_bytes), + reductions: Map.get(metrics, :reductions) + } + end + @doc "Returns the monitored worker spawn options for an evaluation memory limit." def worker_spawn_options(:infinity), do: [:monitor] diff --git a/lib/quickbeam/vm/evaluator.ex b/lib/quickbeam/vm/evaluator.ex index 8d6429040..7dd93c16b 100644 --- a/lib/quickbeam/vm/evaluator.ex +++ b/lib/quickbeam/vm/evaluator.ex @@ -10,12 +10,12 @@ defmodule QuickBEAM.VM.Evaluator do Async, Continuation, Coroutine, - Execution, Exceptions, + Execution, Interpreter, + Program, Promise, PromiseReference, - Program, Reaction } @@ -26,6 +26,19 @@ defmodule QuickBEAM.VM.Evaluator do |> drive() 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 + ref = make_ref() + result = eval(program, Keyword.put(opts, :measurement_target, {self(), ref})) + + receive do + {:quickbeam_vm_measurement, ^ref, metrics} -> {result, metrics} + after + 0 -> {result, nil} + end + end + defp drive({:ok, %PromiseReference{} = promise, execution}), do: await_final_promise(promise, execution) @@ -36,7 +49,7 @@ defmodule QuickBEAM.VM.Evaluator do then_resume(result, continuation) {:empty, _jobs} -> - {:error, :missing_microtask} + finish_final({:error, :missing_microtask, continuation.execution}) end end @@ -44,17 +57,14 @@ defmodule QuickBEAM.VM.Evaluator do await_legacy_promise(continuation) end - defp drive({:suspended, _continuation} = suspended), do: Interpreter.finish(suspended) + defp drive({:suspended, %Continuation{} = continuation} = suspended), + do: finish_suspended(suspended, continuation.execution) - defp drive({status, _value, execution} = result) when status in [:ok, :error] do - Async.cancel_operations(execution) - Interpreter.finish(result) - end + defp drive({status, _value, _execution} = result) when status in [:ok, :error], + do: finish_final(result) - defp drive({:idle, execution}) do - Async.cancel_operations(execution) - {:error, :idle_evaluation} - end + defp drive({:idle, execution}), + do: finish_final({:error, :idle_evaluation, execution}) defp await_final_promise(%PromiseReference{} = promise, execution) do if :queue.is_empty(execution.sync_jobs) do @@ -199,6 +209,41 @@ defmodule QuickBEAM.VM.Evaluator do defp finish_final({status, _value, %Execution{} = execution} = result) when status in [:ok, :error] do Async.cancel_operations(execution) - Interpreter.finish(result) + 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(%Execution{measurement_target: nil}), do: :ok + + defp report_measurement(%Execution{measurement_target: {pid, ref}} = execution) do + process_memory = process_stat(:memory) + reductions = process_stat(:reductions) + + send( + pid, + {:quickbeam_vm_measurement, ref, + %{ + steps: execution.step_limit - execution.remaining_steps, + logical_memory_bytes: execution.memory_used, + process_memory_bytes: process_memory, + reductions: reductions + }} + ) + + :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/execution.ex b/lib/quickbeam/vm/execution.ex index 8d9818c31..a28d6e1f9 100644 --- a/lib/quickbeam/vm/execution.ex +++ b/lib/quickbeam/vm/execution.ex @@ -24,6 +24,7 @@ defmodule QuickBEAM.VM.Execution do memory_exceeded: false, memory_limit: :infinity, memory_used: 0, + measurement_target: nil, next_cell_id: 0, next_object_id: 0, next_promise_id: 0, @@ -64,6 +65,7 @@ defmodule QuickBEAM.VM.Execution do 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(), diff --git a/lib/quickbeam/vm/interpreter.ex b/lib/quickbeam/vm/interpreter.ex index dc858f4ec..6ea2285bb 100644 --- a/lib/quickbeam/vm/interpreter.ex +++ b/lib/quickbeam/vm/interpreter.ex @@ -78,6 +78,7 @@ defmodule QuickBEAM.VM.Interpreter do 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 } 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/mix.exs b/mix.exs index 71f778fb4..1d9197815 100644 --- a/mix.exs +++ b/mix.exs @@ -107,6 +107,9 @@ defmodule QuickBEAM.MixProject do "README.md", "docs/javascript-api.md", "docs/architecture.md", + "docs/beam-interpreter-architecture.md", + "docs/beam-scheduler-measurements.md", + "docs/beam-ssr-measurements.md", "docs/prototype-delta-audit.md", "docs/test262-conformance.md", "CHANGELOG.md" @@ -115,20 +118,44 @@ defmodule QuickBEAM.MixProject do Guides: [ "docs/javascript-api.md", "docs/architecture.md", + "docs/beam-interpreter-architecture.md", + "docs/beam-scheduler-measurements.md", + "docs/beam-ssr-measurements.md", "docs/prototype-delta-audit.md", "docs/test262-conformance.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) do + internal_vm_modules = [ + "QuickBEAM.VM.Async", + "QuickBEAM.VM.Builtin", + "QuickBEAM.VM.Exceptions", + "QuickBEAM.VM.Fuzz", + "QuickBEAM.VM.Invocation", + "QuickBEAM.VM.Iterator", + "QuickBEAM.VM.Opcodes", + "QuickBEAM.VM.Properties", + "QuickBEAM.VM.Value" + ] + + String.starts_with?(reference, "QuickBEAM.Runtime") or + Enum.any?(internal_vm_modules, &String.starts_with?(reference, &1)) or + String.ends_with?(reference, "beam-interpreter-architecture.md") + end + defp documented_module?(module, _metadata) do public_vm_modules = [ QuickBEAM.VM.ABI, QuickBEAM.VM.ClosureVariable, QuickBEAM.VM.Function, + QuickBEAM.VM.Measurement, QuickBEAM.VM.Program, QuickBEAM.VM.SourcePosition, QuickBEAM.VM.Variable 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 From e277739f65fd72ebe04fa623121437820249e4b9 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Tue, 14 Jul 2026 01:13:34 +0200 Subject: [PATCH 48/87] Define bounded BEAM compiler contract --- CHANGELOG.md | 1 + docs/beam-compiler-contract.md | 259 ++++++++++++++++++++++++++ docs/beam-interpreter-architecture.md | 19 +- docs/prototype-delta-audit.md | 8 +- lib/quickbeam/vm.ex | 7 +- lib/quickbeam/vm/compiler/contract.ex | 115 ++++++++++++ lib/quickbeam/vm/compiler/deopt.ex | 144 ++++++++++++++ lib/quickbeam/vm/decoder.ex | 1 + lib/quickbeam/vm/program.ex | 15 +- mix.exs | 3 + test/vm/compiler_contract_test.exs | 139 ++++++++++++++ test/vm/decoder_test.exs | 10 +- 12 files changed, 705 insertions(+), 16 deletions(-) create mode 100644 docs/beam-compiler-contract.md create mode 100644 lib/quickbeam/vm/compiler/contract.ex create mode 100644 lib/quickbeam/vm/compiler/deopt.ex create mode 100644 test/vm/compiler_contract_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index f2d186cce..6df49c040 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Define the optional BEAM compiler contract with binary artifact identities, a fixed 32-module atom pool, lease/purge/cache lifecycle, versioned runtime ABI boundaries, and validated owner-local before-instruction deoptimization states. - Add `QuickBEAM.VM.measure/2` with deterministic step/logical-memory counters and endpoint process observations, plus reproducible pinned SSR concurrency, timeout, cancellation, reclamation, and single-scheduler reports. - Serialize native addon initialization, reuse cached exports for aliases in one runtime, and reject implicit cross-runtime or post-reset reinitialization with a typed error. Add `allow_reinitialization: true` as an explicit compatibility escape hatch for addons that support multiple environments. diff --git a/docs/beam-compiler-contract.md b/docs/beam-compiler-contract.md new file mode 100644 index 000000000..d41c77b87 --- /dev/null +++ b/docs/beam-compiler-contract.md @@ -0,0 +1,259 @@ +# Bounded BEAM compiler contract + +Status: design gate for the optional compiler. No prototype compiler source is +approved for extraction until this contract and its acceptance tests are in +place. + +## Scope + +The compiler is an optimization tier for an already verified +`QuickBEAM.VM.Program`. It is not a JavaScript parser, a third execution engine, +or a native fallback. QuickJS still produces bytecode, the current decoder and +verifier remain authoritative, and all mutable JavaScript state remains owned by +one evaluation process. + +The first extraction slice is intentionally narrow: verified straight-line +basic blocks containing literals, stack movement, local reads/writes, primitive +value operations, and branches. Calls, accessors, constructors, iterators, +exceptions, Promise operations, host calls, and `await` deopt before their +instruction until their resumable compiler ABI exists. + +## Non-negotiable invariants + +1. Compiling unbounded user input never creates an unbounded number of atoms. +2. Generated code never owns a heap, globals, cells, Promise state, jobs, or + handler tasks. It receives and returns canonical `%Frame{}` and + `%Execution{}` values. +3. Generated code calls one versioned compiler runtime ABI. It does not call + heap internals or copy JavaScript semantic algorithms. +4. Every transition to the interpreter is an explicit, validated deoptimization + at a verified instruction boundary. +5. A timeout, memory failure, step failure, throw, or suspension has the same + external result and owner cleanup as interpreter execution. +6. No compiler failure invokes native QuickJS. An explicitly selected compiler + mode returns a typed error or deoptimization result. +7. Loaded code is reused only while protected by a live pool lease. Stale + module/function tuples are never valid execution handles. + +## Versioned artifact identity + +`QuickBEAM.VM.Compiler.Contract.artifact_key/3` returns a binary SHA-256 key. It +includes: + +- the compiler contract version; +- the runtime ABI version; +- the exact QuickJS/QuickBEAM ABI fingerprint and bytecode version; +- the SHA-256 serialized-bytecode digest and source digest when source is available; +- the immutable function IR, constants, atom table, and source positions; +- the lowering profile and semantic feature flags. + +Keys remain binaries. They are never converted to atoms. Changing any ABI, +opcode, value representation, lowering rule, or semantic profile increments a +contract version and invalidates all artifacts. + +The initial implementation has no persistent BEAM-binary cache. Compilation is +process-local and bounded by the module pool. A future disk cache must be opt-in, +size- and entry-bounded, use atomic replacement, validate a JSONCodec metadata +envelope plus the binary digest, and treat cached BEAM files as trusted +executable code. It must never deserialize arbitrary Erlang terms. + +## Static module pool + +The code server is global and module atoms are permanent. The pool therefore +uses exactly the statically declared names returned by +`QuickBEAM.VM.Compiler.Contract.pool_modules/0`. The initial contract reserves +32 names. Configuration may use fewer slots but cannot create names or exceed +that ceiling. + +A singleton supervised pool owns these states: + +```text +free +compiling(key, waiters, task) +ready(key, generation, lease_count, last_used) +retiring(key, generation) +quarantined(reason) +``` + +### Checkout + +1. Compute the binary artifact key. +2. A ready hit increments its lease count and returns a lease containing the + fixed module, generation, opaque token, key, and owner PID. +3. A compiling hit joins the single-flight waiter set. +4. A miss reserves a free slot or the least-recently-used ready slot with zero + leases. If none exists, return `{:error, :compiler_pool_busy}`. +5. Lowering runs under a supervised task with instruction, form, byte, and wall + limits. Only the pool process may install the resulting module. + +The evaluation process invokes code only while holding the lease and returns it +in `after`. The pool monitors every lease owner, so timeout, memory termination, +or owner death releases leases without relying on cleanup code in that process. +A lease is valid only for its owner, key, slot generation, and pool epoch. + +### Eviction and purge + +Eviction is allowed only at lease count zero. The pool: + +1. soft-purges any old code version; +2. deletes the current version; +3. soft-purges the deleted version; +4. increments the generation; +5. loads the new binary under the same fixed module atom. + +Hard purge is forbidden because it can kill arbitrary processes. If either soft +purge reports a live code reference, the slot becomes quarantined and is not +reused. If all slots are busy or quarantined, checkout returns a typed capacity +error. Generated code must not expose BEAM funs or `{module, function}` tuples as +JavaScript values; JavaScript closures remain canonical VM function values and +acquire a fresh lease when invoked. + +Pool restart increments an epoch, invalidating every old lease. Shutdown stops +compile tasks, rejects waiters, waits for leases for a bounded grace period, and +then applies only soft purge. Code that cannot be safely purged remains loaded +until VM shutdown. + +## Compiler runtime ABI + +Generated modules may call `:erlang` guard/BIF operations approved by a +BEAM-disassembly test and one module, `QuickBEAM.VM.Compiler.Runtime`. That +module is the versioned ABI and delegates semantics to the existing canonical +layers: + +- `Value` for primitive coercion and operators; +- `Properties` for descriptors, prototypes, and accessor actions; +- `Invocation` for call classification; +- `Async` and Promise state transitions; +- `Exceptions` for JavaScript throws and stacks; +- existing opcode-family modules for unspecialized operations. + +No generated external call to `Heap`, builtin installers, process dictionaries, +or prototype compiler helpers is allowed. ABI functions return explicit actions +rather than recursively invoking JavaScript. + +The first ABI should contain only: + +- exact step charging at basic-block boundaries; +- local/argument/stack transforms over `%Frame{}`; +- primitive unary/binary operations through `Value`; +- truthiness and verified branch selection; +- construction of a typed `%Compiler.Deopt{}`. + +Properties, calls, throws, and suspension are added only with differential and +resource-limit tests for their action protocol. + +## Steps, memory, timeout, and scheduling + +A compiled basic block may debit its instruction count once only when every +instruction in the block is guaranteed to execute and cannot throw or suspend. +If insufficient steps remain, it deopts before the block without charging any of +its instructions. Blocks with dynamic exits charge at individual instruction +boundaries. This preserves the interpreter's exact `remaining_steps` contract +and `measure/2` counters. + +All allocation goes through canonical runtime layers and their logical memory +charges. The compiled path runs in the same monitored evaluation process, so +process heap limits, outer timeout, handler ownership, and cancellation remain +unchanged. + +Generated basic blocks are capped at 256 QuickJS instructions and call another +module function at control-flow edges. The existing `+S 1:1` ticker-gap and +timeout report remains a regression gate for compiled execution. + +## Deoptimization state + +A `%QuickBEAM.VM.Compiler.Deopt{}` contains: + +- contract version and binary artifact key; +- pool generation; +- reason; +- owner PID; +- canonical `%Frame{}` and `%Execution{}`. + +The initial contract permits only **before-instruction** deoptimization. The +frame PC points at the next unexecuted verified instruction; its operand stack, +locals, arguments, closure references, callers, heap, jobs, and counters are +canonical and complete. The current instruction has not been charged and has +performed no observable effect. + +The dispatcher validates owner PID, contract version, artifact key width, +generation, function identity, PC range, and the active lease before calling the +interpreter. Invalid or stale state is a typed compiler infrastructure error, +not a JavaScript exception. + +Initial reasons are: + +- `:unsupported_opcode`; +- `:unsupported_semantics`; +- `:step_boundary`; +- `:suspension_boundary`; +- `{:guard_failed, guard}`. + +After-instruction deoptimization is excluded initially because property writes, +calls, iterator steps, and Promise actions cannot be duplicated. It may be added +only as a distinct protocol carrying an explicit completed semantic action. + +## Public execution policy + +`QuickBEAM.VM.eval/2` remains the interpreter API. Compiler execution will be an +explicit option or API. The first compiler mode does not silently interpret an +unsupported whole program. A compiled basic block may return the documented +deoptimization action because that transition is part of the selected compiler +engine. Capacity, compile-task, load, stale-lease, and purge failures remain +typed compiler errors. + +An adaptive policy, if added, must be explicitly selected by the caller and +reported by measurement/telemetry. It still may never fall back to native +QuickJS. + +## Prototype analysis map + +Prototype code is not copied as a subsystem. Each part has one bounded target: + +| Prototype concept | Decision and canonical target | +| --- | --- | +| CFG/basic-block discovery | Adapt the pure graph algorithm to current v26 instruction tuples. Targets remain verified instruction indexes. | +| Stack analysis | Do not extract. `StackVerifier` remains authoritative; lowering consumes its verified heights and joins. | +| Opcode support analysis | Replace broad fallback analysis with a closed `:pure_v1` allowlist. Every other opcode emits a before-instruction deopt. | +| Local/stack lowering | Adapt abstract-form patterns to transformations over canonical `%Frame{}` through the compiler runtime ABI. | +| Value lowering | Call `Value`; do not retain prototype coercion or object tags. Guards deopt before an instruction when specialization assumptions fail. | +| Property lowering | Initially deopt. Later call `Properties` and preserve its accessor boundary actions. | +| Call lowering | Initially deopt. Later call `Invocation`; calls never recursively enter generated code or the interpreter. | +| Promise/async lowering | Initially deopt before Promise, host-call, and `await` instructions. Later preserve `Async`, Promise jobs, and typed suspension boundaries. | +| Throw/catch lowering | Initially deopt before potentially throwing instructions. Later route explicit actions through `Exceptions`. | +| Runtime helpers/heap | Reject. The prototype process dictionary, heap, object tags, and runtime state have no compiler ABI role. | +| Form optimizer | Defer until the unspecialized pure compiler is differential-test clean; every optimization needs equivalent deopt points and accounting. | +| Diagnostics/disassembly tests | Adapt with v26 fixtures, source positions, artifact keys, and the external-call allowlist. | + +This mapping permits reuse of isolated algorithms and form-emission techniques, +not prototype runtime modules or fallback behavior. + +## Extraction order + +1. Land contract types, static slot names, and invariant tests. +2. Implement the supervised pool with a fake loader; prove bounds, owner + monitoring, generations, single-flight compilation, and quarantine. +3. Add the minimal runtime ABI and disassembly allowlist. +4. Extract only CFG/stack analysis concepts and pure-block form emission. +5. Compile literals, stack/local operations, primitive values, and branches. +6. Add validated before-instruction deoptimization to the interpreter. +7. Run interpreter/compiler/native differential tests plus exact limit and + scheduler gates. +8. Expand one resumable semantic family at a time. + +## Acceptance gates + +Before enabling compiler execution outside tests: + +- 100,000 unique artifact keys leave atom count unchanged after pool startup; +- loaded compiler modules never exceed the configured static capacity; +- active leases cannot be evicted and owner death releases them; +- duplicate concurrent compilation is single-flight; +- stale generation/epoch leases are rejected; +- soft-purge failure quarantines a slot and never hard-purges; +- interpreter and compiler results, JavaScript errors, steps, and logical memory + match for every compiled opcode family; +- step, memory, timeout, cancellation, and `+S 1:1` gates pass; +- async/host operations deopt and resume without duplicate effects; +- generated-module disassembly contains only allowed external calls; +- no compiler path reaches native QuickJS. diff --git a/docs/beam-interpreter-architecture.md b/docs/beam-interpreter-architecture.md index 0e07f2f34..7a9db2c5e 100644 --- a/docs/beam-interpreter-architecture.md +++ b/docs/beam-interpreter-architecture.md @@ -7,9 +7,10 @@ process-isolated evaluation, explicit frames and detached async continuations, closures, exceptions, owner-local objects, Promise reactions and combinators, asynchronous `Beam.call`, logical and process memory containment, stable JavaScript errors, pinned Preact, Vue, and Svelte SSR acceptance fixtures, and -bounded deterministic decoder/verifier mutation fuzzing. Broader ECMAScript -conformance, object-model hardening, garbage collection, and release hardening -remain in progress. +bounded deterministic decoder/verifier mutation fuzzing, and the bounded +optional-compiler contract. Broader ECMAScript conformance, object-model +hardening, garbage collection, compiler extraction, and release hardening remain +in progress. ## Summary @@ -843,10 +844,14 @@ limits are enforced, and the compatibility matrix is published. Only after interpreter correctness is stable: -- compile verified basic blocks to Erlang abstract forms; -- reuse the same runtime ABI and semantics; -- use a bounded module-name pool to avoid atom leaks; -- deopt explicitly to verified interpreter states; +- implement the static module pool and lifecycle specified in + [`beam-compiler-contract.md`](beam-compiler-contract.md); +- compile verified pure basic blocks to Erlang abstract forms; +- call the versioned ABI over the same canonical runtime semantics; +- deopt before unsupported instructions into validated owner-local interpreter + state; +- pass differential, resource, atom-bound, purge, and scheduler acceptance + gates; - add stateful owner-process sessions if demanded by real workloads. ## Prototype branch extraction map diff --git a/docs/prototype-delta-audit.md b/docs/prototype-delta-audit.md index f82488f1e..8c2bc7b67 100644 --- a/docs/prototype-delta-audit.md +++ b/docs/prototype-delta-audit.md @@ -306,7 +306,13 @@ Compiler extraction gate: 6. Unsupported compiled regions deopt explicitly to verified interpreter state; they never fall back to native execution. -Until those gates hold, the compiler remains quarantined. +The module-name, cache-lifecycle, runtime-ABI, prototype-analysis, and +deoptimization design is now specified in +[`beam-compiler-contract.md`](beam-compiler-contract.md). Static slot identities +and owner-local deoptimization validation are executable contracts. The compiler +remains quarantined until the supervised pool, minimal runtime ABI, and their +acceptance tests are implemented; no prototype compiler runtime is approved for +copying. ## Test extraction policy diff --git a/lib/quickbeam/vm.ex b/lib/quickbeam/vm.ex index ca1828c4c..13e1fb61f 100644 --- a/lib/quickbeam/vm.ex +++ b/lib/quickbeam/vm.ex @@ -33,7 +33,12 @@ defmodule QuickBEAM.VM do try do with {:ok, bytecode} <- QuickBEAM.compile(runtime, source), {:ok, program} <- decode(bytecode, decode_options) do - {:ok, maybe_put_filename(program, filename)} + program = + program + |> maybe_put_filename(filename) + |> Map.put(:source_digest, :crypto.hash(:sha256, source)) + + {:ok, program} end after QuickBEAM.stop(runtime) diff --git a/lib/quickbeam/vm/compiler/contract.ex b/lib/quickbeam/vm/compiler/contract.ex new file mode 100644 index 000000000..f5d94c1c8 --- /dev/null +++ b/lib/quickbeam/vm/compiler/contract.ex @@ -0,0 +1,115 @@ +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.{Function, Program} + + @contract_version 1 + @runtime_abi_version 1 + @artifact_key_bytes 32 + @profiles [:pure_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 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), + :ok <- validate_profile(profile) do + payload = { + @contract_version, + @runtime_abi_version, + program.version, + program.fingerprint, + program.bytecode_digest, + program.source_digest, + program.atoms, + function, + profile + } + + binary = :erlang.term_to_binary(payload, [:deterministic]) + {:ok, :crypto.hash(:sha256, binary)} + end + end + + def artifact_key(program, function, _opts), + do: {:error, {:invalid_artifact_input, program, function}} + + defp validate_options(opts) do + if Keyword.keyword?(opts) do + case Keyword.keys(opts) -- [:profile] do + [] -> :ok + [key | _rest] -> {:error, {:unknown_option, key}} + end + else + {:error, {:invalid_option, :options, opts}} + end + end + + 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/deopt.ex b/lib/quickbeam/vm/compiler/deopt.ex new file mode 100644 index 000000000..0d884a943 --- /dev/null +++ b/lib/quickbeam/vm/compiler/deopt.ex @@ -0,0 +1,144 @@ +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.{Execution, Frame} + + @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: Execution.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(), Execution.t()) :: + {:ok, t()} | {:error, term()} + def new( + reason, + artifact_key, + pool_epoch, + generation, + %Frame{} = frame, + %Execution{} = 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(%Execution{}), do: :ok + defp validate_execution(execution), do: {:error, {:invalid_deopt_execution, execution}} +end diff --git a/lib/quickbeam/vm/decoder.ex b/lib/quickbeam/vm/decoder.ex index 0101eb9c1..1e8f8b840 100644 --- a/lib/quickbeam/vm/decoder.ex +++ b/lib/quickbeam/vm/decoder.ex @@ -63,6 +63,7 @@ defmodule QuickBEAM.VM.Decoder do %Program{ version: version, fingerprint: ABI.fingerprint(), + bytecode_digest: :crypto.hash(:sha256, data), atoms: atoms, root: attach_atoms(value, atoms) }} diff --git a/lib/quickbeam/vm/program.ex b/lib/quickbeam/vm/program.ex index 9259e404c..5458931c2 100644 --- a/lib/quickbeam/vm/program.ex +++ b/lib/quickbeam/vm/program.ex @@ -1,13 +1,22 @@ defmodule QuickBEAM.VM.Program do - @moduledoc "Compiled or decoded JavaScript program: atom table plus top-level VM value." + @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, allowing + optional compiler caches to invalidate on either identity without retaining + source text. + """ @enforce_keys [:version, :fingerprint, :atoms, :root] - defstruct [:version, :fingerprint, :atoms, :root] + defstruct [:version, :fingerprint, :atoms, :root, :bytecode_digest, :source_digest] @type t :: %__MODULE__{ version: non_neg_integer(), fingerprint: String.t(), atoms: tuple(), - root: term() + root: term(), + bytecode_digest: binary() | nil, + source_digest: binary() | nil } end diff --git a/mix.exs b/mix.exs index 1d9197815..a9646121c 100644 --- a/mix.exs +++ b/mix.exs @@ -108,6 +108,7 @@ defmodule QuickBEAM.MixProject do "docs/javascript-api.md", "docs/architecture.md", "docs/beam-interpreter-architecture.md", + "docs/beam-compiler-contract.md", "docs/beam-scheduler-measurements.md", "docs/beam-ssr-measurements.md", "docs/prototype-delta-audit.md", @@ -119,6 +120,7 @@ defmodule QuickBEAM.MixProject do "docs/javascript-api.md", "docs/architecture.md", "docs/beam-interpreter-architecture.md", + "docs/beam-compiler-contract.md", "docs/beam-scheduler-measurements.md", "docs/beam-ssr-measurements.md", "docs/prototype-delta-audit.md", @@ -136,6 +138,7 @@ defmodule QuickBEAM.MixProject do internal_vm_modules = [ "QuickBEAM.VM.Async", "QuickBEAM.VM.Builtin", + "QuickBEAM.VM.Compiler", "QuickBEAM.VM.Exceptions", "QuickBEAM.VM.Fuzz", "QuickBEAM.VM.Invocation", diff --git a/test/vm/compiler_contract_test.exs b/test/vm/compiler_contract_test.exs new file mode 100644 index 000000000..bedc30061 --- /dev/null +++ b/test/vm/compiler_contract_test.exs @@ -0,0 +1,139 @@ +defmodule QuickBEAM.VM.CompilerContractTest do + use ExUnit.Case, async: false + + alias QuickBEAM.VM.Compiler.{Contract, Deopt} + alias QuickBEAM.VM.{Execution, Frame, Function, Program} + + 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, first} = Contract.artifact_key(program, function) + assert {:ok, ^first} = Contract.artifact_key(program, function) + 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 + ) + + refute changed_program_key == key + refute changed_function_key == key + refute changed_source_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) + 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 + %Execution{ + atoms: program.atoms, + max_stack_depth: 32, + remaining_steps: 100, + step_limit: 100 + } + end +end diff --git a/test/vm/decoder_test.exs b/test/vm/decoder_test.exs index 75ceb0ccc..87befe3fd 100644 --- a/test/vm/decoder_test.exs +++ b/test/vm/decoder_test.exs @@ -18,11 +18,11 @@ defmodule QuickBEAM.VM.DecoderTest do end test "public compile API returns a verified program with the requested filename" do - assert {:ok, %Program{} = program} = - QuickBEAM.VM.compile("function render(){ return 'ok' } render()", - filename: "server.js" - ) + 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 @@ -34,6 +34,8 @@ defmodule QuickBEAM.VM.DecoderTest do 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 From 73a579d5908b1afdadd69414d18b9d6a420f7b54 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Tue, 14 Jul 2026 16:28:17 +0200 Subject: [PATCH 49/87] Add bounded compiler module pool --- CHANGELOG.md | 2 +- docs/beam-compiler-contract.md | 27 +- docs/beam-interpreter-architecture.md | 12 +- docs/prototype-delta-audit.md | 10 +- lib/quickbeam/vm/compiler/lease.ex | 21 + lib/quickbeam/vm/compiler/loader.ex | 20 + lib/quickbeam/vm/compiler/module_pool.ex | 663 +++++++++++++++++++++++ test/vm/compiler_module_pool_test.exs | 420 ++++++++++++++ 8 files changed, 1156 insertions(+), 19 deletions(-) create mode 100644 lib/quickbeam/vm/compiler/lease.ex create mode 100644 lib/quickbeam/vm/compiler/loader.ex create mode 100644 lib/quickbeam/vm/compiler/module_pool.ex create mode 100644 test/vm/compiler_module_pool_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index 6df49c040..9c7f2cff7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased -- Define the optional BEAM compiler contract with binary artifact identities, a fixed 32-module atom pool, lease/purge/cache lifecycle, versioned runtime ABI boundaries, and validated owner-local before-instruction deoptimization states. +- Define the optional BEAM compiler contract with binary artifact identities, a fixed 32-module atom pool, lease/purge/cache lifecycle, versioned runtime ABI boundaries, and validated owner-local before-instruction deoptimization states. Add its supervised module-pool foundation with single-flight compilation, monitored leases, LRU reuse, bounded draining, and quarantine behind a testable loader boundary. - Add `QuickBEAM.VM.measure/2` with deterministic step/logical-memory counters and endpoint process observations, plus reproducible pinned SSR concurrency, timeout, cancellation, reclamation, and single-scheduler reports. - Serialize native addon initialization, reuse cached exports for aliases in one runtime, and reject implicit cross-runtime or post-reset reinitialization with a typed error. Add `allow_reinitialization: true` as an explicit compatibility escape hatch for addons that support multiple environments. diff --git a/docs/beam-compiler-contract.md b/docs/beam-compiler-contract.md index d41c77b87..80280ecd3 100644 --- a/docs/beam-compiler-contract.md +++ b/docs/beam-compiler-contract.md @@ -65,6 +65,12 @@ uses exactly the statically declared names returned by 32 names. Configuration may use fewer slots but cannot create names or exceed that ceiling. +`QuickBEAM.VM.Compiler.ModulePool` now implements the lifecycle as a +supervisor-compatible singleton behind the `Compiler.Loader` behavior. Compile +tasks have bounded wall time and a configurable BEAM heap ceiling. The current +tests use a fake loader: no generated BEAM is installed until the minimal +runtime ABI and production soft-purge loader land. + A singleton supervised pool owns these states: ```text @@ -108,10 +114,13 @@ error. Generated code must not expose BEAM funs or `{module, function}` tuples a JavaScript values; JavaScript closures remain canonical VM function values and acquire a fresh lease when invoked. -Pool restart increments an epoch, invalidating every old lease. Shutdown stops -compile tasks, rejects waiters, waits for leases for a bounded grace period, and -then applies only soft purge. Code that cannot be safely purged remains loaded -until VM shutdown. +Pool restart increments an epoch, invalidating every old lease, and attempts to +soft-retire every reserved static slot before admitting work, including slots +outside the currently configured capacity. A slot that may still be referenced +by pre-restart code is quarantined rather than treated as free. Shutdown stops +compile tasks, rejects waiters, waits for leases for a +bounded grace period, and then applies only soft purge. Code that cannot be +safely purged remains loaded until VM shutdown. ## Compiler runtime ABI @@ -230,10 +239,12 @@ not prototype runtime modules or fallback behavior. ## Extraction order -1. Land contract types, static slot names, and invariant tests. -2. Implement the supervised pool with a fake loader; prove bounds, owner - monitoring, generations, single-flight compilation, and quarantine. -3. Add the minimal runtime ABI and disassembly allowlist. +1. **Complete:** land contract types, static slot names, and invariant tests. +2. **Complete:** implement the supervised pool with a fake loader; prove bounds, + owner monitoring, generations, single-flight compilation, bounded shutdown, + and quarantine. +3. Add the minimal runtime ABI, production soft-purge loader, and disassembly + allowlist. 4. Extract only CFG/stack analysis concepts and pure-block form emission. 5. Compile literals, stack/local operations, primitive values, and branches. 6. Add validated before-instruction deoptimization to the interpreter. diff --git a/docs/beam-interpreter-architecture.md b/docs/beam-interpreter-architecture.md index 7a9db2c5e..6c7365600 100644 --- a/docs/beam-interpreter-architecture.md +++ b/docs/beam-interpreter-architecture.md @@ -7,10 +7,10 @@ process-isolated evaluation, explicit frames and detached async continuations, closures, exceptions, owner-local objects, Promise reactions and combinators, asynchronous `Beam.call`, logical and process memory containment, stable JavaScript errors, pinned Preact, Vue, and Svelte SSR acceptance fixtures, and -bounded deterministic decoder/verifier mutation fuzzing, and the bounded -optional-compiler contract. Broader ECMAScript conformance, object-model -hardening, garbage collection, compiler extraction, and release hardening remain -in progress. +bounded deterministic decoder/verifier mutation fuzzing, the bounded optional +compiler contract, and its supervised fixed-slot lifecycle foundation. Broader +ECMAScript conformance, object-model hardening, garbage collection, compiler +lowering, and release hardening remain in progress. ## Summary @@ -844,8 +844,8 @@ limits are enforced, and the compatibility matrix is published. Only after interpreter correctness is stable: -- implement the static module pool and lifecycle specified in - [`beam-compiler-contract.md`](beam-compiler-contract.md); +- build the production soft-purge loader on the implemented static module pool + lifecycle specified in [`beam-compiler-contract.md`](beam-compiler-contract.md); - compile verified pure basic blocks to Erlang abstract forms; - call the versioned ABI over the same canonical runtime semantics; - deopt before unsupported instructions into validated owner-local interpreter diff --git a/docs/prototype-delta-audit.md b/docs/prototype-delta-audit.md index 8c2bc7b67..2a93b9452 100644 --- a/docs/prototype-delta-audit.md +++ b/docs/prototype-delta-audit.md @@ -309,10 +309,12 @@ Compiler extraction gate: The module-name, cache-lifecycle, runtime-ABI, prototype-analysis, and deoptimization design is now specified in [`beam-compiler-contract.md`](beam-compiler-contract.md). Static slot identities -and owner-local deoptimization validation are executable contracts. The compiler -remains quarantined until the supervised pool, minimal runtime ABI, and their -acceptance tests are implemented; no prototype compiler runtime is approved for -copying. +and owner-local deoptimization validation are executable contracts. The +supervisor-compatible fixed-slot pool now proves leases, owner monitoring, +single-flight compilation, LRU reuse, bounded shutdown, and quarantine through a +fake loader. The compiler remains quarantined until the minimal runtime ABI and +production soft-purge loader pass their acceptance tests; no prototype compiler +runtime is approved for copying. ## Test extraction policy diff --git a/lib/quickbeam/vm/compiler/lease.ex b/lib/quickbeam/vm/compiler/lease.ex new file mode 100644 index 000000000..cf88c90df --- /dev/null +++ b/lib/quickbeam/vm/compiler/lease.ex @@ -0,0 +1,21 @@ +defmodule QuickBEAM.VM.Compiler.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/loader.ex b/lib/quickbeam/vm/compiler/loader.ex new file mode 100644 index 000000000..cef5b7f78 --- /dev/null +++ b/lib/quickbeam/vm/compiler/loader.ex @@ -0,0 +1,20 @@ +defmodule QuickBEAM.VM.Compiler.Loader do + @moduledoc """ + Defines the bounded module-pool loading boundary. + + Compilation 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/module_pool.ex b/lib/quickbeam/vm/compiler/module_pool.ex new file mode 100644 index 000000000..e5b75d782 --- /dev/null +++ b/lib/quickbeam/vm/compiler/module_pool.ex @@ -0,0 +1,663 @@ +defmodule QuickBEAM.VM.Compiler.ModulePool 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. Loader + installation and retirement are serialized in the pool process. + """ + + use GenServer + + alias QuickBEAM.VM.Compiler.{Contract, Lease} + + @default_compile_timeout 5_000 + @default_compile_max_heap_bytes 64 * 1024 * 1024 + @max_capacity Contract.pool_capacity() + + @type server :: GenServer.server() + + @doc "Starts a compiler module pool suitable for a supervision tree." + @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 "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 "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, loader} <- fetch_loader(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(loader, &1)}) + modules = Enum.take(all_modules, capacity) + slots = Map.take(initialized_slots, modules) + + {:ok, + %{ + loader: loader, + task_supervisor: task_supervisor, + compile_timeout: compile_timeout, + compile_max_heap_words: compile_max_heap_words, + modules: modules, + slots: slots, + key_index: %{}, + 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({: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), + mode: state.mode, + slots: slots + }, 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_loader_call(state.loader, :retire, [module]) + end + end) + + :ok + 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_loader_call(state.loader, :retire, [module]) do + :ok -> + {:ok, put_slot(state, free_slot(module, slot.generation))} + + result -> + reason = loader_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 + {waiter, state} = monitor_waiter(module, from, state) + loader = state.loader + 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 + }) + + loader.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_loader_call(state.loader, :install, [module, artifact]) do + :ok -> compilation_ready(slot, state) + result -> compilation_failed(slot, {:install_failed, loader_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_loader_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 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(loader, module) do + case safe_loader_call(loader, :retire, [module]) do + :ok -> + free_slot(module, 0) + + result -> + %{ + module: module, + status: :quarantined, + generation: 0, + reason: loader_error(result) + } + end + end + + defp safe_loader_call(loader, function, args) do + apply(loader, function, args) + catch + kind, reason -> {:error, {kind, reason}} + end + + defp loader_error({:error, reason}), do: reason + defp loader_error(result), do: {:invalid_loader_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_loader_call(state.loader, :retire, [module]) do + :ok -> + {put_slot(state, free_slot(module, slot.generation)), quarantined} + + result -> + reason = loader_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_loader(opts) do + case Keyword.fetch(opts, :loader) do + {:ok, loader} when is_atom(loader) -> + if Code.ensure_loaded?(loader) and + function_exported?(loader, :compile, 3) and + function_exported?(loader, :install, 2) and + function_exported?(loader, :retire, 1) do + {:ok, loader} + else + {:error, {:invalid_compiler_loader, loader}} + end + + {:ok, loader} -> + {:error, {:invalid_compiler_loader, loader}} + + :error -> + {:error, {:missing_option, :loader}} + 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/test/vm/compiler_module_pool_test.exs b/test/vm/compiler_module_pool_test.exs new file mode 100644 index 000000000..3fe499e73 --- /dev/null +++ b/test/vm/compiler_module_pool_test.exs @@ -0,0 +1,420 @@ +defmodule QuickBEAM.VM.CompilerModulePoolTest do + use ExUnit.Case, async: false + + alias QuickBEAM.VM.Compiler.{Contract, ModulePool} + + defmodule FakeLoader do + @moduledoc "Test loader that records compiler module-pool lifecycle calls." + + @behaviour QuickBEAM.VM.Compiler.Loader + + @state __MODULE__.State + + @doc "Returns the fake loader 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 loader 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: FakeLoader.state_name(), + start: {Agent, :start_link, [initial_state, [name: FakeLoader.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 = ModulePool.checkout(pool, key, {:block, parent}) + send(parent, {:checkout_result, self(), result}) + + receive do + :release -> + {:ok, lease} = result + send(parent, {:checkin_result, self(), ModulePool.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 FakeLoader.state().compiles[key] == 1 + assert ModulePool.stats(pool).leases == 20 + + Enum.each(owners, &send(&1, :release)) + + for owner <- owners do + assert_receive {:checkin_result, ^owner, :ok} + end + + assert eventually(fn -> ModulePool.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 -> ModulePool.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 -> ModulePool.stats(pool).counts == %{ready: 1} end) + assert ModulePool.stats(pool).leases == 0 + assert {:ok, lease} = ModulePool.checkout(pool, key) + assert FakeLoader.state().compiles[key] == 1 + assert :ok = ModulePool.checkin(pool, lease) + 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}}} = + ModulePool.checkout(pool, key(1), {:exit, :lowering_crash}) + + assert ModulePool.stats(pool).counts == %{free: 1} + assert {:ok, _lease} = ModulePool.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} = ModulePool.checkout(pool, key(id)) + assert :ok = ModulePool.checkin(pool, lease) + end + + stats = ModulePool.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(FakeLoader.state().compile_modules)) == 2 + assert length(FakeLoader.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} = ModulePool.checkout(pool, key(1)) + assert {:error, :compiler_pool_busy} = ModulePool.checkout(pool, key(2)) + assert :ok = ModulePool.validate_lease(pool, lease) + assert :ok = ModulePool.checkin(pool, lease) + assert {:ok, replacement} = ModulePool.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 = ModulePool.checkout(pool, key(1)) + send(parent, {:owner_checkout, self(), result}) + Process.sleep(:infinity) + end) + + assert_receive {:owner_checkout, ^owner, {:ok, lease}} + assert ModulePool.stats(pool).leases == 1 + Process.exit(owner, :kill) + + assert eventually(fn -> ModulePool.stats(pool).leases == 0 end) + assert {:error, :compiler_lease_owner_mismatch} = ModulePool.validate_lease(pool, lease) + assert {:ok, replacement} = ModulePool.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} = ModulePool.checkout(pool, key(1)) + + task = Task.async(fn -> ModulePool.validate_lease(pool, first) end) + assert {:error, :compiler_lease_owner_mismatch} = Task.await(task) + + assert :ok = ModulePool.checkin(pool, first) + assert {:ok, second} = ModulePool.checkout(pool, key(2)) + assert second.generation > first.generation + assert {:error, :stale_compiler_lease} = ModulePool.validate_lease(pool, first) + assert {:error, :stale_compiler_lease} = ModulePool.checkin(pool, first) + end + + test "pool restart changes epoch and rejects every old lease" do + name = ModulePool + pool = start_pool(capacity: 1) + assert {:ok, lease} = ModulePool.checkout(pool, key(1)) + first_epoch = ModulePool.stats(pool).epoch + + FakeLoader.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 ModulePool.stats(restarted).epoch == first_epoch + assert {:error, :stale_compiler_lease} = ModulePool.validate_lease(restarted, lease) + + assert [%{status: :quarantined, reason: :live_code_reference}] = + ModulePool.stats(restarted).slots + end + + test "quarantines a slot when soft retirement fails" do + pool = start_pool(capacity: 1) + assert {:ok, lease} = ModulePool.checkout(pool, key(1)) + assert :ok = ModulePool.checkin(pool, lease) + FakeLoader.put_retire_result(lease.module, {:error, :live_code_reference}) + + assert {:error, :compiler_pool_busy} = ModulePool.checkout(pool, key(2)) + + assert [%{status: :quarantined, reason: :live_code_reference}] = + ModulePool.stats(pool).slots + + assert FakeLoader.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}}} = + ModulePool.checkout(pool, key(1), {:allocate, 1_000_000}) + + assert ModulePool.stats(pool).counts == %{free: 1} + assert {:ok, _lease} = ModulePool.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 -> ModulePool.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 ModulePool.stats(pool).counts == %{free: 1} + assert {:ok, _lease} = ModulePool.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}}} = + ModulePool.checkout(pool, key(1), {:install_error, :bad_beam}) + + assert [%{status: :quarantined, reason: {:install_failed, :bad_beam}}] = + ModulePool.stats(pool).slots + + assert {:error, :compiler_pool_busy} = ModulePool.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 -> ModulePool.checkout(pool, key, {:block, parent}) end) + assert_receive {:compile_started, ^key, _module, compiler_pid} + + assert :ok = ModulePool.drain(pool, 1_000) + assert {:error, {:compiler_compile_failed, :compiler_pool_stopping}} = Task.await(waiter) + refute Process.alive?(compiler_pid) + assert ModulePool.stats(pool).mode == :drained + assert ModulePool.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} = ModulePool.checkout(pool, key(1)) + + drain = Task.async(fn -> ModulePool.drain(pool, 1_000) end) + assert eventually(fn -> ModulePool.stats(pool).mode == :draining end) + assert {:error, :compiler_pool_stopping} = ModulePool.checkout(pool, key(2)) + refute Task.yield(drain, 20) + + assert :ok = ModulePool.checkin(pool, lease) + assert :ok = Task.await(drain) + assert ModulePool.stats(pool).mode == :drained + assert ModulePool.stats(pool).counts == %{free: 1} + assert FakeLoader.state().retires == [lease.module] + end + + test "bounds shutdown waiting without hard-purging an active slot" do + pool = start_pool(capacity: 1) + assert {:ok, lease} = ModulePool.checkout(pool, key(1)) + + assert {:error, {:compiler_pool_shutdown_timeout, 1}} = ModulePool.drain(pool, 20) + assert FakeLoader.state().retires == [] + assert ModulePool.stats(pool).mode == :draining + + assert :ok = ModulePool.checkin(pool, lease) + assert eventually(fn -> ModulePool.stats(pool).mode == :drained end) + assert FakeLoader.state().retires == [lease.module] + end + + test "soft-retires every reserved module name before admitting work" do + pool = + start_supervised!( + {ModulePool, + loader: FakeLoader, task_supervisor: QuickBEAM.VM.TaskSupervisor, capacity: 1} + ) + + assert Process.alive?(pool) + assert Enum.sort(FakeLoader.state().retires) == Enum.sort(Contract.pool_modules()) + assert ModulePool.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}} = + ModulePool.start_link(loader: FakeLoader, capacity: 1) + + assert {:error, {:invalid_option, :name, :another_pool}} = + ModulePool.start_link(loader: FakeLoader, name: :another_pool) + end + + test "validates bounded startup options and artifact keys" do + assert {:error, {:invalid_artifact_key, <<1>>}} = + ModulePool.checkout(self(), <<1>>) + + assert {:error, {{:invalid_option, :capacity, 33}, _child}} = + start_supervised({ModulePool, loader: FakeLoader, capacity: 33}) + + assert {:error, {{:invalid_option, :compile_timeout, 0}, _child}} = + start_supervised({ModulePool, loader: FakeLoader, compile_timeout: 0}) + + assert {:error, {{:invalid_option, :compile_max_heap_bytes, 0}, _child}} = + start_supervised({ModulePool, loader: FakeLoader, compile_max_heap_bytes: 0}) + end + + defp start_pool(opts) do + pool = + start_supervised!( + {ModulePool, + Keyword.merge( + [loader: FakeLoader, task_supervisor: QuickBEAM.VM.TaskSupervisor], + opts + )} + ) + + FakeLoader.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 From 0dcec02626a75530c1c32f77a4f3960b2eb9a917 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 15 Jul 2026 17:48:32 +0200 Subject: [PATCH 50/87] Add structured generated module backend --- CHANGELOG.md | 2 +- docs/beam-compiler-contract.md | 33 +-- docs/beam-interpreter-architecture.md | 9 +- docs/prototype-delta-audit.md | 8 +- lib/quickbeam/vm/compiler/generated_module.ex | 22 ++ .../vm/compiler/generated_module/artifact.ex | 48 ++++ .../generated_module/code_lifecycle.ex | 73 ++++++ .../vm/compiler/generated_module/emitter.ex | 128 +++++++++++ .../generated_module/import_policy.ex | 59 +++++ .../vm/compiler/generated_module/template.ex | 20 ++ lib/quickbeam/vm/compiler/module_pool.ex | 71 +++--- .../{loader.ex => module_pool/backend.ex} | 6 +- .../vm/compiler/{ => module_pool}/lease.ex | 2 +- lib/quickbeam/vm/compiler/runtime.ex | 152 +++++++++++++ test/vm/compiler_generated_module_test.exs | 213 ++++++++++++++++++ test/vm/compiler_module_pool_test.exs | 58 ++--- test/vm/compiler_runtime_test.exs | 128 +++++++++++ 17 files changed, 946 insertions(+), 86 deletions(-) create mode 100644 lib/quickbeam/vm/compiler/generated_module.ex create mode 100644 lib/quickbeam/vm/compiler/generated_module/artifact.ex create mode 100644 lib/quickbeam/vm/compiler/generated_module/code_lifecycle.ex create mode 100644 lib/quickbeam/vm/compiler/generated_module/emitter.ex create mode 100644 lib/quickbeam/vm/compiler/generated_module/import_policy.ex create mode 100644 lib/quickbeam/vm/compiler/generated_module/template.ex rename lib/quickbeam/vm/compiler/{loader.ex => module_pool/backend.ex} (76%) rename lib/quickbeam/vm/compiler/{ => module_pool}/lease.ex (92%) create mode 100644 lib/quickbeam/vm/compiler/runtime.ex create mode 100644 test/vm/compiler_generated_module_test.exs create mode 100644 test/vm/compiler_runtime_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c7f2cff7..5acb2ad64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased -- Define the optional BEAM compiler contract with binary artifact identities, a fixed 32-module atom pool, lease/purge/cache lifecycle, versioned runtime ABI boundaries, and validated owner-local before-instruction deoptimization states. Add its supervised module-pool foundation with single-flight compilation, monitored leases, LRU reuse, bounded draining, and quarantine behind a testable loader boundary. +- Define the optional BEAM compiler contract with binary artifact identities, a fixed 32-module atom pool, lease/purge/cache lifecycle, versioned runtime ABI boundaries, and validated owner-local before-instruction deoptimization states. Add its supervised module pool, minimal canonical runtime ABI, and structured generated-module backend with bounded emission, import policy, artifact validation, and soft-purge code lifecycle. - Add `QuickBEAM.VM.measure/2` with deterministic step/logical-memory counters and endpoint process observations, plus reproducible pinned SSR concurrency, timeout, cancellation, reclamation, and single-scheduler reports. - Serialize native addon initialization, reuse cached exports for aliases in one runtime, and reject implicit cross-runtime or post-reset reinitialization with a typed error. Add `allow_reinitialization: true` as an explicit compatibility escape hatch for addons that support multiple environments. diff --git a/docs/beam-compiler-contract.md b/docs/beam-compiler-contract.md index 80280ecd3..c1938dc6a 100644 --- a/docs/beam-compiler-contract.md +++ b/docs/beam-compiler-contract.md @@ -66,10 +66,11 @@ uses exactly the statically declared names returned by that ceiling. `QuickBEAM.VM.Compiler.ModulePool` now implements the lifecycle as a -supervisor-compatible singleton behind the `Compiler.Loader` behavior. Compile -tasks have bounded wall time and a configurable BEAM heap ceiling. The current -tests use a fake loader: no generated BEAM is installed until the minimal -runtime ABI and production soft-purge loader land. +supervisor-compatible singleton behind the `Compiler.ModulePool.Backend` +behavior. Compile tasks have bounded wall time and a configurable BEAM heap +ceiling. Lifecycle tests use a fake backend. The production +`Compiler.GeneratedModule` backend coordinates bounded template emission, +import validation, artifact installation, and soft-purge code retirement. A singleton supervised pool owns these states: @@ -118,8 +119,8 @@ Pool restart increments an epoch, invalidating every old lease, and attempts to soft-retire every reserved static slot before admitting work, including slots outside the currently configured capacity. A slot that may still be referenced by pre-restart code is quarantined rather than treated as free. Shutdown stops -compile tasks, rejects waiters, waits for leases for a -bounded grace period, and then applies only soft purge. Code that cannot be +compile tasks, rejects waiters, waits for leases for a bounded grace period, and +then applies only soft purge. Code that cannot be safely purged remains loaded until VM shutdown. ## Compiler runtime ABI @@ -140,16 +141,20 @@ No generated external call to `Heap`, builtin installers, process dictionaries, or prototype compiler helpers is allowed. ABI functions return explicit actions rather than recursively invoking JavaScript. -The first ABI should contain only: +The first ABI now contains only: - exact step charging at basic-block boundaries; -- local/argument/stack transforms over `%Frame{}`; +- verified local/argument/stack transforms over `%Frame{}` through existing + opcode-family modules; - primitive unary/binary operations through `Value`; - truthiness and verified branch selection; - construction of a typed `%Compiler.Deopt{}`. -Properties, calls, throws, and suspension are added only with differential and -resource-limit tests for their action protocol. +`Compiler.GeneratedModule.ImportPolicy` inspects every generated module import +before installation. The closed initial allowlist contains this ABI plus the two +Erlang `get_module_info` imports emitted for every generated module. Properties, calls, +throws, and suspension are added only with differential and resource-limit tests +for their action protocol. ## Steps, memory, timeout, and scheduling @@ -240,12 +245,12 @@ not prototype runtime modules or fallback behavior. ## Extraction order 1. **Complete:** land contract types, static slot names, and invariant tests. -2. **Complete:** implement the supervised pool with a fake loader; prove bounds, +2. **Complete:** implement the supervised pool with a fake backend; prove bounds, owner monitoring, generations, single-flight compilation, bounded shutdown, and quarantine. -3. Add the minimal runtime ABI, production soft-purge loader, and disassembly - allowlist. -4. Extract only CFG/stack analysis concepts and pure-block form emission. +3. **Complete:** add the minimal runtime ABI, generated-module emitter and import + policy, and production soft-purge code lifecycle. +4. Extract only CFG/basic-block analysis concepts and pure-block form emission. 5. Compile literals, stack/local operations, primitive values, and branches. 6. Add validated before-instruction deoptimization to the interpreter. 7. Run interpreter/compiler/native differential tests plus exact limit and diff --git a/docs/beam-interpreter-architecture.md b/docs/beam-interpreter-architecture.md index 6c7365600..8bb8a1576 100644 --- a/docs/beam-interpreter-architecture.md +++ b/docs/beam-interpreter-architecture.md @@ -8,7 +8,9 @@ closures, exceptions, owner-local objects, Promise reactions and combinators, asynchronous `Beam.call`, logical and process memory containment, stable JavaScript errors, pinned Preact, Vue, and Svelte SSR acceptance fixtures, and bounded deterministic decoder/verifier mutation fuzzing, the bounded optional -compiler contract, and its supervised fixed-slot lifecycle foundation. Broader +compiler contract, its supervised fixed-slot lifecycle, the minimal generated +code ABI, and a generated-module backend with import policy and soft-purge code +lifecycle. Broader ECMAScript conformance, object-model hardening, garbage collection, compiler lowering, and release hardening remain in progress. @@ -844,8 +846,9 @@ limits are enforced, and the compatibility matrix is published. Only after interpreter correctness is stable: -- build the production soft-purge loader on the implemented static module pool - lifecycle specified in [`beam-compiler-contract.md`](beam-compiler-contract.md); +- extract bounded CFG/basic-block analysis onto the implemented module pool, + runtime ABI, and generated-module backend specified in + [`beam-compiler-contract.md`](beam-compiler-contract.md); - compile verified pure basic blocks to Erlang abstract forms; - call the versioned ABI over the same canonical runtime semantics; - deopt before unsupported instructions into validated owner-local interpreter diff --git a/docs/prototype-delta-audit.md b/docs/prototype-delta-audit.md index 2a93b9452..0798a2dfb 100644 --- a/docs/prototype-delta-audit.md +++ b/docs/prototype-delta-audit.md @@ -312,9 +312,11 @@ deoptimization design is now specified in and owner-local deoptimization validation are executable contracts. The supervisor-compatible fixed-slot pool now proves leases, owner monitoring, single-flight compilation, LRU reuse, bounded shutdown, and quarantine through a -fake loader. The compiler remains quarantined until the minimal runtime ABI and -production soft-purge loader pass their acceptance tests; no prototype compiler -runtime is approved for copying. +fake backend. A minimal canonical runtime ABI, bounded generated-module emitter, +import policy, and soft-purge code lifecycle now pass their +acceptance tests. The compiler remains quarantined until adapted CFG/basic-block +analysis emits the first differential-tested `:pure_v1` lowering; no prototype +compiler runtime is approved for copying. ## Test extraction policy diff --git a/lib/quickbeam/vm/compiler/generated_module.ex b/lib/quickbeam/vm/compiler/generated_module.ex new file mode 100644 index 000000000..ec9feba62 --- /dev/null +++ b/lib/quickbeam/vm/compiler/generated_module.ex @@ -0,0 +1,22 @@ +defmodule QuickBEAM.VM.Compiler.GeneratedModule 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.ModulePool.Backend + + alias QuickBEAM.VM.Compiler.GeneratedModule.{CodeLifecycle, Emitter} + + @impl true + def compile(key, module, template), do: Emitter.emit(key, module, template) + + @impl true + def install(module, artifact), do: CodeLifecycle.install(module, artifact) + + @impl true + def retire(module), do: CodeLifecycle.retire(module) +end diff --git a/lib/quickbeam/vm/compiler/generated_module/artifact.ex b/lib/quickbeam/vm/compiler/generated_module/artifact.ex new file mode 100644 index 000000000..3965cec7e --- /dev/null +++ b/lib/quickbeam/vm/compiler/generated_module/artifact.ex @@ -0,0 +1,48 @@ +defmodule QuickBEAM.VM.Compiler.GeneratedModule.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/generated_module/code_lifecycle.ex b/lib/quickbeam/vm/compiler/generated_module/code_lifecycle.ex new file mode 100644 index 000000000..19c17730d --- /dev/null +++ b/lib/quickbeam/vm/compiler/generated_module/code_lifecycle.ex @@ -0,0 +1,73 @@ +defmodule QuickBEAM.VM.Compiler.GeneratedModule.CodeLifecycle 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.Contract + alias QuickBEAM.VM.Compiler.GeneratedModule.{Artifact, ImportPolicy} + + @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 <- ImportPolicy.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/generated_module/emitter.ex b/lib/quickbeam/vm/compiler/generated_module/emitter.ex new file mode 100644 index 000000000..96ffcf85d --- /dev/null +++ b/lib/quickbeam/vm/compiler/generated_module/emitter.ex @@ -0,0 +1,128 @@ +defmodule QuickBEAM.VM.Compiler.GeneratedModule.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. + """ + + alias QuickBEAM.VM.Compiler.Contract + alias QuickBEAM.VM.Compiler.GeneratedModule.{Artifact, ImportPolicy, Template} + + @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 <- ImportPolicy.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 + case :compile.forms(forms, [:binary, :deterministic, :return_errors, :return_warnings]) 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/generated_module/import_policy.ex b/lib/quickbeam/vm/compiler/generated_module/import_policy.ex new file mode 100644 index 000000000..73bb8ca49 --- /dev/null +++ b/lib/quickbeam/vm/compiler/generated_module/import_policy.ex @@ -0,0 +1,59 @@ +defmodule QuickBEAM.VM.Compiler.GeneratedModule.ImportPolicy 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, :get_module_info, 1}, + {:erlang, :get_module_info, 2}, + {Runtime, :version, 0}, + {Runtime, :charge_block, 4}, + {Runtime, :deopt, 4}, + {Runtime, :execute_stack, 4}, + {Runtime, :execute_local, 4}, + {Runtime, :execute_value, 4}, + {Runtime, :execute_branch, 4}, + {Runtime, :truthy?, 1}, + {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/generated_module/template.ex b/lib/quickbeam/vm/compiler/generated_module/template.ex new file mode 100644 index 000000000..b828f09bb --- /dev/null +++ b/lib/quickbeam/vm/compiler/generated_module/template.ex @@ -0,0 +1,20 @@ +defmodule QuickBEAM.VM.Compiler.GeneratedModule.Template do + @moduledoc """ + Holds bounded Erlang abstract forms before assignment to a static module slot. + + Templates use the reserved `QuickBEAM.VM.Compiler.GeneratedModule.Placeholder` + atom as their module attribute. The emitter replaces only that attribute with + the leased static module name. + """ + + @placeholder_module QuickBEAM.VM.Compiler.GeneratedModule.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/module_pool.ex b/lib/quickbeam/vm/compiler/module_pool.ex index e5b75d782..2c25369e3 100644 --- a/lib/quickbeam/vm/compiler/module_pool.ex +++ b/lib/quickbeam/vm/compiler/module_pool.ex @@ -3,13 +3,14 @@ defmodule QuickBEAM.VM.Compiler.ModulePool do 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. Loader + 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, Lease} + alias QuickBEAM.VM.Compiler.Contract + alias QuickBEAM.VM.Compiler.ModulePool.Lease @default_compile_timeout 5_000 @default_compile_max_heap_bytes 64 * 1024 * 1024 @@ -17,7 +18,7 @@ defmodule QuickBEAM.VM.Compiler.ModulePool do @type server :: GenServer.server() - @doc "Starts a compiler module pool suitable for a supervision tree." + @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 @@ -61,19 +62,19 @@ defmodule QuickBEAM.VM.Compiler.ModulePool do @impl true def init(opts) do - with {:ok, loader} <- fetch_loader(opts), + 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(loader, &1)}) + initialized_slots = Map.new(all_modules, &{&1, initialize_slot(backend, &1)}) modules = Enum.take(all_modules, capacity) slots = Map.take(initialized_slots, modules) {:ok, %{ - loader: loader, + backend: backend, task_supervisor: task_supervisor, compile_timeout: compile_timeout, compile_max_heap_words: compile_max_heap_words, @@ -230,7 +231,7 @@ defmodule QuickBEAM.VM.Compiler.ModulePool do Enum.each(state.slots, fn {module, slot} -> if slot.status == :ready and slot.lease_count == 0 do - safe_loader_call(state.loader, :retire, [module]) + safe_backend_call(state.backend, :retire, [module]) end end) @@ -294,12 +295,12 @@ defmodule QuickBEAM.VM.Compiler.ModulePool do slot = Map.fetch!(state.slots, module) state = %{state | key_index: Map.delete(state.key_index, slot.key)} - case safe_loader_call(state.loader, :retire, [module]) do + case safe_backend_call(state.backend, :retire, [module]) do :ok -> {:ok, put_slot(state, free_slot(module, slot.generation))} result -> - reason = loader_error(result) + reason = backend_error(result) slot = %{ module: module, @@ -314,7 +315,7 @@ defmodule QuickBEAM.VM.Compiler.ModulePool do defp start_compilation(module, key, input, from, state) do {waiter, state} = monitor_waiter(module, from, state) - loader = state.loader + backend = state.backend max_heap_words = state.compile_max_heap_words task = @@ -325,7 +326,7 @@ defmodule QuickBEAM.VM.Compiler.ModulePool do error_logger: false }) - loader.compile(key, module, input) + backend.compile(key, module, input) end) timer = Process.send_after(self(), {:compile_timeout, task.ref}, state.compile_timeout) @@ -363,9 +364,9 @@ defmodule QuickBEAM.VM.Compiler.ModulePool do defp finish_compilation(module, {:ok, artifact}, state) do slot = Map.fetch!(state.slots, module) - case safe_loader_call(state.loader, :install, [module, artifact]) do + case safe_backend_call(state.backend, :install, [module, artifact]) do :ok -> compilation_ready(slot, state) - result -> compilation_failed(slot, {:install_failed, loader_error(result)}, state, true) + result -> compilation_failed(slot, {:install_failed, backend_error(result)}, state, true) end end @@ -376,7 +377,7 @@ defmodule QuickBEAM.VM.Compiler.ModulePool do defp finish_compilation(module, result, state) do slot = Map.fetch!(state.slots, module) - compilation_failed(slot, {:invalid_loader_result, result}, state, false) + compilation_failed(slot, {:invalid_backend_result, result}, state, false) end defp compilation_ready(slot, state) do @@ -508,8 +509,8 @@ defmodule QuickBEAM.VM.Compiler.ModulePool do defp free_slot(module, generation), do: %{module: module, status: :free, generation: generation} - defp initialize_slot(loader, module) do - case safe_loader_call(loader, :retire, [module]) do + defp initialize_slot(backend, module) do + case safe_backend_call(backend, :retire, [module]) do :ok -> free_slot(module, 0) @@ -518,19 +519,19 @@ defmodule QuickBEAM.VM.Compiler.ModulePool do module: module, status: :quarantined, generation: 0, - reason: loader_error(result) + reason: backend_error(result) } end end - defp safe_loader_call(loader, function, args) do - apply(loader, function, args) + defp safe_backend_call(backend, function, args) do + apply(backend, function, args) catch kind, reason -> {:error, {kind, reason}} end - defp loader_error({:error, reason}), do: reason - defp loader_error(result), do: {:invalid_loader_result, result} + 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 -> @@ -573,12 +574,12 @@ defmodule QuickBEAM.VM.Compiler.ModulePool do 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_loader_call(state.loader, :retire, [module]) do + case safe_backend_call(state.backend, :retire, [module]) do :ok -> {put_slot(state, free_slot(module, slot.generation)), quarantined} result -> - reason = loader_error(result) + reason = backend_error(result) replacement = %{ module: module, @@ -593,23 +594,23 @@ defmodule QuickBEAM.VM.Compiler.ModulePool do defp retire_ready_slot(_module, _slot, state, quarantined), do: {state, quarantined} - defp fetch_loader(opts) do - case Keyword.fetch(opts, :loader) do - {:ok, loader} when is_atom(loader) -> - if Code.ensure_loaded?(loader) and - function_exported?(loader, :compile, 3) and - function_exported?(loader, :install, 2) and - function_exported?(loader, :retire, 1) do - {:ok, loader} + 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_loader, loader}} + {:error, {:invalid_compiler_backend, backend}} end - {:ok, loader} -> - {:error, {:invalid_compiler_loader, loader}} + {:ok, backend} -> + {:error, {:invalid_compiler_backend, backend}} :error -> - {:error, {:missing_option, :loader}} + {:error, {:missing_option, :backend}} end end diff --git a/lib/quickbeam/vm/compiler/loader.ex b/lib/quickbeam/vm/compiler/module_pool/backend.ex similarity index 76% rename from lib/quickbeam/vm/compiler/loader.ex rename to lib/quickbeam/vm/compiler/module_pool/backend.ex index cef5b7f78..22ead4bf7 100644 --- a/lib/quickbeam/vm/compiler/loader.ex +++ b/lib/quickbeam/vm/compiler/module_pool/backend.ex @@ -1,8 +1,8 @@ -defmodule QuickBEAM.VM.Compiler.Loader do +defmodule QuickBEAM.VM.Compiler.ModulePool.Backend do @moduledoc """ - Defines the bounded module-pool loading boundary. + Defines the generated-module boundary used by the bounded module pool. - Compilation runs in a supervised task. Installation and retirement are + 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. """ diff --git a/lib/quickbeam/vm/compiler/lease.ex b/lib/quickbeam/vm/compiler/module_pool/lease.ex similarity index 92% rename from lib/quickbeam/vm/compiler/lease.ex rename to lib/quickbeam/vm/compiler/module_pool/lease.ex index cf88c90df..2ad33afca 100644 --- a/lib/quickbeam/vm/compiler/lease.ex +++ b/lib/quickbeam/vm/compiler/module_pool/lease.ex @@ -1,4 +1,4 @@ -defmodule QuickBEAM.VM.Compiler.Lease do +defmodule QuickBEAM.VM.Compiler.ModulePool.Lease do @moduledoc """ Grants one evaluation process temporary access to a compiled module slot. diff --git a/lib/quickbeam/vm/compiler/runtime.ex b/lib/quickbeam/vm/compiler/runtime.ex new file mode 100644 index 000000000..357061881 --- /dev/null +++ b/lib/quickbeam/vm/compiler/runtime.ex @@ -0,0 +1,152 @@ +defmodule QuickBEAM.VM.Compiler.Runtime do + @moduledoc """ + Defines the versioned semantic ABI available to generated BEAM modules. + + The initial ABI supports exact block charging, explicit deoptimization, and + verified pure stack, local, value, and branch operations. Implementations + delegate to canonical VM opcode and value layers rather than duplicating + JavaScript semantics. + """ + + alias QuickBEAM.VM.Compiler.{Contract, Deopt} + alias QuickBEAM.VM.Compiler.ModulePool.Lease + alias QuickBEAM.VM.Execution + alias QuickBEAM.VM.Frame + alias QuickBEAM.VM.Opcodes.{Control, Locals, Stack, Values} + alias QuickBEAM.VM.Value + + @stack_operations Stack.opcodes() + @local_operations [:get_arg, :put_arg, :set_arg, :get_loc, :get_loc0_loc1, :put_loc, :set_loc] + @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] + + @type action :: + {:ok, Frame.t(), Execution.t()} + | {:deopt, Deopt.t()} + | {:error, term(), Execution.t()} + | {:error, term()} + + @doc "Returns the generated-code runtime ABI version." + @spec version() :: pos_integer() + def version, do: Contract.runtime_abi_version() + + @doc "Charges a guaranteed straight-line block or deoptimizes before it." + @spec charge_block(Lease.t(), Frame.t(), Execution.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, %Execution{memory_exceeded: true} = execution, _count), + do: {:error, {:limit_exceeded, :memory_bytes, execution.memory_limit}, execution} + + def charge_block( + %Lease{}, + %Frame{} = frame, + %Execution{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, %Execution{} = 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(), Execution.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, %Execution{} = 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 "Executes one verified pure operand-stack instruction and advances the PC." + @spec execute_stack(atom(), [term()], Frame.t(), Execution.t()) :: action() + def execute_stack(name, operands, %Frame{} = frame, %Execution{} = 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 "Executes one verified pure local or argument instruction and advances the PC." + @spec execute_local(atom(), [term()], Frame.t(), Execution.t()) :: action() + def execute_local(name, operands, %Frame{} = frame, %Execution{} = 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(), Execution.t()) :: action() + def execute_value(name, operands, %Frame{} = frame, %Execution{} = 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(), Execution.t()) :: action() + def execute_branch(name, operands, %Frame{} = frame, %Execution{} = 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}} + + @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/test/vm/compiler_generated_module_test.exs b/test/vm/compiler_generated_module_test.exs new file mode 100644 index 000000000..ca81b00ad --- /dev/null +++ b/test/vm/compiler_generated_module_test.exs @@ -0,0 +1,213 @@ +defmodule QuickBEAM.VM.CompilerGeneratedModuleTest do + use ExUnit.Case, async: false + + alias QuickBEAM.VM.Compiler.{Contract, Deopt, GeneratedModule, ModulePool, Runtime} + + alias QuickBEAM.VM.Compiler.GeneratedModule.{ + Artifact, + CodeLifecycle, + Emitter, + ImportPolicy, + Template + } + + alias QuickBEAM.VM.{Execution, Frame, Function} + + 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} = ImportPolicy.imports(artifact.binary) + + assert imports == [ + {Runtime, :deopt, 4}, + {:erlang, :get_module_info, 1}, + {:erlang, :get_module_info, 2} + ] + + assert :ok = ImportPolicy.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} = ModulePool.checkout(pool, key, deopt_template()) + assert :ok = ModulePool.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 = ModulePool.checkin(pool, lease) + assert :ok = ModulePool.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} = ModulePool.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 = ModulePool.checkin(pool, lease) + lease.module + end + + assert Enum.uniq(modules) == [hd(Contract.pool_modules())] + assert ModulePool.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} = CodeLifecycle.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} = ModulePool.checkout(pool, key(1), blocking_template()) + :ok = ModulePool.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} = ModulePool.checkout(pool, key(2), deopt_template()) + assert Process.alive?(runner) + + assert [%{status: :quarantined, reason: {:live_generated_code, ^module, :current}}] = + ModulePool.stats(pool).slots + + monitor = Process.monitor(runner) + send(runner, :release) + assert_receive {:DOWN, ^monitor, :process, ^runner, :normal} + assert :ok = CodeLifecycle.retire(module) + end + + defp start_pool(opts) do + start_supervised!( + {ModulePool, + Keyword.merge( + [backend: GeneratedModule, 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 + %Execution{ + 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_module_pool_test.exs b/test/vm/compiler_module_pool_test.exs index 3fe499e73..af461b1bb 100644 --- a/test/vm/compiler_module_pool_test.exs +++ b/test/vm/compiler_module_pool_test.exs @@ -3,14 +3,14 @@ defmodule QuickBEAM.VM.CompilerModulePoolTest do alias QuickBEAM.VM.Compiler.{Contract, ModulePool} - defmodule FakeLoader do - @moduledoc "Test loader that records compiler module-pool lifecycle calls." + defmodule FakeBackend do + @moduledoc "Test backend that records compiler module-pool lifecycle calls." - @behaviour QuickBEAM.VM.Compiler.Loader + @behaviour QuickBEAM.VM.Compiler.ModulePool.Backend @state __MODULE__.State - @doc "Returns the fake loader state process name." + @doc "Returns the fake backend state process name." def state_name, do: @state @doc "Configures the next retirement result for one module." @@ -18,7 +18,7 @@ defmodule QuickBEAM.VM.CompilerModulePoolTest do Agent.update(@state, &put_in(&1, [:retire_results, module], result)) end - @doc "Returns the recorded fake loader state." + @doc "Returns the recorded fake backend state." def state, do: Agent.get(@state, & &1) @doc "Clears recorded calls without changing configured retirement results." @@ -98,8 +98,8 @@ defmodule QuickBEAM.VM.CompilerModulePoolTest do end start_supervised!(%{ - id: FakeLoader.state_name(), - start: {Agent, :start_link, [initial_state, [name: FakeLoader.state_name()]]} + id: FakeBackend.state_name(), + start: {Agent, :start_link, [initial_state, [name: FakeBackend.state_name()]]} }) :ok @@ -137,7 +137,7 @@ defmodule QuickBEAM.VM.CompilerModulePoolTest do end assert MapSet.size(MapSet.new(results, & &1.token)) == 20 - assert FakeLoader.state().compiles[key] == 1 + assert FakeBackend.state().compiles[key] == 1 assert ModulePool.stats(pool).leases == 20 Enum.each(owners, &send(&1, :release)) @@ -164,7 +164,7 @@ defmodule QuickBEAM.VM.CompilerModulePoolTest do assert eventually(fn -> ModulePool.stats(pool).counts == %{ready: 1} end) assert ModulePool.stats(pool).leases == 0 assert {:ok, lease} = ModulePool.checkout(pool, key) - assert FakeLoader.state().compiles[key] == 1 + assert FakeBackend.state().compiles[key] == 1 assert :ok = ModulePool.checkin(pool, lease) end @@ -192,8 +192,8 @@ defmodule QuickBEAM.VM.CompilerModulePoolTest do 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(FakeLoader.state().compile_modules)) == 2 - assert length(FakeLoader.state().retires) == 98 + assert length(Enum.uniq(FakeBackend.state().compile_modules)) == 2 + assert length(FakeBackend.state().retires) == 98 assert :erlang.system_info(:atom_count) == atom_count end @@ -248,7 +248,7 @@ defmodule QuickBEAM.VM.CompilerModulePoolTest do assert {:ok, lease} = ModulePool.checkout(pool, key(1)) first_epoch = ModulePool.stats(pool).epoch - FakeLoader.put_retire_result(lease.module, {:error, :live_code_reference}) + 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) @@ -264,14 +264,14 @@ defmodule QuickBEAM.VM.CompilerModulePoolTest do pool = start_pool(capacity: 1) assert {:ok, lease} = ModulePool.checkout(pool, key(1)) assert :ok = ModulePool.checkin(pool, lease) - FakeLoader.put_retire_result(lease.module, {:error, :live_code_reference}) + FakeBackend.put_retire_result(lease.module, {:error, :live_code_reference}) assert {:error, :compiler_pool_busy} = ModulePool.checkout(pool, key(2)) assert [%{status: :quarantined, reason: :live_code_reference}] = ModulePool.stats(pool).slots - assert FakeLoader.state().retires == [lease.module] + assert FakeBackend.state().retires == [lease.module] end @tag capture_log: true @@ -338,7 +338,7 @@ defmodule QuickBEAM.VM.CompilerModulePoolTest do assert :ok = Task.await(drain) assert ModulePool.stats(pool).mode == :drained assert ModulePool.stats(pool).counts == %{free: 1} - assert FakeLoader.state().retires == [lease.module] + assert FakeBackend.state().retires == [lease.module] end test "bounds shutdown waiting without hard-purging an active slot" do @@ -346,23 +346,23 @@ defmodule QuickBEAM.VM.CompilerModulePoolTest do assert {:ok, lease} = ModulePool.checkout(pool, key(1)) assert {:error, {:compiler_pool_shutdown_timeout, 1}} = ModulePool.drain(pool, 20) - assert FakeLoader.state().retires == [] + assert FakeBackend.state().retires == [] assert ModulePool.stats(pool).mode == :draining assert :ok = ModulePool.checkin(pool, lease) assert eventually(fn -> ModulePool.stats(pool).mode == :drained end) - assert FakeLoader.state().retires == [lease.module] + assert FakeBackend.state().retires == [lease.module] end test "soft-retires every reserved module name before admitting work" do pool = start_supervised!( {ModulePool, - loader: FakeLoader, task_supervisor: QuickBEAM.VM.TaskSupervisor, capacity: 1} + backend: FakeBackend, task_supervisor: QuickBEAM.VM.TaskSupervisor, capacity: 1} ) assert Process.alive?(pool) - assert Enum.sort(FakeLoader.state().retires) == Enum.sort(Contract.pool_modules()) + assert Enum.sort(FakeBackend.state().retires) == Enum.sort(Contract.pool_modules()) assert ModulePool.stats(pool).counts == %{free: 1} end @@ -370,24 +370,30 @@ defmodule QuickBEAM.VM.CompilerModulePoolTest do pool = start_pool(capacity: 1) assert {:error, {:already_started, ^pool}} = - ModulePool.start_link(loader: FakeLoader, capacity: 1) + ModulePool.start_link(backend: FakeBackend, capacity: 1) assert {:error, {:invalid_option, :name, :another_pool}} = - ModulePool.start_link(loader: FakeLoader, name: :another_pool) + ModulePool.start_link(backend: FakeBackend, name: :another_pool) end test "validates bounded startup options and artifact keys" do assert {:error, {:invalid_artifact_key, <<1>>}} = ModulePool.checkout(self(), <<1>>) + assert {:error, {{:missing_option, :backend}, _child}} = + start_supervised({ModulePool, []}) + + assert {:error, {{:invalid_compiler_backend, String}, _child}} = + start_supervised({ModulePool, backend: String}) + assert {:error, {{:invalid_option, :capacity, 33}, _child}} = - start_supervised({ModulePool, loader: FakeLoader, capacity: 33}) + start_supervised({ModulePool, backend: FakeBackend, capacity: 33}) assert {:error, {{:invalid_option, :compile_timeout, 0}, _child}} = - start_supervised({ModulePool, loader: FakeLoader, compile_timeout: 0}) + start_supervised({ModulePool, backend: FakeBackend, compile_timeout: 0}) assert {:error, {{:invalid_option, :compile_max_heap_bytes, 0}, _child}} = - start_supervised({ModulePool, loader: FakeLoader, compile_max_heap_bytes: 0}) + start_supervised({ModulePool, backend: FakeBackend, compile_max_heap_bytes: 0}) end defp start_pool(opts) do @@ -395,12 +401,12 @@ defmodule QuickBEAM.VM.CompilerModulePoolTest do start_supervised!( {ModulePool, Keyword.merge( - [loader: FakeLoader, task_supervisor: QuickBEAM.VM.TaskSupervisor], + [backend: FakeBackend, task_supervisor: QuickBEAM.VM.TaskSupervisor], opts )} ) - FakeLoader.clear_calls() + FakeBackend.clear_calls() pool end diff --git a/test/vm/compiler_runtime_test.exs b/test/vm/compiler_runtime_test.exs new file mode 100644 index 000000000..20078da45 --- /dev/null +++ b/test/vm/compiler_runtime_test.exs @@ -0,0 +1,128 @@ +defmodule QuickBEAM.VM.CompilerRuntimeTest do + use ExUnit.Case, async: true + + alias QuickBEAM.VM.Compiler.{Contract, Deopt, Runtime} + alias QuickBEAM.VM.Compiler.ModulePool.Lease + alias QuickBEAM.VM.{Execution, Frame, Function} + + 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 + %Execution{ + 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 From 48303b4ecb75781456a3a3e4b8d669859a5995de Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 15 Jul 2026 18:19:41 +0200 Subject: [PATCH 51/87] Add first bounded pure compiler lowering --- CHANGELOG.md | 2 +- docs/beam-compiler-contract.md | 27 +- docs/beam-interpreter-architecture.md | 14 +- docs/prototype-delta-audit.md | 11 +- lib/quickbeam/vm/compiler/analysis/block.ex | 21 ++ lib/quickbeam/vm/compiler/analysis/cfg.ex | 171 ++++++++++++ lib/quickbeam/vm/compiler/generated_module.ex | 11 + .../generated_module/import_policy.ex | 1 + lib/quickbeam/vm/compiler/lowering/pure_v1.ex | 88 ++++++ lib/quickbeam/vm/compiler/runtime.ex | 63 +++++ lib/quickbeam/vm/interpreter.ex | 15 + test/vm/compiler_generated_module_test.exs | 4 + test/vm/compiler_pure_v1_test.exs | 262 ++++++++++++++++++ 13 files changed, 668 insertions(+), 22 deletions(-) create mode 100644 lib/quickbeam/vm/compiler/analysis/block.ex create mode 100644 lib/quickbeam/vm/compiler/analysis/cfg.ex create mode 100644 lib/quickbeam/vm/compiler/lowering/pure_v1.ex create mode 100644 test/vm/compiler_pure_v1_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index 5acb2ad64..24a22892b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased -- Define the optional BEAM compiler contract with binary artifact identities, a fixed 32-module atom pool, lease/purge/cache lifecycle, versioned runtime ABI boundaries, and validated owner-local before-instruction deoptimization states. Add its supervised module pool, minimal canonical runtime ABI, and structured generated-module backend with bounded emission, import policy, artifact validation, and soft-purge code lifecycle. +- Define the optional BEAM compiler contract with binary artifact identities, a fixed 32-module atom pool, lease/purge/cache lifecycle, versioned runtime ABI boundaries, and validated owner-local before-instruction deoptimization states. Add its supervised module pool, minimal canonical runtime ABI, structured generated-module backend, bounded v26 CFG analysis, and the first unspecialized `:pure_v1` lowering with explicit interpreter deoptimization. - Add `QuickBEAM.VM.measure/2` with deterministic step/logical-memory counters and endpoint process observations, plus reproducible pinned SSR concurrency, timeout, cancellation, reclamation, and single-scheduler reports. - Serialize native addon initialization, reuse cached exports for aliases in one runtime, and reject implicit cross-runtime or post-reset reinitialization with a typed error. Add `allow_reinitialization: true` as an explicit compatibility escape hatch for addons that support multiple environments. diff --git a/docs/beam-compiler-contract.md b/docs/beam-compiler-contract.md index c1938dc6a..0374e7bff 100644 --- a/docs/beam-compiler-contract.md +++ b/docs/beam-compiler-contract.md @@ -12,11 +12,13 @@ or a native fallback. QuickJS still produces bytecode, the current decoder and verifier remain authoritative, and all mutable JavaScript state remains owned by one evaluation process. -The first extraction slice is intentionally narrow: verified straight-line -basic blocks containing literals, stack movement, local reads/writes, primitive -value operations, and branches. Calls, accessors, constructors, iterators, -exceptions, Promise operations, host calls, and `await` deopt before their -instruction until their resumable compiler ABI exists. +The first extraction slice is intentionally narrow: verified basic blocks +containing literals, stack movement, local reads/writes, primitive value +operations, and terminal branches. The initial unspecialized lowering emits a +bounded immutable block plan and delegates those operations to the canonical +runtime ABI. Calls, accessors, constructors, iterators, exceptions, Promise +operations, host calls, and `await` deopt before their instruction until their +resumable compiler ABI exists. ## Non-negotiable invariants @@ -250,12 +252,17 @@ not prototype runtime modules or fallback behavior. and quarantine. 3. **Complete:** add the minimal runtime ABI, generated-module emitter and import policy, and production soft-purge code lifecycle. -4. Extract only CFG/basic-block analysis concepts and pure-block form emission. -5. Compile literals, stack/local operations, primitive values, and branches. -6. Add validated before-instruction deoptimization to the interpreter. -7. Run interpreter/compiler/native differential tests plus exact limit and +4. **Complete:** adapt bounded v26 CFG/basic-block analysis and unspecialized + `:pure_v1` block-plan emission. +5. **Complete:** execute literals, stack/local operations, primitive values, and + terminal branches through the canonical ABI. +6. **Complete:** resume validated before-instruction deoptimization in the + interpreter. +7. Expand differential tests from synthetic verified functions to decoded + JavaScript fixtures, then specialize generated forms without changing the ABI. +8. Run interpreter/compiler/native differential tests plus exact limit and scheduler gates. -8. Expand one resumable semantic family at a time. +9. Expand one resumable semantic family at a time. ## Acceptance gates diff --git a/docs/beam-interpreter-architecture.md b/docs/beam-interpreter-architecture.md index 8bb8a1576..5cd63ca9f 100644 --- a/docs/beam-interpreter-architecture.md +++ b/docs/beam-interpreter-architecture.md @@ -9,10 +9,10 @@ asynchronous `Beam.call`, logical and process memory containment, stable JavaScript errors, pinned Preact, Vue, and Svelte SSR acceptance fixtures, and bounded deterministic decoder/verifier mutation fuzzing, the bounded optional compiler contract, its supervised fixed-slot lifecycle, the minimal generated -code ABI, and a generated-module backend with import policy and soft-purge code -lifecycle. Broader +code ABI, a generated-module backend with import policy and soft-purge code +lifecycle, and the first bounded unspecialized `:pure_v1` lowering. Broader ECMAScript conformance, object-model hardening, garbage collection, compiler -lowering, and release hardening remain in progress. +specialization, and release hardening remain in progress. ## Summary @@ -846,10 +846,10 @@ limits are enforced, and the compatibility matrix is published. Only after interpreter correctness is stable: -- extract bounded CFG/basic-block analysis onto the implemented module pool, - runtime ABI, and generated-module backend specified in - [`beam-compiler-contract.md`](beam-compiler-contract.md); -- compile verified pure basic blocks to Erlang abstract forms; +- expand the implemented bounded CFG and unspecialized pure-block lowering from + synthetic verified functions to decoded JavaScript fixtures; +- specialize pure generated forms on the module pool and runtime ABI specified + in [`beam-compiler-contract.md`](beam-compiler-contract.md); - call the versioned ABI over the same canonical runtime semantics; - deopt before unsupported instructions into validated owner-local interpreter state; diff --git a/docs/prototype-delta-audit.md b/docs/prototype-delta-audit.md index 0798a2dfb..ade77c4fd 100644 --- a/docs/prototype-delta-audit.md +++ b/docs/prototype-delta-audit.md @@ -313,10 +313,13 @@ and owner-local deoptimization validation are executable contracts. The supervisor-compatible fixed-slot pool now proves leases, owner monitoring, single-flight compilation, LRU reuse, bounded shutdown, and quarantine through a fake backend. A minimal canonical runtime ABI, bounded generated-module emitter, -import policy, and soft-purge code lifecycle now pass their -acceptance tests. The compiler remains quarantined until adapted CFG/basic-block -analysis emits the first differential-tested `:pure_v1` lowering; no prototype -compiler runtime is approved for copying. +import policy, and soft-purge code lifecycle now pass their acceptance tests. +Bounded v26 CFG analysis and the first unspecialized `:pure_v1` block-plan +lowering now execute canonical pure operations and resume explicit interpreter +deoptimization with synthetic differential and exact-step tests. The compiler +remains quarantined until decoded JavaScript fixtures and specialized form +emission pass broader differential and scheduler gates; no prototype compiler +runtime is approved for copying. ## Test extraction policy 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/cfg.ex b/lib/quickbeam/vm/compiler/analysis/cfg.ex new file mode 100644 index 000000000..b2c8b8be9 --- /dev/null +++ b/lib/quickbeam/vm/compiler/analysis/cfg.ex @@ -0,0 +1,171 @@ +defmodule QuickBEAM.VM.Compiler.Analysis.CFG 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.Compiler.Analysis.Block + alias QuickBEAM.VM.{Function, Opcodes} + + @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 Opcodes.info(opcode) do + {name, _size, _pops, _pushes, _format} when is_list(operands) -> + {name, operands} = Opcodes.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/generated_module.ex b/lib/quickbeam/vm/compiler/generated_module.ex index ec9feba62..083222b6d 100644 --- a/lib/quickbeam/vm/compiler/generated_module.ex +++ b/lib/quickbeam/vm/compiler/generated_module.ex @@ -10,6 +10,9 @@ defmodule QuickBEAM.VM.Compiler.GeneratedModule do @behaviour QuickBEAM.VM.Compiler.ModulePool.Backend alias QuickBEAM.VM.Compiler.GeneratedModule.{CodeLifecycle, Emitter} + alias QuickBEAM.VM.Compiler.ModulePool + alias QuickBEAM.VM.Compiler.ModulePool.Lease + alias QuickBEAM.VM.{Execution, Frame} @impl true def compile(key, module, template), do: Emitter.emit(key, module, template) @@ -19,4 +22,12 @@ defmodule QuickBEAM.VM.Compiler.GeneratedModule do @impl true def retire(module), do: CodeLifecycle.retire(module) + + @doc "Invokes generated code after validating its owner-local active lease." + @spec invoke(ModulePool.server(), Lease.t(), Frame.t(), Execution.t()) :: term() + def invoke(pool, %Lease{} = lease, %Frame{} = frame, %Execution{} = execution) do + with :ok <- ModulePool.validate_lease(pool, lease) do + lease.module.run(lease, frame, execution) + end + end end diff --git a/lib/quickbeam/vm/compiler/generated_module/import_policy.ex b/lib/quickbeam/vm/compiler/generated_module/import_policy.ex index 73bb8ca49..819a45f8a 100644 --- a/lib/quickbeam/vm/compiler/generated_module/import_policy.ex +++ b/lib/quickbeam/vm/compiler/generated_module/import_policy.ex @@ -15,6 +15,7 @@ defmodule QuickBEAM.VM.Compiler.GeneratedModule.ImportPolicy do {Runtime, :version, 0}, {Runtime, :charge_block, 4}, {Runtime, :deopt, 4}, + {Runtime, :execute_plan, 4}, {Runtime, :execute_stack, 4}, {Runtime, :execute_local, 4}, {Runtime, :execute_value, 4}, diff --git a/lib/quickbeam/vm/compiler/lowering/pure_v1.ex b/lib/quickbeam/vm/compiler/lowering/pure_v1.ex new file mode 100644 index 000000000..106b4d108 --- /dev/null +++ b/lib/quickbeam/vm/compiler/lowering/pure_v1.ex @@ -0,0 +1,88 @@ +defmodule QuickBEAM.VM.Compiler.Lowering.PureV1 do + @moduledoc """ + Lowers verified v26 basic blocks to the first bounded compiler profile. + + The initial profile emits one generated entry function that delegates a + bounded pure block plan to the canonical compiler runtime ABI. Unsupported or + resumable instructions remain explicit before-instruction deopt boundaries. + """ + + alias QuickBEAM.VM.Compiler.Analysis.CFG + alias QuickBEAM.VM.Compiler.GeneratedModule.Template + alias QuickBEAM.VM.Compiler.Runtime + alias QuickBEAM.VM.{Function, StackVerifier} + alias QuickBEAM.VM.Opcodes.Invocation + + @max_block_instruction_count 256 + @suspension_operations [:await] ++ Invocation.opcodes() + @line 1 + + @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 <- StackVerifier.verify(function), + {:ok, blocks} <- CFG.analyze(function) do + {:ok, Map.new(blocks, &plan_block/1)} + end + end + + @doc "Emits a generated-module template for the bounded pure profile." + @spec lower(Function.t()) :: {:ok, Template.t()} | {:error, term()} + def lower(%Function{} = function) do + with {:ok, plan} <- plan(function) do + {:ok, template(plan)} + end + end + + defp plan_block(block) do + {supported, remainder} = Enum.split_while(block.instructions, &supported_instruction?/1) + capped? = length(supported) > @max_block_instruction_count + supported = Enum.take(supported, @max_block_instruction_count) + operations = Enum.map(supported, &operation/1) + next_instruction = Enum.at(block.instructions, length(supported)) + reason = boundary_reason(operations, remainder, next_instruction, capped?) + {block.start_pc, {operations, reason}} + end + + defp supported_instruction?({_pc, name, _operands}), + do: match?({:ok, _family}, Runtime.operation_family(name)) + + defp operation({_pc, name, operands}) do + {:ok, family} = Runtime.operation_family(name) + {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, _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 template(plan) do + lease = {:var, @line, :Lease} + frame = {:var, @line, :Frame} + execution = {:var, @line, :Execution} + + call = + {:call, @line, {:remote, @line, {:atom, @line, Runtime}, {:atom, @line, :execute_plan}}, + [lease, frame, execution, :erl_parse.abstract(plan)]} + + %Template{ + forms: [ + {:attribute, @line, :module, Template.placeholder_module()}, + {:attribute, @line, :export, [run: 3]}, + {:function, @line, :run, 3, [{:clause, @line, [lease, frame, execution], [], [call]}]}, + {:eof, @line} + ] + } + end +end diff --git a/lib/quickbeam/vm/compiler/runtime.ex b/lib/quickbeam/vm/compiler/runtime.ex index 357061881..c697c53ec 100644 --- a/lib/quickbeam/vm/compiler/runtime.ex +++ b/lib/quickbeam/vm/compiler/runtime.ex @@ -49,6 +49,10 @@ defmodule QuickBEAM.VM.Compiler.Runtime do :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 plan :: %{optional(non_neg_integer()) => {[operation()], Deopt.reason()}} @type action :: {:ok, Frame.t(), Execution.t()} @@ -60,6 +64,41 @@ defmodule QuickBEAM.VM.Compiler.Runtime do @spec version() :: pos_integer() def version, do: Contract.runtime_abi_version() + @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(), Execution.t(), plan()) :: action() + def execute_plan(%Lease{} = lease, %Frame{} = frame, %Execution{} = 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 + deopt(reason, lease, frame, execution) + 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}} + @doc "Charges a guaranteed straight-line block or deoptimizes before it." @spec charge_block(Lease.t(), Frame.t(), Execution.t(), pos_integer()) :: action() def charge_block(%Lease{owner: owner}, _frame, _execution, _count) when owner != self(), @@ -133,6 +172,30 @@ defmodule QuickBEAM.VM.Compiler.Runtime do def execute_branch(name, operands, _frame, _execution), do: {:error, {:unsupported_compiler_branch_operation, name, operands}} + 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 "Returns canonical JavaScript truthiness for a represented value." @spec truthy?(term()) :: boolean() def truthy?(value), do: Value.truthy?(value) diff --git a/lib/quickbeam/vm/interpreter.ex b/lib/quickbeam/vm/interpreter.ex index 6ea2285bb..717cb869c 100644 --- a/lib/quickbeam/vm/interpreter.ex +++ b/lib/quickbeam/vm/interpreter.ex @@ -12,6 +12,7 @@ defmodule QuickBEAM.VM.Interpreter do Async, AsyncBoundary, Builtins, + Compiler.Deopt, Continuation, ConstructorBoundary, Coroutine, @@ -93,6 +94,20 @@ defmodule QuickBEAM.VM.Interpreter do def resume(%Continuation{} = continuation, result), do: continuation |> resume_raw(result) |> finish() + @doc "Resumes a validated owner-local compiler deoptimization." + @spec resume_deopt(Deopt.t()) :: result() + def resume_deopt(%Deopt{} = deopt), do: deopt |> resume_deopt_raw() |> finish() + + @doc "Resumes compiler deoptimization without exporting the resulting value." + @spec resume_deopt_raw(Deopt.t()) :: + {:ok, term(), Execution.t()} | {:error, term(), Execution.t()} | {:suspended, term()} + def resume_deopt_raw(%Deopt{} = deopt) do + case Deopt.validate(deopt) do + :ok -> run(deopt.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]} diff --git a/test/vm/compiler_generated_module_test.exs b/test/vm/compiler_generated_module_test.exs index ca81b00ad..96663965b 100644 --- a/test/vm/compiler_generated_module_test.exs +++ b/test/vm/compiler_generated_module_test.exs @@ -49,6 +49,10 @@ defmodule QuickBEAM.VM.CompilerGeneratedModuleTest do assert deopt.frame == frame assert :ok = ModulePool.checkin(pool, lease) + + assert {:error, :stale_compiler_lease} = + GeneratedModule.invoke(pool, lease, frame, execution) + assert :ok = ModulePool.drain(pool) assert :code.is_loaded(lease.module) == false end diff --git a/test/vm/compiler_pure_v1_test.exs b/test/vm/compiler_pure_v1_test.exs new file mode 100644 index 000000000..8e1542ae3 --- /dev/null +++ b/test/vm/compiler_pure_v1_test.exs @@ -0,0 +1,262 @@ +defmodule QuickBEAM.VM.CompilerPureV1Test do + use ExUnit.Case, async: false + + alias QuickBEAM.VM.Compiler.Analysis.CFG + alias QuickBEAM.VM.Compiler.{Contract, Deopt, GeneratedModule, ModulePool, Runtime} + alias QuickBEAM.VM.Compiler.GeneratedModule.{Emitter, ImportPolicy} + alias QuickBEAM.VM.Compiler.Lowering.PureV1 + alias QuickBEAM.VM.{Execution, Frame, Function, Interpreter, Invocation, Opcodes, Program} + + 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 "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} + }} = PureV1.plan(function) + + assert {:ok, template} = PureV1.lower(function) + module = hd(Contract.pool_modules()) + assert {:ok, artifact} = Emitter.emit(key(1), module, template) + + assert {:ok, imports} = ImportPolicy.imports(artifact.binary) + assert {Runtime, :execute_plan, 4} in imports + end + + test "lowering unbounded function values does not allocate per-program atoms" do + function = arithmetic_function() + assert {:ok, _template} = PureV1.lower(function) + 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} = PureV1.lower(%{function | id: value, instructions: instructions}) + end + + 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} + }} = PureV1.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}}} = PureV1.plan(function) + assert length(operations) == 256 + 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} = PureV1.lower(function) + assert {:ok, artifact_key} = Contract.artifact_key(program, function) + assert {:ok, lease} = ModulePool.checkout(pool, artifact_key, template) + + frame = frame(function) + execution = execution(4) + + assert {:deopt, %Deopt{} = deopt} = + GeneratedModule.invoke(pool, lease, frame, execution) + + assert deopt.reason == :unsupported_opcode + assert deopt.frame.pc == 3 + assert deopt.frame.stack == [42] + assert deopt.execution.remaining_steps == 1 + assert :ok = ModulePool.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} = PureV1.lower(function) + assert {:ok, artifact_key} = Contract.artifact_key(program, function) + assert {:ok, lease} = ModulePool.checkout(pool, artifact_key, template) + + assert {:deopt, %Deopt{} = deopt} = + GeneratedModule.invoke(pool, lease, frame(function), execution(3)) + + assert deopt.execution.remaining_steps == 0 + assert :ok = ModulePool.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 "lowers decoded v26 arithmetic before resuming its unsupported return" do + assert {:ok, %Program{root: function} = program} = QuickBEAM.VM.compile("40 + 2") + pool = start_pool() + + assert {:ok, template} = PureV1.lower(function) + assert {:ok, artifact_key} = Contract.artifact_key(program, function) + assert {:ok, lease} = ModulePool.checkout(pool, artifact_key, template) + + frame = Invocation.new_frame(function, function, [], :undefined, {}) + execution = %{execution(100) | atoms: program.atoms} + + assert {:deopt, %Deopt{} = deopt} = + GeneratedModule.invoke(pool, lease, frame, execution) + + assert deopt.frame.pc > 0 + assert :ok = ModulePool.checkin(pool, lease) + assert Interpreter.resume_deopt(deopt) == QuickBEAM.VM.eval(program, max_steps: 100) + end + + test "executes a compiled branch before deoptimizing at its selected successor" do + function = branch_function() + program = program(function) + pool = start_pool() + + assert {:ok, template} = PureV1.lower(function) + assert {:ok, artifact_key} = Contract.artifact_key(program, function) + assert {:ok, lease} = ModulePool.checkout(pool, artifact_key, template) + + assert {:deopt, %Deopt{} = deopt} = + GeneratedModule.invoke(pool, lease, frame(function), execution(10)) + + assert deopt.reason == :unsupported_semantics + assert deopt.frame.pc == 2 + assert deopt.frame.stack == [] + assert deopt.execution.remaining_steps == 8 + assert :ok = ModulePool.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!( + {ModulePool, + backend: GeneratedModule, 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: {Opcodes.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 + %Execution{ + atoms: {}, + max_stack_depth: 32, + remaining_steps: steps, + step_limit: steps + } + end + + defp key(integer), do: :crypto.hash(:sha256, <>) +end From 10bb81c8a038c61b2017875af1d160839ab8a8da Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 15 Jul 2026 19:01:41 +0200 Subject: [PATCH 52/87] Specialize pure compiler block forms --- CHANGELOG.md | 2 +- docs/beam-compiler-contract.md | 29 +-- docs/beam-interpreter-architecture.md | 14 +- docs/prototype-delta-audit.md | 12 +- .../generated_module/import_policy.ex | 1 + lib/quickbeam/vm/compiler/lowering/pure_v1.ex | 203 ++++++++++++++++-- lib/quickbeam/vm/compiler/runtime.ex | 17 +- test/vm/compiler_pure_v1_test.exs | 98 ++++++++- 8 files changed, 324 insertions(+), 52 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 24a22892b..668d55e60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased -- Define the optional BEAM compiler contract with binary artifact identities, a fixed 32-module atom pool, lease/purge/cache lifecycle, versioned runtime ABI boundaries, and validated owner-local before-instruction deoptimization states. Add its supervised module pool, minimal canonical runtime ABI, structured generated-module backend, bounded v26 CFG analysis, and the first unspecialized `:pure_v1` lowering with explicit interpreter deoptimization. +- Define the optional BEAM compiler contract with binary artifact identities, a fixed 32-module atom pool, lease/purge/cache lifecycle, versioned runtime ABI boundaries, and validated owner-local before-instruction deoptimization states. Add its supervised module pool, minimal canonical runtime ABI, structured generated-module backend, bounded v26 CFG analysis, and specialized fixed-name `:pure_v1` forms with explicit interpreter deoptimization. - Add `QuickBEAM.VM.measure/2` with deterministic step/logical-memory counters and endpoint process observations, plus reproducible pinned SSR concurrency, timeout, cancellation, reclamation, and single-scheduler reports. - Serialize native addon initialization, reuse cached exports for aliases in one runtime, and reject implicit cross-runtime or post-reset reinitialization with a typed error. Add `allow_reinitialization: true` as an explicit compatibility escape hatch for addons that support multiple environments. diff --git a/docs/beam-compiler-contract.md b/docs/beam-compiler-contract.md index 0374e7bff..5ce514d05 100644 --- a/docs/beam-compiler-contract.md +++ b/docs/beam-compiler-contract.md @@ -14,9 +14,10 @@ one evaluation process. The first extraction slice is intentionally narrow: verified basic blocks containing literals, stack movement, local reads/writes, primitive value -operations, and terminal branches. The initial unspecialized lowering emits a -bounded immutable block plan and delegates those operations to the canonical -runtime ABI. Calls, accessors, constructors, iterators, exceptions, Promise +operations, and terminal branches. Lowering first builds a bounded immutable +block plan, then emits specialized fixed-name `run/3`, `block/4`, and `step/4` +clauses whose operations call the canonical runtime ABI. Calls, accessors, +constructors, iterators, exceptions, Promise operations, host calls, and `await` deopt before their instruction until their resumable compiler ABI exists. @@ -172,9 +173,12 @@ charges. The compiled path runs in the same monitored evaluation process, so process heap limits, outer timeout, handler ownership, and cancellation remain unchanged. -Generated basic blocks are capped at 256 QuickJS instructions and call another -module function at control-flow edges. The existing `+S 1:1` ticker-gap and -timeout report remains a regression gate for compiled execution. +Generated basic blocks are capped at 256 QuickJS instructions and one function +artifact at 4,096 blocks and 4,096 lowered instructions. The initial profile +deoptimizes at a +control-flow edge rather than recursively running another JavaScript block. The +existing `+S 1:1` ticker-gap and timeout report remains a regression gate for +compiled execution. ## Deoptimization state @@ -252,16 +256,17 @@ not prototype runtime modules or fallback behavior. and quarantine. 3. **Complete:** add the minimal runtime ABI, generated-module emitter and import policy, and production soft-purge code lifecycle. -4. **Complete:** adapt bounded v26 CFG/basic-block analysis and unspecialized - `:pure_v1` block-plan emission. +4. **Complete:** adapt bounded v26 CFG/basic-block analysis and deterministic + `:pure_v1` block plans. 5. **Complete:** execute literals, stack/local operations, primitive values, and terminal branches through the canonical ABI. 6. **Complete:** resume validated before-instruction deoptimization in the interpreter. -7. Expand differential tests from synthetic verified functions to decoded - JavaScript fixtures, then specialize generated forms without changing the ABI. -8. Run interpreter/compiler/native differential tests plus exact limit and - scheduler gates. +7. **Complete for the initial pure subset:** emit specialized fixed-name block + and step clauses without dynamic atoms, and cover decoded expressions plus + function arguments/locals differentially against the interpreter. +8. Expand decoded JavaScript and native differential coverage, then run exact + limit and scheduler gates. 9. Expand one resumable semantic family at a time. ## Acceptance gates diff --git a/docs/beam-interpreter-architecture.md b/docs/beam-interpreter-architecture.md index 5cd63ca9f..82c144baf 100644 --- a/docs/beam-interpreter-architecture.md +++ b/docs/beam-interpreter-architecture.md @@ -10,9 +10,9 @@ JavaScript errors, pinned Preact, Vue, and Svelte SSR acceptance fixtures, and bounded deterministic decoder/verifier mutation fuzzing, the bounded optional compiler contract, its supervised fixed-slot lifecycle, the minimal generated code ABI, a generated-module backend with import policy and soft-purge code -lifecycle, and the first bounded unspecialized `:pure_v1` lowering. Broader -ECMAScript conformance, object-model hardening, garbage collection, compiler -specialization, and release hardening remain in progress. +lifecycle, and specialized fixed-name forms for the first bounded `:pure_v1` +lowering. Broader ECMAScript conformance, object-model hardening, garbage +collection, compiler coverage, and release hardening remain in progress. ## Summary @@ -846,10 +846,10 @@ limits are enforced, and the compatibility matrix is published. Only after interpreter correctness is stable: -- expand the implemented bounded CFG and unspecialized pure-block lowering from - synthetic verified functions to decoded JavaScript fixtures; -- specialize pure generated forms on the module pool and runtime ABI specified - in [`beam-compiler-contract.md`](beam-compiler-contract.md); +- expand the implemented bounded CFG and specialized pure-block lowering across + decoded JavaScript and native differential fixtures; +- retain the module pool, fixed generated names, runtime ABI, and deoptimization + contract specified in [`beam-compiler-contract.md`](beam-compiler-contract.md); - call the versioned ABI over the same canonical runtime semantics; - deopt before unsupported instructions into validated owner-local interpreter state; diff --git a/docs/prototype-delta-audit.md b/docs/prototype-delta-audit.md index ade77c4fd..1ce2227a0 100644 --- a/docs/prototype-delta-audit.md +++ b/docs/prototype-delta-audit.md @@ -314,12 +314,12 @@ supervisor-compatible fixed-slot pool now proves leases, owner monitoring, single-flight compilation, LRU reuse, bounded shutdown, and quarantine through a fake backend. A minimal canonical runtime ABI, bounded generated-module emitter, import policy, and soft-purge code lifecycle now pass their acceptance tests. -Bounded v26 CFG analysis and the first unspecialized `:pure_v1` block-plan -lowering now execute canonical pure operations and resume explicit interpreter -deoptimization with synthetic differential and exact-step tests. The compiler -remains quarantined until decoded JavaScript fixtures and specialized form -emission pass broader differential and scheduler gates; no prototype compiler -runtime is approved for copying. +Bounded v26 CFG analysis now produces deterministic `:pure_v1` plans and +specialized fixed-name block/step forms. Canonical pure operations resume +explicit interpreter deoptimization with synthetic, decoded-expression, +argument/local, and exact-step differential tests. The compiler remains +quarantined until broader decoded/native fixtures and scheduler gates pass; no +prototype compiler runtime is approved for copying. ## Test extraction policy diff --git a/lib/quickbeam/vm/compiler/generated_module/import_policy.ex b/lib/quickbeam/vm/compiler/generated_module/import_policy.ex index 819a45f8a..6a0d660d2 100644 --- a/lib/quickbeam/vm/compiler/generated_module/import_policy.ex +++ b/lib/quickbeam/vm/compiler/generated_module/import_policy.ex @@ -20,6 +20,7 @@ defmodule QuickBEAM.VM.Compiler.GeneratedModule.ImportPolicy do {Runtime, :execute_local, 4}, {Runtime, :execute_value, 4}, {Runtime, :execute_branch, 4}, + {Runtime, :frame_pc, 1}, {Runtime, :truthy?, 1}, {Runtime, :unary, 2}, {Runtime, :binary, 3} diff --git a/lib/quickbeam/vm/compiler/lowering/pure_v1.ex b/lib/quickbeam/vm/compiler/lowering/pure_v1.ex index 106b4d108..a973cdca3 100644 --- a/lib/quickbeam/vm/compiler/lowering/pure_v1.ex +++ b/lib/quickbeam/vm/compiler/lowering/pure_v1.ex @@ -2,9 +2,9 @@ defmodule QuickBEAM.VM.Compiler.Lowering.PureV1 do @moduledoc """ Lowers verified v26 basic blocks to the first bounded compiler profile. - The initial profile emits one generated entry function that delegates a - bounded pure block plan to the canonical compiler runtime ABI. Unsupported or - resumable instructions remain explicit before-instruction deopt boundaries. + 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.Compiler.Analysis.CFG @@ -14,6 +14,8 @@ defmodule QuickBEAM.VM.Compiler.Lowering.PureV1 do alias QuickBEAM.VM.Opcodes.Invocation @max_block_instruction_count 256 + @max_block_count 4_096 + @max_lowered_instruction_count 4_096 @suspension_operations [:await] ++ Invocation.opcodes() @line 1 @@ -21,12 +23,14 @@ defmodule QuickBEAM.VM.Compiler.Lowering.PureV1 do @spec plan(Function.t()) :: {:ok, Runtime.plan()} | {:error, term()} def plan(%Function{} = function) do with :ok <- StackVerifier.verify(function), - {:ok, blocks} <- CFG.analyze(function) do - {:ok, Map.new(blocks, &plan_block/1)} + {:ok, blocks} <- CFG.analyze(function), + plan = Map.new(blocks, &plan_block/1), + :ok <- validate_plan_size(plan) do + {:ok, plan} end end - @doc "Emits a generated-module template for the bounded pure profile." + @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} <- plan(function) do @@ -67,22 +71,183 @@ defmodule QuickBEAM.VM.Compiler.Lowering.PureV1 do defp deopt_reason(name) when name in @suspension_operations, do: :suspension_boundary defp deopt_reason(_name), do: :unsupported_opcode - defp template(plan) do - lease = {:var, @line, :Lease} - frame = {:var, @line, :Frame} - execution = {:var, @line, :Execution} + 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 - call = - {:call, @line, {:remote, @line, {:atom, @line, Runtime}, {:atom, @line, :execute_plan}}, - [lease, frame, execution, :erl_parse.abstract(plan)]} + defp template(plan) do + step_forms = if has_operations?(plan), do: [step_form(plan)], else: [] %Template{ - forms: [ - {:attribute, @line, :module, Template.placeholder_module()}, - {:attribute, @line, :export, [run: 3]}, - {:function, @line, :run, 3, [{:clause, @line, [lease, frame, execution], [], [call]}]}, - {:eof, @line} - ] + forms: + [ + {:attribute, @line, :module, Template.placeholder_module()}, + {:attribute, @line, :export, [run: 3]}, + run_form(), + block_form(plan) + ] ++ + step_forms ++ [{:eof, @line}] } end + + defp has_operations?(plan), + do: Enum.any?(plan, fn {_pc, {operations, _reason}} -> 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 + 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.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) + ], + [deopt_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 deopt_call(reason) do + remote_call(Runtime, :deopt, [ + atom(reason), + variable(:Lease), + variable(:Frame), + variable(: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/runtime.ex b/lib/quickbeam/vm/compiler/runtime.ex index c697c53ec..4fbf0546f 100644 --- a/lib/quickbeam/vm/compiler/runtime.ex +++ b/lib/quickbeam/vm/compiler/runtime.ex @@ -16,7 +16,18 @@ defmodule QuickBEAM.VM.Compiler.Runtime do alias QuickBEAM.VM.Value @stack_operations Stack.opcodes() - @local_operations [:get_arg, :put_arg, :set_arg, :get_loc, :get_loc0_loc1, :put_loc, :set_loc] + @local_operations [ + :get_arg, + :put_arg, + :set_arg, + :get_loc, + :get_loc0_loc1, + :put_loc, + :set_loc, + :set_loc_uninitialized, + :put_loc_check_init, + :put_loc_check + ] @value_operations [ :add, :sub, @@ -64,6 +75,10 @@ defmodule QuickBEAM.VM.Compiler.Runtime do @spec version() :: pos_integer() def version, do: Contract.runtime_abi_version() + @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} diff --git a/test/vm/compiler_pure_v1_test.exs b/test/vm/compiler_pure_v1_test.exs index 8e1542ae3..accaa24fa 100644 --- a/test/vm/compiler_pure_v1_test.exs +++ b/test/vm/compiler_pure_v1_test.exs @@ -57,16 +57,28 @@ defmodule QuickBEAM.VM.CompilerPureV1Test do }} = PureV1.plan(function) assert {:ok, template} = PureV1.lower(function) + + assert [:run, :block, :step] == + 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} = ImportPolicy.imports(artifact.binary) - assert {Runtime, :execute_plan, 4} in imports + assert {Runtime, :charge_block, 4} in imports + assert {Runtime, :execute_stack, 4} in imports + assert {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() - assert {:ok, _template} = PureV1.lower(function) + + for value <- 1..100 do + instructions = put_elem(function.instructions, 0, instruction(:push_i32, [value])) + assert {:ok, _template} = PureV1.lower(%{function | id: value, instructions: instructions}) + end + atom_count = :erlang.system_info(:atom_count) for value <- 1..10_000 do @@ -77,6 +89,25 @@ defmodule QuickBEAM.VM.CompilerPureV1Test do 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} = PureV1.lower(function) + assert {:ok, artifact_key} = Contract.artifact_key(program(function), function) + assert {:ok, lease} = ModulePool.checkout(pool, artifact_key, template) + assert :ok = ModulePool.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, @@ -105,6 +136,32 @@ defmodule QuickBEAM.VM.CompilerPureV1Test do 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}} = + PureV1.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}} = + PureV1.lower(function) + end + test "runs a lowered pure prefix and resumes the interpreter before return" do function = arithmetic_function() program = program(function) @@ -116,10 +173,16 @@ defmodule QuickBEAM.VM.CompilerPureV1Test do frame = frame(function) execution = execution(4) + assert {:ok, plan} = PureV1.plan(function) + + assert {:deopt, %Deopt{} = unspecialized} = + Runtime.execute_plan(lease, frame, execution, plan) assert {:deopt, %Deopt{} = deopt} = GeneratedModule.invoke(pool, lease, frame, execution) + assert deopt.frame == unspecialized.frame + assert deopt.execution == unspecialized.execution assert deopt.reason == :unsupported_opcode assert deopt.frame.pc == 3 assert deopt.frame.stack == [42] @@ -150,15 +213,38 @@ defmodule QuickBEAM.VM.CompilerPureV1Test do assert Interpreter.eval(program, max_steps: 3) == expected end - test "lowers decoded v26 arithmetic before resuming its unsupported return" do - assert {:ok, %Program{root: function} = program} = QuickBEAM.VM.compile("40 + 2") + test "matches the interpreter across decoded v26 pure expressions" do + pool = start_pool() + + for source <- ["40 + 2", "6 * 7", "10 > 3", "true ? 11 : 22", "(5 << 2) | 1"] do + assert {:ok, %Program{root: function} = program} = QuickBEAM.VM.compile(source) + assert {:ok, template} = PureV1.lower(function) + assert {:ok, artifact_key} = Contract.artifact_key(program, function) + assert {:ok, lease} = ModulePool.checkout(pool, artifact_key, template) + + frame = Invocation.new_frame(function, function, [], :undefined, {}) + execution = %{execution(100) | atoms: program.atoms} + + assert {:deopt, %Deopt{} = deopt} = + GeneratedModule.invoke(pool, lease, frame, execution) + + assert deopt.frame.pc > 0 + assert :ok = ModulePool.checkin(pool, lease) + assert Interpreter.resume_deopt(deopt) == QuickBEAM.VM.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} = PureV1.lower(function) assert {:ok, artifact_key} = Contract.artifact_key(program, function) assert {:ok, lease} = ModulePool.checkout(pool, artifact_key, template) - frame = Invocation.new_frame(function, function, [], :undefined, {}) + frame = Invocation.new_frame(function, function, [20, 1], :undefined, {}) execution = %{execution(100) | atoms: program.atoms} assert {:deopt, %Deopt{} = deopt} = @@ -166,7 +252,7 @@ defmodule QuickBEAM.VM.CompilerPureV1Test do assert deopt.frame.pc > 0 assert :ok = ModulePool.checkin(pool, lease) - assert Interpreter.resume_deopt(deopt) == QuickBEAM.VM.eval(program, max_steps: 100) + assert Interpreter.resume_deopt(deopt) == {:ok, 42} end test "executes a compiled branch before deoptimizing at its selected successor" do From cad39310d5dca26d3a0d4a85de9aa49b47a80921 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 15 Jul 2026 22:16:00 +0200 Subject: [PATCH 53/87] Add compiler orchestration and scheduler gates --- CHANGELOG.md | 2 +- bench/README.md | 12 +- bench/vm_scheduler_probe.exs | 45 ++++- docs/beam-compiler-contract.md | 18 +- docs/beam-compiler-scheduler-measurements.md | 36 ++++ docs/beam-interpreter-architecture.md | 21 ++- docs/prototype-delta-audit.md | 9 +- lib/quickbeam/vm.ex | 64 +++++-- lib/quickbeam/vm/compiler.ex | 117 ++++++++++++ lib/quickbeam/vm/evaluator.ex | 14 +- lib/quickbeam/vm/interpreter.ex | 14 +- mix.exs | 3 + test/vm/compiler_orchestration_test.exs | 176 +++++++++++++++++++ 13 files changed, 482 insertions(+), 49 deletions(-) create mode 100644 docs/beam-compiler-scheduler-measurements.md create mode 100644 lib/quickbeam/vm/compiler.ex create mode 100644 test/vm/compiler_orchestration_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index 668d55e60..06e93bd75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased -- Define the optional BEAM compiler contract with binary artifact identities, a fixed 32-module atom pool, lease/purge/cache lifecycle, versioned runtime ABI boundaries, and validated owner-local before-instruction deoptimization states. Add its supervised module pool, minimal canonical runtime ABI, structured generated-module backend, bounded v26 CFG analysis, and specialized fixed-name `:pure_v1` forms with explicit interpreter deoptimization. +- Define the optional BEAM compiler contract with binary artifact identities, a fixed 32-module atom pool, lease/purge/cache lifecycle, versioned runtime ABI boundaries, and validated owner-local before-instruction deoptimization states. Add its supervised module pool, minimal canonical runtime ABI, structured generated-module backend, bounded v26 CFG analysis, specialized fixed-name `:pure_v1` forms, and explicit release-quarantined `engine: :compiler` orchestration with native, Preact SSR, resource, concurrency, measurement, and single-scheduler acceptance coverage. - Add `QuickBEAM.VM.measure/2` with deterministic step/logical-memory counters and endpoint process observations, plus reproducible pinned SSR concurrency, timeout, cancellation, reclamation, and single-scheduler reports. - Serialize native addon initialization, reuse cached exports for aliases in one runtime, and reject implicit cross-runtime or post-reset reinitialization with a typed error. Add `allow_reinitialization: true` as an explicit compatibility escape hatch for addons that support multiple environments. diff --git a/bench/README.md b/bench/README.md index 4e845e04b..36b35467d 100644 --- a/bench/README.md +++ b/bench/README.md @@ -24,9 +24,13 @@ MIX_ENV=bench mix run bench/concurrent.exs MIX_ENV=bench mix run bench/vm_ssr.exs \ --output docs/beam-ssr-measurements.md -# Reproduce the single-scheduler fairness/timeout probe +# Reproduce the interpreter single-scheduler fairness/timeout probe ERL_FLAGS='+S 1:1' MIX_ENV=bench mix run bench/vm_scheduler_probe.exs \ --output docs/beam-scheduler-measurements.md + +# Reproduce the release-quarantined compiler-tier probe +ERL_FLAGS='+S 1:1' MIX_ENV=bench mix run bench/vm_scheduler_probe.exs \ + --engine compiler --output docs/beam-compiler-scheduler-measurements.md ``` The SSR runner accepts `--samples`, `--warmup`, and a comma-separated @@ -34,8 +38,10 @@ The SSR runner accepts `--samples`, `--warmup`, and a comma-separated endpoint BEAM process observations, sequential latency, concurrent throughput, and timeout/cancellation behavior for the pinned Preact, Vue, and Svelte fixtures. Published results are in -[`docs/beam-ssr-measurements.md`](../docs/beam-ssr-measurements.md) and -[`docs/beam-scheduler-measurements.md`](../docs/beam-scheduler-measurements.md). +[`docs/beam-ssr-measurements.md`](../docs/beam-ssr-measurements.md), +[`docs/beam-scheduler-measurements.md`](../docs/beam-scheduler-measurements.md), +and +[`docs/beam-compiler-scheduler-measurements.md`](../docs/beam-compiler-scheduler-measurements.md). ## Results diff --git a/bench/vm_scheduler_probe.exs b/bench/vm_scheduler_probe.exs index 5c17806e9..b7debbf14 100644 --- a/bench/vm_scheduler_probe.exs +++ b/bench/vm_scheduler_probe.exs @@ -1,6 +1,8 @@ defmodule QuickBEAM.Bench.VMSchedulerProbe do @moduledoc "Single-scheduler fairness and timeout probe for the BEAM VM." + alias QuickBEAM.VM.Compiler + @fixture "test/fixtures/vm/vue_ssr.js" @bundle_opts [ format: :esm, @@ -24,7 +26,9 @@ defmodule QuickBEAM.Bench.VMSchedulerProbe do def run(args) do {opts, positional, invalid} = - OptionParser.parse(args, strict: [samples: :integer, output: :string]) + OptionParser.parse(args, + strict: [engine: :string, samples: :integer, output: :string] + ) if positional != [] or invalid != [], do: raise(ArgumentError, "invalid arguments: #{inspect(positional ++ invalid)}") @@ -34,12 +38,16 @@ defmodule QuickBEAM.Bench.VMSchedulerProbe do end samples = positive!(Keyword.get(opts, :samples, 10), :samples) + engine = engine!(Keyword.get(opts, :engine, "interpreter")) + maybe_start_compiler!(engine) fixture = compile_fixture!() - Enum.each(1..2, fn _iteration -> render!(fixture) end) + Enum.each(1..2, fn _iteration -> render!(fixture, engine) end) render_observations = - Enum.map(1..samples, fn _iteration -> observe_ticker(fn -> render!(fixture) end) end) + Enum.map(1..samples, fn _iteration -> + observe_ticker(fn -> render!(fixture, engine) 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) @@ -55,6 +63,7 @@ defmodule QuickBEAM.Bench.VMSchedulerProbe do Enum.map(1..samples, fn _iteration -> {:ok, measurement} = QuickBEAM.VM.measure(timeout_program, + engine: engine, max_steps: 1_000_000_000, timeout: 50 ) @@ -71,7 +80,7 @@ defmodule QuickBEAM.Bench.VMSchedulerProbe do enforce_gates!(render_summary, timeout_summary) report = - report(samples, baseline_ms, render_summary, baseline_summary, timeout_summary) + report(engine, samples, baseline_ms, render_summary, baseline_summary, timeout_summary) IO.write(report) @@ -93,11 +102,14 @@ defmodule QuickBEAM.Bench.VMSchedulerProbe do program end - defp render!(fixture) do + defp render!(fixture, engine) do handler = fn [] -> fixture.props end {:ok, measurement} = - QuickBEAM.VM.measure(fixture.program, [handlers: %{"load_props" => handler}] ++ @eval_opts) + QuickBEAM.VM.measure( + fixture.program, + [engine: engine, handlers: %{"load_props" => handler}] ++ @eval_opts + ) unless match?({:ok, _rendered}, measurement.result), do: raise("Vue scheduler probe failed: #{inspect(measurement.result)}") @@ -174,14 +186,15 @@ defmodule QuickBEAM.Bench.VMSchedulerProbe do do: raise("timeout p95 #{timeout.p95} µs exceeded #{@max_timeout_wall_us} µs") end - defp report(samples, baseline_ms, render, baseline, timeout) do + defp report(engine, samples, baseline_ms, render, baseline, timeout) do """ # BEAM VM single-scheduler probe 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 interpreter work. + time, allowing the same ticker to run without #{engine} work. + - Engine: #{engine} - Git base: `#{command("git", ["rev-parse", "--short", "HEAD"])}` - Working tree at measurement: #{tree_state()} - Generated: #{DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601()} @@ -254,6 +267,22 @@ defmodule QuickBEAM.Bench.VMSchedulerProbe do 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 maybe_start_compiler!(:interpreter), do: :ok + + defp maybe_start_compiler!(:compiler) do + case Compiler.start_link(capacity: 8) 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), diff --git a/docs/beam-compiler-contract.md b/docs/beam-compiler-contract.md index 5ce514d05..43df57497 100644 --- a/docs/beam-compiler-contract.md +++ b/docs/beam-compiler-contract.md @@ -215,17 +215,27 @@ only as a distinct protocol carrying an explicit completed semantic action. ## Public execution policy -`QuickBEAM.VM.eval/2` remains the interpreter API. Compiler execution will be an -explicit option or API. The first compiler mode does not silently interpret an -unsupported whole program. A compiled basic block may return the documented +`QuickBEAM.VM.eval/2` remains interpreter-first. The optional tier is selected +with `engine: :compiler` after supervising `QuickBEAM.VM.Compiler`; the default +remains `engine: :interpreter`. A compiled basic block may return the documented deoptimization action because that transition is part of the selected compiler engine. Capacity, compile-task, load, stale-lease, and purge failures remain -typed compiler errors. +typed compiler errors rather than silently restarting the whole program in the +interpreter. + +The orchestration API is present for acceptance testing but remains release +quarantined until the pinned resource, scheduler, and broader native +differential gates pass. An adaptive policy, if added, must be explicitly selected by the caller and reported by measurement/telemetry. It still may never fall back to native QuickJS. +The current `+S 1:1` compiler-tier Vue probe reports a 35.48 ms maximum ticker +gap against the 75 ms bound and a 51.0 ms timeout p95 against the 60 ms bound. +These fixture-specific results satisfy the initial scheduler gate but do not +remove the broader release quarantine. + ## Prototype analysis map Prototype code is not copied as a subsystem. Each part has one bounded target: diff --git a/docs/beam-compiler-scheduler-measurements.md b/docs/beam-compiler-scheduler-measurements.md new file mode 100644 index 000000000..12c7e0e4e --- /dev/null +++ b/docs/beam-compiler-scheduler-measurements.md @@ -0,0 +1,36 @@ +# BEAM VM single-scheduler probe + +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 compiler work. + +- Engine: compiler +- Git base: `10bb81c8` +- Working tree at measurement: modified +- Generated: 2026-07-15T20:07:45Z +- Elixir: 1.20.2 +- OTP: 29 +- ERTS: 17.0.2 +- OS: Linux 7.0.0-27-generic +- Architecture: x86_64-pc-linux-gnu +- CPU: AMD Ryzen 9 9950X 16-Core Processor +- Online schedulers: 1 +- Vue probe memory limit: 512 MB +- Samples: 10 + +| workload | wall median | wall p95 | ticker gap median | ticker gap p95 | ticker gap max | ticks median | +|---|---:|---:|---:|---:|---:|---:| +| Vue SSR | 180.38 ms | 217.78 ms | 2.0 ms | 8.2 ms | 35.48 ms | 65 | +| sleep baseline (180 ms target) | 180.95 ms | 180.98 ms | 2.0 ms | 2.01 ms | 6.2 ms | 90 | + +Acceptance bound: Vue SSR ticker gap ≤ 75.0 ms. + +## 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 | 50.98 ms | 51.0 ms | 51.0 ms | 975 µs | + +Acceptance bound: timeout p95 ≤ 60.0 ms. diff --git a/docs/beam-interpreter-architecture.md b/docs/beam-interpreter-architecture.md index 82c144baf..4d227a822 100644 --- a/docs/beam-interpreter-architecture.md +++ b/docs/beam-interpreter-architecture.md @@ -757,8 +757,11 @@ Minimum scheduler acceptance scenarios: The reproducible fixture measurements are published in [`beam-ssr-measurements.md`](beam-ssr-measurements.md), with the `+S 1:1` -fairness and timeout gate in -[`beam-scheduler-measurements.md`](beam-scheduler-measurements.md). The reports +fairness and timeout gates in +[`beam-scheduler-measurements.md`](beam-scheduler-measurements.md) and the +release-quarantined compiler run in +[`beam-compiler-scheduler-measurements.md`](beam-compiler-scheduler-measurements.md). +The reports separate deterministic VM steps/logical allocation from endpoint BEAM process observations, wall latency, concurrent throughput, and cancellation. Current single-scheduler acceptance bounds are a maximum 75 ms ticker gap during the @@ -779,6 +782,20 @@ Do not automatically fall back between engines. Silent fallback hides semantic and scheduling differences. If an application wants fallback, it can choose it explicitly after handling an unsupported-feature error. +The optional compiler tier has an explicit, release-quarantined orchestration +path: + +```elixir +children = [{QuickBEAM.VM.Compiler, capacity: 8}] +QuickBEAM.VM.eval(program, engine: :compiler) +``` + +The compiler runs one bounded specialized pure block and may deopt at a verified +instruction boundary into the interpreter. Compiler infrastructure failures are +typed errors and never restart the program or invoke native QuickJS. The default +`QuickBEAM.VM` path remains the interpreter until compiler resource, scheduler, +and native differential gates are published. + After the SSR profile is stable, a convenience integration may preload programs for Phoenix rendering. A stateful `QuickBEAM.VM.Session` and an optimizing BEAM compiler are later features with separate acceptance gates. diff --git a/docs/prototype-delta-audit.md b/docs/prototype-delta-audit.md index 1ce2227a0..d8fe4a1a1 100644 --- a/docs/prototype-delta-audit.md +++ b/docs/prototype-delta-audit.md @@ -317,9 +317,12 @@ import policy, and soft-purge code lifecycle now pass their acceptance tests. Bounded v26 CFG analysis now produces deterministic `:pure_v1` plans and specialized fixed-name block/step forms. Canonical pure operations resume explicit interpreter deoptimization with synthetic, decoded-expression, -argument/local, and exact-step differential tests. The compiler remains -quarantined until broader decoded/native fixtures and scheduler gates pass; no -prototype compiler runtime is approved for copying. +argument/local, and exact-step differential tests. Explicit supervised +orchestration now exercises isolated evaluation, measurement, asynchronous +handler deoptimization, native pure-expression parity, timeout/memory/step +containment, and concurrent cache reuse. The compiler remains release +quarantined until broader SSR/native fixtures and single-scheduler gates pass; +no prototype compiler runtime is approved for copying. ## Test extraction policy diff --git a/lib/quickbeam/vm.ex b/lib/quickbeam/vm.ex index 13e1fb61f..bca5d575a 100644 --- a/lib/quickbeam/vm.ex +++ b/lib/quickbeam/vm.ex @@ -6,7 +6,7 @@ defmodule QuickBEAM.VM do heap, Promise state, host operations, and resource limits. """ - alias QuickBEAM.VM.{ABI, Decoder, Evaluator, Function, Measurement, Program, Verifier} + alias QuickBEAM.VM.{ABI, Compiler, Decoder, Evaluator, Function, Measurement, Program, Verifier} @type program :: QuickBEAM.VM.Program.t() @@ -94,18 +94,19 @@ defmodule QuickBEAM.VM do @doc """ Evaluates a verified program in an isolated BEAM process. - Supported options include `:vars`, asynchronous `:handlers`, the builtin - `:profile` (`:core` or `:ssr`), `:timeout`, `:max_steps`, `:max_stack_depth`, and the JavaScript allocation budget - `:memory_limit`. Isolated workers also receive a BEAM process heap ceiling. - `isolation: :caller` is available for - trusted diagnostics. + Supported options include the explicit `:engine` (`:interpreter` or + `:compiler`), `:vars`, asynchronous `:handlers`, the builtin `:profile` + (`:core` or `:ssr`), `:timeout`, `:max_steps`, `:max_stack_depth`, and the + JavaScript allocation budget `:memory_limit`. Isolated workers also receive a + BEAM process heap ceiling. `isolation: :caller` is available for trusted + diagnostics. The compiler engine requires a supervised `QuickBEAM.VM.Compiler`. """ @spec eval(Program.t(), keyword()) :: {:ok, term()} | {:error, term()} def eval(%Program{} = program, opts \\ []) when is_list(opts) do with :ok <- Verifier.verify(program), {:ok, options} <- evaluation_options(opts) do case options.isolation do - :caller -> Evaluator.eval(program, Map.to_list(options.interpreter)) + :caller -> evaluate(program, options) :process -> eval_isolated(program, options) end end @@ -128,7 +129,7 @@ defmodule QuickBEAM.VM do payload = case options.isolation do - :caller -> safe_measure(program, options.interpreter) + :caller -> safe_measure(program, options) :process -> measure_isolated(program, options) end @@ -140,6 +141,8 @@ defmodule QuickBEAM.VM do defp evaluation_options(opts) do allowed = [ + :compiler_pool, + :engine, :handlers, :isolation, :max_stack_depth, @@ -158,6 +161,8 @@ defmodule QuickBEAM.VM do 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.ModulePool) 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) @@ -170,6 +175,12 @@ defmodule QuickBEAM.VM do isolation not in [:caller, :process] -> {:error, {:invalid_option, :isolation, isolation}} + engine not in [:interpreter, :compiler] -> + {:error, {:invalid_option, :engine, engine}} + + not (is_atom(compiler_pool) or is_pid(compiler_pool)) -> + {:error, {:invalid_option, :compiler_pool, compiler_pool}} + timeout != :infinity and (not is_integer(timeout) or timeout <= 0) -> {:error, {:invalid_option, :timeout, timeout}} @@ -198,9 +209,11 @@ defmodule QuickBEAM.VM do {:ok, %{ isolation: isolation, + engine: engine, memory_limit: memory_limit, timeout: timeout, interpreter: %{ + compiler_pool: compiler_pool, handlers: handlers, max_steps: max_steps, max_stack_depth: max_stack_depth, @@ -217,7 +230,7 @@ defmodule QuickBEAM.VM do reply_ref = make_ref() worker = fn -> - result = safe_interpret(program, options.interpreter) + result = safe_evaluate(program, options) send(caller, {reply_ref, result}) end @@ -226,15 +239,21 @@ defmodule QuickBEAM.VM do await_evaluation(pid, monitor_ref, reply_ref, options.timeout, options.memory_limit) end - defp safe_interpret(program, options) do - case Evaluator.eval(program, Map.to_list(options)) do + defp evaluate(program, %{engine: :interpreter, interpreter: options}), + do: Evaluator.eval(program, Map.to_list(options)) + + defp evaluate(program, %{engine: :compiler, interpreter: options}), + do: Compiler.eval(program, Map.to_list(options)) + + defp safe_evaluate(program, options) do + case evaluate(program, options) do {:suspended, _continuation} -> {:error, {:unsupported, :async_wait}} result -> result end rescue - exception -> {:error, {:interpreter_crash, exception, __STACKTRACE__}} + exception -> {:error, {engine_crash(options.engine), exception, __STACKTRACE__}} catch - kind, reason -> {:error, {:interpreter_crash, {kind, reason}, __STACKTRACE__}} + kind, reason -> {:error, {engine_crash(options.engine), {kind, reason}, __STACKTRACE__}} end defp measure_isolated(program, options) do @@ -242,7 +261,7 @@ defmodule QuickBEAM.VM do reply_ref = make_ref() worker = fn -> - payload = safe_measure(program, options.interpreter) + payload = safe_measure(program, options) send(caller, {reply_ref, payload}) end @@ -254,8 +273,8 @@ defmodule QuickBEAM.VM do end end - defp safe_measure(program, options) do - {result, metrics} = Evaluator.eval_with_metrics(program, Map.to_list(options)) + defp safe_measure(program, %{engine: engine, interpreter: options}) do + {result, metrics} = measure_engine(engine, program, Map.to_list(options)) result = if match?({:suspended, _continuation}, result), @@ -264,12 +283,21 @@ defmodule QuickBEAM.VM do {:measured, result, metrics} rescue - exception -> {:measured, {:error, {:interpreter_crash, exception, __STACKTRACE__}}, nil} + exception -> {:measured, {:error, {engine_crash(engine), exception, __STACKTRACE__}}, nil} catch kind, reason -> - {:measured, {:error, {:interpreter_crash, {kind, reason}, __STACKTRACE__}}, nil} + {:measured, {:error, {engine_crash(engine), {kind, reason}, __STACKTRACE__}}, nil} end + defp measure_engine(:interpreter, program, options), + do: Evaluator.eval_with_metrics(program, options) + + defp measure_engine(:compiler, program, options), + do: Compiler.eval_with_metrics(program, options) + + 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 || %{} diff --git a/lib/quickbeam/vm/compiler.ex b/lib/quickbeam/vm/compiler.ex new file mode 100644 index 000000000..39cc9d261 --- /dev/null +++ b/lib/quickbeam/vm/compiler.ex @@ -0,0 +1,117 @@ +defmodule QuickBEAM.VM.Compiler do + @moduledoc """ + Supervises and orchestrates the optional bounded BEAM compiler tier. + + Add this module to a supervision tree before selecting `engine: :compiler`: + + 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.{Contract, GeneratedModule, ModulePool} + alias QuickBEAM.VM.Compiler.Lowering.PureV1 + alias QuickBEAM.VM.{Evaluator, Execution, Interpreter, Program} + + @type result :: {:ok, term()} | {:error, term()} | {:suspended, 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: ModulePool, + 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, GeneratedModule) + |> Keyword.put_new(:task_supervisor, QuickBEAM.VM.TaskSupervisor) + + ModulePool.start_link(opts) + end + + @doc "Evaluates a verified program through one compiled pure block and explicit deoptimization." + @spec eval(Program.t(), keyword()) :: result() + def eval(%Program{} = program, opts \\ []) when is_list(opts) do + program + |> start(opts) + |> Evaluator.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{} = program, opts \\ []) when is_list(opts) do + {frame, execution} = Interpreter.initialize(program, opts) + pool = Keyword.get(opts, :compiler_pool, ModulePool) + + with :ok <- ensure_pool_available(pool), + {:ok, template} <- PureV1.lower(program.root), + {:ok, key} <- Contract.artifact_key(program, program.root, profile: :pure_v1), + {:ok, lease} <- ModulePool.checkout(pool, key, template) do + action = + try do + GeneratedModule.invoke(pool, lease, frame, execution) + after + safe_checkin(pool, lease) + end + + resume_action(action, execution) + else + {:error, reason} -> {:error, {:compiler_error, reason}, execution} + end + end + + defp resume_action({:deopt, deopt}, _execution), do: Interpreter.resume_deopt_raw(deopt) + + defp resume_action({status, _value, %Execution{}} = result, _execution) + when status in [:ok, :error], do: result + + defp resume_action({:suspended, _continuation} = result, _execution), do: result + + 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 + ModulePool.checkin(pool, lease) + catch + :exit, _reason -> :ok + end +end diff --git a/lib/quickbeam/vm/evaluator.ex b/lib/quickbeam/vm/evaluator.ex index 7dd93c16b..e2018e852 100644 --- a/lib/quickbeam/vm/evaluator.ex +++ b/lib/quickbeam/vm/evaluator.ex @@ -39,10 +39,12 @@ defmodule QuickBEAM.VM.Evaluator do end end - defp drive({:ok, %PromiseReference{} = promise, execution}), + @doc "Drives a raw interpreter or compiler machine result through the owner-local event loop." + @spec drive(term()) :: Interpreter.result() + def drive({:ok, %PromiseReference{} = promise, execution}), do: await_final_promise(promise, execution) - defp drive({:suspended, %Continuation{awaiting: :microtask} = continuation}) do + def drive({:suspended, %Continuation{awaiting: :microtask} = continuation}) 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}} @@ -53,17 +55,17 @@ defmodule QuickBEAM.VM.Evaluator do end end - defp drive({:suspended, %Continuation{awaiting: %PromiseReference{}} = continuation}) do + def drive({:suspended, %Continuation{awaiting: %PromiseReference{}} = continuation}) do await_legacy_promise(continuation) end - defp drive({:suspended, %Continuation{} = continuation} = suspended), + def drive({:suspended, %Continuation{} = continuation} = suspended), do: finish_suspended(suspended, continuation.execution) - defp drive({status, _value, _execution} = result) when status in [:ok, :error], + def drive({status, _value, _execution} = result) when status in [:ok, :error], do: finish_final(result) - defp drive({:idle, execution}), + def drive({:idle, execution}), do: finish_final({:error, :idle_evaluation, execution}) defp await_final_promise(%PromiseReference{} = promise, execution) do diff --git a/lib/quickbeam/vm/interpreter.ex b/lib/quickbeam/vm/interpreter.ex index 717cb869c..439dd4d6f 100644 --- a/lib/quickbeam/vm/interpreter.ex +++ b/lib/quickbeam/vm/interpreter.ex @@ -12,10 +12,10 @@ defmodule QuickBEAM.VM.Interpreter do Async, AsyncBoundary, Builtins, - Compiler.Deopt, Continuation, ConstructorBoundary, Coroutine, + Compiler.Deopt, Execution, Exceptions, Export, @@ -67,10 +67,10 @@ defmodule QuickBEAM.VM.Interpreter do @spec eval(Program.t(), keyword()) :: result() def eval(%Program{} = program, opts \\ []), do: program |> start(opts) |> finish() - @doc "Starts interpreting a program and returns its raw machine result." - def start(%Program{} = program, opts \\ []) do + @doc "Initializes the canonical owner-local frame and execution state." + @spec initialize(Program.t(), keyword()) :: {Frame.t(), Execution.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 = %Execution{ @@ -87,6 +87,12 @@ defmodule QuickBEAM.VM.Interpreter do 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 diff --git a/mix.exs b/mix.exs index a9646121c..70a89f33f 100644 --- a/mix.exs +++ b/mix.exs @@ -109,6 +109,7 @@ defmodule QuickBEAM.MixProject do "docs/architecture.md", "docs/beam-interpreter-architecture.md", "docs/beam-compiler-contract.md", + "docs/beam-compiler-scheduler-measurements.md", "docs/beam-scheduler-measurements.md", "docs/beam-ssr-measurements.md", "docs/prototype-delta-audit.md", @@ -121,6 +122,7 @@ defmodule QuickBEAM.MixProject do "docs/architecture.md", "docs/beam-interpreter-architecture.md", "docs/beam-compiler-contract.md", + "docs/beam-compiler-scheduler-measurements.md", "docs/beam-scheduler-measurements.md", "docs/beam-ssr-measurements.md", "docs/prototype-delta-audit.md", @@ -157,6 +159,7 @@ defmodule QuickBEAM.MixProject do public_vm_modules = [ QuickBEAM.VM.ABI, QuickBEAM.VM.ClosureVariable, + QuickBEAM.VM.Compiler, QuickBEAM.VM.Function, QuickBEAM.VM.Measurement, QuickBEAM.VM.Program, diff --git a/test/vm/compiler_orchestration_test.exs b/test/vm/compiler_orchestration_test.exs new file mode 100644 index 000000000..2c7932ad6 --- /dev/null +++ b/test/vm/compiler_orchestration_test.exs @@ -0,0 +1,176 @@ +defmodule QuickBEAM.VM.CompilerOrchestrationTest do + use ExUnit.Case, async: false + + alias QuickBEAM.VM.{Compiler, Measurement} + alias QuickBEAM.VM.Compiler.ModulePool + + test "requires an explicitly supervised compiler service" do + assert {:ok, program} = QuickBEAM.VM.compile("40 + 2") + + assert {:error, {:compiler_error, {:compiler_pool_unavailable, ModulePool}}} = + QuickBEAM.VM.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 QuickBEAM.VM.eval(program) == {:ok, expected} + assert QuickBEAM.VM.eval(program, engine: :compiler) == {:ok, expected} + end + after + QuickBEAM.stop(runtime) + end + 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} = + QuickBEAM.VM.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} = + QuickBEAM.VM.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}} = + QuickBEAM.VM.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} = QuickBEAM.VM.measure(program) + + assert {:ok, %Measurement{} = compiled} = + QuickBEAM.VM.measure(program, engine: :compiler) + + assert compiled.result == interpreted.result + assert compiled.steps == interpreted.steps + assert compiled.logical_memory_bytes == interpreted.logical_memory_bytes + 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 QuickBEAM.VM.eval(finite, max_steps: 2) == expected + assert QuickBEAM.VM.eval(finite, engine: :compiler, max_steps: 2) == expected + + memory_error = {:error, {:limit_exceeded, :memory_bytes, 1}} + assert QuickBEAM.VM.eval(finite, memory_limit: 1) == memory_error + assert QuickBEAM.VM.eval(finite, engine: :compiler, memory_limit: 1) == memory_error + + assert {:ok, loop} = QuickBEAM.VM.compile("while (true) {}") + + assert {:error, {:limit_exceeded, :timeout, 50}} = + QuickBEAM.VM.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 + ] + + assert QuickBEAM.VM.eval(program, [engine: :compiler] ++ options) == + QuickBEAM.VM.eval(program, options) + end + + test "shares cached generated code across isolated evaluation owners" do + start_compiler() + assert {:ok, program} = QuickBEAM.VM.compile("(20 + 1) * 2") + + tasks = + for _ <- 1..40 do + Task.async(fn -> QuickBEAM.VM.eval(program, engine: :compiler) end) + end + + assert Task.await_many(tasks, 5_000) == List.duplicate({:ok, 42}, 40) + + stats = ModulePool.stats(ModulePool) + 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}} = + QuickBEAM.VM.eval(program, engine: :native) + + assert {:error, {:invalid_option, :compiler_pool, "pool"}} = + QuickBEAM.VM.eval(program, engine: :compiler, compiler_pool: "pool") + end + + defp start_compiler do + start_supervised!({Compiler, capacity: 4}) + end +end From a703c92987cba0ab4cb87eab02ad5be6307ad693 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 15 Jul 2026 22:32:41 +0200 Subject: [PATCH 54/87] Publish compiler SSR compatibility gate --- CHANGELOG.md | 2 +- bench/README.md | 9 ++- bench/vm_ssr.exs | 56 +++++++++++++++--- docs/beam-compiler-contract.md | 27 ++++++++- docs/beam-compiler-ssr-measurements.md | 77 +++++++++++++++++++++++++ docs/beam-interpreter-architecture.md | 6 +- docs/prototype-delta-audit.md | 10 +++- docs/test262-conformance.md | 12 ++-- mix.exs | 2 + test/support/test262.ex | 29 ++++++---- test/vm/compiler_orchestration_test.exs | 19 ++++++ test/vm/svelte_ssr_test.exs | 9 +++ test/vm/test262_test.exs | 32 ++++++++++ test/vm/vue_ssr_test.exs | 8 +++ 14 files changed, 263 insertions(+), 35 deletions(-) create mode 100644 docs/beam-compiler-ssr-measurements.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 06e93bd75..bf6130856 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased -- Define the optional BEAM compiler contract with binary artifact identities, a fixed 32-module atom pool, lease/purge/cache lifecycle, versioned runtime ABI boundaries, and validated owner-local before-instruction deoptimization states. Add its supervised module pool, minimal canonical runtime ABI, structured generated-module backend, bounded v26 CFG analysis, specialized fixed-name `:pure_v1` forms, and explicit release-quarantined `engine: :compiler` orchestration with native, Preact SSR, resource, concurrency, measurement, and single-scheduler acceptance coverage. +- Define the optional BEAM compiler contract with binary artifact identities, a fixed 32-module atom pool, lease/purge/cache lifecycle, versioned runtime ABI boundaries, and validated owner-local before-instruction deoptimization states. Add its supervised module pool, minimal canonical runtime ABI, structured generated-module backend, bounded v26 CFG analysis, specialized fixed-name `:pure_v1` forms, and explicit release-quarantined `engine: :compiler` orchestration with native, Preact/Vue/Svelte SSR, selected Test262, resource, concurrency, measurement, and single-scheduler acceptance coverage. - Add `QuickBEAM.VM.measure/2` with deterministic step/logical-memory counters and endpoint process observations, plus reproducible pinned SSR concurrency, timeout, cancellation, reclamation, and single-scheduler reports. - Serialize native addon initialization, reuse cached exports for aliases in one runtime, and reject implicit cross-runtime or post-reset reinitialization with a typed error. Add `allow_reinitialization: true` as an explicit compatibility escape hatch for addons that support multiple environments. diff --git a/bench/README.md b/bench/README.md index 36b35467d..967a80147 100644 --- a/bench/README.md +++ b/bench/README.md @@ -24,6 +24,10 @@ MIX_ENV=bench mix run bench/concurrent.exs MIX_ENV=bench mix run bench/vm_ssr.exs \ --output docs/beam-ssr-measurements.md +# Reproduce the release-quarantined compiler SSR report +MIX_ENV=bench mix run bench/vm_ssr.exs \ + --engine compiler --output docs/beam-compiler-ssr-measurements.md + # Reproduce the interpreter single-scheduler fairness/timeout probe ERL_FLAGS='+S 1:1' MIX_ENV=bench mix run bench/vm_scheduler_probe.exs \ --output docs/beam-scheduler-measurements.md @@ -33,12 +37,13 @@ ERL_FLAGS='+S 1:1' MIX_ENV=bench mix run bench/vm_scheduler_probe.exs \ --engine compiler --output docs/beam-compiler-scheduler-measurements.md ``` -The SSR runner accepts `--samples`, `--warmup`, and a comma-separated -`--concurrency` list. It reports deterministic VM steps and logical allocation, +The SSR runner accepts `--engine interpreter|compiler`, `--samples`, `--warmup`, +and a comma-separated `--concurrency` list. It reports deterministic VM steps and logical allocation, endpoint BEAM process observations, sequential latency, concurrent throughput, and timeout/cancellation behavior for the pinned Preact, Vue, and Svelte fixtures. Published results are in [`docs/beam-ssr-measurements.md`](../docs/beam-ssr-measurements.md), +[`docs/beam-compiler-ssr-measurements.md`](../docs/beam-compiler-ssr-measurements.md), [`docs/beam-scheduler-measurements.md`](../docs/beam-scheduler-measurements.md), and [`docs/beam-compiler-scheduler-measurements.md`](../docs/beam-compiler-scheduler-measurements.md). diff --git a/bench/vm_ssr.exs b/bench/vm_ssr.exs index 75af7e5ea..2cf7670a4 100644 --- a/bench/vm_ssr.exs +++ b/bench/vm_ssr.exs @@ -3,6 +3,8 @@ defmodule QuickBEAM.Bench.VMSSR do Reproducible fixture-specific measurements for the isolated BEAM VM SSR path. """ + alias QuickBEAM.VM.Compiler + @default_samples 30 @default_warmup 3 @default_concurrency [1, 4, 8] @@ -10,19 +12,27 @@ defmodule QuickBEAM.Bench.VMSSR do def run(args) do {opts, positional, invalid} = OptionParser.parse(args, - strict: [samples: :integer, warmup: :integer, concurrency: :string, output: :string] + strict: [ + engine: :string, + 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")) + 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) + fixtures = Enum.map(fixture_specs(), &compile_fixture!(&1, engine)) results = Enum.map(fixtures, fn fixture -> @@ -37,7 +47,7 @@ defmodule QuickBEAM.Bench.VMSSR do end) isolation = isolation_probe(hd(fixtures)) - report = markdown_report(results, isolation, samples, warmup, concurrency) + report = markdown_report(engine, results, isolation, samples, warmup, concurrency) IO.write(report) if output = opts[:output] do @@ -93,10 +103,13 @@ defmodule QuickBEAM.Bench.VMSSR do ] end - defp compile_fixture!(spec) do + defp compile_fixture!(spec, engine) do {:ok, source} = QuickBEAM.JS.bundle_file(spec.fixture, spec.bundle_opts) {:ok, program} = QuickBEAM.VM.compile(source, filename: spec.fixture) - Map.put(spec, :program, program) + + spec + |> Map.put(:program, program) + |> update_in([:eval_opts], &Keyword.put(&1, :engine, engine)) end defp warm(_fixture, 0), do: :ok @@ -310,20 +323,31 @@ defmodule QuickBEAM.Bench.VMSSR do defp result_label({:error, reason}), do: "error:#{inspect(reason)}" defp result_label({:ok, _value}), do: "ok" - defp markdown_report(results, isolation, samples, warmup, concurrency) do + defp markdown_report(engine, results, isolation, samples, warmup, concurrency) do metadata = metadata() + scheduler_report = + if engine == :compiler, + do: "beam-compiler-scheduler-measurements.md", + else: "beam-scheduler-measurements.md" + + title = + if engine == :compiler, + do: "BEAM compiler SSR measurements", + else: "BEAM VM SSR measurements" + """ - # BEAM VM SSR measurements + # #{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. The single-scheduler fairness and timeout gate is published separately in - [`beam-scheduler-measurements.md`](beam-scheduler-measurements.md). + [`#{scheduler_report}`](#{scheduler_report}). ## Environment + - Engine: #{engine} - Git base: `#{metadata.git}` - Working tree at measurement: #{metadata.tree_state} - Generated: #{metadata.generated} @@ -481,6 +505,22 @@ defmodule QuickBEAM.Bench.VMSSR do 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 maybe_start_compiler!(:interpreter), do: :ok + + defp maybe_start_compiler!(:compiler) do + case Compiler.start_link(capacity: 8) 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), diff --git a/docs/beam-compiler-contract.md b/docs/beam-compiler-contract.md index 43df57497..51d82312b 100644 --- a/docs/beam-compiler-contract.md +++ b/docs/beam-compiler-contract.md @@ -233,8 +233,31 @@ QuickJS. The current `+S 1:1` compiler-tier Vue probe reports a 35.48 ms maximum ticker gap against the 75 ms bound and a 51.0 ms timeout p95 against the 60 ms bound. -These fixture-specific results satisfy the initial scheduler gate but do not -remove the broader release quarantine. +The pinned compiler SSR report covers 30 sequential samples plus concurrency +1/4/8 for Preact, Vue, and Svelte, with 100/100 isolated Preact renders and +successful step, memory, timeout, and cancellation checks. The selected Test262 +gate passes 65/65 supported tests through both the interpreter and compiler tier. + +On the published runs, compiler-tier sequential medians are 10.11 ms for Preact, +84.56 ms for Vue, and 19.06 ms for Svelte, versus interpreter medians of 8.49 ms, +48.55 ms, and 12.36 ms respectively. These separate reproducible runs are not a +paired statistical comparison, but they show that the current one-block tier is +not a performance release candidate. + +### Release policy + +The compiler remains release-quarantined despite those compatibility results: + +- `engine: :interpreter` remains the documented default; +- compiler selection and supervision must always be explicit; +- compiler infrastructure failures never restart in another engine; +- fixture parity does not imply that the current one-block specialization has a + production performance benefit; +- stable release promotion requires bounded re-entry for nested functions or + equivalent useful compiled coverage, a published compiler/interpreter + performance comparison, and no regression in the existing safety gates; +- until promotion, compiler API compatibility may change within the development + major release. ## Prototype analysis map diff --git a/docs/beam-compiler-ssr-measurements.md b/docs/beam-compiler-ssr-measurements.md new file mode 100644 index 000000000..835cadc89 --- /dev/null +++ b/docs/beam-compiler-ssr-measurements.md @@ -0,0 +1,77 @@ +# BEAM compiler SSR measurements + +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. The +single-scheduler fairness and timeout gate is published separately in +[`beam-compiler-scheduler-measurements.md`](beam-compiler-scheduler-measurements.md). + +## Environment + +- Engine: compiler +- Git base: `cad39310` +- Working tree at measurement: modified +- Generated: 2026-07-15T20:22:55Z +- Elixir: 1.20.2 +- OTP: 29 +- ERTS: 17.0.2 +- OS: Linux 7.0.0-27-generic +- Architecture: x86_64-pc-linux-gnu +- CPU: AMD Ryzen 9 9950X 16-Core Processor +- Logical schedulers: 32 +- Mix environment: `bench` +- Samples per fixture: 30 after 3 warmups +- Concurrency levels: 1, 4, 8 + +## Sequential isolated renders + +| Fixture | wall median | wall p95 | VM steps | logical memory | endpoint process memory | reductions median | +|---|---:|---:|---:|---:|---:|---:| +| Preact 10.29.7 | 10.11 ms | 11.02 ms | 3651 | 266.2 KiB | 4.5 MiB | 324701 | +| Vue 3.5.39 | 84.56 ms | 91.19 ms | 11957 | 992.3 KiB | 77.15 MiB | 3304925 | +| Svelte 5.56.4 | 19.06 ms | 20.71 ms | 1777 | 397.3 KiB | 12.48 MiB | 720499 | + +`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. + +## Concurrent isolated renders + +| Fixture | concurrency | renders | throughput | per-render wall median | per-render wall p95 | +|---|---:|---:|---:|---:|---:| +| Preact 10.29.7 | 1 | 30 | 77.1 renders/s | 9.74 ms | 10.97 ms | +| Preact 10.29.7 | 4 | 30 | 252.2 renders/s | 10.45 ms | 12.57 ms | +| Preact 10.29.7 | 8 | 30 | 388.5 renders/s | 12.7 ms | 14.4 ms | +| Vue 3.5.39 | 1 | 30 | 6.7 renders/s | 88.39 ms | 102.6 ms | +| Vue 3.5.39 | 4 | 30 | 15.8 renders/s | 139.16 ms | 176.45 ms | +| Vue 3.5.39 | 8 | 30 | 16.6 renders/s | 241.41 ms | 301.65 ms | +| Svelte 5.56.4 | 1 | 30 | 34.3 renders/s | 18.73 ms | 26.33 ms | +| Svelte 5.56.4 | 4 | 30 | 88.6 renders/s | 26.56 ms | 30.59 ms | +| Svelte 5.56.4 | 8 | 30 | 109.5 renders/s | 39.57 ms | 46.1 ms | + +## 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 | +|---:|---:|---:|---:| +| 100/100 | 483.6 renders/s | -941.8 KiB | 0 | + +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 | +|---|---:|---:|---:|---:|---:| +| Preact 10.29.7 | limit:steps at 3650 | limit:memory_bytes at 133.1 KiB | limit:timeout at 200 ms | 201.77 ms | 37 µs | +| Vue 3.5.39 | limit:steps at 11956 | limit:memory_bytes at 496.1 KiB | limit:timeout at 200 ms | 213.2 ms | 19 µs | +| Svelte 5.56.4 | limit:steps at 1776 | limit:memory_bytes at 198.7 KiB | limit:timeout at 200 ms | 202.36 ms | 22 µs | + +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. diff --git a/docs/beam-interpreter-architecture.md b/docs/beam-interpreter-architecture.md index 4d227a822..c50ea6901 100644 --- a/docs/beam-interpreter-architecture.md +++ b/docs/beam-interpreter-architecture.md @@ -758,8 +758,10 @@ Minimum scheduler acceptance scenarios: The reproducible fixture measurements are published in [`beam-ssr-measurements.md`](beam-ssr-measurements.md), with the `+S 1:1` fairness and timeout gates in -[`beam-scheduler-measurements.md`](beam-scheduler-measurements.md) and the -release-quarantined compiler run in +[`beam-scheduler-measurements.md`](beam-scheduler-measurements.md), the +release-quarantined compiler SSR matrix in +[`beam-compiler-ssr-measurements.md`](beam-compiler-ssr-measurements.md), and its +single-scheduler run in [`beam-compiler-scheduler-measurements.md`](beam-compiler-scheduler-measurements.md). The reports separate deterministic VM steps/logical allocation from endpoint BEAM process diff --git a/docs/prototype-delta-audit.md b/docs/prototype-delta-audit.md index d8fe4a1a1..81bbe330d 100644 --- a/docs/prototype-delta-audit.md +++ b/docs/prototype-delta-audit.md @@ -320,9 +320,13 @@ explicit interpreter deoptimization with synthetic, decoded-expression, argument/local, and exact-step differential tests. Explicit supervised orchestration now exercises isolated evaluation, measurement, asynchronous handler deoptimization, native pure-expression parity, timeout/memory/step -containment, and concurrent cache reuse. The compiler remains release -quarantined until broader SSR/native fixtures and single-scheduler gates pass; -no prototype compiler runtime is approved for copying. +containment, and concurrent cache reuse. Preact, Vue, and Svelte now have +compiler/interpreter/native parity; compiler measurements cover sequential and +concurrent SSR, cancellation, reclamation, and `+S 1:1`; and the selected +Test262 compiler gate passes 65/65 supported cases. The compiler remains release +quarantined because one-block specialization does not yet provide useful nested +compiled coverage or a demonstrated production performance benefit. No +prototype compiler runtime is approved for copying. ## Test extraction policy diff --git a/docs/test262-conformance.md b/docs/test262-conformance.md index d7085bfdd..6cfec3627 100644 --- a/docs/test262-conformance.md +++ b/docs/test262-conformance.md @@ -9,7 +9,8 @@ gate for `QuickBEAM.VM`. Full Test262 compliance is not claimed. - Selected tests: 69 - Explicitly unsupported by flags: 4 asynchronous tests - Supported tests: 65 -- Passing: 65 +- Interpreter passing: 65 +- Compiler tier passing: 65 - Known failures: 0 - Supported-test pass rate: **100%** - Required pass rate: **100%** @@ -42,11 +43,12 @@ external corpus gate is reported as skipped. ## Classification -Each selected path is run in a fresh native QuickJS runtime and an isolated -BEAM VM evaluation. Results are classified as: +Each selected path is run in a fresh native QuickJS runtime and isolated BEAM +interpreter and release-quarantined compiler-tier evaluations. Results are +classified as: -- `pass` — both engines satisfy the Test262 expectation; -- `vm_failure` — native QuickJS passes and the BEAM VM fails; +- `pass` — native QuickJS and the selected BEAM tier satisfy the expectation; +- `vm_failure` — native QuickJS passes and the selected BEAM tier fails; - `native_failure` — the selected test or harness is incompatible with the vendored native engine; - `unsupported_flag` — module, raw, or asynchronous harness behavior is outside diff --git a/mix.exs b/mix.exs index 70a89f33f..d3a9774bb 100644 --- a/mix.exs +++ b/mix.exs @@ -110,6 +110,7 @@ defmodule QuickBEAM.MixProject do "docs/beam-interpreter-architecture.md", "docs/beam-compiler-contract.md", "docs/beam-compiler-scheduler-measurements.md", + "docs/beam-compiler-ssr-measurements.md", "docs/beam-scheduler-measurements.md", "docs/beam-ssr-measurements.md", "docs/prototype-delta-audit.md", @@ -123,6 +124,7 @@ defmodule QuickBEAM.MixProject do "docs/beam-interpreter-architecture.md", "docs/beam-compiler-contract.md", "docs/beam-compiler-scheduler-measurements.md", + "docs/beam-compiler-ssr-measurements.md", "docs/beam-scheduler-measurements.md", "docs/beam-ssr-measurements.md", "docs/prototype-delta-audit.md", diff --git a/test/support/test262.ex b/test/support/test262.ex index c0d37abbc..1a84ba06b 100644 --- a/test/support/test262.ex +++ b/test/support/test262.ex @@ -104,14 +104,14 @@ defmodule QuickBEAM.Test262 do end @doc "Runs every entry in a selected manifest and returns classified results." - @spec run_manifest(Path.t(), keyword()) :: [result()] - def run_manifest(root, manifest) do - Enum.map(manifest[:tests], &run(root, &1)) + @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 both engines." - @spec run(Path.t(), Path.t()) :: result() - def run(root, relative_path) do + @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 @@ -120,7 +120,7 @@ defmodule QuickBEAM.Test262 do case unsupported_flag(metadata.flags) do nil -> - run_supported(root, relative_path, source, metadata) + run_supported(root, relative_path, source, metadata, opts) flag -> result(relative_path, :unsupported_flag, {:unsupported_flag, flag}, :not_run, metadata) @@ -139,12 +139,12 @@ defmodule QuickBEAM.Test262 do %{total: length(results), supported: supported, pass_rate: pass_rate, counts: counts} end - defp run_supported(root, relative_path, source, metadata) do + 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) + vm = vm_result(full_source, metadata.negative, opts) classification = cond do @@ -166,10 +166,15 @@ defmodule QuickBEAM.Test262 do |> then(&(@minimal_harness <> "\n" <> &1 <> "\n")) end - defp vm_result(source, negative) do + defp vm_result(source, negative, opts) do + engine = Keyword.get(opts, :engine, :interpreter) + case QuickBEAM.VM.compile(source, filename: "test262.js") do - {:ok, program} -> classify_execution(QuickBEAM.VM.eval(program), negative, :runtime) - {:error, error} -> classify_execution({:error, error}, negative, :parse) + {:ok, program} -> + classify_execution(QuickBEAM.VM.eval(program, engine: engine), negative, :runtime) + + {:error, error} -> + classify_execution({:error, error}, negative, :parse) end end diff --git a/test/vm/compiler_orchestration_test.exs b/test/vm/compiler_orchestration_test.exs index 2c7932ad6..12a220bac 100644 --- a/test/vm/compiler_orchestration_test.exs +++ b/test/vm/compiler_orchestration_test.exs @@ -27,6 +27,25 @@ defmodule QuickBEAM.VM.CompilerOrchestrationTest do 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} = QuickBEAM.VM.eval(program) + assert {:error, compiled_error} = QuickBEAM.VM.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 "deoptimizes into asynchronous handlers without duplicating effects" do start_compiler() assert {:ok, program} = QuickBEAM.VM.compile("Beam.call('double', 21)") diff --git a/test/vm/svelte_ssr_test.exs b/test/vm/svelte_ssr_test.exs index d73045112..a91876631 100644 --- a/test/vm/svelte_ssr_test.exs +++ b/test/vm/svelte_ssr_test.exs @@ -1,6 +1,8 @@ defmodule QuickBEAM.VM.SvelteSSRTest do use ExUnit.Case, async: false + alias QuickBEAM.VM.Compiler + @fixture "test/fixtures/vm/svelte_ssr.js" @eval_opts [ profile: :ssr, @@ -10,6 +12,8 @@ defmodule QuickBEAM.VM.SvelteSSRTest do ] setup_all do + start_supervised!({Compiler, capacity: 8}) + assert {:ok, source} = QuickBEAM.JS.bundle_file(@fixture, format: :esm, minify: true) @@ -39,7 +43,12 @@ defmodule QuickBEAM.VM.SvelteSSRTest do QuickBEAM.eval(runtime, "await globalThis.__quickbeamSSRResult", timeout: 5_000) assert {:ok, beam_rendered} = QuickBEAM.VM.eval(program, [handlers: handlers] ++ @eval_opts) + + assert {:ok, compiler_rendered} = + QuickBEAM.VM.eval(program, [engine: :compiler, handlers: handlers] ++ @eval_opts) + assert beam_rendered == native_rendered + assert compiler_rendered == native_rendered after QuickBEAM.stop(runtime) end diff --git a/test/vm/test262_test.exs b/test/vm/test262_test.exs index eff82134b..506f9dc9a 100644 --- a/test/vm/test262_test.exs +++ b/test/vm/test262_test.exs @@ -89,6 +89,34 @@ defmodule QuickBEAM.VM.Test262Test do 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}) + results = QuickBEAM.Test262.run_manifest(@root, @manifest, engine: :compiler) + 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 -> @@ -101,5 +129,9 @@ defmodule QuickBEAM.VM.Test262Test do @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 end end diff --git a/test/vm/vue_ssr_test.exs b/test/vm/vue_ssr_test.exs index 6074d57da..73c48f4a6 100644 --- a/test/vm/vue_ssr_test.exs +++ b/test/vm/vue_ssr_test.exs @@ -1,6 +1,8 @@ defmodule QuickBEAM.VM.VueSSRTest do use ExUnit.Case, async: false + alias QuickBEAM.VM.Compiler + @fixture "test/fixtures/vm/vue_ssr.js" @bundle_opts [ format: :esm, @@ -20,6 +22,7 @@ defmodule QuickBEAM.VM.VueSSRTest do ] 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) {:ok, source: source, program: program} @@ -44,7 +47,12 @@ defmodule QuickBEAM.VM.VueSSRTest do QuickBEAM.eval(runtime, "await globalThis.__quickbeamSSRResult", timeout: 5_000) assert {:ok, beam_html} = QuickBEAM.VM.eval(program, [handlers: handlers] ++ @eval_opts) + + assert {:ok, compiler_html} = + QuickBEAM.VM.eval(program, [engine: :compiler, handlers: handlers] ++ @eval_opts) + assert beam_html == native_html + assert compiler_html == native_html after QuickBEAM.stop(runtime) end From 85d7a677d479496f30deebe12fa35ff1e532c882 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 15 Jul 2026 23:30:39 +0200 Subject: [PATCH 55/87] Add bounded nested compiler re-entry --- CHANGELOG.md | 2 +- bench/vm_scheduler_probe.exs | 2 +- bench/vm_ssr.exs | 4 +- docs/beam-compiler-contract.md | 38 ++++---- docs/beam-compiler-scheduler-measurements.md | 10 +-- docs/beam-compiler-ssr-measurements.md | 36 ++++---- docs/beam-interpreter-architecture.md | 5 +- docs/prototype-delta-audit.md | 8 +- lib/quickbeam/vm/compiler.ex | 89 ++++++++++++++++--- lib/quickbeam/vm/compiler/context.ex | 19 ++++ .../vm/compiler/generated_module/artifact.ex | 6 +- lib/quickbeam/vm/compiler/lowering/pure_v1.ex | 23 ++++- lib/quickbeam/vm/execution.ex | 2 + lib/quickbeam/vm/frame.ex | 2 + lib/quickbeam/vm/interpreter.ex | 22 ++++- test/vm/compiler_orchestration_test.exs | 56 ++++++++++++ test/vm/compiler_pure_v1_test.exs | 3 + test/vm/vue_ssr_test.exs | 2 + 18 files changed, 260 insertions(+), 69 deletions(-) create mode 100644 lib/quickbeam/vm/compiler/context.ex diff --git a/CHANGELOG.md b/CHANGELOG.md index bf6130856..8d6ef361b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased -- Define the optional BEAM compiler contract with binary artifact identities, a fixed 32-module atom pool, lease/purge/cache lifecycle, versioned runtime ABI boundaries, and validated owner-local before-instruction deoptimization states. Add its supervised module pool, minimal canonical runtime ABI, structured generated-module backend, bounded v26 CFG analysis, specialized fixed-name `:pure_v1` forms, and explicit release-quarantined `engine: :compiler` orchestration with native, Preact/Vue/Svelte SSR, selected Test262, resource, concurrency, measurement, and single-scheduler acceptance coverage. +- Define the optional BEAM compiler contract with binary artifact identities, a fixed 32-module atom pool, lease/purge/cache lifecycle, versioned runtime ABI boundaries, and validated owner-local before-instruction deoptimization states. Add its supervised module pool, minimal canonical runtime ABI, structured generated-module backend, bounded v26 CFG analysis, specialized fixed-name `:pure_v1` forms, selective bounded nested-function re-entry, and explicit release-quarantined `engine: :compiler` orchestration with native, Preact/Vue/Svelte SSR, selected Test262, resource, concurrency, measurement, and single-scheduler acceptance coverage. - Add `QuickBEAM.VM.measure/2` with deterministic step/logical-memory counters and endpoint process observations, plus reproducible pinned SSR concurrency, timeout, cancellation, reclamation, and single-scheduler reports. - Serialize native addon initialization, reuse cached exports for aliases in one runtime, and reject implicit cross-runtime or post-reset reinitialization with a typed error. Add `allow_reinitialization: true` as an explicit compatibility escape hatch for addons that support multiple environments. diff --git a/bench/vm_scheduler_probe.exs b/bench/vm_scheduler_probe.exs index b7debbf14..12ae28496 100644 --- a/bench/vm_scheduler_probe.exs +++ b/bench/vm_scheduler_probe.exs @@ -276,7 +276,7 @@ defmodule QuickBEAM.Bench.VMSchedulerProbe do defp maybe_start_compiler!(:interpreter), do: :ok defp maybe_start_compiler!(:compiler) do - case Compiler.start_link(capacity: 8) do + case Compiler.start_link(capacity: 32) do {:ok, _pid} -> :ok {:error, {:already_started, _pid}} -> :ok {:error, reason} -> raise "compiler start failed: #{inspect(reason)}" diff --git a/bench/vm_ssr.exs b/bench/vm_ssr.exs index 2cf7670a4..88cd048ad 100644 --- a/bench/vm_ssr.exs +++ b/bench/vm_ssr.exs @@ -250,7 +250,7 @@ defmodule QuickBEAM.Bench.VMSSR do receive do {:vm_ssr_handler_started, pid} -> pid after - 1_000 -> raise "#{fixture.name} did not start its asynchronous handler" + timeout_ms + 1_000 -> raise "#{fixture.name} did not start its asynchronous handler" end started = System.monotonic_time() @@ -514,7 +514,7 @@ defmodule QuickBEAM.Bench.VMSSR do defp maybe_start_compiler!(:interpreter), do: :ok defp maybe_start_compiler!(:compiler) do - case Compiler.start_link(capacity: 8) do + case Compiler.start_link(capacity: 32) do {:ok, _pid} -> :ok {:error, {:already_started, _pid}} -> :ok {:error, reason} -> raise "compiler start failed: #{inspect(reason)}" diff --git a/docs/beam-compiler-contract.md b/docs/beam-compiler-contract.md index 51d82312b..62f7c120f 100644 --- a/docs/beam-compiler-contract.md +++ b/docs/beam-compiler-contract.md @@ -1,8 +1,7 @@ # Bounded BEAM compiler contract -Status: design gate for the optional compiler. No prototype compiler source is -approved for extraction until this contract and its acceptance tests are in -place. +Status: implemented experimental gate for the optional compiler. The tier remains +release-quarantined, and no prototype compiler runtime is approved for copying. ## Scope @@ -19,7 +18,10 @@ block plan, then emits specialized fixed-name `run/3`, `block/4`, and `step/4` clauses whose operations call the canonical runtime ABI. Calls, accessors, constructors, iterators, exceptions, Promise operations, host calls, and `await` deopt before their instruction until their -resumable compiler ABI exists. +resumable compiler ABI exists. The root frame always receives an entry attempt; +nested frames require at least 32 pure entry instructions. Owner-local compile +or skip decisions are cached for at most 256 function IDs per evaluation, which +bounds metadata while avoiding repeated CFG analysis for hot calls. ## Non-negotiable invariants @@ -231,18 +233,20 @@ An adaptive policy, if added, must be explicitly selected by the caller and reported by measurement/telemetry. It still may never fall back to native QuickJS. -The current `+S 1:1` compiler-tier Vue probe reports a 35.48 ms maximum ticker -gap against the 75 ms bound and a 51.0 ms timeout p95 against the 60 ms bound. +The current `+S 1:1` compiler-tier Vue probe reports a 33.0 ms maximum ticker +gap against the 75 ms bound and a 50.99 ms timeout p95 against the 60 ms bound. The pinned compiler SSR report covers 30 sequential samples plus concurrency 1/4/8 for Preact, Vue, and Svelte, with 100/100 isolated Preact renders and -successful step, memory, timeout, and cancellation checks. The selected Test262 -gate passes 65/65 supported tests through both the interpreter and compiler tier. +successful step, memory, timeout, and cancellation checks. The Vue parity gate +also requires more than the root generated module, proving selected nested-frame +coverage. The selected Test262 gate passes 65/65 supported tests through both +the interpreter and compiler tier. -On the published runs, compiler-tier sequential medians are 10.11 ms for Preact, -84.56 ms for Vue, and 19.06 ms for Svelte, versus interpreter medians of 8.49 ms, +On the published runs, compiler-tier sequential medians are 10.71 ms for Preact, +69.95 ms for Vue, and 16.50 ms for Svelte, versus interpreter medians of 8.49 ms, 48.55 ms, and 12.36 ms respectively. These separate reproducible runs are not a -paired statistical comparison, but they show that the current one-block tier is -not a performance release candidate. +paired statistical comparison, but they show that selective nested-function +re-entry is still not a performance release candidate. ### Release policy @@ -251,11 +255,11 @@ The compiler remains release-quarantined despite those compatibility results: - `engine: :interpreter` remains the documented default; - compiler selection and supervision must always be explicit; - compiler infrastructure failures never restart in another engine; -- fixture parity does not imply that the current one-block specialization has a - production performance benefit; -- stable release promotion requires bounded re-entry for nested functions or - equivalent useful compiled coverage, a published compiler/interpreter - performance comparison, and no regression in the existing safety gates; +- fixture parity does not imply that the current one-entry-block-per-selected- + frame specialization has a production performance benefit; +- stable release promotion requires broader useful compiled coverage, a + non-regressing compiler/interpreter performance comparison, and no regression + in the existing safety gates; - until promotion, compiler API compatibility may change within the development major release. diff --git a/docs/beam-compiler-scheduler-measurements.md b/docs/beam-compiler-scheduler-measurements.md index 12c7e0e4e..95d04416d 100644 --- a/docs/beam-compiler-scheduler-measurements.md +++ b/docs/beam-compiler-scheduler-measurements.md @@ -5,9 +5,9 @@ ticker share one scheduler. The baseline sleeps for the median render wall time, allowing the same ticker to run without compiler work. - Engine: compiler -- Git base: `10bb81c8` +- Git base: `a703c929` - Working tree at measurement: modified -- Generated: 2026-07-15T20:07:45Z +- Generated: 2026-07-15T21:28:21Z - Elixir: 1.20.2 - OTP: 29 - ERTS: 17.0.2 @@ -20,8 +20,8 @@ time, allowing the same ticker to run without compiler work. | workload | wall median | wall p95 | ticker gap median | ticker gap p95 | ticker gap max | ticks median | |---|---:|---:|---:|---:|---:|---:| -| Vue SSR | 180.38 ms | 217.78 ms | 2.0 ms | 8.2 ms | 35.48 ms | 65 | -| sleep baseline (180 ms target) | 180.95 ms | 180.98 ms | 2.0 ms | 2.01 ms | 6.2 ms | 90 | +| Vue SSR | 169.57 ms | 196.97 ms | 2.0 ms | 8.24 ms | 33.0 ms | 63 | +| sleep baseline (170 ms target) | 170.95 ms | 170.97 ms | 2.0 ms | 2.01 ms | 4.11 ms | 85 | Acceptance bound: Vue SSR ticker gap ≤ 75.0 ms. @@ -31,6 +31,6 @@ An infinite JavaScript loop was evaluated with a 50 ms outer timeout. | timeout | wall median | wall p95 | wall max | median overshoot | |---:|---:|---:|---:|---:| -| 50 ms | 50.98 ms | 51.0 ms | 51.0 ms | 975 µs | +| 50 ms | 50.98 ms | 50.99 ms | 50.99 ms | 975 µs | Acceptance bound: timeout p95 ≤ 60.0 ms. diff --git a/docs/beam-compiler-ssr-measurements.md b/docs/beam-compiler-ssr-measurements.md index 835cadc89..50d96be24 100644 --- a/docs/beam-compiler-ssr-measurements.md +++ b/docs/beam-compiler-ssr-measurements.md @@ -9,9 +9,9 @@ single-scheduler fairness and timeout gate is published separately in ## Environment - Engine: compiler -- Git base: `cad39310` +- Git base: `a703c929` - Working tree at measurement: modified -- Generated: 2026-07-15T20:22:55Z +- Generated: 2026-07-15T21:27:57Z - Elixir: 1.20.2 - OTP: 29 - ERTS: 17.0.2 @@ -27,9 +27,9 @@ single-scheduler fairness and timeout gate is published separately in | Fixture | wall median | wall p95 | VM steps | logical memory | endpoint process memory | reductions median | |---|---:|---:|---:|---:|---:|---:| -| Preact 10.29.7 | 10.11 ms | 11.02 ms | 3651 | 266.2 KiB | 4.5 MiB | 324701 | -| Vue 3.5.39 | 84.56 ms | 91.19 ms | 11957 | 992.3 KiB | 77.15 MiB | 3304925 | -| Svelte 5.56.4 | 19.06 ms | 20.71 ms | 1777 | 397.3 KiB | 12.48 MiB | 720499 | +| Preact 10.29.7 | 10.71 ms | 11.44 ms | 3651 | 266.2 KiB | 4.5 MiB | 515490 | +| Vue 3.5.39 | 69.95 ms | 75.98 ms | 11957 | 992.3 KiB | 77.15 MiB | 3872070 | +| Svelte 5.56.4 | 16.5 ms | 19.3 ms | 1777 | 397.3 KiB | 15.61 MiB | 770310 | `VM steps` and `logical memory` are deterministic counters. Endpoint process memory and reductions are observed once after result conversion; they are not @@ -40,15 +40,15 @@ rendering, conversion, and reply delivery. | Fixture | concurrency | renders | throughput | per-render wall median | per-render wall p95 | |---|---:|---:|---:|---:|---:| -| Preact 10.29.7 | 1 | 30 | 77.1 renders/s | 9.74 ms | 10.97 ms | -| Preact 10.29.7 | 4 | 30 | 252.2 renders/s | 10.45 ms | 12.57 ms | -| Preact 10.29.7 | 8 | 30 | 388.5 renders/s | 12.7 ms | 14.4 ms | -| Vue 3.5.39 | 1 | 30 | 6.7 renders/s | 88.39 ms | 102.6 ms | -| Vue 3.5.39 | 4 | 30 | 15.8 renders/s | 139.16 ms | 176.45 ms | -| Vue 3.5.39 | 8 | 30 | 16.6 renders/s | 241.41 ms | 301.65 ms | -| Svelte 5.56.4 | 1 | 30 | 34.3 renders/s | 18.73 ms | 26.33 ms | -| Svelte 5.56.4 | 4 | 30 | 88.6 renders/s | 26.56 ms | 30.59 ms | -| Svelte 5.56.4 | 8 | 30 | 109.5 renders/s | 39.57 ms | 46.1 ms | +| Preact 10.29.7 | 1 | 30 | 73.3 renders/s | 10.52 ms | 11.61 ms | +| Preact 10.29.7 | 4 | 30 | 248.7 renders/s | 11.17 ms | 12.12 ms | +| Preact 10.29.7 | 8 | 30 | 410.5 renders/s | 12.57 ms | 14.32 ms | +| Vue 3.5.39 | 1 | 30 | 7.5 renders/s | 82.1 ms | 87.74 ms | +| Vue 3.5.39 | 4 | 30 | 16.5 renders/s | 132.6 ms | 153.1 ms | +| Vue 3.5.39 | 8 | 30 | 18.1 renders/s | 226.17 ms | 270.85 ms | +| Svelte 5.56.4 | 1 | 30 | 35.8 renders/s | 18.63 ms | 19.86 ms | +| Svelte 5.56.4 | 4 | 30 | 94.7 renders/s | 24.23 ms | 28.54 ms | +| Svelte 5.56.4 | 8 | 30 | 103.9 renders/s | 39.81 ms | 48.51 ms | ## 100-render isolation and reclamation probe @@ -57,7 +57,7 @@ data and one shared immutable program. | successful isolated renders | throughput | caller memory delta after GC | process-count delta | |---:|---:|---:|---:| -| 100/100 | 483.6 renders/s | -941.8 KiB | 0 | +| 100/100 | 520.9 renders/s | -941.8 KiB | 0 | Request-specific IDs were checked in every result. Memory and process deltas are endpoint observations after explicit caller GC, not operating-system RSS @@ -67,9 +67,9 @@ measurements. | Fixture | step rejection | memory rejection | timeout | observed timeout wall | handler cancellation after return | |---|---:|---:|---:|---:|---:| -| Preact 10.29.7 | limit:steps at 3650 | limit:memory_bytes at 133.1 KiB | limit:timeout at 200 ms | 201.77 ms | 37 µs | -| Vue 3.5.39 | limit:steps at 11956 | limit:memory_bytes at 496.1 KiB | limit:timeout at 200 ms | 213.2 ms | 19 µs | -| Svelte 5.56.4 | limit:steps at 1776 | limit:memory_bytes at 198.7 KiB | limit:timeout at 200 ms | 202.36 ms | 22 µs | +| Preact 10.29.7 | limit:steps at 3650 | limit:memory_bytes at 133.1 KiB | limit:timeout at 200 ms | 201.59 ms | 36 µs | +| Vue 3.5.39 | limit:steps at 11956 | limit:memory_bytes at 496.1 KiB | limit:timeout at 200 ms | 212.02 ms | 23 µs | +| Svelte 5.56.4 | limit:steps at 1776 | limit:memory_bytes at 198.7 KiB | limit:timeout at 200 ms | 203.43 ms | 28 µs | Memory rejection uses half the fixture's successful logical allocation. Timeout uses a non-returning asynchronous handler and verifies that its BEAM diff --git a/docs/beam-interpreter-architecture.md b/docs/beam-interpreter-architecture.md index c50ea6901..28cceb255 100644 --- a/docs/beam-interpreter-architecture.md +++ b/docs/beam-interpreter-architecture.md @@ -10,8 +10,9 @@ JavaScript errors, pinned Preact, Vue, and Svelte SSR acceptance fixtures, and bounded deterministic decoder/verifier mutation fuzzing, the bounded optional compiler contract, its supervised fixed-slot lifecycle, the minimal generated code ABI, a generated-module backend with import policy and soft-purge code -lifecycle, and specialized fixed-name forms for the first bounded `:pure_v1` -lowering. Broader ECMAScript conformance, object-model hardening, garbage +lifecycle, specialized fixed-name forms for the first bounded `:pure_v1` +lowering, and selective owner-local nested-function re-entry. Broader +ECMAScript conformance, object-model hardening, garbage collection, compiler coverage, and release hardening remain in progress. ## Summary diff --git a/docs/prototype-delta-audit.md b/docs/prototype-delta-audit.md index 81bbe330d..940455ca7 100644 --- a/docs/prototype-delta-audit.md +++ b/docs/prototype-delta-audit.md @@ -323,9 +323,11 @@ handler deoptimization, native pure-expression parity, timeout/memory/step containment, and concurrent cache reuse. Preact, Vue, and Svelte now have compiler/interpreter/native parity; compiler measurements cover sequential and concurrent SSR, cancellation, reclamation, and `+S 1:1`; and the selected -Test262 compiler gate passes 65/65 supported cases. The compiler remains release -quarantined because one-block specialization does not yet provide useful nested -compiled coverage or a demonstrated production performance benefit. No +Test262 compiler gate passes 65/65 supported cases. Selected nested bytecode +frames now re-enter compiled execution through owner-local, bounded eligibility +decisions while preserving stack limits and tail calls. The compiler remains +release quarantined because published Preact/Vue/Svelte medians still regress +against the interpreter despite improving Vue over root-only compilation. No prototype compiler runtime is approved for copying. ## Test extraction policy diff --git a/lib/quickbeam/vm/compiler.ex b/lib/quickbeam/vm/compiler.ex index 39cc9d261..a1f5adfe4 100644 --- a/lib/quickbeam/vm/compiler.ex +++ b/lib/quickbeam/vm/compiler.ex @@ -13,11 +13,12 @@ defmodule QuickBEAM.VM.Compiler do as typed compiler errors and never invoke native QuickJS. """ - alias QuickBEAM.VM.Compiler.{Contract, GeneratedModule, ModulePool} + alias QuickBEAM.VM.Compiler.{Context, Contract, GeneratedModule, ModulePool} alias QuickBEAM.VM.Compiler.Lowering.PureV1 - alias QuickBEAM.VM.{Evaluator, Execution, Interpreter, Program} + alias QuickBEAM.VM.{Evaluator, Execution, Frame, Function, Interpreter, Program} @type result :: {:ok, term()} | {:error, term()} | {:suspended, term()} + @type frame_action :: {:deopt, term()} | {:skip, struct(), struct()} | {:error, term()} @doc "Returns a child specification for the singleton generated-module pool." @spec child_spec(keyword()) :: Supervisor.child_spec() @@ -65,29 +66,89 @@ defmodule QuickBEAM.VM.Compiler do @doc "Starts compiled execution and returns a raw owner-local machine result." @spec start(Program.t(), keyword()) :: term() - def start(%Program{} = program, opts \\ []) when is_list(opts) do + def start(%Program{root: %Function{}} = program, opts \\ []) when is_list(opts) do {frame, execution} = Interpreter.initialize(program, opts) pool = Keyword.get(opts, :compiler_pool, ModulePool) + context = %Context{pool: pool, program: program} + execution = %{execution | compiler_context: context} - with :ok <- ensure_pool_available(pool), - {:ok, template} <- PureV1.lower(program.root), - {:ok, key} <- Contract.artifact_key(program, program.root, profile: :pure_v1), - {:ok, lease} <- ModulePool.checkout(pool, key, template) do - action = - try do - GeneratedModule.invoke(pool, lease, frame, execution) - after - safe_checkin(pool, lease) + 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()) :: frame_action() + def execute_frame( + %Frame{function: %Function{id: function_id} = function} = frame, + %Execution{compiler_context: %Context{pool: pool} = context} = execution + ) do + with :ok <- ensure_pool_available(pool) do + case Map.fetch(context.decisions, function_id) do + {:ok, :skip} -> + {:skip, frame, execution} + + {:ok, {:compile, key, template}} -> + invoke_frame(pool, key, template, frame, execution) + + :error -> + prepare_frame(function, frame, execution) + end + end + end + + defp prepare_frame( + %Function{} = function, + frame, + %Execution{ + compiler_context: %Context{ + program: %Program{root: %Function{} = root} = program, + min_nested_instructions: nested_minimum + } + } = execution + ) do + minimum = if function.id == root.id, do: 0, else: nested_minimum + + case PureV1.prepare(function, minimum) do + {:ok, template, _count} -> + with {:ok, key} <- Contract.artifact_key(program, function, profile: :pure_v1) do + execution = cache_decision(execution, function.id, {:compile, key, template}) + invoke_frame(execution.compiler_context.pool, key, template, frame, execution) end - resume_action(action, execution) + {:skip, _count} -> + {:skip, frame, cache_decision(execution, function.id, :skip)} + + {:error, reason} -> + {:error, reason} + end + end + + defp invoke_frame(pool, key, template, frame, execution) do + with {:ok, lease} <- ModulePool.checkout(pool, key, template) do + try do + GeneratedModule.invoke(pool, lease, frame, execution) + after + safe_checkin(pool, lease) + end + end + end + + defp cache_decision(%Execution{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 - {:error, reason} -> {:error, {:compiler_error, reason}, execution} + execution end end defp resume_action({:deopt, deopt}, _execution), do: Interpreter.resume_deopt_raw(deopt) + defp resume_action({:skip, frame, execution}, _initial), + do: Interpreter.run_frame(frame, execution) + defp resume_action({status, _value, %Execution{}} = result, _execution) when status in [:ok, :error], do: result diff --git a/lib/quickbeam/vm/compiler/context.ex b/lib/quickbeam/vm/compiler/context.ex new file mode 100644 index 000000000..9a632eac4 --- /dev/null +++ b/lib/quickbeam/vm/compiler/context.ex @@ -0,0 +1,19 @@ +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.Execution{}`. + """ + + @enforce_keys [:pool, :program] + defstruct [:pool, :program, decisions: %{}, max_decisions: 256, min_nested_instructions: 32] + + @type t :: %__MODULE__{ + pool: QuickBEAM.VM.Compiler.ModulePool.server(), + program: QuickBEAM.VM.Program.t(), + decisions: %{optional(non_neg_integer()) => :skip | {:compile, binary(), term()}}, + max_decisions: pos_integer(), + min_nested_instructions: non_neg_integer() + } +end diff --git a/lib/quickbeam/vm/compiler/generated_module/artifact.ex b/lib/quickbeam/vm/compiler/generated_module/artifact.ex index 3965cec7e..5a6616ff9 100644 --- a/lib/quickbeam/vm/compiler/generated_module/artifact.ex +++ b/lib/quickbeam/vm/compiler/generated_module/artifact.ex @@ -19,8 +19,7 @@ defmodule QuickBEAM.VM.Compiler.GeneratedModule.Artifact 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}} + {:error, {:compiler_resource_limit, :module_bytes, byte_size(binary), @max_binary_bytes}} end end @@ -31,8 +30,7 @@ defmodule QuickBEAM.VM.Compiler.GeneratedModule.Artifact do 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}} + {:error, {:compiler_resource_limit, :module_bytes, byte_size(binary), @max_binary_bytes}} expected != digest(binary) -> {:error, :artifact_digest_mismatch} diff --git a/lib/quickbeam/vm/compiler/lowering/pure_v1.ex b/lib/quickbeam/vm/compiler/lowering/pure_v1.ex index a973cdca3..18d99de82 100644 --- a/lib/quickbeam/vm/compiler/lowering/pure_v1.ex +++ b/lib/quickbeam/vm/compiler/lowering/pure_v1.ex @@ -33,8 +33,29 @@ defmodule QuickBEAM.VM.Compiler.Lowering.PureV1 do @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} <- plan(function), do: lower_plan(plan) + end + + @doc "Selects functions with a useful entry prefix and emits their validated plan." + @spec prepare(Function.t(), non_neg_integer()) :: + {:ok, Template.t(), non_neg_integer()} | {:skip, non_neg_integer()} | {:error, term()} + def prepare(%Function{} = function, minimum) when is_integer(minimum) and minimum >= 0 do with {:ok, plan} <- plan(function) do - {:ok, template(plan)} + count = entry_operation_count(plan) + + if count >= minimum, + do: {:ok, template(plan), count}, + else: {:skip, count} + end + end + + @spec lower_plan(Runtime.plan()) :: {:ok, Template.t()} + defp lower_plan(plan), do: {:ok, template(plan)} + + defp entry_operation_count(plan) do + case Map.get(plan, 0) do + {operations, _reason} -> length(operations) + nil -> 0 end end diff --git a/lib/quickbeam/vm/execution.ex b/lib/quickbeam/vm/execution.ex index a28d6e1f9..963f76312 100644 --- a/lib/quickbeam/vm/execution.ex +++ b/lib/quickbeam/vm/execution.ex @@ -12,6 +12,7 @@ defmodule QuickBEAM.VM.Execution do :step_limit, callers: [], cells: %{}, + compiler_context: nil, depth: 1, default_prototypes: %{}, error_prototypes: %{}, @@ -51,6 +52,7 @@ defmodule QuickBEAM.VM.Execution do | QuickBEAM.VM.ThenGetterBoundary.t() ], cells: %{optional(non_neg_integer()) => term()}, + compiler_context: QuickBEAM.VM.Compiler.Context.t() | nil, depth: non_neg_integer(), default_prototypes: %{ optional(QuickBEAM.VM.Object.kind()) => QuickBEAM.VM.Reference.t() diff --git a/lib/quickbeam/vm/frame.ex b/lib/quickbeam/vm/frame.ex index 083d8574f..1ee44240f 100644 --- a/lib/quickbeam/vm/frame.ex +++ b/lib/quickbeam/vm/frame.ex @@ -10,6 +10,7 @@ defmodule QuickBEAM.VM.Frame do :this, actual_arg_count: 0, closure_refs: {}, + compiler_entered: false, pc: 0, stack: [] ] @@ -18,6 +19,7 @@ defmodule QuickBEAM.VM.Frame do function: QuickBEAM.VM.Function.t(), callable: term(), closure_refs: tuple(), + compiler_entered: boolean(), locals: tuple(), args: tuple(), actual_arg_count: non_neg_integer(), diff --git a/lib/quickbeam/vm/interpreter.ex b/lib/quickbeam/vm/interpreter.ex index 439dd4d6f..06d78bdb4 100644 --- a/lib/quickbeam/vm/interpreter.ex +++ b/lib/quickbeam/vm/interpreter.ex @@ -12,10 +12,10 @@ defmodule QuickBEAM.VM.Interpreter do Async, AsyncBoundary, Builtins, + Compiler.Deopt, Continuation, ConstructorBoundary, Coroutine, - Compiler.Deopt, Execution, Exceptions, Export, @@ -42,6 +42,7 @@ defmodule QuickBEAM.VM.Interpreter do Value } + alias QuickBEAM.VM.Compiler, as: VMCompiler alias QuickBEAM.VM.Opcodes.Control, as: ControlOpcodes alias QuickBEAM.VM.Opcodes.Invocation, as: InvocationOpcodes alias QuickBEAM.VM.Opcodes.Locals, as: LocalOpcodes @@ -96,6 +97,10 @@ defmodule QuickBEAM.VM.Interpreter do run(frame, execution) end + @doc "Runs one canonical frame and execution state through the machine loop." + @spec run_frame(Frame.t(), Execution.t()) :: term() + def run_frame(%Frame{} = frame, %Execution{} = execution), do: run(frame, execution) + @spec resume(Continuation.t(), {:ok, term()} | {:error, term()}) :: result() def resume(%Continuation{} = continuation, result), do: continuation |> resume_raw(result) |> finish() @@ -255,6 +260,21 @@ defmodule QuickBEAM.VM.Interpreter do when pc >= tuple_size(function.instructions), do: {:error, {:invalid_program_counter, pc}, execution} + defp run( + %Frame{compiler_entered: false} = frame, + %Execution{compiler_context: compiler_context} = execution + ) + when not is_nil(compiler_context) do + frame = %{frame | compiler_entered: true} + + case VMCompiler.execute_frame(frame, execution) do + {:deopt, %Deopt{} = deopt} -> resume_deopt_raw(deopt) + {: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, %Execution{} = execution) do {opcode, operands} = elem(frame.function.instructions, frame.pc) {name, _size, _pops, _pushes, _format} = Opcodes.info(opcode) diff --git a/test/vm/compiler_orchestration_test.exs b/test/vm/compiler_orchestration_test.exs index 12a220bac..a468941d9 100644 --- a/test/vm/compiler_orchestration_test.exs +++ b/test/vm/compiler_orchestration_test.exs @@ -46,6 +46,62 @@ defmodule QuickBEAM.VM.CompilerOrchestrationTest do 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} = QuickBEAM.VM.eval(program, engine: :compiler) + + stats = ModulePool.stats(ModulePool) + assert stats.counts.ready >= 2 + assert stats.leases == 0 + end + + test "caches bounded owner-local compile and 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) == 1 + + assert Enum.count(decisions, fn {_id, decision} -> match?({:compile, _, _}, decision) end) == + 1 + 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 QuickBEAM.VM.eval(recursive, max_stack_depth: 5) == expected + assert QuickBEAM.VM.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} = + QuickBEAM.VM.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)") diff --git a/test/vm/compiler_pure_v1_test.exs b/test/vm/compiler_pure_v1_test.exs index accaa24fa..1ccad7f69 100644 --- a/test/vm/compiler_pure_v1_test.exs +++ b/test/vm/compiler_pure_v1_test.exs @@ -56,7 +56,10 @@ defmodule QuickBEAM.VM.CompilerPureV1Test do ], :unsupported_opcode} }} = PureV1.plan(function) + assert {:skip, 3} = PureV1.prepare(function, 4) + assert {:ok, prepared, 3} = PureV1.prepare(function, 3) assert {:ok, template} = PureV1.lower(function) + assert prepared == template assert [:run, :block, :step] == for({:function, _line, name, _arity, _clauses} <- template.forms, do: name) diff --git a/test/vm/vue_ssr_test.exs b/test/vm/vue_ssr_test.exs index 73c48f4a6..ca7aa8fd9 100644 --- a/test/vm/vue_ssr_test.exs +++ b/test/vm/vue_ssr_test.exs @@ -2,6 +2,7 @@ defmodule QuickBEAM.VM.VueSSRTest do use ExUnit.Case, async: false alias QuickBEAM.VM.Compiler + alias QuickBEAM.VM.Compiler.ModulePool @fixture "test/fixtures/vm/vue_ssr.js" @bundle_opts [ @@ -53,6 +54,7 @@ defmodule QuickBEAM.VM.VueSSRTest do assert beam_html == native_html assert compiler_html == native_html + assert ModulePool.stats(ModulePool).counts.ready >= 2 after QuickBEAM.stop(runtime) end From 041fd186a146aaaa6829808e88461d3234a91ef9 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Thu, 16 Jul 2026 01:11:07 +0200 Subject: [PATCH 56/87] Add guarded scalar compiler lowering --- CHANGELOG.md | 2 +- bench/README.md | 5 + bench/vm_compiler_perf.exs | 64 ++ docs/beam-compiler-contract.md | 90 ++- .../beam-compiler-performance-measurements.md | 47 ++ docs/beam-compiler-scheduler-measurements.md | 10 +- docs/beam-compiler-ssr-measurements.md | 36 +- docs/beam-interpreter-architecture.md | 11 +- docs/prototype-delta-audit.md | 13 +- lib/quickbeam/vm/compiler.ex | 60 +- lib/quickbeam/vm/compiler/context.ex | 5 +- lib/quickbeam/vm/compiler/contract.ex | 2 +- .../generated_module/import_policy.ex | 21 + lib/quickbeam/vm/compiler/lowering/pure_v1.ex | 176 ++++- .../vm/compiler/lowering/scalar_blocks.ex | 636 ++++++++++++++++++ lib/quickbeam/vm/compiler/module_pool.ex | 100 +++ lib/quickbeam/vm/compiler/runtime.ex | 339 +++++++++- lib/quickbeam/vm/opcodes/locals.ex | 145 ++-- lib/quickbeam/vm/opcodes/stack.ex | 86 +-- lib/quickbeam/vm/stack_state.ex | 79 +++ lib/quickbeam/vm/stack_verifier.ex | 28 +- mix.exs | 2 + test/vm/compiler_module_pool_test.exs | 32 + test/vm/compiler_orchestration_test.exs | 22 +- test/vm/compiler_pure_v1_test.exs | 65 +- test/vm/vue_ssr_test.exs | 4 +- 26 files changed, 1809 insertions(+), 271 deletions(-) create mode 100644 bench/vm_compiler_perf.exs create mode 100644 docs/beam-compiler-performance-measurements.md create mode 100644 lib/quickbeam/vm/compiler/lowering/scalar_blocks.ex create mode 100644 lib/quickbeam/vm/stack_state.ex diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d6ef361b..a975f45b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased -- Define the optional BEAM compiler contract with binary artifact identities, a fixed 32-module atom pool, lease/purge/cache lifecycle, versioned runtime ABI boundaries, and validated owner-local before-instruction deoptimization states. Add its supervised module pool, minimal canonical runtime ABI, structured generated-module backend, bounded v26 CFG analysis, specialized fixed-name `:pure_v1` forms, selective bounded nested-function re-entry, and explicit release-quarantined `engine: :compiler` orchestration with native, Preact/Vue/Svelte SSR, selected Test262, resource, concurrency, measurement, and single-scheduler acceptance coverage. +- Define the optional BEAM compiler contract with binary artifact identities, a fixed 32-module atom pool, lease/purge/cache lifecycle, versioned runtime ABI boundaries, and validated owner-local before-instruction deoptimization states. Add its supervised module pool, minimal canonical runtime ABI, structured generated-module backend, bounded v26 CFG analysis, specialized fixed-name `:pure_v1` forms, selective bounded nested-function re-entry, guarded scalar loop lowering with warm artifact/negative-decision caches, and explicit release-quarantined `engine: :compiler` orchestration with native, Preact/Vue/Svelte SSR, selected Test262, resource, concurrency, measurement, and single-scheduler acceptance coverage. - Add `QuickBEAM.VM.measure/2` with deterministic step/logical-memory counters and endpoint process observations, plus reproducible pinned SSR concurrency, timeout, cancellation, reclamation, and single-scheduler reports. - Serialize native addon initialization, reuse cached exports for aliases in one runtime, and reject implicit cross-runtime or post-reset reinitialization with a typed error. Add `allow_reinitialization: true` as an explicit compatibility escape hatch for addons that support multiple environments. diff --git a/bench/README.md b/bench/README.md index 967a80147..b587b49c1 100644 --- a/bench/README.md +++ b/bench/README.md @@ -24,6 +24,10 @@ MIX_ENV=bench mix run bench/concurrent.exs MIX_ENV=bench mix run bench/vm_ssr.exs \ --output docs/beam-ssr-measurements.md +# Reproduce cold-versus-warm compiler loop measurements +COMPILER_PERF_ITERATIONS=2000 MIX_ENV=bench \ + mix run bench/vm_compiler_perf.exs + # Reproduce the release-quarantined compiler SSR report MIX_ENV=bench mix run bench/vm_ssr.exs \ --engine compiler --output docs/beam-compiler-ssr-measurements.md @@ -43,6 +47,7 @@ endpoint BEAM process observations, sequential latency, concurrent throughput, and timeout/cancellation behavior for the pinned Preact, Vue, and Svelte fixtures. Published results are in [`docs/beam-ssr-measurements.md`](../docs/beam-ssr-measurements.md), +[`docs/beam-compiler-performance-measurements.md`](../docs/beam-compiler-performance-measurements.md), [`docs/beam-compiler-ssr-measurements.md`](../docs/beam-compiler-ssr-measurements.md), [`docs/beam-scheduler-measurements.md`](../docs/beam-scheduler-measurements.md), and diff --git a/bench/vm_compiler_perf.exs b/bench/vm_compiler_perf.exs new file mode 100644 index 000000000..3f8a7c17f --- /dev/null +++ b/bench/vm_compiler_perf.exs @@ -0,0 +1,64 @@ +Mix.Task.run("app.start") + +alias QuickBEAM.VM.Compiler + +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) + +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, isolation: :caller, max_steps: 1_000_000] + + {cold_us, compiled_result} = :timer.tc(fn -> QuickBEAM.VM.eval(program, compiler_opts) end) + interpreted_result = QuickBEAM.VM.eval(program, interpreter_opts) + {:ok, _raw_value, raw_execution} = Compiler.start(program, 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 = QuickBEAM.VM.eval(program, compiler_opts) end) + + interpreter_us = + average_us.(fn -> ^interpreted_result = QuickBEAM.VM.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/docs/beam-compiler-contract.md b/docs/beam-compiler-contract.md index 62f7c120f..b9da2ec6b 100644 --- a/docs/beam-compiler-contract.md +++ b/docs/beam-compiler-contract.md @@ -14,14 +14,26 @@ one evaluation process. The first extraction slice is intentionally narrow: verified basic blocks containing literals, stack movement, local reads/writes, primitive value operations, and terminal branches. Lowering first builds a bounded immutable -block plan, then emits specialized fixed-name `run/3`, `block/4`, and `step/4` -clauses whose operations call the canonical runtime ABI. Calls, accessors, -constructors, iterators, exceptions, Promise -operations, host calls, and `await` deopt before their instruction until their -resumable compiler ABI exists. The root frame always receives an entry attempt; -nested frames require at least 32 pure entry instructions. Owner-local compile -or skip decisions are cached for at most 256 function IDs per evaluation, which -bounds metadata while avoiding repeated CFG analysis for hot calls. +block plan, then emits fixed-name generated forms. The generic path uses +`run/3` and `block/4` with bounded canonical runtime block calls. Eligible scalar +loops use `run/3` and `block/7`, retain stack values as BEAM expressions, carry +locals as bounded tuples, and rebuild canonical frames only at deoptimization. +Calls, accessors, constructors, iterators, exceptions, Promise operations, host +calls, and `await` deopt before their instruction until their resumable compiler +ABI exists. + +The root frame always receives an eligibility attempt. Nested frames are +selected by a 32-operation entry prefix on the generic path; scalar-eligible +functions may instead qualify by total lowered size or a verified backward CFG +edge. Owner-local compile or skip decisions are cached for at most 256 +function IDs per evaluation. The module pool also keeps at most 256 binary-keyed +negative decisions, avoiding repeated warm lowering without creating atoms. + +Scalar lowering is deliberately narrower: at most 64 lowered operations, eight +blocks, 32 operations per block, eight arguments, eight locals, and stack depth +64. Locals captured by nested functions are excluded. Lexical checked-local +reads require a successful initialization dataflow proof; otherwise the generic +before-instruction deoptimization path remains authoritative. ## Non-negotiable invariants @@ -29,8 +41,9 @@ bounds metadata while avoiding repeated CFG analysis for hot calls. 2. Generated code never owns a heap, globals, cells, Promise state, jobs, or handler tasks. It receives and returns canonical `%Frame{}` and `%Execution{}` values. -3. Generated code calls one versioned compiler runtime ABI. It does not call - heap internals or copy JavaScript semantic algorithms. +3. Generated code calls one versioned compiler runtime ABI and never calls heap + internals. Allowlisted BEAM primitives may specialize proven or guarded + primitive values; every guard miss calls the canonical runtime semantics. 4. Every transition to the interpreter is an explicit, validated deoptimization at a verified instruction boundary. 5. A timeout, memory failure, step failure, throw, or suspension has the same @@ -132,8 +145,8 @@ safely purged remains loaded until VM shutdown. Generated modules may call `:erlang` guard/BIF operations approved by a BEAM-disassembly test and one module, `QuickBEAM.VM.Compiler.Runtime`. That -module is the versioned ABI and delegates semantics to the existing canonical -layers: +module is runtime ABI version 2 and delegates semantics to the existing +canonical layers: - `Value` for primitive coercion and operators; - `Properties` for descriptors, prototypes, and accessor actions; @@ -148,16 +161,17 @@ rather than recursively invoking JavaScript. The first ABI now contains only: -- exact step charging at basic-block boundaries; -- verified local/argument/stack transforms over `%Frame{}` through existing - opcode-family modules; -- primitive unary/binary operations through `Value`; +- exact canonical-frame and compact-state charging at basic-block boundaries; +- verified local/argument/stack transforms shared with opcode-family modules; +- guarded primitive operations with canonical `Value` fallback; - truthiness and verified branch selection; -- construction of a typed `%Compiler.Deopt{}`. +- reconstruction of canonical frames and typed `%Compiler.Deopt{}` values. `Compiler.GeneratedModule.ImportPolicy` inspects every generated module import -before installation. The closed initial allowlist contains this ABI plus the two -Erlang `get_module_info` imports emitted for every generated module. Properties, calls, +before installation. The closed allowlist contains this ABI, a fixed set of +numeric/tuple guard primitives, and the two Erlang `get_module_info` imports +emitted for every generated module. Generated variable names come only from a +fixed compiler-owned bank; no source value becomes an atom. Properties, calls, throws, and suspension are added only with differential and resource-limit tests for their action protocol. @@ -166,8 +180,8 @@ for their action protocol. A compiled basic block may debit its instruction count once only when every instruction in the block is guaranteed to execute and cannot throw or suspend. If insufficient steps remain, it deopts before the block without charging any of -its instructions. Blocks with dynamic exits charge at individual instruction -boundaries. This preserves the interpreter's exact `remaining_steps` contract +its instructions. A terminal conditional still executes exactly once after the +preceding straight-line operations. This preserves the interpreter's exact `remaining_steps` contract and `measure/2` counters. All allocation goes through canonical runtime layers and their logical memory @@ -175,12 +189,12 @@ charges. The compiled path runs in the same monitored evaluation process, so process heap limits, outer timeout, handler ownership, and cancellation remain unchanged. -Generated basic blocks are capped at 256 QuickJS instructions and one function -artifact at 4,096 blocks and 4,096 lowered instructions. The initial profile -deoptimizes at a -control-flow edge rather than recursively running another JavaScript block. The -existing `+S 1:1` ticker-gap and timeout report remains a regression gate for -compiled execution. +Generated generic blocks are capped at 256 QuickJS instructions and one function +artifact at 4,096 blocks and 4,096 lowered instructions. Generic blocks still +deoptimize at control-flow edges. The narrower scalar profile may tail-call at +most eight generated successor blocks while preserving per-block charging and +outer process containment. The existing `+S 1:1` ticker-gap and timeout report +remains a regression gate for compiled execution. ## Deoptimization state @@ -233,8 +247,8 @@ An adaptive policy, if added, must be explicitly selected by the caller and reported by measurement/telemetry. It still may never fall back to native QuickJS. -The current `+S 1:1` compiler-tier Vue probe reports a 33.0 ms maximum ticker -gap against the 75 ms bound and a 50.99 ms timeout p95 against the 60 ms bound. +The current `+S 1:1` compiler-tier Vue probe reports a 31.67 ms maximum ticker +gap against the 75 ms bound and a 51.03 ms timeout p95 against the 60 ms bound. The pinned compiler SSR report covers 30 sequential samples plus concurrency 1/4/8 for Preact, Vue, and Svelte, with 100/100 isolated Preact renders and successful step, memory, timeout, and cancellation checks. The Vue parity gate @@ -242,8 +256,14 @@ also requires more than the root generated module, proving selected nested-frame coverage. The selected Test262 gate passes 65/65 supported tests through both the interpreter and compiler tier. -On the published runs, compiler-tier sequential medians are 10.71 ms for Preact, -69.95 ms for Vue, and 16.50 ms for Svelte, versus interpreter medians of 8.49 ms, +The separate warm loop report now measures 1.72× arithmetic-loop, 1.97× +branch-loop, and 2.31× local-arithmetic speedups over the interpreter after cold +compilation costs of 9.26–42.61 ms. Those bounded micro-workloads demonstrate +that scalar generated execution can amortize compilation; they do not replace +the SSR release gate. + +On the published runs, compiler-tier sequential medians are 9.83 ms for Preact, +72.42 ms for Vue, and 17.05 ms for Svelte, versus interpreter medians of 8.49 ms, 48.55 ms, and 12.36 ms respectively. These separate reproducible runs are not a paired statistical comparison, but they show that selective nested-function re-entry is still not a performance release candidate. @@ -255,8 +275,8 @@ The compiler remains release-quarantined despite those compatibility results: - `engine: :interpreter` remains the documented default; - compiler selection and supervision must always be explicit; - compiler infrastructure failures never restart in another engine; -- fixture parity does not imply that the current one-entry-block-per-selected- - frame specialization has a production performance benefit; +- scalar loop speedups do not imply that unsupported SSR-heavy opcode families + have a production performance benefit; - stable release promotion requires broader useful compiled coverage, a non-regressing compiler/interpreter performance comparison, and no regression in the existing safety gates; @@ -272,8 +292,8 @@ Prototype code is not copied as a subsystem. Each part has one bounded target: | CFG/basic-block discovery | Adapt the pure graph algorithm to current v26 instruction tuples. Targets remain verified instruction indexes. | | Stack analysis | Do not extract. `StackVerifier` remains authoritative; lowering consumes its verified heights and joins. | | Opcode support analysis | Replace broad fallback analysis with a closed `:pure_v1` allowlist. Every other opcode emits a before-instruction deopt. | -| Local/stack lowering | Adapt abstract-form patterns to transformations over canonical `%Frame{}` through the compiler runtime ABI. | -| Value lowering | Call `Value`; do not retain prototype coercion or object tags. Guards deopt before an instruction when specialization assumptions fail. | +| Local/stack lowering | Adapt scalar block arguments only under fixed stack/slot/form bounds. Rebuild canonical `%Frame{}` at deoptimization and exclude captured locals. | +| Value lowering | Use allowlisted guarded BEAM primitives with canonical `Value` fallback. Do not retain prototype coercion or object tags. | | Property lowering | Initially deopt. Later call `Properties` and preserve its accessor boundary actions. | | Call lowering | Initially deopt. Later call `Invocation`; calls never recursively enter generated code or the interpreter. | | Promise/async lowering | Initially deopt before Promise, host-call, and `await` instructions. Later preserve `Async`, Promise jobs, and typed suspension boundaries. | diff --git a/docs/beam-compiler-performance-measurements.md b/docs/beam-compiler-performance-measurements.md new file mode 100644 index 000000000..7f7be7765 --- /dev/null +++ b/docs/beam-compiler-performance-measurements.md @@ -0,0 +1,47 @@ +# BEAM compiler warm-execution measurements + +This report separates cold compilation from repeated owner-local execution for +three bounded lexical-loop workloads. It measures the experimental BEAM compiler +against the canonical interpreter through `QuickBEAM.VM.eval/2` with +`isolation: :caller`; native QuickJS is not part of this comparison. + +## Reproduction + +```sh +COMPILER_PERF_ITERATIONS=2000 MIX_ENV=bench \ + mix run bench/vm_compiler_perf.exs +``` + +- Git base: `85d7a677` +- Working tree at measurement: modified +- Generated: 2026-07-15T23:01:55Z +- Elixir: 1.20.2 +- OTP: 29 +- OS: Linux 7.0.0-27-generic +- CPU: AMD Ryzen 9 9950X 16-Core Processor +- Iterations per warm tier: 2,000 + +## Results + +| workload | compiled functions | cold compiler eval | warm compiler average | interpreter average | warm speedup | +|---|---:|---:|---:|---:|---:| +| arithmetic loop | 1 | 42.61 ms | 300.45 µs | 515.71 µs | 1.72× | +| branch loop | 1 | 9.26 ms | 304.48 µs | 598.69 µs | 1.97× | +| local arithmetic loop | 1 | 26.76 ms | 317.08 µs | 732.01 µs | 2.31× | + +Cold time includes bounded CFG/dataflow analysis, Erlang form compilation, +module installation, lease orchestration, and the first evaluation. Warm time +includes public VM option handling, root interpreter dispatch, compiler pool +lookup, one generated nested-function execution, and result completion. It does +not hide compiler orchestration by calling a generated module directly. + +The optimized tier keeps verified stack values as generated BEAM expressions, +uses bounded tuple locals, tail-calls generated successor blocks, specializes +numeric operations behind guards, and reconstructs `%QuickBEAM.VM.Frame{}` only +at explicit deoptimization. Fallback clauses still call canonical +`QuickBEAM.VM.Compiler.Runtime` value semantics. + +These micro-workload gains do not promote the compiler for release. The pinned +Preact/Vue/Svelte report remains the end-to-end compatibility and performance +gate; unsupported object, call, iterator, Promise, and exception operations +still deoptimize to the interpreter. diff --git a/docs/beam-compiler-scheduler-measurements.md b/docs/beam-compiler-scheduler-measurements.md index 95d04416d..ad961838e 100644 --- a/docs/beam-compiler-scheduler-measurements.md +++ b/docs/beam-compiler-scheduler-measurements.md @@ -5,9 +5,9 @@ ticker share one scheduler. The baseline sleeps for the median render wall time, allowing the same ticker to run without compiler work. - Engine: compiler -- Git base: `a703c929` +- Git base: `85d7a677` - Working tree at measurement: modified -- Generated: 2026-07-15T21:28:21Z +- Generated: 2026-07-15T23:03:26Z - Elixir: 1.20.2 - OTP: 29 - ERTS: 17.0.2 @@ -20,8 +20,8 @@ time, allowing the same ticker to run without compiler work. | workload | wall median | wall p95 | ticker gap median | ticker gap p95 | ticker gap max | ticks median | |---|---:|---:|---:|---:|---:|---:| -| Vue SSR | 169.57 ms | 196.97 ms | 2.0 ms | 8.24 ms | 33.0 ms | 63 | -| sleep baseline (170 ms target) | 170.95 ms | 170.97 ms | 2.0 ms | 2.01 ms | 4.11 ms | 85 | +| Vue SSR | 161.83 ms | 195.69 ms | 2.0 ms | 7.98 ms | 31.67 ms | 61 | +| sleep baseline (162 ms target) | 162.96 ms | 162.98 ms | 2.0 ms | 2.01 ms | 2.03 ms | 81 | Acceptance bound: Vue SSR ticker gap ≤ 75.0 ms. @@ -31,6 +31,6 @@ An infinite JavaScript loop was evaluated with a 50 ms outer timeout. | timeout | wall median | wall p95 | wall max | median overshoot | |---:|---:|---:|---:|---:| -| 50 ms | 50.98 ms | 50.99 ms | 50.99 ms | 975 µs | +| 50 ms | 50.98 ms | 51.03 ms | 51.03 ms | 981 µs | Acceptance bound: timeout p95 ≤ 60.0 ms. diff --git a/docs/beam-compiler-ssr-measurements.md b/docs/beam-compiler-ssr-measurements.md index 50d96be24..552358ee8 100644 --- a/docs/beam-compiler-ssr-measurements.md +++ b/docs/beam-compiler-ssr-measurements.md @@ -9,9 +9,9 @@ single-scheduler fairness and timeout gate is published separately in ## Environment - Engine: compiler -- Git base: `a703c929` +- Git base: `85d7a677` - Working tree at measurement: modified -- Generated: 2026-07-15T21:27:57Z +- Generated: 2026-07-15T23:02:53Z - Elixir: 1.20.2 - OTP: 29 - ERTS: 17.0.2 @@ -27,9 +27,9 @@ single-scheduler fairness and timeout gate is published separately in | Fixture | wall median | wall p95 | VM steps | logical memory | endpoint process memory | reductions median | |---|---:|---:|---:|---:|---:|---:| -| Preact 10.29.7 | 10.71 ms | 11.44 ms | 3651 | 266.2 KiB | 4.5 MiB | 515490 | -| Vue 3.5.39 | 69.95 ms | 75.98 ms | 11957 | 992.3 KiB | 77.15 MiB | 3872070 | -| Svelte 5.56.4 | 16.5 ms | 19.3 ms | 1777 | 397.3 KiB | 15.61 MiB | 770310 | +| Preact 10.29.7 | 9.83 ms | 10.44 ms | 3651 | 266.2 KiB | 4.5 MiB | 336743 | +| Vue 3.5.39 | 72.42 ms | 79.31 ms | 11957 | 992.3 KiB | 77.15 MiB | 3990571 | +| Svelte 5.56.4 | 17.05 ms | 20.15 ms | 1777 | 397.3 KiB | 15.61 MiB | 735344 | `VM steps` and `logical memory` are deterministic counters. Endpoint process memory and reductions are observed once after result conversion; they are not @@ -40,15 +40,15 @@ rendering, conversion, and reply delivery. | Fixture | concurrency | renders | throughput | per-render wall median | per-render wall p95 | |---|---:|---:|---:|---:|---:| -| Preact 10.29.7 | 1 | 30 | 73.3 renders/s | 10.52 ms | 11.61 ms | -| Preact 10.29.7 | 4 | 30 | 248.7 renders/s | 11.17 ms | 12.12 ms | -| Preact 10.29.7 | 8 | 30 | 410.5 renders/s | 12.57 ms | 14.32 ms | -| Vue 3.5.39 | 1 | 30 | 7.5 renders/s | 82.1 ms | 87.74 ms | -| Vue 3.5.39 | 4 | 30 | 16.5 renders/s | 132.6 ms | 153.1 ms | -| Vue 3.5.39 | 8 | 30 | 18.1 renders/s | 226.17 ms | 270.85 ms | -| Svelte 5.56.4 | 1 | 30 | 35.8 renders/s | 18.63 ms | 19.86 ms | -| Svelte 5.56.4 | 4 | 30 | 94.7 renders/s | 24.23 ms | 28.54 ms | -| Svelte 5.56.4 | 8 | 30 | 103.9 renders/s | 39.81 ms | 48.51 ms | +| Preact 10.29.7 | 1 | 30 | 78.3 renders/s | 10.16 ms | 10.61 ms | +| Preact 10.29.7 | 4 | 30 | 242.8 renders/s | 10.82 ms | 13.05 ms | +| Preact 10.29.7 | 8 | 30 | 403.1 renders/s | 12.56 ms | 14.29 ms | +| Vue 3.5.39 | 1 | 30 | 7.4 renders/s | 82.03 ms | 88.88 ms | +| Vue 3.5.39 | 4 | 30 | 16.7 renders/s | 136.04 ms | 156.63 ms | +| Vue 3.5.39 | 8 | 30 | 18.0 renders/s | 232.63 ms | 289.2 ms | +| Svelte 5.56.4 | 1 | 30 | 36.2 renders/s | 18.3 ms | 20.34 ms | +| Svelte 5.56.4 | 4 | 30 | 98.5 renders/s | 23.94 ms | 29.09 ms | +| Svelte 5.56.4 | 8 | 30 | 115.8 renders/s | 35.31 ms | 45.59 ms | ## 100-render isolation and reclamation probe @@ -57,7 +57,7 @@ data and one shared immutable program. | successful isolated renders | throughput | caller memory delta after GC | process-count delta | |---:|---:|---:|---:| -| 100/100 | 520.9 renders/s | -941.8 KiB | 0 | +| 100/100 | 407.4 renders/s | -941.8 KiB | 0 | Request-specific IDs were checked in every result. Memory and process deltas are endpoint observations after explicit caller GC, not operating-system RSS @@ -67,9 +67,9 @@ measurements. | Fixture | step rejection | memory rejection | timeout | observed timeout wall | handler cancellation after return | |---|---:|---:|---:|---:|---:| -| Preact 10.29.7 | limit:steps at 3650 | limit:memory_bytes at 133.1 KiB | limit:timeout at 200 ms | 201.59 ms | 36 µs | -| Vue 3.5.39 | limit:steps at 11956 | limit:memory_bytes at 496.1 KiB | limit:timeout at 200 ms | 212.02 ms | 23 µs | -| Svelte 5.56.4 | limit:steps at 1776 | limit:memory_bytes at 198.7 KiB | limit:timeout at 200 ms | 203.43 ms | 28 µs | +| Preact 10.29.7 | limit:steps at 3650 | limit:memory_bytes at 133.1 KiB | limit:timeout at 200 ms | 201.08 ms | 28 µs | +| Vue 3.5.39 | limit:steps at 11956 | limit:memory_bytes at 496.1 KiB | limit:timeout at 200 ms | 212.87 ms | 16 µs | +| Svelte 5.56.4 | limit:steps at 1776 | limit:memory_bytes at 198.7 KiB | limit:timeout at 200 ms | 203.77 ms | 24 µs | Memory rejection uses half the fixture's successful logical allocation. Timeout uses a non-returning asynchronous handler and verifies that its BEAM diff --git a/docs/beam-interpreter-architecture.md b/docs/beam-interpreter-architecture.md index 28cceb255..4f201f718 100644 --- a/docs/beam-interpreter-architecture.md +++ b/docs/beam-interpreter-architecture.md @@ -761,7 +761,8 @@ The reproducible fixture measurements are published in fairness and timeout gates in [`beam-scheduler-measurements.md`](beam-scheduler-measurements.md), the release-quarantined compiler SSR matrix in -[`beam-compiler-ssr-measurements.md`](beam-compiler-ssr-measurements.md), and its +[`beam-compiler-ssr-measurements.md`](beam-compiler-ssr-measurements.md), warm-loop results in +[`beam-compiler-performance-measurements.md`](beam-compiler-performance-measurements.md), and its single-scheduler run in [`beam-compiler-scheduler-measurements.md`](beam-compiler-scheduler-measurements.md). The reports @@ -793,9 +794,11 @@ children = [{QuickBEAM.VM.Compiler, capacity: 8}] QuickBEAM.VM.eval(program, engine: :compiler) ``` -The compiler runs one bounded specialized pure block and may deopt at a verified -instruction boundary into the interpreter. Compiler infrastructure failures are -typed errors and never restart the program or invoke native QuickJS. The default +The generic compiler path runs one bounded pure block. Eligible lexical loops +use bounded scalar generated forms, tail-call successor blocks, and reconstruct +the canonical frame only at a verified deoptimization boundary. Compiler +infrastructure failures are typed errors and never restart the program or invoke +native QuickJS. The default `QuickBEAM.VM` path remains the interpreter until compiler resource, scheduler, and native differential gates are published. diff --git a/docs/prototype-delta-audit.md b/docs/prototype-delta-audit.md index 940455ca7..a590bdad2 100644 --- a/docs/prototype-delta-audit.md +++ b/docs/prototype-delta-audit.md @@ -325,10 +325,15 @@ compiler/interpreter/native parity; compiler measurements cover sequential and concurrent SSR, cancellation, reclamation, and `+S 1:1`; and the selected Test262 compiler gate passes 65/65 supported cases. Selected nested bytecode frames now re-enter compiled execution through owner-local, bounded eligibility -decisions while preserving stack limits and tail calls. The compiler remains -release quarantined because published Preact/Vue/Svelte medians still regress -against the interpreter despite improving Vue over root-only compilation. No -prototype compiler runtime is approved for copying. +decisions while preserving stack limits and tail calls. The prototype's useful +performance lesson has also been extracted without its runtime: eligible +uncaptured lexical loops carry scalar stack/tuple state across generated blocks, +use guarded BEAM numeric primitives with canonical fallback, and rebuild frames +only at deoptimization. Warm arithmetic/branch/local loop execution is now +1.72×/1.97×/2.31× faster than the interpreter while cold compilation remains +9.26–42.61 ms. The compiler remains release quarantined because broader +Preact/Vue/Svelte performance and unsupported semantic families remain the +release gate. No prototype compiler runtime is approved for copying. ## Test extraction policy diff --git a/lib/quickbeam/vm/compiler.ex b/lib/quickbeam/vm/compiler.ex index a1f5adfe4..1dfe6bb71 100644 --- a/lib/quickbeam/vm/compiler.ex +++ b/lib/quickbeam/vm/compiler.ex @@ -92,6 +92,9 @@ defmodule QuickBEAM.VM.Compiler do {: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 @@ -108,33 +111,62 @@ defmodule QuickBEAM.VM.Compiler do } } = execution ) do - minimum = if function.id == root.id, do: 0, else: nested_minimum + minimum = if function.id == root.id, do: 1, else: nested_minimum + + with {:ok, key} <- Contract.artifact_key(program, function, profile: :pure_v1) do + case ModulePool.checkout_cached(execution.compiler_context.pool, key) do + {:ok, lease} -> + execution = cache_decision(execution, function.id, {:cached, key}) + invoke_lease(execution.compiler_context.pool, lease, frame, execution) + + :skip -> + {:skip, frame, cache_decision(execution, function.id, :skip)} + :miss -> + prepare_uncached_frame(function, minimum, key, frame, execution) + + {:error, reason} -> + {:error, reason} + end + end + end + + defp prepare_uncached_frame(function, minimum, key, frame, execution) do case PureV1.prepare(function, minimum) do {:ok, template, _count} -> - with {:ok, key} <- Contract.artifact_key(program, function, profile: :pure_v1) do - execution = cache_decision(execution, function.id, {:compile, key, template}) - invoke_frame(execution.compiler_context.pool, key, template, frame, execution) - end + execution = cache_decision(execution, function.id, {:compile, key, template}) + invoke_frame(execution.compiler_context.pool, key, template, frame, execution) {:skip, _count} -> - {:skip, frame, cache_decision(execution, function.id, :skip)} + with :ok <- ModulePool.remember_skip(execution.compiler_context.pool, key) do + {:skip, frame, cache_decision(execution, function.id, :skip)} + end {:error, reason} -> {:error, reason} end end - defp invoke_frame(pool, key, template, frame, execution) do - with {:ok, lease} <- ModulePool.checkout(pool, key, template) do - try do - GeneratedModule.invoke(pool, lease, frame, execution) - after - safe_checkin(pool, lease) - end + defp invoke_cached(pool, key, function, frame, execution) do + case ModulePool.checkout_cached(pool, key) do + {:ok, lease} -> invoke_lease(pool, lease, frame, execution) + :skip -> {:skip, frame, cache_decision(execution, function.id, :skip)} + :miss -> prepare_frame(function, frame, execution) + {:error, reason} -> {:error, reason} end end + defp invoke_frame(pool, key, template, frame, execution) do + with {:ok, lease} <- ModulePool.checkout(pool, key, template), + do: invoke_lease(pool, lease, frame, execution) + end + + defp invoke_lease(pool, lease, frame, execution) do + GeneratedModule.invoke(pool, lease, frame, execution) + after + safe_checkin(pool, lease) + end + defp cache_decision(%Execution{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)} @@ -171,7 +203,7 @@ defmodule QuickBEAM.VM.Compiler do defp ensure_pool_available(pool), do: {:error, {:compiler_pool_unavailable, pool}} defp safe_checkin(pool, lease) do - ModulePool.checkin(pool, lease) + ModulePool.checkin_active(pool, lease) catch :exit, _reason -> :ok end diff --git a/lib/quickbeam/vm/compiler/context.ex b/lib/quickbeam/vm/compiler/context.ex index 9a632eac4..5aa597bcb 100644 --- a/lib/quickbeam/vm/compiler/context.ex +++ b/lib/quickbeam/vm/compiler/context.ex @@ -12,7 +12,10 @@ defmodule QuickBEAM.VM.Compiler.Context do @type t :: %__MODULE__{ pool: QuickBEAM.VM.Compiler.ModulePool.server(), program: QuickBEAM.VM.Program.t(), - decisions: %{optional(non_neg_integer()) => :skip | {:compile, binary(), term()}}, + decisions: %{ + optional(non_neg_integer()) => + :skip | {:cached, binary()} | {:compile, binary(), term()} + }, max_decisions: pos_integer(), min_nested_instructions: non_neg_integer() } diff --git a/lib/quickbeam/vm/compiler/contract.ex b/lib/quickbeam/vm/compiler/contract.ex index f5d94c1c8..b85ae034f 100644 --- a/lib/quickbeam/vm/compiler/contract.ex +++ b/lib/quickbeam/vm/compiler/contract.ex @@ -10,7 +10,7 @@ defmodule QuickBEAM.VM.Compiler.Contract do alias QuickBEAM.VM.{Function, Program} @contract_version 1 - @runtime_abi_version 1 + @runtime_abi_version 2 @artifact_key_bytes 32 @profiles [:pure_v1] diff --git a/lib/quickbeam/vm/compiler/generated_module/import_policy.ex b/lib/quickbeam/vm/compiler/generated_module/import_policy.ex index 6a0d660d2..e75d67313 100644 --- a/lib/quickbeam/vm/compiler/generated_module/import_policy.ex +++ b/lib/quickbeam/vm/compiler/generated_module/import_policy.ex @@ -10,17 +10,38 @@ defmodule QuickBEAM.VM.Compiler.GeneratedModule.ImportPolicy do 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, :truthy?, 1}, {Runtime, :unary, 2}, {Runtime, :binary, 3} diff --git a/lib/quickbeam/vm/compiler/lowering/pure_v1.ex b/lib/quickbeam/vm/compiler/lowering/pure_v1.ex index 18d99de82..4298eaf94 100644 --- a/lib/quickbeam/vm/compiler/lowering/pure_v1.ex +++ b/lib/quickbeam/vm/compiler/lowering/pure_v1.ex @@ -9,6 +9,7 @@ defmodule QuickBEAM.VM.Compiler.Lowering.PureV1 do alias QuickBEAM.VM.Compiler.Analysis.CFG alias QuickBEAM.VM.Compiler.GeneratedModule.Template + alias QuickBEAM.VM.Compiler.Lowering.ScalarBlocks alias QuickBEAM.VM.Compiler.Runtime alias QuickBEAM.VM.{Function, StackVerifier} alias QuickBEAM.VM.Opcodes.Invocation @@ -17,40 +18,56 @@ defmodule QuickBEAM.VM.Compiler.Lowering.PureV1 do @max_block_count 4_096 @max_lowered_instruction_count 4_096 @suspension_operations [:await] ++ Invocation.opcodes() + @scalar_operations %{get_loc_check: :local, post_inc: :value, post_dec: :value} @line 1 @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 <- StackVerifier.verify(function), - {:ok, blocks} <- CFG.analyze(function), - plan = Map.new(blocks, &plan_block/1), - :ok <- validate_plan_size(plan) do - {:ok, plan} - end + with {:ok, plan, _levels} <- analyze_plan(function), do: {:ok, plan} 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} <- plan(function), do: lower_plan(plan) + with {:ok, plan, levels} <- analyze_plan(function), do: lower_plan(function, plan, levels) end @doc "Selects functions with a useful entry prefix and emits their validated plan." @spec prepare(Function.t(), non_neg_integer()) :: {:ok, Template.t(), non_neg_integer()} | {:skip, non_neg_integer()} | {:error, term()} def prepare(%Function{} = function, minimum) when is_integer(minimum) and minimum >= 0 do - with {:ok, plan} <- plan(function) do - count = entry_operation_count(plan) + with {:ok, plan, levels} <- analyze_plan(function) do + count = lowered_operation_count(plan) + entry_count = entry_operation_count(plan) + template = template(function, plan, levels) + + eligible? = + if scalar_template?(template) do + count >= minimum or (minimum > 0 and loop_plan?(plan)) + else + entry_count >= minimum + end + + if eligible?, do: {:ok, template, count}, else: {:skip, count} + end + end - if count >= minimum, - do: {:ok, template(plan), count}, - else: {:skip, count} + defp analyze_plan(function) do + with {:ok, analysis} <- StackVerifier.analyze(function), + true <- analysis.maximum == function.stack_size, + {:ok, blocks} <- CFG.analyze(function), + plan = Map.new(blocks, &plan_block/1), + :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(Runtime.plan()) :: {:ok, Template.t()} - defp lower_plan(plan), do: {:ok, template(plan)} + @spec lower_plan(Function.t(), Runtime.plan(), map()) :: {:ok, Template.t()} + defp lower_plan(function, plan, levels), do: {:ok, template(function, plan, levels)} defp entry_operation_count(plan) do case Map.get(plan, 0) do @@ -59,6 +76,22 @@ defmodule QuickBEAM.VM.Compiler.Lowering.PureV1 do end end + defp scalar_template?(%Template{forms: forms}), + do: Enum.any?(forms, &match?({:function, _, :block, 7, _}, &1)) + + defp lowered_operation_count(plan) do + Enum.reduce(plan, 0, fn {_pc, {operations, _reason}}, count -> count + length(operations) 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 plan_block(block) do {supported, remainder} = Enum.split_while(block.instructions, &supported_instruction?/1) capped? = length(supported) > @max_block_instruction_count @@ -70,10 +103,17 @@ defmodule QuickBEAM.VM.Compiler.Lowering.PureV1 do end defp supported_instruction?({_pc, name, _operands}), - do: match?({:ok, _family}, Runtime.operation_family(name)) + do: + Map.has_key?(@scalar_operations, name) or + match?({:ok, _family}, Runtime.operation_family(name)) defp operation({_pc, name, operands}) do - {:ok, family} = Runtime.operation_family(name) + family = + case Map.fetch(@scalar_operations, name) do + {:ok, family} -> family + :error -> name |> Runtime.operation_family() |> elem(1) + end + {family, name, operands} end @@ -86,6 +126,8 @@ defmodule QuickBEAM.VM.Compiler.Lowering.PureV1 do 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 @@ -108,8 +150,33 @@ defmodule QuickBEAM.VM.Compiler.Lowering.PureV1 do {:compiler_resource_limit, :lowered_instructions, count, @max_lowered_instruction_count}} end - defp template(plan) do - step_forms = if has_operations?(plan), do: [step_form(plan)], else: [] + defp template(function, plan, levels) do + case ScalarBlocks.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: @@ -123,8 +190,11 @@ defmodule QuickBEAM.VM.Compiler.Lowering.PureV1 do } end - defp has_operations?(plan), - do: Enum.any?(plan, fn {_pc, {operations, _reason}} -> operations != [] 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) @@ -157,7 +227,43 @@ defmodule QuickBEAM.VM.Compiler.Lowering.PureV1 do ) end - defp block_clause(pc, {operations, _reason}) do + 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) @@ -184,6 +290,7 @@ defmodule QuickBEAM.VM.Compiler.Lowering.PureV1 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 = @@ -209,7 +316,7 @@ defmodule QuickBEAM.VM.Compiler.Lowering.PureV1 do variable(:Frame), variable(:Execution) ], - [deopt_call(reason)] + [boundary_call(reason)] ) operation_clauses ++ [final] @@ -248,13 +355,24 @@ defmodule QuickBEAM.VM.Compiler.Lowering.PureV1 do defp family_function(:value), do: :execute_value defp family_function(:branch), do: :execute_branch - defp deopt_call(reason) do - remote_call(Runtime, :deopt, [ - atom(reason), - variable(:Lease), - variable(:Frame), - variable(:Execution) - ]) + 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)]) diff --git a/lib/quickbeam/vm/compiler/lowering/scalar_blocks.ex b/lib/quickbeam/vm/compiler/lowering/scalar_blocks.ex new file mode 100644 index 000000000..dd3294626 --- /dev/null +++ b/lib/quickbeam/vm/compiler/lowering/scalar_blocks.ex @@ -0,0 +1,636 @@ +defmodule QuickBEAM.VM.Compiler.Lowering.ScalarBlocks 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.GeneratedModule.Template + alias QuickBEAM.VM.Compiler.Runtime + alias QuickBEAM.VM.Function + + @line 1 + @max_stack_depth 64 + @max_scalar_operations 64 + @stack_variables List.to_tuple( + for index <- 0..(@max_stack_depth - 1), do: :"_CompilerStack#{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}") + + @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 + if eligible?(function, plan, levels) do + {:ok, + %Template{ + forms: [ + {:attribute, @line, :module, Template.placeholder_module()}, + {:attribute, @line, :export, [run: 3]}, + run_form(), + block_form(plan, levels), + {:eof, @line} + ] + }} + else + :not_eligible + end + end + + defp eligible?(function, plan, levels) do + function.stack_size <= @max_stack_depth and function.arg_count <= 8 and + function.var_count <= 8 and map_size(plan) <= 8 and + scalar_operation_count(plan) <= @max_scalar_operations and + Enum.all?(plan, fn {_pc, {operations, _reason}} -> length(operations) <= 32 end) and + Enum.all?(levels, fn {_pc, {depth, _catch}} -> depth <= @max_stack_depth end) and + not captured_frame_slots?(function.constants) and + checked_locals_initialized?(function, plan) + 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, frame, args, locals, stack, execution])] + ) + ]) + + function(:run, 3, [clause([lease, frame, execution], [body])]) + end + + defp block_form(plan, levels) do + clauses = + plan + |> Enum.sort_by(fn {pc, _block} -> pc end) + |> Enum.map(fn {pc, block} -> block_clause(pc, block, levels) end) + + fallback = + clause( + [ + variable(:_PC), + variable(:Lease), + variable(:Frame), + variable(:Args), + variable(:Locals), + variable(:Stack), + variable(:Execution) + ], + [deopt_from_arguments(:unsupported_semantics, variable(:_PC), variable(:Stack))] + ) + + function(:block, 7, clauses ++ [fallback]) + end + + defp block_clause(pc, {[], reason}, levels) 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, {operations, reason}, levels) do + depth = stack_depth!(levels, pc) + lease = variable(:Lease) + frame = variable(:Frame) + args = variable(:Args) + locals = variable(:Locals) + execution = variable(:Execution) + charged_execution = variable(:ChargedExecution) + action = variable(:Action) + + state = %{ + pc: pc, + lease: lease, + frame: frame, + args: args, + locals: locals, + stack: stack_values(depth), + execution: charged_execution + } + + 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))]) + + body = + case_expression(charge, [ + clause([tuple([atom(:ok), charged_execution])], [lowered]), + clause([action], [action]) + ]) + + clause(block_arguments(pc, depth), [body]) + end + + defp block_arguments(pc, depth) do + [ + integer(pc), + variable(:Lease), + variable(:Frame), + variable(:Args), + variable(:Locals), + stack_expression(depth), + variable(:Execution) + ] + end + + defp lower_operations([], reason, state), do: boundary_expression(reason, state) + + defp lower_operations([operation | operations], reason, state) do + case lower_operation(operation, state) do + {:next, state} -> lower_operations(operations, reason, state) + {:terminal, expression} -> expression + end + end + + 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) + {: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) + {: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) + {: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]) + ])} + end + + defp lower_operation({:branch, name, [target]}, state) + when name in [:goto, :goto8, :goto16], + do: {:terminal, block_call(%{state | pc: target})} + + 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 | pc: state.pc + 1, args: tuple_put(state.args, index, value), stack: stack} + + defp lower_local(:set_arg, [index], %{stack: [value | _]} = state), + do: %{state | pc: state.pc + 1, args: tuple_put(state.args, index, value)} + + 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 = tuple_element(state.locals, first) + second = 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) + %{state | pc: state.pc + 1, locals: tuple_put(state.locals, index, value)} + 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) + %{state | pc: state.pc + 1, locals: tuple_put(state.locals, index, value), 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 + | pc: state.pc + 1, + locals: tuple_put(state.locals, index, value), + stack: stack + } + + defp lower_local(:set_loc, [index], %{stack: [value | _]} = state), + do: %{state | pc: state.pc + 1, locals: tuple_put(state.locals, index, value)} + + defp lower_local(:set_loc_uninitialized, [index], state), + do: %{state | pc: state.pc + 1, locals: tuple_put(state.locals, index, atom(:uninitialized))} + + 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, + 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 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: %{state | pc: state.pc + 1, stack: [expression | state.stack]} + + 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 tuple_put(tuple, index, value), + do: remote_call(:erlang, :setelement, [integer(index + 1), tuple, value]) + + 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 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/module_pool.ex b/lib/quickbeam/vm/compiler/module_pool.ex index 2c25369e3..68b0d2d3a 100644 --- a/lib/quickbeam/vm/compiler/module_pool.ex +++ b/lib/quickbeam/vm/compiler/module_pool.ex @@ -15,6 +15,7 @@ defmodule QuickBEAM.VM.Compiler.ModulePool do @default_compile_timeout 5_000 @default_compile_max_heap_bytes 64 * 1024 * 1024 @max_capacity Contract.pool_capacity() + @max_skip_entries @max_capacity * 8 @type server :: GenServer.server() @@ -37,11 +38,41 @@ defmodule QuickBEAM.VM.Compiler.ModulePool do 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 "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), @@ -81,6 +112,7 @@ defmodule QuickBEAM.VM.Compiler.ModulePool do modules: modules, slots: slots, key_index: %{}, + skip_index: %{}, leases: %{}, monitor_index: %{}, tasks: %{}, @@ -106,6 +138,31 @@ defmodule QuickBEAM.VM.Compiler.ModulePool do 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({:checkin, lease, caller}, _from, state) do case fetch_lease(lease, caller, state) do {:ok, record} -> @@ -163,11 +220,23 @@ defmodule QuickBEAM.VM.Compiler.ModulePool do counts: counts, leases: map_size(state.leases), compilations: map_size(state.tasks), + skips: map_size(state.skip_index), 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 @@ -238,6 +307,22 @@ defmodule QuickBEAM.VM.Compiler.ModulePool do :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} -> @@ -314,6 +399,7 @@ defmodule QuickBEAM.VM.Compiler.ModulePool do 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 @@ -502,6 +588,20 @@ defmodule QuickBEAM.VM.Compiler.ModulePool do 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 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) diff --git a/lib/quickbeam/vm/compiler/runtime.ex b/lib/quickbeam/vm/compiler/runtime.ex index 4fbf0546f..a8f5c5e39 100644 --- a/lib/quickbeam/vm/compiler/runtime.ex +++ b/lib/quickbeam/vm/compiler/runtime.ex @@ -13,7 +13,7 @@ defmodule QuickBEAM.VM.Compiler.Runtime do alias QuickBEAM.VM.Execution alias QuickBEAM.VM.Frame alias QuickBEAM.VM.Opcodes.{Control, Locals, Stack, Values} - alias QuickBEAM.VM.Value + alias QuickBEAM.VM.{StackState, Value} @stack_operations Stack.opcodes() @local_operations [ @@ -22,6 +22,9 @@ defmodule QuickBEAM.VM.Compiler.Runtime do :set_arg, :get_loc, :get_loc0_loc1, + :inc_loc, + :dec_loc, + :add_loc, :put_loc, :set_loc, :set_loc_uninitialized, @@ -63,7 +66,8 @@ defmodule QuickBEAM.VM.Compiler.Runtime do @max_block_instruction_count 256 @type operation :: {:stack | :local | :value | :branch, atom(), [term()]} - @type plan :: %{optional(non_neg_integer()) => {[operation()], Deopt.reason()}} + @type block_boundary :: Deopt.reason() | :continue + @type plan :: %{optional(non_neg_integer()) => {[operation()], block_boundary()}} @type action :: {:ok, Frame.t(), Execution.t()} @@ -75,6 +79,18 @@ defmodule QuickBEAM.VM.Compiler.Runtime do @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 @@ -100,7 +116,7 @@ defmodule QuickBEAM.VM.Compiler.Runtime do with {:ok, frame, execution} <- charge_block(lease, frame, execution, length(operations)), {:ok, frame, execution} <- execute_operations(operations, frame, execution) do - deopt(reason, lease, frame, execution) + finish_block(reason, lease, frame, execution, plan) end {:ok, invalid} -> @@ -114,6 +130,38 @@ defmodule QuickBEAM.VM.Compiler.Runtime do 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(), Execution.t(), pos_integer()) :: + {:ok, Execution.t()} | action() + def charge_state(%Lease{owner: owner}, _state, _execution, _count) when owner != self(), + do: {:error, :compiler_lease_owner_mismatch} + + def charge_state(_lease, _state, %Execution{memory_exceeded: true} = execution, _count), + do: {:error, {:limit_exceeded, :memory_bytes, execution.memory_limit}, execution} + + def charge_state( + %Lease{}, + {%Frame{}, pc, args, locals, stack}, + %Execution{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: {:ok, %{execution | remaining_steps: remaining - count}} + + def charge_state(%Lease{} = lease, state, %Execution{} = 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(), Execution.t(), pos_integer()) :: action() def charge_block(%Lease{owner: owner}, _frame, _execution, _count) when owner != self(), @@ -151,6 +199,44 @@ defmodule QuickBEAM.VM.Compiler.Runtime do end end + @doc "Deoptimizes after rebuilding a canonical frame from bounded scalar state." + @spec deopt_state(Deopt.reason(), Lease.t(), tuple(), Execution.t()) :: action() + def deopt_state( + reason, + %Lease{} = lease, + {%Frame{} = frame, pc, args, locals, stack}, + %Execution{} = execution + ) + when is_integer(pc) and pc >= 0 and is_tuple(args) and is_tuple(locals) and is_list(stack) do + deopt(reason, lease, %{frame | pc: pc, args: args, locals: locals, stack: stack}, 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(), Execution.t(), [operation()]) :: action() + def execute_fast_block(%Lease{} = lease, %Frame{} = frame, %Execution{} = 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(), Execution.t()) :: action() def execute_stack(name, operands, %Frame{} = frame, %Execution{} = execution) @@ -187,6 +273,253 @@ defmodule QuickBEAM.VM.Compiler.Runtime do 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} <- StackState.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 diff --git a/lib/quickbeam/vm/opcodes/locals.ex b/lib/quickbeam/vm/opcodes/locals.ex index 3b971b703..aafeecd6d 100644 --- a/lib/quickbeam/vm/opcodes/locals.ex +++ b/lib/quickbeam/vm/opcodes/locals.ex @@ -26,6 +26,22 @@ defmodule QuickBEAM.VM.Opcodes.Locals do :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, @@ -96,41 +112,12 @@ defmodule QuickBEAM.VM.Opcodes.Locals do push(frame, execution, array) end - def execute(:get_arg, [index], frame, execution), - do: push(frame, execution, read_slot(tuple_get(frame.args, index), execution)) - - def execute(:put_arg, [index], %{stack: [value | stack]} = frame, execution) do - {args, execution} = write_tuple_slot(frame.args, index, value, execution) - next(%{frame | args: args, stack: stack}, execution) - end - - def execute(:set_arg, [index], %{stack: [value | _]} = frame, execution) do - {args, execution} = write_tuple_slot(frame.args, index, value, execution) - next(%{frame | args: args}, execution) - end - - def execute(:get_loc, [index], frame, execution), - do: push(frame, execution, read_slot(elem(frame.locals, index), execution)) - - def execute(:get_loc0_loc1, [first, second], frame, execution) do - first = read_slot(elem(frame.locals, first), execution) - second = read_slot(elem(frame.locals, second), execution) - next(%{frame | stack: [first, second | frame.stack]}, execution) - end - - def execute(:put_loc, [index], %{stack: [value | stack]} = frame, execution) do - {locals, execution} = write_tuple_slot(frame.locals, index, value, execution) - next(%{frame | locals: locals, stack: stack}, execution) - end - - def execute(:set_loc, [index], %{stack: [value | _]} = frame, execution) do - {locals, execution} = write_tuple_slot(frame.locals, index, value, execution) - next(%{frame | locals: locals}, execution) - end + def execute(name, operands, %Frame{} = frame, %Execution{} = execution) + when name in @compact_operations do + {:ok, args, locals, stack, execution} = + execute_compact(name, operands, frame.args, frame.locals, frame.stack, execution) - def execute(:set_loc_uninitialized, [index], frame, execution) do - {locals, execution} = write_tuple_slot(frame.locals, index, :uninitialized, execution) - next(%{frame | locals: locals}, execution) + next(%{frame | args: args, locals: locals, stack: stack}, execution) end def execute(:get_loc_check, [index], frame, execution) do @@ -140,26 +127,8 @@ defmodule QuickBEAM.VM.Opcodes.Locals do end end - def execute(name, [index], frame, execution) when name in [:put_loc_check_init, :put_loc_check], - do: execute(:put_loc, [index], frame, execution) - def execute(:close_loc, [_index], frame, execution), do: next(frame, execution) - def execute(:inc_loc, [index], frame, execution), - do: update_local(frame, execution, index, &Value.unary(:inc, &1)) - - def execute(:dec_loc, [index], frame, execution), - do: update_local(frame, execution, index, &Value.unary(:dec, &1)) - - def execute(:add_loc, [index], %{stack: [value | stack]} = frame, execution) do - current = read_slot(elem(frame.locals, index), execution) - - {locals, execution} = - write_tuple_slot(frame.locals, index, Value.binary(:add, current, value), execution) - - next(%{frame | locals: locals, stack: stack}, execution) - end - def execute(name, [index], frame, execution) when name in @get_reference_ops do value = read_reference(elem(frame.closure_refs, index), execution) @@ -233,6 +202,72 @@ defmodule QuickBEAM.VM.Opcodes.Locals do {reference, frame, execution} end + @doc "Executes a verified local/argument operation over compact frame fields." + @spec execute_compact(atom(), [term()], tuple(), tuple(), [term()], Execution.t()) :: + {:ok, tuple(), tuple(), [term()], Execution.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(), Execution.t()) :: term() def read_slot({:cell, _id} = reference, execution), do: read_reference(reference, execution) @@ -284,12 +319,6 @@ defmodule QuickBEAM.VM.Opcodes.Locals do end end - defp update_local(frame, execution, index, operation) do - value = read_slot(elem(frame.locals, index), execution) - {locals, execution} = write_tuple_slot(frame.locals, index, operation.(value), execution) - next(%{frame | locals: locals}, execution) - end - defp allocate_function(callable, function, execution) do {reference, execution} = Heap.allocate(execution, :function, callable: callable) diff --git a/lib/quickbeam/vm/opcodes/stack.ex b/lib/quickbeam/vm/opcodes/stack.ex index 759577bb7..9ec48e468 100644 --- a/lib/quickbeam/vm/opcodes/stack.ex +++ b/lib/quickbeam/vm/opcodes/stack.ex @@ -2,12 +2,12 @@ defmodule QuickBEAM.VM.Opcodes.Stack do @moduledoc """ Executes literal and operand-stack QuickJS opcode families. - The module only transforms explicit frames. It returns a `:next` action so the - interpreter remains the sole owner of instruction stepping and resource - accounting. + 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.{Execution, Frame} + alias QuickBEAM.VM.{Execution, Frame, StackState} @opcodes [ :push_i32, @@ -50,77 +50,11 @@ defmodule QuickBEAM.VM.Opcodes.Stack do @doc "Executes one supported literal or stack-manipulation opcode." @spec execute(atom(), [term()], Frame.t(), Execution.t()) :: action() - def execute(name, [value], frame, execution) - when name in [:push_i32, :push_i8, :push_i16], - do: push(frame, execution, value) + def execute(name, operands, %Frame{} = frame, %Execution{} = execution) + when name in @opcodes and is_list(operands) do + {:ok, stack} = + StackState.execute(name, operands, frame.stack, frame.this, frame.function.constants) - def execute(:push_bigint_i32, [value], frame, execution), - do: push(frame, execution, {:bigint, value}) - - def execute(:undefined, [], frame, execution), do: push(frame, execution, :undefined) - def execute(:null, [], frame, execution), do: push(frame, execution, nil) - def execute(:push_false, [], frame, execution), do: push(frame, execution, false) - def execute(:push_true, [], frame, execution), do: push(frame, execution, true) - def execute(:push_this, [], frame, execution), do: push(frame, execution, frame.this) - - def execute(name, [index], frame, execution) when name in [:push_const, :push_const8], - do: push(frame, execution, Enum.at(frame.function.constants, index)) - - def execute(:drop, [], %{stack: [_value | stack]} = frame, execution), - do: next(%{frame | stack: stack}, execution) - - def execute(:dup, [], %{stack: [value | _]} = frame, execution), - do: next(%{frame | stack: [value | frame.stack]}, execution) - - def execute(:dup1, [], %{stack: [a, b | stack]} = frame, execution), - do: next(%{frame | stack: [a, b, b | stack]}, execution) - - def execute(:dup2, [], %{stack: [a, b | stack]} = frame, execution), - do: next(%{frame | stack: [a, b, a, b | stack]}, execution) - - def execute(:dup3, [], %{stack: [a, b, c | stack]} = frame, execution), - do: next(%{frame | stack: [a, b, c, a, b, c | stack]}, execution) - - def execute(name, [], %{stack: [a, _b | stack]} = frame, execution) - when name in [:nip, :nip_catch], - do: next(%{frame | stack: [a | stack]}, execution) - - def execute(:nip1, [], %{stack: [a, b, _c | stack]} = frame, execution), - do: next(%{frame | stack: [a, b | stack]}, execution) - - def execute(:swap, [], %{stack: [a, b | stack]} = frame, execution), - do: next(%{frame | stack: [b, a | stack]}, execution) - - def execute(:swap2, [], %{stack: [a, b, c, d | stack]} = frame, execution), - do: next(%{frame | stack: [c, d, a, b | stack]}, execution) - - def execute(:perm3, [], %{stack: [a, b, c | stack]} = frame, execution), - do: next(%{frame | stack: [a, c, b | stack]}, execution) - - def execute(:perm4, [], %{stack: [a, b, c, d | stack]} = frame, execution), - do: next(%{frame | stack: [a, c, d, b | stack]}, execution) - - def execute(:perm5, [], %{stack: [a, b, c, d, e | stack]} = frame, execution), - do: next(%{frame | stack: [a, c, d, e, b | stack]}, execution) - - def execute(:rot3l, [], %{stack: [a, b, c | stack]} = frame, execution), - do: next(%{frame | stack: [c, a, b | stack]}, execution) - - def execute(:rot3r, [], %{stack: [a, b, c | stack]} = frame, execution), - do: next(%{frame | stack: [b, c, a | stack]}, execution) - - def execute(:rot4l, [], %{stack: [a, b, c, d | stack]} = frame, execution), - do: next(%{frame | stack: [d, a, b, c | stack]}, execution) - - def execute(:rot5l, [], %{stack: [a, b, c, d, e | stack]} = frame, execution), - do: next(%{frame | stack: [e, a, b, c, d | stack]}, execution) - - def execute(:insert2, [], %{stack: [a, b | stack]} = frame, execution), - do: next(%{frame | stack: [a, b, a | stack]}, execution) - - def execute(:insert3, [], %{stack: [a, b, c | stack]} = frame, execution), - do: next(%{frame | stack: [a, b, c, a | stack]}, execution) - - defp push(frame, execution, value), do: next(%{frame | stack: [value | frame.stack]}, execution) - defp next(frame, execution), do: {:next, frame, execution} + {:next, %{frame | stack: stack}, execution} + end end diff --git a/lib/quickbeam/vm/stack_state.ex b/lib/quickbeam/vm/stack_state.ex new file mode 100644 index 000000000..3f78636eb --- /dev/null +++ b/lib/quickbeam/vm/stack_state.ex @@ -0,0 +1,79 @@ +defmodule QuickBEAM.VM.StackState 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/stack_verifier.ex b/lib/quickbeam/vm/stack_verifier.ex index 0f3025e37..3da6c69a0 100644 --- a/lib/quickbeam/vm/stack_verifier.ex +++ b/lib/quickbeam/vm/stack_verifier.ex @@ -28,12 +28,9 @@ defmodule QuickBEAM.VM.StackVerifier do @doc "Checks stack underflow, joins, catch state, and the declared maximum." @spec verify(Function.t()) :: :ok | {:error, term()} - def verify(%Function{instructions: instructions, stack_size: declared} = 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), - true <- state.maximum == declared do + 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}} @@ -41,7 +38,24 @@ defmodule QuickBEAM.VM.StackVerifier do end end - def verify(%Function{}), do: {:error, :empty_instruction_stream} + @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} diff --git a/mix.exs b/mix.exs index d3a9774bb..41afa1df3 100644 --- a/mix.exs +++ b/mix.exs @@ -109,6 +109,7 @@ defmodule QuickBEAM.MixProject do "docs/architecture.md", "docs/beam-interpreter-architecture.md", "docs/beam-compiler-contract.md", + "docs/beam-compiler-performance-measurements.md", "docs/beam-compiler-scheduler-measurements.md", "docs/beam-compiler-ssr-measurements.md", "docs/beam-scheduler-measurements.md", @@ -123,6 +124,7 @@ defmodule QuickBEAM.MixProject do "docs/architecture.md", "docs/beam-interpreter-architecture.md", "docs/beam-compiler-contract.md", + "docs/beam-compiler-performance-measurements.md", "docs/beam-compiler-scheduler-measurements.md", "docs/beam-compiler-ssr-measurements.md", "docs/beam-scheduler-measurements.md", diff --git a/test/vm/compiler_module_pool_test.exs b/test/vm/compiler_module_pool_test.exs index af461b1bb..d5fb27a74 100644 --- a/test/vm/compiler_module_pool_test.exs +++ b/test/vm/compiler_module_pool_test.exs @@ -168,6 +168,38 @@ defmodule QuickBEAM.VM.CompilerModulePoolTest do assert :ok = ModulePool.checkin(pool, lease) end + test "probes warm artifacts without compiling on a miss" do + pool = start_pool(capacity: 1) + key = key(1) + + assert :miss = ModulePool.checkout_cached(pool, key) + assert :ok = ModulePool.remember_skip(pool, key(2)) + assert :skip = ModulePool.checkout_cached(pool, key(2)) + assert ModulePool.stats(pool).skips == 1 + assert FakeBackend.state().compiles == %{} + + assert {:ok, cold_lease} = ModulePool.checkout(pool, key, :cold_input) + assert :ok = ModulePool.checkin(pool, cold_lease) + assert {:ok, warm_lease} = ModulePool.checkout_cached(pool, key) + assert warm_lease.key == key + assert FakeBackend.state().compiles[key] == 1 + assert :ok = ModulePool.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 = ModulePool.remember_skip(pool, key(id)) + end + + assert ModulePool.stats(pool).skips == 256 + assert :miss = ModulePool.checkout_cached(pool, key(1)) + assert :skip = ModulePool.checkout_cached(pool, key(300)) + 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) diff --git a/test/vm/compiler_orchestration_test.exs b/test/vm/compiler_orchestration_test.exs index a468941d9..5a1830e2e 100644 --- a/test/vm/compiler_orchestration_test.exs +++ b/test/vm/compiler_orchestration_test.exs @@ -54,21 +54,33 @@ defmodule QuickBEAM.VM.CompilerOrchestrationTest do assert {:ok, 40} = QuickBEAM.VM.eval(program, engine: :compiler) stats = ModulePool.stats(ModulePool) - assert stats.counts.ready >= 2 + assert stats.counts.ready >= 1 assert stats.leases == 0 end - test "caches bounded owner-local compile and skip decisions" do + 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) == 1 + assert Enum.count(decisions, fn {_id, decision} -> decision == :skip end) == 2 + end + + test "uses the loaded artifact before repeating warm lowering" do + start_compiler() + assert {:ok, program} = QuickBEAM.VM.compile("40 + 2") + assert {:ok, 42, cold_execution} = Compiler.start(program) + assert {:ok, 42, warm_execution} = Compiler.start(program) + + assert Enum.any?(cold_execution.compiler_context.decisions, fn {_id, decision} -> + match?({:compile, _, _}, decision) + end) - assert Enum.count(decisions, fn {_id, decision} -> match?({:compile, _, _}, decision) end) == - 1 + 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 diff --git a/test/vm/compiler_pure_v1_test.exs b/test/vm/compiler_pure_v1_test.exs index 1ccad7f69..d70437641 100644 --- a/test/vm/compiler_pure_v1_test.exs +++ b/test/vm/compiler_pure_v1_test.exs @@ -61,16 +61,18 @@ defmodule QuickBEAM.VM.CompilerPureV1Test do assert {:ok, template} = PureV1.lower(function) assert prepared == template - assert [:run, :block, :step] == + 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} = ImportPolicy.imports(artifact.binary) - assert {Runtime, :charge_block, 4} in imports - assert {Runtime, :execute_stack, 4} in imports - assert {Runtime, :execute_value, 4} in imports + 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 @@ -216,6 +218,51 @@ defmodule QuickBEAM.VM.CompilerPureV1Test do 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= 2 + stats = ModulePool.stats(ModulePool) + assert stats.counts.ready >= 1 + assert stats.skips >= 1 after QuickBEAM.stop(runtime) end From bc1aaf507523b4068d95e2bb9f71ba8b5c601405 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Thu, 16 Jul 2026 10:36:05 +0200 Subject: [PATCH 57/87] Add scalar compiler actions and cached host templates --- CHANGELOG.md | 3 +- bench/README.md | 16 ++- bench/vm_compiler_eprof.exs | 80 +++++++++++ bench/vm_compiler_perf.exs | 26 +++- docs/beam-compiler-contract.md | 37 +++-- .../beam-compiler-performance-measurements.md | 81 ++++++++--- docs/beam-compiler-scheduler-measurements.md | 10 +- docs/beam-compiler-ssr-measurements.md | 36 ++--- docs/beam-interpreter-architecture.md | 24 +++- docs/beam-scheduler-measurements.md | 11 +- docs/beam-ssr-measurements.md | 37 ++--- docs/prototype-delta-audit.md | 7 +- lib/quickbeam/vm.ex | 9 +- lib/quickbeam/vm/builtin/registry.ex | 14 +- lib/quickbeam/vm/compiler.ex | 31 ++++- lib/quickbeam/vm/compiler/context.ex | 12 +- lib/quickbeam/vm/compiler/contract.ex | 4 +- .../generated_module/import_policy.ex | 3 + lib/quickbeam/vm/compiler/lowering/pure_v1.ex | 130 ++++++++++++++---- .../vm/compiler/lowering/scalar_blocks.ex | 78 ++++++++++- lib/quickbeam/vm/compiler/runtime.ex | 58 +++++++- lib/quickbeam/vm/frame.ex | 4 + lib/quickbeam/vm/heap.ex | 17 ++- lib/quickbeam/vm/interpreter.ex | 118 +++++++++++++++- lib/quickbeam/vm/memory.ex | 19 ++- test/vm/builtin_dsl_test.exs | 2 + test/vm/compiler_orchestration_test.exs | 3 + test/vm/compiler_pure_v1_test.exs | 41 +++++- test/vm/interpreter_test.exs | 14 ++ test/vm/memory_limit_test.exs | 6 + 30 files changed, 779 insertions(+), 152 deletions(-) create mode 100644 bench/vm_compiler_eprof.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index a975f45b8..5799da8ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,8 @@ ## Unreleased -- Define the optional BEAM compiler contract with binary artifact identities, a fixed 32-module atom pool, lease/purge/cache lifecycle, versioned runtime ABI boundaries, and validated owner-local before-instruction deoptimization states. Add its supervised module pool, minimal canonical runtime ABI, structured generated-module backend, bounded v26 CFG analysis, specialized fixed-name `:pure_v1` forms, selective bounded nested-function re-entry, guarded scalar loop lowering with warm artifact/negative-decision caches, and explicit release-quarantined `engine: :compiler` orchestration with native, Preact/Vue/Svelte SSR, selected Test262, resource, concurrency, measurement, and single-scheduler acceptance coverage. +- Define the optional BEAM compiler contract with binary artifact identities, a fixed 32-module atom pool, lease/purge/cache lifecycle, versioned runtime ABI boundaries, and validated owner-local before-instruction deoptimization states. Add its supervised module pool, minimal canonical runtime ABI, structured generated-module backend, bounded v26 CFG analysis, specialized fixed-name `:pure_v1` forms, selective bounded nested-function re-entry, guarded opt-in `:scalar_v1` loop/property lowering, explicit call actions, warm artifact/negative-decision caches, and release-quarantined `engine: :compiler` orchestration with native, Preact/Vue/Svelte SSR, selected Test262, resource, concurrency, measurement, and single-scheduler acceptance coverage. +- Cache validated immutable builtin host templates by profile and registry generation, preserving owner-local mutation isolation and exact logical-memory charging while removing repeated installation from warm evaluations. - Add `QuickBEAM.VM.measure/2` with deterministic step/logical-memory counters and endpoint process observations, plus reproducible pinned SSR concurrency, timeout, cancellation, reclamation, and single-scheduler reports. - Serialize native addon initialization, reuse cached exports for aliases in one runtime, and reject implicit cross-runtime or post-reset reinitialization with a typed error. Add `allow_reinitialization: true` as an explicit compatibility escape hatch for addons that support multiple environments. diff --git a/bench/README.md b/bench/README.md index b587b49c1..e9ceacea7 100644 --- a/bench/README.md +++ b/bench/README.md @@ -24,10 +24,18 @@ MIX_ENV=bench mix run bench/concurrent.exs MIX_ENV=bench mix run bench/vm_ssr.exs \ --output docs/beam-ssr-measurements.md -# Reproduce cold-versus-warm compiler loop measurements +# Reproduce runtime-initialization, cold-compilation, and warm loop measurements COMPILER_PERF_ITERATIONS=2000 MIX_ENV=bench \ mix run bench/vm_compiler_perf.exs +# Profile warm generated execution or one-time host-template initialization +COMPILER_EPROF_PHASE=execution COMPILER_EPROF_ENGINE=compiler \ + COMPILER_EPROF_WORKLOAD=object_property_loop COMPILER_EPROF_ITERATIONS=200 \ + MIX_ENV=bench mix run bench/vm_compiler_eprof.exs +COMPILER_EPROF_PHASE=initialization COMPILER_EPROF_ENGINE=interpreter \ + COMPILER_EPROF_WORKLOAD=object_property_loop MIX_ENV=bench \ + mix run bench/vm_compiler_eprof.exs + # Reproduce the release-quarantined compiler SSR report MIX_ENV=bench mix run bench/vm_ssr.exs \ --engine compiler --output docs/beam-compiler-ssr-measurements.md @@ -41,6 +49,12 @@ ERL_FLAGS='+S 1:1' MIX_ENV=bench mix run bench/vm_scheduler_probe.exs \ --engine compiler --output docs/beam-compiler-scheduler-measurements.md ``` +The compiler performance runner reports one-time core-profile initialization +separately before measuring cold compilation and warm execution. The eprof +runner accepts `COMPILER_EPROF_PHASE=execution|initialization`; initialization +profiles exactly one first evaluation in a fresh Mix VM, while execution warms +the profile template and generated artifact before collecting samples. + The SSR runner accepts `--engine interpreter|compiler`, `--samples`, `--warmup`, and a comma-separated `--concurrency` list. It reports deterministic VM steps and logical allocation, endpoint BEAM process observations, sequential latency, concurrent throughput, diff --git a/bench/vm_compiler_eprof.exs b/bench/vm_compiler_eprof.exs new file mode 100644 index 000000000..9d70b4a4b --- /dev/null +++ b/bench/vm_compiler_eprof.exs @@ -0,0 +1,80 @@ +Mix.Task.run("app.start") + +alias QuickBEAM.VM.Compiler + +iterations = String.to_integer(System.get_env("COMPILER_EPROF_ITERATIONS", "200")) +engine = System.get_env("COMPILER_EPROF_ENGINE", "compiler") +workload = System.get_env("COMPILER_EPROF_WORKLOAD", "object_property_loop") +phase = System.get_env("COMPILER_EPROF_PHASE", "execution") + +sources = %{ + "arithmetic_loop" => "(function(n){let s=0;for(let i=0;i + "(function(arr){let s=0;for(let i=0;i + "(function(obj,n){let s=0;for(let i=0;i + [ + engine: :compiler, + compiler_profile: :scalar_v1, + isolation: :caller, + max_steps: 1_000_000 + ] + + "interpreter" -> + [isolation: :caller, max_steps: 1_000_000] + + invalid -> + raise "unsupported COMPILER_EPROF_ENGINE=#{inspect(invalid)}" + end + +expected = + case phase do + "execution" -> QuickBEAM.VM.eval(program, options) + "initialization" -> nil + invalid -> raise "unsupported COMPILER_EPROF_PHASE=#{inspect(invalid)}" + end + +pool = Process.whereis(QuickBEAM.VM.Compiler.ModulePool) + +tools_pattern = Path.join([to_string(:code.root_dir()), "lib", "tools-*", "ebin"]) +[tools_ebin | _] = Path.wildcard(tools_pattern) +true = :code.add_patha(String.to_charlist(tools_ebin)) +{:module, :eprof} = :code.ensure_loaded(:eprof) + +:eprof.start() +:eprof.start_profiling([self(), pool]) + +profiled_iterations = + case phase do + "execution" -> + Enum.each(1..iterations, fn _iteration -> + ^expected = QuickBEAM.VM.eval(program, options) + end) + + iterations + + "initialization" -> + {:ok, _value} = QuickBEAM.VM.eval(program, options) + 1 + end + +:eprof.stop_profiling() + +profiled_workload = if phase == "initialization", do: "host_template", else: workload + +IO.puts( + "EPROF phase=#{phase} engine=#{engine} workload=#{profiled_workload} iterations=#{profiled_iterations}" +) + +:eprof.analyze(:total) +:eprof.stop() +GenServer.stop(compiler) diff --git a/bench/vm_compiler_perf.exs b/bench/vm_compiler_perf.exs index 3f8a7c17f..7a0ec0aea 100644 --- a/bench/vm_compiler_perf.exs +++ b/bench/vm_compiler_perf.exs @@ -13,7 +13,11 @@ workloads = [ {"branch_loop", "(function(n){let s=0; for(let i=0;i @@ -24,16 +28,32 @@ average_us = fn fun -> end {:ok, compiler} = Compiler.start_link(capacity: 32) +{:ok, initialization_program} = QuickBEAM.VM.compile("0") + +{runtime_init_us, {:ok, 0}} = + :timer.tc(fn -> + QuickBEAM.VM.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, 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 -> QuickBEAM.VM.eval(program, compiler_opts) end) interpreted_result = QuickBEAM.VM.eval(program, interpreter_opts) - {:ok, _raw_value, raw_execution} = Compiler.start(program, max_steps: 1_000_000) + + {: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} -> diff --git a/docs/beam-compiler-contract.md b/docs/beam-compiler-contract.md index b9da2ec6b..73dc2a913 100644 --- a/docs/beam-compiler-contract.md +++ b/docs/beam-compiler-contract.md @@ -18,9 +18,13 @@ block plan, then emits fixed-name generated forms. The generic path uses `run/3` and `block/4` with bounded canonical runtime block calls. Eligible scalar loops use `run/3` and `block/7`, retain stack values as BEAM expressions, carry locals as bounded tuples, and rebuild canonical frames only at deoptimization. -Calls, accessors, constructors, iterators, exceptions, Promise operations, host -calls, and `await` deopt before their instruction until their resumable compiler -ABI exists. +The conservative `:pure_v1` profile remains the default. The explicitly selected +`:scalar_v1` profile additionally lets ordinary scalar `call` and `call_method` +instructions return canonical invocation actions and lets non-accessor property +reads continue in generated code. Accessors deopt before invocation. +Constructors, iterators, exceptions, +Promise operations, host calls, and `await` still deopt before their instruction +until their resumable compiler ABI exists. The root frame always receives an eligibility attempt. Nested frames are selected by a 32-operation entry prefix on the generic path; scalar-eligible @@ -145,7 +149,7 @@ safely purged remains loaded until VM shutdown. Generated modules may call `:erlang` guard/BIF operations approved by a BEAM-disassembly test and one module, `QuickBEAM.VM.Compiler.Runtime`. That -module is runtime ABI version 2 and delegates semantics to the existing +module is runtime ABI version 3 and delegates semantics to the existing canonical layers: - `Value` for primitive coercion and operators; @@ -159,11 +163,12 @@ No generated external call to `Heap`, builtin installers, process dictionaries, or prototype compiler helpers is allowed. ABI functions return explicit actions rather than recursively invoking JavaScript. -The first ABI now contains only: +The current ABI contains only: - exact canonical-frame and compact-state charging at basic-block boundaries; - verified local/argument/stack transforms shared with opcode-family modules; - guarded primitive operations with canonical `Value` fallback; +- canonical non-accessor property reads and explicit invocation actions; - truthiness and verified branch selection; - reconstruction of canonical frames and typed `%Compiler.Deopt{}` values. @@ -247,8 +252,8 @@ An adaptive policy, if added, must be explicitly selected by the caller and reported by measurement/telemetry. It still may never fall back to native QuickJS. -The current `+S 1:1` compiler-tier Vue probe reports a 31.67 ms maximum ticker -gap against the 75 ms bound and a 51.03 ms timeout p95 against the 60 ms bound. +The current `+S 1:1` compiler-tier Vue probe reports a 46.8 ms maximum ticker +gap against the 75 ms bound and a 51.0 ms timeout p95 against the 60 ms bound. The pinned compiler SSR report covers 30 sequential samples plus concurrency 1/4/8 for Preact, Vue, and Svelte, with 100/100 isolated Preact renders and successful step, memory, timeout, and cancellation checks. The Vue parity gate @@ -256,15 +261,17 @@ also requires more than the root generated module, proving selected nested-frame coverage. The selected Test262 gate passes 65/65 supported tests through both the interpreter and compiler tier. -The separate warm loop report now measures 1.72× arithmetic-loop, 1.97× -branch-loop, and 2.31× local-arithmetic speedups over the interpreter after cold -compilation costs of 9.26–42.61 ms. Those bounded micro-workloads demonstrate -that scalar generated execution can amortize compilation; they do not replace +The separate warm loop report now measures 8.18× arithmetic-loop, 7.70× +branch-loop, 5.56× local-arithmetic, 1.81× array-sum, and 6.14× object-property +speedups over the interpreter after cold compilation costs of 8.50–25.86 ms. +One-time host-profile initialization is reported separately. Those bounded +micro-workloads demonstrate that scalar generated execution can amortize +compilation; they do not replace the SSR release gate. -On the published runs, compiler-tier sequential medians are 9.83 ms for Preact, -72.42 ms for Vue, and 17.05 ms for Svelte, versus interpreter medians of 8.49 ms, -48.55 ms, and 12.36 ms respectively. These separate reproducible runs are not a +On the published runs, compiler-tier sequential medians are 10.4 ms for Preact, +75.77 ms for Vue, and 16.19 ms for Svelte, versus interpreter medians of 8.22 ms, +49.21 ms, and 15.15 ms respectively. These separate reproducible runs are not a paired statistical comparison, but they show that selective nested-function re-entry is still not a performance release candidate. @@ -291,7 +298,7 @@ Prototype code is not copied as a subsystem. Each part has one bounded target: | --- | --- | | CFG/basic-block discovery | Adapt the pure graph algorithm to current v26 instruction tuples. Targets remain verified instruction indexes. | | Stack analysis | Do not extract. `StackVerifier` remains authoritative; lowering consumes its verified heights and joins. | -| Opcode support analysis | Replace broad fallback analysis with a closed `:pure_v1` allowlist. Every other opcode emits a before-instruction deopt. | +| Opcode support analysis | Keep the conservative `:pure_v1` allowlist and add an explicit `:scalar_v1` property/call extension. Every other opcode emits a before-instruction deopt. | | Local/stack lowering | Adapt scalar block arguments only under fixed stack/slot/form bounds. Rebuild canonical `%Frame{}` at deoptimization and exclude captured locals. | | Value lowering | Use allowlisted guarded BEAM primitives with canonical `Value` fallback. Do not retain prototype coercion or object tags. | | Property lowering | Initially deopt. Later call `Properties` and preserve its accessor boundary actions. | diff --git a/docs/beam-compiler-performance-measurements.md b/docs/beam-compiler-performance-measurements.md index 7f7be7765..39b3bd1a6 100644 --- a/docs/beam-compiler-performance-measurements.md +++ b/docs/beam-compiler-performance-measurements.md @@ -1,9 +1,10 @@ # BEAM compiler warm-execution measurements -This report separates cold compilation from repeated owner-local execution for -three bounded lexical-loop workloads. It measures the experimental BEAM compiler -against the canonical interpreter through `QuickBEAM.VM.eval/2` with -`isolation: :caller`; native QuickJS is not part of this comparison. +This report separates one-time host-runtime initialization, cold compilation, +and repeated owner-local execution for five bounded lexical/property-loop +workloads. It measures the experimental BEAM compiler against the canonical +interpreter through `QuickBEAM.VM.eval/2` with `isolation: :caller`; native +QuickJS is not part of this comparison. ## Reproduction @@ -12,9 +13,9 @@ COMPILER_PERF_ITERATIONS=2000 MIX_ENV=bench \ mix run bench/vm_compiler_perf.exs ``` -- Git base: `85d7a677` +- Git base: `041fd186` - Working tree at measurement: modified -- Generated: 2026-07-15T23:01:55Z +- Generated: 2026-07-16T10:32:00Z - Elixir: 1.20.2 - OTP: 29 - OS: Linux 7.0.0-27-generic @@ -23,25 +24,71 @@ COMPILER_PERF_ITERATIONS=2000 MIX_ENV=bench \ ## Results +The first core-profile evaluation took **44.12 ms**. This one-time runtime +initialization is reported separately and is not folded into the first +workload's compiler time. + | workload | compiled functions | cold compiler eval | warm compiler average | interpreter average | warm speedup | |---|---:|---:|---:|---:|---:| -| arithmetic loop | 1 | 42.61 ms | 300.45 µs | 515.71 µs | 1.72× | -| branch loop | 1 | 9.26 ms | 304.48 µs | 598.69 µs | 1.97× | -| local arithmetic loop | 1 | 26.76 ms | 317.08 µs | 732.01 µs | 2.31× | +| arithmetic loop | 1 | 11.10 ms | 38.25 µs | 312.72 µs | 8.18× | +| branch loop | 1 | 9.72 ms | 50.12 µs | 386.01 µs | 7.70× | +| local arithmetic loop | 1 | 25.86 ms | 93.12 µs | 517.36 µs | 5.56× | +| array sum | 1 | 9.30 ms | 50.40 µs | 91.24 µs | 1.81× | +| object property loop | 1 | 8.50 ms | 48.04 µs | 294.83 µs | 6.14× | Cold time includes bounded CFG/dataflow analysis, Erlang form compilation, -module installation, lease orchestration, and the first evaluation. Warm time -includes public VM option handling, root interpreter dispatch, compiler pool -lookup, one generated nested-function execution, and result completion. It does -not hide compiler orchestration by calling a generated module directly. +module installation, lease orchestration, and the first evaluation after the +host template is warm. Warm time includes public VM option handling, root +interpreter dispatch, compiler pool lookup, one generated nested-function +execution, and result completion. It does not hide compiler orchestration by +calling a generated module directly. Both warm tiers seed each evaluation from +the same immutable, profile-specific host template and charge its full logical +allocation. Mutations still remain owner-local through immutable-map +copy-on-write semantics. + +## CPU profile + +The warm object-property workload was also profiled with `:eprof` for 200 +public `eval/2` calls: + +```sh +COMPILER_EPROF_PHASE=execution COMPILER_EPROF_ENGINE=compiler \ + COMPILER_EPROF_WORKLOAD=object_property_loop COMPILER_EPROF_ITERATIONS=200 \ + MIX_ENV=bench mix run bench/vm_compiler_eprof.exs +COMPILER_EPROF_PHASE=execution COMPILER_EPROF_ENGINE=interpreter \ + COMPILER_EPROF_WORKLOAD=object_property_loop COMPILER_EPROF_ITERATIONS=200 \ + MIX_ENV=bench mix run bench/vm_compiler_eprof.exs +``` + +Profiling overhead is substantial, so these are CPU attribution results rather +than latency numbers. The compiler profile recorded 62.56 ms total traced CPU, +including 9.21 ms in generated `block/7` and 3.09 ms in exact scalar step +charging. The interpreter profile recorded 337.01 ms total traced CPU, +including 39.99 ms in `execute_current/2`, 31.39 ms in opcode expansion, +24.03 ms in `run/2`, and 22.70 ms in instruction execution. + +A fresh Mix VM can isolate first-profile initialization: + +```sh +COMPILER_EPROF_PHASE=initialization COMPILER_EPROF_ENGINE=interpreter \ + MIX_ENV=bench mix run bench/vm_compiler_eprof.exs +``` + +That single minimal evaluation recorded 22.66 ms traced CPU; 19.71 ms was OTP +module loading (`erts_internal:prepare_loading/2`). Builtin heap construction, +logical accounting, and the persistent-template write were individually below +0.21 ms in that traced run. Initialization and warm generated execution are +therefore reported as separate phases. The optimized tier keeps verified stack values as generated BEAM expressions, uses bounded tuple locals, tail-calls generated successor blocks, specializes -numeric operations behind guards, and reconstructs `%QuickBEAM.VM.Frame{}` only -at explicit deoptimization. Fallback clauses still call canonical +numeric operations behind guards, performs non-accessor property reads through +canonical `Properties`, and reconstructs `%QuickBEAM.VM.Frame{}` only at +explicit deoptimization. Fallback clauses still call canonical `QuickBEAM.VM.Compiler.Runtime` value semantics. These micro-workload gains do not promote the compiler for release. The pinned Preact/Vue/Svelte report remains the end-to-end compatibility and performance -gate; unsupported object, call, iterator, Promise, and exception operations -still deoptimize to the interpreter. +gate; object writes, constructors, iterators, Promise operations, and exception +regions still deoptimize to the interpreter. Ordinary calls use explicit +interpreter-owned actions rather than recursive generated execution. diff --git a/docs/beam-compiler-scheduler-measurements.md b/docs/beam-compiler-scheduler-measurements.md index ad961838e..25f944868 100644 --- a/docs/beam-compiler-scheduler-measurements.md +++ b/docs/beam-compiler-scheduler-measurements.md @@ -5,9 +5,9 @@ ticker share one scheduler. The baseline sleeps for the median render wall time, allowing the same ticker to run without compiler work. - Engine: compiler -- Git base: `85d7a677` +- Git base: `041fd186` - Working tree at measurement: modified -- Generated: 2026-07-15T23:03:26Z +- Generated: 2026-07-16T08:24:50Z - Elixir: 1.20.2 - OTP: 29 - ERTS: 17.0.2 @@ -20,8 +20,8 @@ time, allowing the same ticker to run without compiler work. | workload | wall median | wall p95 | ticker gap median | ticker gap p95 | ticker gap max | ticks median | |---|---:|---:|---:|---:|---:|---:| -| Vue SSR | 161.83 ms | 195.69 ms | 2.0 ms | 7.98 ms | 31.67 ms | 61 | -| sleep baseline (162 ms target) | 162.96 ms | 162.98 ms | 2.0 ms | 2.01 ms | 2.03 ms | 81 | +| Vue SSR | 274.38 ms | 330.6 ms | 2.0 ms | 5.15 ms | 46.8 ms | 97 | +| sleep baseline (274 ms target) | 274.93 ms | 274.95 ms | 2.0 ms | 2.01 ms | 6.24 ms | 137 | Acceptance bound: Vue SSR ticker gap ≤ 75.0 ms. @@ -31,6 +31,6 @@ An infinite JavaScript loop was evaluated with a 50 ms outer timeout. | timeout | wall median | wall p95 | wall max | median overshoot | |---:|---:|---:|---:|---:| -| 50 ms | 50.98 ms | 51.03 ms | 51.03 ms | 981 µs | +| 50 ms | 50.94 ms | 51.0 ms | 51.0 ms | 942 µs | Acceptance bound: timeout p95 ≤ 60.0 ms. diff --git a/docs/beam-compiler-ssr-measurements.md b/docs/beam-compiler-ssr-measurements.md index 552358ee8..d414c7be3 100644 --- a/docs/beam-compiler-ssr-measurements.md +++ b/docs/beam-compiler-ssr-measurements.md @@ -9,9 +9,9 @@ single-scheduler fairness and timeout gate is published separately in ## Environment - Engine: compiler -- Git base: `85d7a677` +- Git base: `041fd186` - Working tree at measurement: modified -- Generated: 2026-07-15T23:02:53Z +- Generated: 2026-07-16T08:24:26Z - Elixir: 1.20.2 - OTP: 29 - ERTS: 17.0.2 @@ -27,9 +27,9 @@ single-scheduler fairness and timeout gate is published separately in | Fixture | wall median | wall p95 | VM steps | logical memory | endpoint process memory | reductions median | |---|---:|---:|---:|---:|---:|---:| -| Preact 10.29.7 | 9.83 ms | 10.44 ms | 3651 | 266.2 KiB | 4.5 MiB | 336743 | -| Vue 3.5.39 | 72.42 ms | 79.31 ms | 11957 | 992.3 KiB | 77.15 MiB | 3990571 | -| Svelte 5.56.4 | 17.05 ms | 20.15 ms | 1777 | 397.3 KiB | 15.61 MiB | 735344 | +| Preact 10.29.7 | 10.4 ms | 11.1 ms | 3651 | 266.2 KiB | 7.28 MiB | 275998 | +| Vue 3.5.39 | 75.77 ms | 86.55 ms | 11957 | 991.9 KiB | 92.58 MiB | 3842362 | +| Svelte 5.56.4 | 16.19 ms | 18.67 ms | 1777 | 397.1 KiB | 12.48 MiB | 653386 | `VM steps` and `logical memory` are deterministic counters. Endpoint process memory and reductions are observed once after result conversion; they are not @@ -40,15 +40,15 @@ rendering, conversion, and reply delivery. | Fixture | concurrency | renders | throughput | per-render wall median | per-render wall p95 | |---|---:|---:|---:|---:|---:| -| Preact 10.29.7 | 1 | 30 | 78.3 renders/s | 10.16 ms | 10.61 ms | -| Preact 10.29.7 | 4 | 30 | 242.8 renders/s | 10.82 ms | 13.05 ms | -| Preact 10.29.7 | 8 | 30 | 403.1 renders/s | 12.56 ms | 14.29 ms | -| Vue 3.5.39 | 1 | 30 | 7.4 renders/s | 82.03 ms | 88.88 ms | -| Vue 3.5.39 | 4 | 30 | 16.7 renders/s | 136.04 ms | 156.63 ms | -| Vue 3.5.39 | 8 | 30 | 18.0 renders/s | 232.63 ms | 289.2 ms | -| Svelte 5.56.4 | 1 | 30 | 36.2 renders/s | 18.3 ms | 20.34 ms | -| Svelte 5.56.4 | 4 | 30 | 98.5 renders/s | 23.94 ms | 29.09 ms | -| Svelte 5.56.4 | 8 | 30 | 115.8 renders/s | 35.31 ms | 45.59 ms | +| Preact 10.29.7 | 1 | 30 | 69.5 renders/s | 10.7 ms | 11.87 ms | +| Preact 10.29.7 | 4 | 30 | 249.6 renders/s | 10.88 ms | 12.18 ms | +| Preact 10.29.7 | 8 | 30 | 392.9 renders/s | 12.6 ms | 14.88 ms | +| Vue 3.5.39 | 1 | 30 | 7.3 renders/s | 84.18 ms | 91.52 ms | +| Vue 3.5.39 | 4 | 30 | 15.1 renders/s | 146.39 ms | 192.97 ms | +| Vue 3.5.39 | 8 | 30 | 18.0 renders/s | 237.52 ms | 252.46 ms | +| Svelte 5.56.4 | 1 | 30 | 38.7 renders/s | 17.25 ms | 19.27 ms | +| Svelte 5.56.4 | 4 | 30 | 98.6 renders/s | 23.65 ms | 28.03 ms | +| Svelte 5.56.4 | 8 | 30 | 121.4 renders/s | 35.74 ms | 44.39 ms | ## 100-render isolation and reclamation probe @@ -57,7 +57,7 @@ data and one shared immutable program. | successful isolated renders | throughput | caller memory delta after GC | process-count delta | |---:|---:|---:|---:| -| 100/100 | 407.4 renders/s | -941.8 KiB | 0 | +| 100/100 | 447.2 renders/s | -941.8 KiB | 0 | Request-specific IDs were checked in every result. Memory and process deltas are endpoint observations after explicit caller GC, not operating-system RSS @@ -67,9 +67,9 @@ measurements. | Fixture | step rejection | memory rejection | timeout | observed timeout wall | handler cancellation after return | |---|---:|---:|---:|---:|---:| -| Preact 10.29.7 | limit:steps at 3650 | limit:memory_bytes at 133.1 KiB | limit:timeout at 200 ms | 201.08 ms | 28 µs | -| Vue 3.5.39 | limit:steps at 11956 | limit:memory_bytes at 496.1 KiB | limit:timeout at 200 ms | 212.87 ms | 16 µs | -| Svelte 5.56.4 | limit:steps at 1776 | limit:memory_bytes at 198.7 KiB | limit:timeout at 200 ms | 203.77 ms | 24 µs | +| Preact 10.29.7 | limit:steps at 3650 | limit:memory_bytes at 133.1 KiB | limit:timeout at 200 ms | 201.1 ms | 22 µs | +| Vue 3.5.39 | limit:steps at 11956 | limit:memory_bytes at 495.9 KiB | limit:timeout at 200 ms | 212.76 ms | 25 µs | +| Svelte 5.56.4 | limit:steps at 1776 | limit:memory_bytes at 198.6 KiB | limit:timeout at 200 ms | 202.47 ms | 24 µs | Memory rejection uses half the fixture's successful logical allocation. Timeout uses a non-returning asynchronous handler and verifies that its BEAM diff --git a/docs/beam-interpreter-architecture.md b/docs/beam-interpreter-architecture.md index 4f201f718..489a6ddf8 100644 --- a/docs/beam-interpreter-architecture.md +++ b/docs/beam-interpreter-architecture.md @@ -84,7 +84,7 @@ The existing `QuickBEAM` runtime remains the stateful engine: - a `%QuickBEAM.VM.Program{}` is immutable and reusable; - each evaluation executes in a dedicated BEAM process by default; - the JavaScript heap is local to that process; -- there is no implicit persistent state between evaluations; +- there is no implicit mutable persistent state between evaluations; - concurrent evaluations of one program are independent. This distinction must be visible in the API. A `mode: :beam` option on a @@ -503,9 +503,19 @@ Function prototype, constructor cycles, boxed primitives, and Array defaults without a bootstrap constructor table. Mix records the complete QuickBEAM module inventory in the compiled application manifest. The profile registry filters that inventory for immutable builtin specs, orders them by declared -JavaScript dependencies, caches the result, and installs it into each owner-local -execution. This avoids both a manually duplicated module list and -code-loading-order-dependent runtime discovery. +JavaScript dependencies, and caches the result. The first evaluation of each +profile and registry generation validates and installs those specs into an +immutable host template; +subsequent evaluations seed their owner-local heap and globals from that +persistent template. BEAM's immutable maps provide copy-on-write isolation, so +mutating `Object`, `globalThis`, or any other intrinsic in one evaluation cannot +change another evaluation or the template. References are interpreted only +against the receiving evaluation's heap, and no jobs, handlers, continuations, +or mutable owner state enter the template. The template's exact logical +allocation is charged to every evaluation before user code runs, preserving +memory-limit behavior and deterministic measurement while removing repeated +spec validation and topology construction. This avoids both a manually +duplicated module list and code-loading-order-dependent runtime discovery. Installed functions are real owner-local function objects carrying stable module/handler tokens, not captured closures. Calls receive an explicit @@ -795,8 +805,10 @@ QuickBEAM.VM.eval(program, engine: :compiler) ``` The generic compiler path runs one bounded pure block. Eligible lexical loops -use bounded scalar generated forms, tail-call successor blocks, and reconstruct -the canonical frame only at a verified deoptimization boundary. Compiler +use bounded scalar generated forms, tail-call successor blocks, perform +canonical non-accessor property reads, and return explicit invocation actions. +They reconstruct the canonical frame only at a verified deoptimization or call +boundary. Compiler infrastructure failures are typed errors and never restart the program or invoke native QuickJS. The default `QuickBEAM.VM` path remains the interpreter until compiler resource, scheduler, diff --git a/docs/beam-scheduler-measurements.md b/docs/beam-scheduler-measurements.md index 07be0f32a..d0f7995a2 100644 --- a/docs/beam-scheduler-measurements.md +++ b/docs/beam-scheduler-measurements.md @@ -4,9 +4,10 @@ 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 interpreter work. -- Git base: `548fec89` +- Engine: interpreter +- Git base: `041fd186` - Working tree at measurement: modified -- Generated: 2026-07-13T22:08:17Z +- Generated: 2026-07-16T08:24:37Z - Elixir: 1.20.2 - OTP: 29 - ERTS: 17.0.2 @@ -19,8 +20,8 @@ time, allowing the same ticker to run without interpreter work. | workload | wall median | wall p95 | ticker gap median | ticker gap p95 | ticker gap max | ticks median | |---|---:|---:|---:|---:|---:|---:| -| Vue SSR | 263.25 ms | 305.47 ms | 2.0 ms | 5.76 ms | 37.83 ms | 94 | -| sleep baseline (263 ms target) | 263.93 ms | 263.95 ms | 2.0 ms | 2.01 ms | 4.94 ms | 132 | +| Vue SSR | 289.32 ms | 374.44 ms | 2.0 ms | 2.19 ms | 43.7 ms | 99 | +| sleep baseline (289 ms target) | 289.87 ms | 289.93 ms | 2.0 ms | 2.01 ms | 7.81 ms | 145 | Acceptance bound: Vue SSR ticker gap ≤ 75.0 ms. @@ -30,6 +31,6 @@ An infinite JavaScript loop was evaluated with a 50 ms outer timeout. | timeout | wall median | wall p95 | wall max | median overshoot | |---:|---:|---:|---:|---:| -| 50 ms | 50.98 ms | 51.0 ms | 51.0 ms | 979 µs | +| 50 ms | 50.96 ms | 51.04 ms | 51.04 ms | 957 µs | Acceptance bound: timeout p95 ≤ 60.0 ms. diff --git a/docs/beam-ssr-measurements.md b/docs/beam-ssr-measurements.md index 9e670b6f7..e5478c527 100644 --- a/docs/beam-ssr-measurements.md +++ b/docs/beam-ssr-measurements.md @@ -8,9 +8,10 @@ single-scheduler fairness and timeout gate is published separately in ## Environment -- Git base: `548fec89` +- Engine: interpreter +- Git base: `041fd186` - Working tree at measurement: modified -- Generated: 2026-07-13T22:08:37Z +- Generated: 2026-07-16T08:17:54Z - Elixir: 1.20.2 - OTP: 29 - ERTS: 17.0.2 @@ -26,9 +27,9 @@ single-scheduler fairness and timeout gate is published separately in | Fixture | wall median | wall p95 | VM steps | logical memory | endpoint process memory | reductions median | |---|---:|---:|---:|---:|---:|---:| -| Preact 10.29.7 | 8.49 ms | 9.47 ms | 3651 | 266.2 KiB | 4.5 MiB | 194939 | -| Vue 3.5.39 | 48.55 ms | 50.4 ms | 11957 | 992.3 KiB | 92.58 MiB | 838301 | -| Svelte 5.56.4 | 12.36 ms | 13.24 ms | 1777 | 397.3 KiB | 12.48 MiB | 204614 | +| Preact 10.29.7 | 8.22 ms | 9.09 ms | 3651 | 266.2 KiB | 4.5 MiB | 135016 | +| Vue 3.5.39 | 49.21 ms | 50.88 ms | 11957 | 991.9 KiB | 77.15 MiB | 694328 | +| Svelte 5.56.4 | 15.15 ms | 18.05 ms | 1777 | 397.1 KiB | 15.61 MiB | 134963 | `VM steps` and `logical memory` are deterministic counters. Endpoint process memory and reductions are observed once after result conversion; they are not @@ -39,15 +40,15 @@ rendering, conversion, and reply delivery. | Fixture | concurrency | renders | throughput | per-render wall median | per-render wall p95 | |---|---:|---:|---:|---:|---:| -| Preact 10.29.7 | 1 | 30 | 89.0 renders/s | 8.47 ms | 9.33 ms | -| Preact 10.29.7 | 4 | 30 | 270.1 renders/s | 9.79 ms | 10.68 ms | -| Preact 10.29.7 | 8 | 30 | 448.4 renders/s | 10.51 ms | 12.19 ms | -| Vue 3.5.39 | 1 | 30 | 9.3 renders/s | 58.7 ms | 60.37 ms | -| Vue 3.5.39 | 4 | 30 | 19.3 renders/s | 99.46 ms | 125.37 ms | -| Vue 3.5.39 | 8 | 30 | 20.3 renders/s | 182.81 ms | 227.28 ms | -| Svelte 5.56.4 | 1 | 30 | 43.8 renders/s | 14.08 ms | 15.46 ms | -| Svelte 5.56.4 | 4 | 30 | 114.7 renders/s | 18.82 ms | 21.83 ms | -| Svelte 5.56.4 | 8 | 30 | 133.9 renders/s | 29.37 ms | 35.65 ms | +| Preact 10.29.7 | 1 | 30 | 87.7 renders/s | 8.52 ms | 9.38 ms | +| Preact 10.29.7 | 4 | 30 | 284.5 renders/s | 9.11 ms | 11.4 ms | +| Preact 10.29.7 | 8 | 30 | 466.9 renders/s | 9.8 ms | 11.67 ms | +| Vue 3.5.39 | 1 | 30 | 8.5 renders/s | 62.79 ms | 72.85 ms | +| Vue 3.5.39 | 4 | 30 | 17.5 renders/s | 117.98 ms | 135.12 ms | +| Vue 3.5.39 | 8 | 30 | 18.1 renders/s | 204.55 ms | 269.75 ms | +| Svelte 5.56.4 | 1 | 30 | 38.3 renders/s | 15.9 ms | 17.14 ms | +| Svelte 5.56.4 | 4 | 30 | 105.3 renders/s | 21.34 ms | 26.59 ms | +| Svelte 5.56.4 | 8 | 30 | 124.2 renders/s | 32.42 ms | 41.3 ms | ## 100-render isolation and reclamation probe @@ -56,7 +57,7 @@ data and one shared immutable program. | successful isolated renders | throughput | caller memory delta after GC | process-count delta | |---:|---:|---:|---:| -| 100/100 | 480.1 renders/s | -941.8 KiB | 0 | +| 100/100 | 534.1 renders/s | -941.8 KiB | 0 | Request-specific IDs were checked in every result. Memory and process deltas are endpoint observations after explicit caller GC, not operating-system RSS @@ -66,9 +67,9 @@ measurements. | Fixture | step rejection | memory rejection | timeout | observed timeout wall | handler cancellation after return | |---|---:|---:|---:|---:|---:| -| Preact 10.29.7 | limit:steps at 3650 | limit:memory_bytes at 133.1 KiB | limit:timeout at 200 ms | 201.19 ms | 34 µs | -| Vue 3.5.39 | limit:steps at 11956 | limit:memory_bytes at 496.1 KiB | limit:timeout at 200 ms | 212.69 ms | 25 µs | -| Svelte 5.56.4 | limit:steps at 1776 | limit:memory_bytes at 198.7 KiB | limit:timeout at 200 ms | 202.65 ms | 24 µs | +| Preact 10.29.7 | limit:steps at 3650 | limit:memory_bytes at 133.1 KiB | limit:timeout at 200 ms | 200.7 ms | 22 µs | +| Vue 3.5.39 | limit:steps at 11956 | limit:memory_bytes at 495.9 KiB | limit:timeout at 200 ms | 214.46 ms | 27 µs | +| Svelte 5.56.4 | limit:steps at 1776 | limit:memory_bytes at 198.6 KiB | limit:timeout at 200 ms | 202.89 ms | 31 µs | Memory rejection uses half the fixture's successful logical allocation. Timeout uses a non-returning asynchronous handler and verifies that its BEAM diff --git a/docs/prototype-delta-audit.md b/docs/prototype-delta-audit.md index a590bdad2..1315afc8a 100644 --- a/docs/prototype-delta-audit.md +++ b/docs/prototype-delta-audit.md @@ -329,9 +329,10 @@ decisions while preserving stack limits and tail calls. The prototype's useful performance lesson has also been extracted without its runtime: eligible uncaptured lexical loops carry scalar stack/tuple state across generated blocks, use guarded BEAM numeric primitives with canonical fallback, and rebuild frames -only at deoptimization. Warm arithmetic/branch/local loop execution is now -1.72×/1.97×/2.31× faster than the interpreter while cold compilation remains -9.26–42.61 ms. The compiler remains release quarantined because broader +only at deoptimization. After one-time host-profile initialization, warm +arithmetic/branch/local loop execution is now 8.18×/7.70×/5.56× faster than the +interpreter, with array/property loops at 1.81×/6.14× and cold compilation at +8.50–25.86 ms. The compiler remains release quarantined because broader Preact/Vue/Svelte performance and unsupported semantic families remain the release gate. No prototype compiler runtime is approved for copying. diff --git a/lib/quickbeam/vm.ex b/lib/quickbeam/vm.ex index bca5d575a..e9ade0331 100644 --- a/lib/quickbeam/vm.ex +++ b/lib/quickbeam/vm.ex @@ -95,7 +95,8 @@ defmodule QuickBEAM.VM do Evaluates a verified program in an isolated BEAM process. Supported options include the explicit `:engine` (`:interpreter` or - `:compiler`), `:vars`, asynchronous `:handlers`, the builtin `:profile` + `:compiler`), the experimental compiler `:compiler_profile` (`:pure_v1` by + default or opt-in `:scalar_v1`), `:vars`, asynchronous `:handlers`, the builtin `:profile` (`:core` or `:ssr`), `:timeout`, `:max_steps`, `:max_stack_depth`, and the JavaScript allocation budget `:memory_limit`. Isolated workers also receive a BEAM process heap ceiling. `isolation: :caller` is available for trusted @@ -142,6 +143,7 @@ defmodule QuickBEAM.VM do defp evaluation_options(opts) do allowed = [ :compiler_pool, + :compiler_profile, :engine, :handlers, :isolation, @@ -163,6 +165,7 @@ defmodule QuickBEAM.VM do isolation = Keyword.get(opts, :isolation, :process) engine = Keyword.get(opts, :engine, :interpreter) compiler_pool = Keyword.get(opts, :compiler_pool, QuickBEAM.VM.Compiler.ModulePool) + compiler_profile = Keyword.get(opts, :compiler_profile, :pure_v1) 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) @@ -181,6 +184,9 @@ defmodule QuickBEAM.VM do not (is_atom(compiler_pool) or is_pid(compiler_pool)) -> {:error, {:invalid_option, :compiler_pool, compiler_pool}} + compiler_profile not in [:pure_v1, :scalar_v1] -> + {:error, {:invalid_option, :compiler_profile, compiler_profile}} + timeout != :infinity and (not is_integer(timeout) or timeout <= 0) -> {:error, {:invalid_option, :timeout, timeout}} @@ -214,6 +220,7 @@ defmodule QuickBEAM.VM do timeout: timeout, interpreter: %{ compiler_pool: compiler_pool, + compiler_profile: compiler_profile, handlers: handlers, max_steps: max_steps, max_stack_depth: max_stack_depth, diff --git a/lib/quickbeam/vm/builtin/registry.ex b/lib/quickbeam/vm/builtin/registry.ex index a15c391ac..ced5df1fe 100644 --- a/lib/quickbeam/vm/builtin/registry.ex +++ b/lib/quickbeam/vm/builtin/registry.ex @@ -7,14 +7,15 @@ defmodule QuickBEAM.VM.Builtin.Registry do 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` because every - isolated evaluation installs the same immutable builtin topology. + 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." @@ -25,6 +26,13 @@ defmodule QuickBEAM.VM.Builtin.Registry do |> 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 @@ -37,7 +45,9 @@ defmodule QuickBEAM.VM.Builtin.Registry do {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 diff --git a/lib/quickbeam/vm/compiler.ex b/lib/quickbeam/vm/compiler.ex index 1dfe6bb71..70ce96d1e 100644 --- a/lib/quickbeam/vm/compiler.ex +++ b/lib/quickbeam/vm/compiler.ex @@ -18,7 +18,11 @@ defmodule QuickBEAM.VM.Compiler do alias QuickBEAM.VM.{Evaluator, Execution, Frame, Function, Interpreter, Program} @type result :: {:ok, term()} | {:error, term()} | {:suspended, term()} - @type frame_action :: {:deopt, term()} | {:skip, struct(), struct()} | {:error, 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() @@ -69,7 +73,13 @@ defmodule QuickBEAM.VM.Compiler do def start(%Program{root: %Function{}} = program, opts \\ []) when is_list(opts) do {frame, execution} = Interpreter.initialize(program, opts) pool = Keyword.get(opts, :compiler_pool, ModulePool) - context = %Context{pool: pool, program: program} + + context = %Context{ + pool: pool, + profile: Keyword.get(opts, :compiler_profile, :pure_v1), + program: program + } + execution = %{execution | compiler_context: context} frame @@ -107,13 +117,14 @@ defmodule QuickBEAM.VM.Compiler do %Execution{ compiler_context: %Context{ program: %Program{root: %Function{} = root} = program, - min_nested_instructions: nested_minimum + min_nested_instructions: nested_minimum, + profile: profile } } = execution ) do minimum = if function.id == root.id, do: 1, else: nested_minimum - with {:ok, key} <- Contract.artifact_key(program, function, profile: :pure_v1) do + with {:ok, key} <- Contract.artifact_key(program, function, profile: profile) do case ModulePool.checkout_cached(execution.compiler_context.pool, key) do {:ok, lease} -> execution = cache_decision(execution, function.id, {:cached, key}) @@ -123,7 +134,7 @@ defmodule QuickBEAM.VM.Compiler do {:skip, frame, cache_decision(execution, function.id, :skip)} :miss -> - prepare_uncached_frame(function, minimum, key, frame, execution) + prepare_uncached_frame(function, minimum, profile, key, frame, execution) {:error, reason} -> {:error, reason} @@ -131,8 +142,8 @@ defmodule QuickBEAM.VM.Compiler do end end - defp prepare_uncached_frame(function, minimum, key, frame, execution) do - case PureV1.prepare(function, minimum) do + defp prepare_uncached_frame(function, minimum, profile, key, frame, execution) do + case PureV1.prepare(function, minimum, profile) do {:ok, template, _count} -> execution = cache_decision(execution, function.id, {:compile, key, template}) invoke_frame(execution.compiler_context.pool, key, template, frame, execution) @@ -178,6 +189,12 @@ defmodule QuickBEAM.VM.Compiler do 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) diff --git a/lib/quickbeam/vm/compiler/context.ex b/lib/quickbeam/vm/compiler/context.ex index 5aa597bcb..df2280862 100644 --- a/lib/quickbeam/vm/compiler/context.ex +++ b/lib/quickbeam/vm/compiler/context.ex @@ -7,7 +7,14 @@ defmodule QuickBEAM.VM.Compiler.Context do """ @enforce_keys [:pool, :program] - defstruct [:pool, :program, decisions: %{}, max_decisions: 256, min_nested_instructions: 32] + defstruct [ + :pool, + :program, + decisions: %{}, + max_decisions: 256, + min_nested_instructions: 32, + profile: :pure_v1 + ] @type t :: %__MODULE__{ pool: QuickBEAM.VM.Compiler.ModulePool.server(), @@ -17,6 +24,7 @@ defmodule QuickBEAM.VM.Compiler.Context do :skip | {:cached, binary()} | {:compile, binary(), term()} }, max_decisions: pos_integer(), - min_nested_instructions: non_neg_integer() + min_nested_instructions: non_neg_integer(), + profile: :pure_v1 | :scalar_v1 } end diff --git a/lib/quickbeam/vm/compiler/contract.ex b/lib/quickbeam/vm/compiler/contract.ex index b85ae034f..e2cabc6f5 100644 --- a/lib/quickbeam/vm/compiler/contract.ex +++ b/lib/quickbeam/vm/compiler/contract.ex @@ -10,9 +10,9 @@ defmodule QuickBEAM.VM.Compiler.Contract do alias QuickBEAM.VM.{Function, Program} @contract_version 1 - @runtime_abi_version 2 + @runtime_abi_version 3 @artifact_key_bytes 32 - @profiles [:pure_v1] + @profiles [:pure_v1, :scalar_v1] @pool_modules [ QuickBEAM.VM.Compiler.Slot00, diff --git a/lib/quickbeam/vm/compiler/generated_module/import_policy.ex b/lib/quickbeam/vm/compiler/generated_module/import_policy.ex index e75d67313..a6778fc3e 100644 --- a/lib/quickbeam/vm/compiler/generated_module/import_policy.ex +++ b/lib/quickbeam/vm/compiler/generated_module/import_policy.ex @@ -42,6 +42,9 @@ defmodule QuickBEAM.VM.Compiler.GeneratedModule.ImportPolicy do {Runtime, :frame_pc, 1}, {Runtime, :frame_state, 1}, {Runtime, :frame_this, 1}, + {Runtime, :invoke_state, 5}, + {Runtime, :property_get, 3}, + {Runtime, :resolve_atom, 2}, {Runtime, :truthy?, 1}, {Runtime, :unary, 2}, {Runtime, :binary, 3} diff --git a/lib/quickbeam/vm/compiler/lowering/pure_v1.ex b/lib/quickbeam/vm/compiler/lowering/pure_v1.ex index 4298eaf94..f13e41c67 100644 --- a/lib/quickbeam/vm/compiler/lowering/pure_v1.ex +++ b/lib/quickbeam/vm/compiler/lowering/pure_v1.ex @@ -18,46 +18,58 @@ defmodule QuickBEAM.VM.Compiler.Lowering.PureV1 do @max_block_count 4_096 @max_lowered_instruction_count 4_096 @suspension_operations [:await] ++ Invocation.opcodes() - @scalar_operations %{get_loc_check: :local, post_inc: :value, post_dec: :value} + @core_scalar_operations %{ + get_loc_check: :local, + post_dec: :value, + post_inc: :value + } + @extended_scalar_operations %{ + 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 "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), do: {:ok, plan} + with {:ok, plan, _levels} <- analyze_plan(function, :pure_v1), do: {:ok, plan} 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), do: lower_plan(function, plan, levels) + 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()) :: + @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{} = function, minimum) when is_integer(minimum) and minimum >= 0 do - with {:ok, plan, levels} <- analyze_plan(function) do + 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, plan, levels} <- analyze_plan(function, profile) do count = lowered_operation_count(plan) entry_count = entry_operation_count(plan) - template = template(function, plan, levels) - - eligible? = - if scalar_template?(template) do - count >= minimum or (minimum > 0 and loop_plan?(plan)) - else - entry_count >= minimum - end + template = template(function, plan, levels, profile) - if eligible?, do: {:ok, template, count}, else: {:skip, count} + if eligible_template?(template, plan, count, entry_count, minimum), + do: {:ok, template, count}, + else: {:skip, count} end end - defp analyze_plan(function) do + defp analyze_plan(function, profile) do with {:ok, analysis} <- StackVerifier.analyze(function), true <- analysis.maximum == function.stack_size, {:ok, blocks} <- CFG.analyze(function), - plan = Map.new(blocks, &plan_block/1), + plan <- build_plan(blocks, profile), :ok <- validate_plan_size(plan) do {:ok, plan, analysis.levels} else @@ -66,8 +78,10 @@ defmodule QuickBEAM.VM.Compiler.Lowering.PureV1 do end end - @spec lower_plan(Function.t(), Runtime.plan(), map()) :: {:ok, Template.t()} - defp lower_plan(function, plan, levels), do: {:ok, template(function, plan, levels)} + @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 @@ -83,6 +97,20 @@ defmodule QuickBEAM.VM.Compiler.Lowering.PureV1 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 loop_plan?(plan) do Enum.any?(plan, fn {pc, {operations, _reason}} -> Enum.any?(operations, fn @@ -92,24 +120,68 @@ defmodule QuickBEAM.VM.Compiler.Lowering.PureV1 do end) end - defp plan_block(block) do - {supported, remainder} = Enum.split_while(block.instructions, &supported_instruction?/1) + 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_invocation_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_invocation_segments([], [], segments), do: Enum.reverse(segments) + + defp split_invocation_segments([], current, segments), + do: Enum.reverse([Enum.reverse(current) | segments]) + + defp split_invocation_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_invocation_segments(instructions, [], [[instruction] | segments]) + + invocation_instruction?(instruction) -> + current = [instruction | current] + split_invocation_segments(instructions, [], [Enum.reverse(current) | segments]) + + true -> + split_invocation_segments(instructions, [instruction | current], segments) + end + end + + 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) + 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 - defp supported_instruction?({_pc, name, _operands}), - do: - Map.has_key?(@scalar_operations, name) or - match?({:ok, _family}, Runtime.operation_family(name)) + defp supported_instruction?({_pc, name, _operands}, profile) 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 - defp operation({_pc, name, operands}) do family = - case Map.fetch(@scalar_operations, name) do + case Map.fetch(scalar_operations, name) do {:ok, family} -> family :error -> name |> Runtime.operation_family() |> elem(1) end @@ -150,7 +222,7 @@ defmodule QuickBEAM.VM.Compiler.Lowering.PureV1 do {:compiler_resource_limit, :lowered_instructions, count, @max_lowered_instruction_count}} end - defp template(function, plan, levels) do + defp template(function, plan, levels, _profile) do case ScalarBlocks.lower(function, plan, levels) do {:ok, template} -> template :not_eligible -> generic_template(generic_plan(plan)) diff --git a/lib/quickbeam/vm/compiler/lowering/scalar_blocks.ex b/lib/quickbeam/vm/compiler/lowering/scalar_blocks.ex index dd3294626..2a7f5af53 100644 --- a/lib/quickbeam/vm/compiler/lowering/scalar_blocks.ex +++ b/lib/quickbeam/vm/compiler/lowering/scalar_blocks.ex @@ -20,6 +20,7 @@ defmodule QuickBEAM.VM.Compiler.Lowering.ScalarBlocks do @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}") @type plan :: Runtime.plan() @@ -43,15 +44,21 @@ defmodule QuickBEAM.VM.Compiler.Lowering.ScalarBlocks do end defp eligible?(function, plan, levels) do + bounded_shape?(function, plan, levels) and eligible_constants?(function.constants) and + checked_locals_initialized?(function, plan) + end + + defp bounded_shape?(function, plan, levels) do function.stack_size <= @max_stack_depth and function.arg_count <= 8 and function.var_count <= 8 and map_size(plan) <= 8 and scalar_operation_count(plan) <= @max_scalar_operations and Enum.all?(plan, fn {_pc, {operations, _reason}} -> length(operations) <= 32 end) and - Enum.all?(levels, fn {_pc, {depth, _catch}} -> depth <= @max_stack_depth end) and - not captured_frame_slots?(function.constants) and - checked_locals_initialized?(function, plan) + Enum.all?(levels, fn {_pc, {depth, _catch}} -> depth <= @max_stack_depth end) end + defp eligible_constants?(constants), + do: not nested_function_constants?(constants) and not captured_frame_slots?(constants) + defp checked_locals_initialized?(function, plan) do count = max(function.arg_count + function.var_count, 1) initialized = MapSet.new(0..(count - 1)) @@ -143,6 +150,8 @@ defmodule QuickBEAM.VM.Compiler.Lowering.ScalarBlocks do count + length(operations) end) + defp nested_function_constants?(constants), do: Enum.any?(constants, &is_struct(&1, Function)) + defp captured_frame_slots?(constants) do Enum.any?(constants, fn %Function{closure_vars: closure_vars} -> @@ -257,6 +266,13 @@ defmodule QuickBEAM.VM.Compiler.Lowering.ScalarBlocks do defp lower_operations([], reason, state), do: boundary_expression(reason, state) + defp lower_operations([{:object, name, operands} | operations], reason, state) do + lower_property(name, operands, operations, reason, state) + end + + defp lower_operations([{:invocation, name, operands}], _reason, state), + do: lower_invocation(name, operands, state) + defp lower_operations([operation | operations], reason, state) do case lower_operation(operation, state) do {:next, state} -> lower_operations(operations, reason, state) @@ -264,6 +280,62 @@ defmodule QuickBEAM.VM.Compiler.Lowering.ScalarBlocks do end end + defp lower_invocation(:call, [argument_count], state) do + {arguments, [callable | stack]} = Enum.split(state.stack, argument_count) + arguments = arguments |> Enum.reverse() |> list() + state = %{state | pc: state.pc + 1, stack: stack} + invoke_call(callable, arguments, literal(:undefined), state) + end + + defp lower_invocation(:call_method, [argument_count], state) do + {arguments, [callable, this | stack]} = Enum.split(state.stack, argument_count) + arguments = arguments |> Enum.reverse() |> list() + state = %{state | pc: state.pc + 1, stack: stack} + invoke_call(callable, arguments, this, state) + end + + defp invoke_call(callable, arguments, this, state) do + compact = tuple([state.frame, integer(state.pc), state.args, state.locals, list(state.stack)]) + + remote_call(Runtime, :invoke_state, [ + callable, + arguments, + this, + compact, + state.execution + ]) + 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]) + next_state = %{state | pc: state.pc + 1, stack: [property_value | result_stack]} + + case_expression(get, [ + clause([tuple([atom(:ok), property_value])], [ + lower_operations(operations, reason, next_state) + ]), + clause([atom(:deopt)], [deopt_call(:unsupported_semantics, integer(state.pc), state)]) + ]) + 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)} diff --git a/lib/quickbeam/vm/compiler/runtime.ex b/lib/quickbeam/vm/compiler/runtime.ex index a8f5c5e39..76c704402 100644 --- a/lib/quickbeam/vm/compiler/runtime.ex +++ b/lib/quickbeam/vm/compiler/runtime.ex @@ -13,7 +13,7 @@ defmodule QuickBEAM.VM.Compiler.Runtime do alias QuickBEAM.VM.Execution alias QuickBEAM.VM.Frame alias QuickBEAM.VM.Opcodes.{Control, Locals, Stack, Values} - alias QuickBEAM.VM.{StackState, Value} + alias QuickBEAM.VM.{Properties, StackState, Value} @stack_operations Stack.opcodes() @local_operations [ @@ -72,6 +72,7 @@ defmodule QuickBEAM.VM.Compiler.Runtime do @type action :: {:ok, Frame.t(), Execution.t()} | {:deopt, Deopt.t()} + | {:invoke, term(), [term()], term(), Frame.t(), Execution.t(), false} | {:error, term(), Execution.t()} | {:error, term()} @@ -208,7 +209,16 @@ defmodule QuickBEAM.VM.Compiler.Runtime do %Execution{} = execution ) when is_integer(pc) and pc >= 0 and is_tuple(args) and is_tuple(locals) and is_list(stack) do - deopt(reason, lease, %{frame | pc: pc, args: args, locals: locals, stack: stack}, execution) + 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), @@ -544,6 +554,50 @@ defmodule QuickBEAM.VM.Compiler.Runtime do defp execute_operation(family, name, operands, _frame, _execution), do: {:error, {:unsupported_compiler_operation, family, name, operands}} + @doc "Resolves one decoded atom operand through the canonical local layer." + @spec resolve_atom(term(), Execution.t()) :: term() + def resolve_atom(atom, %Execution{} = execution), do: Locals.resolve_atom(atom, execution) + + @doc "Reads a non-accessor property or requests before-instruction deoptimization." + @spec property_get(term(), term(), Execution.t()) :: {:ok, term()} | :deopt + def property_get(object, key, %Execution{} = execution) do + case Properties.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(), Execution.t()) :: action() + def invoke_state( + callable, + arguments, + this, + {%Frame{} = frame, pc, args, locals, stack}, + %Execution{} = 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?(%Execution{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) diff --git a/lib/quickbeam/vm/frame.ex b/lib/quickbeam/vm/frame.ex index 1ee44240f..d5a070b06 100644 --- a/lib/quickbeam/vm/frame.ex +++ b/lib/quickbeam/vm/frame.ex @@ -10,7 +10,9 @@ defmodule QuickBEAM.VM.Frame do :this, actual_arg_count: 0, closure_refs: {}, + compiler_allow_reentry: false, compiler_entered: false, + compiler_reentry_after_instruction: false, pc: 0, stack: [] ] @@ -19,7 +21,9 @@ defmodule QuickBEAM.VM.Frame do function: QuickBEAM.VM.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(), diff --git a/lib/quickbeam/vm/heap.ex b/lib/quickbeam/vm/heap.ex index a05f0d1a4..5cb793865 100644 --- a/lib/quickbeam/vm/heap.ex +++ b/lib/quickbeam/vm/heap.ex @@ -11,9 +11,14 @@ defmodule QuickBEAM.VM.Heap do @max_prototype_depth 1_000 @spec allocate(Execution.t(), Object.kind(), keyword()) :: {Reference.t(), Execution.t()} - def allocate(%Execution{} = execution, kind \\ :ordinary, opts \\ []) do - id = execution.next_object_id + def allocate(execution, kind \\ :ordinary, opts \\ []) + + def allocate(%Execution{} = execution, kind, []) do + object = %Object{kind: kind, prototype: Map.get(execution.default_prototypes, kind)} + store_new_object(execution, object) + end + def allocate(%Execution{} = execution, kind, opts) do prototype = case Keyword.fetch(opts, :prototype) do {:ok, prototype} -> prototype @@ -28,6 +33,11 @@ defmodule QuickBEAM.VM.Heap do internal: Keyword.get(opts, :internal) } + store_new_object(execution, object) + end + + defp store_new_object(execution, object) do + id = execution.next_object_id reference = %Reference{id: id} execution = Memory.charge_object(execution, object) execution = %{execution | heap: Map.put(execution.heap, id, object), next_object_id: id + 1} @@ -586,7 +596,7 @@ defmodule QuickBEAM.VM.Heap do defp normalize_key(key) when is_float(key) and trunc(key) == key, do: Integer.to_string(trunc(key)) - defp normalize_key(key) when is_binary(key) do + 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 @@ -596,5 +606,6 @@ defmodule QuickBEAM.VM.Heap do 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/interpreter.ex b/lib/quickbeam/vm/interpreter.ex index 06d78bdb4..a43d9f36e 100644 --- a/lib/quickbeam/vm/interpreter.ex +++ b/lib/quickbeam/vm/interpreter.ex @@ -42,6 +42,7 @@ defmodule QuickBEAM.VM.Interpreter do Value } + alias QuickBEAM.VM.Builtin.Registry alias QuickBEAM.VM.Compiler, as: VMCompiler alias QuickBEAM.VM.Opcodes.Control, as: ControlOpcodes alias QuickBEAM.VM.Opcodes.Invocation, as: InvocationOpcodes @@ -52,6 +53,9 @@ defmodule QuickBEAM.VM.Interpreter do @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() @@ -101,6 +105,17 @@ defmodule QuickBEAM.VM.Interpreter do @spec run_frame(Frame.t(), Execution.t()) :: term() def run_frame(%Frame{} = frame, %Execution{} = execution), do: run(frame, execution) + @doc "Resumes an explicit generated-code invocation through canonical dispatch." + @spec resume_compiler_invoke(term(), [term()], term(), Frame.t(), Execution.t()) :: term() + def resume_compiler_invoke( + callable, + arguments, + this, + %Frame{} = caller, + %Execution{} = execution + ), + do: dispatch_call(callable, arguments, this, caller, execution, false) + @spec resume(Continuation.t(), {:ok, term()} | {:error, term()}) :: result() def resume(%Continuation{} = continuation, result), do: continuation |> resume_raw(result) |> finish() @@ -114,8 +129,16 @@ defmodule QuickBEAM.VM.Interpreter do {:ok, term(), Execution.t()} | {:error, term(), Execution.t()} | {:suspended, term()} def resume_deopt_raw(%Deopt{} = deopt) do case Deopt.validate(deopt) do - :ok -> run(deopt.frame, deopt.execution) - {:error, reason} -> {:error, {:invalid_compiler_deopt, reason}, deopt.execution} + :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 @@ -260,6 +283,14 @@ defmodule QuickBEAM.VM.Interpreter do when pc >= tuple_size(function.instructions), do: {:error, {:invalid_program_counter, pc}, execution} + defp run( + %Frame{compiler_reentry_after_instruction: true} = frame, + %Execution{} = execution + ) do + frame = %{frame | compiler_entered: false, compiler_reentry_after_instruction: false} + execute_current(frame, execution) + end + defp run( %Frame{compiler_entered: false} = frame, %Execution{compiler_context: compiler_context} = execution @@ -268,14 +299,26 @@ defmodule QuickBEAM.VM.Interpreter do frame = %{frame | compiler_entered: true} case VMCompiler.execute_frame(frame, execution) do - {:deopt, %Deopt{} = deopt} -> resume_deopt_raw(deopt) - {:skip, frame, execution} -> run(frame, execution) - {:error, reason} -> {:error, {:compiler_error, reason}, execution} - action -> {:error, {:compiler_error, {:invalid_generated_action, action}}, execution} + {:deopt, %Deopt{} = deopt} -> + resume_deopt_raw(deopt) + + {: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, %Execution{} = execution) do + defp run(%Frame{} = frame, %Execution{} = execution), do: execute_current(frame, execution) + + defp execute_current(frame, execution) do {opcode, operands} = elem(frame.function.instructions, frame.pc) {name, _size, _pops, _pushes, _format} = Opcodes.info(opcode) {name, operands} = Opcodes.expand_short_form(name, operands, frame.function.arg_count) @@ -812,6 +855,58 @@ defmodule QuickBEAM.VM.Interpreter do 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 = %Execution{ + 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 = Builtins.install(execution, profile) {beam, execution} = Heap.allocate(execution) @@ -831,6 +926,15 @@ defmodule QuickBEAM.VM.Interpreter do %{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?) diff --git a/lib/quickbeam/vm/memory.ex b/lib/quickbeam/vm/memory.ex index 935083263..091954143 100644 --- a/lib/quickbeam/vm/memory.ex +++ b/lib/quickbeam/vm/memory.ex @@ -6,7 +6,7 @@ defmodule QuickBEAM.VM.Memory do controlled limit failures before the worker's process heap ceiling. """ - alias QuickBEAM.VM.Execution + alias QuickBEAM.VM.{Execution, Object} @object_bytes 128 @property_bytes 64 @@ -42,6 +42,23 @@ defmodule QuickBEAM.VM.Memory do def estimate(%QuickBEAM.VM.PromiseReference{}), do: 16 def estimate(%QuickBEAM.VM.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 diff --git a/test/vm/builtin_dsl_test.exs b/test/vm/builtin_dsl_test.exs index 52d26e9ff..e3e0c0520 100644 --- a/test/vm/builtin_dsl_test.exs +++ b/test/vm/builtin_dsl_test.exs @@ -135,7 +135,9 @@ defmodule QuickBEAM.VM.BuiltinDSLTest do refute QuickBEAM.VM.Builtins.Console in Registry.modules(:core) assert QuickBEAM.VM.Builtins.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 QuickBEAM.VM.Builtins.String.builtin_spec().kind == :constructor diff --git a/test/vm/compiler_orchestration_test.exs b/test/vm/compiler_orchestration_test.exs index 5a1830e2e..1efc08ead 100644 --- a/test/vm/compiler_orchestration_test.exs +++ b/test/vm/compiler_orchestration_test.exs @@ -255,6 +255,9 @@ defmodule QuickBEAM.VM.CompilerOrchestrationTest do assert {:error, {:invalid_option, :compiler_pool, "pool"}} = QuickBEAM.VM.eval(program, engine: :compiler, compiler_pool: "pool") + + assert {:error, {:invalid_option, :compiler_profile, :unbounded}} = + QuickBEAM.VM.eval(program, engine: :compiler, compiler_profile: :unbounded) end defp start_compiler do diff --git a/test/vm/compiler_pure_v1_test.exs b/test/vm/compiler_pure_v1_test.exs index d70437641..8b7cfa79e 100644 --- a/test/vm/compiler_pure_v1_test.exs +++ b/test/vm/compiler_pure_v1_test.exs @@ -186,7 +186,7 @@ defmodule QuickBEAM.VM.CompilerPureV1Test do assert {:deopt, %Deopt{} = deopt} = GeneratedModule.invoke(pool, lease, frame, execution) - assert deopt.frame == unspecialized.frame + assert %{deopt.frame | compiler_allow_reentry: false} == unspecialized.frame assert deopt.execution == unspecialized.execution assert deopt.reason == :unsupported_opcode assert deopt.frame.pc == 3 @@ -255,6 +255,45 @@ defmodule QuickBEAM.VM.CompilerPureV1Test do end end + test "scalar properties and explicit calls preserve canonical boundaries" do + start_pool() + + sources = [ + "(function(arr){let s=0;for(let i=0;i 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") diff --git a/test/vm/memory_limit_test.exs b/test/vm/memory_limit_test.exs index bf69121a2..69767c4e3 100644 --- a/test/vm/memory_limit_test.exs +++ b/test/vm/memory_limit_test.exs @@ -1,6 +1,12 @@ defmodule QuickBEAM.VM.MemoryLimitTest do use ExUnit.Case, async: false + alias QuickBEAM.VM.{Memory, 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") From 0a332b81cb2a91423b54f01c456284ecfb860bcc Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Thu, 16 Jul 2026 12:05:10 +0200 Subject: [PATCH 58/87] Add bounded compiler coverage counters and globals --- CHANGELOG.md | 4 +- bench/README.md | 22 ++- bench/vm_compiler_ssr_eprof.exs | 73 +++++++++ bench/vm_scheduler_probe.exs | 44 ++++- bench/vm_ssr.exs | 131 +++++++++++++-- docs/beam-compiler-contract.md | 59 ++++--- .../beam-compiler-performance-measurements.md | 54 +++++-- ...-compiler-scalar-scheduler-measurements.md | 37 +++++ docs/beam-compiler-scalar-ssr-measurements.md | 91 +++++++++++ docs/beam-compiler-scheduler-measurements.md | 11 +- docs/beam-compiler-ssr-measurements.md | 50 +++--- docs/beam-interpreter-architecture.md | 21 ++- docs/prototype-delta-audit.md | 16 +- docs/test262-conformance.md | 5 +- lib/quickbeam/vm.ex | 5 +- lib/quickbeam/vm/compiler.ex | 121 +++++++++++--- lib/quickbeam/vm/compiler/context.ex | 10 +- lib/quickbeam/vm/compiler/contract.ex | 69 ++++++-- lib/quickbeam/vm/compiler/counters.ex | 152 ++++++++++++++++++ .../generated_module/import_policy.ex | 2 + lib/quickbeam/vm/compiler/lowering/pure_v1.ex | 55 ++++++- .../vm/compiler/lowering/scalar_blocks.ex | 96 ++++++++++- lib/quickbeam/vm/compiler/runtime.ex | 23 ++- lib/quickbeam/vm/evaluator.ex | 3 + lib/quickbeam/vm/interpreter.ex | 15 ++ lib/quickbeam/vm/measurement.ex | 8 +- lib/quickbeam/vm/opcodes/locals.ex | 14 +- mix.exs | 4 + test/support/test262.ex | 4 +- test/vm/compiler_contract_test.exs | 6 + test/vm/compiler_counters_test.exs | 33 ++++ test/vm/compiler_orchestration_test.exs | 13 +- test/vm/compiler_pure_v1_test.exs | 72 ++++++++- test/vm/svelte_ssr_test.exs | 8 + test/vm/test262_test.exs | 21 ++- test/vm/vue_ssr_test.exs | 8 + 36 files changed, 1185 insertions(+), 175 deletions(-) create mode 100644 bench/vm_compiler_ssr_eprof.exs create mode 100644 docs/beam-compiler-scalar-scheduler-measurements.md create mode 100644 docs/beam-compiler-scalar-ssr-measurements.md create mode 100644 lib/quickbeam/vm/compiler/counters.ex create mode 100644 test/vm/compiler_counters_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index 5799da8ed..5c13abeea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,9 @@ ## Unreleased -- Define the optional BEAM compiler contract with binary artifact identities, a fixed 32-module atom pool, lease/purge/cache lifecycle, versioned runtime ABI boundaries, and validated owner-local before-instruction deoptimization states. Add its supervised module pool, minimal canonical runtime ABI, structured generated-module backend, bounded v26 CFG analysis, specialized fixed-name `:pure_v1` forms, selective bounded nested-function re-entry, guarded opt-in `:scalar_v1` loop/property lowering, explicit call actions, warm artifact/negative-decision caches, and release-quarantined `engine: :compiler` orchestration with native, Preact/Vue/Svelte SSR, selected Test262, resource, concurrency, measurement, and single-scheduler acceptance coverage. +- Define the optional BEAM compiler contract with binary artifact identities, a fixed 32-module atom pool, lease/purge/cache lifecycle, versioned runtime ABI boundaries, and validated owner-local before-instruction deoptimization states. Add its supervised module pool, minimal canonical runtime ABI, structured generated-module backend, bounded v26 CFG analysis, specialized fixed-name `:pure_v1` forms, selective bounded nested-function re-entry, guarded opt-in `:scalar_v1` loop/property/global lowering, explicit call actions, exact shared program artifact namespaces, warm artifact/negative-decision caches, and release-quarantined `engine: :compiler` orchestration with native, Preact/Vue/Svelte SSR, selected Test262, resource, concurrency, measurement, and single-scheduler acceptance coverage. - Cache validated immutable builtin host templates by profile and registry generation, preserving owner-local mutation isolation and exact logical-memory charging while removing repeated installation from warm evaluations. -- Add `QuickBEAM.VM.measure/2` with deterministic step/logical-memory counters and endpoint process observations, plus reproducible pinned SSR concurrency, timeout, cancellation, reclamation, and single-scheduler reports. +- Add `QuickBEAM.VM.measure/2` with deterministic step/logical-memory counters, fixed owner-local OTP compiler counters, and endpoint process observations, plus reproducible pinned SSR concurrency, timeout, cancellation, reclamation, and single-scheduler reports. - Serialize native addon initialization, reuse cached exports for aliases in one runtime, and reject implicit cross-runtime or post-reset reinitialization with a typed error. Add `allow_reinitialization: true` as an explicit compatibility escape hatch for addons that support multiple environments. ## 0.10.19 diff --git a/bench/README.md b/bench/README.md index e9ceacea7..96a6a56ce 100644 --- a/bench/README.md +++ b/bench/README.md @@ -36,9 +36,16 @@ COMPILER_EPROF_PHASE=initialization COMPILER_EPROF_ENGINE=interpreter \ COMPILER_EPROF_WORKLOAD=object_property_loop MIX_ENV=bench \ mix run bench/vm_compiler_eprof.exs -# Reproduce the release-quarantined compiler SSR report +# Attribute CPU in the pinned Vue fixture +COMPILER_SSR_EPROF_PROFILE=scalar_v1 COMPILER_SSR_EPROF_ITERATIONS=3 \ + MIX_ENV=bench mix run bench/vm_compiler_ssr_eprof.exs + +# Reproduce the release-quarantined compiler SSR reports MIX_ENV=bench mix run bench/vm_ssr.exs \ --engine compiler --output docs/beam-compiler-ssr-measurements.md +MIX_ENV=bench mix run bench/vm_ssr.exs \ + --engine compiler --compiler-profile scalar_v1 \ + --output docs/beam-compiler-scalar-ssr-measurements.md # Reproduce the interpreter single-scheduler fairness/timeout probe ERL_FLAGS='+S 1:1' MIX_ENV=bench mix run bench/vm_scheduler_probe.exs \ @@ -47,6 +54,9 @@ ERL_FLAGS='+S 1:1' MIX_ENV=bench mix run bench/vm_scheduler_probe.exs \ # Reproduce the release-quarantined compiler-tier probe ERL_FLAGS='+S 1:1' MIX_ENV=bench mix run bench/vm_scheduler_probe.exs \ --engine compiler --output docs/beam-compiler-scheduler-measurements.md +ERL_FLAGS='+S 1:1' MIX_ENV=bench mix run bench/vm_scheduler_probe.exs \ + --engine compiler --compiler-profile scalar_v1 \ + --output docs/beam-compiler-scalar-scheduler-measurements.md ``` The compiler performance runner reports one-time core-profile initialization @@ -55,17 +65,21 @@ runner accepts `COMPILER_EPROF_PHASE=execution|initialization`; initialization profiles exactly one first evaluation in a fresh Mix VM, while execution warms the profile template and generated artifact before collecting samples. -The SSR runner accepts `--engine interpreter|compiler`, `--samples`, `--warmup`, -and a comma-separated `--concurrency` list. It reports deterministic VM steps and logical allocation, +The SSR and scheduler runners accept `--compiler-profile pure_v1|scalar_v1`. +The SSR runner also accepts `--engine interpreter|compiler`, `--samples`, +`--warmup`, and a comma-separated `--concurrency` list. It reports deterministic +VM steps and logical allocation, fixed compiler coverage counters, endpoint BEAM process observations, sequential latency, concurrent throughput, and timeout/cancellation behavior for the pinned Preact, Vue, and Svelte fixtures. Published results are in [`docs/beam-ssr-measurements.md`](../docs/beam-ssr-measurements.md), [`docs/beam-compiler-performance-measurements.md`](../docs/beam-compiler-performance-measurements.md), [`docs/beam-compiler-ssr-measurements.md`](../docs/beam-compiler-ssr-measurements.md), +[`docs/beam-compiler-scalar-ssr-measurements.md`](../docs/beam-compiler-scalar-ssr-measurements.md), [`docs/beam-scheduler-measurements.md`](../docs/beam-scheduler-measurements.md), +[`docs/beam-compiler-scheduler-measurements.md`](../docs/beam-compiler-scheduler-measurements.md), and -[`docs/beam-compiler-scheduler-measurements.md`](../docs/beam-compiler-scheduler-measurements.md). +[`docs/beam-compiler-scalar-scheduler-measurements.md`](../docs/beam-compiler-scalar-scheduler-measurements.md). ## Results diff --git a/bench/vm_compiler_ssr_eprof.exs b/bench/vm_compiler_ssr_eprof.exs new file mode 100644 index 000000000..54c588552 --- /dev/null +++ b/bench/vm_compiler_ssr_eprof.exs @@ -0,0 +1,73 @@ +Mix.Task.run("app.start") + +alias QuickBEAM.VM.Compiler + +iterations = String.to_integer(System.get_env("COMPILER_SSR_EPROF_ITERATIONS", "5")) +profile = System.get_env("COMPILER_SSR_EPROF_PROFILE", "pure_v1") + +{engine, compiler_profile} = + case profile do + "interpreter" -> {:interpreter, :pure_v1} + "pure_v1" -> {:compiler, :pure_v1} + "scalar_v1" -> {:compiler, :scalar_v1} + invalid -> raise "unsupported COMPILER_SSR_EPROF_PROFILE=#{inspect(invalid)}" + end + +{:ok, _compiler} = Compiler.start_link(capacity: 32) + +{:ok, source} = + QuickBEAM.JS.bundle_file("test/fixtures/vm/vue_ssr.js", + 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") + } + ) + +{:ok, program} = QuickBEAM.VM.compile(source, filename: "test/fixtures/vm/vue_ssr.js") + +props = %{ + "title" => "Compiler profile", + "products" => [ + %{"id" => 1, "name" => "Product 1", "inStock" => true, "priceCents" => 1_299} + ] +} + +options = [ + engine: engine, + compiler_profile: compiler_profile, + isolation: :caller, + profile: :ssr, + handlers: %{"load_props" => fn [] -> props end}, + max_steps: 50_000_000, + memory_limit: 256_000_000, + timeout: 5_000 +] + +expected = QuickBEAM.VM.eval(program, options) +pool = Process.whereis(QuickBEAM.VM.Compiler.ModulePool) + +tools_pattern = Path.join([to_string(:code.root_dir()), "lib", "tools-*", "ebin"]) +[tools_ebin | _] = Path.wildcard(tools_pattern) +true = :code.add_patha(String.to_charlist(tools_ebin)) +{:module, :eprof} = :code.ensure_loaded(:eprof) + +:eprof.start() +:eprof.start_profiling([self(), pool]) + +Enum.each(1..iterations, fn _iteration -> + ^expected = QuickBEAM.VM.eval(program, options) +end) + +:eprof.stop_profiling() + +IO.puts( + "EPROF fixture=vue_ssr engine=#{engine} compiler_profile=#{compiler_profile} iterations=#{iterations}" +) + +:eprof.analyze(:total) +:eprof.stop() +GenServer.stop(Compiler.ModulePool) diff --git a/bench/vm_scheduler_probe.exs b/bench/vm_scheduler_probe.exs index 12ae28496..c301f37b1 100644 --- a/bench/vm_scheduler_probe.exs +++ b/bench/vm_scheduler_probe.exs @@ -27,7 +27,7 @@ defmodule QuickBEAM.Bench.VMSchedulerProbe do def run(args) do {opts, positional, invalid} = OptionParser.parse(args, - strict: [engine: :string, samples: :integer, output: :string] + strict: [engine: :string, compiler_profile: :string, samples: :integer, output: :string] ) if positional != [] or invalid != [], @@ -39,14 +39,15 @@ defmodule QuickBEAM.Bench.VMSchedulerProbe do 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")) maybe_start_compiler!(engine) fixture = compile_fixture!() - Enum.each(1..2, fn _iteration -> render!(fixture, engine) end) + 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) end) + observe_ticker(fn -> render!(fixture, engine, compiler_profile) end) end) render_wall = render_observations |> Enum.map(& &1.wall_time_us) |> Enum.sort() @@ -64,6 +65,7 @@ defmodule QuickBEAM.Bench.VMSchedulerProbe do {:ok, measurement} = QuickBEAM.VM.measure(timeout_program, engine: engine, + compiler_profile: compiler_profile, max_steps: 1_000_000_000, timeout: 50 ) @@ -80,7 +82,15 @@ defmodule QuickBEAM.Bench.VMSchedulerProbe do enforce_gates!(render_summary, timeout_summary) report = - report(engine, samples, baseline_ms, render_summary, baseline_summary, timeout_summary) + report( + engine, + compiler_profile, + samples, + baseline_ms, + render_summary, + baseline_summary, + timeout_summary + ) IO.write(report) @@ -102,13 +112,17 @@ defmodule QuickBEAM.Bench.VMSchedulerProbe do program end - defp render!(fixture, engine) do + defp render!(fixture, engine, compiler_profile) do handler = fn [] -> fixture.props end {:ok, measurement} = QuickBEAM.VM.measure( fixture.program, - [engine: engine, handlers: %{"load_props" => handler}] ++ @eval_opts + [ + engine: engine, + compiler_profile: compiler_profile, + handlers: %{"load_props" => handler} + ] ++ @eval_opts ) unless match?({:ok, _rendered}, measurement.result), @@ -186,15 +200,21 @@ defmodule QuickBEAM.Bench.VMSchedulerProbe do do: raise("timeout p95 #{timeout.p95} µs exceeded #{@max_timeout_wall_us} µs") end - defp report(engine, samples, baseline_ms, render, baseline, timeout) do + defp report(engine, compiler_profile, 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" + """ - # 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} - Git base: `#{command("git", ["rev-parse", "--short", "HEAD"])}` - Working tree at measurement: #{tree_state()} - Generated: #{DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601()} @@ -273,6 +293,14 @@ defmodule QuickBEAM.Bench.VMSchedulerProbe do 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 diff --git a/bench/vm_ssr.exs b/bench/vm_ssr.exs index 88cd048ad..39b447a3c 100644 --- a/bench/vm_ssr.exs +++ b/bench/vm_ssr.exs @@ -14,6 +14,7 @@ defmodule QuickBEAM.Bench.VMSSR do OptionParser.parse(args, strict: [ engine: :string, + compiler_profile: :string, samples: :integer, warmup: :integer, concurrency: :string, @@ -25,6 +26,7 @@ defmodule QuickBEAM.Bench.VMSSR do 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")) maybe_start_compiler!(engine) samples = positive!(Keyword.get(opts, :samples, @default_samples), :samples) warmup = non_negative!(Keyword.get(opts, :warmup, @default_warmup), :warmup) @@ -32,7 +34,7 @@ defmodule QuickBEAM.Bench.VMSSR do concurrency = concurrency!(Keyword.get(opts, :concurrency, Enum.join(@default_concurrency, ","))) - fixtures = Enum.map(fixture_specs(), &compile_fixture!(&1, engine)) + fixtures = Enum.map(fixture_specs(), &compile_fixture!(&1, engine, compiler_profile)) results = Enum.map(fixtures, fn fixture -> @@ -47,7 +49,10 @@ defmodule QuickBEAM.Bench.VMSSR do end) isolation = isolation_probe(hd(fixtures)) - report = markdown_report(engine, results, isolation, samples, warmup, concurrency) + + report = + markdown_report(engine, compiler_profile, results, isolation, samples, warmup, concurrency) + IO.write(report) if output = opts[:output] do @@ -103,13 +108,18 @@ defmodule QuickBEAM.Bench.VMSSR do ] end - defp compile_fixture!(spec, engine) do + defp compile_fixture!(spec, engine, compiler_profile) do {:ok, source} = QuickBEAM.JS.bundle_file(spec.fixture, spec.bundle_opts) {:ok, program} = QuickBEAM.VM.compile(source, filename: spec.fixture) + eval_opts = + spec.eval_opts + |> Keyword.put(:engine, engine) + |> Keyword.put(:compiler_profile, compiler_profile) + spec |> Map.put(:program, program) - |> update_in([:eval_opts], &Keyword.put(&1, :engine, engine)) + |> Map.put(:eval_opts, eval_opts) end defp warm(_fixture, 0), do: :ok @@ -134,7 +144,8 @@ defmodule QuickBEAM.Bench.VMSSR do 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) + logical_memory: stable_value!(measurements, & &1.logical_memory_bytes, :logical_memory), + compiler_counters: summarize_counters(measurements) } end @@ -303,6 +314,37 @@ defmodule QuickBEAM.Bench.VMSSR do 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() @@ -323,18 +365,30 @@ defmodule QuickBEAM.Bench.VMSSR do defp result_label({:error, reason}), do: "error:#{inspect(reason)}" defp result_label({:ok, _value}), do: "ok" - defp markdown_report(engine, results, isolation, samples, warmup, concurrency) do + defp markdown_report( + engine, + compiler_profile, + results, + isolation, + samples, + warmup, + concurrency + ) do metadata = metadata() scheduler_report = - if engine == :compiler, - do: "beam-compiler-scheduler-measurements.md", - else: "beam-scheduler-measurements.md" + 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 = - if engine == :compiler, - do: "BEAM compiler SSR measurements", - else: "BEAM VM SSR measurements" + case {engine, compiler_profile} do + {:compiler, :scalar_v1} -> "BEAM scalar compiler SSR measurements" + {:compiler, _profile} -> "BEAM compiler SSR measurements" + {:interpreter, _profile} -> "BEAM VM SSR measurements" + end """ # #{title} @@ -348,6 +402,7 @@ defmodule QuickBEAM.Bench.VMSSR do ## Environment - Engine: #{engine} + - Compiler profile: #{compiler_profile} - Git base: `#{metadata.git}` - Working tree at measurement: #{metadata.tree_state} - Generated: #{metadata.generated} @@ -373,6 +428,7 @@ defmodule QuickBEAM.Bench.VMSSR do 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 | @@ -405,6 +461,49 @@ defmodule QuickBEAM.Bench.VMSSR do """ 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 @@ -511,6 +610,14 @@ defmodule QuickBEAM.Bench.VMSSR do 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 diff --git a/docs/beam-compiler-contract.md b/docs/beam-compiler-contract.md index 73dc2a913..58b4f1a2e 100644 --- a/docs/beam-compiler-contract.md +++ b/docs/beam-compiler-contract.md @@ -33,7 +33,7 @@ edge. Owner-local compile or skip decisions are cached for at most 256 function IDs per evaluation. The module pool also keeps at most 256 binary-keyed negative decisions, avoiding repeated warm lowering without creating atoms. -Scalar lowering is deliberately narrower: at most 64 lowered operations, eight +Scalar lowering is deliberately narrower: at most 64 lowered operations, 16 blocks, 32 operations per block, eight arguments, eight locals, and stack depth 64. Locals captured by nested functions are excluded. Lexical checked-local reads require a successful initialization dataflow proof; otherwise the generic @@ -59,14 +59,16 @@ before-instruction deoptimization path remains authoritative. ## Versioned artifact identity -`QuickBEAM.VM.Compiler.Contract.artifact_key/3` returns a binary SHA-256 key. It -includes: +`QuickBEAM.VM.Compiler.Contract.artifact_key/3` returns a binary SHA-256 key. +The compiler hashes the exact immutable program namespace once per evaluation, +then combines it with each function payload and profile. The identity includes: - the compiler contract version; - the runtime ABI version; - the exact QuickJS/QuickBEAM ABI fingerprint and bytecode version; - the SHA-256 serialized-bytecode digest and source digest when source is available; -- the immutable function IR, constants, atom table, and source positions; +- the immutable function IR, constants, and source positions; +- the exact program atom table in the shared namespace rather than repeated in every function payload; - the lowering profile and semantic feature flags. Keys remain binaries. They are never converted to atoms. Changing any ABI, @@ -149,7 +151,7 @@ safely purged remains loaded until VM shutdown. Generated modules may call `:erlang` guard/BIF operations approved by a BEAM-disassembly test and one module, `QuickBEAM.VM.Compiler.Runtime`. That -module is runtime ABI version 3 and delegates semantics to the existing +module is runtime ABI version 4 and delegates semantics to the existing canonical layers: - `Value` for primitive coercion and operators; @@ -168,7 +170,7 @@ The current ABI contains only: - exact canonical-frame and compact-state charging at basic-block boundaries; - verified local/argument/stack transforms shared with opcode-family modules; - guarded primitive operations with canonical `Value` fallback; -- canonical non-accessor property reads and explicit invocation actions; +- canonical non-accessor property reads, owner-local global reads/writes, and explicit invocation actions; - truthiness and verified branch selection; - reconstruction of canonical frames and typed `%Compiler.Deopt{}` values. @@ -187,7 +189,10 @@ instruction in the block is guaranteed to execute and cannot throw or suspend. If insufficient steps remain, it deopts before the block without charging any of its instructions. A terminal conditional still executes exactly once after the preceding straight-line operations. This preserves the interpreter's exact `remaining_steps` contract -and `measure/2` counters. +and `measure/2` counters. Potentially deoptimizing property and strict-global +reads occupy isolated preflight blocks: lookup classification happens without an +observable effect, the instruction is charged only after a successful preflight, +and a deoptimization resumes the still-uncharged instruction in the interpreter. All allocation goes through canonical runtime layers and their logical memory charges. The compiled path runs in the same monitored evaluation process, so @@ -197,9 +202,13 @@ unchanged. Generated generic blocks are capped at 256 QuickJS instructions and one function artifact at 4,096 blocks and 4,096 lowered instructions. Generic blocks still deoptimize at control-flow edges. The narrower scalar profile may tail-call at -most eight generated successor blocks while preserving per-block charging and +most 16 generated successor blocks while preserving per-block charging and outer process containment. The existing `+S 1:1` ticker-gap and timeout report -remains a regression gate for compiled execution. +remains a regression gate for compiled execution. Measurement-only compiler +instrumentation uses one fixed-size owner-local OTP `:counters` reference. It +records generated/interpreted opcode counts and fixed deoptimization/action +fields, snapshots only at evaluation completion, and never creates keys from +user input or runs exporters in generated code. ## Deoptimization state @@ -252,28 +261,30 @@ An adaptive policy, if added, must be explicitly selected by the caller and reported by measurement/telemetry. It still may never fall back to native QuickJS. -The current `+S 1:1` compiler-tier Vue probe reports a 46.8 ms maximum ticker -gap against the 75 ms bound and a 51.0 ms timeout p95 against the 60 ms bound. +The current `+S 1:1` compiler-tier Vue probe reports a 33.57 ms maximum ticker +gap against the 75 ms bound and a 51.15 ms timeout p95 against the 60 ms bound. +The opt-in scalar profile reports 35.52 ms and 51.10 ms respectively. The pinned compiler SSR report covers 30 sequential samples plus concurrency 1/4/8 for Preact, Vue, and Svelte, with 100/100 isolated Preact renders and successful step, memory, timeout, and cancellation checks. The Vue parity gate also requires more than the root generated module, proving selected nested-frame -coverage. The selected Test262 gate passes 65/65 supported tests through both -the interpreter and compiler tier. +coverage. The selected Test262 gate passes 65/65 supported tests through the +interpreter and both compiler profiles. -The separate warm loop report now measures 8.18× arithmetic-loop, 7.70× -branch-loop, 5.56× local-arithmetic, 1.81× array-sum, and 6.14× object-property -speedups over the interpreter after cold compilation costs of 8.50–25.86 ms. +The separate warm loop report now measures 8.23× arithmetic-loop, 7.65× +branch-loop, 5.69× local-arithmetic, 1.71× array-sum, and 5.26× object-property +speedups over the interpreter after cold compilation costs of 10.77–25.65 ms. One-time host-profile initialization is reported separately. Those bounded micro-workloads demonstrate that scalar generated execution can amortize -compilation; they do not replace -the SSR release gate. - -On the published runs, compiler-tier sequential medians are 10.4 ms for Preact, -75.77 ms for Vue, and 16.19 ms for Svelte, versus interpreter medians of 8.22 ms, -49.21 ms, and 15.15 ms respectively. These separate reproducible runs are not a -paired statistical comparison, but they show that selective nested-function -re-entry is still not a performance release candidate. +compilation; they do not replace the SSR release gate. + +On the published runs, default compiler-tier sequential medians are 9.74 ms for +Preact, 60.13 ms for Vue, and 15.70 ms for Svelte. The scalar profile reports +9.66 ms, 64.45 ms, and 15.45 ms, versus interpreter medians of 8.22 ms, +49.21 ms, and 15.15 ms respectively. Vue generated-step coverage remains only +0.4% for `:pure_v1` and 1.1% for `:scalar_v1`. These separate reproducible runs +are not a paired statistical comparison, and the low useful coverage keeps the +compiler out of the release path. ### Release policy diff --git a/docs/beam-compiler-performance-measurements.md b/docs/beam-compiler-performance-measurements.md index 39b3bd1a6..5a7af0e92 100644 --- a/docs/beam-compiler-performance-measurements.md +++ b/docs/beam-compiler-performance-measurements.md @@ -13,9 +13,9 @@ COMPILER_PERF_ITERATIONS=2000 MIX_ENV=bench \ mix run bench/vm_compiler_perf.exs ``` -- Git base: `041fd186` +- Git base: `bc1aaf50` - Working tree at measurement: modified -- Generated: 2026-07-16T10:32:00Z +- Generated: 2026-07-16T09:40:00Z - Elixir: 1.20.2 - OTP: 29 - OS: Linux 7.0.0-27-generic @@ -24,17 +24,17 @@ COMPILER_PERF_ITERATIONS=2000 MIX_ENV=bench \ ## Results -The first core-profile evaluation took **44.12 ms**. This one-time runtime +The first core-profile evaluation took **50.62 ms**. This one-time runtime initialization is reported separately and is not folded into the first workload's compiler time. | workload | compiled functions | cold compiler eval | warm compiler average | interpreter average | warm speedup | |---|---:|---:|---:|---:|---:| -| arithmetic loop | 1 | 11.10 ms | 38.25 µs | 312.72 µs | 8.18× | -| branch loop | 1 | 9.72 ms | 50.12 µs | 386.01 µs | 7.70× | -| local arithmetic loop | 1 | 25.86 ms | 93.12 µs | 517.36 µs | 5.56× | -| array sum | 1 | 9.30 ms | 50.40 µs | 91.24 µs | 1.81× | -| object property loop | 1 | 8.50 ms | 48.04 µs | 294.83 µs | 6.14× | +| arithmetic loop | 1 | 15.95 ms | 38.31 µs | 315.17 µs | 8.23× | +| branch loop | 1 | 11.08 ms | 52.40 µs | 400.73 µs | 7.65× | +| local arithmetic loop | 1 | 25.65 ms | 91.74 µs | 522.34 µs | 5.69× | +| array sum | 1 | 12.74 ms | 54.11 µs | 92.58 µs | 1.71× | +| object property loop | 1 | 10.77 ms | 55.91 µs | 294.28 µs | 5.26× | Cold time includes bounded CFG/dataflow analysis, Erlang form compilation, module installation, lease orchestration, and the first evaluation after the @@ -61,11 +61,14 @@ COMPILER_EPROF_PHASE=execution COMPILER_EPROF_ENGINE=interpreter \ ``` Profiling overhead is substantial, so these are CPU attribution results rather -than latency numbers. The compiler profile recorded 62.56 ms total traced CPU, -including 9.21 ms in generated `block/7` and 3.09 ms in exact scalar step -charging. The interpreter profile recorded 337.01 ms total traced CPU, -including 39.99 ms in `execute_current/2`, 31.39 ms in opcode expansion, -24.03 ms in `run/2`, and 22.70 ms in instruction execution. +than latency numbers. The compiler profile recorded 76.82 ms total traced CPU, +including 12.05 ms in generated `block/7` and 7.07 ms in exact scalar step +charging. The interpreter profile recorded 386.12 ms total traced CPU, +including 40.30 ms in `execute_current_opcode/4`, 34.83 ms in opcode expansion, +26.68 ms in `run/2`, and 23.63 ms in instruction execution. Property reads use +separate preflight blocks so accessor/error deoptimization does not pre-charge +or duplicate the instruction; this correctness boundary explains part of the +property-heavy overhead. A fresh Mix VM can isolate first-profile initialization: @@ -74,16 +77,37 @@ COMPILER_EPROF_PHASE=initialization COMPILER_EPROF_ENGINE=interpreter \ MIX_ENV=bench mix run bench/vm_compiler_eprof.exs ``` -That single minimal evaluation recorded 22.66 ms traced CPU; 19.71 ms was OTP +That single minimal evaluation recorded 19.67 ms traced CPU; 17.05 ms was OTP module loading (`erts_internal:prepare_loading/2`). Builtin heap construction, logical accounting, and the persistent-template write were individually below 0.21 ms in that traced run. Initialization and warm generated execution are therefore reported as separate phases. +The pinned Vue fixture can be profiled separately from micro-workloads: + +```sh +COMPILER_SSR_EPROF_ITERATIONS=3 COMPILER_SSR_EPROF_PROFILE=interpreter \ + MIX_ENV=bench mix run bench/vm_compiler_ssr_eprof.exs +COMPILER_SSR_EPROF_ITERATIONS=3 COMPILER_SSR_EPROF_PROFILE=pure_v1 \ + MIX_ENV=bench mix run bench/vm_compiler_ssr_eprof.exs +COMPILER_SSR_EPROF_ITERATIONS=3 COMPILER_SSR_EPROF_PROFILE=scalar_v1 \ + MIX_ENV=bench mix run bench/vm_compiler_ssr_eprof.exs +``` + +The three traced runs recorded 281.72 ms for the interpreter, 300.34 ms for +`:pure_v1`, and 292.91 ms for `:scalar_v1`. The compiler runs spent about +11.8 ms across three renders in `term_to_binary/2`; artifact identities now +hash the exact program namespace once per evaluation and omit repeated atom +tables from per-function payloads. These profiles also motivated fixed OTP +`:counters` at the measurement boundary: Vue currently executes only 0.4% of +steps through `:pure_v1` and 1.1% through `:scalar_v1`, so bounded coverage—not +scalar primitive speed—is the remaining SSR bottleneck. + The optimized tier keeps verified stack values as generated BEAM expressions, uses bounded tuple locals, tail-calls generated successor blocks, specializes numeric operations behind guards, performs non-accessor property reads through -canonical `Properties`, and reconstructs `%QuickBEAM.VM.Frame{}` only at +canonical `Properties`, threads canonical global reads/writes through owner-local +execution state, and reconstructs `%QuickBEAM.VM.Frame{}` only at explicit deoptimization. Fallback clauses still call canonical `QuickBEAM.VM.Compiler.Runtime` value semantics. diff --git a/docs/beam-compiler-scalar-scheduler-measurements.md b/docs/beam-compiler-scalar-scheduler-measurements.md new file mode 100644 index 000000000..6b897a298 --- /dev/null +++ b/docs/beam-compiler-scalar-scheduler-measurements.md @@ -0,0 +1,37 @@ +# BEAM scalar compiler single-scheduler probe + +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 compiler work. + +- Engine: compiler +- Compiler profile: scalar_v1 +- Git base: `bc1aaf50` +- Working tree at measurement: modified +- Generated: 2026-07-16T09:54:48Z +- Elixir: 1.20.2 +- OTP: 29 +- ERTS: 17.0.2 +- OS: Linux 7.0.0-27-generic +- Architecture: x86_64-pc-linux-gnu +- CPU: AMD Ryzen 9 9950X 16-Core Processor +- Online schedulers: 1 +- Vue probe memory limit: 512 MB +- Samples: 10 + +| workload | wall median | wall p95 | ticker gap median | ticker gap p95 | ticker gap max | ticks median | +|---|---:|---:|---:|---:|---:|---:| +| Vue SSR | 177.57 ms | 205.3 ms | 2.0 ms | 8.53 ms | 35.52 ms | 65 | +| sleep baseline (178 ms target) | 178.95 ms | 178.98 ms | 2.0 ms | 2.01 ms | 3.8 ms | 89 | + +Acceptance bound: Vue SSR ticker gap ≤ 75.0 ms. + +## 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 | 50.98 ms | 51.1 ms | 51.1 ms | 983 µs | + +Acceptance bound: timeout p95 ≤ 60.0 ms. diff --git a/docs/beam-compiler-scalar-ssr-measurements.md b/docs/beam-compiler-scalar-ssr-measurements.md new file mode 100644 index 000000000..9b0a0cf93 --- /dev/null +++ b/docs/beam-compiler-scalar-ssr-measurements.md @@ -0,0 +1,91 @@ +# BEAM scalar compiler SSR measurements + +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. The +single-scheduler fairness and timeout gate is published separately in +[`beam-compiler-scalar-scheduler-measurements.md`](beam-compiler-scalar-scheduler-measurements.md). + +## Environment + +- Engine: compiler +- Compiler profile: scalar_v1 +- Git base: `bc1aaf50` +- Working tree at measurement: modified +- Generated: 2026-07-16T09:53:54Z +- Elixir: 1.20.2 +- OTP: 29 +- ERTS: 17.0.2 +- OS: Linux 7.0.0-27-generic +- Architecture: x86_64-pc-linux-gnu +- CPU: AMD Ryzen 9 9950X 16-Core Processor +- Logical schedulers: 32 +- Mix environment: `bench` +- Samples per fixture: 30 after 3 warmups +- Concurrency levels: 1, 4, 8 + +## Sequential isolated renders + +| Fixture | wall median | wall p95 | VM steps | logical memory | endpoint process memory | reductions median | +|---|---:|---:|---:|---:|---:|---:| +| Preact 10.29.7 | 9.66 ms | 11.15 ms | 3651 | 266.2 KiB | 7.28 MiB | 221871 | +| Vue 3.5.39 | 64.45 ms | 69.78 ms | 11957 | 991.9 KiB | 77.15 MiB | 1275315 | +| Svelte 5.56.4 | 15.45 ms | 19.98 ms | 1777 | 397.1 KiB | 15.61 MiB | 271145 | + +`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 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 | +|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---|---| +| Preact 10.29.7 | 50 | 49 | 0/1/15 | 0 | 0.0% | 1 | 1 | 0 | 0 | `check_define_var`=1 | `if_false8`=269, `get_loc8`=234, `push_atom_value`=234, `dup`=230, `drop`=193, `get_var`=182, `get_arg0`=180, `strict_eq`=109 | +| Vue 3.5.39 | 306 | 296 | 0/3/90 | 126 | 1.1% | 10 | 4 | 6 | 2 | `return_undef`=2, `check_define_var`=1, `get_var`=1 | `swap`=936, `dup`=664, `get_loc_check`=598, `if_false8`=582, `check_define_var`=480, `get_var`=468, `get_arg0`=464, `set_loc_uninitialized`=403 | +| Svelte 5.56.4 | 34 | 33 | 0/1/22 | 0 | 0.0% | 1 | 1 | 0 | 0 | `check_define_var`=1 | `check_define_var`=202, `fclosure8`=154, `define_func`=117, `define_var`=85, `set_loc_uninitialized`=74, `get_var`=65, `get_loc_check`=64, `put_var_init`=63 | + + +## Concurrent isolated renders + +| Fixture | concurrency | renders | throughput | per-render wall median | per-render wall p95 | +|---|---:|---:|---:|---:|---:| +| Preact 10.29.7 | 1 | 30 | 73.6 renders/s | 10.2 ms | 11.55 ms | +| Preact 10.29.7 | 4 | 30 | 239.6 renders/s | 10.98 ms | 12.28 ms | +| Preact 10.29.7 | 8 | 30 | 395.3 renders/s | 12.62 ms | 14.06 ms | +| Vue 3.5.39 | 1 | 30 | 7.6 renders/s | 70.51 ms | 88.52 ms | +| Vue 3.5.39 | 4 | 30 | 16.4 renders/s | 126.54 ms | 151.01 ms | +| Vue 3.5.39 | 8 | 30 | 18.3 renders/s | 219.96 ms | 264.18 ms | +| Svelte 5.56.4 | 1 | 30 | 37.3 renders/s | 16.67 ms | 21.02 ms | +| Svelte 5.56.4 | 4 | 30 | 95.3 renders/s | 23.99 ms | 27.05 ms | +| Svelte 5.56.4 | 8 | 30 | 116.7 renders/s | 33.25 ms | 41.72 ms | + +## 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 | +|---:|---:|---:|---:| +| 100/100 | 468.6 renders/s | -929.3 KiB | 0 | + +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 | +|---|---:|---:|---:|---:|---:| +| Preact 10.29.7 | limit:steps at 3650 | limit:memory_bytes at 133.1 KiB | limit:timeout at 200 ms | 200.88 ms | 34 µs | +| Vue 3.5.39 | limit:steps at 11956 | limit:memory_bytes at 495.9 KiB | limit:timeout at 200 ms | 213.9 ms | 37 µs | +| Svelte 5.56.4 | limit:steps at 1776 | limit:memory_bytes at 198.6 KiB | limit:timeout at 200 ms | 202.46 ms | 30 µs | + +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. diff --git a/docs/beam-compiler-scheduler-measurements.md b/docs/beam-compiler-scheduler-measurements.md index 25f944868..1751ba9e2 100644 --- a/docs/beam-compiler-scheduler-measurements.md +++ b/docs/beam-compiler-scheduler-measurements.md @@ -5,9 +5,10 @@ ticker share one scheduler. The baseline sleeps for the median render wall time, allowing the same ticker to run without compiler work. - Engine: compiler -- Git base: `041fd186` +- Compiler profile: pure_v1 +- Git base: `bc1aaf50` - Working tree at measurement: modified -- Generated: 2026-07-16T08:24:50Z +- Generated: 2026-07-16T09:54:39Z - Elixir: 1.20.2 - OTP: 29 - ERTS: 17.0.2 @@ -20,8 +21,8 @@ time, allowing the same ticker to run without compiler work. | workload | wall median | wall p95 | ticker gap median | ticker gap p95 | ticker gap max | ticks median | |---|---:|---:|---:|---:|---:|---:| -| Vue SSR | 274.38 ms | 330.6 ms | 2.0 ms | 5.15 ms | 46.8 ms | 97 | -| sleep baseline (274 ms target) | 274.93 ms | 274.95 ms | 2.0 ms | 2.01 ms | 6.24 ms | 137 | +| Vue SSR | 166.46 ms | 200.73 ms | 2.0 ms | 8.75 ms | 33.57 ms | 60 | +| sleep baseline (166 ms target) | 166.95 ms | 166.96 ms | 2.0 ms | 2.01 ms | 2.58 ms | 83 | Acceptance bound: Vue SSR ticker gap ≤ 75.0 ms. @@ -31,6 +32,6 @@ An infinite JavaScript loop was evaluated with a 50 ms outer timeout. | timeout | wall median | wall p95 | wall max | median overshoot | |---:|---:|---:|---:|---:| -| 50 ms | 50.94 ms | 51.0 ms | 51.0 ms | 942 µs | +| 50 ms | 50.97 ms | 51.15 ms | 51.15 ms | 974 µs | Acceptance bound: timeout p95 ≤ 60.0 ms. diff --git a/docs/beam-compiler-ssr-measurements.md b/docs/beam-compiler-ssr-measurements.md index d414c7be3..7d3356a4e 100644 --- a/docs/beam-compiler-ssr-measurements.md +++ b/docs/beam-compiler-ssr-measurements.md @@ -9,9 +9,10 @@ single-scheduler fairness and timeout gate is published separately in ## Environment - Engine: compiler -- Git base: `041fd186` +- Compiler profile: pure_v1 +- Git base: `bc1aaf50` - Working tree at measurement: modified -- Generated: 2026-07-16T08:24:26Z +- Generated: 2026-07-16T09:53:39Z - Elixir: 1.20.2 - OTP: 29 - ERTS: 17.0.2 @@ -27,28 +28,41 @@ single-scheduler fairness and timeout gate is published separately in | Fixture | wall median | wall p95 | VM steps | logical memory | endpoint process memory | reductions median | |---|---:|---:|---:|---:|---:|---:| -| Preact 10.29.7 | 10.4 ms | 11.1 ms | 3651 | 266.2 KiB | 7.28 MiB | 275998 | -| Vue 3.5.39 | 75.77 ms | 86.55 ms | 11957 | 991.9 KiB | 92.58 MiB | 3842362 | -| Svelte 5.56.4 | 16.19 ms | 18.67 ms | 1777 | 397.1 KiB | 12.48 MiB | 653386 | +| Preact 10.29.7 | 9.74 ms | 10.49 ms | 3651 | 266.2 KiB | 7.28 MiB | 221689 | +| Vue 3.5.39 | 60.13 ms | 68.72 ms | 11957 | 991.9 KiB | 92.58 MiB | 1266415 | +| Svelte 5.56.4 | 15.7 ms | 18.82 ms | 1777 | 397.1 KiB | 15.61 MiB | 270352 | `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 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 | +|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---|---| +| Preact 10.29.7 | 50 | 49 | 0/1/15 | 0 | 0.0% | 1 | 1 | 0 | 0 | `check_define_var`=1 | `if_false8`=269, `get_loc8`=234, `push_atom_value`=234, `dup`=230, `drop`=193, `get_var`=182, `get_arg0`=180, `strict_eq`=109 | +| Vue 3.5.39 | 300 | 296 | 0/3/90 | 50 | 0.4% | 4 | 4 | 0 | 0 | `push_empty_string`=3, `get_var`=1 | `swap`=936, `dup`=664, `get_loc_check`=609, `if_false8`=587, `check_define_var`=480, `get_var`=474, `get_arg0`=467, `set_loc_uninitialized`=402 | +| Svelte 5.56.4 | 34 | 28 | 0/1/22 | 24 | 1.4% | 6 | 6 | 0 | 0 | `get_var`=6 | `check_define_var`=202, `fclosure8`=154, `define_func`=117, `define_var`=85, `get_var`=65, `get_loc_check`=64, `put_var_init`=63, `push_atom_value`=61 | + + ## Concurrent isolated renders | Fixture | concurrency | renders | throughput | per-render wall median | per-render wall p95 | |---|---:|---:|---:|---:|---:| -| Preact 10.29.7 | 1 | 30 | 69.5 renders/s | 10.7 ms | 11.87 ms | -| Preact 10.29.7 | 4 | 30 | 249.6 renders/s | 10.88 ms | 12.18 ms | -| Preact 10.29.7 | 8 | 30 | 392.9 renders/s | 12.6 ms | 14.88 ms | -| Vue 3.5.39 | 1 | 30 | 7.3 renders/s | 84.18 ms | 91.52 ms | -| Vue 3.5.39 | 4 | 30 | 15.1 renders/s | 146.39 ms | 192.97 ms | -| Vue 3.5.39 | 8 | 30 | 18.0 renders/s | 237.52 ms | 252.46 ms | -| Svelte 5.56.4 | 1 | 30 | 38.7 renders/s | 17.25 ms | 19.27 ms | -| Svelte 5.56.4 | 4 | 30 | 98.6 renders/s | 23.65 ms | 28.03 ms | -| Svelte 5.56.4 | 8 | 30 | 121.4 renders/s | 35.74 ms | 44.39 ms | +| Preact 10.29.7 | 1 | 30 | 78.0 renders/s | 9.68 ms | 10.52 ms | +| Preact 10.29.7 | 4 | 30 | 244.4 renders/s | 10.81 ms | 12.04 ms | +| Preact 10.29.7 | 8 | 30 | 410.9 renders/s | 11.28 ms | 13.54 ms | +| Vue 3.5.39 | 1 | 30 | 7.8 renders/s | 71.89 ms | 83.18 ms | +| Vue 3.5.39 | 4 | 30 | 14.7 renders/s | 139.53 ms | 157.85 ms | +| Vue 3.5.39 | 8 | 30 | 15.8 renders/s | 246.22 ms | 286.71 ms | +| Svelte 5.56.4 | 1 | 30 | 35.2 renders/s | 17.79 ms | 20.55 ms | +| Svelte 5.56.4 | 4 | 30 | 89.2 renders/s | 23.69 ms | 27.81 ms | +| Svelte 5.56.4 | 8 | 30 | 111.0 renders/s | 37.49 ms | 46.63 ms | ## 100-render isolation and reclamation probe @@ -57,7 +71,7 @@ data and one shared immutable program. | successful isolated renders | throughput | caller memory delta after GC | process-count delta | |---:|---:|---:|---:| -| 100/100 | 447.2 renders/s | -941.8 KiB | 0 | +| 100/100 | 418.1 renders/s | -929.3 KiB | 0 | Request-specific IDs were checked in every result. Memory and process deltas are endpoint observations after explicit caller GC, not operating-system RSS @@ -67,9 +81,9 @@ measurements. | Fixture | step rejection | memory rejection | timeout | observed timeout wall | handler cancellation after return | |---|---:|---:|---:|---:|---:| -| Preact 10.29.7 | limit:steps at 3650 | limit:memory_bytes at 133.1 KiB | limit:timeout at 200 ms | 201.1 ms | 22 µs | -| Vue 3.5.39 | limit:steps at 11956 | limit:memory_bytes at 495.9 KiB | limit:timeout at 200 ms | 212.76 ms | 25 µs | -| Svelte 5.56.4 | limit:steps at 1776 | limit:memory_bytes at 198.6 KiB | limit:timeout at 200 ms | 202.47 ms | 24 µs | +| Preact 10.29.7 | limit:steps at 3650 | limit:memory_bytes at 133.1 KiB | limit:timeout at 200 ms | 200.83 ms | 29 µs | +| Vue 3.5.39 | limit:steps at 11956 | limit:memory_bytes at 495.9 KiB | limit:timeout at 200 ms | 213.49 ms | 28 µs | +| Svelte 5.56.4 | limit:steps at 1776 | limit:memory_bytes at 198.6 KiB | limit:timeout at 200 ms | 203.06 ms | 26 µs | Memory rejection uses half the fixture's successful logical allocation. Timeout uses a non-returning asynchronous handler and verifies that its BEAM diff --git a/docs/beam-interpreter-architecture.md b/docs/beam-interpreter-architecture.md index 489a6ddf8..cf784ad5a 100644 --- a/docs/beam-interpreter-architecture.md +++ b/docs/beam-interpreter-architecture.md @@ -124,8 +124,9 @@ compiler pool, but evaluation must not require a QuickJS execution context. `decode/2` is an advanced API. It accepts only bytecode matching the running QuickBEAM build fingerprint. `measure/2` runs the same isolated evaluation as -`eval/2` while retaining deterministic step/logical-memory counters, endpoint -process observations, and end-to-end wall time. +`eval/2` while retaining deterministic step/logical-memory counters, fixed +owner-local compiler counters when selected, endpoint process observations, and +end-to-end wall time. ### Evaluate @@ -771,10 +772,15 @@ The reproducible fixture measurements are published in fairness and timeout gates in [`beam-scheduler-measurements.md`](beam-scheduler-measurements.md), the release-quarantined compiler SSR matrix in -[`beam-compiler-ssr-measurements.md`](beam-compiler-ssr-measurements.md), warm-loop results in -[`beam-compiler-performance-measurements.md`](beam-compiler-performance-measurements.md), and its -single-scheduler run in -[`beam-compiler-scheduler-measurements.md`](beam-compiler-scheduler-measurements.md). +[`beam-compiler-ssr-measurements.md`](beam-compiler-ssr-measurements.md), the +extended profile in +[`beam-compiler-scalar-ssr-measurements.md`](beam-compiler-scalar-ssr-measurements.md), +warm-loop results in +[`beam-compiler-performance-measurements.md`](beam-compiler-performance-measurements.md), +and their single-scheduler runs in +[`beam-compiler-scheduler-measurements.md`](beam-compiler-scheduler-measurements.md) +and +[`beam-compiler-scalar-scheduler-measurements.md`](beam-compiler-scalar-scheduler-measurements.md). The reports separate deterministic VM steps/logical allocation from endpoint BEAM process observations, wall latency, concurrent throughput, and cancellation. Current @@ -806,7 +812,8 @@ QuickBEAM.VM.eval(program, engine: :compiler) The generic compiler path runs one bounded pure block. Eligible lexical loops use bounded scalar generated forms, tail-call successor blocks, perform -canonical non-accessor property reads, and return explicit invocation actions. +canonical non-accessor property and owner-local global reads/writes, and return +explicit invocation actions. They reconstruct the canonical frame only at a verified deoptimization or call boundary. Compiler infrastructure failures are typed errors and never restart the program or invoke diff --git a/docs/prototype-delta-audit.md b/docs/prototype-delta-audit.md index 1315afc8a..77a5d05d6 100644 --- a/docs/prototype-delta-audit.md +++ b/docs/prototype-delta-audit.md @@ -323,16 +323,20 @@ handler deoptimization, native pure-expression parity, timeout/memory/step containment, and concurrent cache reuse. Preact, Vue, and Svelte now have compiler/interpreter/native parity; compiler measurements cover sequential and concurrent SSR, cancellation, reclamation, and `+S 1:1`; and the selected -Test262 compiler gate passes 65/65 supported cases. Selected nested bytecode +Test262 compiler gate passes 65/65 supported cases through both compiler +profiles. Selected nested bytecode frames now re-enter compiled execution through owner-local, bounded eligibility decisions while preserving stack limits and tail calls. The prototype's useful performance lesson has also been extracted without its runtime: eligible uncaptured lexical loops carry scalar stack/tuple state across generated blocks, -use guarded BEAM numeric primitives with canonical fallback, and rebuild frames -only at deoptimization. After one-time host-profile initialization, warm -arithmetic/branch/local loop execution is now 8.18×/7.70×/5.56× faster than the -interpreter, with array/property loops at 1.81×/6.14× and cold compilation at -8.50–25.86 ms. The compiler remains release quarantined because broader +use guarded BEAM numeric primitives with canonical fallback, thread canonical +global state, and rebuild frames only at deoptimization. Exact program +namespaces avoid repeatedly serializing atom tables for per-function keys, and +measurement-only OTP `:counters` expose bounded opcode coverage. After one-time +host-profile initialization, warm arithmetic/branch/local loop execution is now +8.23×/7.65×/5.69× faster than the interpreter, with array/property loops at +1.71×/5.26× and cold compilation at 10.77–25.65 ms. The compiler remains +release quarantined because broader Preact/Vue/Svelte performance and unsupported semantic families remain the release gate. No prototype compiler runtime is approved for copying. diff --git a/docs/test262-conformance.md b/docs/test262-conformance.md index 6cfec3627..eb9992441 100644 --- a/docs/test262-conformance.md +++ b/docs/test262-conformance.md @@ -43,8 +43,9 @@ external corpus gate is reported as skipped. ## Classification -Each selected path is run in a fresh native QuickJS runtime and isolated BEAM -interpreter and release-quarantined compiler-tier evaluations. Results are +Each selected path is run in a fresh native QuickJS runtime, the isolated BEAM +interpreter, and release-quarantined `:pure_v1` and `:scalar_v1` compiler-tier +evaluations. Results are classified as: - `pass` — native QuickJS and the selected BEAM tier satisfy the expectation; diff --git a/lib/quickbeam/vm.ex b/lib/quickbeam/vm.ex index e9ade0331..2d834206a 100644 --- a/lib/quickbeam/vm.ex +++ b/lib/quickbeam/vm.ex @@ -115,8 +115,8 @@ defmodule QuickBEAM.VM do @doc """ Evaluates a program with the same isolation and limits as `eval/2`, returning - its result together with deterministic step/logical-memory counters and - endpoint process observations. + its result together with deterministic step/logical-memory counters, bounded + compiler telemetry when selected, and endpoint process observations. Evaluation failures, including resource limits, are stored in `measurement.result`. Invalid programs or options are returned directly as @@ -313,6 +313,7 @@ defmodule QuickBEAM.VM do 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), process_memory_bytes: Map.get(metrics, :process_memory_bytes), reductions: Map.get(metrics, :reductions) } diff --git a/lib/quickbeam/vm/compiler.ex b/lib/quickbeam/vm/compiler.ex index 70ce96d1e..5099009ad 100644 --- a/lib/quickbeam/vm/compiler.ex +++ b/lib/quickbeam/vm/compiler.ex @@ -13,7 +13,7 @@ defmodule QuickBEAM.VM.Compiler do as typed compiler errors and never invoke native QuickJS. """ - alias QuickBEAM.VM.Compiler.{Context, Contract, GeneratedModule, ModulePool} + alias QuickBEAM.VM.Compiler.{Context, Contract, Counters, Deopt, GeneratedModule, ModulePool} alias QuickBEAM.VM.Compiler.Lowering.PureV1 alias QuickBEAM.VM.{Evaluator, Execution, Frame, Function, Interpreter, Program} @@ -47,7 +47,7 @@ defmodule QuickBEAM.VM.Compiler do ModulePool.start_link(opts) end - @doc "Evaluates a verified program through one compiled pure block and explicit deoptimization." + @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 @@ -73,8 +73,11 @@ defmodule QuickBEAM.VM.Compiler do def start(%Program{root: %Function{}} = program, opts \\ []) when is_list(opts) do {frame, execution} = Interpreter.initialize(program, opts) pool = Keyword.get(opts, :compiler_pool, ModulePool) + {:ok, artifact_namespace} = Contract.program_identity(program) context = %Context{ + artifact_namespace: artifact_namespace, + counters: if(execution.measurement_target, do: Counters.new()), pool: pool, profile: Keyword.get(opts, :compiler_profile, :pure_v1), program: program @@ -92,23 +95,29 @@ defmodule QuickBEAM.VM.Compiler do @spec execute_frame(struct(), struct()) :: frame_action() def execute_frame( %Frame{function: %Function{id: function_id} = function} = frame, - %Execution{compiler_context: %Context{pool: pool} = context} = execution + %Execution{compiler_context: %Context{pool: pool}} = execution ) do - with :ok <- ensure_pool_available(pool) do - case Map.fetch(context.decisions, function_id) do - {:ok, :skip} -> - {:skip, frame, execution} + execution = Counters.increment(execution, :frame_attempts) + context = execution.compiler_context - {:ok, {:compile, key, template}} -> - invoke_frame(pool, key, template, frame, execution) + action = + with :ok <- ensure_pool_available(pool) do + case Map.fetch(context.decisions, function_id) do + {:ok, :skip} -> + {:skip, frame, execution} - {:ok, {:cached, key}} -> - invoke_cached(pool, key, function, frame, execution) + {:ok, {:compile, key, template}} -> + invoke_frame(pool, key, template, frame, execution) - :error -> - prepare_frame(function, frame, execution) + {:ok, {:cached, key}} -> + invoke_cached(pool, key, function, frame, execution) + + :error -> + prepare_frame(function, frame, execution) + end end - end + + observe_action(action) end defp prepare_frame( @@ -124,13 +133,24 @@ defmodule QuickBEAM.VM.Compiler do ) do minimum = if function.id == root.id, do: 1, else: nested_minimum - with {:ok, key} <- Contract.artifact_key(program, function, profile: profile) do + if PureV1.candidate?(function, minimum, profile) do + prepare_keyed_frame(program, function, minimum, profile, frame, execution) + else + execution = Counters.increment(execution, :skipped_functions) + {:skip, frame, cache_decision(execution, function.id, :skip)} + 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 ModulePool.checkout_cached(execution.compiler_context.pool, key) do {:ok, lease} -> + execution = Counters.increment(execution, :cached_functions) execution = cache_decision(execution, function.id, {:cached, key}) invoke_lease(execution.compiler_context.pool, lease, frame, execution) :skip -> + execution = Counters.increment(execution, :skipped_functions) {:skip, frame, cache_decision(execution, function.id, :skip)} :miss -> @@ -142,14 +162,23 @@ defmodule QuickBEAM.VM.Compiler do end end + defp artifact_key(%Context{artifact_namespace: namespace}, _program, function, profile) + when is_binary(namespace), + do: Contract.artifact_key_from_identity(namespace, function, profile: profile) + + defp artifact_key(_context, program, function, profile), + do: Contract.artifact_key(program, function, profile: profile) + defp prepare_uncached_frame(function, minimum, profile, key, frame, execution) do case PureV1.prepare(function, minimum, profile) do {:ok, template, _count} -> + execution = Counters.increment(execution, :compiled_functions) execution = cache_decision(execution, function.id, {:compile, key, template}) invoke_frame(execution.compiler_context.pool, key, template, frame, execution) {:skip, _count} -> with :ok <- ModulePool.remember_skip(execution.compiler_context.pool, key) do + execution = Counters.increment(execution, :skipped_functions) {:skip, frame, cache_decision(execution, function.id, :skip)} end @@ -160,10 +189,18 @@ defmodule QuickBEAM.VM.Compiler do defp invoke_cached(pool, key, function, frame, execution) do case ModulePool.checkout_cached(pool, key) do - {:ok, lease} -> invoke_lease(pool, lease, frame, execution) - :skip -> {:skip, frame, cache_decision(execution, function.id, :skip)} - :miss -> prepare_frame(function, frame, execution) - {:error, reason} -> {:error, reason} + {:ok, lease} -> + invoke_lease(pool, lease, frame, execution) + + :skip -> + execution = Counters.increment(execution, :skipped_functions) + {:skip, frame, cache_decision(execution, function.id, :skip)} + + :miss -> + prepare_frame(function, frame, execution) + + {:error, reason} -> + {:error, reason} end end @@ -173,7 +210,12 @@ defmodule QuickBEAM.VM.Compiler do end defp invoke_lease(pool, lease, frame, execution) do - GeneratedModule.invoke(pool, lease, frame, execution) + execution = Counters.increment(execution, :generated_entries) + remaining_steps = execution.remaining_steps + + pool + |> GeneratedModule.invoke(lease, frame, execution) + |> add_generated_steps(remaining_steps) after safe_checkin(pool, lease) end @@ -187,6 +229,45 @@ defmodule QuickBEAM.VM.Compiler do end end + defp observe_action({:deopt, %Deopt{} = deopt}) do + execution = Counters.deopt(deopt.execution, deopt.reason, deopt.frame) + {:deopt, %{deopt | execution: execution}} + end + + defp observe_action({:invoke, callable, arguments, this, caller, execution, false}) do + execution = Counters.increment(execution, :invocation_actions) + {:invoke, callable, arguments, this, caller, execution, false} + end + + defp observe_action({:skip, frame, execution}) do + {:skip, frame, Counters.increment(execution, :skipped_frames)} + end + + defp observe_action(action), do: action + + defp add_generated_steps(action, before) do + update_action_execution(action, fn execution -> + Counters.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, %Execution{} = 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( diff --git a/lib/quickbeam/vm/compiler/context.ex b/lib/quickbeam/vm/compiler/context.ex index df2280862..22e070712 100644 --- a/lib/quickbeam/vm/compiler/context.ex +++ b/lib/quickbeam/vm/compiler/context.ex @@ -6,25 +6,31 @@ defmodule QuickBEAM.VM.Compiler.Context do continuations stay in the owning `%QuickBEAM.VM.Execution{}`. """ + alias QuickBEAM.VM.Compiler.Counters + @enforce_keys [:pool, :program] defstruct [ :pool, :program, + artifact_namespace: nil, decisions: %{}, max_decisions: 256, min_nested_instructions: 32, - profile: :pure_v1 + profile: :pure_v1, + counters: nil ] @type t :: %__MODULE__{ pool: QuickBEAM.VM.Compiler.ModulePool.server(), program: QuickBEAM.VM.Program.t(), + artifact_namespace: binary() | nil, decisions: %{ optional(non_neg_integer()) => :skip | {:cached, binary()} | {:compile, binary(), term()} }, max_decisions: pos_integer(), min_nested_instructions: non_neg_integer(), - profile: :pure_v1 | :scalar_v1 + profile: :pure_v1 | :scalar_v1, + counters: Counters.t() | nil } end diff --git a/lib/quickbeam/vm/compiler/contract.ex b/lib/quickbeam/vm/compiler/contract.ex index e2cabc6f5..efb86151c 100644 --- a/lib/quickbeam/vm/compiler/contract.ex +++ b/lib/quickbeam/vm/compiler/contract.ex @@ -10,7 +10,7 @@ defmodule QuickBEAM.VM.Compiler.Contract do alias QuickBEAM.VM.{Function, Program} @contract_version 1 - @runtime_abi_version 3 + @runtime_abi_version 4 @artifact_key_bytes 32 @profiles [:pure_v1, :scalar_v1] @@ -69,6 +69,24 @@ defmodule QuickBEAM.VM.Compiler.Contract do @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()} @@ -78,27 +96,46 @@ defmodule QuickBEAM.VM.Compiler.Contract do when is_list(opts) do with :ok <- validate_options(opts), profile = Keyword.get(opts, :profile, :pure_v1), - :ok <- validate_profile(profile) do - payload = { - @contract_version, - @runtime_abi_version, - program.version, - program.fingerprint, - program.bytecode_digest, - program.source_digest, - program.atoms, - function, - profile - } - - binary = :erlang.term_to_binary(payload, [:deterministic]) - {:ok, :crypto.hash(:sha256, binary)} + :ok <- validate_profile(profile), + {:ok, program_identity} <- program_identity(program) do + artifact_key_from_identity(program_identity, function, profile: profile) end end def artifact_key(program, function, _opts), do: {:error, {:invalid_artifact_input, program, function}} + @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), + :ok <- validate_profile(profile) do + payload = {program_identity, strip_repeated_atoms(function), profile} + {:ok, digest(payload)} + end + end + + def artifact_key_from_identity(program_identity, function, _opts), + do: {:error, {:invalid_artifact_identity, program_identity, function}} + + defp strip_repeated_atoms(%Function{} = function) do + constants = Enum.map(function.constants, &strip_repeated_atoms/1) + %{function | atoms: nil, constants: constants} + end + + defp strip_repeated_atoms(value), do: value + + 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] do diff --git a/lib/quickbeam/vm/compiler/counters.ex b/lib/quickbeam/vm/compiler/counters.ex new file mode 100644 index 000000000..9c3dfa47d --- /dev/null +++ b/lib/quickbeam/vm/compiler/counters.ex @@ -0,0 +1,152 @@ +defmodule QuickBEAM.VM.Compiler.Counters 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.{Execution, Frame, Opcodes} + + @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 + } + @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(Execution.t(), atom()) :: Execution.t() + def increment( + %Execution{ + 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(%Execution{} = execution, _event), do: execution + + @doc "Adds an exact generated instruction count to owner-local counters." + @spec add_generated_steps(Execution.t(), non_neg_integer()) :: Execution.t() + def add_generated_steps( + %Execution{ + 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(%Execution{} = execution, _count), do: execution + + @doc "Records one interpreted opcode while compiler measurement is enabled." + @spec interpreted_opcode(Execution.t(), non_neg_integer()) :: Execution.t() + def interpreted_opcode( + %Execution{ + 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(%Execution{} = execution, _opcode), do: execution + + @doc "Records one validated compiler deoptimization reason and opcode." + @spec deopt(Execution.t(), term(), Frame.t()) :: Execution.t() + def deopt(%Execution{} = 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(Execution.t()) :: map() | nil + def snapshot(%Execution{ + 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(%Execution{}), do: nil + + defp increment_deopt_opcode( + %Execution{ + 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(%Execution{} = execution, _frame), do: execution + + defp opcode_counts(reference, offset) do + Opcodes.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/generated_module/import_policy.ex b/lib/quickbeam/vm/compiler/generated_module/import_policy.ex index a6778fc3e..0292c314f 100644 --- a/lib/quickbeam/vm/compiler/generated_module/import_policy.ex +++ b/lib/quickbeam/vm/compiler/generated_module/import_policy.ex @@ -42,6 +42,8 @@ defmodule QuickBEAM.VM.Compiler.GeneratedModule.ImportPolicy do {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}, diff --git a/lib/quickbeam/vm/compiler/lowering/pure_v1.ex b/lib/quickbeam/vm/compiler/lowering/pure_v1.ex index f13e41c67..a4337f041 100644 --- a/lib/quickbeam/vm/compiler/lowering/pure_v1.ex +++ b/lib/quickbeam/vm/compiler/lowering/pure_v1.ex @@ -11,7 +11,7 @@ defmodule QuickBEAM.VM.Compiler.Lowering.PureV1 do alias QuickBEAM.VM.Compiler.GeneratedModule.Template alias QuickBEAM.VM.Compiler.Lowering.ScalarBlocks alias QuickBEAM.VM.Compiler.Runtime - alias QuickBEAM.VM.{Function, StackVerifier} + alias QuickBEAM.VM.{Function, Opcodes, StackVerifier} alias QuickBEAM.VM.Opcodes.Invocation @max_block_instruction_count 256 @@ -24,6 +24,14 @@ defmodule QuickBEAM.VM.Compiler.Lowering.PureV1 do 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, @@ -34,6 +42,14 @@ defmodule QuickBEAM.VM.Compiler.Lowering.PureV1 do @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 "Returns the deterministic pure-block execution plan for one function." @spec plan(Function.t()) :: {:ok, Runtime.plan()} | {:error, term()} def plan(%Function{} = function) do @@ -111,6 +127,22 @@ defmodule QuickBEAM.VM.Compiler.Lowering.PureV1 do end) end + defp backward_branch?(instructions) do + instructions + |> Tuple.to_list() + |> Enum.with_index() + |> Enum.any?(fn {{opcode, operands}, pc} -> + case Opcodes.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 @@ -127,33 +159,40 @@ defmodule QuickBEAM.VM.Compiler.Lowering.PureV1 do defp plan_block_segments(block) do block.instructions - |> split_invocation_segments([], []) + |> 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_invocation_segments([], [], segments), do: Enum.reverse(segments) + defp split_scalar_segments([], [], segments), do: Enum.reverse(segments) - defp split_invocation_segments([], current, segments), + defp split_scalar_segments([], current, segments), do: Enum.reverse([Enum.reverse(current) | segments]) - defp split_invocation_segments([instruction | instructions], current, segments) do + 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_invocation_segments(instructions, [], [[instruction] | 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_invocation_segments(instructions, [], [Enum.reverse(current) | segments]) + split_scalar_segments(instructions, [], [Enum.reverse(current) | segments]) true -> - split_invocation_segments(instructions, [instruction | current], segments) + 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 diff --git a/lib/quickbeam/vm/compiler/lowering/scalar_blocks.ex b/lib/quickbeam/vm/compiler/lowering/scalar_blocks.ex index 2a7f5af53..3d340cb6e 100644 --- a/lib/quickbeam/vm/compiler/lowering/scalar_blocks.ex +++ b/lib/quickbeam/vm/compiler/lowering/scalar_blocks.ex @@ -14,6 +14,7 @@ defmodule QuickBEAM.VM.Compiler.Lowering.ScalarBlocks do @line 1 @max_stack_depth 64 @max_scalar_operations 64 + @max_scalar_blocks 16 @stack_variables List.to_tuple( for index <- 0..(@max_stack_depth - 1), do: :"_CompilerStack#{index}" ) @@ -21,6 +22,8 @@ defmodule QuickBEAM.VM.Compiler.Lowering.ScalarBlocks do @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}") @type plan :: Runtime.plan() @@ -50,14 +53,13 @@ defmodule QuickBEAM.VM.Compiler.Lowering.ScalarBlocks do defp bounded_shape?(function, plan, levels) do function.stack_size <= @max_stack_depth and function.arg_count <= 8 and - function.var_count <= 8 and map_size(plan) <= 8 and + function.var_count <= 8 and map_size(plan) <= @max_scalar_blocks and scalar_operation_count(plan) <= @max_scalar_operations and Enum.all?(plan, fn {_pc, {operations, _reason}} -> length(operations) <= 32 end) and Enum.all?(levels, fn {_pc, {depth, _catch}} -> depth <= @max_stack_depth end) end - defp eligible_constants?(constants), - do: not nested_function_constants?(constants) and not captured_frame_slots?(constants) + defp eligible_constants?(constants), do: not captured_frame_slots?(constants) defp checked_locals_initialized?(function, plan) do count = max(function.arg_count + function.var_count, 1) @@ -150,8 +152,6 @@ defmodule QuickBEAM.VM.Compiler.Lowering.ScalarBlocks do count + length(operations) end) - defp nested_function_constants?(constants), do: Enum.any?(constants, &is_struct(&1, Function)) - defp captured_frame_slots?(constants) do Enum.any?(constants, fn %Function{closure_vars: closure_vars} -> @@ -216,6 +216,23 @@ defmodule QuickBEAM.VM.Compiler.Lowering.ScalarBlocks do ) end + defp block_clause(pc, {[{family, name, _operands}] = operations, reason}, levels) + 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) + } + + clause(block_arguments(pc, depth), [lower_operations(operations, reason, state)]) + end + defp block_clause(pc, {operations, reason}, levels) do depth = stack_depth!(levels, pc) lease = variable(:Lease) @@ -266,6 +283,10 @@ defmodule QuickBEAM.VM.Compiler.Lowering.ScalarBlocks do defp lower_operations([], reason, state), do: boundary_expression(reason, state) + defp lower_operations([{:global, name, operands} | operations], reason, state) do + lower_global(name, operands, operations, reason, state) + end + defp lower_operations([{:object, name, operands} | operations], reason, state) do lower_property(name, operands, operations, reason, state) end @@ -280,6 +301,48 @@ defmodule QuickBEAM.VM.Compiler.Lowering.ScalarBlocks do 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]) + next_state = %{state | pc: state.pc + 1, stack: [value | state.stack]} + + success = + if name == :get_var do + charge_preflight(state, fn execution -> + lower_operations(operations, reason, %{next_state | execution: execution}) + end) + else + 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) arguments = arguments |> Enum.reverse() |> list() @@ -312,14 +375,31 @@ defmodule QuickBEAM.VM.Compiler.Lowering.ScalarBlocks do get = remote_call(Runtime, :property_get, [object, key, state.execution]) next_state = %{state | pc: state.pc + 1, stack: [property_value | result_stack]} + success = + charge_preflight(state, fn execution -> + lower_operations(operations, reason, %{next_state | execution: execution}) + end) + case_expression(get, [ - clause([tuple([atom(:ok), property_value])], [ - lower_operations(operations, reason, next_state) - ]), + 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 + execution = variable(elem(@execution_variables, rem(state.pc, 256))) + action = variable(:Action) + compact = tuple([state.frame, integer(state.pc), state.args, state.locals, list(state.stack)]) + + charge = + remote_call(Runtime, :charge_state, [state.lease, compact, state.execution, integer(1)]) + + case_expression(charge, [ + clause([tuple([atom(:ok), execution])], [continuation.(execution)]), + 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} diff --git a/lib/quickbeam/vm/compiler/runtime.ex b/lib/quickbeam/vm/compiler/runtime.ex index 76c704402..29c9cb448 100644 --- a/lib/quickbeam/vm/compiler/runtime.ex +++ b/lib/quickbeam/vm/compiler/runtime.ex @@ -2,8 +2,8 @@ defmodule QuickBEAM.VM.Compiler.Runtime do @moduledoc """ Defines the versioned semantic ABI available to generated BEAM modules. - The initial ABI supports exact block charging, explicit deoptimization, and - verified pure stack, local, value, and branch operations. Implementations + 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. """ @@ -554,6 +554,25 @@ defmodule QuickBEAM.VM.Compiler.Runtime do 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(), Execution.t()) :: {:ok, term()} | :deopt + def global_get(mode, atom, %Execution{} = 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(), Execution.t()) :: Execution.t() + def global_put(atom, value, %Execution{} = 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(), Execution.t()) :: term() def resolve_atom(atom, %Execution{} = execution), do: Locals.resolve_atom(atom, execution) diff --git a/lib/quickbeam/vm/evaluator.ex b/lib/quickbeam/vm/evaluator.ex index e2018e852..b56bbb462 100644 --- a/lib/quickbeam/vm/evaluator.ex +++ b/lib/quickbeam/vm/evaluator.ex @@ -6,6 +6,8 @@ defmodule QuickBEAM.VM.Evaluator do coroutines, and waits for the final Promise without polling. """ + alias QuickBEAM.VM.Compiler.Counters + alias QuickBEAM.VM.{ Async, Continuation, @@ -234,6 +236,7 @@ defmodule QuickBEAM.VM.Evaluator do %{ steps: execution.step_limit - execution.remaining_steps, logical_memory_bytes: execution.memory_used, + compiler_counters: Counters.snapshot(execution), process_memory_bytes: process_memory, reductions: reductions }} diff --git a/lib/quickbeam/vm/interpreter.ex b/lib/quickbeam/vm/interpreter.ex index a43d9f36e..7bcb26336 100644 --- a/lib/quickbeam/vm/interpreter.ex +++ b/lib/quickbeam/vm/interpreter.ex @@ -12,6 +12,7 @@ defmodule QuickBEAM.VM.Interpreter do Async, AsyncBoundary, Builtins, + Compiler.Counters, Compiler.Deopt, Continuation, ConstructorBoundary, @@ -288,6 +289,7 @@ defmodule QuickBEAM.VM.Interpreter do %Execution{} = execution ) do frame = %{frame | compiler_entered: false, compiler_reentry_after_instruction: false} + execution = Counters.increment(execution, :reentries) execute_current(frame, execution) end @@ -318,8 +320,21 @@ defmodule QuickBEAM.VM.Interpreter do defp run(%Frame{} = frame, %Execution{} = execution), do: execute_current(frame, execution) + defp execute_current( + frame, + %Execution{compiler_context: %{counters: %Counters{}}} = execution + ) do + {opcode, operands} = elem(frame.function.instructions, frame.pc) + execution = Counters.interpreted_opcode(execution, 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} = Opcodes.info(opcode) {name, operands} = Opcodes.expand_short_form(name, operands, frame.function.arg_count) execution = %{execution | remaining_steps: execution.remaining_steps - 1} diff --git a/lib/quickbeam/vm/measurement.ex b/lib/quickbeam/vm/measurement.ex index cd2054d07..f7416273f 100644 --- a/lib/quickbeam/vm/measurement.ex +++ b/lib/quickbeam/vm/measurement.ex @@ -3,8 +3,10 @@ defmodule QuickBEAM.VM.Measurement do 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 + `compiler_counters` snapshots fixed-size owner-local OTP `:counters` when the + compiler engine is selected. Its compilation/cache decision fields + are lifecycle observations. `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. """ @@ -14,6 +16,7 @@ defmodule QuickBEAM.VM.Measurement do :wall_time_us, :steps, :logical_memory_bytes, + :compiler_counters, :process_memory_bytes, :reductions ] @@ -23,6 +26,7 @@ defmodule QuickBEAM.VM.Measurement do wall_time_us: non_neg_integer(), steps: non_neg_integer() | nil, logical_memory_bytes: non_neg_integer() | nil, + compiler_counters: map() | nil, process_memory_bytes: non_neg_integer() | nil, reductions: non_neg_integer() | nil } diff --git a/lib/quickbeam/vm/opcodes/locals.ex b/lib/quickbeam/vm/opcodes/locals.ex index aafeecd6d..c00596c53 100644 --- a/lib/quickbeam/vm/opcodes/locals.ex +++ b/lib/quickbeam/vm/opcodes/locals.ex @@ -151,7 +151,7 @@ defmodule QuickBEAM.VM.Opcodes.Locals do def execute(:get_var, [atom], frame, execution) do name = resolve_atom(atom, execution) - case global_value(execution, name) do + case read_global(execution, name) do {:ok, value} -> push(frame, execution, value) :error -> {:throw, {:reference_error, name}, frame, execution} end @@ -161,7 +161,7 @@ defmodule QuickBEAM.VM.Opcodes.Locals do name = resolve_atom(atom, execution) value = - case global_value(execution, name) do + case read_global(execution, name) do {:ok, value} -> value :error -> :undefined end @@ -172,7 +172,7 @@ defmodule QuickBEAM.VM.Opcodes.Locals do 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 = put_global(execution, name, value) + execution = write_global(execution, name, value) next(%{frame | stack: stack}, execution) end @@ -290,7 +290,9 @@ defmodule QuickBEAM.VM.Opcodes.Locals do def resolve_atom(value, _execution), do: value - defp global_value(execution, name) do + @doc "Reads a resolved global name through the canonical global-object fallback." + @spec read_global(Execution.t(), term()) :: {:ok, term()} | :error + def read_global(execution, name) do case Map.get(execution.globals, "globalThis") do %QuickBEAM.VM.Reference{} = global_this -> case Properties.get(global_this, name, execution) do @@ -304,7 +306,9 @@ defmodule QuickBEAM.VM.Opcodes.Locals do end end - defp put_global(execution, name, value) do + @doc "Writes a resolved global name and its canonical global-object property." + @spec write_global(Execution.t(), term(), term()) :: Execution.t() + def write_global(execution, name, value) do execution = %{execution | globals: Map.put(execution.globals, name, value)} case Map.get(execution.globals, "globalThis") do diff --git a/mix.exs b/mix.exs index 41afa1df3..d8c513f5a 100644 --- a/mix.exs +++ b/mix.exs @@ -111,7 +111,9 @@ defmodule QuickBEAM.MixProject do "docs/beam-compiler-contract.md", "docs/beam-compiler-performance-measurements.md", "docs/beam-compiler-scheduler-measurements.md", + "docs/beam-compiler-scalar-scheduler-measurements.md", "docs/beam-compiler-ssr-measurements.md", + "docs/beam-compiler-scalar-ssr-measurements.md", "docs/beam-scheduler-measurements.md", "docs/beam-ssr-measurements.md", "docs/prototype-delta-audit.md", @@ -126,7 +128,9 @@ defmodule QuickBEAM.MixProject do "docs/beam-compiler-contract.md", "docs/beam-compiler-performance-measurements.md", "docs/beam-compiler-scheduler-measurements.md", + "docs/beam-compiler-scalar-scheduler-measurements.md", "docs/beam-compiler-ssr-measurements.md", + "docs/beam-compiler-scalar-ssr-measurements.md", "docs/beam-scheduler-measurements.md", "docs/beam-ssr-measurements.md", "docs/prototype-delta-audit.md", diff --git a/test/support/test262.ex b/test/support/test262.ex index 1a84ba06b..143aacb1b 100644 --- a/test/support/test262.ex +++ b/test/support/test262.ex @@ -168,10 +168,12 @@ defmodule QuickBEAM.Test262 do defp vm_result(source, negative, opts) do engine = Keyword.get(opts, :engine, :interpreter) + compiler_profile = Keyword.get(opts, :compiler_profile, :pure_v1) case QuickBEAM.VM.compile(source, filename: "test262.js") do {:ok, program} -> - classify_execution(QuickBEAM.VM.eval(program, engine: engine), negative, :runtime) + result = QuickBEAM.VM.eval(program, engine: engine, compiler_profile: compiler_profile) + classify_execution(result, negative, :runtime) {:error, error} -> classify_execution({:error, error}, negative, :parse) diff --git a/test/vm/compiler_contract_test.exs b/test/vm/compiler_contract_test.exs index bedc30061..f85384a87 100644 --- a/test/vm/compiler_contract_test.exs +++ b/test/vm/compiler_contract_test.exs @@ -16,8 +16,11 @@ defmodule QuickBEAM.VM.CompilerContractTest do 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 byte_size(first) == Contract.artifact_key_bytes() # Warm every code path before measuring the permanent atom table. @@ -48,9 +51,12 @@ defmodule QuickBEAM.VM.CompilerContractTest do function ) + assert {:ok, changed_atoms_key} = Contract.artifact_key(%{program | atoms: {"x"}}, function) + refute changed_program_key == key refute changed_function_key == key refute changed_source_key == key + refute changed_atoms_key == key assert {:error, {:unknown_option, :unknown}} = Contract.artifact_key(program, function, unknown: true) diff --git a/test/vm/compiler_counters_test.exs b/test/vm/compiler_counters_test.exs new file mode 100644 index 000000000..a9b6602ea --- /dev/null +++ b/test/vm/compiler_counters_test.exs @@ -0,0 +1,33 @@ +defmodule QuickBEAM.VM.CompilerCountersTest do + use ExUnit.Case, async: true + + alias QuickBEAM.VM.Compiler.{Context, Counters} + alias QuickBEAM.VM.{Execution, Program} + + test "keeps fixed OTP counters in the evaluation owner" do + execution = %Execution{ + atoms: {}, + compiler_context: %Context{ + counters: Counters.new(), + pool: self(), + program: %Program{version: 26, fingerprint: "test", atoms: {}, root: nil} + }, + max_stack_depth: 8, + remaining_steps: 8, + step_limit: 8 + } + + execution = Counters.increment(execution, :frame_attempts) + assert Counters.snapshot(execution).frame_attempts == 1 + + task = + Task.async(fn -> + foreign = Counters.increment(execution, :frame_attempts) + Counters.snapshot(foreign) + end) + + assert Task.await(task) == nil + assert Counters.snapshot(execution).frame_attempts == 1 + assert map_size(Counters.snapshot(execution).deopt_opcodes) == 0 + end +end diff --git a/test/vm/compiler_orchestration_test.exs b/test/vm/compiler_orchestration_test.exs index 1efc08ead..8f5892f70 100644 --- a/test/vm/compiler_orchestration_test.exs +++ b/test/vm/compiler_orchestration_test.exs @@ -176,6 +176,10 @@ defmodule QuickBEAM.VM.CompilerOrchestrationTest do 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 @@ -226,8 +230,13 @@ defmodule QuickBEAM.VM.CompilerOrchestrationTest do timeout: 2_000 ] - assert QuickBEAM.VM.eval(program, [engine: :compiler] ++ options) == - QuickBEAM.VM.eval(program, options) + expected = QuickBEAM.VM.eval(program, options) + assert QuickBEAM.VM.eval(program, [engine: :compiler] ++ options) == expected + + assert QuickBEAM.VM.eval( + program, + [engine: :compiler, compiler_profile: :scalar_v1] ++ options + ) == expected end test "shares cached generated code across isolated evaluation owners" do diff --git a/test/vm/compiler_pure_v1_test.exs b/test/vm/compiler_pure_v1_test.exs index 8b7cfa79e..6004409e7 100644 --- a/test/vm/compiler_pure_v1_test.exs +++ b/test/vm/compiler_pure_v1_test.exs @@ -43,6 +43,21 @@ defmodule QuickBEAM.VM.CompilerPureV1Test do 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 PureV1.candidate?(small, 32, :pure_v1) + refute PureV1.candidate?(small, 32, :scalar_v1) + assert PureV1.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 PureV1.candidate?(loop, 10_000, :pure_v1) + assert PureV1.candidate?(loop, 10_000, :scalar_v1) + end + test "emits bounded pure plans with explicit unsupported boundaries" do function = arithmetic_function() @@ -268,9 +283,25 @@ defmodule QuickBEAM.VM.CompilerPureV1Test do for source <- sources do assert {:ok, program} = QuickBEAM.VM.compile(source) - - assert QuickBEAM.VM.eval(program, engine: :compiler, compiler_profile: :scalar_v1) == - QuickBEAM.VM.eval(program) + assert {:ok, interpreted} = QuickBEAM.VM.measure(program) + + assert {:ok, compiled} = + QuickBEAM.VM.measure(program, + engine: :compiler, + compiler_profile: :scalar_v1 + ) + + assert compiled.result == interpreted.result + assert compiled.steps == interpreted.steps + assert compiled.logical_memory_bytes == interpreted.logical_memory_bytes + + for limit <- [1, max(interpreted.steps - 1, 1)] do + assert QuickBEAM.VM.eval(program, + engine: :compiler, + compiler_profile: :scalar_v1, + max_steps: limit + ) == QuickBEAM.VM.eval(program, max_steps: limit) + end end end @@ -287,6 +318,10 @@ defmodule QuickBEAM.VM.CompilerPureV1Test do assert compiled.result == interpreted.result assert compiled.steps == interpreted.steps assert compiled.logical_memory_bytes == interpreted.logical_memory_bytes + assert compiled.compiler_counters.profile == :scalar_v1 + assert compiled.compiler_counters.generated_steps > 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 QuickBEAM.VM.eval(program, Keyword.put(opts, :max_steps, limit)) == @@ -294,6 +329,37 @@ defmodule QuickBEAM.VM.CompilerPureV1Test do 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 QuickBEAM.VM.eval(program, Keyword.put(opts, :max_steps, limit)) == + QuickBEAM.VM.eval(program, max_steps: limit) + end + + missing = "(function(n){for(let i=0;i= 1 assert stats.skips >= 1 From dbf6768ccaee54eafec0f5f8f5ca6d90b3431388 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Thu, 16 Jul 2026 15:30:30 +0200 Subject: [PATCH 59/87] Measure bounded compiler region admission --- CHANGELOG.md | 1 + bench/README.md | 19 +- bench/vm_compiler_region_hotspots.exs | 263 ++++++++++++++++++ bench/vm_compiler_region_probe.exs | 225 +++++++++++++++ bench/vm_compiler_ssr_eprof.exs | 13 +- bench/vm_ssr.exs | 43 ++- docs/beam-compiler-contract.md | 20 +- docs/beam-compiler-region-hotspots.md | 85 ++++++ docs/beam-compiler-region-investigation.md | 74 +++++ docs/beam-compiler-region-probe.md | 32 +++ docs/beam-compiler-region-ssr-measurements.md | 85 ++++++ lib/quickbeam/vm.ex | 16 +- lib/quickbeam/vm/compiler.ex | 208 ++++++++++++-- lib/quickbeam/vm/compiler/context.ex | 12 +- lib/quickbeam/vm/compiler/contract.ex | 51 +++- lib/quickbeam/vm/compiler/counters.ex | 6 +- .../generated_module/import_policy.ex | 1 + lib/quickbeam/vm/compiler/lowering/pure_v1.ex | 51 +++- .../vm/compiler/lowering/scalar_blocks.ex | 62 +++-- lib/quickbeam/vm/compiler/module_pool.ex | 64 +++++ lib/quickbeam/vm/compiler/region_probe.ex | 100 +++++++ lib/quickbeam/vm/compiler/runtime.ex | 5 + lib/quickbeam/vm/evaluator.ex | 3 +- lib/quickbeam/vm/interpreter.ex | 19 +- lib/quickbeam/vm/measurement.ex | 6 +- mix.exs | 4 + test/vm/compiler_contract_test.exs | 18 ++ test/vm/compiler_module_pool_test.exs | 24 ++ test/vm/compiler_orchestration_test.exs | 54 ++++ test/vm/compiler_pure_v1_test.exs | 20 ++ test/vm/compiler_region_probe_test.exs | 48 ++++ 31 files changed, 1560 insertions(+), 72 deletions(-) create mode 100644 bench/vm_compiler_region_hotspots.exs create mode 100644 bench/vm_compiler_region_probe.exs create mode 100644 docs/beam-compiler-region-hotspots.md create mode 100644 docs/beam-compiler-region-investigation.md create mode 100644 docs/beam-compiler-region-probe.md create mode 100644 docs/beam-compiler-region-ssr-measurements.md create mode 100644 lib/quickbeam/vm/compiler/region_probe.ex create mode 100644 test/vm/compiler_region_probe_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c13abeea..b515a8613 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased - Define the optional BEAM compiler contract with binary artifact identities, a fixed 32-module atom pool, lease/purge/cache lifecycle, versioned runtime ABI boundaries, and validated owner-local before-instruction deoptimization states. Add its supervised module pool, minimal canonical runtime ABI, structured generated-module backend, bounded v26 CFG analysis, specialized fixed-name `:pure_v1` forms, selective bounded nested-function re-entry, guarded opt-in `:scalar_v1` loop/property/global lowering, explicit call actions, exact shared program artifact namespaces, warm artifact/negative-decision caches, and release-quarantined `engine: :compiler` orchestration with native, Preact/Vue/Svelte SSR, selected Test262, resource, concurrency, measurement, and single-scheduler acceptance coverage. +- Add bounded static and dynamic compiler-region probes plus a quarantined `compiler_regions: true` executor experiment with binary admission identities, a fixed 256-entry maximum, three-encounter admission, 32-operation straight-line regions, safe early-boundary tuple updates, exact resource accounting, and reproducible SSR measurements. The experiment remains disabled after Vue reached only 4.3% generated coverage and regressed to a 112.70 ms median. - Cache validated immutable builtin host templates by profile and registry generation, preserving owner-local mutation isolation and exact logical-memory charging while removing repeated installation from warm evaluations. - Add `QuickBEAM.VM.measure/2` with deterministic step/logical-memory counters, fixed owner-local OTP compiler counters, and endpoint process observations, plus reproducible pinned SSR concurrency, timeout, cancellation, reclamation, and single-scheduler reports. - Serialize native addon initialization, reuse cached exports for aliases in one runtime, and reject implicit cross-runtime or post-reset reinitialization with a typed error. Add `allow_reinitialization: true` as an explicit compatibility escape hatch for addons that support multiple environments. diff --git a/bench/README.md b/bench/README.md index 96a6a56ce..1b5f480d8 100644 --- a/bench/README.md +++ b/bench/README.md @@ -39,6 +39,9 @@ COMPILER_EPROF_PHASE=initialization COMPILER_EPROF_ENGINE=interpreter \ # Attribute CPU in the pinned Vue fixture COMPILER_SSR_EPROF_PROFILE=scalar_v1 COMPILER_SSR_EPROF_ITERATIONS=3 \ MIX_ENV=bench mix run bench/vm_compiler_ssr_eprof.exs +COMPILER_SSR_EPROF_PROFILE=scalar_v1 COMPILER_SSR_EPROF_REGIONS=true \ + COMPILER_SSR_EPROF_ITERATIONS=3 MIX_ENV=bench \ + mix run bench/vm_compiler_ssr_eprof.exs # Reproduce the release-quarantined compiler SSR reports MIX_ENV=bench mix run bench/vm_ssr.exs \ @@ -47,6 +50,15 @@ MIX_ENV=bench mix run bench/vm_ssr.exs \ --engine compiler --compiler-profile scalar_v1 \ --output docs/beam-compiler-scalar-ssr-measurements.md +# Reproduce bounded-region opportunity and rejected executor measurements +MIX_ENV=bench mix run bench/vm_compiler_region_probe.exs \ + --output docs/beam-compiler-region-probe.md +MIX_ENV=bench mix run bench/vm_compiler_region_hotspots.exs \ + --output docs/beam-compiler-region-hotspots.md +MIX_ENV=bench mix run bench/vm_ssr.exs \ + --engine compiler --compiler-profile scalar_v1 --compiler-regions \ + --output docs/beam-compiler-region-ssr-measurements.md + # Reproduce the interpreter single-scheduler fairness/timeout probe ERL_FLAGS='+S 1:1' MIX_ENV=bench mix run bench/vm_scheduler_probe.exs \ --output docs/beam-scheduler-measurements.md @@ -66,8 +78,9 @@ profiles exactly one first evaluation in a fresh Mix VM, while execution warms the profile template and generated artifact before collecting samples. The SSR and scheduler runners accept `--compiler-profile pure_v1|scalar_v1`. -The SSR runner also accepts `--engine interpreter|compiler`, `--samples`, -`--warmup`, and a comma-separated `--concurrency` list. It reports deterministic +The SSR runner also accepts `--engine interpreter|compiler`, the quarantined +`--compiler-regions` experiment, `--samples`, `--warmup`, and a comma-separated +`--concurrency` list. It reports deterministic VM steps and logical allocation, fixed compiler coverage counters, endpoint BEAM process observations, sequential latency, concurrent throughput, and timeout/cancellation behavior for the pinned Preact, Vue, and Svelte @@ -76,6 +89,8 @@ fixtures. Published results are in [`docs/beam-compiler-performance-measurements.md`](../docs/beam-compiler-performance-measurements.md), [`docs/beam-compiler-ssr-measurements.md`](../docs/beam-compiler-ssr-measurements.md), [`docs/beam-compiler-scalar-ssr-measurements.md`](../docs/beam-compiler-scalar-ssr-measurements.md), +[`docs/beam-compiler-region-investigation.md`](../docs/beam-compiler-region-investigation.md), +[`docs/beam-compiler-region-ssr-measurements.md`](../docs/beam-compiler-region-ssr-measurements.md), [`docs/beam-scheduler-measurements.md`](../docs/beam-scheduler-measurements.md), [`docs/beam-compiler-scheduler-measurements.md`](../docs/beam-compiler-scheduler-measurements.md), and diff --git a/bench/vm_compiler_region_hotspots.exs b/bench/vm_compiler_region_hotspots.exs new file mode 100644 index 000000000..e7f0115d1 --- /dev/null +++ b/bench/vm_compiler_region_hotspots.exs @@ -0,0 +1,263 @@ +defmodule QuickBEAM.Bench.VMCompilerRegionHotspots do + @moduledoc """ + Samples bounded dynamic instruction windows in the pinned SSR fixtures. + """ + + alias QuickBEAM.VM.Compiler + alias QuickBEAM.VM.Compiler.Lowering.PureV1 + alias QuickBEAM.VM.{Function, Opcodes} + + @module_slots 32 + + def run(args) do + {opts, positional, invalid} = OptionParser.parse(args, strict: [output: :string]) + + if positional != [] or invalid != [], + do: raise(ArgumentError, "invalid arguments: #{inspect(positional ++ invalid)}") + + {:ok, _compiler} = Compiler.start_link(capacity: @module_slots) + + results = + for fixture <- fixture_specs(), profile <- [:pure_v1, :scalar_v1] do + analyze_fixture(fixture, profile) + end + + report = report(results) + IO.write(report) + + if output = opts[:output] do + File.mkdir_p!(Path.dirname(output)) + File.write!(output, report) + end + after + if Process.whereis(Compiler.ModulePool), do: GenServer.stop(Compiler.ModulePool) + end + + defp analyze_fixture(fixture, profile) do + {:ok, source} = QuickBEAM.JS.bundle_file(fixture.path, fixture.bundle_opts) + {:ok, program} = QuickBEAM.VM.compile(source, filename: fixture.path) + functions = program.root |> functions() |> Map.new(&{&1.id, &1}) + options = eval_options(fixture, profile) + + {:ok, _value} = QuickBEAM.VM.eval(program, options) + + {:ok, measurement} = + QuickBEAM.VM.measure(program, Keyword.put(options, :compiler_region_probe, true)) + + unless match?({:ok, _value}, measurement.result), + do: raise("#{fixture.name} failed: #{inspect(measurement.result)}") + + probe = measurement.compiler_regions + + regions = + Enum.map(probe.regions, fn region -> + function = Map.fetch!(functions, region.function_id) + support = supported_window(function, region.entry_pc, probe.window_size, profile) + lower_bound = max(region.samples - region.error, 0) + weighted_support = lower_bound * support.supported / max(support.instructions, 1) + + Map.merge(region, %{ + instructions: support.instructions, + supported: support.supported, + lower_bound: lower_bound, + weighted_support: weighted_support + }) + end) + + selected = + regions + |> Enum.sort_by(&{-&1.weighted_support, &1.function_id, &1.entry_pc}) + |> Enum.take(@module_slots) + + lower_bound_coverage = + 100 * Enum.sum(Enum.map(selected, & &1.weighted_support)) / max(probe.total_samples, 1) + + %{ + fixture: fixture.name, + profile: profile, + steps: measurement.steps, + generated_steps: measurement.compiler_counters.generated_steps, + total_samples: probe.total_samples, + retained_regions: length(regions), + lower_bound_coverage: Float.round(lower_bound_coverage, 1), + regions: regions + } + end + + defp supported_window(function, entry_pc, window_size, profile) do + end_pc = min(entry_pc + window_size, tuple_size(function.instructions)) - 1 + + if end_pc < entry_pc do + %{instructions: 0, supported: 0} + else + Enum.reduce(entry_pc..end_pc, %{instructions: 0, supported: 0}, fn pc, counts -> + {opcode, operands} = elem(function.instructions, pc) + {name, _size, _pops, _pushes, _format} = Opcodes.info(opcode) + {name, operands} = Opcodes.expand_short_form(name, operands, function.arg_count) + instruction = {pc, name, operands} + + %{ + instructions: counts.instructions + 1, + supported: + counts.supported + + if(PureV1.supported_instruction?(instruction, profile), do: 1, else: 0) + } + end) + end + end + + defp functions(%Function{} = function) do + nested = + Enum.flat_map(function.constants, fn + %Function{} = child -> functions(child) + _constant -> [] + end) + + [function | nested] + end + + defp eval_options(fixture, compiler_profile) do + [ + engine: :compiler, + compiler_profile: compiler_profile, + isolation: :caller, + profile: :ssr, + handlers: %{"load_props" => fn [] -> props() end}, + max_steps: fixture.max_steps, + memory_limit: fixture.memory_limit, + timeout: 5_000 + ] + end + + defp fixture_specs do + [ + %{ + name: "Preact 10.29.7", + path: "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", + path: "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", + path: "test/fixtures/vm/svelte_ssr.js", + bundle_opts: [format: :esm, minify: true], + max_steps: 20_000_000, + memory_limit: 64_000_000 + } + ] + end + + defp props do + %{ + "title" => "Region probe", + "products" => [ + %{"id" => 1, "name" => "Product 1", "inStock" => true, "priceCents" => 1_299} + ] + } + end + + defp report(results) do + """ + # Bounded compiler dynamic region hotspots + + This opt-in diagnostic samples every 16th interpreted instruction into at + most 64 owner-local Space-Saving heavy hitters. Windows are aligned to 64 + instruction PCs. `samples-error` is the conservative frequency lower bound; + fixed-pool potential applies each window's statically supported ratio to the + 32 strongest lower bounds. Generated instructions are not sampled. Results + are fixture/profile-specific and are not production telemetry or speedup + claims. + + - 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()} + - CPU: #{cpu_model()} + - Sampling interval: 16 interpreted instructions + - Heavy-hitter capacity: 64 regions + - Fixed generated-module slots: #{@module_slots} + + | Fixture | profile | VM steps | existing generated steps | samples | retained regions | fixed-pool supported lower bound | + |---|---|---:|---:|---:|---:|---:| + #{Enum.map_join(results, "\n", &summary_row/1)} + + ## Leading sampled windows + + | Fixture | profile | function@pc | samples | error | lower bound | supported instructions | + |---|---|---|---:|---:|---:|---:| + #{Enum.map_join(results, "\n", &hotspot_rows/1)} + + The next implementation gate is positive only when a small fixed region set + has a meaningful conservative dynamic lower bound. Otherwise region + compilation would add artifact churn without enough warm execution. + """ + end + + defp summary_row(result) do + "| #{result.fixture} | `#{result.profile}` | #{result.steps} | " <> + "#{result.generated_steps} | #{result.total_samples} | #{result.retained_regions} | " <> + "#{result.lower_bound_coverage}% |" + end + + defp hotspot_rows(result) do + result.regions + |> Enum.sort_by(&{-&1.lower_bound, -&1.samples, &1.function_id, &1.entry_pc}) + |> Enum.take(8) + |> Enum.map_join("\n", fn region -> + "| #{result.fixture} | `#{result.profile}` | `f#{region.function_id}@#{region.entry_pc}` | " <> + "#{region.samples} | #{region.error} | #{region.lower_bound} | " <> + "#{region.supported}/#{region.instructions} |" + 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 cpu_model do + case File.read("/proc/cpuinfo") do + {:ok, contents} -> + contents + |> String.split("\n") + |> Enum.find_value("unknown", fn line -> + case String.split(line, ":", parts: 2) do + [key, model] -> if(String.trim(key) == "model name", do: String.trim(model)) + _line -> nil + end + end) + + {:error, _reason} -> + command("sysctl", ["-n", "machdep.cpu.brand_string"]) + end + end +end + +QuickBEAM.Bench.VMCompilerRegionHotspots.run(System.argv()) diff --git a/bench/vm_compiler_region_probe.exs b/bench/vm_compiler_region_probe.exs new file mode 100644 index 000000000..ff8e2b7e0 --- /dev/null +++ b/bench/vm_compiler_region_probe.exs @@ -0,0 +1,225 @@ +defmodule QuickBEAM.Bench.VMCompilerRegionProbe do + @moduledoc """ + Computes a static upper bound for bounded one-block compiler regions. + """ + + alias QuickBEAM.VM.Compiler.Analysis.CFG + alias QuickBEAM.VM.Compiler.Lowering.PureV1 + alias QuickBEAM.VM.Function + + @max_region_operations 64 + @module_slots 32 + @preflight_operations [:get_array_el, :get_field, :get_field2, :get_length, :get_var] + @invocation_operations [:call, :call_method] + + def run(args) do + {opts, positional, invalid} = OptionParser.parse(args, strict: [output: :string]) + + if positional != [] or invalid != [], + do: raise(ArgumentError, "invalid arguments: #{inspect(positional ++ invalid)}") + + results = + for fixture <- fixture_specs(), profile <- [:pure_v1, :scalar_v1] do + analyze_fixture(fixture, profile) + end + + report = report(results) + IO.write(report) + + if output = opts[:output] do + File.mkdir_p!(Path.dirname(output)) + File.write!(output, report) + end + end + + defp analyze_fixture(fixture, profile) do + {:ok, source} = QuickBEAM.JS.bundle_file(fixture.path, fixture.bundle_opts) + {:ok, program} = QuickBEAM.VM.compile(source, filename: fixture.path) + functions = functions(program.root) + + regions = + Enum.flat_map(functions, fn function -> + {:ok, blocks} = CFG.analyze(function) + + Enum.flat_map(blocks, fn block -> + block.instructions + |> split_regions(profile, [], []) + |> Enum.map(fn instructions -> + %{ + function_id: function.id, + entry_pc: instructions |> hd() |> elem(0), + operations: length(instructions) + } + end) + end) + end) + + instruction_count = Enum.sum(Enum.map(functions, &tuple_size(&1.instructions))) + region_operations = Enum.sum(Enum.map(regions, & &1.operations)) + + slot_operations = + regions + |> Enum.sort_by(&{-&1.operations, &1.function_id, &1.entry_pc}) + |> Enum.take(@module_slots) + |> Enum.reduce(0, &(&1.operations + &2)) + + %{ + fixture: fixture.name, + profile: profile, + functions: length(functions), + instructions: instruction_count, + region_functions: regions |> Enum.map(& &1.function_id) |> Enum.uniq() |> length(), + regions: length(regions), + region_operations: region_operations, + region_coverage: percentage(region_operations, instruction_count), + slot_operations: slot_operations, + slot_coverage: percentage(slot_operations, instruction_count) + } + end + + defp split_regions([], _profile, [], regions), do: Enum.reverse(regions) + + defp split_regions([], _profile, current, regions), + do: Enum.reverse([Enum.reverse(current) | regions]) + + defp split_regions([instruction | instructions], profile, current, regions) do + {_pc, name, _operands} = instruction + + cond do + not PureV1.supported_instruction?(instruction, profile) -> + regions = flush(current, regions) + split_regions(instructions, profile, [], regions) + + name in @preflight_operations -> + regions = [[instruction] | flush(current, regions)] + split_regions(instructions, profile, [], regions) + + name in @invocation_operations -> + current = [instruction | current] + split_regions(instructions, profile, [], [Enum.reverse(current) | regions]) + + length(current) + 1 == @max_region_operations -> + current = [instruction | current] + split_regions(instructions, profile, [], [Enum.reverse(current) | regions]) + + true -> + split_regions(instructions, profile, [instruction | current], regions) + end + end + + defp flush([], regions), do: regions + defp flush(current, regions), do: [Enum.reverse(current) | regions] + + defp functions(%Function{} = function) do + nested = + Enum.flat_map(function.constants, fn + %Function{} = child -> functions(child) + _constant -> [] + end) + + [function | nested] + end + + defp fixture_specs do + [ + %{ + name: "Preact 10.29.7", + path: "test/fixtures/vm/preact_ssr.js", + bundle_opts: [format: :esm, minify: false] + }, + %{ + name: "Vue 3.5.39", + path: "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") + } + ] + }, + %{ + name: "Svelte 5.56.4", + path: "test/fixtures/vm/svelte_ssr.js", + bundle_opts: [format: :esm, minify: true] + } + ] + end + + defp report(results) do + """ + # Bounded compiler region coverage probe + + This static probe partitions each verified basic block into independently + compilable regions of at most #{@max_region_operations} operations. Property + and strict-global reads remain isolated preflight regions, calls terminate a + region, and unsupported instructions are excluded. The fixed module-pool + estimate retains only the #{@module_slots} largest regions. These figures are + an instruction-inventory bound, not dynamic execution coverage or a speedup + claim. + + - 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()} + - CPU: #{cpu_model()} + - Maximum operations per region: #{@max_region_operations} + - Fixed generated-module slots: #{@module_slots} + + | Fixture | profile | functions | instructions | region functions | bounded regions | regionizable instructions | static region coverage | largest 32 instructions | fixed-pool static coverage | + |---|---|---:|---:|---:|---:|---:|---:|---:|---:| + #{Enum.map_join(results, "\n", &row/1)} + + A region tier is useful only if dynamic hot-region measurements show that a + small fixed set is executed repeatedly. Static coverage alone cannot justify + compiling every listed region or exceeding the existing module and decision + bounds. + """ + end + + defp row(result) do + "| #{result.fixture} | `#{result.profile}` | #{result.functions} | " <> + "#{result.instructions} | #{result.region_functions} | #{result.regions} | " <> + "#{result.region_operations} | #{result.region_coverage}% | " <> + "#{result.slot_operations} | #{result.slot_coverage}% |" + end + + defp percentage(value, total), do: Float.round(100 * value / max(total, 1), 1) + + 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 cpu_model do + case File.read("/proc/cpuinfo") do + {:ok, contents} -> + contents + |> String.split("\n") + |> Enum.find_value("unknown", fn line -> + case String.split(line, ":", parts: 2) do + [key, model] -> if(String.trim(key) == "model name", do: String.trim(model)) + _line -> nil + end + end) + + {:error, _reason} -> + command("sysctl", ["-n", "machdep.cpu.brand_string"]) + end + end +end + +QuickBEAM.Bench.VMCompilerRegionProbe.run(System.argv()) diff --git a/bench/vm_compiler_ssr_eprof.exs b/bench/vm_compiler_ssr_eprof.exs index 54c588552..601aac4d6 100644 --- a/bench/vm_compiler_ssr_eprof.exs +++ b/bench/vm_compiler_ssr_eprof.exs @@ -4,6 +4,7 @@ alias QuickBEAM.VM.Compiler iterations = String.to_integer(System.get_env("COMPILER_SSR_EPROF_ITERATIONS", "5")) profile = System.get_env("COMPILER_SSR_EPROF_PROFILE", "pure_v1") +regions = System.get_env("COMPILER_SSR_EPROF_REGIONS", "false") == "true" {engine, compiler_profile} = case profile do @@ -39,6 +40,7 @@ props = %{ options = [ engine: engine, compiler_profile: compiler_profile, + compiler_regions: regions, isolation: :caller, profile: :ssr, handlers: %{"load_props" => fn [] -> props end}, @@ -47,7 +49,13 @@ options = [ timeout: 5_000 ] -expected = QuickBEAM.VM.eval(program, options) +expected = + Enum.reduce(1..if(regions, do: 3, else: 1), nil, fn _iteration, expected -> + result = QuickBEAM.VM.eval(program, options) + if expected != nil and result != expected, do: raise("warmup result changed") + result + end) + pool = Process.whereis(QuickBEAM.VM.Compiler.ModulePool) tools_pattern = Path.join([to_string(:code.root_dir()), "lib", "tools-*", "ebin"]) @@ -65,7 +73,8 @@ end) :eprof.stop_profiling() IO.puts( - "EPROF fixture=vue_ssr engine=#{engine} compiler_profile=#{compiler_profile} iterations=#{iterations}" + "EPROF fixture=vue_ssr engine=#{engine} compiler_profile=#{compiler_profile} " <> + "regions=#{regions} iterations=#{iterations}" ) :eprof.analyze(:total) diff --git a/bench/vm_ssr.exs b/bench/vm_ssr.exs index 39b447a3c..1d905ac2a 100644 --- a/bench/vm_ssr.exs +++ b/bench/vm_ssr.exs @@ -15,6 +15,7 @@ defmodule QuickBEAM.Bench.VMSSR do strict: [ engine: :string, compiler_profile: :string, + compiler_regions: :boolean, samples: :integer, warmup: :integer, concurrency: :string, @@ -27,6 +28,7 @@ defmodule QuickBEAM.Bench.VMSSR do 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) maybe_start_compiler!(engine) samples = positive!(Keyword.get(opts, :samples, @default_samples), :samples) warmup = non_negative!(Keyword.get(opts, :warmup, @default_warmup), :warmup) @@ -34,7 +36,8 @@ defmodule QuickBEAM.Bench.VMSSR do concurrency = concurrency!(Keyword.get(opts, :concurrency, Enum.join(@default_concurrency, ","))) - fixtures = Enum.map(fixture_specs(), &compile_fixture!(&1, engine, compiler_profile)) + fixtures = + Enum.map(fixture_specs(), &compile_fixture!(&1, engine, compiler_profile, compiler_regions)) results = Enum.map(fixtures, fn fixture -> @@ -51,7 +54,16 @@ defmodule QuickBEAM.Bench.VMSSR do isolation = isolation_probe(hd(fixtures)) report = - markdown_report(engine, compiler_profile, results, isolation, samples, warmup, concurrency) + markdown_report( + engine, + compiler_profile, + compiler_regions, + results, + isolation, + samples, + warmup, + concurrency + ) IO.write(report) @@ -108,7 +120,7 @@ defmodule QuickBEAM.Bench.VMSSR do ] end - defp compile_fixture!(spec, engine, compiler_profile) do + defp compile_fixture!(spec, engine, compiler_profile, compiler_regions) do {:ok, source} = QuickBEAM.JS.bundle_file(spec.fixture, spec.bundle_opts) {:ok, program} = QuickBEAM.VM.compile(source, filename: spec.fixture) @@ -116,6 +128,7 @@ defmodule QuickBEAM.Bench.VMSSR do spec.eval_opts |> Keyword.put(:engine, engine) |> Keyword.put(:compiler_profile, compiler_profile) + |> Keyword.put(:compiler_regions, compiler_regions) spec |> Map.put(:program, program) @@ -368,6 +381,7 @@ defmodule QuickBEAM.Bench.VMSSR do defp markdown_report( engine, compiler_profile, + compiler_regions, results, isolation, samples, @@ -384,10 +398,19 @@ defmodule QuickBEAM.Bench.VMSSR do end title = - case {engine, compiler_profile} do - {:compiler, :scalar_v1} -> "BEAM scalar compiler SSR measurements" - {:compiler, _profile} -> "BEAM compiler SSR measurements" - {:interpreter, _profile} -> "BEAM VM SSR measurements" + 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 """ @@ -395,14 +418,14 @@ defmodule QuickBEAM.Bench.VMSSR do 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. The - single-scheduler fairness and timeout gate is published separately in - [`#{scheduler_report}`](#{scheduler_report}). + 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} - Git base: `#{metadata.git}` - Working tree at measurement: #{metadata.tree_state} - Generated: #{metadata.generated} diff --git a/docs/beam-compiler-contract.md b/docs/beam-compiler-contract.md index 58b4f1a2e..ec1a6860a 100644 --- a/docs/beam-compiler-contract.md +++ b/docs/beam-compiler-contract.md @@ -151,7 +151,7 @@ safely purged remains loaded until VM shutdown. Generated modules may call `:erlang` guard/BIF operations approved by a BEAM-disassembly test and one module, `QuickBEAM.VM.Compiler.Runtime`. That -module is runtime ABI version 4 and delegates semantics to the existing +module is runtime ABI version 5 and delegates semantics to the existing canonical layers: - `Value` for primitive coercion and operators; @@ -172,7 +172,8 @@ The current ABI contains only: - guarded primitive operations with canonical `Value` fallback; - canonical non-accessor property reads, owner-local global reads/writes, and explicit invocation actions; - truthiness and verified branch selection; -- reconstruction of canonical frames and typed `%Compiler.Deopt{}` values. +- reconstruction of canonical frames and typed `%Compiler.Deopt{}` values; +- bounded runtime tuple updates for generated regions that end before the full scalar CFG. `Compiler.GeneratedModule.ImportPolicy` inspects every generated module import before installation. The closed allowlist contains this ABI, a fixed set of @@ -201,7 +202,20 @@ unchanged. Generated generic blocks are capped at 256 QuickJS instructions and one function artifact at 4,096 blocks and 4,096 lowered instructions. Generic blocks still -deoptimize at control-flow edges. The narrower scalar profile may tail-call at +deoptimize at control-flow edges. The quarantined `compiler_regions: true` experiment admits binary +`{program, function, entry_pc, profile}` identities only after three encounters. +Its shared observation table is capped at `capacity * 8` (at most 256 entries), +the stable admitted set is capped at half the module pool, owner-local decisions +remain capped at 256, and generated artifacts still use only the fixed module +pool. The current executor deliberately compiles only the +first straight-line block at function entry, with at most 32 operations; it +removes a terminal branch and deoptimizes before the next instruction. Runtime +tuple updates avoid invalid early-boundary BEAM tuple-update optimization. This +experiment is disabled by default: measured Vue overhead and module churn reject +it as a production tier even though exact steps, logical memory, and results +remain canonical. + +The narrower scalar profile may tail-call at most 16 generated successor blocks while preserving per-block charging and outer process containment. The existing `+S 1:1` ticker-gap and timeout report remains a regression gate for compiled execution. Measurement-only compiler diff --git a/docs/beam-compiler-region-hotspots.md b/docs/beam-compiler-region-hotspots.md new file mode 100644 index 000000000..80a511056 --- /dev/null +++ b/docs/beam-compiler-region-hotspots.md @@ -0,0 +1,85 @@ +# Bounded compiler dynamic region hotspots + +This opt-in diagnostic samples every 16th interpreted instruction into at +most 64 owner-local Space-Saving heavy hitters. Windows are aligned to 64 +instruction PCs. `samples-error` is the conservative frequency lower bound; +fixed-pool potential applies each window's statically supported ratio to the +32 strongest lower bounds. Generated instructions are not sampled. Results +are fixture/profile-specific and are not production telemetry or speedup +claims. + +- Git base: `0a332b81` +- Working tree at measurement: modified +- Generated: 2026-07-16T13:22:35Z +- Elixir: 1.20.2 +- OTP: 29 +- CPU: AMD Ryzen 9 9950X 16-Core Processor +- Sampling interval: 16 interpreted instructions +- Heavy-hitter capacity: 64 regions +- Fixed generated-module slots: 32 + +| Fixture | profile | VM steps | existing generated steps | samples | retained regions | fixed-pool supported lower bound | +|---|---|---:|---:|---:|---:|---:| +| Preact 10.29.7 | `pure_v1` | 3651 | 0 | 228 | 41 | 61.8% | +| Preact 10.29.7 | `scalar_v1` | 3651 | 0 | 228 | 41 | 85.2% | +| Vue 3.5.39 | `pure_v1` | 11957 | 50 | 744 | 64 | 37.0% | +| Vue 3.5.39 | `scalar_v1` | 11957 | 126 | 739 | 64 | 46.9% | +| Svelte 5.56.4 | `pure_v1` | 1777 | 24 | 109 | 34 | 34.0% | +| Svelte 5.56.4 | `scalar_v1` | 1777 | 0 | 111 | 35 | 77.7% | + +## Leading sampled windows + +| Fixture | profile | function@pc | samples | error | lower bound | supported instructions | +|---|---|---|---:|---:|---:|---:| +| Preact 10.29.7 | `pure_v1` | `f56@0` | 28 | 0 | 28 | 46/64 | +| Preact 10.29.7 | `pure_v1` | `f4@0` | 19 | 0 | 19 | 35/57 | +| Preact 10.29.7 | `pure_v1` | `f3@0` | 18 | 0 | 18 | 48/64 | +| Preact 10.29.7 | `pure_v1` | `f56@64` | 13 | 0 | 13 | 51/64 | +| Preact 10.29.7 | `pure_v1` | `f56@1024` | 12 | 0 | 12 | 52/64 | +| Preact 10.29.7 | `pure_v1` | `f56@128` | 10 | 0 | 10 | 41/64 | +| Preact 10.29.7 | `pure_v1` | `f56@1088` | 10 | 0 | 10 | 49/64 | +| Preact 10.29.7 | `pure_v1` | `f56@768` | 9 | 0 | 9 | 50/64 | +| Preact 10.29.7 | `scalar_v1` | `f56@0` | 28 | 0 | 28 | 59/64 | +| Preact 10.29.7 | `scalar_v1` | `f4@0` | 19 | 0 | 19 | 42/57 | +| Preact 10.29.7 | `scalar_v1` | `f3@0` | 18 | 0 | 18 | 57/64 | +| Preact 10.29.7 | `scalar_v1` | `f56@64` | 13 | 0 | 13 | 58/64 | +| Preact 10.29.7 | `scalar_v1` | `f56@1024` | 12 | 0 | 12 | 62/64 | +| Preact 10.29.7 | `scalar_v1` | `f56@128` | 10 | 0 | 10 | 60/64 | +| Preact 10.29.7 | `scalar_v1` | `f56@1088` | 10 | 0 | 10 | 61/64 | +| Preact 10.29.7 | `scalar_v1` | `f56@768` | 9 | 0 | 9 | 62/64 | +| Vue 3.5.39 | `pure_v1` | `f1@0` | 165 | 0 | 165 | 21/35 | +| Vue 3.5.39 | `pure_v1` | `f493@0` | 20 | 1 | 19 | 62/64 | +| Vue 3.5.39 | `pure_v1` | `f492@64` | 20 | 3 | 17 | 39/64 | +| Vue 3.5.39 | `pure_v1` | `f652@0` | 20 | 5 | 15 | 31/53 | +| Vue 3.5.39 | `pure_v1` | `f175@0` | 15 | 0 | 15 | 11/20 | +| Vue 3.5.39 | `pure_v1` | `f660@0` | 18 | 4 | 14 | 50/64 | +| Vue 3.5.39 | `pure_v1` | `f658@0` | 16 | 4 | 12 | 47/64 | +| Vue 3.5.39 | `pure_v1` | `f492@0` | 15 | 3 | 12 | 63/64 | +| Vue 3.5.39 | `scalar_v1` | `f1@0` | 165 | 0 | 165 | 27/35 | +| Vue 3.5.39 | `scalar_v1` | `f493@0` | 20 | 1 | 19 | 64/64 | +| Vue 3.5.39 | `scalar_v1` | `f492@64` | 20 | 3 | 17 | 44/64 | +| Vue 3.5.39 | `scalar_v1` | `f175@0` | 15 | 0 | 15 | 13/20 | +| Vue 3.5.39 | `scalar_v1` | `f660@0` | 18 | 4 | 14 | 60/64 | +| Vue 3.5.39 | `scalar_v1` | `f652@0` | 17 | 5 | 12 | 42/53 | +| Vue 3.5.39 | `scalar_v1` | `f492@0` | 15 | 3 | 12 | 64/64 | +| Vue 3.5.39 | `scalar_v1` | `f658@0` | 15 | 4 | 11 | 61/64 | +| Svelte 5.56.4 | `pure_v1` | `f1@0` | 8 | 0 | 8 | 46/64 | +| Svelte 5.56.4 | `pure_v1` | `f199@0` | 5 | 0 | 5 | 31/64 | +| Svelte 5.56.4 | `pure_v1` | `f0@0` | 4 | 0 | 4 | 0/64 | +| Svelte 5.56.4 | `pure_v1` | `f0@64` | 4 | 0 | 4 | 0/64 | +| Svelte 5.56.4 | `pure_v1` | `f0@128` | 4 | 0 | 4 | 0/64 | +| Svelte 5.56.4 | `pure_v1` | `f0@192` | 4 | 0 | 4 | 0/64 | +| Svelte 5.56.4 | `pure_v1` | `f0@256` | 4 | 0 | 4 | 0/64 | +| Svelte 5.56.4 | `pure_v1` | `f0@320` | 4 | 0 | 4 | 0/64 | +| Svelte 5.56.4 | `scalar_v1` | `f1@0` | 10 | 0 | 10 | 63/64 | +| Svelte 5.56.4 | `scalar_v1` | `f207@0` | 6 | 0 | 6 | 10/13 | +| Svelte 5.56.4 | `scalar_v1` | `f0@0` | 4 | 0 | 4 | 64/64 | +| Svelte 5.56.4 | `scalar_v1` | `f0@64` | 4 | 0 | 4 | 64/64 | +| Svelte 5.56.4 | `scalar_v1` | `f0@128` | 4 | 0 | 4 | 64/64 | +| Svelte 5.56.4 | `scalar_v1` | `f0@192` | 4 | 0 | 4 | 52/64 | +| Svelte 5.56.4 | `scalar_v1` | `f0@256` | 4 | 0 | 4 | 37/64 | +| Svelte 5.56.4 | `scalar_v1` | `f0@320` | 4 | 0 | 4 | 42/64 | + +The next implementation gate is positive only when a small fixed region set +has a meaningful conservative dynamic lower bound. Otherwise region +compilation would add artifact churn without enough warm execution. diff --git a/docs/beam-compiler-region-investigation.md b/docs/beam-compiler-region-investigation.md new file mode 100644 index 000000000..ae48d9020 --- /dev/null +++ b/docs/beam-compiler-region-investigation.md @@ -0,0 +1,74 @@ +# Bounded compiler region investigation + +This investigation tests whether bounded region compilation can overcome the +whole-function admission bottleneck without weakening the compiler contract. +It does **not** enable a production compiler tier. `QuickBEAM.VM.eval/2` still +defaults to the interpreter, and `compiler_regions: false` is the compiler +default. + +## Coverage probes + +The reproducible static inventory in +[`beam-compiler-region-probe.md`](beam-compiler-region-probe.md) found +86.0–89.9% scalar-regionizable instructions across the pinned fixtures, but the +32 largest static regions covered only 2.9–6.1% of fixture bytecode. The opt-in +dynamic Space-Saving probe in +[`beam-compiler-region-hotspots.md`](beam-compiler-region-hotspots.md) estimated +fixed-pool supported lower bounds of 85.2% for Preact, 46.9% for Vue, and 77.7% +for Svelte. Those estimates count supported instructions in sampled 64-PC +windows; they are opportunity bounds, not generated-code coverage or speedup +predictions. + +## Bounded executor experiment + +The opt-in `compiler_regions: true` path preserves the existing safety bounds: + +- binary program/function/entry/profile admission and artifact identities; +- three encounters before admission; +- at most `capacity * 8`, and never more than 256, shared observation entries; +- a stable admitted set capped at half the module pool (16 regions at capacity 32), + preventing admitted regions alone from churning the pool; +- at most 256 owner-local decisions and the existing fixed 32-module pool; +- exact lease validation, soft-purge-only eviction, and before-instruction deopt; +- one straight-line entry block of at most 32 operations; +- runtime tuple updates at early boundaries, avoiding unsafe generated + `setelement` optimization; +- exact interpreter parity for result, steps, logical allocation, limits, and + cancellation. + +The executor currently enters regions only at function PC 0. It deoptimizes +before a terminal branch, unsupported instruction, suspension, or the next +uncompiled block. It does not recursively execute generated regions. + +## Measured result: reject for release + +The pinned 10-sample report is +[`beam-compiler-region-ssr-measurements.md`](beam-compiler-region-ssr-measurements.md). +Warm sequential medians were: + +| Fixture | scalar compiler without regions | bounded regions | generated step coverage | +|---|---:|---:|---:| +| Preact 10.29.7 | 9.66 ms | 17.01 ms | 2.4% | +| Vue 3.5.39 | 64.45 ms | 112.70 ms | 0.7% | +| Svelte 5.56.4 | 15.45 ms | 15.97 ms | 0.0% | + +The stable 16-region cap prevents region-driven module-pool churn, but a simple +three-encounter policy admits early regions rather than the strongest dynamic +heavy hitters. Vue entered generated code only eight times and executed 79 of +11,957 steps in generated code. Preact entered 23 small regions and +immediately deoptimized each time. Boundary, lease, frame-reconstruction, and +admission costs therefore remain larger than the generated work. + +A warm three-render `:eprof` comparison attributed 371.04 ms of CPU with regions +versus 337.28 ms without them, a 10.0% increase. Deterministic artifact and +admission serialization (`:erlang.term_to_binary/2`) rose from 13.55 ms across +144 calls to 18.22 ms across 831 calls, while synchronous `gen:do_call/4` rose +from 0.21 ms to 0.84 ms. The region profile made 639 admission calls and 1,035 +region-frame attempts. This identifies repeated admission identity work and +small-region transitions as measured CPU costs after pool churn is bounded. + +The experiment therefore fails the SSR release gate and remains explicitly +quarantined. A future design should not expand opcode coverage or admit more +regions until it can select materially longer hot traces, amortize entry/deopt +cost, and demonstrate lower Vue wall time with the same resource and scheduler +contracts. diff --git a/docs/beam-compiler-region-probe.md b/docs/beam-compiler-region-probe.md new file mode 100644 index 000000000..c8e0f2a67 --- /dev/null +++ b/docs/beam-compiler-region-probe.md @@ -0,0 +1,32 @@ +# Bounded compiler region coverage probe + +This static probe partitions each verified basic block into independently +compilable regions of at most 64 operations. Property +and strict-global reads remain isolated preflight regions, calls terminate a +region, and unsupported instructions are excluded. The fixed module-pool +estimate retains only the 32 largest regions. These figures are +an instruction-inventory bound, not dynamic execution coverage or a speedup +claim. + +- Git base: `0a332b81` +- Working tree at measurement: modified +- Generated: 2026-07-16T13:22:33Z +- Elixir: 1.20.2 +- OTP: 29 +- CPU: AMD Ryzen 9 9950X 16-Core Processor +- Maximum operations per region: 64 +- Fixed generated-module slots: 32 + +| Fixture | profile | functions | instructions | region functions | bounded regions | regionizable instructions | static region coverage | largest 32 instructions | fixed-pool static coverage | +|---|---|---:|---:|---:|---:|---:|---:|---:|---:| +| Preact 10.29.7 | `pure_v1` | 64 | 6648 | 60 | 2213 | 4332 | 65.2% | 224 | 3.4% | +| Preact 10.29.7 | `scalar_v1` | 64 | 6648 | 62 | 3188 | 5977 | 89.9% | 408 | 6.1% | +| Vue 3.5.39 | `pure_v1` | 685 | 33916 | 641 | 10104 | 21095 | 62.2% | 604 | 1.8% | +| Vue 3.5.39 | `scalar_v1` | 685 | 33916 | 670 | 15237 | 29184 | 86.0% | 998 | 2.9% | +| Svelte 5.56.4 | `pure_v1` | 209 | 12199 | 199 | 3664 | 6902 | 56.6% | 329 | 2.7% | +| Svelte 5.56.4 | `scalar_v1` | 209 | 12199 | 208 | 5882 | 10561 | 86.6% | 628 | 5.1% | + +A region tier is useful only if dynamic hot-region measurements show that a +small fixed set is executed repeatedly. Static coverage alone cannot justify +compiling every listed region or exceeding the existing module and decision +bounds. diff --git a/docs/beam-compiler-region-ssr-measurements.md b/docs/beam-compiler-region-ssr-measurements.md new file mode 100644 index 000000000..cd6d7288c --- /dev/null +++ b/docs/beam-compiler-region-ssr-measurements.md @@ -0,0 +1,85 @@ +# Bounded region compiler SSR measurements + +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. +The region experiment has no scheduler-gate claim because it fails the SSR latency gate. + +## Environment + +- Engine: compiler +- Compiler profile: scalar_v1 +- Compiler regions: true +- Git base: `0a332b81` +- Working tree at measurement: modified +- Generated: 2026-07-16T13:26:26Z +- Elixir: 1.20.2 +- OTP: 29 +- ERTS: 17.0.2 +- OS: Linux 7.0.0-27-generic +- Architecture: x86_64-pc-linux-gnu +- CPU: AMD Ryzen 9 9950X 16-Core Processor +- Logical schedulers: 32 +- Mix environment: `bench` +- Samples per fixture: 10 after 3 warmups +- Concurrency levels: 1 + +## Sequential isolated renders + +| Fixture | wall median | wall p95 | VM steps | logical memory | endpoint process memory | reductions median | +|---|---:|---:|---:|---:|---:|---:| +| Preact 10.29.7 | 17.01 ms | 24.87 ms | 3651 | 266.2 KiB | 4.5 MiB | 295627 | +| Vue 3.5.39 | 112.7 ms | 129.6 ms | 11957 | 991.9 KiB | 77.15 MiB | 1416407 | +| Svelte 5.56.4 | 15.97 ms | 17.85 ms | 1777 | 397.1 KiB | 15.61 MiB | 273454 | + +`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 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 | +|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---|---| +| Preact 10.29.7 | 73 | 50 | 0/10/16 | 86 | 2.4% | 23 | 23 | 0 | 23 | `get_length`=7, `if_false8`=7, `get_field`=4, `get_var`=2, `check_define_var`=1, `push_atom_value`=1, `to_object`=1 | `if_false8`=269, `get_loc8`=234, `push_atom_value`=234, `dup`=223, `drop`=193, `get_var`=179, `get_arg0`=161, `strict_eq`=109 | +| Vue 3.5.39 | 306 | 298 | 0/1/92 | 79 | 0.7% | 8 | 2 | 6 | 2 | `return_undef`=2 | `swap`=936, `dup`=664, `get_loc_check`=598, `if_false8`=582, `check_define_var`=480, `get_var`=468, `get_arg0`=464, `set_loc_uninitialized`=450 | +| Svelte 5.56.4 | 34 | 34 | 0/0/23 | 0 | 0.0% | 0 | 0 | 0 | 0 | | `check_define_var`=202, `fclosure8`=154, `define_func`=117, `define_var`=85, `set_loc_uninitialized`=74, `get_var`=65, `get_loc_check`=64, `put_var_init`=63 | + + +## Concurrent isolated renders + +| Fixture | concurrency | renders | throughput | per-render wall median | per-render wall p95 | +|---|---:|---:|---:|---:|---:| +| Preact 10.29.7 | 1 | 10 | 52.1 renders/s | 14.44 ms | 17.68 ms | +| Vue 3.5.39 | 1 | 10 | 4.7 renders/s | 124.4 ms | 153.57 ms | +| Svelte 5.56.4 | 1 | 10 | 33.6 renders/s | 19.09 ms | 22.0 ms | + +## 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 | +|---:|---:|---:|---:| +| 100/100 | 192.4 renders/s | -929.3 KiB | 0 | + +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 | +|---|---:|---:|---:|---:|---:| +| Preact 10.29.7 | limit:steps at 3650 | limit:memory_bytes at 133.1 KiB | limit:timeout at 200 ms | 201.19 ms | 42 µs | +| Vue 3.5.39 | limit:steps at 11956 | limit:memory_bytes at 495.9 KiB | limit:timeout at 200 ms | 223.41 ms | 25 µs | +| Svelte 5.56.4 | limit:steps at 1776 | limit:memory_bytes at 198.6 KiB | limit:timeout at 200 ms | 206.26 ms | 64 µs | + +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. diff --git a/lib/quickbeam/vm.ex b/lib/quickbeam/vm.ex index 2d834206a..4a1d04aec 100644 --- a/lib/quickbeam/vm.ex +++ b/lib/quickbeam/vm.ex @@ -96,7 +96,8 @@ defmodule QuickBEAM.VM do Supported options include the explicit `:engine` (`:interpreter` or `:compiler`), the experimental compiler `:compiler_profile` (`:pure_v1` by - default or opt-in `:scalar_v1`), `:vars`, asynchronous `:handlers`, the builtin `:profile` + default or opt-in `:scalar_v1`), the quarantined `:compiler_regions` experiment, + `:vars`, asynchronous `:handlers`, the builtin `:profile` (`:core` or `:ssr`), `:timeout`, `:max_steps`, `:max_stack_depth`, and the JavaScript allocation budget `:memory_limit`. Isolated workers also receive a BEAM process heap ceiling. `isolation: :caller` is available for trusted @@ -144,6 +145,8 @@ defmodule QuickBEAM.VM do allowed = [ :compiler_pool, :compiler_profile, + :compiler_region_probe, + :compiler_regions, :engine, :handlers, :isolation, @@ -166,6 +169,8 @@ defmodule QuickBEAM.VM do engine = Keyword.get(opts, :engine, :interpreter) compiler_pool = Keyword.get(opts, :compiler_pool, QuickBEAM.VM.Compiler.ModulePool) 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) @@ -187,6 +192,12 @@ defmodule QuickBEAM.VM do compiler_profile not in [:pure_v1, :scalar_v1] -> {:error, {:invalid_option, :compiler_profile, compiler_profile}} + not is_boolean(compiler_region_probe) -> + {:error, {:invalid_option, :compiler_region_probe, compiler_region_probe}} + + not is_boolean(compiler_regions) -> + {:error, {:invalid_option, :compiler_regions, compiler_regions}} + timeout != :infinity and (not is_integer(timeout) or timeout <= 0) -> {:error, {:invalid_option, :timeout, timeout}} @@ -221,6 +232,8 @@ defmodule QuickBEAM.VM do 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, @@ -314,6 +327,7 @@ defmodule QuickBEAM.VM do 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) } diff --git a/lib/quickbeam/vm/compiler.ex b/lib/quickbeam/vm/compiler.ex index 5099009ad..03cf8d0e2 100644 --- a/lib/quickbeam/vm/compiler.ex +++ b/lib/quickbeam/vm/compiler.ex @@ -13,7 +13,16 @@ defmodule QuickBEAM.VM.Compiler do as typed compiler errors and never invoke native QuickJS. """ - alias QuickBEAM.VM.Compiler.{Context, Contract, Counters, Deopt, GeneratedModule, ModulePool} + alias QuickBEAM.VM.Compiler.{ + Context, + Contract, + Counters, + Deopt, + GeneratedModule, + ModulePool, + RegionProbe + } + alias QuickBEAM.VM.Compiler.Lowering.PureV1 alias QuickBEAM.VM.{Evaluator, Execution, Frame, Function, Interpreter, Program} @@ -80,7 +89,9 @@ defmodule QuickBEAM.VM.Compiler do counters: if(execution.measurement_target, do: Counters.new()), pool: pool, profile: Keyword.get(opts, :compiler_profile, :pure_v1), - program: program + program: program, + region_probe: if(Keyword.get(opts, :compiler_region_probe) == true, do: RegionProbe.new()), + regions: Keyword.get(opts, :compiler_regions, false) } execution = %{execution | compiler_context: context} @@ -104,7 +115,7 @@ defmodule QuickBEAM.VM.Compiler do with :ok <- ensure_pool_available(pool) do case Map.fetch(context.decisions, function_id) do {:ok, :skip} -> - {:skip, frame, execution} + prepare_region_frame(function, frame, execution) {:ok, {:compile, key, template}} -> invoke_frame(pool, key, template, frame, execution) @@ -137,7 +148,8 @@ defmodule QuickBEAM.VM.Compiler do prepare_keyed_frame(program, function, minimum, profile, frame, execution) else execution = Counters.increment(execution, :skipped_functions) - {:skip, frame, cache_decision(execution, function.id, :skip)} + execution = cache_decision(execution, function.id, :skip) + prepare_region_frame(function, frame, execution) end end @@ -151,7 +163,8 @@ defmodule QuickBEAM.VM.Compiler do :skip -> execution = Counters.increment(execution, :skipped_functions) - {:skip, frame, cache_decision(execution, function.id, :skip)} + execution = cache_decision(execution, function.id, :skip) + prepare_region_frame(function, frame, execution) :miss -> prepare_uncached_frame(function, minimum, profile, key, frame, execution) @@ -162,31 +175,64 @@ defmodule QuickBEAM.VM.Compiler do end end - defp artifact_key(%Context{artifact_namespace: namespace}, _program, function, profile) + 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) - - defp artifact_key(_context, program, function, profile), - do: Contract.artifact_key(program, function, profile: profile) + 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 PureV1.prepare(function, minimum, profile) do {:ok, template, _count} -> - execution = Counters.increment(execution, :compiled_functions) - execution = cache_decision(execution, function.id, {:compile, key, template}) - invoke_frame(execution.compiler_context.pool, key, template, frame, execution) + prepare_template(function, profile, key, template, frame, execution) {:skip, _count} -> - with :ok <- ModulePool.remember_skip(execution.compiler_context.pool, key) do - execution = Counters.increment(execution, :skipped_functions) - {:skip, frame, cache_decision(execution, function.id, :skip)} - end + 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 PureV1.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 = Counters.increment(execution, :compiled_functions) + execution = cache_decision(execution, function.id, {:compile, key, template}) + invoke_frame(execution.compiler_context.pool, key, template, frame, execution) + end + + defp skip_uncached_frame(function, key, frame, execution) do + with :ok <- ModulePool.remember_skip(execution.compiler_context.pool, key) do + execution = Counters.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 ModulePool.checkout_cached(pool, key) do {:ok, lease} -> @@ -194,7 +240,8 @@ defmodule QuickBEAM.VM.Compiler do :skip -> execution = Counters.increment(execution, :skipped_functions) - {:skip, frame, cache_decision(execution, function.id, :skip)} + execution = cache_decision(execution, function.id, :skip) + prepare_region_frame(function, frame, execution) :miss -> prepare_frame(function, frame, execution) @@ -204,6 +251,131 @@ defmodule QuickBEAM.VM.Compiler do end end + defp prepare_region_frame( + %Function{id: function_id} = function, + %Frame{pc: 0} = frame, + %Execution{compiler_context: %Context{profile: :scalar_v1, regions: true} = context} = + execution + ) do + decision_key = {:region, function_id, 0} + execution = Counters.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, + %Execution{ + 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 ModulePool.admit_region(pool, admission_key) do + :cold -> + {:skip, frame, Counters.increment(execution, :region_cold)} + + :hot -> + execution = Counters.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, + %Execution{ + 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 ModulePool.checkout_cached(pool, key) do + {:ok, lease} -> + execution = Counters.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 PureV1.prepare_region(function, frame.pc, profile) do + {:ok, template, _count} -> + execution = Counters.increment(execution, :compiled_functions) + execution = Counters.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 <- ModulePool.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 ModulePool.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} <- ModulePool.checkout(pool, key, template), do: invoke_lease(pool, lease, frame, execution) diff --git a/lib/quickbeam/vm/compiler/context.ex b/lib/quickbeam/vm/compiler/context.ex index 22e070712..0ee069d30 100644 --- a/lib/quickbeam/vm/compiler/context.ex +++ b/lib/quickbeam/vm/compiler/context.ex @@ -6,7 +6,7 @@ defmodule QuickBEAM.VM.Compiler.Context do continuations stay in the owning `%QuickBEAM.VM.Execution{}`. """ - alias QuickBEAM.VM.Compiler.Counters + alias QuickBEAM.VM.Compiler.{Counters, RegionProbe} @enforce_keys [:pool, :program] defstruct [ @@ -17,7 +17,9 @@ defmodule QuickBEAM.VM.Compiler.Context do max_decisions: 256, min_nested_instructions: 32, profile: :pure_v1, - counters: nil + counters: nil, + region_probe: nil, + regions: false ] @type t :: %__MODULE__{ @@ -25,12 +27,14 @@ defmodule QuickBEAM.VM.Compiler.Context do program: QuickBEAM.VM.Program.t(), artifact_namespace: binary() | nil, decisions: %{ - optional(non_neg_integer()) => + optional(non_neg_integer() | {:region, non_neg_integer(), non_neg_integer()}) => :skip | {:cached, binary()} | {:compile, binary(), term()} }, max_decisions: pos_integer(), min_nested_instructions: non_neg_integer(), profile: :pure_v1 | :scalar_v1, - counters: Counters.t() | nil + counters: Counters.t() | nil, + region_probe: RegionProbe.t() | nil, + regions: boolean() } end diff --git a/lib/quickbeam/vm/compiler/contract.ex b/lib/quickbeam/vm/compiler/contract.ex index efb86151c..7d0c8c5c7 100644 --- a/lib/quickbeam/vm/compiler/contract.ex +++ b/lib/quickbeam/vm/compiler/contract.ex @@ -10,7 +10,7 @@ defmodule QuickBEAM.VM.Compiler.Contract do alias QuickBEAM.VM.{Function, Program} @contract_version 1 - @runtime_abi_version 4 + @runtime_abi_version 5 @artifact_key_bytes 32 @profiles [:pure_v1, :scalar_v1] @@ -96,15 +96,38 @@ defmodule QuickBEAM.VM.Compiler.Contract do 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) + 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()} @@ -115,8 +138,19 @@ defmodule QuickBEAM.VM.Compiler.Contract do is_list(opts) do with :ok <- validate_options(opts), profile = Keyword.get(opts, :profile, :pure_v1), - :ok <- validate_profile(profile) do - payload = {program_identity, strip_repeated_atoms(function), profile} + 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, + strip_repeated_atoms(function), + profile, + region_entry, + region_preferred + } + {:ok, digest(payload)} end end @@ -138,7 +172,7 @@ defmodule QuickBEAM.VM.Compiler.Contract do defp validate_options(opts) do if Keyword.keyword?(opts) do - case Keyword.keys(opts) -- [:profile] do + case Keyword.keys(opts) -- [:profile, :region_entry, :region_preferred] do [] -> :ok [key | _rest] -> {:error, {:unknown_option, key}} end @@ -147,6 +181,13 @@ defmodule QuickBEAM.VM.Compiler.Contract do 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/counters.ex b/lib/quickbeam/vm/compiler/counters.ex index 9c3dfa47d..2d675cee3 100644 --- a/lib/quickbeam/vm/compiler/counters.ex +++ b/lib/quickbeam/vm/compiler/counters.ex @@ -27,7 +27,11 @@ defmodule QuickBEAM.VM.Compiler.Counters do suspension_boundary_deopts: 12, guard_failed_deopts: 13, invocation_actions: 14, - reentries: 15 + reentries: 15, + region_attempts: 16, + region_cold: 17, + region_hot: 18, + region_compiled: 19 } @event_count map_size(@indexes) @opcode_slots 256 diff --git a/lib/quickbeam/vm/compiler/generated_module/import_policy.ex b/lib/quickbeam/vm/compiler/generated_module/import_policy.ex index 0292c314f..ac0fb8c8f 100644 --- a/lib/quickbeam/vm/compiler/generated_module/import_policy.ex +++ b/lib/quickbeam/vm/compiler/generated_module/import_policy.ex @@ -48,6 +48,7 @@ defmodule QuickBEAM.VM.Compiler.GeneratedModule.ImportPolicy do {Runtime, :property_get, 3}, {Runtime, :resolve_atom, 2}, {Runtime, :truthy?, 1}, + {Runtime, :tuple_put, 3}, {Runtime, :unary, 2}, {Runtime, :binary, 3} ] diff --git a/lib/quickbeam/vm/compiler/lowering/pure_v1.ex b/lib/quickbeam/vm/compiler/lowering/pure_v1.ex index a4337f041..c80ea6450 100644 --- a/lib/quickbeam/vm/compiler/lowering/pure_v1.ex +++ b/lib/quickbeam/vm/compiler/lowering/pure_v1.ex @@ -17,6 +17,7 @@ defmodule QuickBEAM.VM.Compiler.Lowering.PureV1 do @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, @@ -50,6 +51,22 @@ defmodule QuickBEAM.VM.Compiler.Lowering.PureV1 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 ScalarBlocks.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 @@ -106,7 +123,9 @@ defmodule QuickBEAM.VM.Compiler.Lowering.PureV1 do end end - defp scalar_template?(%Template{forms: forms}), + @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, 7, _}, &1)) defp lowered_operation_count(plan) do @@ -152,6 +171,30 @@ defmodule QuickBEAM.VM.Compiler.Lowering.PureV1 do 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), @@ -207,7 +250,11 @@ defmodule QuickBEAM.VM.Compiler.Lowering.PureV1 do {block.start_pc, {operations, reason}} end - defp supported_instruction?({_pc, name, _operands}, profile) do + @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 diff --git a/lib/quickbeam/vm/compiler/lowering/scalar_blocks.ex b/lib/quickbeam/vm/compiler/lowering/scalar_blocks.ex index 3d340cb6e..ad21ee0ca 100644 --- a/lib/quickbeam/vm/compiler/lowering/scalar_blocks.ex +++ b/lib/quickbeam/vm/compiler/lowering/scalar_blocks.ex @@ -29,7 +29,15 @@ defmodule QuickBEAM.VM.Compiler.Lowering.ScalarBlocks do @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 + 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 if eligible?(function, plan, levels) do {:ok, %Template{ @@ -37,7 +45,7 @@ defmodule QuickBEAM.VM.Compiler.Lowering.ScalarBlocks do {:attribute, @line, :module, Template.placeholder_module()}, {:attribute, @line, :export, [run: 3]}, run_form(), - block_form(plan, levels), + block_form(plan, levels, tuple_mode), {:eof, @line} ] }} @@ -184,11 +192,11 @@ defmodule QuickBEAM.VM.Compiler.Lowering.ScalarBlocks do function(:run, 3, [clause([lease, frame, execution], [body])]) end - defp block_form(plan, levels) do + 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) end) + |> Enum.map(fn {pc, block} -> block_clause(pc, block, levels, tuple_mode) end) fallback = clause( @@ -207,7 +215,7 @@ defmodule QuickBEAM.VM.Compiler.Lowering.ScalarBlocks do function(:block, 7, clauses ++ [fallback]) end - defp block_clause(pc, {[], reason}, levels) do + defp block_clause(pc, {[], reason}, levels, _tuple_mode) do depth = stack_depth!(levels, pc) clause( @@ -216,7 +224,7 @@ defmodule QuickBEAM.VM.Compiler.Lowering.ScalarBlocks do ) end - defp block_clause(pc, {[{family, name, _operands}] = operations, reason}, levels) + 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) @@ -227,13 +235,14 @@ defmodule QuickBEAM.VM.Compiler.Lowering.ScalarBlocks do args: variable(:Args), locals: variable(:Locals), stack: stack_values(depth), - execution: variable(:Execution) + execution: variable(:Execution), + tuple_mode: tuple_mode } clause(block_arguments(pc, depth), [lower_operations(operations, reason, state)]) end - defp block_clause(pc, {operations, reason}, levels) do + defp block_clause(pc, {operations, reason}, levels, tuple_mode) do depth = stack_depth!(levels, pc) lease = variable(:Lease) frame = variable(:Frame) @@ -250,7 +259,8 @@ defmodule QuickBEAM.VM.Compiler.Lowering.ScalarBlocks do args: args, locals: locals, stack: stack_values(depth), - execution: charged_execution + execution: charged_execution, + tuple_mode: tuple_mode } lowered = lower_operations(operations, reason, state) @@ -564,10 +574,15 @@ defmodule QuickBEAM.VM.Compiler.Lowering.ScalarBlocks do do: push_expression(state, tuple_element(state.args, index)) defp lower_local(:put_arg, [index], %{stack: [value | stack]} = state), - do: %{state | pc: state.pc + 1, args: tuple_put(state.args, index, value), stack: stack} + do: %{ + state + | pc: state.pc + 1, + args: tuple_put(state, state.args, index, value), + stack: stack + } defp lower_local(:set_arg, [index], %{stack: [value | _]} = state), - do: %{state | pc: state.pc + 1, args: tuple_put(state.args, index, value)} + do: %{state | pc: state.pc + 1, args: tuple_put(state, state.args, index, value)} defp lower_local(name, [index], state) when name in [:get_loc, :get_loc_check], do: push_expression(state, tuple_element(state.locals, index)) @@ -582,13 +597,19 @@ defmodule QuickBEAM.VM.Compiler.Lowering.ScalarBlocks do operation = if name == :inc_loc, do: :inc, else: :dec current = tuple_element(state.locals, index) value = unary_expression(operation, current, state.pc) - %{state | pc: state.pc + 1, locals: tuple_put(state.locals, index, value)} + %{state | pc: state.pc + 1, locals: tuple_put(state, state.locals, index, value)} 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) - %{state | pc: state.pc + 1, locals: tuple_put(state.locals, index, value), stack: stack} + + %{ + state + | pc: state.pc + 1, + locals: tuple_put(state, state.locals, index, value), + stack: stack + } end defp lower_local(name, [index], %{stack: [value | stack]} = state) @@ -596,15 +617,19 @@ defmodule QuickBEAM.VM.Compiler.Lowering.ScalarBlocks do do: %{ state | pc: state.pc + 1, - locals: tuple_put(state.locals, index, value), + locals: tuple_put(state, state.locals, index, value), stack: stack } defp lower_local(:set_loc, [index], %{stack: [value | _]} = state), - do: %{state | pc: state.pc + 1, locals: tuple_put(state.locals, index, value)} + do: %{state | pc: state.pc + 1, locals: tuple_put(state, state.locals, index, value)} defp lower_local(:set_loc_uninitialized, [index], state), - do: %{state | pc: state.pc + 1, locals: tuple_put(state.locals, index, atom(:uninitialized))} + do: %{ + state + | pc: state.pc + 1, + locals: tuple_put(state, state.locals, index, atom(:uninitialized)) + } defp boundary_expression(:continue, state), do: block_call(state) defp boundary_expression(reason, state), do: deopt_call(reason, integer(state.pc), state) @@ -761,9 +786,12 @@ defmodule QuickBEAM.VM.Compiler.Lowering.ScalarBlocks do defp tuple_element(tuple, index), do: remote_call(:erlang, :element, [integer(index + 1), tuple]) - defp tuple_put(tuple, index, value), + defp tuple_put(%{tuple_mode: :beam}, tuple, index, value), do: remote_call(:erlang, :setelement, [integer(index + 1), tuple, value]) + defp tuple_put(%{tuple_mode: :runtime}, tuple, index, value), + do: remote_call(Runtime, :tuple_put, [tuple, integer(index), value]) + 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} diff --git a/lib/quickbeam/vm/compiler/module_pool.ex b/lib/quickbeam/vm/compiler/module_pool.ex index 68b0d2d3a..d9fdc7a6d 100644 --- a/lib/quickbeam/vm/compiler/module_pool.ex +++ b/lib/quickbeam/vm/compiler/module_pool.ex @@ -16,6 +16,8 @@ defmodule QuickBEAM.VM.Compiler.ModulePool do @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() @@ -59,6 +61,16 @@ defmodule QuickBEAM.VM.Compiler.ModulePool do 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), @@ -113,6 +125,9 @@ defmodule QuickBEAM.VM.Compiler.ModulePool do slots: slots, key_index: %{}, skip_index: %{}, + region_admissions: %{}, + region_hot: MapSet.new(), + region_hot_capacity: max(div(capacity, 2), 1), leases: %{}, monitor_index: %{}, tasks: %{}, @@ -163,6 +178,25 @@ defmodule QuickBEAM.VM.Compiler.ModulePool do {: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} -> @@ -221,6 +255,9 @@ defmodule QuickBEAM.VM.Compiler.ModulePool do 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} @@ -602,6 +639,33 @@ defmodule QuickBEAM.VM.Compiler.ModulePool do %{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) diff --git a/lib/quickbeam/vm/compiler/region_probe.ex b/lib/quickbeam/vm/compiler/region_probe.ex new file mode 100644 index 000000000..83154a868 --- /dev/null +++ b/lib/quickbeam/vm/compiler/region_probe.ex @@ -0,0 +1,100 @@ +defmodule QuickBEAM.VM.Compiler.RegionProbe 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.{Execution, Frame} + + @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(Execution.t(), Frame.t()) :: Execution.t() + def observe( + %Execution{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(%Execution{} = execution, %Frame{}), do: execution + + @doc "Returns bounded heavy hitters and sampling metadata at evaluation completion." + @spec snapshot(Execution.t()) :: map() | nil + def snapshot(%Execution{ + 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(%Execution{}), 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 index 29c9cb448..13330dc26 100644 --- a/lib/quickbeam/vm/compiler/runtime.ex +++ b/lib/quickbeam/vm/compiler/runtime.ex @@ -256,6 +256,11 @@ defmodule QuickBEAM.VM.Compiler.Runtime do 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(), Execution.t()) :: action() def execute_local(name, operands, %Frame{} = frame, %Execution{} = execution) diff --git a/lib/quickbeam/vm/evaluator.ex b/lib/quickbeam/vm/evaluator.ex index b56bbb462..003984a0c 100644 --- a/lib/quickbeam/vm/evaluator.ex +++ b/lib/quickbeam/vm/evaluator.ex @@ -6,7 +6,7 @@ defmodule QuickBEAM.VM.Evaluator do coroutines, and waits for the final Promise without polling. """ - alias QuickBEAM.VM.Compiler.Counters + alias QuickBEAM.VM.Compiler.{Counters, RegionProbe} alias QuickBEAM.VM.{ Async, @@ -237,6 +237,7 @@ defmodule QuickBEAM.VM.Evaluator do steps: execution.step_limit - execution.remaining_steps, logical_memory_bytes: execution.memory_used, compiler_counters: Counters.snapshot(execution), + compiler_regions: RegionProbe.snapshot(execution), process_memory_bytes: process_memory, reductions: reductions }} diff --git a/lib/quickbeam/vm/interpreter.ex b/lib/quickbeam/vm/interpreter.ex index 7bcb26336..7824dcc97 100644 --- a/lib/quickbeam/vm/interpreter.ex +++ b/lib/quickbeam/vm/interpreter.ex @@ -12,13 +12,11 @@ defmodule QuickBEAM.VM.Interpreter do Async, AsyncBoundary, Builtins, - Compiler.Counters, - Compiler.Deopt, - Continuation, ConstructorBoundary, + Continuation, Coroutine, - Execution, Exceptions, + Execution, Export, Frame, Heap, @@ -30,10 +28,10 @@ defmodule QuickBEAM.VM.Interpreter do ObjectAssignBoundary, Opcodes, Program, - Properties, Promise, PromiseExecutorBoundary, PromiseReference, + Properties, Reaction, ReactionBoundary, Reference, @@ -45,6 +43,7 @@ defmodule QuickBEAM.VM.Interpreter do alias QuickBEAM.VM.Builtin.Registry alias QuickBEAM.VM.Compiler, as: VMCompiler + alias QuickBEAM.VM.Compiler.{Counters, Deopt, RegionProbe} alias QuickBEAM.VM.Opcodes.Control, as: ControlOpcodes alias QuickBEAM.VM.Opcodes.Invocation, as: InvocationOpcodes alias QuickBEAM.VM.Opcodes.Locals, as: LocalOpcodes @@ -320,6 +319,16 @@ defmodule QuickBEAM.VM.Interpreter do defp run(%Frame{} = frame, %Execution{} = execution), do: execute_current(frame, execution) + defp execute_current( + frame, + %Execution{compiler_context: %{region_probe: %RegionProbe{}}} = execution + ) do + {opcode, operands} = elem(frame.function.instructions, frame.pc) + execution = RegionProbe.observe(execution, frame) + execution = Counters.interpreted_opcode(execution, opcode) + execute_current_opcode(opcode, operands, frame, execution) + end + defp execute_current( frame, %Execution{compiler_context: %{counters: %Counters{}}} = execution diff --git a/lib/quickbeam/vm/measurement.ex b/lib/quickbeam/vm/measurement.ex index f7416273f..643435343 100644 --- a/lib/quickbeam/vm/measurement.ex +++ b/lib/quickbeam/vm/measurement.ex @@ -5,7 +5,9 @@ defmodule QuickBEAM.VM.Measurement do `steps` and `logical_memory_bytes` come from deterministic VM accounting. `compiler_counters` snapshots fixed-size owner-local OTP `:counters` when the compiler engine is selected. Its compilation/cache decision fields - are lifecycle observations. `process_memory_bytes` and `reductions` are + are lifecycle observations. `compiler_regions` contains bounded sampled + heavy hitters only when the explicit region probe is enabled. + `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. """ @@ -17,6 +19,7 @@ defmodule QuickBEAM.VM.Measurement do :steps, :logical_memory_bytes, :compiler_counters, + :compiler_regions, :process_memory_bytes, :reductions ] @@ -27,6 +30,7 @@ defmodule QuickBEAM.VM.Measurement do 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 } diff --git a/mix.exs b/mix.exs index d8c513f5a..5a019945b 100644 --- a/mix.exs +++ b/mix.exs @@ -127,6 +127,10 @@ defmodule QuickBEAM.MixProject do "docs/beam-interpreter-architecture.md", "docs/beam-compiler-contract.md", "docs/beam-compiler-performance-measurements.md", + "docs/beam-compiler-region-investigation.md", + "docs/beam-compiler-region-probe.md", + "docs/beam-compiler-region-hotspots.md", + "docs/beam-compiler-region-ssr-measurements.md", "docs/beam-compiler-scheduler-measurements.md", "docs/beam-compiler-scalar-scheduler-measurements.md", "docs/beam-compiler-ssr-measurements.md", diff --git a/test/vm/compiler_contract_test.exs b/test/vm/compiler_contract_test.exs index f85384a87..716954117 100644 --- a/test/vm/compiler_contract_test.exs +++ b/test/vm/compiler_contract_test.exs @@ -21,6 +21,18 @@ defmodule QuickBEAM.VM.CompilerContractTest do 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. @@ -63,6 +75,12 @@ defmodule QuickBEAM.VM.CompilerContractTest do 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 diff --git a/test/vm/compiler_module_pool_test.exs b/test/vm/compiler_module_pool_test.exs index d5fb27a74..0943fac5a 100644 --- a/test/vm/compiler_module_pool_test.exs +++ b/test/vm/compiler_module_pool_test.exs @@ -200,6 +200,30 @@ defmodule QuickBEAM.VM.CompilerModulePoolTest do 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 = ModulePool.admit_region(pool, key(1)) + assert :cold = ModulePool.admit_region(pool, key(1)) + assert :hot = ModulePool.admit_region(pool, key(1)) + assert :hot = ModulePool.admit_region(pool, key(1)) + + for id <- 2..300 do + assert :cold = ModulePool.admit_region(pool, key(id)) + end + + stats = ModulePool.stats(pool) + assert stats.region_admissions == 256 + assert stats.region_hot == 1 + assert stats.region_hot_capacity == 1 + assert :hot = ModulePool.admit_region(pool, key(1)) + assert :cold = ModulePool.admit_region(pool, key(2)) + assert :cold = ModulePool.admit_region(pool, key(2)) + assert :cold = ModulePool.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) diff --git a/test/vm/compiler_orchestration_test.exs b/test/vm/compiler_orchestration_test.exs index 8f5892f70..d5578386a 100644 --- a/test/vm/compiler_orchestration_test.exs +++ b/test/vm/compiler_orchestration_test.exs @@ -239,6 +239,54 @@ defmodule QuickBEAM.VM.CompilerOrchestrationTest do ) == 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} = + QuickBEAM.VM.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} = QuickBEAM.VM.eval(program, opts) + assert {:ok, 100} = QuickBEAM.VM.eval(program, opts) + + assert {:ok, interpreted} = QuickBEAM.VM.measure(program, max_steps: 10_000) + assert {:ok, admitted} = QuickBEAM.VM.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} = QuickBEAM.VM.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 ModulePool.stats(ModulePool).region_admissions == 2 + + Enum.each([1, 16, 32, 33, interpreted.steps - 1], fn step_limit -> + interpreter_result = QuickBEAM.VM.eval(program, max_steps: step_limit) + compiler_result = QuickBEAM.VM.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() assert {:ok, program} = QuickBEAM.VM.compile("(20 + 1) * 2") @@ -267,6 +315,12 @@ defmodule QuickBEAM.VM.CompilerOrchestrationTest do assert {:error, {:invalid_option, :compiler_profile, :unbounded}} = QuickBEAM.VM.eval(program, engine: :compiler, compiler_profile: :unbounded) + + assert {:error, {:invalid_option, :compiler_region_probe, :always}} = + QuickBEAM.VM.eval(program, engine: :compiler, compiler_region_probe: :always) + + assert {:error, {:invalid_option, :compiler_regions, :always}} = + QuickBEAM.VM.eval(program, engine: :compiler, compiler_regions: :always) end defp start_compiler do diff --git a/test/vm/compiler_pure_v1_test.exs b/test/vm/compiler_pure_v1_test.exs index 6004409e7..1a5a86472 100644 --- a/test/vm/compiler_pure_v1_test.exs +++ b/test/vm/compiler_pure_v1_test.exs @@ -58,6 +58,26 @@ defmodule QuickBEAM.VM.CompilerPureV1Test do assert PureV1.candidate?(loop, 10_000, :scalar_v1) 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 {:ok, whole_template, _count} = PureV1.prepare(function, 32, :scalar_v1) + refute PureV1.scalar_template?(whole_template) + + assert {:ok, region_template, 32} = PureV1.prepare_region(function, 0, :scalar_v1) + assert PureV1.scalar_template?(region_template) + + assert [{:function, _, :block, 7, clauses}] = + Enum.filter(region_template.forms, &match?({:function, _, :block, 7, _}, &1)) + + assert length(clauses) <= 17 + end + test "emits bounded pure plans with explicit unsupported boundaries" do function = arithmetic_function() diff --git a/test/vm/compiler_region_probe_test.exs b/test/vm/compiler_region_probe_test.exs new file mode 100644 index 000000000..89526f76c --- /dev/null +++ b/test/vm/compiler_region_probe_test.exs @@ -0,0 +1,48 @@ +defmodule QuickBEAM.VM.CompilerRegionProbeTest do + use ExUnit.Case, async: true + + alias QuickBEAM.VM.Compiler.{Context, RegionProbe} + alias QuickBEAM.VM.{Execution, Frame, Function, Program} + + 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 -> + RegionProbe.observe(execution, frame) + end) + end) + + snapshot = RegionProbe.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 -> RegionProbe.snapshot(execution) end)) == nil + end + + defp execution do + %Execution{ + atoms: {}, + compiler_context: %Context{ + pool: self(), + program: %Program{version: 26, fingerprint: "test", atoms: {}, root: nil}, + region_probe: RegionProbe.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 From 76b1e437f71f6967656edee57d94049f234f0751 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Thu, 16 Jul 2026 16:01:36 +0200 Subject: [PATCH 60/87] Remove redundant array allocation --- CHANGELOG.md | 3 ++- lib/quickbeam/vm/heap.ex | 32 ++++++++++++++++++++++++++++- lib/quickbeam/vm/opcodes/objects.ex | 12 +---------- test/vm/heap_test.exs | 24 ++++++++++++++++++++++ 4 files changed, 58 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b515a8613..80ca029de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,8 @@ ## Unreleased - Define the optional BEAM compiler contract with binary artifact identities, a fixed 32-module atom pool, lease/purge/cache lifecycle, versioned runtime ABI boundaries, and validated owner-local before-instruction deoptimization states. Add its supervised module pool, minimal canonical runtime ABI, structured generated-module backend, bounded v26 CFG analysis, specialized fixed-name `:pure_v1` forms, selective bounded nested-function re-entry, guarded opt-in `:scalar_v1` loop/property/global lowering, explicit call actions, exact shared program artifact namespaces, warm artifact/negative-decision caches, and release-quarantined `engine: :compiler` orchestration with native, Preact/Vue/Svelte SSR, selected Test262, resource, concurrency, measurement, and single-scheduler acceptance coverage. -- Add bounded static and dynamic compiler-region probes plus a quarantined `compiler_regions: true` executor experiment with binary admission identities, a fixed 256-entry maximum, three-encounter admission, 32-operation straight-line regions, safe early-boundary tuple updates, exact resource accounting, and reproducible SSR measurements. The experiment remains disabled after Vue reached only 4.3% generated coverage and regressed to a 112.70 ms median. +- Remove integer-index keys from redundant object insertion-order lists and bulk-build literal arrays in one heap update, preserving exact logical accounting while eliminating quadratic array-order allocation. +- Add bounded static and dynamic compiler-region probes plus a quarantined `compiler_regions: true` executor experiment with binary admission identities, a fixed 256-entry maximum, three-encounter admission, 32-operation straight-line regions, safe early-boundary tuple updates, exact resource accounting, and reproducible SSR measurements. The experiment remains disabled after Vue reached only 0.7% generated coverage and regressed to a 112.70 ms median. - Cache validated immutable builtin host templates by profile and registry generation, preserving owner-local mutation isolation and exact logical-memory charging while removing repeated installation from warm evaluations. - Add `QuickBEAM.VM.measure/2` with deterministic step/logical-memory counters, fixed owner-local OTP compiler counters, and endpoint process observations, plus reproducible pinned SSR concurrency, timeout, cancellation, reclamation, and single-scheduler reports. - Serialize native addon initialization, reuse cached exports for aliases in one runtime, and reject implicit cross-runtime or post-reset reinitialization with a typed error. Add `allow_reinitialization: true` as an explicit compatibility escape hatch for addons that support multiple environments. diff --git a/lib/quickbeam/vm/heap.ex b/lib/quickbeam/vm/heap.ex index 5cb793865..76bd0d4d6 100644 --- a/lib/quickbeam/vm/heap.ex +++ b/lib/quickbeam/vm/heap.ex @@ -36,10 +36,38 @@ defmodule QuickBEAM.VM.Heap do store_new_object(execution, object) end + @doc "Allocates a dense array in one heap update with canonical default descriptors." + @spec allocate_array(Execution.t(), [term()]) :: {Reference.t(), Execution.t()} + def allocate_array(%Execution{} = 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, %Property{value: 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 = Memory.charge_object(execution, object) execution = %{execution | heap: Map.put(execution.heap, id, object), next_object_id: id + 1} {reference, execution} end @@ -522,6 +550,8 @@ defmodule QuickBEAM.VM.Heap do %{object | properties: Map.put(object.properties, key, property)} 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, diff --git a/lib/quickbeam/vm/opcodes/objects.ex b/lib/quickbeam/vm/opcodes/objects.ex index 708d6e12b..259224a5d 100644 --- a/lib/quickbeam/vm/opcodes/objects.ex +++ b/lib/quickbeam/vm/opcodes/objects.ex @@ -256,17 +256,7 @@ defmodule QuickBEAM.VM.Opcodes.Objects do def execute(:array_from, [count], frame, execution) do {elements, stack} = Enum.split(frame.stack, count) - {reference, execution} = Heap.allocate(execution, :array) - - execution = - elements - |> Enum.reverse() - |> Enum.with_index() - |> Enum.reduce(execution, fn {value, index}, execution -> - {:ok, execution} = Properties.define(reference, index, value, execution) - execution - end) - + {reference, execution} = Heap.allocate_array(execution, Enum.reverse(elements)) push(%{frame | stack: stack}, execution, reference) end diff --git a/test/vm/heap_test.exs b/test/vm/heap_test.exs index 9ac183033..eea26ebc5 100644 --- a/test/vm/heap_test.exs +++ b/test/vm/heap_test.exs @@ -13,6 +13,29 @@ defmodule QuickBEAM.VM.HeapTest do 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 "tracks array length while preserving sparse entries" do execution = execution() {array, execution} = Heap.allocate(execution, :array) @@ -57,6 +80,7 @@ defmodule QuickBEAM.VM.HeapTest do {: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 From d6162072aa66d7b482a92970064a4767530ff0b4 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Thu, 16 Jul 2026 16:39:33 +0200 Subject: [PATCH 61/87] Compact default array descriptors --- lib/quickbeam/vm/builtins/array.ex | 23 +-- lib/quickbeam/vm/export.ex | 22 +-- lib/quickbeam/vm/heap.ex | 219 ++++++++++++++++++++++++----- lib/quickbeam/vm/interpreter.ex | 16 +-- lib/quickbeam/vm/iterator.ex | 19 +-- lib/quickbeam/vm/object.ex | 4 +- test/vm/heap_test.exs | 16 +++ 7 files changed, 217 insertions(+), 102 deletions(-) diff --git a/lib/quickbeam/vm/builtins/array.ex b/lib/quickbeam/vm/builtins/array.ex index 3940028be..8b9ff8299 100644 --- a/lib/quickbeam/vm/builtins/array.ex +++ b/lib/quickbeam/vm/builtins/array.ex @@ -5,7 +5,7 @@ defmodule QuickBEAM.VM.Builtins.Array do alias QuickBEAM.VM.Builtin alias QuickBEAM.VM.Builtin.Call - alias QuickBEAM.VM.{Heap, Object, Properties, Property, Reference, Value} + alias QuickBEAM.VM.{Heap, Object, Properties, Reference, Value} builtin "Array", kind: :constructor, @@ -182,10 +182,7 @@ defmodule QuickBEAM.VM.Builtins.Array do end) |> Enum.sort_by(&Value.to_string_value/1) - {:ok, execution} = - Heap.update_object(execution, array, fn object -> - %{object | properties: %{}, property_order: [], length: 0} - end) + {:ok, execution} = Heap.update_object(execution, array, &Heap.clear_array/1) execution = values @@ -209,20 +206,8 @@ defmodule QuickBEAM.VM.Builtins.Array do defp array_entries(%Reference{} = reference, execution) do case Heap.fetch_object(execution, reference) do - {:ok, %Object{kind: :array, length: length, properties: properties}} -> - entries = - if length == 0 do - [] - else - for index <- 0..(length - 1) do - case Map.get(properties, index) do - %Property{value: value} -> {:present, value} - nil -> :hole - end - end - end - - {:ok, entries} + {:ok, %Object{kind: :array} = object} -> + {:ok, Heap.array_entries(object)} _not_array -> {:error, :not_an_array} diff --git a/lib/quickbeam/vm/export.ex b/lib/quickbeam/vm/export.ex index 866e417be..f5aadfd79 100644 --- a/lib/quickbeam/vm/export.ex +++ b/lib/quickbeam/vm/export.ex @@ -64,17 +64,12 @@ defmodule QuickBEAM.VM.Export do defp convert_object(%Object{callable: callable}, _execution, _seen) when not is_nil(callable), do: {:error, :function_result} - defp convert_object( - %Object{kind: :array, length: length, properties: properties}, - execution, - seen - ) do + defp convert_object(%Object{kind: :array} = object, execution, seen) do values = - if length == 0 do - [] - else - for index <- 0..(length - 1), do: property_value(properties, index) - end + Enum.map(Heap.array_entries(object), fn + {:present, value} -> value + :hole -> :undefined + end) convert_list(values, execution, seen, []) end @@ -98,11 +93,4 @@ defmodule QuickBEAM.VM.Export do {:error, reason} -> {:error, reason} end end - - defp property_value(properties, key) do - case Map.get(properties, key) do - %Property{value: value} -> value - nil -> :undefined - end - end end diff --git a/lib/quickbeam/vm/heap.ex b/lib/quickbeam/vm/heap.ex index 76bd0d4d6..48e8c1a46 100644 --- a/lib/quickbeam/vm/heap.ex +++ b/lib/quickbeam/vm/heap.ex @@ -46,7 +46,7 @@ defmodule QuickBEAM.VM.Heap do properties: values |> Enum.with_index() - |> Map.new(fn {value, index} -> {index, %Property{value: value}} end) + |> Map.new(fn {value, index} -> {index, {value}} end) } empty_object = %{object | length: 0, properties: %{}} @@ -79,7 +79,9 @@ defmodule QuickBEAM.VM.Heap do {:ok, object} -> integer_keys = object.properties - |> Enum.filter(fn {key, property} -> is_integer(key) and property.enumerable end) + |> Enum.filter(fn {key, property} -> + is_integer(key) and property_enumerable?(property) + end) |> Enum.map(&elem(&1, 0)) |> Enum.sort() @@ -99,6 +101,27 @@ defmodule QuickBEAM.VM.Heap do def fetch_object(%Execution{} = 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} + %Property{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 + @spec get(Execution.t(), Reference.t(), term()) :: {:ok, term()} | {:error, term()} def get(%Execution{} = execution, %Reference{} = reference, key) do get_with_depth(execution, reference, normalize_key(key), reference, 0) @@ -127,15 +150,19 @@ defmodule QuickBEAM.VM.Heap do def put(%Execution{} = execution, %Reference{id: id} = reference, key, value) do key = normalize_key(key) - with {:ok, object} <- fetch_object(execution, reference), - {:ok, object} <- put_value(execution, object, key, value) do - execution = maybe_charge_property(execution, object, key, value) - updated = put_property(object, key, value) - {:ok, %{execution | heap: Map.put(execution.heap, id, updated)}} - else - :error -> {:error, {:invalid_reference, id}} - {:error, reason} -> {:error, reason} - {:array_length, object} -> {:ok, %{execution | heap: Map.put(execution.heap, id, object)}} + 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) + %Property{} -> put_object(execution, id, object, key, value) + end + + {:ok, object} -> + put_object(execution, id, object, key, value) + + :error -> + {:error, {:invalid_reference, id}} end end @@ -144,16 +171,24 @@ defmodule QuickBEAM.VM.Heap do def define(%Execution{} = execution, %Reference{id: id} = reference, key, value, opts \\ []) do key = normalize_key(key) - with {:ok, object} <- fetch_object(execution, reference), - {:ok, object} <- define_property(object, key, value, opts) do - execution = maybe_charge_property(execution, object, key, value) - property = property(value, opts) - object = put_property_struct(object, key, property) - {:ok, %{execution | heap: Map.put(execution.heap, id, object)}} - else - :error -> {:error, {:invalid_reference, id}} - {:error, reason} -> {:error, reason} - {:array_length, object} -> {:ok, %{execution | heap: Map.put(execution.heap, id, object)}} + 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 @@ -174,7 +209,7 @@ defmodule QuickBEAM.VM.Heap do }} {:ok, object} -> - {:ok, Map.get(object.properties, key)} + {:ok, own_property_value(object, key)} :error -> {:error, {:invalid_reference, id}} @@ -229,7 +264,7 @@ defmodule QuickBEAM.VM.Heap do key = normalize_key(key) with {:ok, object} <- fetch_object(execution, reference) do - current = Map.get(object.properties, key) + current = own_property_value(object, key) property = %Property{ kind: :accessor, @@ -326,7 +361,9 @@ defmodule QuickBEAM.VM.Heap do {:ok, object} -> integer_keys = object.properties - |> Enum.filter(fn {key, property} -> is_integer(key) and property.enumerable end) + |> Enum.filter(fn {key, property} -> + is_integer(key) and property_enumerable?(property) + end) |> Enum.map(&elem(&1, 0)) |> Enum.sort() @@ -379,6 +416,9 @@ defmodule QuickBEAM.VM.Heap do {:ok, object} -> case Map.fetch(object.properties, key) do + {:ok, {value}} -> + {:ok, value} + {:ok, %Property{getter: getter}} when not is_nil(getter) -> {:ok, {:accessor, getter, receiver}} @@ -400,6 +440,88 @@ defmodule QuickBEAM.VM.Heap do end end + 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} -> + execution = maybe_charge_property(execution, object, key, value) + property = property(value, opts) + 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?(%Property{setter: setter} when not is_nil(setter), inherited) -> + {:error, {:invoke_setter, inherited.setter}} + + accessor?(inherited) or match?(%Property{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, @@ -416,7 +538,7 @@ defmodule QuickBEAM.VM.Heap do defp put_value(execution, object, key, _value) do descriptor = - Map.get(object.properties, key) || inherited_property(execution, object.prototype, key, 0) + own_property_value(object, key) || inherited_property(execution, object.prototype, key, 0) cond do match?(%Property{setter: setter} when not is_nil(setter), descriptor) -> @@ -465,13 +587,13 @@ defmodule QuickBEAM.VM.Heap do do: validate_definition(object, key, property(value, opts)) defp validate_definition(object, key, candidate) do - case Map.get(object.properties, key) do + case own_property_value(object, key) do %Property{configurable: false} = current -> cond do candidate.configurable -> {:error, {:property_not_configurable, key}} - candidate.enumerable != current.enumerable -> + property_enumerable?(candidate) != property_enumerable?(current) -> {:error, {:property_not_configurable, key}} accessor?(candidate) != accessor?(current) -> @@ -525,6 +647,7 @@ defmodule QuickBEAM.VM.Heap do defp put_property(object, key, value) do property = case Map.get(object.properties, key) do + {_old_value} -> %Property{value: value} %Property{} = property -> %{property | value: value} nil -> %Property{value: value} end @@ -534,15 +657,13 @@ defmodule QuickBEAM.VM.Heap do defp put_property_struct(%Object{kind: :array} = object, key, property) when is_integer(key) and key >= 0 do - object - |> remember_property(key) - |> then(fn object -> - %{ - object - | properties: Map.put(object.properties, key, property), - length: max(object.length, key + 1) - } - end) + stored = if default_data_property?(property), do: {property.value}, else: property + + %{ + object + | properties: Map.put(object.properties, key, stored), + length: max(object.length, key + 1) + } end defp put_property_struct(object, key, property) do @@ -567,7 +688,7 @@ defmodule QuickBEAM.VM.Heap do defp inherited_property(execution, %Reference{} = prototype, key, depth) do case fetch_object(execution, prototype) do {:ok, object} -> - Map.get(object.properties, key) || + own_property_value(object, key) || inherited_property(execution, object.prototype, key, depth + 1) :error -> @@ -575,6 +696,30 @@ defmodule QuickBEAM.VM.Heap do end end + defp default_data_property?(%Property{ + 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{kind: :array} = object, key) when is_integer(key) do + case Map.get(object.properties, key) do + {value} -> %Property{value: value} + property -> property + end + end + + defp own_property_value(object, key), do: Map.get(object.properties, key) + + defp property_enumerable?({_value}), do: true + defp property_enumerable?(%Property{enumerable: enumerable}), do: enumerable + defp current_accessor(%Property{} = property, field), do: Map.fetch!(property, field) defp current_accessor(nil, _field), do: nil diff --git a/lib/quickbeam/vm/interpreter.ex b/lib/quickbeam/vm/interpreter.ex index 7824dcc97..f82738c8d 100644 --- a/lib/quickbeam/vm/interpreter.ex +++ b/lib/quickbeam/vm/interpreter.ex @@ -751,20 +751,8 @@ defmodule QuickBEAM.VM.Interpreter do defp interpreter_array_values(%Reference{} = reference, execution) do case Heap.fetch_object(execution, reference) do - {:ok, %QuickBEAM.VM.Object{kind: :array, length: length, properties: properties}} -> - values = - if length == 0 do - [] - else - for index <- 0..(length - 1) do - case Map.get(properties, index) do - %QuickBEAM.VM.Property{value: value} -> {:present, value} - nil -> :hole - end - end - end - - {:ok, values} + {:ok, %QuickBEAM.VM.Object{kind: :array} = object} -> + {:ok, Heap.array_entries(object)} _ -> {:error, :not_an_array} diff --git a/lib/quickbeam/vm/iterator.ex b/lib/quickbeam/vm/iterator.ex index a6fcdf1e2..f2c4aee67 100644 --- a/lib/quickbeam/vm/iterator.ex +++ b/lib/quickbeam/vm/iterator.ex @@ -16,7 +16,6 @@ defmodule QuickBEAM.VM.Iterator do IteratorBoundary, Object, Promise, - Property, Reference, Symbol, Value @@ -31,13 +30,12 @@ defmodule QuickBEAM.VM.Iterator do def values(%Reference{} = reference, execution) do case Heap.fetch_object(execution, reference) do - {:ok, %Object{kind: :array, length: length, properties: properties}} -> + {:ok, %Object{kind: :array} = object} -> values = - if length == 0 do - [] - else - for index <- 0..(length - 1), do: property_value(properties, index) - end + Enum.map(Heap.array_entries(object), fn + {:present, value} -> value + :hole -> :undefined + end) {:ok, values} @@ -258,11 +256,4 @@ defmodule QuickBEAM.VM.Iterator do defp object?(%Reference{}), do: true defp object?(value) when is_map(value) and not is_struct(value), do: true defp object?(_value), do: false - - defp property_value(properties, index) do - case Map.get(properties, index) do - %Property{value: value} -> value - nil -> :undefined - end - end end diff --git a/lib/quickbeam/vm/object.ex b/lib/quickbeam/vm/object.ex index 84338cadd..291197160 100644 --- a/lib/quickbeam/vm/object.ex +++ b/lib/quickbeam/vm/object.ex @@ -15,7 +15,9 @@ defmodule QuickBEAM.VM.Object do @type t :: %__MODULE__{ kind: kind(), prototype: QuickBEAM.VM.Reference.t() | nil, - properties: %{optional(term()) => QuickBEAM.VM.Property.t()}, + properties: %{ + optional(term()) => QuickBEAM.VM.Property.t() | {term()} + }, property_order: [term()], extensible: boolean(), length: non_neg_integer(), diff --git a/test/vm/heap_test.exs b/test/vm/heap_test.exs index eea26ebc5..fa0ffa441 100644 --- a/test/vm/heap_test.exs +++ b/test/vm/heap_test.exs @@ -36,6 +36,22 @@ defmodule QuickBEAM.VM.HeapTest do assert {:ok, %{property_order: []}} = Heap.fetch_object(bulk_execution, bulk) end + test "stores default array descriptors compactly and retains exceptional descriptors" do + {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, object} = Heap.fetch_object(execution, array) + assert object.properties[0] == {:undefined} + assert %QuickBEAM.VM.Property{value: "fixed", writable: false} = object.properties[1] + + assert {:ok, %QuickBEAM.VM.Property{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) From 0ed8c357481ac40d3d963318cba248fe8fe59910 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Thu, 16 Jul 2026 16:50:15 +0200 Subject: [PATCH 62/87] Document BEAM object allocation findings --- CHANGELOG.md | 2 +- bench/README.md | 6 + bench/vm_object_memory.exs | 163 ++++++++++++++++++++++ docs/beam-interpreter-architecture.md | 7 +- docs/beam-object-memory-investigation.md | 167 +++++++++++++++++++++++ docs/beam-object-memory-measurements.md | 27 ++++ lib/quickbeam/vm/iterator.ex | 9 +- mix.exs | 4 + 8 files changed, 379 insertions(+), 6 deletions(-) create mode 100644 bench/vm_object_memory.exs create mode 100644 docs/beam-object-memory-investigation.md create mode 100644 docs/beam-object-memory-measurements.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 80ca029de..b83e18fd6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ ## Unreleased - Define the optional BEAM compiler contract with binary artifact identities, a fixed 32-module atom pool, lease/purge/cache lifecycle, versioned runtime ABI boundaries, and validated owner-local before-instruction deoptimization states. Add its supervised module pool, minimal canonical runtime ABI, structured generated-module backend, bounded v26 CFG analysis, specialized fixed-name `:pure_v1` forms, selective bounded nested-function re-entry, guarded opt-in `:scalar_v1` loop/property/global lowering, explicit call actions, exact shared program artifact namespaces, warm artifact/negative-decision caches, and release-quarantined `engine: :compiler` orchestration with native, Preact/Vue/Svelte SSR, selected Test262, resource, concurrency, measurement, and single-scheduler acceptance coverage. -- Remove integer-index keys from redundant object insertion-order lists and bulk-build literal arrays in one heap update, preserving exact logical accounting while eliminating quadratic array-order allocation. +- Remove integer-index keys from redundant object insertion-order lists, bulk-build literal arrays in one heap update, and encode default array descriptors compactly in the existing property map. Exact logical accounting is preserved while a retained 20,000-element array uses about 62% less endpoint process memory. - Add bounded static and dynamic compiler-region probes plus a quarantined `compiler_regions: true` executor experiment with binary admission identities, a fixed 256-entry maximum, three-encounter admission, 32-operation straight-line regions, safe early-boundary tuple updates, exact resource accounting, and reproducible SSR measurements. The experiment remains disabled after Vue reached only 0.7% generated coverage and regressed to a 112.70 ms median. - Cache validated immutable builtin host templates by profile and registry generation, preserving owner-local mutation isolation and exact logical-memory charging while removing repeated installation from warm evaluations. - Add `QuickBEAM.VM.measure/2` with deterministic step/logical-memory counters, fixed owner-local OTP compiler counters, and endpoint process observations, plus reproducible pinned SSR concurrency, timeout, cancellation, reclamation, and single-scheduler reports. diff --git a/bench/README.md b/bench/README.md index 1b5f480d8..21cb5a09d 100644 --- a/bench/README.md +++ b/bench/README.md @@ -24,6 +24,10 @@ MIX_ENV=bench mix run bench/concurrent.exs MIX_ENV=bench mix run bench/vm_ssr.exs \ --output docs/beam-ssr-measurements.md +# Reproduce the retained array/object-memory measurement +MIX_ENV=bench mix run bench/vm_object_memory.exs \ + --output docs/beam-object-memory-measurements.md + # Reproduce runtime-initialization, cold-compilation, and warm loop measurements COMPILER_PERF_ITERATIONS=2000 MIX_ENV=bench \ mix run bench/vm_compiler_perf.exs @@ -86,6 +90,8 @@ endpoint BEAM process observations, sequential latency, concurrent throughput, and timeout/cancellation behavior for the pinned Preact, Vue, and Svelte fixtures. Published results are in [`docs/beam-ssr-measurements.md`](../docs/beam-ssr-measurements.md), +[`docs/beam-object-memory-investigation.md`](../docs/beam-object-memory-investigation.md), +[`docs/beam-object-memory-measurements.md`](../docs/beam-object-memory-measurements.md), [`docs/beam-compiler-performance-measurements.md`](../docs/beam-compiler-performance-measurements.md), [`docs/beam-compiler-ssr-measurements.md`](../docs/beam-compiler-ssr-measurements.md), [`docs/beam-compiler-scalar-ssr-measurements.md`](../docs/beam-compiler-scalar-ssr-measurements.md), diff --git a/bench/vm_object_memory.exs b/bench/vm_object_memory.exs new file mode 100644 index 000000000..dc3b33ff7 --- /dev/null +++ b/bench/vm_object_memory.exs @@ -0,0 +1,163 @@ +defmodule QuickBEAM.Bench.VMObjectMemory do + @moduledoc """ + Measures the isolated VM's array-heavy allocation path. + """ + + @default_count 20_000 + @default_samples 30 + + def run(args) do + {opts, positional, invalid} = + OptionParser.parse(args, strict: [count: :integer, samples: :integer, output: :string]) + + if positional != [] or invalid != [], + do: raise(ArgumentError, "invalid arguments: #{inspect(positional ++ invalid)}") + + count = positive!(Keyword.get(opts, :count, @default_count), :count) + samples = positive!(Keyword.get(opts, :samples, @default_samples), :samples) + program = compile_workload!(count) + + Enum.each(1..3, fn _iteration -> measure!(program, count) end) + measurements = Enum.map(1..samples, fn _iteration -> measure!(program, count) end) + report = report(count, samples, measurements) + IO.write(report) + + if output = opts[:output] do + File.mkdir_p!(Path.dirname(output)) + File.write!(output, report) + end + end + + defp compile_workload!(count) do + source = """ + let values = []; + for (let index = 0; index < #{count}; index++) values.push(index); + globalThis.__quickbeamRetainedArray = values; + values.length; + """ + + {:ok, program} = QuickBEAM.VM.compile(source, filename: "vm_object_memory.js") + program + end + + defp measure!(program, count) do + {:ok, measurement} = + QuickBEAM.VM.measure(program, + max_steps: count * 20 + 10_000, + memory_limit: 256_000_000, + timeout: 5_000 + ) + + if measurement.result != {:ok, count}, + do: raise("array allocation measurement failed: #{inspect(measurement.result)}") + + measurement + end + + defp report(count, samples, measurements) do + metadata = metadata() + wall = Enum.map(measurements, & &1.wall_time_us) + reductions = Enum.map(measurements, & &1.reductions) + memory = Enum.map(measurements, & &1.process_memory_bytes) + first = hd(measurements) + + """ + # BEAM VM object-memory measurements + + This fixture retains one JavaScript array populated by sequential + `Array.prototype.push` calls. It measures the canonical interpreter path, + including isolated-process startup and result conversion. Endpoint process + memory is not a sampled peak or operating-system RSS value. + + ## Environment + + - Git base: `#{metadata.git}` + - Working tree at measurement: #{metadata.tree_state} + - Generated: #{metadata.generated} + - Elixir: #{metadata.elixir} + - OTP: #{metadata.otp} + - ERTS: #{metadata.erts} + - Architecture: #{metadata.architecture} + - CPU: #{metadata.cpu} + - Array entries: #{count} + - Samples after 3 warmups: #{samples} + + | wall median | wall p95 | reductions median | endpoint process memory | VM steps | logical memory | + |---:|---:|---:|---:|---:|---:| + | #{format_us(median(wall))} | #{format_us(percentile(wall, 0.95))} | #{median(reductions)} | #{format_bytes(median(memory))} | #{first.steps} | #{format_bytes(first.logical_memory_bytes)} | + + VM steps and logical memory are deterministic. The benchmark intentionally + retains the array until measurement so the endpoint observation includes + its live representation. + """ + end + + defp median(values), do: percentile(values, 0.5) + + defp percentile(values, fraction) do + values = Enum.sort(values) + index = (fraction * (length(values) - 1)) |> Float.ceil() |> trunc() + Enum.at(values, index) + end + + defp format_us(value) when value >= 1_000, + do: "#{Float.round(value / 1_000, 2)} ms" + + defp format_us(value), do: "#{value} µs" + + defp format_bytes(value) when value >= 1024 * 1024, + do: "#{Float.round(value / (1024 * 1024), 2)} MiB" + + defp format_bytes(value) when value >= 1024, + do: "#{Float.round(value / 1024, 1)} KiB" + + defp format_bytes(value), do: "#{value} B" + + 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 metadata do + %{ + git: command("git", ["rev-parse", "--short", "HEAD"]), + tree_state: + if(command("git", ["status", "--porcelain"]) == "", do: "clean", else: "modified"), + generated: DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601(), + elixir: System.version(), + otp: System.otp_release(), + erts: to_string(:erlang.system_info(:version)), + architecture: to_string(:erlang.system_info(:system_architecture)), + cpu: cpu_model() + } + end + + defp command(executable, arguments) do + case System.cmd(executable, arguments, stderr_to_stdout: true) do + {output, 0} -> String.trim(output) + _error -> "unknown" + end + end + + defp cpu_model do + case File.read("/proc/cpuinfo") do + {:ok, cpuinfo} -> + cpuinfo + |> String.split("\n") + |> Enum.find_value("unknown", &cpu_model_from_line/1) + + {:error, _reason} -> + command("sysctl", ["-n", "machdep.cpu.brand_string"]) + end + end + + defp cpu_model_from_line(line) do + case String.split(line, ":", parts: 2) do + ["model name", model] -> String.trim(model) + [key, model] -> if(String.trim(key) == "model name", do: String.trim(model)) + _line -> nil + end + end +end + +QuickBEAM.Bench.VMObjectMemory.run(System.argv()) diff --git a/docs/beam-interpreter-architecture.md b/docs/beam-interpreter-architecture.md index cf784ad5a..f2d29bd82 100644 --- a/docs/beam-interpreter-architecture.md +++ b/docs/beam-interpreter-architecture.md @@ -466,7 +466,12 @@ ECMAScript own-key ordering, prototype-cycle rejection, constructor return rules, and `instanceof`. Native Array callback frames retain explicit holes: `map` preserves their indices, while `filter`, `forEach`, `some`, and `reduce` skip them; reduction without an initial value starts at the first present -entry. Getter, setter, and `Object.assign` calls use resumable +entry. Default array data descriptors are encoded compactly as one-tuples in the +existing property map; exceptional descriptors retain full `Property` structs. +Integer indices never enter the separate insertion-order list. This preserves +one OTP map lookup path and avoids descriptor and quadratic ordering allocation; +see the [object-memory investigation](beam-object-memory-investigation.md). +Getter, setter, and `Object.assign` calls use resumable boundaries so arbitrary JavaScript and exceptions do not escape the explicit machine state. Promise resolution reads accessor-backed `then` properties synchronously and queues invocation of returned then functions as microtasks. diff --git a/docs/beam-object-memory-investigation.md b/docs/beam-object-memory-investigation.md new file mode 100644 index 000000000..8fc8457eb --- /dev/null +++ b/docs/beam-object-memory-investigation.md @@ -0,0 +1,167 @@ +# BEAM object-memory investigation + +This investigation studies QuickBEAM's JavaScript object heap against Erlang/OTP +29 and current OTP `master`. It is an implementation-specific optimization +record, not a general JavaScript performance claim. + +## What OTP actually optimizes + +BeamAsm performs load-time instruction specialization but deliberately does very +little cross-instruction optimization. The Erlang compiler's SSA, type, alias, +and liveness passes therefore determine which native fast paths are reachable. +The relevant sources are: + +- [`BeamAsm.md`](https://github.com/erlang/otp/blob/master/erts/emulator/internal_doc/BeamAsm.md) +- [`jit/x86/instr_map.cpp`](https://github.com/erlang/otp/blob/master/erts/emulator/beam/jit/x86/instr_map.cpp) +- [`jit/x86/instr_common.cpp`](https://github.com/erlang/otp/blob/master/erts/emulator/beam/jit/x86/instr_common.cpp) +- [`beam_common.c`](https://github.com/erlang/otp/blob/master/erts/emulator/beam/beam_common.c) +- [`erl_map.c`](https://github.com/erlang/otp/blob/master/erts/emulator/beam/erl_map.c) + +Small map literals with literal keys are excellent construction targets. The x86 +JIT emits their headers and values directly at `HTOP`, shares the literal key +tuple, and can move adjacent values with SIMD. Tuple construction is similarly +direct. In contrast, associative and exact map updates generally enter shared C +runtime fragments. Flatmaps contain at most 32 entries; inserting a key copies +keys and values, while exact updates can retain the old key tuple but still copy +the value vector. Larger maps are persistent HAMTs. + +OTP 26 and later can combine fixed-size tuple updates into `update_record`. +Recent compiler alias analysis can mark a dead, unique tuple `inplace`, allowing +the JIT to overwrite it when doing so cannot create an old-to-young pointer. +This is useful for fixed internal state authored as Erlang records. It does not +make dynamic JavaScript property dictionaries mutable. OTP 29 native records are +experimental and require atom field names, so they are not suitable for +user-derived JavaScript keys. + +Inspecting assembly is therefore useful, but only in a measured loop: + +1. inspect BEAM shape with `erlc -S` or `:beam_disasm.file/1`; +2. inspect the matching BeamAsm emitter and runtime helper; +3. use `+JPperf true`, `perf`, reductions, GC observations, and `:eprof` on the + real workload. + +`erts_debug:disassemble/1` returns `false` on BeamAsm because that disassembler +is compiled only for the non-JIT emulator. + +### QuickBEAM's emitted BEAM + +`:beam_disasm.file/1` confirms the shape of the compact array write rather than +assuming source-level syntax is cheap. `store_default_array_index/6` emits a +`test_heap 2` plus `put_tuple2` for `{value}`, then calls `maps:put/3` for the +element dictionary. The `%Object{}` update is one `put_map_exact` carrying both +`:properties` and `:length`; the outer heap still requires another +`maps:put/3`, followed by a final exact `%Execution{}` map update. The new-entry +branch calls `Memory.charge_property/3`; replacement skips it before allocating +the compact tuple. + +The full descriptor path constructs a `%Property{}` flatmap before performing +the same persistent dictionary, object, outer-heap, and execution updates. The +assembly therefore predicts exactly the measured result: compact descriptors +remove substantial per-element retained allocation, but they do not remove the +persistent update cascade. Hidden classes and a different owner-local heap +layout are required to address that remaining CPU cost. + +## Allocation pathology found + +The VM stored every array index twice: + +- as an integer key in `Object.properties`; and +- in `Object.property_order` using `property_order ++ [key]`. + +ECMAScript integer keys are already enumerated from the property dictionary and +sorted numerically. The insertion-order list is consulted only for non-integer +keys. Sequential array growth was therefore performing a useless quadratic list +append and retaining every index twice. + +The first fix removes integer keys from `property_order` and bulk-constructs +literal arrays in one heap update. Exact VM steps and logical memory accounting +are unchanged. + +A second representation improvement encodes an array's overwhelmingly common +data descriptor directly in the existing property map as the one-tuple +`{value}`. Accessors and data properties with non-default descriptor flags remain +full `%QuickBEAM.VM.Property{}` values. This keeps one OTP map, one lookup path, +and canonical descriptor behavior while avoiding a full descriptor struct for +every ordinary element. No input-derived atoms, ETS table, process dictionary, +or native mutable state is introduced. + +## Measurements + +A synthetic retained workload creating 5,000 outer-array entries and 10,000 +ordinary objects showed why the first fix matters: + +- wall observation: 110.35 ms to 84.22 ms; +- reductions: 8.22 million to 7.83 million; +- retained insertion-order cells: 40,352 to 35,352. + +The dedicated 20,000-element isolated array fixture compares the pre-compaction +layout with the compact default descriptor: + +| layout | wall median | reductions median | endpoint process memory | steps | logical memory | +|---|---:|---:|---:|---:|---:| +| full descriptor per element | 74.87 ms | 9,803,575 | 7.28 MiB | 280,022 | 1.95 MiB | +| compact default descriptor | 72.92 ms | 9,655,746 | 2.78 MiB | 280,022 | 1.95 MiB | + +The compact representation reduces endpoint process memory by about 62%, +reductions by 1.5%, and observed wall time by 2.6% on this fixture. The current +reproducible report is +[`beam-object-memory-measurements.md`](beam-object-memory-measurements.md). + +On the pinned Vue fixture, a five-render `:eprof` comparison attributed +466.69 ms of CPU before compact descriptors and 458.06 ms after them. SSR +endpoint heap-size classes did not change, so the improvement is intentionally +claimed for retained array-heavy workloads rather than as a framework-wide +memory reduction. + +## Rejected representations + +OTP's `array` module is a mature persistent 16-way tuple tree and is excellent +when its API amortizes surrounding work. In a standalone 20,000-entry build it +used 22,705 words and took about 0.60 ms, versus 293,811 words and 4.98 ms for a +map of full descriptors. However, placing `:array` behind every canonical +JavaScript property operation added Erlang call, wrapper, and tree-update costs. +Pinned SSR reductions rose materially and endpoint heap classes increased. The +representation was rejected. + +A separate raw element map plus exceptional descriptor map also reduced retained +terms but required two lookup/update paths and enlarged array objects. It +regressed SSR reductions and was rejected. Encoding compact values in the +existing map retained the memory benefit without a second persistent structure. + +Private ETS was not selected. ETS would copy inserted and fetched terms, lose +literal/subterm sharing, move memory outside the evaluation process's +`max_heap_size`, and require separate accounting and cleanup. It remains a +candidate only if a future measured overlay beats the persistent one-map design. + +## Next deep optimization: shapes + +Ordinary objects still allocate a descriptor struct, a property-map update, an +object-struct update, and an outer heap-map update for each new field. The next +high-leverage design is a bounded owner-local hidden-class fast path: + +- integer shape IDs, never dynamic atoms; +- one shape record containing ordered keys, default descriptors, and key-to-slot + indices; +- one compact values tuple per object; +- cached `{shape, key, flags}` transitions; +- dictionary fallback for deletes, accessors, non-default descriptors, and + pathological dynamic keys; +- bulk object-literal construction into its final shape; +- optional owner-local string-key interning to immediate integer IDs, keeping + non-negative integers reserved for ECMAScript array indices. + +This design should be prototyped against exact result/step/memory differentials +before changing the canonical heap. Fixed metadata helpers may be authored as +Erlang records to expose OTP's `update_record` optimization, but native records +remain too experimental for the runtime contract. + +## Process heap sizing + +OTP's efficiency guide explicitly recommends a larger `min_heap_size` for +short-lived compute processes. QuickBEAM's isolated evaluation owner is a good +match because process termination bulk-reclaims the heap. A controlled Vue probe +found a best inner-evaluation median near a 1 MiB initial heap, but repeated +system-level SSR runs were too noisy and small fixtures retained roughly an +extra MiB heap class. No default was changed. A future adaptive policy must be a +separate, paired benchmark with concurrency, scheduler, peak-memory, binary +retention, and low-memory-limit gates. diff --git a/docs/beam-object-memory-measurements.md b/docs/beam-object-memory-measurements.md new file mode 100644 index 000000000..9d251ee6e --- /dev/null +++ b/docs/beam-object-memory-measurements.md @@ -0,0 +1,27 @@ +# BEAM VM object-memory measurements + +This fixture retains one JavaScript array populated by sequential +`Array.prototype.push` calls. It measures the canonical interpreter path, +including isolated-process startup and result conversion. Endpoint process +memory is not a sampled peak or operating-system RSS value. + +## Environment + +- Git base: `d6162072` +- Working tree at measurement: modified +- Generated: 2026-07-16T14:40:42Z +- Elixir: 1.20.2 +- OTP: 29 +- ERTS: 17.0.2 +- Architecture: x86_64-pc-linux-gnu +- CPU: AMD Ryzen 9 9950X 16-Core Processor +- Array entries: 20000 +- Samples after 3 warmups: 30 + +| wall median | wall p95 | reductions median | endpoint process memory | VM steps | logical memory | +|---:|---:|---:|---:|---:|---:| +| 72.92 ms | 80.51 ms | 9655746 | 2.78 MiB | 280022 | 1.95 MiB | + +VM steps and logical memory are deterministic. The benchmark intentionally +retains the array until measurement so the endpoint observation includes +its live representation. diff --git a/lib/quickbeam/vm/iterator.ex b/lib/quickbeam/vm/iterator.ex index f2c4aee67..a8ba6f196 100644 --- a/lib/quickbeam/vm/iterator.ex +++ b/lib/quickbeam/vm/iterator.ex @@ -16,6 +16,7 @@ defmodule QuickBEAM.VM.Iterator do IteratorBoundary, Object, Promise, + Properties, Reference, Symbol, Value @@ -137,7 +138,7 @@ defmodule QuickBEAM.VM.Iterator do do: reject(boundary, reason, execution) defp read_iterator_method(boundary, execution) do - case QuickBEAM.VM.Properties.get(boundary.iterable, Symbol.iterator(), execution) do + case Properties.get(boundary.iterable, Symbol.iterator(), execution) do {:ok, {:accessor, getter, receiver}} -> dispatch_getter(boundary, :iterator_getter, getter, receiver, execution) @@ -158,7 +159,7 @@ defmodule QuickBEAM.VM.Iterator do end defp read_next(boundary, execution) do - case QuickBEAM.VM.Properties.get(boundary.iterator, "next", execution) do + case Properties.get(boundary.iterator, "next", execution) do {:ok, {:accessor, getter, receiver}} -> dispatch_getter(boundary, :next_getter, getter, receiver, execution) @@ -182,7 +183,7 @@ defmodule QuickBEAM.VM.Iterator do do: dispatch(boundary, :next_call, boundary.next, [], boundary.iterator, execution) defp read_done(boundary, execution) do - case QuickBEAM.VM.Properties.get(boundary.result, "done", execution) do + case Properties.get(boundary.result, "done", execution) do {:ok, {:accessor, getter, receiver}} -> dispatch_getter(boundary, :done_getter, getter, receiver, execution) @@ -221,7 +222,7 @@ defmodule QuickBEAM.VM.Iterator do end defp read_value(boundary, execution) do - case QuickBEAM.VM.Properties.get(boundary.result, "value", execution) do + case Properties.get(boundary.result, "value", execution) do {:ok, {:accessor, getter, receiver}} -> dispatch_getter(boundary, :value_getter, getter, receiver, execution) diff --git a/mix.exs b/mix.exs index 5a019945b..9223e0e4b 100644 --- a/mix.exs +++ b/mix.exs @@ -125,6 +125,8 @@ defmodule QuickBEAM.MixProject do "docs/javascript-api.md", "docs/architecture.md", "docs/beam-interpreter-architecture.md", + "docs/beam-object-memory-investigation.md", + "docs/beam-object-memory-measurements.md", "docs/beam-compiler-contract.md", "docs/beam-compiler-performance-measurements.md", "docs/beam-compiler-region-investigation.md", @@ -148,6 +150,8 @@ defmodule QuickBEAM.MixProject do ] end + defp skip_doc_warning?(reference) when not is_binary(reference), do: false + defp skip_doc_warning?(reference) do internal_vm_modules = [ "QuickBEAM.VM.Async", From dff7429767c2006044b2e5accac2380a6567414d Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Thu, 16 Jul 2026 17:05:57 +0200 Subject: [PATCH 63/87] Compact ordinary object descriptors --- lib/quickbeam/vm/export.ex | 8 +++-- lib/quickbeam/vm/heap.ex | 65 +++++++++++++++++++------------------- lib/quickbeam/vm/object.ex | 24 +++++++++++--- test/vm/heap_test.exs | 22 +++++++------ 4 files changed, 71 insertions(+), 48 deletions(-) diff --git a/lib/quickbeam/vm/export.ex b/lib/quickbeam/vm/export.ex index f5aadfd79..c084596a3 100644 --- a/lib/quickbeam/vm/export.ex +++ b/lib/quickbeam/vm/export.ex @@ -76,8 +76,12 @@ defmodule QuickBEAM.VM.Export do defp convert_object(%Object{properties: properties}, execution, seen) do properties - |> Enum.filter(fn {key, property} -> property.enumerable and not is_struct(key, Symbol) end) - |> Enum.reduce_while({:ok, %{}}, fn {key, %Property{value: value}}, {:ok, result} -> + |> 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} -> + %Property{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}} diff --git a/lib/quickbeam/vm/heap.ex b/lib/quickbeam/vm/heap.ex index 48e8c1a46..4ccfaebb8 100644 --- a/lib/quickbeam/vm/heap.ex +++ b/lib/quickbeam/vm/heap.ex @@ -87,7 +87,7 @@ defmodule QuickBEAM.VM.Heap do other_keys = Enum.filter(object.property_order, fn key -> - not is_integer(key) and match?(%Property{enumerable: true}, object.properties[key]) + not is_integer(key) and property_enumerable?(object.properties[key]) end) {:ok, integer_keys ++ other_keys} @@ -209,7 +209,7 @@ defmodule QuickBEAM.VM.Heap do }} {:ok, object} -> - {:ok, own_property_value(object, key)} + {:ok, object |> own_property_value(key) |> Object.property_descriptor()} :error -> {:error, {:invalid_reference, id}} @@ -369,7 +369,7 @@ defmodule QuickBEAM.VM.Heap do string_keys = Enum.filter(object.property_order, fn key -> - is_binary(key) and match?(%Property{enumerable: true}, object.properties[key]) + is_binary(key) and property_enumerable?(object.properties[key]) end) {:ok, integer_keys ++ string_keys} @@ -457,9 +457,8 @@ defmodule QuickBEAM.VM.Heap do defp define_object(execution, id, object, key, value, opts) do case define_property(object, key, value, opts) do - {:ok, object} -> + {:ok, object, property} -> execution = maybe_charge_property(execution, object, key, value) - property = property(value, opts) object = put_property_struct(object, key, property) {:ok, %{execution | heap: Map.put(execution.heap, id, object)}} @@ -583,8 +582,14 @@ defmodule QuickBEAM.VM.Heap do end end - defp define_property(object, key, value, opts), - do: validate_definition(object, key, property(value, opts)) + 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 @@ -645,30 +650,29 @@ defmodule QuickBEAM.VM.Heap do end defp put_property(object, key, value) do - property = - case Map.get(object.properties, key) do - {_old_value} -> %Property{value: value} - %Property{} = property -> %{property | value: value} - nil -> %Property{value: value} - end - - put_property_struct(object, key, property) + case Map.get(object.properties, key) do + {_old_value} -> put_default_property(object, key, value) + %Property{} = property -> put_property_struct(object, key, %{property | value: value}) + nil -> put_default_property(object, key, value) + end end - defp put_property_struct(%Object{kind: :array} = object, key, property) - when is_integer(key) and key >= 0 do - stored = if default_data_property?(property), do: {property.value}, else: property - - %{ - object - | properties: Map.put(object.properties, key, stored), - length: max(object.length, key + 1) - } + 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_property_struct(object, key, property) do + defp put_default_property(object, key, value) do object = remember_property(object, key) - %{object | properties: Map.put(object.properties, key, property)} + 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 @@ -708,22 +712,17 @@ defmodule QuickBEAM.VM.Heap do defp default_data_property?(_property), do: false - defp own_property_value(%Object{kind: :array} = object, key) when is_integer(key) do - case Map.get(object.properties, key) do - {value} -> %Property{value: value} - property -> property - end - end - defp own_property_value(object, key), do: Map.get(object.properties, key) defp property_enumerable?({_value}), do: true defp property_enumerable?(%Property{enumerable: enumerable}), do: enumerable defp current_accessor(%Property{} = property, field), do: Map.fetch!(property, field) + defp current_accessor({_value}, _field), do: nil defp current_accessor(nil, _field), do: nil defp current_flag(%Property{} = 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 diff --git a/lib/quickbeam/vm/object.ex b/lib/quickbeam/vm/object.ex index 291197160..70b98ddf0 100644 --- a/lib/quickbeam/vm/object.ex +++ b/lib/quickbeam/vm/object.ex @@ -1,5 +1,11 @@ defmodule QuickBEAM.VM.Object do - @moduledoc "Defines an object stored in an evaluation-owned VM heap." + @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.Property` structs. + Callers must use the descriptor helpers instead of depending on either layout. + """ defstruct kind: :ordinary, prototype: nil, @@ -12,12 +18,11 @@ defmodule QuickBEAM.VM.Object do internal: nil @type kind :: :ordinary | :array | :function | :map | :promise | :regexp | :set + @type stored_property :: QuickBEAM.VM.Property.t() | {term()} @type t :: %__MODULE__{ kind: kind(), prototype: QuickBEAM.VM.Reference.t() | nil, - properties: %{ - optional(term()) => QuickBEAM.VM.Property.t() | {term()} - }, + properties: %{optional(term()) => stored_property()}, property_order: [term()], extensible: boolean(), length: non_neg_integer(), @@ -25,4 +30,15 @@ defmodule QuickBEAM.VM.Object do callable: term() | nil, internal: term() } + + @doc "Expands a compact default data property into its canonical descriptor." + @spec property_descriptor(stored_property() | nil) :: QuickBEAM.VM.Property.t() | nil + def property_descriptor({value}), do: %QuickBEAM.VM.Property{value: value} + def property_descriptor(%QuickBEAM.VM.Property{} = 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.Property{enumerable: enumerable}), do: enumerable end diff --git a/test/vm/heap_test.exs b/test/vm/heap_test.exs index fa0ffa441..7a6f19c38 100644 --- a/test/vm/heap_test.exs +++ b/test/vm/heap_test.exs @@ -1,7 +1,7 @@ defmodule QuickBEAM.VM.HeapTest do use ExUnit.Case, async: true - alias QuickBEAM.VM.{Execution, Export, Heap} + alias QuickBEAM.VM.{Execution, Export, Heap, Property} test "resolves inherited properties through the prototype chain" do execution = execution() @@ -36,18 +36,22 @@ defmodule QuickBEAM.VM.HeapTest do assert {:ok, %{property_order: []}} = Heap.fetch_object(bulk_execution, bulk) end - test "stores default array descriptors compactly and retains exceptional descriptors" do - {array, execution} = Heap.allocate(execution(), :array) + 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, object} = Heap.fetch_object(execution, array) - assert object.properties[0] == {:undefined} - assert %QuickBEAM.VM.Property{value: "fixed", writable: false} = object.properties[1] - - assert {:ok, %QuickBEAM.VM.Property{value: :undefined}} = - Heap.own_property(execution, array, 0) + assert {:ok, ordinary_object} = Heap.fetch_object(execution, ordinary) + assert ordinary_object.properties["answer"] == {42} + assert {:ok, %Property{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 %Property{value: "fixed", writable: false} = array_object.properties[1] + assert {:ok, %Property{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 From 8e5ada3d591618bfa50f6a4d4d1eed4833ad6689 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Thu, 16 Jul 2026 17:12:52 +0200 Subject: [PATCH 64/87] Measure compact object allocation --- CHANGELOG.md | 2 +- bench/vm_object_memory.exs | 100 +++++++++++++++++------ docs/beam-interpreter-architecture.md | 4 +- docs/beam-object-memory-investigation.md | 77 +++++++++++------ docs/beam-object-memory-measurements.md | 26 +++--- 5 files changed, 145 insertions(+), 64 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b83e18fd6..9921c1190 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ ## Unreleased - Define the optional BEAM compiler contract with binary artifact identities, a fixed 32-module atom pool, lease/purge/cache lifecycle, versioned runtime ABI boundaries, and validated owner-local before-instruction deoptimization states. Add its supervised module pool, minimal canonical runtime ABI, structured generated-module backend, bounded v26 CFG analysis, specialized fixed-name `:pure_v1` forms, selective bounded nested-function re-entry, guarded opt-in `:scalar_v1` loop/property/global lowering, explicit call actions, exact shared program artifact namespaces, warm artifact/negative-decision caches, and release-quarantined `engine: :compiler` orchestration with native, Preact/Vue/Svelte SSR, selected Test262, resource, concurrency, measurement, and single-scheduler acceptance coverage. -- Remove integer-index keys from redundant object insertion-order lists, bulk-build literal arrays in one heap update, and encode default array descriptors compactly in the existing property map. Exact logical accounting is preserved while a retained 20,000-element array uses about 62% less endpoint process memory. +- Remove integer-index keys from redundant object insertion-order lists, bulk-build literal arrays in one heap update, and encode default data descriptors compactly in the existing property map. Exact logical accounting is preserved; paired retained fixtures reduce array endpoint process memory by about 62% and ordinary-object retained VM heap size by 31%. - Add bounded static and dynamic compiler-region probes plus a quarantined `compiler_regions: true` executor experiment with binary admission identities, a fixed 256-entry maximum, three-encounter admission, 32-operation straight-line regions, safe early-boundary tuple updates, exact resource accounting, and reproducible SSR measurements. The experiment remains disabled after Vue reached only 0.7% generated coverage and regressed to a 112.70 ms median. - Cache validated immutable builtin host templates by profile and registry generation, preserving owner-local mutation isolation and exact logical-memory charging while removing repeated installation from warm evaluations. - Add `QuickBEAM.VM.measure/2` with deterministic step/logical-memory counters, fixed owner-local OTP compiler counters, and endpoint process observations, plus reproducible pinned SSR concurrency, timeout, cancellation, reclamation, and single-scheduler reports. diff --git a/bench/vm_object_memory.exs b/bench/vm_object_memory.exs index dc3b33ff7..df380e1a5 100644 --- a/bench/vm_object_memory.exs +++ b/bench/vm_object_memory.exs @@ -3,23 +3,45 @@ defmodule QuickBEAM.Bench.VMObjectMemory do Measures the isolated VM's array-heavy allocation path. """ + alias QuickBEAM.VM.{Execution, Interpreter} + @default_count 20_000 + @default_object_count 5_000 @default_samples 30 def run(args) do {opts, positional, invalid} = - OptionParser.parse(args, strict: [count: :integer, samples: :integer, output: :string]) + OptionParser.parse(args, + strict: [count: :integer, object_count: :integer, samples: :integer, output: :string] + ) if positional != [] or invalid != [], do: raise(ArgumentError, "invalid arguments: #{inspect(positional ++ invalid)}") count = positive!(Keyword.get(opts, :count, @default_count), :count) + + object_count = + positive!(Keyword.get(opts, :object_count, @default_object_count), :object_count) + samples = positive!(Keyword.get(opts, :samples, @default_samples), :samples) - program = compile_workload!(count) - Enum.each(1..3, fn _iteration -> measure!(program, count) end) - measurements = Enum.map(1..samples, fn _iteration -> measure!(program, count) end) - report = report(count, samples, measurements) + fixtures = [ + {"sequential array", count, 20, compile_array_workload!(count)}, + {"ordinary objects", object_count, 80, compile_object_workload!(object_count)} + ] + + measurements = + Enum.map(fixtures, fn {name, entries, step_factor, program} -> + Enum.each(1..3, fn _iteration -> measure!(program, entries, step_factor) end) + + values = + Enum.map(1..samples, fn _iteration -> measure!(program, entries, step_factor) end) + + retained_heap_bytes = retained_heap_bytes!(program, entries, step_factor) + {name, entries, retained_heap_bytes, values} + end) + + report = report(samples, measurements) IO.write(report) if output = opts[:output] do @@ -28,7 +50,7 @@ defmodule QuickBEAM.Bench.VMObjectMemory do end end - defp compile_workload!(count) do + defp compile_array_workload!(count) do source = """ let values = []; for (let index = 0; index < #{count}; index++) values.push(index); @@ -36,14 +58,28 @@ defmodule QuickBEAM.Bench.VMObjectMemory do values.length; """ + {:ok, program} = QuickBEAM.VM.compile(source, filename: "vm_array_memory.js") + program + end + + defp compile_object_workload!(count) do + source = """ + let values = []; + for (let index = 0; index < #{count}; index++) { + values.push({id: index, label: "item-" + index, active: (index & 1) === 0}); + } + globalThis.__quickbeamRetainedObjects = values; + values.length; + """ + {:ok, program} = QuickBEAM.VM.compile(source, filename: "vm_object_memory.js") program end - defp measure!(program, count) do + defp measure!(program, count, step_factor) do {:ok, measurement} = QuickBEAM.VM.measure(program, - max_steps: count * 20 + 10_000, + max_steps: count * step_factor + 10_000, memory_limit: 256_000_000, timeout: 5_000 ) @@ -54,18 +90,35 @@ defmodule QuickBEAM.Bench.VMObjectMemory do measurement end - defp report(count, samples, measurements) do + defp retained_heap_bytes!(program, count, step_factor) do + {:ok, ^count, %Execution{} = execution} = + Interpreter.start(program, + max_steps: count * step_factor + 10_000, + memory_limit: 256_000_000 + ) + + :erts_debug.size(execution.heap) * :erlang.system_info(:wordsize) + end + + defp report(samples, fixture_measurements) do metadata = metadata() - wall = Enum.map(measurements, & &1.wall_time_us) - reductions = Enum.map(measurements, & &1.reductions) - memory = Enum.map(measurements, & &1.process_memory_bytes) - first = hd(measurements) + + rows = + Enum.map_join(fixture_measurements, "\n", fn + {name, entries, retained_heap_bytes, measurements} -> + wall = Enum.map(measurements, & &1.wall_time_us) + reductions = Enum.map(measurements, & &1.reductions) + memory = Enum.map(measurements, & &1.process_memory_bytes) + first = hd(measurements) + + "| #{name} | #{entries} | #{format_us(median(wall))} | #{format_us(percentile(wall, 0.95))} | #{median(reductions)} | #{format_bytes(median(memory))} | #{format_bytes(retained_heap_bytes)} | #{first.steps} | #{format_bytes(first.logical_memory_bytes)} |" + end) """ # BEAM VM object-memory measurements - This fixture retains one JavaScript array populated by sequential - `Array.prototype.push` calls. It measures the canonical interpreter path, + These fixtures retain a sequential JavaScript array and an array of ordinary + three-property objects. They measure the canonical interpreter path, including isolated-process startup and result conversion. Endpoint process memory is not a sampled peak or operating-system RSS value. @@ -79,16 +132,17 @@ defmodule QuickBEAM.Bench.VMObjectMemory do - ERTS: #{metadata.erts} - Architecture: #{metadata.architecture} - CPU: #{metadata.cpu} - - Array entries: #{count} - - Samples after 3 warmups: #{samples} + - Samples per fixture after 3 warmups: #{samples} - | wall median | wall p95 | reductions median | endpoint process memory | VM steps | logical memory | - |---:|---:|---:|---:|---:|---:| - | #{format_us(median(wall))} | #{format_us(percentile(wall, 0.95))} | #{median(reductions)} | #{format_bytes(median(memory))} | #{first.steps} | #{format_bytes(first.logical_memory_bytes)} | + | workload | entries | wall median | wall p95 | reductions median | endpoint process memory | retained VM heap | VM steps | logical memory | + |---|---:|---:|---:|---:|---:|---:|---:|---:| + #{rows} - VM steps and logical memory are deterministic. The benchmark intentionally - retains the array until measurement so the endpoint observation includes - its live representation. + VM steps and logical memory are deterministic. Retained VM heap uses + `:erts_debug.size/1` after direct canonical interpretation and includes + shared subterms once; it is diagnostic rather than a supported OTP API. + Endpoint process memory is observational and reflects allocated heap classes, + not only live terms. """ end diff --git a/docs/beam-interpreter-architecture.md b/docs/beam-interpreter-architecture.md index f2d29bd82..664b77fc6 100644 --- a/docs/beam-interpreter-architecture.md +++ b/docs/beam-interpreter-architecture.md @@ -466,8 +466,8 @@ ECMAScript own-key ordering, prototype-cycle rejection, constructor return rules, and `instanceof`. Native Array callback frames retain explicit holes: `map` preserves their indices, while `filter`, `forEach`, `some`, and `reduce` skip them; reduction without an initial value starts at the first present -entry. Default array data descriptors are encoded compactly as one-tuples in the -existing property map; exceptional descriptors retain full `Property` structs. +entry. Default data descriptors are encoded compactly as one-tuples in the existing +property map; accessors and non-default descriptors retain full `Property` structs. Integer indices never enter the separate insertion-order list. This preserves one OTP map lookup path and avoids descriptor and quadratic ordering allocation; see the [object-memory investigation](beam-object-memory-investigation.md). diff --git a/docs/beam-object-memory-investigation.md b/docs/beam-object-memory-investigation.md index 8fc8457eb..24cb4a0c5 100644 --- a/docs/beam-object-memory-investigation.md +++ b/docs/beam-object-memory-investigation.md @@ -45,14 +45,15 @@ is compiled only for the non-JIT emulator. ### QuickBEAM's emitted BEAM -`:beam_disasm.file/1` confirms the shape of the compact array write rather than -assuming source-level syntax is cheap. `store_default_array_index/6` emits a -`test_heap 2` plus `put_tuple2` for `{value}`, then calls `maps:put/3` for the -element dictionary. The `%Object{}` update is one `put_map_exact` carrying both -`:properties` and `:length`; the outer heap still requires another -`maps:put/3`, followed by a final exact `%Execution{}` map update. The new-entry -branch calls `Memory.charge_property/3`; replacement skips it before allocating -the compact tuple. +`:beam_disasm.file/1` confirms the shape of compact writes rather than assuming +source-level syntax is cheap. `store_default_array_index/6` and the common +`put_default_property/3` path emit a `test_heap 2` plus `put_tuple2` for +`{value}`, then call `maps:put/3` for the property dictionary. The array +`%Object{}` update is one `put_map_exact` carrying both `:properties` and +`:length`; the ordinary-object path updates `:properties`. The outer heap still +requires another `maps:put/3`, followed by a final exact `%Execution{}` map +update. A new property calls `Memory.charge_property/3`; replacement skips that +charge before allocating the compact tuple. The full descriptor path constructs a `%Property{}` flatmap before performing the same persistent dictionary, object, outer-heap, and execution updates. The @@ -77,13 +78,15 @@ The first fix removes integer keys from `property_order` and bulk-constructs literal arrays in one heap update. Exact VM steps and logical memory accounting are unchanged. -A second representation improvement encodes an array's overwhelmingly common -data descriptor directly in the existing property map as the one-tuple -`{value}`. Accessors and data properties with non-default descriptor flags remain -full `%QuickBEAM.VM.Property{}` values. This keeps one OTP map, one lookup path, -and canonical descriptor behavior while avoiding a full descriptor struct for -every ordinary element. No input-derived atoms, ETS table, process dictionary, -or native mutable state is introduced. +A second representation improvement encodes every default data descriptor +directly in the existing property map as the one-tuple `{value}`. Accessors and +data properties with non-default descriptor flags remain full +`%QuickBEAM.VM.Property{}` values. Reflection expands the compact form only at +the canonical descriptor boundary. Common writes avoid constructing a transient +full descriptor, and property definition reuses its validated candidate rather +than building it twice. This keeps one OTP map, one lookup path, and canonical +descriptor behavior. No input-derived atoms, ETS table, process dictionary, or +native mutable state is introduced. ## Measurements @@ -102,16 +105,27 @@ layout with the compact default descriptor: | full descriptor per element | 74.87 ms | 9,803,575 | 7.28 MiB | 280,022 | 1.95 MiB | | compact default descriptor | 72.92 ms | 9,655,746 | 2.78 MiB | 280,022 | 1.95 MiB | -The compact representation reduces endpoint process memory by about 62%, -reductions by 1.5%, and observed wall time by 2.6% on this fixture. The current -reproducible report is +The compact array representation reduces endpoint process memory by about 62%, +reductions by 1.5%, and observed wall time by 2.6% in that paired run. + +A second paired fixture retains 5,000 ordinary three-property objects: + +| layout | wall median | reductions median | endpoint process memory | retained VM heap | steps | logical memory | +|---|---:|---:|---:|---:|---:|---:| +| full ordinary descriptors | 45.50 ms | 5,011,812 | 9.13 MiB | 3.30 MiB | 130,022 | 5.14 MiB | +| compact ordinary descriptors | 44.97 ms | 4,904,137 | 7.28 MiB | 2.27 MiB | 130,022 | 5.14 MiB | + +This lowers the shared retained VM heap by 31%, reductions by 2.1%, and observed +wall time by 1.2%. Endpoint process memory is less stable because it reports +allocated BEAM heap classes rather than only live terms. The reproducible report +therefore includes both endpoint memory and diagnostic `:erts_debug.size/1` +retained bytes: [`beam-object-memory-measurements.md`](beam-object-memory-measurements.md). -On the pinned Vue fixture, a five-render `:eprof` comparison attributed -466.69 ms of CPU before compact descriptors and 458.06 ms after them. SSR -endpoint heap-size classes did not change, so the improvement is intentionally -claimed for retained array-heavy workloads rather than as a framework-wide -memory reduction. +Three paired five-render Vue `:eprof` repetitions had median totals of 466.44 ms +before ordinary-object compaction and 462.88 ms after it. The improvement is +small but, together with lower deterministic reductions, rules out hiding a CPU +regression behind the retained-memory win. ## Rejected representations @@ -128,6 +142,15 @@ terms but required two lookup/update paths and enlarged array objects. It regressed SSR reductions and was rejected. Encoding compact values in the existing map retained the memory benefit without a second persistent structure. +The evaluation's outer object heap has dense, monotonic integer IDs, so OTP +`:array` was also tested there rather than as a JavaScript property store. The +full VM and pinned Test262 gates passed. However, the 5,000-object fixture reduced +retained heap size only from 2.32 MiB to 2.22 MiB while increasing reductions by +8.1%; the 20,000-element fixture had negligible retained improvement and 4.4% +more reductions. Three paired Vue `:eprof` repetitions had median CPU totals of +567.10 ms for the persistent map and 607.12 ms for `:array`, a 7.1% regression. +The outer persistent map remains the better fit. + Private ETS was not selected. ETS would copy inserted and fetched terms, lose literal/subterm sharing, move memory outside the evaluation process's `max_heap_size`, and require separate accounting and cleanup. It remains a @@ -135,9 +158,11 @@ candidate only if a future measured overlay beats the persistent one-map design. ## Next deep optimization: shapes -Ordinary objects still allocate a descriptor struct, a property-map update, an -object-struct update, and an outer heap-map update for each new field. The next -high-leverage design is a bounded owner-local hidden-class fast path: +Ordinary objects no longer retain a descriptor struct for default fields, but +each new field still performs a property-map update, an object-struct update, +and an outer heap-map update. Non-default definitions also construct descriptor +maps. The next high-leverage design is a bounded owner-local hidden-class fast +path: - integer shape IDs, never dynamic atoms; - one shape record containing ordered keys, default descriptors, and key-to-slot diff --git a/docs/beam-object-memory-measurements.md b/docs/beam-object-memory-measurements.md index 9d251ee6e..e07c12d11 100644 --- a/docs/beam-object-memory-measurements.md +++ b/docs/beam-object-memory-measurements.md @@ -1,27 +1,29 @@ # BEAM VM object-memory measurements -This fixture retains one JavaScript array populated by sequential -`Array.prototype.push` calls. It measures the canonical interpreter path, +These fixtures retain a sequential JavaScript array and an array of ordinary +three-property objects. They measure the canonical interpreter path, including isolated-process startup and result conversion. Endpoint process memory is not a sampled peak or operating-system RSS value. ## Environment -- Git base: `d6162072` +- Git base: `dff74297` - Working tree at measurement: modified -- Generated: 2026-07-16T14:40:42Z +- Generated: 2026-07-16T15:09:18Z - Elixir: 1.20.2 - OTP: 29 - ERTS: 17.0.2 - Architecture: x86_64-pc-linux-gnu - CPU: AMD Ryzen 9 9950X 16-Core Processor -- Array entries: 20000 -- Samples after 3 warmups: 30 +- Samples per fixture after 3 warmups: 30 -| wall median | wall p95 | reductions median | endpoint process memory | VM steps | logical memory | -|---:|---:|---:|---:|---:|---:| -| 72.92 ms | 80.51 ms | 9655746 | 2.78 MiB | 280022 | 1.95 MiB | +| workload | entries | wall median | wall p95 | reductions median | endpoint process memory | retained VM heap | VM steps | logical memory | +|---|---:|---:|---:|---:|---:|---:|---:|---:| +| sequential array | 20000 | 70.45 ms | 73.14 ms | 9627442 | 4.5 MiB | 1017.3 KiB | 280022 | 1.95 MiB | +| ordinary objects | 5000 | 44.32 ms | 45.27 ms | 4907714 | 9.13 MiB | 2.32 MiB | 130022 | 5.14 MiB | -VM steps and logical memory are deterministic. The benchmark intentionally -retains the array until measurement so the endpoint observation includes -its live representation. +VM steps and logical memory are deterministic. Retained VM heap uses +`:erts_debug.size/1` after direct canonical interpretation and includes +shared subterms once; it is diagnostic rather than a supported OTP API. +Endpoint process memory is observational and reflects allocated heap classes, +not only live terms. From 4689d577eb28b724efbd4bb6d27777f458584be0 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Thu, 16 Jul 2026 18:32:04 +0200 Subject: [PATCH 65/87] Measure bounded object shapes --- CHANGELOG.md | 1 + bench/README.md | 1 + docs/beam-object-memory-investigation.md | 37 +++--- docs/beam-object-shape-investigation.md | 144 +++++++++++++++++++++++ mix.exs | 1 + 5 files changed, 164 insertions(+), 20 deletions(-) create mode 100644 docs/beam-object-shape-investigation.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 9921c1190..ba7fd245e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - Define the optional BEAM compiler contract with binary artifact identities, a fixed 32-module atom pool, lease/purge/cache lifecycle, versioned runtime ABI boundaries, and validated owner-local before-instruction deoptimization states. Add its supervised module pool, minimal canonical runtime ABI, structured generated-module backend, bounded v26 CFG analysis, specialized fixed-name `:pure_v1` forms, selective bounded nested-function re-entry, guarded opt-in `:scalar_v1` loop/property/global lowering, explicit call actions, exact shared program artifact namespaces, warm artifact/negative-decision caches, and release-quarantined `engine: :compiler` orchestration with native, Preact/Vue/Svelte SSR, selected Test262, resource, concurrency, measurement, and single-scheduler acceptance coverage. - Remove integer-index keys from redundant object insertion-order lists, bulk-build literal arrays in one heap update, and encode default data descriptors compactly in the existing property map. Exact logical accounting is preserved; paired retained fixtures reduce array endpoint process memory by about 62% and ordinary-object retained VM heap size by 31%. +- Measure and reject a bounded owner-local hidden-class prototype. Although ideal repeated-shape objects retained 13.4% less VM heap, only 82 of 1,130 final Vue objects were wholly eligible; Vue regressed from 47.07 ms to 50.30 ms with higher reductions and no endpoint memory improvement. - Add bounded static and dynamic compiler-region probes plus a quarantined `compiler_regions: true` executor experiment with binary admission identities, a fixed 256-entry maximum, three-encounter admission, 32-operation straight-line regions, safe early-boundary tuple updates, exact resource accounting, and reproducible SSR measurements. The experiment remains disabled after Vue reached only 0.7% generated coverage and regressed to a 112.70 ms median. - Cache validated immutable builtin host templates by profile and registry generation, preserving owner-local mutation isolation and exact logical-memory charging while removing repeated installation from warm evaluations. - Add `QuickBEAM.VM.measure/2` with deterministic step/logical-memory counters, fixed owner-local OTP compiler counters, and endpoint process observations, plus reproducible pinned SSR concurrency, timeout, cancellation, reclamation, and single-scheduler reports. diff --git a/bench/README.md b/bench/README.md index 21cb5a09d..2d3e2c123 100644 --- a/bench/README.md +++ b/bench/README.md @@ -92,6 +92,7 @@ fixtures. Published results are in [`docs/beam-ssr-measurements.md`](../docs/beam-ssr-measurements.md), [`docs/beam-object-memory-investigation.md`](../docs/beam-object-memory-investigation.md), [`docs/beam-object-memory-measurements.md`](../docs/beam-object-memory-measurements.md), +[`docs/beam-object-shape-investigation.md`](../docs/beam-object-shape-investigation.md), [`docs/beam-compiler-performance-measurements.md`](../docs/beam-compiler-performance-measurements.md), [`docs/beam-compiler-ssr-measurements.md`](../docs/beam-compiler-ssr-measurements.md), [`docs/beam-compiler-scalar-ssr-measurements.md`](../docs/beam-compiler-scalar-ssr-measurements.md), diff --git a/docs/beam-object-memory-investigation.md b/docs/beam-object-memory-investigation.md index 24cb4a0c5..6ba3b0f82 100644 --- a/docs/beam-object-memory-investigation.md +++ b/docs/beam-object-memory-investigation.md @@ -156,29 +156,26 @@ literal/subterm sharing, move memory outside the evaluation process's `max_heap_size`, and require separate accounting and cleanup. It remains a candidate only if a future measured overlay beats the persistent one-map design. -## Next deep optimization: shapes +## Bounded shapes were tested Ordinary objects no longer retain a descriptor struct for default fields, but each new field still performs a property-map update, an object-struct update, -and an outer heap-map update. Non-default definitions also construct descriptor -maps. The next high-leverage design is a bounded owner-local hidden-class fast -path: - -- integer shape IDs, never dynamic atoms; -- one shape record containing ordered keys, default descriptors, and key-to-slot - indices; -- one compact values tuple per object; -- cached `{shape, key, flags}` transitions; -- dictionary fallback for deletes, accessors, non-default descriptors, and - pathological dynamic keys; -- bulk object-literal construction into its final shape; -- optional owner-local string-key interning to immediate integer IDs, keeping - non-negative integers reserved for ECMAScript array indices. - -This design should be prototyped against exact result/step/memory differentials -before changing the canonical heap. Fixed metadata helpers may be authored as -Erlang records to expose OTP's `update_record` optimization, but native records -remain too experimental for the runtime contract. +and an outer heap-map update. A bounded owner-local hidden-class prototype tested +shared immutable shapes, compact values tuples, a 256-transition cap, a 32-field +cap, and canonical dictionary fallback. + +The ideal 5,000-object fixture reduced retained VM heap by 13.4%, but increased +reductions by 8.1%. Vue had only 82 wholly eligible objects among 1,130 total; +its 50-sample median regressed from 47.07 ms to 50.30 ms with no endpoint memory +change. The runtime shape representation was rejected. See the +[bounded object-shape investigation](beam-object-shape-investigation.md). + +If shapes are revisited, they should be selected from decode-time object-literal +semantics or repeated complete key sequences rather than speculative first-use +transitions. Bulk construction must prove that the object cannot escape while it +is incomplete. Fixed metadata helpers may use Erlang records where OTP's +`update_record` optimization is measurable, but OTP 29 native records remain too +experimental for the runtime contract. ## Process heap sizing diff --git a/docs/beam-object-shape-investigation.md b/docs/beam-object-shape-investigation.md new file mode 100644 index 000000000..45e8503f7 --- /dev/null +++ b/docs/beam-object-shape-investigation.md @@ -0,0 +1,144 @@ +# Bounded object-shape investigation + +This report evaluates owner-local hidden classes after compact default property +descriptors had already removed most descriptor allocation. The prototype is +preserved on branch `hidden-object-shapes` at commit `fc609df9` and is rejected +for the release path. + +## Opportunity probe + +A temporary completed-evaluation probe grouped final Vue 3.5.39 objects by +ordered default binary keys. It did not mutate shared state and was removed after +measurement. + +- total heap objects: 1,130; +- ordinary objects: 361; +- objects containing only shape-eligible default binary properties: 82; +- distinct eligible final shapes: 40; +- empty eligible objects: 29; +- most frequent non-empty shape: five 27-field Vue VNodes; +- most other eligible shapes occurred once to four times. + +Only 7.3% of all objects and 22.7% of ordinary objects were wholly eligible. +Framework bootstrap objects commonly mix accessors, symbols, non-default flags, +or one-off wide dictionaries. Compact descriptors had therefore already taken +the low-risk portion of the memory win, while a pure slot representation could +cover only a small final-heap subset. + +## Prototype contract + +The prototype deliberately preserved the VM's ownership and boundedness rules: + +- immutable shapes and values tuples owned by one evaluation; +- binary keys and integer shape identities, never input-derived atoms; +- at most 256 transitions and 32 fields per shape; +- transition metadata stored with existing owner-local prototype metadata so the + hot `%Execution{}` struct did not gain fields; +- host templates built with shapes disabled, then evaluation-local transitions + enabled after copy-on-write installation; +- ordinary objects with `internal: nil` only; +- full dictionary fallback for accessors, non-default descriptors, deletion, + non-binary keys, capacity exhaustion, and unsupported layouts; +- canonical `Heap`, `Properties`, descriptor, enumeration, export, and logical + memory behavior; +- no ETS, process dictionary, native mutable state, or cross-evaluation shape + sharing. + +`QuickBEAM.VM.ObjectStorage` isolated dictionary and slot layouts. A shaped +object stored `{:slots, shape, values}` in the existing `properties` field, so +all objects did not pay for extra struct fields. Cached transitions reused +immutable key tuples and key-to-slot maps. Guarded lookups returned compact +`{value}` descriptors and exceptional writes materialized a canonical map before +performing their effect. + +## Correctness + +The experimental branch passed: + +- 244 VM tests with three explicit skips; +- all six pinned Test262 interpreter/compiler gates; +- exact step and logical-memory assertions; +- property ordering, accessors, deletion, reflection, export, SSR parity, + concurrency, and compiler-profile tests; +- focused shape sharing, exceptional fallback, and 256-transition capacity + tests. + +Correctness and boundedness were not the reason for rejection. + +## Measurements + +All comparisons used the same OTP 29 / ERTS 17.0.2 environment and the existing +reproducible benchmark scripts. + +### Retained object fixture + +The 5,000-object fixture creates three default fields per object. + +| representation | wall median | reductions median | retained VM heap | endpoint process memory | +|---|---:|---:|---:|---:| +| compact property maps | 44.59 ms | 4,908,322 | 2.32 MiB | 9.13 MiB | +| bounded shapes and slots | 42.68 ms | 5,307,243 | 2.01 MiB | 7.28 MiB | + +Slots reduced retained VM heap by 13.4% and the wall observation by 4.3%, but +increased deterministic reductions by 8.1%. This synthetic workload gives every +object exactly the same ideal shape and therefore represents an upper-bound use +case rather than SSR behavior. + +### Pinned SSR + +A 50-sample sequential comparison produced: + +| fixture | compact maps | bounded shapes | change | reduction change | endpoint memory change | +|---|---:|---:|---:|---:|---:| +| Preact 10.29.7 | 8.37 ms | 8.39 ms | +0.2% | +5.1% | none | +| Vue 3.5.39 | 47.07 ms | 50.30 ms | +6.9% | +4.4% | none | +| Svelte 5.56.4 | 12.30 ms | 12.25 ms | -0.4% | +5.4% | none | + +Three additional paired 30-sample Vue runs had compact-map medians of 46.36, +46.68, and 52.39 ms. Shape medians were 50.62, 84.17, and 51.75 ms. Discarding +neither outliers nor unfavorable runs, the shape path was slower and more +variable. + +Three five-render `:eprof` repetitions were mixed: median attributed CPU was +470.41 ms for compact maps and 463.10 ms for shapes. That small profiler-only +improvement did not translate into the release-gating endpoint and accompanied +higher reductions. No CPU win is claimed. + +## Why it lost + +The existing compact map performs one property-map lookup and retains only a +one-tuple around default values. The shape path replaces that with: + +- representation dispatch; +- a shape index-map lookup followed by tuple access; +- transition-map admission on new fields; +- values-tuple copying; +- eventual dictionary materialization for mixed objects. + +Those costs are amortized in the ideal repeated-shape fixture, but Vue has few +repeated wholly eligible shapes. A dynamic first-use transition policy also +spends work on one-off bootstrap objects before knowing whether a shape will be +reused. The retained reduction was too small to alter SSR endpoint heap classes. + +## Decision + +Do not merge the runtime shape representation. Compact property maps remain the +canonical release implementation. + +A future shape design must avoid speculative transition work. Viable directions +are narrower and semantics-informed: + +1. decode-time object-literal plans keyed by immutable function/PC identity; +2. bulk final-shape construction only when bytecode proves the object cannot + escape during construction; +3. measured admission after repeated complete key sequences, not first-use + transition creation; +4. hybrid default slots plus an exceptional sidecar, but only if mixed-object + coverage justifies its permanent object overhead; +5. generated monomorphic property access only after a shape representation wins + the interpreter-first SSR gate. + +Any replacement must again preserve bounded binary identities, owner-local +state, dictionary fallback, exact accounting, Test262, SSR parity, and scheduler +containment. It must improve pinned Vue rather than only a synthetic ideal-shape +fixture. diff --git a/mix.exs b/mix.exs index 9223e0e4b..7684280bd 100644 --- a/mix.exs +++ b/mix.exs @@ -127,6 +127,7 @@ defmodule QuickBEAM.MixProject do "docs/beam-interpreter-architecture.md", "docs/beam-object-memory-investigation.md", "docs/beam-object-memory-measurements.md", + "docs/beam-object-shape-investigation.md", "docs/beam-compiler-contract.md", "docs/beam-compiler-performance-measurements.md", "docs/beam-compiler-region-investigation.md", From 416f874027534cc085a9744e53a8f53434f5eedb Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Thu, 16 Jul 2026 19:27:18 +0200 Subject: [PATCH 66/87] Measure object literal allocation plans --- CHANGELOG.md | 1 + bench/README.md | 1 + docs/beam-object-literal-investigation.md | 124 ++++++++++++++++++++++ docs/beam-object-memory-investigation.md | 18 ++-- mix.exs | 1 + 5 files changed, 139 insertions(+), 6 deletions(-) create mode 100644 docs/beam-object-literal-investigation.md diff --git a/CHANGELOG.md b/CHANGELOG.md index ba7fd245e..44d87f2a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - Define the optional BEAM compiler contract with binary artifact identities, a fixed 32-module atom pool, lease/purge/cache lifecycle, versioned runtime ABI boundaries, and validated owner-local before-instruction deoptimization states. Add its supervised module pool, minimal canonical runtime ABI, structured generated-module backend, bounded v26 CFG analysis, specialized fixed-name `:pure_v1` forms, selective bounded nested-function re-entry, guarded opt-in `:scalar_v1` loop/property/global lowering, explicit call actions, exact shared program artifact namespaces, warm artifact/negative-decision caches, and release-quarantined `engine: :compiler` orchestration with native, Preact/Vue/Svelte SSR, selected Test262, resource, concurrency, measurement, and single-scheduler acceptance coverage. - Remove integer-index keys from redundant object insertion-order lists, bulk-build literal arrays in one heap update, and encode default data descriptors compactly in the existing property map. Exact logical accounting is preserved; paired retained fixtures reduce array endpoint process memory by about 62% and ordinary-object retained VM heap size by 31%. - Measure and reject a bounded owner-local hidden-class prototype. Although ideal repeated-shape objects retained 13.4% less VM heap, only 82 of 1,130 final Vue objects were wholly eligible; Vue regressed from 47.07 ms to 50.30 ms with higher reductions and no endpoint memory improvement. +- Measure and reject decode-time object-literal allocation plans with reserved identities, exact accounting, fixed tuple builders, and one-shot compact-map materialization. An ideal repeated four-field fixture improved by 10.1%, but only 16 Vue literals per render were eligible and the controlled Vue median regressed by 7.1%. - Add bounded static and dynamic compiler-region probes plus a quarantined `compiler_regions: true` executor experiment with binary admission identities, a fixed 256-entry maximum, three-encounter admission, 32-operation straight-line regions, safe early-boundary tuple updates, exact resource accounting, and reproducible SSR measurements. The experiment remains disabled after Vue reached only 0.7% generated coverage and regressed to a 112.70 ms median. - Cache validated immutable builtin host templates by profile and registry generation, preserving owner-local mutation isolation and exact logical-memory charging while removing repeated installation from warm evaluations. - Add `QuickBEAM.VM.measure/2` with deterministic step/logical-memory counters, fixed owner-local OTP compiler counters, and endpoint process observations, plus reproducible pinned SSR concurrency, timeout, cancellation, reclamation, and single-scheduler reports. diff --git a/bench/README.md b/bench/README.md index 2d3e2c123..038fbc3ef 100644 --- a/bench/README.md +++ b/bench/README.md @@ -93,6 +93,7 @@ fixtures. Published results are in [`docs/beam-object-memory-investigation.md`](../docs/beam-object-memory-investigation.md), [`docs/beam-object-memory-measurements.md`](../docs/beam-object-memory-measurements.md), [`docs/beam-object-shape-investigation.md`](../docs/beam-object-shape-investigation.md), +[`docs/beam-object-literal-investigation.md`](../docs/beam-object-literal-investigation.md), [`docs/beam-compiler-performance-measurements.md`](../docs/beam-compiler-performance-measurements.md), [`docs/beam-compiler-ssr-measurements.md`](../docs/beam-compiler-ssr-measurements.md), [`docs/beam-compiler-scalar-ssr-measurements.md`](../docs/beam-compiler-scalar-ssr-measurements.md), diff --git a/docs/beam-object-literal-investigation.md b/docs/beam-object-literal-investigation.md new file mode 100644 index 000000000..0707c5a7b --- /dev/null +++ b/docs/beam-object-literal-investigation.md @@ -0,0 +1,124 @@ +# Object-literal allocation investigation + +This report evaluates semantics-aware one-shot construction of proven +non-escaping object-literal prefixes. The prototype is preserved on branch +`object-literal-plans` at commit `4f17cb7f` and is rejected for the release path. + +## Prototype contract + +The decoder attached bounded plans to immutable functions by function-local +program counter. A plan required at least four statically named default fields +and accepted only straight-line stack, scalar, local, and selected value/object +operations. Calls, control flow, computed definitions, methods, accessors, +spread, and any operation capable of consuming the protected object terminated +analysis. Nested eligible literals had independent plans. + +At runtime the implementation: + +- reserved the exact owner-local object ID at the original `object` instruction; +- charged logical object memory at that instruction; +- retained a fixed tuple builder on the explicit frame stack; +- charged each first property definition at its original `define_field`; +- preserved duplicate-key last-value and first-insertion ordering; +- allowed nested literals while preventing the incomplete outer object from + becoming an ordinary JavaScript value; +- constructed the compact property map once and inserted the object into the + owner heap after the final planned field; +- preserved IDs when nested allocations occurred between object creation and + materialization; +- used no process dictionary, ETS, dynamic atoms, native mutable state, or + cross-evaluation cache. + +The physical heap write was delayed, but deterministic steps, logical allocation, +exceptions, and step-limit rejection remained at canonical instruction +boundaries. + +## Static opportunity + +The final conservative analyzer found: + +| fixture | object opcodes | planned prefixes | planned fields | field counts | +|---|---:|---:|---:|---| +| Preact 10.29.7 | 23 | 2 | 16 | 6, 10 | +| Vue 3.5.39 | 111 | 13 | 99 | five 4-field, two 5-field, two 7-field, two 10-field, one 11-field, one 24-field | +| Svelte 5.56.4 | 20 | 3 | 19 | 4, 6, 9 | + +Vue `:eprof` observed 80 planned allocations and 575 planned fields over five +renders: 16 literals and 115 fields per render. The same profile performed about +948 ordinary heap allocations and 1,028 property definitions per render. The +optimization therefore reached roughly 1.7% of allocations and 11.2% of property +definitions. + +Broader variants were also tested. Allowing resumable calls and default methods +raised Vue's static inventory to 32 prefixes and 171 fields, but retaining +builders across invocation boundaries increased endpoint latency and variance. +That variant was discarded before the final conservative measurement. + +## Ideal repeated-literal fixture + +A controlled fixture creates 5,000 objects with the same four default fields. +Two pinned-core 100-sample runs per representation produced stable results: + +| representation | wall median | reductions median | endpoint process memory | steps | logical memory | +|---|---:|---:|---:|---:|---:| +| canonical incremental maps | 51.95 ms | 5,677,983 | 7.28 MiB | 150,022 | 5.62 MiB | +| one-shot literal plans | 46.69 ms | 5,194,294 | 9.13 MiB | 150,022 | 5.62 MiB | + +One-shot construction reduced wall time by 10.1% and reductions by 8.5% in its +ideal workload. The higher endpoint heap class illustrates why endpoint process +memory is not equivalent to retained term size or peak RSS. + +## Pinned Vue gate + +Three pinned-core, single-scheduler runs used 200 samples after ten warmups. Run +order was alternated: + +| run | canonical median | planned median | canonical p95 | planned p95 | +|---:|---:|---:|---:|---:| +| 1 | 53.81 ms | 49.44 ms | 74.21 ms | 53.50 ms | +| 2 | 46.19 ms | 51.58 ms | 50.63 ms | 56.96 ms | +| 3 | 46.07 ms | 49.43 ms | 48.01 ms | 57.18 ms | + +The first run reflected machine warmup. In both subsequent orderings the planned +path was 7–12% slower. Median-of-run medians was 46.19 ms for canonical maps and +49.44 ms for plans, a 7.1% regression. Median reductions improved only about +0.3%, steps and logical memory were exact, and endpoint process-memory classes +were unchanged. + +Five-render `:eprof` totals were effectively neutral: approximately 450 ms for +both implementations. Avoiding a small number of persistent map updates did not +amortize tuple-builder updates, list accumulation, reversal, and final `Map.new/1` +on Vue's short and mostly cold literal prefixes. + +The experimental scheduler probe remained within the existing bounds, but that +does not override failure of the Vue latency gate. + +## Correctness + +The experimental branch passed: + +- 246 VM tests with three explicit skips; +- all six pinned Test262 interpreter/compiler gates; +- duplicate keys, nested literals, caught exceptions, exact resource parity, and + exact step-limit rejection; +- existing Preact, Vue, and Svelte parity, async, cancellation, and concurrency + tests; +- warnings-as-errors and focused new-module Credo checks. + +Correctness was not the reason for rejection. + +## Decision + +Do not merge object-literal builders. Compact incremental property maps remain +the canonical implementation. + +The experiment establishes an important boundary: one-shot construction is +valuable when a repeated literal has at least four fields, but current pinned SSR +executes too few eligible literals to offset builder and finalization overhead. +Expanding across calls improves static coverage while worsening the release +endpoint. + +Further object optimization should begin with exact call-site attribution of the +remaining `maps:put/3` and object-heap updates. Another speculative allocation +tier should not be added unless dynamic measurements identify a substantially +larger hot population than either bounded shapes or object-literal plans reached. diff --git a/docs/beam-object-memory-investigation.md b/docs/beam-object-memory-investigation.md index 6ba3b0f82..ede47d10f 100644 --- a/docs/beam-object-memory-investigation.md +++ b/docs/beam-object-memory-investigation.md @@ -170,12 +170,18 @@ its 50-sample median regressed from 47.07 ms to 50.30 ms with no endpoint memory change. The runtime shape representation was rejected. See the [bounded object-shape investigation](beam-object-shape-investigation.md). -If shapes are revisited, they should be selected from decode-time object-literal -semantics or repeated complete key sequences rather than speculative first-use -transitions. Bulk construction must prove that the object cannot escape while it -is incomplete. Fixed metadata helpers may use Erlang records where OTP's -`update_record` optimization is measurable, but OTP 29 native records remain too -experimental for the runtime contract. +A decode-time object-literal implementation was subsequently tested with reserved +identity, exact per-instruction accounting, fixed tuple builders, and one-shot +map construction. It improved an ideal repeated four-field fixture by 10.1%, but +only reached 16 literals per Vue render and regressed Vue's controlled median by +7.1%. It was also rejected; see the +[object-literal allocation investigation](beam-object-literal-investigation.md). + +If either technique is revisited, admission must come from a substantially larger +measured dynamic population rather than static opportunity. Fixed metadata +helpers may use Erlang records where OTP's `update_record` optimization is +measurable, but OTP 29 native records remain too experimental for the runtime +contract. ## Process heap sizing diff --git a/mix.exs b/mix.exs index 7684280bd..1448bd919 100644 --- a/mix.exs +++ b/mix.exs @@ -128,6 +128,7 @@ defmodule QuickBEAM.MixProject do "docs/beam-object-memory-investigation.md", "docs/beam-object-memory-measurements.md", "docs/beam-object-shape-investigation.md", + "docs/beam-object-literal-investigation.md", "docs/beam-compiler-contract.md", "docs/beam-compiler-performance-measurements.md", "docs/beam-compiler-region-investigation.md", From 81d72707659bf02136540fa72b8b18111ee6ec4c Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Thu, 16 Jul 2026 21:42:17 +0200 Subject: [PATCH 67/87] Attribute interpreter allocation hotspots --- CHANGELOG.md | 1 + bench/README.md | 5 + bench/vm_interpreter_fprof.exs | 71 +++++++++++ docs/beam-interpreter-architecture.md | 5 +- .../beam-interpreter-hotspot-investigation.md | 115 ++++++++++++++++++ 5 files changed, 196 insertions(+), 1 deletion(-) create mode 100644 bench/vm_interpreter_fprof.exs create mode 100644 docs/beam-interpreter-hotspot-investigation.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 44d87f2a1..28e8e54fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Remove integer-index keys from redundant object insertion-order lists, bulk-build literal arrays in one heap update, and encode default data descriptors compactly in the existing property map. Exact logical accounting is preserved; paired retained fixtures reduce array endpoint process memory by about 62% and ordinary-object retained VM heap size by 31%. - Measure and reject a bounded owner-local hidden-class prototype. Although ideal repeated-shape objects retained 13.4% less VM heap, only 82 of 1,130 final Vue objects were wholly eligible; Vue regressed from 47.07 ms to 50.30 ms with higher reductions and no endpoint memory improvement. - Measure and reject decode-time object-literal allocation plans with reserved identities, exact accounting, fixed tuple builders, and one-shot compact-map materialization. An ideal repeated four-field fixture improved by 10.1%, but only 16 Vue literals per render were eligible and the controlled Vue median regressed by 7.1%. +- Attribute pinned Vue interpreter churn by exact caller: 55.8% of `maps:put/3` calls update the outer object heap, 30.9% update property dictionaries, and 13.0% update globals or closure cells. Reject tuple/OTP-array constant pools and smaller opcode/property fast paths because lower call counts or reductions did not produce stable endpoint latency without resource regressions. - Add bounded static and dynamic compiler-region probes plus a quarantined `compiler_regions: true` executor experiment with binary admission identities, a fixed 256-entry maximum, three-encounter admission, 32-operation straight-line regions, safe early-boundary tuple updates, exact resource accounting, and reproducible SSR measurements. The experiment remains disabled after Vue reached only 0.7% generated coverage and regressed to a 112.70 ms median. - Cache validated immutable builtin host templates by profile and registry generation, preserving owner-local mutation isolation and exact logical-memory charging while removing repeated installation from warm evaluations. - Add `QuickBEAM.VM.measure/2` with deterministic step/logical-memory counters, fixed owner-local OTP compiler counters, and endpoint process observations, plus reproducible pinned SSR concurrency, timeout, cancellation, reclamation, and single-scheduler reports. diff --git a/bench/README.md b/bench/README.md index 038fbc3ef..30d917ed9 100644 --- a/bench/README.md +++ b/bench/README.md @@ -40,6 +40,10 @@ COMPILER_EPROF_PHASE=initialization COMPILER_EPROF_ENGINE=interpreter \ COMPILER_EPROF_WORKLOAD=object_property_loop MIX_ENV=bench \ mix run bench/vm_compiler_eprof.exs +# Attribute interpreter calls in one warm pinned Vue render +VM_INTERPRETER_FPROF_OUTPUT=/tmp/vue.fprof \ + MIX_ENV=bench mix run bench/vm_interpreter_fprof.exs + # Attribute CPU in the pinned Vue fixture COMPILER_SSR_EPROF_PROFILE=scalar_v1 COMPILER_SSR_EPROF_ITERATIONS=3 \ MIX_ENV=bench mix run bench/vm_compiler_ssr_eprof.exs @@ -94,6 +98,7 @@ fixtures. Published results are in [`docs/beam-object-memory-measurements.md`](../docs/beam-object-memory-measurements.md), [`docs/beam-object-shape-investigation.md`](../docs/beam-object-shape-investigation.md), [`docs/beam-object-literal-investigation.md`](../docs/beam-object-literal-investigation.md), +[`docs/beam-interpreter-hotspot-investigation.md`](../docs/beam-interpreter-hotspot-investigation.md), [`docs/beam-compiler-performance-measurements.md`](../docs/beam-compiler-performance-measurements.md), [`docs/beam-compiler-ssr-measurements.md`](../docs/beam-compiler-ssr-measurements.md), [`docs/beam-compiler-scalar-ssr-measurements.md`](../docs/beam-compiler-scalar-ssr-measurements.md), diff --git a/bench/vm_interpreter_fprof.exs b/bench/vm_interpreter_fprof.exs new file mode 100644 index 000000000..0bdabb8cc --- /dev/null +++ b/bench/vm_interpreter_fprof.exs @@ -0,0 +1,71 @@ +output = System.get_env("VM_INTERPRETER_FPROF_OUTPUT", "vm-interpreter.fprof") +trace = output <> ".trace" + +tools_ebin = + :code.root_dir() + |> to_string() + |> Path.join("lib/tools-*/ebin") + |> Path.wildcard() + |> List.first() + +runtime_tools_ebin = + :code.root_dir() + |> to_string() + |> Path.join("lib/runtime_tools-*/ebin") + |> Path.wildcard() + |> List.first() + +true = :code.add_patha(String.to_charlist(tools_ebin)) +true = :code.add_patha(String.to_charlist(runtime_tools_ebin)) +{:module, :dbg} = :code.ensure_loaded(:dbg) +{:module, :fprof} = :code.ensure_loaded(:fprof) + +{:ok, source} = + QuickBEAM.JS.bundle_file("test/fixtures/vm/vue_ssr.js", + 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") + } + ) + +{:ok, program} = QuickBEAM.VM.compile(source, filename: "test/fixtures/vm/vue_ssr.js") + +props = %{ + "title" => "Profile", + "products" => [ + %{"id" => 1, "name" => "Product 1", "inStock" => true, "priceCents" => 1299} + ] +} + +handler = fn [] -> props end + +opts = [ + profile: :ssr, + handlers: %{"load_props" => handler}, + max_steps: 50_000_000, + memory_limit: 256_000_000, + timeout: 5_000, + isolation: :caller +] + +{:ok, _html} = QuickBEAM.VM.Evaluator.eval(program, opts) + +:fprof.apply(fn -> QuickBEAM.VM.Evaluator.eval(program, opts) end, [], + file: String.to_charlist(trace) +) + +:fprof.profile(file: String.to_charlist(trace)) + +:fprof.analyse( + dest: String.to_charlist(output), + callers: true, + sort: :own, + totals: true, + details: true +) + +IO.puts("wrote #{output}") diff --git a/docs/beam-interpreter-architecture.md b/docs/beam-interpreter-architecture.md index 664b77fc6..3dc0fa990 100644 --- a/docs/beam-interpreter-architecture.md +++ b/docs/beam-interpreter-architecture.md @@ -470,7 +470,10 @@ entry. Default data descriptors are encoded compactly as one-tuples in the exist property map; accessors and non-default descriptors retain full `Property` structs. Integer indices never enter the separate insertion-order list. This preserves one OTP map lookup path and avoids descriptor and quadratic ordering allocation; -see the [object-memory investigation](beam-object-memory-investigation.md). +see the [object-memory investigation](beam-object-memory-investigation.md). Exact +Vue caller attribution confirms that the remaining persistent-map population is +mostly the outer object heap; rejected alternatives and profiler caveats are in +the [interpreter hotspot investigation](beam-interpreter-hotspot-investigation.md). Getter, setter, and `Object.assign` calls use resumable boundaries so arbitrary JavaScript and exceptions do not escape the explicit machine state. Promise resolution reads accessor-backed `then` properties diff --git a/docs/beam-interpreter-hotspot-investigation.md b/docs/beam-interpreter-hotspot-investigation.md new file mode 100644 index 000000000..a7cf577e5 --- /dev/null +++ b/docs/beam-interpreter-hotspot-investigation.md @@ -0,0 +1,115 @@ +# BEAM interpreter hotspot investigation + +This report attributes one warm render of the pinned Vue 3.5.39 fixture after +the compact-object work. It records rejected experiments as well as retained +conclusions because reductions and instrumented profiler time did not reliably +predict endpoint latency. + +## Method + +The caller graph is reproducible with: + +```sh +VM_INTERPRETER_FPROF_OUTPUT=/tmp/vue.fprof \ + MIX_ENV=bench mix run bench/vm_interpreter_fprof.exs +``` + +The runner warms the interpreter, then profiles one caller-isolated render with +`:fprof`. Candidate changes were evaluated separately with paired, alternating +runs of the process-isolated Vue fixture. Each controlled run used 200 samples +and preserved output, 11,957 VM steps, and 1,015,684 bytes of logical memory. +Single-scheduler runs used `ERL_FLAGS='+S 1:1'` and CPU affinity. Default-scheduler +checks were also made for the constant-pool experiment. + +`:fprof` is useful here for exact call counts and caller attribution. Its elapsed +and own-time columns are not release measurements: tracing magnifies functions +that recurse or make many calls. Endpoint wall time remains the acceptance gate. + +## Persistent-map attribution + +One Vue render made 5,600 `maps:put/3` calls. The caller graph separates them as +follows: + +| category | calls | share | +|---|---:|---:| +| outer object heap updates | 3,126 | 55.8% | +| object property dictionaries | 1,729 | 30.9% | +| globals and closure cells | 730 | 13.0% | +| promises and other state | 15 | 0.3% | + +The outer-heap total consists of 1,001 object insertions, 983 writes after +property definition, 741 writes after ordinary property assignment, 364 array +index writes, 26 generic object updates, nine descriptor writes, and two +prototype writes. Property dictionaries account for 1,166 compact default-data +writes and 563 exceptional/full-descriptor writes. + +This rules out an unmeasured assumption that descriptor maps dominate the +remaining churn. The largest population is the sequential-ID outer heap, whose +OTP `:array` replacement was already rejected: it slightly reduced retained +heap but increased reductions by 8.1% and regressed Vue `:eprof` CPU by 7.1%. +The accepted persistent map remains the better release representation. + +The same render made 5,491 `maps:find/2` calls, including 4,311 direct object +fetches and 1,073 prototype/property-depth lookups. These are canonical semantic +operations rather than duplicate descriptor lookups. + +## Constant-pool experiment + +Caller tracing initially appeared to identify a larger problem than map churn: +567 nested-function lookups through list-backed constant pools produced about +114,000 recursive list-drop calls. Converting every function constant pool to a +tuple removed that recursion and reduced traced calls from roughly 451,000 to +336,000. + +The isolated endpoint results did not support promotion: + +- reductions fell from about 702,000–705,000 to 584,000–587,000; +- endpoint process memory fell from 97,079,584 to 80,899,872 bytes; +- the controlled single-scheduler wall median increased from 46.69 ms to + 49.45 ms in the representative paired run; +- repeated default-scheduler checks were neutral to slower rather than showing + a stable latency gain. + +A full OTP `:array` constant pool sometimes improved median wall time, but raised +endpoint process memory to 102,676,424 bytes and had unstable tail behavior. A +32-entry hybrid retained the baseline heap class but consistently regressed the +controlled median by about 7%. A separate nested-function index duplicated the +recursive function graph during isolated-process copying and exceeded the +worker resource bound. + +The list constant pool is therefore retained. The result also demonstrates why +recursive call count, reductions, and process heap class cannot substitute for +the pinned endpoint gate. + +## Smaller rejected fast paths + +Three local changes reduced profiler work or BEAM reductions but regressed +paired wall time: + +- replacing the opcode metadata map with a dense tuple reduced reductions by + about 3%, while the median increased by about 7%; +- replacing short-opcode alias map lookups with generated function clauses + reduced reductions by about 10%, while the median increased by about 6%; +- bypassing `%Property{}` construction for 425 ordinary default definitions + left reductions effectively unchanged and increased the median by about 7%. + +None is retained. Small map lookups and the current compact-property path are +already effective under BeamAsm; source-level operation counts did not expose a +better endpoint implementation. + +## Decision + +No runtime representation or fast-path change from this phase is promoted. +Release defaults remain: + +- list-backed immutable constant pools; +- compact `{value}` default descriptors in one property map; +- persistent maps for the owner-local outer heap, globals, and cells; +- canonical incremental property semantics; +- interpreter-first execution with compiler regions disabled. + +Further work should begin from a dynamically larger semantic population than +the 425 default-definition shortcut or 567 function-constant lookups. It must +measure pinned Preact, Vue, and Svelte wall time, reductions, endpoint process +memory, scheduler behavior, exact steps, and logical memory before changing the +release representation. From 8f498a5a93aabdf771c2b0515ed58251444f7070 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Thu, 16 Jul 2026 22:48:49 +0200 Subject: [PATCH 68/87] Measure closure allocation fast paths --- CHANGELOG.md | 2 +- .../beam-interpreter-hotspot-investigation.md | 50 +++++++++++++++++-- 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 28e8e54fb..3c20143ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ - Remove integer-index keys from redundant object insertion-order lists, bulk-build literal arrays in one heap update, and encode default data descriptors compactly in the existing property map. Exact logical accounting is preserved; paired retained fixtures reduce array endpoint process memory by about 62% and ordinary-object retained VM heap size by 31%. - Measure and reject a bounded owner-local hidden-class prototype. Although ideal repeated-shape objects retained 13.4% less VM heap, only 82 of 1,130 final Vue objects were wholly eligible; Vue regressed from 47.07 ms to 50.30 ms with higher reductions and no endpoint memory improvement. - Measure and reject decode-time object-literal allocation plans with reserved identities, exact accounting, fixed tuple builders, and one-shot compact-map materialization. An ideal repeated four-field fixture improved by 10.1%, but only 16 Vue literals per render were eligible and the controlled Vue median regressed by 7.1%. -- Attribute pinned Vue interpreter churn by exact caller: 55.8% of `maps:put/3` calls update the outer object heap, 30.9% update property dictionaries, and 13.0% update globals or closure cells. Reject tuple/OTP-array constant pools and smaller opcode/property fast paths because lower call counts or reductions did not produce stable endpoint latency without resource regressions. +- Attribute pinned Vue interpreter churn by exact caller: 55.8% of `maps:put/3` calls update the outer object heap, 30.9% update property dictionaries, and 13.0% update globals or closure cells. Reject tuple/OTP-array constant pools, closure-allocation specialization, bulk function/prototype construction, and smaller opcode/property fast paths because lower call counts or reductions did not produce stable endpoint latency without resource regressions; removing 22.6% of traced map writes regressed the single-scheduler Vue median by 7.3%. - Add bounded static and dynamic compiler-region probes plus a quarantined `compiler_regions: true` executor experiment with binary admission identities, a fixed 256-entry maximum, three-encounter admission, 32-operation straight-line regions, safe early-boundary tuple updates, exact resource accounting, and reproducible SSR measurements. The experiment remains disabled after Vue reached only 0.7% generated coverage and regressed to a 112.70 ms median. - Cache validated immutable builtin host templates by profile and registry generation, preserving owner-local mutation isolation and exact logical-memory charging while removing repeated installation from warm evaluations. - Add `QuickBEAM.VM.measure/2` with deterministic step/logical-memory counters, fixed owner-local OTP compiler counters, and endpoint process observations, plus reproducible pinned SSR concurrency, timeout, cancellation, reclamation, and single-scheduler reports. diff --git a/docs/beam-interpreter-hotspot-investigation.md b/docs/beam-interpreter-hotspot-investigation.md index a7cf577e5..42a7b6bd4 100644 --- a/docs/beam-interpreter-hotspot-investigation.md +++ b/docs/beam-interpreter-hotspot-investigation.md @@ -81,6 +81,47 @@ The list constant pool is therefore retained. The result also demonstrates why recursive call count, reductions, and process heap class cannot substitute for the pinned endpoint gate. +## Closure-allocation follow-up + +The allocation caller graph provides a larger dynamic population than the +constant lookups. Of 1,001 object allocations in one Vue render: + +- 567 allocate function objects for `fclosure`; +- 253 allocate eager ordinary `prototype` objects for constructable functions; +- the remaining 181 cover arrays, ordinary objects, regular expressions, and + builtin results. + +Function objects and their prototypes therefore account for 820 allocations. +A callable-only `Heap.allocate/3` specialization removed generic keyword-option +parsing, but did not improve endpoint latency. Across five alternating, +200-sample single-scheduler runs, the median-of-run medians was 47.251 ms for +the baseline and 47.378 ms for the specialization. Reductions fell about 1%, +while the median p95 worsened about 4.7%. + +A second prototype bulk-built each constructable function/prototype pair while +preserving function and prototype IDs, default prototypes, full descriptor +flags, property order, allocation order, steps, and logical charges. A direct +equivalence test compared its final heap and accounting with the canonical +incremental sequence. It reduced `maps:put/3` calls from 5,600 to 4,335 +(-22.6%): object insertion calls fell from 1,001 to 495, property-definition +outer writes from 983 to 477, and full-descriptor dictionary writes from 563 to +57. + +The endpoint gate rejected the bulk path despite that call reduction. Five +alternating, 200-sample `+S 1:1` runs produced: + +| metric | baseline median of runs | bulk pair median of runs | +|---|---:|---:| +| wall median | 46.234 ms | 49.600 ms | +| wall p95 | 49.050 ms | 54.602 ms | +| reductions | 702,287 | 677,930 | +| endpoint process memory | 97,079,584 bytes | 97,079,528 bytes | + +The bulk path regressed median wall time by 7.3% and median p95 by 11.3%, with +no meaningful endpoint-memory improvement. Neither closure-allocation change is +retained. This is a stronger example than the constant pool: even removing more +than one fifth of all traced map writes did not improve real latency. + ## Smaller rejected fast paths Three local changes reduced profiler work or BEAM reductions but regressed @@ -108,8 +149,7 @@ Release defaults remain: - canonical incremental property semantics; - interpreter-first execution with compiler regions disabled. -Further work should begin from a dynamically larger semantic population than -the 425 default-definition shortcut or 567 function-constant lookups. It must -measure pinned Preact, Vue, and Svelte wall time, reductions, endpoint process -memory, scheduler behavior, exact steps, and logical memory before changing the -release representation. +Further work should not introduce another allocation representation or bulk +construction tier without new endpoint evidence. It must measure pinned Preact, +Vue, and Svelte wall time, reductions, endpoint process memory, scheduler +behavior, exact steps, and logical memory before changing the release path. From d8e01ad22727f10bbecf50323c0e4bcce03e273b Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 17 Jul 2026 00:08:19 +0200 Subject: [PATCH 69/87] Add bounded shared VM programs --- CHANGELOG.md | 1 + bench/README.md | 18 +- bench/vm_interpreter_perf.exs | 94 ++++ bench/vm_scheduler_probe.exs | 36 +- bench/vm_ssr.exs | 22 +- docs/beam-interpreter-architecture.md | 23 +- docs/beam-shared-program-investigation.md | 132 ++++++ docs/beam-shared-program-measurements.md | 80 ++++ ...m-shared-program-scheduler-measurements.md | 38 ++ lib/quickbeam/application.ex | 1 + lib/quickbeam/vm.ex | 149 +++++- lib/quickbeam/vm/decoder.ex | 1 + lib/quickbeam/vm/program.ex | 33 +- lib/quickbeam/vm/program_store.ex | 424 ++++++++++++++++++ lib/quickbeam/vm/shared_program.ex | 14 + lib/quickbeam/vm/verifier.ex | 5 + mix.exs | 10 + test/vm/program_store_test.exs | 119 +++++ test/vm/vue_ssr_test.exs | 21 +- 19 files changed, 1179 insertions(+), 42 deletions(-) create mode 100644 bench/vm_interpreter_perf.exs create mode 100644 docs/beam-shared-program-investigation.md create mode 100644 docs/beam-shared-program-measurements.md create mode 100644 docs/beam-shared-program-scheduler-measurements.md create mode 100644 lib/quickbeam/vm/program_store.ex create mode 100644 lib/quickbeam/vm/shared_program.ex create mode 100644 test/vm/program_store_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c20143ba..e9db5defc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Add explicit bounded immutable program sharing with lightweight `QuickBEAM.VM.SharedProgram` handles, eight fixed persistent slots, binary identities, single-flight admission, owner-monitored leases, deferred release, restart restoration, and copied-program fallback semantics. Pinned Vue SSR improves from a 49.21 ms median and 77.15 MiB endpoint process memory to 11.01 ms and 673.3 KiB while preserving exact steps, logical allocation, limits, cancellation, concurrency, and scheduler gates. - Define the optional BEAM compiler contract with binary artifact identities, a fixed 32-module atom pool, lease/purge/cache lifecycle, versioned runtime ABI boundaries, and validated owner-local before-instruction deoptimization states. Add its supervised module pool, minimal canonical runtime ABI, structured generated-module backend, bounded v26 CFG analysis, specialized fixed-name `:pure_v1` forms, selective bounded nested-function re-entry, guarded opt-in `:scalar_v1` loop/property/global lowering, explicit call actions, exact shared program artifact namespaces, warm artifact/negative-decision caches, and release-quarantined `engine: :compiler` orchestration with native, Preact/Vue/Svelte SSR, selected Test262, resource, concurrency, measurement, and single-scheduler acceptance coverage. - Remove integer-index keys from redundant object insertion-order lists, bulk-build literal arrays in one heap update, and encode default data descriptors compactly in the existing property map. Exact logical accounting is preserved; paired retained fixtures reduce array endpoint process memory by about 62% and ordinary-object retained VM heap size by 31%. - Measure and reject a bounded owner-local hidden-class prototype. Although ideal repeated-shape objects retained 13.4% less VM heap, only 82 of 1,130 final Vue objects were wholly eligible; Vue regressed from 47.07 ms to 50.30 ms with higher reductions and no endpoint memory improvement. diff --git a/bench/README.md b/bench/README.md index 30d917ed9..8870217a7 100644 --- a/bench/README.md +++ b/bench/README.md @@ -20,9 +20,11 @@ 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 -# Reproduce the pinned BEAM VM SSR report +# Reproduce the pinned BEAM VM SSR reports MIX_ENV=bench mix run bench/vm_ssr.exs \ --output docs/beam-ssr-measurements.md +MIX_ENV=bench mix run bench/vm_ssr.exs --shared-programs \ + --output docs/beam-shared-program-measurements.md # Reproduce the retained array/object-memory measurement MIX_ENV=bench mix run bench/vm_object_memory.exs \ @@ -44,6 +46,10 @@ COMPILER_EPROF_PHASE=initialization COMPILER_EPROF_ENGINE=interpreter \ VM_INTERPRETER_FPROF_OUTPUT=/tmp/vue.fprof \ MIX_ENV=bench mix run bench/vm_interpreter_fprof.exs +# Drive non-instrumented BeamAsm/perf sampling +ERL_FLAGS='+S 1:1 +JPperf true' perf record -F 997 -e cycles:u -- \ + env MIX_ENV=bench mix run bench/vm_interpreter_perf.exs --mode shared + # Attribute CPU in the pinned Vue fixture COMPILER_SSR_EPROF_PROFILE=scalar_v1 COMPILER_SSR_EPROF_ITERATIONS=3 \ MIX_ENV=bench mix run bench/vm_compiler_ssr_eprof.exs @@ -67,9 +73,11 @@ MIX_ENV=bench mix run bench/vm_ssr.exs \ --engine compiler --compiler-profile scalar_v1 --compiler-regions \ --output docs/beam-compiler-region-ssr-measurements.md -# Reproduce the interpreter single-scheduler fairness/timeout probe +# Reproduce the interpreter single-scheduler fairness/timeout probes ERL_FLAGS='+S 1:1' MIX_ENV=bench mix run bench/vm_scheduler_probe.exs \ --output docs/beam-scheduler-measurements.md +ERL_FLAGS='+S 1:1' MIX_ENV=bench mix run bench/vm_scheduler_probe.exs \ + --shared-programs --output docs/beam-shared-program-scheduler-measurements.md # Reproduce the release-quarantined compiler-tier probe ERL_FLAGS='+S 1:1' MIX_ENV=bench mix run bench/vm_scheduler_probe.exs \ @@ -86,7 +94,8 @@ profiles exactly one first evaluation in a fresh Mix VM, while execution warms the profile template and generated artifact before collecting samples. The SSR and scheduler runners accept `--compiler-profile pure_v1|scalar_v1`. -The SSR runner also accepts `--engine interpreter|compiler`, the quarantined +The SSR and scheduler runners also accept explicit `--shared-programs` handles. +The SSR runner accepts `--engine interpreter|compiler`, the quarantined `--compiler-regions` experiment, `--samples`, `--warmup`, and a comma-separated `--concurrency` list. It reports deterministic VM steps and logical allocation, fixed compiler coverage counters, @@ -94,6 +103,9 @@ endpoint BEAM process observations, sequential latency, concurrent throughput, and timeout/cancellation behavior for the pinned Preact, Vue, and Svelte fixtures. Published results are in [`docs/beam-ssr-measurements.md`](../docs/beam-ssr-measurements.md), +[`docs/beam-shared-program-investigation.md`](../docs/beam-shared-program-investigation.md), +[`docs/beam-shared-program-measurements.md`](../docs/beam-shared-program-measurements.md), +[`docs/beam-shared-program-scheduler-measurements.md`](../docs/beam-shared-program-scheduler-measurements.md), [`docs/beam-object-memory-investigation.md`](../docs/beam-object-memory-investigation.md), [`docs/beam-object-memory-measurements.md`](../docs/beam-object-memory-measurements.md), [`docs/beam-object-shape-investigation.md`](../docs/beam-object-shape-investigation.md), diff --git a/bench/vm_interpreter_perf.exs b/bench/vm_interpreter_perf.exs new file mode 100644 index 000000000..f5a9f84dc --- /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, "shared")) + 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 == :shared do + {:ok, shared_program} = QuickBEAM.VM.share_program(decoded_program) + shared_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 == :shared, do: QuickBEAM.VM.release_program(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!("shared"), do: :shared + 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 index c301f37b1..99ea9243a 100644 --- a/bench/vm_scheduler_probe.exs +++ b/bench/vm_scheduler_probe.exs @@ -27,7 +27,13 @@ defmodule QuickBEAM.Bench.VMSchedulerProbe do def run(args) do {opts, positional, invalid} = OptionParser.parse(args, - strict: [engine: :string, compiler_profile: :string, samples: :integer, output: :string] + strict: [ + engine: :string, + compiler_profile: :string, + shared_programs: :boolean, + samples: :integer, + output: :string + ] ) if positional != [] or invalid != [], @@ -40,8 +46,9 @@ defmodule QuickBEAM.Bench.VMSchedulerProbe do 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")) + shared_programs = Keyword.get(opts, :shared_programs, false) maybe_start_compiler!(engine) - fixture = compile_fixture!() + fixture = compile_fixture!(shared_programs) Enum.each(1..2, fn _iteration -> render!(fixture, engine, compiler_profile) end) @@ -85,6 +92,7 @@ defmodule QuickBEAM.Bench.VMSchedulerProbe do report( engine, compiler_profile, + shared_programs, samples, baseline_ms, render_summary, @@ -100,9 +108,17 @@ defmodule QuickBEAM.Bench.VMSchedulerProbe do end end - defp compile_fixture! do + defp compile_fixture!(shared_programs) do {:ok, source} = QuickBEAM.JS.bundle_file(@fixture, @bundle_opts) - {:ok, program} = QuickBEAM.VM.compile(source, filename: @fixture) + {:ok, decoded_program} = QuickBEAM.VM.compile(source, filename: @fixture) + + program = + if shared_programs do + {:ok, shared_program} = QuickBEAM.VM.share_program(decoded_program) + shared_program + else + decoded_program + end %{program: program, props: props()} end @@ -200,7 +216,16 @@ defmodule QuickBEAM.Bench.VMSchedulerProbe do do: raise("timeout p95 #{timeout.p95} µs exceeded #{@max_timeout_wall_us} µs") end - defp report(engine, compiler_profile, samples, baseline_ms, render, baseline, timeout) do + defp report( + engine, + compiler_profile, + shared_programs, + samples, + baseline_ms, + render, + baseline, + timeout + ) do title = if engine == :compiler and compiler_profile == :scalar_v1, do: "BEAM scalar compiler single-scheduler probe", @@ -215,6 +240,7 @@ defmodule QuickBEAM.Bench.VMSchedulerProbe do - Engine: #{engine} - Compiler profile: #{compiler_profile} + - Shared program handles: #{shared_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()} diff --git a/bench/vm_ssr.exs b/bench/vm_ssr.exs index 1d905ac2a..486ca0597 100644 --- a/bench/vm_ssr.exs +++ b/bench/vm_ssr.exs @@ -16,6 +16,7 @@ defmodule QuickBEAM.Bench.VMSSR do engine: :string, compiler_profile: :string, compiler_regions: :boolean, + shared_programs: :boolean, samples: :integer, warmup: :integer, concurrency: :string, @@ -29,6 +30,7 @@ defmodule QuickBEAM.Bench.VMSSR do 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) + shared_programs = Keyword.get(opts, :shared_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) @@ -37,7 +39,10 @@ defmodule QuickBEAM.Bench.VMSSR do concurrency!(Keyword.get(opts, :concurrency, Enum.join(@default_concurrency, ","))) fixtures = - Enum.map(fixture_specs(), &compile_fixture!(&1, engine, compiler_profile, compiler_regions)) + Enum.map( + fixture_specs(), + &compile_fixture!(&1, engine, compiler_profile, compiler_regions, shared_programs) + ) results = Enum.map(fixtures, fn fixture -> @@ -58,6 +63,7 @@ defmodule QuickBEAM.Bench.VMSSR do engine, compiler_profile, compiler_regions, + shared_programs, results, isolation, samples, @@ -120,9 +126,17 @@ defmodule QuickBEAM.Bench.VMSSR do ] end - defp compile_fixture!(spec, engine, compiler_profile, compiler_regions) do + defp compile_fixture!(spec, engine, compiler_profile, compiler_regions, shared_programs) do {:ok, source} = QuickBEAM.JS.bundle_file(spec.fixture, spec.bundle_opts) - {:ok, program} = QuickBEAM.VM.compile(source, filename: spec.fixture) + {:ok, decoded_program} = QuickBEAM.VM.compile(source, filename: spec.fixture) + + program = + if shared_programs do + {:ok, shared_program} = QuickBEAM.VM.share_program(decoded_program) + shared_program + else + decoded_program + end eval_opts = spec.eval_opts @@ -382,6 +396,7 @@ defmodule QuickBEAM.Bench.VMSSR do engine, compiler_profile, compiler_regions, + shared_programs, results, isolation, samples, @@ -426,6 +441,7 @@ defmodule QuickBEAM.Bench.VMSSR do - Engine: #{engine} - Compiler profile: #{compiler_profile} - Compiler regions: #{compiler_regions} + - Shared program handles: #{shared_programs} - Git base: `#{metadata.git}` - Working tree at measurement: #{metadata.tree_state} - Generated: #{metadata.generated} diff --git a/docs/beam-interpreter-architecture.md b/docs/beam-interpreter-architecture.md index 3dc0fa990..b882cdb2e 100644 --- a/docs/beam-interpreter-architecture.md +++ b/docs/beam-interpreter-architecture.md @@ -27,18 +27,23 @@ feature of the stateful native QuickJS runtime. ```elixir {:ok, program} = QuickBEAM.VM.compile(bundle, filename: "server.js") +{:ok, shared_program} = QuickBEAM.VM.share_program(program) Task.async_stream(requests, fn props -> - QuickBEAM.VM.eval(program, + QuickBEAM.VM.eval(shared_program, vars: %{"props" => props}, timeout: 250, max_steps: 5_000_000, memory_limit: 64 * 1024 * 1024 ) end) + +QuickBEAM.VM.release_program(shared_program) ``` -The immutable program is safe to share. Every evaluation owns its JavaScript +The immutable program is safe to share. The lightweight shared handle avoids +copying the decoded function graph through each request and worker process. +Every evaluation owns its JavaScript heap, globals, jobs, and limits. A failed or timed-out render only terminates its evaluation process. @@ -82,6 +87,8 @@ The existing `QuickBEAM` runtime remains the stateful engine: `QuickBEAM.VM` is an isolated execution engine: - a `%QuickBEAM.VM.Program{}` is immutable and reusable; +- an explicit `%QuickBEAM.VM.SharedProgram{}` uses a bounded fixed-slot store to + avoid copying the decoded graph between request and evaluation processes; - each evaluation executes in a dedicated BEAM process by default; - the JavaScript heap is local to that process; - there is no implicit mutable persistent state between evaluations; @@ -106,8 +113,13 @@ would still be scheduled independently. @spec QuickBEAM.VM.decode(binary(), keyword()) :: {:ok, QuickBEAM.VM.Program.t()} | {:error, term()} -@spec QuickBEAM.VM.measure(QuickBEAM.VM.Program.t(), keyword()) :: - {:ok, QuickBEAM.VM.Measurement.t()} | {:error, term()} +@spec QuickBEAM.VM.share_program(QuickBEAM.VM.Program.t()) :: + {:ok, QuickBEAM.VM.SharedProgram.t()} | {:error, term()} + +@spec QuickBEAM.VM.measure( + QuickBEAM.VM.Program.t() | QuickBEAM.VM.SharedProgram.t(), + keyword() + ) :: {:ok, QuickBEAM.VM.Measurement.t()} | {:error, term()} ``` Suggested compile options: @@ -121,6 +133,9 @@ Suggested compile options: `compile/2` asks the vendored QuickJS compiler for serialized bytecode, decodes it, verifies it, and returns an immutable program. Compilation may use a native compiler pool, but evaluation must not require a QuickJS execution context. +`share_program/1` is an explicit bounded optimization for programs reused across +processes; ordinary `%Program{}` evaluation retains process-copy semantics. See +the [shared-program investigation](beam-shared-program-investigation.md). `decode/2` is an advanced API. It accepts only bytecode matching the running QuickBEAM build fingerprint. `measure/2` runs the same isolated evaluation as diff --git a/docs/beam-shared-program-investigation.md b/docs/beam-shared-program-investigation.md new file mode 100644 index 000000000..17e916609 --- /dev/null +++ b/docs/beam-shared-program-investigation.md @@ -0,0 +1,132 @@ +# Bounded shared-program investigation + +This investigation profiles the process-isolated BEAM interpreter without +call tracing and addresses the largest measured endpoint cost. It applies only +to immutable, already verified `QuickBEAM.VM.Program` values. + +## Non-instrumented profile + +The pinned Vue 3.5.39 fixture was sampled on Linux/OTP 29 with: + +```sh +ERL_FLAGS='+S 1:1 +JPperf true' perf record -F 997 -e cycles:u -- \ + env MIX_ENV=bench mix run bench/vm_interpreter_perf.exs --mode copied + +ERL_FLAGS='+S 1:1 +JPperf true' perf record -F 997 -e cycles:u -- \ + env MIX_ENV=bench mix run bench/vm_interpreter_perf.exs --mode shared +``` + +`kernel.perf_event_paranoid` was lowered only for the profiling command and +restored afterward. `+JPperf true` supplied BeamAsm symbols to `perf`. + +A caller-local render spends its time in interpreter work: map element loads, +map updates, minor collection, opcode dispatch, list append, equality, and hash +map operations. No individual Elixir semantic helper beyond the interpreter +loop owns a large fraction of cycles. + +The ordinary isolated path was different. Its largest flat samples were: + +| runtime symbol | cycles | +|---|---:| +| `sweep_off_heap` | 16.55% | +| `full_sweep_heaps` | 15.96% | +| `copy_struct_x` | 10.68% | +| `sweep_new_heap` | 7.82% | +| `erts_cleanup_offheap_list` | 5.12% | +| `size_object_x` | 2.54% | + +Together these account for about 58.7% of sampled cycles. The immutable decoded +program was captured by the worker closure and copied onto every evaluation +process heap, then traversed again when that process exited. On this fixture the +program has extensive structural sharing but a flat copy size of roughly 7.4 +million words. This explains why reducing interpreter map writes did not improve +the old endpoint: process copying and teardown dominated it. + +## Design + +`QuickBEAM.VM.share_program/1` explicitly places a verified immutable program in +`QuickBEAM.VM.ProgramStore` and returns a small `%QuickBEAM.VM.SharedProgram{}` +handle. Evaluating the handle acquires an owner-monitored lease and fetches the +program from a fixed `:persistent_term` slot before spawning the worker. BeamAsm +retains that literal reference across the spawn instead of copying the graph. +The request process and evaluation worker therefore copy only the handle and +options, not the decoded function graph. + +The store is deliberately not an automatic cache: + +- sharing is explicit; +- the default capacity is eight and the hard maximum is 32; +- a shared serialized bytecode input is at most 2 MiB; +- slots use fixed integer keys and binary program identities; +- there is no input-derived atom and no implicit eviction; +- concurrent first admission is single-flight; +- every lease has a unique reference and owner monitor; +- owner death returns its lease; +- explicit release waits for active leases and then erases the slot; +- manager restart restores fixed slots without copying programs into its state; +- failed persistent-term installation returns a typed capacity error rather + than crashing evaluation; +- ordinary `%Program{}` evaluation retains the prior copied-process behavior. + +Program identities include the bytecode ABI/version, bytecode digest, source +digest, and filename. JavaScript heaps, globals, cells, jobs, counters, handlers, +and continuations remain evaluation-owner-local. Shared program memory is a +bounded global immutable resource and is intentionally separate from endpoint +process memory and logical JavaScript allocation. + +Typical use is: + +```elixir +{:ok, program} = QuickBEAM.VM.compile(bundle, filename: "server.js") +{:ok, shared} = QuickBEAM.VM.share_program(program) + +Task.async_stream(requests, fn props -> + QuickBEAM.VM.eval(shared, vars: %{"props" => props}) +end) + +QuickBEAM.VM.release_program(shared) +``` + +## Admission cost + +Sharing is an initialization operation, not part of the warm-render numbers. +Three fresh-VM Vue admissions took 11.307, 14.298, and 10.995 ms (11.307 ms +median). Admission reserves a slot through the store but installs the persistent +term in the caller, avoiding a giant GenServer message. The program retained +about 310,582 words (approximately 2.37 MiB) in the persistent slot according +to the unsupported ERTS debug size diagnostic, +while its process-copy flat size was 7,370,007 words (approximately 56.23 MiB). +The handle's external encoding was 95 bytes. The store process itself retained +only 3,088 bytes because program terms never enter its mailbox or state. + +These are OTP implementation observations, not logical JavaScript memory or a +stable public size API. Applications should share long-lived bundles during +startup and release them during replacement or shutdown, not churn slots per +request. + +## Pinned SSR result + +The reproducible report is +[`beam-shared-program-measurements.md`](beam-shared-program-measurements.md). +Against the prior copied-program report on the same machine: + +| fixture | copied median | shared median | copied endpoint memory | shared endpoint memory | +|---|---:|---:|---:|---:| +| Preact 10.29.7 | 8.22 ms | 7.01 ms | 4.5 MiB | 257.7 KiB | +| Vue 3.5.39 | 49.21 ms | 11.01 ms | 77.15 MiB | 673.3 KiB | +| Svelte 5.56.4 | 15.15 ms | 6.99 ms | 15.61 MiB | 673.3 KiB | + +At concurrency eight, Vue improved from 18.1 renders/s with a 204.55 ms median +to 684.7 renders/s with an 11.07 ms median. Exact VM steps, logical memory, +rendered output, limits, cancellation, and native parity are unchanged. + +The published +[`beam-shared-program-scheduler-measurements.md`](beam-shared-program-scheduler-measurements.md) +report measured a 4.91 ms Vue median without the SSR report's fixed 5 ms handler +delay, a 2.04 ms maximum ticker gap against the 75 ms bound, and a 51.0 ms +timeout p95 against the 60 ms bound. + +After sharing, the endpoint `perf` profile returns to interpreter work. Program +copy helpers fall below 0.5% individually; minor collection is 8.48%, map element +loads are the largest execution primitive, and no new semantic representation +is justified by the remaining profile. diff --git a/docs/beam-shared-program-measurements.md b/docs/beam-shared-program-measurements.md new file mode 100644 index 000000000..4703edb73 --- /dev/null +++ b/docs/beam-shared-program-measurements.md @@ -0,0 +1,80 @@ +# BEAM VM SSR measurements + +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. +The single-scheduler fairness and timeout gate is published separately in [`beam-scheduler-measurements.md`](beam-scheduler-measurements.md). + +## Environment + +- Engine: interpreter +- Compiler profile: pure_v1 +- Compiler regions: false +- Shared program handles: true +- Git base: `8f498a5a` +- Working tree at measurement: modified +- Generated: 2026-07-16T21:50:19Z +- Elixir: 1.20.2 +- OTP: 29 +- ERTS: 17.0.2 +- OS: Linux 7.0.0-27-generic +- Architecture: x86_64-pc-linux-gnu +- CPU: AMD Ryzen 9 9950X 16-Core Processor +- Logical schedulers: 32 +- Mix environment: `bench` +- Samples per fixture: 100 after 10 warmups +- Concurrency levels: 1, 4, 8 + +## Sequential isolated renders + +| Fixture | wall median | wall p95 | VM steps | logical memory | endpoint process memory | reductions median | +|---|---:|---:|---:|---:|---:|---:| +| Preact 10.29.7 | 7.01 ms | 8.0 ms | 3651 | 266.2 KiB | 257.7 KiB | 137493 | +| Vue 3.5.39 | 11.01 ms | 12.4 ms | 11957 | 991.9 KiB | 673.3 KiB | 630442 | +| Svelte 5.56.4 | 6.99 ms | 7.23 ms | 1777 | 397.1 KiB | 673.3 KiB | 124663 | + +`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. + + +## Concurrent isolated renders + +| Fixture | concurrency | renders | throughput | per-render wall median | per-render wall p95 | +|---|---:|---:|---:|---:|---:| +| Preact 10.29.7 | 1 | 100 | 142.3 renders/s | 6.98 ms | 7.56 ms | +| Preact 10.29.7 | 4 | 100 | 547.9 renders/s | 7.09 ms | 8.07 ms | +| Preact 10.29.7 | 8 | 100 | 1068.4 renders/s | 7.09 ms | 7.86 ms | +| Vue 3.5.39 | 1 | 100 | 90.0 renders/s | 10.98 ms | 12.16 ms | +| Vue 3.5.39 | 4 | 100 | 361.5 renders/s | 10.95 ms | 12.2 ms | +| Vue 3.5.39 | 8 | 100 | 684.7 renders/s | 11.07 ms | 12.01 ms | +| Svelte 5.56.4 | 1 | 100 | 141.3 renders/s | 6.97 ms | 7.9 ms | +| Svelte 5.56.4 | 4 | 100 | 562.0 renders/s | 6.98 ms | 7.78 ms | +| Svelte 5.56.4 | 8 | 100 | 1072.1 renders/s | 6.99 ms | 7.96 ms | + +## 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 | +|---:|---:|---:|---:| +| 100/100 | 6240.6 renders/s | -12.5 KiB | 0 | + +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 | +|---|---:|---:|---:|---:|---:| +| Preact 10.29.7 | limit:steps at 3650 | limit:memory_bytes at 133.1 KiB | limit:timeout at 200 ms | 200.3 ms | 36 µs | +| Vue 3.5.39 | limit:steps at 11956 | limit:memory_bytes at 495.9 KiB | limit:timeout at 200 ms | 201.08 ms | 5 µs | +| Svelte 5.56.4 | limit:steps at 1776 | limit:memory_bytes at 198.6 KiB | limit:timeout at 200 ms | 200.68 ms | 19 µs | + +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. diff --git a/docs/beam-shared-program-scheduler-measurements.md b/docs/beam-shared-program-scheduler-measurements.md new file mode 100644 index 000000000..e2dc2b91b --- /dev/null +++ b/docs/beam-shared-program-scheduler-measurements.md @@ -0,0 +1,38 @@ +# BEAM VM single-scheduler probe + +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 interpreter work. + +- Engine: interpreter +- Compiler profile: pure_v1 +- Shared program handles: true +- Git base: `8f498a5a` +- Working tree at measurement: modified +- Generated: 2026-07-16T22:01:33Z +- Elixir: 1.20.2 +- OTP: 29 +- ERTS: 17.0.2 +- OS: Linux 7.0.0-27-generic +- Architecture: x86_64-pc-linux-gnu +- CPU: AMD Ryzen 9 9950X 16-Core Processor +- Online schedulers: 1 +- Vue probe memory limit: 512 MB +- Samples: 30 + +| workload | wall median | wall p95 | ticker gap median | ticker gap p95 | ticker gap max | ticks median | +|---|---:|---:|---:|---:|---:|---:| +| Vue SSR | 4.91 ms | 5.74 ms | 1.73 ms | 2.03 ms | 2.04 ms | 2 | +| sleep baseline (5 ms target) | 6.0 ms | 6.0 ms | 2.0 ms | 2.01 ms | 2.01 ms | 3 | + +Acceptance bound: Vue SSR ticker gap ≤ 75.0 ms. + +## 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 | 50.99 ms | 51.0 ms | 51.02 ms | 990 µs | + +Acceptance bound: timeout p95 ≤ 60.0 ms. diff --git a/lib/quickbeam/application.ex b/lib/quickbeam/application.ex index 4d84581d8..89414b0e6 100644 --- a/lib/quickbeam/application.ex +++ b/lib/quickbeam/application.ex @@ -12,6 +12,7 @@ defmodule QuickBEAM.Application do }, QuickBEAM.LockManager, QuickBEAM.WasmAPI, + QuickBEAM.VM.ProgramStore, {Task.Supervisor, name: QuickBEAM.VM.TaskSupervisor} ] diff --git a/lib/quickbeam/vm.ex b/lib/quickbeam/vm.ex index 4a1d04aec..c7854973a 100644 --- a/lib/quickbeam/vm.ex +++ b/lib/quickbeam/vm.ex @@ -6,9 +6,21 @@ defmodule QuickBEAM.VM do heap, Promise state, host operations, and resource limits. """ - alias QuickBEAM.VM.{ABI, Compiler, Decoder, Evaluator, Function, Measurement, Program, Verifier} + alias QuickBEAM.VM.{ + ABI, + Compiler, + Decoder, + Evaluator, + Function, + Measurement, + Program, + ProgramStore, + SharedProgram, + Verifier + } @type program :: QuickBEAM.VM.Program.t() + @type shared_program :: QuickBEAM.VM.SharedProgram.t() @max_bytecode_bytes 16 * 1024 * 1024 @default_timeout 5_000 @@ -37,6 +49,7 @@ defmodule QuickBEAM.VM do program |> maybe_put_filename(filename) |> Map.put(:source_digest, :crypto.hash(:sha256, source)) + |> Program.put_share_key() {:ok, program} end @@ -46,6 +59,20 @@ defmodule QuickBEAM.VM do end end + @doc "Places a verified program in bounded shared storage and returns a lightweight handle." + @spec share_program(Program.t()) :: {:ok, SharedProgram.t()} | {:error, term()} + def share_program(%Program{} = program) do + program = Program.put_share_key(program) + + with :ok <- Verifier.verify(program) do + case ProgramStore.share(program) do + {:ok, shared} -> {:ok, shared} + {:error, reason} -> {:error, reason} + :unavailable -> {:error, :shared_program_capacity} + end + end + end + @doc "Decodes and verifies bytecode from this exact QuickJS build." @spec decode(binary(), keyword()) :: {:ok, program()} | {:error, term()} def decode(bytecode, opts \\ []) when is_binary(bytecode) and is_list(opts) do @@ -56,7 +83,7 @@ defmodule QuickBEAM.VM do :ok <- within_bytecode_limit(bytecode, max_bytecode_bytes), {:ok, program} <- Decoder.decode(bytecode), :ok <- Verifier.verify(program, verifier_options) do - {:ok, program} + {:ok, Program.put_share_key(program)} end end @@ -103,8 +130,24 @@ defmodule QuickBEAM.VM do BEAM process heap ceiling. `isolation: :caller` is available for trusted diagnostics. The compiler engine requires a supervised `QuickBEAM.VM.Compiler`. """ - @spec eval(Program.t(), keyword()) :: {:ok, term()} | {:error, term()} - def eval(%Program{} = program, opts \\ []) when is_list(opts) do + @spec eval(Program.t() | SharedProgram.t(), keyword()) :: {:ok, term()} | {:error, term()} + def eval(program, opts \\ []) + + def eval(%SharedProgram{} = shared, opts) when is_list(opts) do + with {:ok, options} <- evaluation_options(opts), + {:ok, lease} <- shared_lease(shared) do + try do + case options.isolation do + :caller -> evaluate_shared_caller(lease, options) + :process -> eval_isolated_shared(lease, options) + end + after + ProgramStore.checkin(lease) + end + end + end + + def eval(%Program{} = program, opts) when is_list(opts) do with :ok <- Verifier.verify(program), {:ok, options} <- evaluation_options(opts) do case options.isolation do @@ -123,8 +166,32 @@ defmodule QuickBEAM.VM do `measurement.result`. Invalid programs or options are returned directly as `{:error, reason}` because no evaluation was started. """ - @spec measure(Program.t(), keyword()) :: {:ok, Measurement.t()} | {:error, term()} - def measure(%Program{} = program, opts \\ []) when is_list(opts) do + @spec measure(Program.t() | SharedProgram.t(), keyword()) :: + {:ok, Measurement.t()} | {:error, term()} + def measure(program, opts \\ []) + + def measure(%SharedProgram{} = shared, opts) when is_list(opts) do + with {:ok, options} <- evaluation_options(opts), + {:ok, lease} <- shared_lease(shared) do + started = System.monotonic_time() + + payload = + try do + case options.isolation do + :caller -> measure_shared_caller(lease, options) + :process -> measure_isolated_shared(lease, options) + end + after + ProgramStore.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 + + def measure(%Program{} = program, opts) when is_list(opts) do with :ok <- Verifier.verify(program), {:ok, options} <- evaluation_options(opts) do started = System.monotonic_time() @@ -245,17 +312,42 @@ defmodule QuickBEAM.VM do end end - defp eval_isolated(program, options) do - caller = self() - reply_ref = make_ref() + defp shared_lease(shared) do + case ProgramStore.checkout(shared) do + {:ok, lease} -> {:ok, lease} + :unavailable -> {:error, :shared_program_unavailable} + end + end - worker = fn -> - result = safe_evaluate(program, options) - send(caller, {reply_ref, result}) + defp evaluate_shared_caller(lease, options) do + with {:ok, program} <- ProgramStore.fetch(lease), + :ok <- Verifier.verify_identity(program) do + evaluate(program, options) end + end - {pid, monitor_ref} = :erlang.spawn_opt(worker, worker_spawn_options(options.memory_limit)) + defp measure_shared_caller(lease, options) do + case fetch_verified_shared(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_shared(lease, options) do + with {:ok, program} <- ProgramStore.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 @@ -276,17 +368,30 @@ defmodule QuickBEAM.VM do kind, reason -> {:error, {engine_crash(options.engine), {kind, reason}, __STACKTRACE__}} end - defp measure_isolated(program, options) do - caller = self() - reply_ref = make_ref() + defp measure_isolated(program, options), do: measure_isolated_program(program, options) - worker = fn -> - payload = safe_measure(program, options) - send(caller, {reply_ref, payload}) + defp measure_isolated_shared(lease, options) do + case fetch_verified_shared(lease) do + {:ok, program} -> measure_isolated_program(program, options) + {:error, reason} -> {:measured, {:error, reason}, nil} end + end + defp fetch_verified_shared(lease) do + with {:ok, program} <- ProgramStore.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} @@ -333,6 +438,12 @@ defmodule QuickBEAM.VM do } end + @doc "Releases a bounded shared-program slot after its current evaluations finish." + @spec release_program(Program.t() | SharedProgram.t()) :: :ok | :not_shared + def release_program(program) + when is_struct(program, Program) or is_struct(program, SharedProgram), + do: ProgramStore.release(program) + @doc "Returns the monitored worker spawn options for an evaluation memory limit." def worker_spawn_options(:infinity), do: [:monitor] diff --git a/lib/quickbeam/vm/decoder.ex b/lib/quickbeam/vm/decoder.ex index 1e8f8b840..c48b42e09 100644 --- a/lib/quickbeam/vm/decoder.ex +++ b/lib/quickbeam/vm/decoder.ex @@ -64,6 +64,7 @@ defmodule QuickBEAM.VM.Decoder do version: version, fingerprint: ABI.fingerprint(), bytecode_digest: :crypto.hash(:sha256, data), + bytecode_size: byte_size(data), atoms: atoms, root: attach_atoms(value, atoms) }} diff --git a/lib/quickbeam/vm/program.ex b/lib/quickbeam/vm/program.ex index 5458931c2..edfe8102e 100644 --- a/lib/quickbeam/vm/program.ex +++ b/lib/quickbeam/vm/program.ex @@ -3,13 +3,22 @@ defmodule QuickBEAM.VM.Program do 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, allowing - optional compiler caches to invalidate on either identity without retaining - source text. + also present when the public source compiler produced the program. The + binary `share_key` includes version, digest, and filename identity so bounded + shared-program storage never derives atoms from input. """ @enforce_keys [:version, :fingerprint, :atoms, :root] - defstruct [:version, :fingerprint, :atoms, :root, :bytecode_digest, :source_digest] + defstruct [ + :version, + :fingerprint, + :atoms, + :root, + :bytecode_digest, + :bytecode_size, + :source_digest, + :share_key + ] @type t :: %__MODULE__{ version: non_neg_integer(), @@ -17,6 +26,20 @@ defmodule QuickBEAM.VM.Program do atoms: tuple(), root: term(), bytecode_digest: binary() | nil, - source_digest: binary() | nil + bytecode_size: non_neg_integer() | nil, + source_digest: binary() | nil, + share_key: binary() | nil } + + @doc "Derives the binary identity used by bounded immutable program sharing." + @spec put_share_key(t()) :: t() + def put_share_key(%__MODULE__{} = 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 | share_key: :crypto.hash(:sha256, :erlang.term_to_binary(identity))} + end end diff --git a/lib/quickbeam/vm/program_store.ex b/lib/quickbeam/vm/program_store.ex new file mode 100644 index 000000000..f6ae1e747 --- /dev/null +++ b/lib/quickbeam/vm/program_store.ex @@ -0,0 +1,424 @@ +defmodule QuickBEAM.VM.ProgramStore.Lease do + @moduledoc """ + Identifies one bounded lease on an immutable shared 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 + +defmodule QuickBEAM.VM.ProgramStore 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 `share_program/1` + calls. The store never derives atoms from input, holds at most `capacity` + persistent terms, and does not evict programs implicitly. Programs can be + explicitly released; active leases defer erasure until their workers finish. + """ + + use GenServer + + alias QuickBEAM.VM.{Program, SharedProgram, Verifier} + alias QuickBEAM.VM.ProgramStore.Lease + + @default_capacity 8 + @maximum_capacity 32 + @maximum_shared_bytecode_bytes 2 * 1024 * 1024 + @type checkout_result :: {:ok, Lease.t()} | :unavailable + + @doc "Starts the bounded shared-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 "Stores a verified program explicitly and returns its lightweight handle." + @spec share(Program.t(), GenServer.server()) :: + {:ok, SharedProgram.t()} | :unavailable | {:error, :program_too_large} + def share(program, server \\ __MODULE__) + + def share( + %Program{share_key: key, bytecode_size: size} = program, + server + ) + when is_binary(key) and is_integer(size) and size <= @maximum_shared_bytecode_bytes do + case reserve_and_install(key, program, server) do + {:ok, lease} -> + checkin(lease, server) + {:ok, %SharedProgram{key: key}} + + :unavailable -> + :unavailable + end + end + + def share(%Program{bytecode_size: size}, _server) + when is_integer(size) and size > @maximum_shared_bytecode_bytes, + do: {:error, :program_too_large} + + def share(%Program{}, _server), do: :unavailable + + @doc "Checks out an explicitly shared immutable program." + @spec checkout(SharedProgram.t(), GenServer.server()) :: checkout_result() + def checkout(%SharedProgram{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 "Releases a program slot, deferring erasure while leases remain active." + @spec release(Program.t() | SharedProgram.t(), GenServer.server()) :: :ok | :not_shared + def release(program, server \\ __MODULE__) + + def release(%SharedProgram{key: key}, server), do: release_key(key, server) + + def release(%Program{share_key: key}, server) when is_binary(key), + do: release_key(key, server) + + def release(%Program{}, _server), do: :not_shared + + defp release_key(key, server) do + case GenServer.whereis(server) && safe_store_call(server, {:release, key}) do + :ok -> :ok + _unavailable -> :not_shared + 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} = restore_slots(capacity) + + {:ok, + %{ + capacity: capacity, + entries: entries, + slots: slots, + pending: %{} + }} + end + end + + @impl true + def handle_call({:checkout_existing, key}, from, state) do + case state.entries do + %{^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}, from, state) do + case state.entries do + %{^key => entry} -> + {lease, entry} = grant_lease(key, entry, from) + {:reply, {:ok, lease}, put_in(state.entries[key], entry)} + + _entries -> + reserve_missing(key, 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({:release, 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 | release?: true})} + + _entries -> + {:reply, :not_shared, 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, server) do + if GenServer.whereis(server) do + case safe_store_call(server, {:reserve, key}) do + {:install, token, slot} -> install_reserved(server, key, token, slot, program) + result -> result + end + else + :unavailable + end + end + + defp reserve_missing(key, 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) do + nil -> + {:reply, :unavailable, state} + + slot -> + owner = elem(from, 0) + token = make_ref() + + pending = %{ + slot: slot, + token: token, + owner: owner, + monitor: Process.monitor(owner), + waiters: [] + } + + {: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 restore_slots(capacity) do + Enum.reduce(0..(capacity - 1), {%{}, %{}}, fn slot, {entries, slots} -> + 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) + + :missing -> + {entries, slots} + + _other -> + :persistent_term.erase(storage_key(slot)) + {entries, slots} + end + end) + end + + defp restore_program_slot(program, key, token, slot, entries, slots) do + if Verifier.verify_identity(program) == :ok do + entry = %{key: key, slot: slot, token: token, leases: %{}, release?: false} + {Map.put(entries, key, entry), Map.put(slots, slot, key)} + else + :persistent_term.erase(storage_key(slot)) + {entries, slots} + 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: %{}, + release?: false + } + + {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) + } + + {: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.release? 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 + %{state | entries: Map.delete(state.entries, key), slots: Map.delete(state.slots, slot)} + 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/shared_program.ex b/lib/quickbeam/vm/shared_program.ex new file mode 100644 index 000000000..e5cad8aa0 --- /dev/null +++ b/lib/quickbeam/vm/shared_program.ex @@ -0,0 +1,14 @@ +defmodule QuickBEAM.VM.SharedProgram do + @moduledoc """ + A lightweight handle to a verified immutable program in bounded shared storage. + + Handles can be copied cheaply between request processes. The underlying + decoded program remains in a fixed `QuickBEAM.VM.ProgramStore` slot until + explicitly released with `QuickBEAM.VM.release_program/1`. + """ + + @enforce_keys [:key] + defstruct [:key] + + @type t :: %__MODULE__{key: binary()} +end diff --git a/lib/quickbeam/vm/verifier.ex b/lib/quickbeam/vm/verifier.ex index bbee53771..68b357990 100644 --- a/lib/quickbeam/vm/verifier.ex +++ b/lib/quickbeam/vm/verifier.ex @@ -34,6 +34,11 @@ defmodule QuickBEAM.VM.Verifier do def verify(_program, _opts), do: {:error, :invalid_program} + @doc "Checks the constant-time ABI identity of an already verified shared 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) do Enum.reduce_while(opts, {:ok, @default_limits}, fn {key, value}, {:ok, limits} diff --git a/mix.exs b/mix.exs index 1448bd919..49002076d 100644 --- a/mix.exs +++ b/mix.exs @@ -108,6 +108,10 @@ defmodule QuickBEAM.MixProject do "docs/javascript-api.md", "docs/architecture.md", "docs/beam-interpreter-architecture.md", + "docs/beam-interpreter-hotspot-investigation.md", + "docs/beam-shared-program-investigation.md", + "docs/beam-shared-program-measurements.md", + "docs/beam-shared-program-scheduler-measurements.md", "docs/beam-compiler-contract.md", "docs/beam-compiler-performance-measurements.md", "docs/beam-compiler-scheduler-measurements.md", @@ -125,6 +129,10 @@ defmodule QuickBEAM.MixProject do "docs/javascript-api.md", "docs/architecture.md", "docs/beam-interpreter-architecture.md", + "docs/beam-interpreter-hotspot-investigation.md", + "docs/beam-shared-program-investigation.md", + "docs/beam-shared-program-measurements.md", + "docs/beam-shared-program-scheduler-measurements.md", "docs/beam-object-memory-investigation.md", "docs/beam-object-memory-measurements.md", "docs/beam-object-shape-investigation.md", @@ -164,6 +172,7 @@ defmodule QuickBEAM.MixProject do "QuickBEAM.VM.Invocation", "QuickBEAM.VM.Iterator", "QuickBEAM.VM.Opcodes", + "QuickBEAM.VM.ProgramStore", "QuickBEAM.VM.Properties", "QuickBEAM.VM.Value" ] @@ -181,6 +190,7 @@ defmodule QuickBEAM.MixProject do QuickBEAM.VM.Function, QuickBEAM.VM.Measurement, QuickBEAM.VM.Program, + QuickBEAM.VM.SharedProgram, QuickBEAM.VM.SourcePosition, QuickBEAM.VM.Variable ] diff --git a/test/vm/program_store_test.exs b/test/vm/program_store_test.exs new file mode 100644 index 000000000..ddd2fc455 --- /dev/null +++ b/test/vm/program_store_test.exs @@ -0,0 +1,119 @@ +defmodule QuickBEAM.VM.ProgramStoreTest do + use ExUnit.Case, async: false + + alias QuickBEAM.VM.Program + alias QuickBEAM.VM.ProgramStore + + test "shares one immutable program under concurrent bounded leases" do + program = program(:crypto.strong_rand_bytes(32)) + + assert {:ok, shared} = ProgramStore.share(program) + parent = self() + + tasks = + Enum.map(1..20, fn _index -> + Task.async(fn -> + {:ok, lease} = ProgramStore.checkout(shared) + send(parent, {:lease, lease}) + + receive do + :finish -> ProgramStore.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 -> ProgramStore.fetch(lease) == {:ok, program} end) + assert :ok = ProgramStore.release(shared) + assert Enum.all?(leases, fn lease -> ProgramStore.fetch(lease) == {:ok, program} end) + + Enum.each(tasks, &send(&1.pid, :finish)) + Enum.each(tasks, &Task.await/1) + assert eventually(fn -> ProgramStore.release(shared) == :not_shared end) + assert Enum.all?(leases, fn lease -> ProgramStore.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, shared} = ProgramStore.share(program) + + old_store = Process.whereis(ProgramStore) + 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(ProgramStore)) end) + + assert {:ok, lease} = ProgramStore.checkout(shared) + assert {:ok, ^program} = ProgramStore.fetch(lease) + ProgramStore.checkin(lease) + assert :ok = ProgramStore.release(shared) + end + + test "rejects programs above the bounded shared bytecode size" do + program = %{program(:crypto.strong_rand_bytes(32)) | bytecode_size: 2 * 1024 * 1024 + 1} + assert {:error, :program_too_large} = ProgramStore.share(program) + assert :not_shared = ProgramStore.release(program) + end + + test "released handles fail explicitly instead of copying or falling back" do + assert {:ok, program} = QuickBEAM.VM.compile("1 + 1") + assert {:ok, shared} = QuickBEAM.VM.share_program(program) + assert :ok = QuickBEAM.VM.release_program(shared) + assert {:error, :shared_program_unavailable} = QuickBEAM.VM.eval(shared) + end + + test "share 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 = Program.put_share_key(base) + + second = + base |> put_in([Access.key(:root), :filename], "second.js") |> Program.put_share_key() + + changed_source = + %{base | source_digest: :crypto.strong_rand_bytes(32)} |> Program.put_share_key() + + refute first.share_key == second.share_key + refute first.share_key == changed_source.share_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, + share_key: key + } + end +end diff --git a/test/vm/vue_ssr_test.exs b/test/vm/vue_ssr_test.exs index d5c011998..53b7b99d9 100644 --- a/test/vm/vue_ssr_test.exs +++ b/test/vm/vue_ssr_test.exs @@ -26,7 +26,9 @@ defmodule QuickBEAM.VM.VueSSRTest 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) - {:ok, source: source, program: program} + assert {:ok, shared_program} = QuickBEAM.VM.share_program(program) + on_exit(fn -> QuickBEAM.VM.release_program(shared_program) end) + {:ok, source: source, program: program, shared_program: shared_program} end test "renders pinned Vue HTML after an asynchronous Beam.call", %{program: program} do @@ -36,7 +38,11 @@ defmodule QuickBEAM.VM.VueSSRTest do ~s(

Featured

  • Product 1: $12.99
) end - test "matches the vendored native QuickJS renderer", %{program: program, source: source} do + test "matches the vendored native QuickJS renderer", %{ + program: program, + shared_program: shared_program, + source: source + } do props = props("Native parity", 2) handlers = %{"load_props" => fn [] -> props end} assert {:ok, runtime} = QuickBEAM.start(handlers: handlers) @@ -59,9 +65,16 @@ defmodule QuickBEAM.VM.VueSSRTest do @eval_opts ) + assert {:ok, shared_compiler_html} = + QuickBEAM.VM.eval( + shared_program, + [engine: :compiler, handlers: handlers] ++ @eval_opts + ) + assert beam_html == native_html assert compiler_html == native_html assert scalar_html == native_html + assert shared_compiler_html == native_html stats = ModulePool.stats(ModulePool) assert stats.counts.ready >= 1 assert stats.skips >= 1 @@ -70,7 +83,9 @@ defmodule QuickBEAM.VM.VueSSRTest do end end - test "shares one program across isolated concurrent Vue renders", %{program: program} do + test "shares one lightweight program handle across isolated concurrent Vue renders", %{ + shared_program: program + } do tasks = for id <- 1..8 do Task.async(fn -> eval(program, props("Catalog #{id}", id)) end) From 971f6c22e9cf078d32a25bb1168db724e3e97659 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 17 Jul 2026 11:43:51 +0200 Subject: [PATCH 70/87] Rename shared programs to pinned programs --- CHANGELOG.md | 2 +- bench/README.md | 16 ++--- bench/vm_interpreter_perf.exs | 12 ++-- bench/vm_scheduler_probe.exs | 20 +++--- bench/vm_ssr.exs | 20 +++--- docs/beam-interpreter-architecture.md | 20 +++--- ...d => beam-pinned-program-investigation.md} | 24 +++---- ...md => beam-pinned-program-measurements.md} | 2 +- ...-pinned-program-scheduler-measurements.md} | 2 +- lib/quickbeam/vm.ex | 72 +++++++++---------- .../{shared_program.ex => pinned_program.ex} | 6 +- lib/quickbeam/vm/program.ex | 14 ++-- lib/quickbeam/vm/program_store.ex | 70 +++++++++--------- lib/quickbeam/vm/verifier.ex | 2 +- mix.exs | 14 ++-- test/vm/program_store_test.exs | 44 ++++++------ test/vm/vue_ssr_test.exs | 18 ++--- 17 files changed, 179 insertions(+), 179 deletions(-) rename docs/{beam-shared-program-investigation.md => beam-pinned-program-investigation.md} (88%) rename docs/{beam-shared-program-measurements.md => beam-pinned-program-measurements.md} (99%) rename docs/{beam-shared-program-scheduler-measurements.md => beam-pinned-program-scheduler-measurements.md} (97%) rename lib/quickbeam/vm/{shared_program.ex => pinned_program.ex} (58%) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9db5defc..cca29f97a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased -- Add explicit bounded immutable program sharing with lightweight `QuickBEAM.VM.SharedProgram` handles, eight fixed persistent slots, binary identities, single-flight admission, owner-monitored leases, deferred release, restart restoration, and copied-program fallback semantics. Pinned Vue SSR improves from a 49.21 ms median and 77.15 MiB endpoint process memory to 11.01 ms and 673.3 KiB while preserving exact steps, logical allocation, limits, cancellation, concurrency, and scheduler gates. +- Add explicit bounded immutable program pinning with lightweight `QuickBEAM.VM.PinnedProgram` handles, eight fixed persistent slots, binary identities, single-flight admission, owner-monitored leases, deferred unpinning, restart restoration, and copied-program semantics for ordinary programs. Pinned Vue SSR improves from a 49.21 ms median and 77.15 MiB endpoint process memory to 11.01 ms and 673.3 KiB while preserving exact steps, logical allocation, limits, cancellation, concurrency, and scheduler gates. - Define the optional BEAM compiler contract with binary artifact identities, a fixed 32-module atom pool, lease/purge/cache lifecycle, versioned runtime ABI boundaries, and validated owner-local before-instruction deoptimization states. Add its supervised module pool, minimal canonical runtime ABI, structured generated-module backend, bounded v26 CFG analysis, specialized fixed-name `:pure_v1` forms, selective bounded nested-function re-entry, guarded opt-in `:scalar_v1` loop/property/global lowering, explicit call actions, exact shared program artifact namespaces, warm artifact/negative-decision caches, and release-quarantined `engine: :compiler` orchestration with native, Preact/Vue/Svelte SSR, selected Test262, resource, concurrency, measurement, and single-scheduler acceptance coverage. - Remove integer-index keys from redundant object insertion-order lists, bulk-build literal arrays in one heap update, and encode default data descriptors compactly in the existing property map. Exact logical accounting is preserved; paired retained fixtures reduce array endpoint process memory by about 62% and ordinary-object retained VM heap size by 31%. - Measure and reject a bounded owner-local hidden-class prototype. Although ideal repeated-shape objects retained 13.4% less VM heap, only 82 of 1,130 final Vue objects were wholly eligible; Vue regressed from 47.07 ms to 50.30 ms with higher reductions and no endpoint memory improvement. diff --git a/bench/README.md b/bench/README.md index 8870217a7..b8457106c 100644 --- a/bench/README.md +++ b/bench/README.md @@ -23,8 +23,8 @@ MIX_ENV=bench mix run bench/concurrent.exs # Reproduce the pinned BEAM VM SSR reports MIX_ENV=bench mix run bench/vm_ssr.exs \ --output docs/beam-ssr-measurements.md -MIX_ENV=bench mix run bench/vm_ssr.exs --shared-programs \ - --output docs/beam-shared-program-measurements.md +MIX_ENV=bench mix run bench/vm_ssr.exs --pinned-programs \ + --output docs/beam-pinned-program-measurements.md # Reproduce the retained array/object-memory measurement MIX_ENV=bench mix run bench/vm_object_memory.exs \ @@ -48,7 +48,7 @@ VM_INTERPRETER_FPROF_OUTPUT=/tmp/vue.fprof \ # Drive non-instrumented BeamAsm/perf sampling ERL_FLAGS='+S 1:1 +JPperf true' perf record -F 997 -e cycles:u -- \ - env MIX_ENV=bench mix run bench/vm_interpreter_perf.exs --mode shared + env MIX_ENV=bench mix run bench/vm_interpreter_perf.exs --mode pinned # Attribute CPU in the pinned Vue fixture COMPILER_SSR_EPROF_PROFILE=scalar_v1 COMPILER_SSR_EPROF_ITERATIONS=3 \ @@ -77,7 +77,7 @@ MIX_ENV=bench mix run bench/vm_ssr.exs \ ERL_FLAGS='+S 1:1' MIX_ENV=bench mix run bench/vm_scheduler_probe.exs \ --output docs/beam-scheduler-measurements.md ERL_FLAGS='+S 1:1' MIX_ENV=bench mix run bench/vm_scheduler_probe.exs \ - --shared-programs --output docs/beam-shared-program-scheduler-measurements.md + --pinned-programs --output docs/beam-pinned-program-scheduler-measurements.md # Reproduce the release-quarantined compiler-tier probe ERL_FLAGS='+S 1:1' MIX_ENV=bench mix run bench/vm_scheduler_probe.exs \ @@ -94,7 +94,7 @@ profiles exactly one first evaluation in a fresh Mix VM, while execution warms the profile template and generated artifact before collecting samples. The SSR and scheduler runners accept `--compiler-profile pure_v1|scalar_v1`. -The SSR and scheduler runners also accept explicit `--shared-programs` handles. +The SSR and scheduler runners also accept explicit `--pinned-programs` handles. The SSR runner accepts `--engine interpreter|compiler`, the quarantined `--compiler-regions` experiment, `--samples`, `--warmup`, and a comma-separated `--concurrency` list. It reports deterministic @@ -103,9 +103,9 @@ endpoint BEAM process observations, sequential latency, concurrent throughput, and timeout/cancellation behavior for the pinned Preact, Vue, and Svelte fixtures. Published results are in [`docs/beam-ssr-measurements.md`](../docs/beam-ssr-measurements.md), -[`docs/beam-shared-program-investigation.md`](../docs/beam-shared-program-investigation.md), -[`docs/beam-shared-program-measurements.md`](../docs/beam-shared-program-measurements.md), -[`docs/beam-shared-program-scheduler-measurements.md`](../docs/beam-shared-program-scheduler-measurements.md), +[`docs/beam-pinned-program-investigation.md`](../docs/beam-pinned-program-investigation.md), +[`docs/beam-pinned-program-measurements.md`](../docs/beam-pinned-program-measurements.md), +[`docs/beam-pinned-program-scheduler-measurements.md`](../docs/beam-pinned-program-scheduler-measurements.md), [`docs/beam-object-memory-investigation.md`](../docs/beam-object-memory-investigation.md), [`docs/beam-object-memory-measurements.md`](../docs/beam-object-memory-measurements.md), [`docs/beam-object-shape-investigation.md`](../docs/beam-object-shape-investigation.md), diff --git a/bench/vm_interpreter_perf.exs b/bench/vm_interpreter_perf.exs index f5a9f84dc..aae7d6577 100644 --- a/bench/vm_interpreter_perf.exs +++ b/bench/vm_interpreter_perf.exs @@ -10,16 +10,16 @@ defmodule QuickBEAM.Bench.VMInterpreterPerf do if positional != [] or invalid != [], do: raise(ArgumentError, "invalid arguments: #{inspect(positional ++ invalid)}") - mode = mode!(Keyword.get(opts, :mode, "shared")) + 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 == :shared do - {:ok, shared_program} = QuickBEAM.VM.share_program(decoded_program) - shared_program + if mode == :pinned do + {:ok, pinned_program} = QuickBEAM.VM.pin(decoded_program) + pinned_program else decoded_program end @@ -42,7 +42,7 @@ defmodule QuickBEAM.Bench.VMInterpreterPerf do IO.puts("mode=#{mode} iterations=#{iterations} elapsed_ms=#{elapsed_ms}") - if mode == :shared, do: QuickBEAM.VM.release_program(program) + if mode == :pinned, do: QuickBEAM.VM.unpin(program) end defp operation(:caller, program, options), @@ -80,7 +80,7 @@ defmodule QuickBEAM.Bench.VMInterpreterPerf do defp mode!("caller"), do: :caller defp mode!("copied"), do: :copied - defp mode!("shared"), do: :shared + 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 diff --git a/bench/vm_scheduler_probe.exs b/bench/vm_scheduler_probe.exs index 99ea9243a..776c0be00 100644 --- a/bench/vm_scheduler_probe.exs +++ b/bench/vm_scheduler_probe.exs @@ -30,7 +30,7 @@ defmodule QuickBEAM.Bench.VMSchedulerProbe do strict: [ engine: :string, compiler_profile: :string, - shared_programs: :boolean, + pinned_programs: :boolean, samples: :integer, output: :string ] @@ -46,9 +46,9 @@ defmodule QuickBEAM.Bench.VMSchedulerProbe do 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")) - shared_programs = Keyword.get(opts, :shared_programs, false) + pinned_programs = Keyword.get(opts, :pinned_programs, false) maybe_start_compiler!(engine) - fixture = compile_fixture!(shared_programs) + fixture = compile_fixture!(pinned_programs) Enum.each(1..2, fn _iteration -> render!(fixture, engine, compiler_profile) end) @@ -92,7 +92,7 @@ defmodule QuickBEAM.Bench.VMSchedulerProbe do report( engine, compiler_profile, - shared_programs, + pinned_programs, samples, baseline_ms, render_summary, @@ -108,14 +108,14 @@ defmodule QuickBEAM.Bench.VMSchedulerProbe do end end - defp compile_fixture!(shared_programs) do + 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 shared_programs do - {:ok, shared_program} = QuickBEAM.VM.share_program(decoded_program) - shared_program + if pinned_programs do + {:ok, pinned_program} = QuickBEAM.VM.pin(decoded_program) + pinned_program else decoded_program end @@ -219,7 +219,7 @@ defmodule QuickBEAM.Bench.VMSchedulerProbe do defp report( engine, compiler_profile, - shared_programs, + pinned_programs, samples, baseline_ms, render, @@ -240,7 +240,7 @@ defmodule QuickBEAM.Bench.VMSchedulerProbe do - Engine: #{engine} - Compiler profile: #{compiler_profile} - - Shared program handles: #{shared_programs} + - 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()} diff --git a/bench/vm_ssr.exs b/bench/vm_ssr.exs index 486ca0597..1d437e28f 100644 --- a/bench/vm_ssr.exs +++ b/bench/vm_ssr.exs @@ -16,7 +16,7 @@ defmodule QuickBEAM.Bench.VMSSR do engine: :string, compiler_profile: :string, compiler_regions: :boolean, - shared_programs: :boolean, + pinned_programs: :boolean, samples: :integer, warmup: :integer, concurrency: :string, @@ -30,7 +30,7 @@ defmodule QuickBEAM.Bench.VMSSR do 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) - shared_programs = Keyword.get(opts, :shared_programs, 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) @@ -41,7 +41,7 @@ defmodule QuickBEAM.Bench.VMSSR do fixtures = Enum.map( fixture_specs(), - &compile_fixture!(&1, engine, compiler_profile, compiler_regions, shared_programs) + &compile_fixture!(&1, engine, compiler_profile, compiler_regions, pinned_programs) ) results = @@ -63,7 +63,7 @@ defmodule QuickBEAM.Bench.VMSSR do engine, compiler_profile, compiler_regions, - shared_programs, + pinned_programs, results, isolation, samples, @@ -126,14 +126,14 @@ defmodule QuickBEAM.Bench.VMSSR do ] end - defp compile_fixture!(spec, engine, compiler_profile, compiler_regions, shared_programs) do + 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 shared_programs do - {:ok, shared_program} = QuickBEAM.VM.share_program(decoded_program) - shared_program + if pinned_programs do + {:ok, pinned_program} = QuickBEAM.VM.pin(decoded_program) + pinned_program else decoded_program end @@ -396,7 +396,7 @@ defmodule QuickBEAM.Bench.VMSSR do engine, compiler_profile, compiler_regions, - shared_programs, + pinned_programs, results, isolation, samples, @@ -441,7 +441,7 @@ defmodule QuickBEAM.Bench.VMSSR do - Engine: #{engine} - Compiler profile: #{compiler_profile} - Compiler regions: #{compiler_regions} - - Shared program handles: #{shared_programs} + - Pinned program handles: #{pinned_programs} - Git base: `#{metadata.git}` - Working tree at measurement: #{metadata.tree_state} - Generated: #{metadata.generated} diff --git a/docs/beam-interpreter-architecture.md b/docs/beam-interpreter-architecture.md index b882cdb2e..f8539bef6 100644 --- a/docs/beam-interpreter-architecture.md +++ b/docs/beam-interpreter-architecture.md @@ -27,10 +27,10 @@ feature of the stateful native QuickJS runtime. ```elixir {:ok, program} = QuickBEAM.VM.compile(bundle, filename: "server.js") -{:ok, shared_program} = QuickBEAM.VM.share_program(program) +{:ok, pinned_program} = QuickBEAM.VM.pin(program) Task.async_stream(requests, fn props -> - QuickBEAM.VM.eval(shared_program, + QuickBEAM.VM.eval(pinned_program, vars: %{"props" => props}, timeout: 250, max_steps: 5_000_000, @@ -38,10 +38,10 @@ Task.async_stream(requests, fn props -> ) end) -QuickBEAM.VM.release_program(shared_program) +QuickBEAM.VM.unpin(pinned_program) ``` -The immutable program is safe to share. The lightweight shared handle avoids +The immutable program is safe to share. The lightweight pinned handle avoids copying the decoded function graph through each request and worker process. Every evaluation owns its JavaScript heap, globals, jobs, and limits. A failed or timed-out render only terminates its @@ -87,7 +87,7 @@ The existing `QuickBEAM` runtime remains the stateful engine: `QuickBEAM.VM` is an isolated execution engine: - a `%QuickBEAM.VM.Program{}` is immutable and reusable; -- an explicit `%QuickBEAM.VM.SharedProgram{}` uses a bounded fixed-slot store to +- an explicit `%QuickBEAM.VM.PinnedProgram{}` uses a bounded fixed-slot store to avoid copying the decoded graph between request and evaluation processes; - each evaluation executes in a dedicated BEAM process by default; - the JavaScript heap is local to that process; @@ -113,11 +113,11 @@ would still be scheduled independently. @spec QuickBEAM.VM.decode(binary(), keyword()) :: {:ok, QuickBEAM.VM.Program.t()} | {:error, term()} -@spec QuickBEAM.VM.share_program(QuickBEAM.VM.Program.t()) :: - {:ok, QuickBEAM.VM.SharedProgram.t()} | {:error, term()} +@spec QuickBEAM.VM.pin(QuickBEAM.VM.Program.t()) :: + {:ok, QuickBEAM.VM.PinnedProgram.t()} | {:error, term()} @spec QuickBEAM.VM.measure( - QuickBEAM.VM.Program.t() | QuickBEAM.VM.SharedProgram.t(), + QuickBEAM.VM.Program.t() | QuickBEAM.VM.PinnedProgram.t(), keyword() ) :: {:ok, QuickBEAM.VM.Measurement.t()} | {:error, term()} ``` @@ -133,9 +133,9 @@ Suggested compile options: `compile/2` asks the vendored QuickJS compiler for serialized bytecode, decodes it, verifies it, and returns an immutable program. Compilation may use a native compiler pool, but evaluation must not require a QuickJS execution context. -`share_program/1` is an explicit bounded optimization for programs reused across +`pin/1` is an explicit bounded optimization for programs reused across processes; ordinary `%Program{}` evaluation retains process-copy semantics. See -the [shared-program investigation](beam-shared-program-investigation.md). +the [pinned-program investigation](beam-pinned-program-investigation.md). `decode/2` is an advanced API. It accepts only bytecode matching the running QuickBEAM build fingerprint. `measure/2` runs the same isolated evaluation as diff --git a/docs/beam-shared-program-investigation.md b/docs/beam-pinned-program-investigation.md similarity index 88% rename from docs/beam-shared-program-investigation.md rename to docs/beam-pinned-program-investigation.md index 17e916609..7ff0e746d 100644 --- a/docs/beam-shared-program-investigation.md +++ b/docs/beam-pinned-program-investigation.md @@ -1,4 +1,4 @@ -# Bounded shared-program investigation +# Bounded pinned-program investigation This investigation profiles the process-isolated BEAM interpreter without call tracing and addresses the largest measured endpoint cost. It applies only @@ -13,7 +13,7 @@ ERL_FLAGS='+S 1:1 +JPperf true' perf record -F 997 -e cycles:u -- \ env MIX_ENV=bench mix run bench/vm_interpreter_perf.exs --mode copied ERL_FLAGS='+S 1:1 +JPperf true' perf record -F 997 -e cycles:u -- \ - env MIX_ENV=bench mix run bench/vm_interpreter_perf.exs --mode shared + env MIX_ENV=bench mix run bench/vm_interpreter_perf.exs --mode pinned ``` `kernel.perf_event_paranoid` was lowered only for the profiling command and @@ -44,8 +44,8 @@ the old endpoint: process copying and teardown dominated it. ## Design -`QuickBEAM.VM.share_program/1` explicitly places a verified immutable program in -`QuickBEAM.VM.ProgramStore` and returns a small `%QuickBEAM.VM.SharedProgram{}` +`QuickBEAM.VM.pin/1` explicitly places a verified immutable program in +`QuickBEAM.VM.ProgramStore` and returns a small `%QuickBEAM.VM.PinnedProgram{}` handle. Evaluating the handle acquires an owner-monitored lease and fetches the program from a fixed `:persistent_term` slot before spawning the worker. BeamAsm retains that literal reference across the spawn instead of copying the graph. @@ -56,7 +56,7 @@ The store is deliberately not an automatic cache: - sharing is explicit; - the default capacity is eight and the hard maximum is 32; -- a shared serialized bytecode input is at most 2 MiB; +- a pinned serialized bytecode input is at most 2 MiB; - slots use fixed integer keys and binary program identities; - there is no input-derived atom and no implicit eviction; - concurrent first admission is single-flight; @@ -70,7 +70,7 @@ The store is deliberately not an automatic cache: Program identities include the bytecode ABI/version, bytecode digest, source digest, and filename. JavaScript heaps, globals, cells, jobs, counters, handlers, -and continuations remain evaluation-owner-local. Shared program memory is a +and continuations remain evaluation-owner-local. Pinned program memory is a bounded global immutable resource and is intentionally separate from endpoint process memory and logical JavaScript allocation. @@ -78,13 +78,13 @@ Typical use is: ```elixir {:ok, program} = QuickBEAM.VM.compile(bundle, filename: "server.js") -{:ok, shared} = QuickBEAM.VM.share_program(program) +{:ok, pinned} = QuickBEAM.VM.pin(program) Task.async_stream(requests, fn props -> - QuickBEAM.VM.eval(shared, vars: %{"props" => props}) + QuickBEAM.VM.eval(pinned, vars: %{"props" => props}) end) -QuickBEAM.VM.release_program(shared) +QuickBEAM.VM.unpin(pinned) ``` ## Admission cost @@ -107,10 +107,10 @@ request. ## Pinned SSR result The reproducible report is -[`beam-shared-program-measurements.md`](beam-shared-program-measurements.md). +[`beam-pinned-program-measurements.md`](beam-pinned-program-measurements.md). Against the prior copied-program report on the same machine: -| fixture | copied median | shared median | copied endpoint memory | shared endpoint memory | +| fixture | copied median | pinned median | copied endpoint memory | pinned endpoint memory | |---|---:|---:|---:|---:| | Preact 10.29.7 | 8.22 ms | 7.01 ms | 4.5 MiB | 257.7 KiB | | Vue 3.5.39 | 49.21 ms | 11.01 ms | 77.15 MiB | 673.3 KiB | @@ -121,7 +121,7 @@ to 684.7 renders/s with an 11.07 ms median. Exact VM steps, logical memory, rendered output, limits, cancellation, and native parity are unchanged. The published -[`beam-shared-program-scheduler-measurements.md`](beam-shared-program-scheduler-measurements.md) +[`beam-pinned-program-scheduler-measurements.md`](beam-pinned-program-scheduler-measurements.md) report measured a 4.91 ms Vue median without the SSR report's fixed 5 ms handler delay, a 2.04 ms maximum ticker gap against the 75 ms bound, and a 51.0 ms timeout p95 against the 60 ms bound. diff --git a/docs/beam-shared-program-measurements.md b/docs/beam-pinned-program-measurements.md similarity index 99% rename from docs/beam-shared-program-measurements.md rename to docs/beam-pinned-program-measurements.md index 4703edb73..31893cef2 100644 --- a/docs/beam-shared-program-measurements.md +++ b/docs/beam-pinned-program-measurements.md @@ -10,7 +10,7 @@ The single-scheduler fairness and timeout gate is published separately in [`beam - Engine: interpreter - Compiler profile: pure_v1 - Compiler regions: false -- Shared program handles: true +- Pinned program handles: true - Git base: `8f498a5a` - Working tree at measurement: modified - Generated: 2026-07-16T21:50:19Z diff --git a/docs/beam-shared-program-scheduler-measurements.md b/docs/beam-pinned-program-scheduler-measurements.md similarity index 97% rename from docs/beam-shared-program-scheduler-measurements.md rename to docs/beam-pinned-program-scheduler-measurements.md index e2dc2b91b..d47574bd7 100644 --- a/docs/beam-shared-program-scheduler-measurements.md +++ b/docs/beam-pinned-program-scheduler-measurements.md @@ -6,7 +6,7 @@ time, allowing the same ticker to run without interpreter work. - Engine: interpreter - Compiler profile: pure_v1 -- Shared program handles: true +- Pinned program handles: true - Git base: `8f498a5a` - Working tree at measurement: modified - Generated: 2026-07-16T22:01:33Z diff --git a/lib/quickbeam/vm.ex b/lib/quickbeam/vm.ex index c7854973a..6d83fabae 100644 --- a/lib/quickbeam/vm.ex +++ b/lib/quickbeam/vm.ex @@ -15,12 +15,12 @@ defmodule QuickBEAM.VM do Measurement, Program, ProgramStore, - SharedProgram, + PinnedProgram, Verifier } @type program :: QuickBEAM.VM.Program.t() - @type shared_program :: QuickBEAM.VM.SharedProgram.t() + @type pinned_program :: QuickBEAM.VM.PinnedProgram.t() @max_bytecode_bytes 16 * 1024 * 1024 @default_timeout 5_000 @@ -49,7 +49,7 @@ defmodule QuickBEAM.VM do program |> maybe_put_filename(filename) |> Map.put(:source_digest, :crypto.hash(:sha256, source)) - |> Program.put_share_key() + |> Program.put_pin_key() {:ok, program} end @@ -59,16 +59,16 @@ defmodule QuickBEAM.VM do end end - @doc "Places a verified program in bounded shared storage and returns a lightweight handle." - @spec share_program(Program.t()) :: {:ok, SharedProgram.t()} | {:error, term()} - def share_program(%Program{} = program) do - program = Program.put_share_key(program) + @doc "Pins a verified program in bounded storage and returns a lightweight handle." + @spec pin(Program.t()) :: {:ok, PinnedProgram.t()} | {:error, term()} + def pin(%Program{} = program) do + program = Program.put_pin_key(program) with :ok <- Verifier.verify(program) do - case ProgramStore.share(program) do - {:ok, shared} -> {:ok, shared} + case ProgramStore.pin(program) do + {:ok, pinned} -> {:ok, pinned} {:error, reason} -> {:error, reason} - :unavailable -> {:error, :shared_program_capacity} + :unavailable -> {:error, :pinned_program_capacity} end end end @@ -83,7 +83,7 @@ defmodule QuickBEAM.VM do :ok <- within_bytecode_limit(bytecode, max_bytecode_bytes), {:ok, program} <- Decoder.decode(bytecode), :ok <- Verifier.verify(program, verifier_options) do - {:ok, Program.put_share_key(program)} + {:ok, Program.put_pin_key(program)} end end @@ -130,16 +130,16 @@ defmodule QuickBEAM.VM do BEAM process heap ceiling. `isolation: :caller` is available for trusted diagnostics. The compiler engine requires a supervised `QuickBEAM.VM.Compiler`. """ - @spec eval(Program.t() | SharedProgram.t(), keyword()) :: {:ok, term()} | {:error, term()} + @spec eval(Program.t() | PinnedProgram.t(), keyword()) :: {:ok, term()} | {:error, term()} def eval(program, opts \\ []) - def eval(%SharedProgram{} = shared, opts) when is_list(opts) do + def eval(%PinnedProgram{} = pinned, opts) when is_list(opts) do with {:ok, options} <- evaluation_options(opts), - {:ok, lease} <- shared_lease(shared) do + {:ok, lease} <- pinned_lease(pinned) do try do case options.isolation do - :caller -> evaluate_shared_caller(lease, options) - :process -> eval_isolated_shared(lease, options) + :caller -> evaluate_pinned_caller(lease, options) + :process -> eval_isolated_pinned(lease, options) end after ProgramStore.checkin(lease) @@ -166,20 +166,20 @@ defmodule QuickBEAM.VM do `measurement.result`. Invalid programs or options are returned directly as `{:error, reason}` because no evaluation was started. """ - @spec measure(Program.t() | SharedProgram.t(), keyword()) :: + @spec measure(Program.t() | PinnedProgram.t(), keyword()) :: {:ok, Measurement.t()} | {:error, term()} def measure(program, opts \\ []) - def measure(%SharedProgram{} = shared, opts) when is_list(opts) do + def measure(%PinnedProgram{} = pinned, opts) when is_list(opts) do with {:ok, options} <- evaluation_options(opts), - {:ok, lease} <- shared_lease(shared) do + {:ok, lease} <- pinned_lease(pinned) do started = System.monotonic_time() payload = try do case options.isolation do - :caller -> measure_shared_caller(lease, options) - :process -> measure_isolated_shared(lease, options) + :caller -> measure_pinned_caller(lease, options) + :process -> measure_isolated_pinned(lease, options) end after ProgramStore.checkin(lease) @@ -312,22 +312,22 @@ defmodule QuickBEAM.VM do end end - defp shared_lease(shared) do - case ProgramStore.checkout(shared) do + defp pinned_lease(pinned) do + case ProgramStore.checkout(pinned) do {:ok, lease} -> {:ok, lease} - :unavailable -> {:error, :shared_program_unavailable} + :unavailable -> {:error, :pinned_program_unavailable} end end - defp evaluate_shared_caller(lease, options) do + defp evaluate_pinned_caller(lease, options) do with {:ok, program} <- ProgramStore.fetch(lease), :ok <- Verifier.verify_identity(program) do evaluate(program, options) end end - defp measure_shared_caller(lease, options) do - case fetch_verified_shared(lease) do + 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 @@ -335,7 +335,7 @@ defmodule QuickBEAM.VM do defp eval_isolated(program, options), do: eval_isolated_program(program, options) - defp eval_isolated_shared(lease, options) do + defp eval_isolated_pinned(lease, options) do with {:ok, program} <- ProgramStore.fetch(lease), :ok <- Verifier.verify_identity(program) do eval_isolated_program(program, options) @@ -370,14 +370,14 @@ defmodule QuickBEAM.VM do defp measure_isolated(program, options), do: measure_isolated_program(program, options) - defp measure_isolated_shared(lease, options) do - case fetch_verified_shared(lease) do + 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_shared(lease) do + defp fetch_verified_pinned(lease) do with {:ok, program} <- ProgramStore.fetch(lease), :ok <- Verifier.verify_identity(program), do: {:ok, program} @@ -438,11 +438,11 @@ defmodule QuickBEAM.VM do } end - @doc "Releases a bounded shared-program slot after its current evaluations finish." - @spec release_program(Program.t() | SharedProgram.t()) :: :ok | :not_shared - def release_program(program) - when is_struct(program, Program) or is_struct(program, SharedProgram), - do: ProgramStore.release(program) + @doc "Unpins a bounded program slot after its current evaluations finish." + @spec unpin(Program.t() | PinnedProgram.t()) :: :ok | :not_pinned + def unpin(program) + when is_struct(program, Program) or is_struct(program, PinnedProgram), + do: ProgramStore.unpin(program) @doc "Returns the monitored worker spawn options for an evaluation memory limit." def worker_spawn_options(:infinity), do: [:monitor] diff --git a/lib/quickbeam/vm/shared_program.ex b/lib/quickbeam/vm/pinned_program.ex similarity index 58% rename from lib/quickbeam/vm/shared_program.ex rename to lib/quickbeam/vm/pinned_program.ex index e5cad8aa0..7aee0d0ef 100644 --- a/lib/quickbeam/vm/shared_program.ex +++ b/lib/quickbeam/vm/pinned_program.ex @@ -1,10 +1,10 @@ -defmodule QuickBEAM.VM.SharedProgram do +defmodule QuickBEAM.VM.PinnedProgram do @moduledoc """ - A lightweight handle to a verified immutable program in bounded shared storage. + 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.ProgramStore` slot until - explicitly released with `QuickBEAM.VM.release_program/1`. + explicitly removed with `QuickBEAM.VM.unpin/1`. """ @enforce_keys [:key] diff --git a/lib/quickbeam/vm/program.ex b/lib/quickbeam/vm/program.ex index edfe8102e..a13b92c8c 100644 --- a/lib/quickbeam/vm/program.ex +++ b/lib/quickbeam/vm/program.ex @@ -4,8 +4,8 @@ defmodule QuickBEAM.VM.Program do `bytecode_digest` identifies the serialized QuickJS input. `source_digest` is also present when the public source compiler produced the program. The - binary `share_key` includes version, digest, and filename identity so bounded - shared-program storage never derives atoms from input. + binary `pin_key` includes version, digest, and filename identity so bounded + pinned storage never derives atoms from input. """ @enforce_keys [:version, :fingerprint, :atoms, :root] @@ -17,7 +17,7 @@ defmodule QuickBEAM.VM.Program do :bytecode_digest, :bytecode_size, :source_digest, - :share_key + :pin_key ] @type t :: %__MODULE__{ @@ -28,18 +28,18 @@ defmodule QuickBEAM.VM.Program do bytecode_digest: binary() | nil, bytecode_size: non_neg_integer() | nil, source_digest: binary() | nil, - share_key: binary() | nil + pin_key: binary() | nil } @doc "Derives the binary identity used by bounded immutable program sharing." - @spec put_share_key(t()) :: t() - def put_share_key(%__MODULE__{} = program) do + @spec put_pin_key(t()) :: t() + def put_pin_key(%__MODULE__{} = 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 | share_key: :crypto.hash(:sha256, :erlang.term_to_binary(identity))} + %{program | pin_key: :crypto.hash(:sha256, :erlang.term_to_binary(identity))} end end diff --git a/lib/quickbeam/vm/program_store.ex b/lib/quickbeam/vm/program_store.ex index f6ae1e747..5a7e33aa5 100644 --- a/lib/quickbeam/vm/program_store.ex +++ b/lib/quickbeam/vm/program_store.ex @@ -1,6 +1,6 @@ defmodule QuickBEAM.VM.ProgramStore.Lease do @moduledoc """ - Identifies one bounded lease on an immutable shared VM program. + 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 @@ -23,7 +23,7 @@ defmodule QuickBEAM.VM.ProgramStore do 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 `share_program/1` + evaluation process. Programs enter only through explicit `pin/1` calls. The store never derives atoms from input, holds at most `capacity` persistent terms, and does not evict programs implicitly. Programs can be explicitly released; active leases defer erasure until their workers finish. @@ -31,50 +31,50 @@ defmodule QuickBEAM.VM.ProgramStore do use GenServer - alias QuickBEAM.VM.{Program, SharedProgram, Verifier} + alias QuickBEAM.VM.{PinnedProgram, Program, Verifier} alias QuickBEAM.VM.ProgramStore.Lease @default_capacity 8 @maximum_capacity 32 - @maximum_shared_bytecode_bytes 2 * 1024 * 1024 + @maximum_pinned_bytecode_bytes 2 * 1024 * 1024 @type checkout_result :: {:ok, Lease.t()} | :unavailable - @doc "Starts the bounded shared-program store." + @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 "Stores a verified program explicitly and returns its lightweight handle." - @spec share(Program.t(), GenServer.server()) :: - {:ok, SharedProgram.t()} | :unavailable | {:error, :program_too_large} - def share(program, server \\ __MODULE__) + @doc "Pins a verified program explicitly and returns its lightweight handle." + @spec pin(Program.t(), GenServer.server()) :: + {:ok, PinnedProgram.t()} | :unavailable | {:error, :program_too_large} + def pin(program, server \\ __MODULE__) - def share( - %Program{share_key: key, bytecode_size: size} = program, + def pin( + %Program{pin_key: key, bytecode_size: size} = program, server ) - when is_binary(key) and is_integer(size) and size <= @maximum_shared_bytecode_bytes do + when is_binary(key) and is_integer(size) and size <= @maximum_pinned_bytecode_bytes do case reserve_and_install(key, program, server) do {:ok, lease} -> checkin(lease, server) - {:ok, %SharedProgram{key: key}} + {:ok, %PinnedProgram{key: key}} :unavailable -> :unavailable end end - def share(%Program{bytecode_size: size}, _server) - when is_integer(size) and size > @maximum_shared_bytecode_bytes, + def pin(%Program{bytecode_size: size}, _server) + when is_integer(size) and size > @maximum_pinned_bytecode_bytes, do: {:error, :program_too_large} - def share(%Program{}, _server), do: :unavailable + def pin(%Program{}, _server), do: :unavailable - @doc "Checks out an explicitly shared immutable program." - @spec checkout(SharedProgram.t(), GenServer.server()) :: checkout_result() - def checkout(%SharedProgram{key: key}, server \\ __MODULE__) do + @doc "Checks out an explicitly pinned immutable program." + @spec checkout(PinnedProgram.t(), GenServer.server()) :: checkout_result() + def checkout(%PinnedProgram{key: key}, server \\ __MODULE__) do if GenServer.whereis(server), do: safe_store_call(server, {:checkout_existing, key}), else: :unavailable @@ -96,21 +96,21 @@ defmodule QuickBEAM.VM.ProgramStore do :ok end - @doc "Releases a program slot, deferring erasure while leases remain active." - @spec release(Program.t() | SharedProgram.t(), GenServer.server()) :: :ok | :not_shared - def release(program, server \\ __MODULE__) + @doc "Unpins a program slot, deferring erasure while leases remain active." + @spec unpin(Program.t() | PinnedProgram.t(), GenServer.server()) :: :ok | :not_pinned + def unpin(program, server \\ __MODULE__) - def release(%SharedProgram{key: key}, server), do: release_key(key, server) + def unpin(%PinnedProgram{key: key}, server), do: unpin_key(key, server) - def release(%Program{share_key: key}, server) when is_binary(key), - do: release_key(key, server) + def unpin(%Program{pin_key: key}, server) when is_binary(key), + do: unpin_key(key, server) - def release(%Program{}, _server), do: :not_shared + def unpin(%Program{}, _server), do: :not_pinned - defp release_key(key, server) do - case GenServer.whereis(server) && safe_store_call(server, {:release, key}) do + defp unpin_key(key, server) do + case GenServer.whereis(server) && safe_store_call(server, {:unpin, key}) do :ok -> :ok - _unavailable -> :not_shared + _unavailable -> :not_pinned end end @@ -178,17 +178,17 @@ defmodule QuickBEAM.VM.ProgramStore do end end - def handle_call({:release, key}, _from, state) do + 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 | release?: true})} + {:reply, :ok, put_in(state.entries[key], %{entry | unpin?: true})} _entries -> - {:reply, :not_shared, state} + {:reply, :not_pinned, state} end end @@ -288,7 +288,7 @@ defmodule QuickBEAM.VM.ProgramStore do defp restore_program_slot(program, key, token, slot, entries, slots) do if Verifier.verify_identity(program) == :ok do - entry = %{key: key, slot: slot, token: token, leases: %{}, release?: false} + entry = %{key: key, slot: slot, token: token, leases: %{}, unpin?: false} {Map.put(entries, key, entry), Map.put(slots, slot, key)} else :persistent_term.erase(storage_key(slot)) @@ -304,7 +304,7 @@ defmodule QuickBEAM.VM.ProgramStore do slot: pending.slot, token: token, leases: %{}, - release?: false + unpin?: false } {lease, entry} = grant_lease(key, entry, {owner, nil}) @@ -397,7 +397,7 @@ defmodule QuickBEAM.VM.ProgramStore do end defp maybe_release_entry(state, key, entry) do - if map_size(entry.leases) == 0 and entry.release? 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 diff --git a/lib/quickbeam/vm/verifier.ex b/lib/quickbeam/vm/verifier.ex index 68b357990..4da01225b 100644 --- a/lib/quickbeam/vm/verifier.ex +++ b/lib/quickbeam/vm/verifier.ex @@ -34,7 +34,7 @@ defmodule QuickBEAM.VM.Verifier do def verify(_program, _opts), do: {:error, :invalid_program} - @doc "Checks the constant-time ABI identity of an already verified shared 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} diff --git a/mix.exs b/mix.exs index 49002076d..14d2a8d00 100644 --- a/mix.exs +++ b/mix.exs @@ -109,9 +109,9 @@ defmodule QuickBEAM.MixProject do "docs/architecture.md", "docs/beam-interpreter-architecture.md", "docs/beam-interpreter-hotspot-investigation.md", - "docs/beam-shared-program-investigation.md", - "docs/beam-shared-program-measurements.md", - "docs/beam-shared-program-scheduler-measurements.md", + "docs/beam-pinned-program-investigation.md", + "docs/beam-pinned-program-measurements.md", + "docs/beam-pinned-program-scheduler-measurements.md", "docs/beam-compiler-contract.md", "docs/beam-compiler-performance-measurements.md", "docs/beam-compiler-scheduler-measurements.md", @@ -130,9 +130,9 @@ defmodule QuickBEAM.MixProject do "docs/architecture.md", "docs/beam-interpreter-architecture.md", "docs/beam-interpreter-hotspot-investigation.md", - "docs/beam-shared-program-investigation.md", - "docs/beam-shared-program-measurements.md", - "docs/beam-shared-program-scheduler-measurements.md", + "docs/beam-pinned-program-investigation.md", + "docs/beam-pinned-program-measurements.md", + "docs/beam-pinned-program-scheduler-measurements.md", "docs/beam-object-memory-investigation.md", "docs/beam-object-memory-measurements.md", "docs/beam-object-shape-investigation.md", @@ -190,7 +190,7 @@ defmodule QuickBEAM.MixProject do QuickBEAM.VM.Function, QuickBEAM.VM.Measurement, QuickBEAM.VM.Program, - QuickBEAM.VM.SharedProgram, + QuickBEAM.VM.PinnedProgram, QuickBEAM.VM.SourcePosition, QuickBEAM.VM.Variable ] diff --git a/test/vm/program_store_test.exs b/test/vm/program_store_test.exs index ddd2fc455..e9eb1430b 100644 --- a/test/vm/program_store_test.exs +++ b/test/vm/program_store_test.exs @@ -4,16 +4,16 @@ defmodule QuickBEAM.VM.ProgramStoreTest do alias QuickBEAM.VM.Program alias QuickBEAM.VM.ProgramStore - test "shares one immutable program under concurrent bounded leases" do + test "pins one immutable program under concurrent bounded leases" do program = program(:crypto.strong_rand_bytes(32)) - assert {:ok, shared} = ProgramStore.share(program) + assert {:ok, pinned} = ProgramStore.pin(program) parent = self() tasks = Enum.map(1..20, fn _index -> Task.async(fn -> - {:ok, lease} = ProgramStore.checkout(shared) + {:ok, lease} = ProgramStore.checkout(pinned) send(parent, {:lease, lease}) receive do @@ -31,18 +31,18 @@ defmodule QuickBEAM.VM.ProgramStoreTest do assert Enum.uniq_by(leases, &{&1.slot, &1.token}) |> length() == 1 assert Enum.all?(leases, fn lease -> ProgramStore.fetch(lease) == {:ok, program} end) - assert :ok = ProgramStore.release(shared) + assert :ok = ProgramStore.unpin(pinned) assert Enum.all?(leases, fn lease -> ProgramStore.fetch(lease) == {:ok, program} end) Enum.each(tasks, &send(&1.pid, :finish)) Enum.each(tasks, &Task.await/1) - assert eventually(fn -> ProgramStore.release(shared) == :not_shared end) + assert eventually(fn -> ProgramStore.unpin(pinned) == :not_pinned end) assert Enum.all?(leases, fn lease -> ProgramStore.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, shared} = ProgramStore.share(program) + assert {:ok, pinned} = ProgramStore.pin(program) old_store = Process.whereis(ProgramStore) monitor = Process.monitor(old_store) @@ -50,26 +50,26 @@ defmodule QuickBEAM.VM.ProgramStoreTest do assert_receive {:DOWN, ^monitor, :process, ^old_store, :killed} assert eventually(fn -> is_pid(Process.whereis(ProgramStore)) end) - assert {:ok, lease} = ProgramStore.checkout(shared) + assert {:ok, lease} = ProgramStore.checkout(pinned) assert {:ok, ^program} = ProgramStore.fetch(lease) ProgramStore.checkin(lease) - assert :ok = ProgramStore.release(shared) + assert :ok = ProgramStore.unpin(pinned) end - test "rejects programs above the bounded shared bytecode size" do + 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} = ProgramStore.share(program) - assert :not_shared = ProgramStore.release(program) + assert {:error, :program_too_large} = ProgramStore.pin(program) + assert :not_pinned = ProgramStore.unpin(program) end - test "released handles fail explicitly instead of copying or falling back" do + test "unpinned handles fail explicitly instead of copying or falling back" do assert {:ok, program} = QuickBEAM.VM.compile("1 + 1") - assert {:ok, shared} = QuickBEAM.VM.share_program(program) - assert :ok = QuickBEAM.VM.release_program(shared) - assert {:error, :shared_program_unavailable} = QuickBEAM.VM.eval(shared) + assert {:ok, pinned} = QuickBEAM.VM.pin(program) + assert :ok = QuickBEAM.VM.unpin(pinned) + assert {:error, :pinned_program_unavailable} = QuickBEAM.VM.eval(pinned) end - test "share identity includes source and filename identity" do + test "pin identity includes source and filename identity" do base = %Program{ version: 26, fingerprint: "abi", @@ -80,16 +80,16 @@ defmodule QuickBEAM.VM.ProgramStoreTest do source_digest: :crypto.strong_rand_bytes(32) } - first = Program.put_share_key(base) + first = Program.put_pin_key(base) second = - base |> put_in([Access.key(:root), :filename], "second.js") |> Program.put_share_key() + base |> put_in([Access.key(:root), :filename], "second.js") |> Program.put_pin_key() changed_source = - %{base | source_digest: :crypto.strong_rand_bytes(32)} |> Program.put_share_key() + %{base | source_digest: :crypto.strong_rand_bytes(32)} |> Program.put_pin_key() - refute first.share_key == second.share_key - refute first.share_key == changed_source.share_key + refute first.pin_key == second.pin_key + refute first.pin_key == changed_source.pin_key end defp eventually(fun, attempts \\ 20) @@ -113,7 +113,7 @@ defmodule QuickBEAM.VM.ProgramStoreTest do root: %{filename: "test.js"}, bytecode_digest: key, bytecode_size: 100_000, - share_key: key + pin_key: key } end end diff --git a/test/vm/vue_ssr_test.exs b/test/vm/vue_ssr_test.exs index 53b7b99d9..ad91f63c5 100644 --- a/test/vm/vue_ssr_test.exs +++ b/test/vm/vue_ssr_test.exs @@ -26,9 +26,9 @@ defmodule QuickBEAM.VM.VueSSRTest 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, shared_program} = QuickBEAM.VM.share_program(program) - on_exit(fn -> QuickBEAM.VM.release_program(shared_program) end) - {:ok, source: source, program: program, shared_program: shared_program} + 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 @@ -40,7 +40,7 @@ defmodule QuickBEAM.VM.VueSSRTest do test "matches the vendored native QuickJS renderer", %{ program: program, - shared_program: shared_program, + pinned_program: pinned_program, source: source } do props = props("Native parity", 2) @@ -65,16 +65,16 @@ defmodule QuickBEAM.VM.VueSSRTest do @eval_opts ) - assert {:ok, shared_compiler_html} = + assert {:ok, pinned_compiler_html} = QuickBEAM.VM.eval( - shared_program, + pinned_program, [engine: :compiler, handlers: handlers] ++ @eval_opts ) assert beam_html == native_html assert compiler_html == native_html assert scalar_html == native_html - assert shared_compiler_html == native_html + assert pinned_compiler_html == native_html stats = ModulePool.stats(ModulePool) assert stats.counts.ready >= 1 assert stats.skips >= 1 @@ -83,8 +83,8 @@ defmodule QuickBEAM.VM.VueSSRTest do end end - test "shares one lightweight program handle across isolated concurrent Vue renders", %{ - shared_program: program + test "pins one lightweight program handle across isolated concurrent Vue renders", %{ + pinned_program: program } do tasks = for id <- 1..8 do From 57e87e2891c5ad62e34dd5404c0954c70dad126d Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 17 Jul 2026 12:07:24 +0200 Subject: [PATCH 71/87] Harden pinned program residency --- CHANGELOG.md | 2 +- docs/beam-interpreter-architecture.md | 29 ++--- docs/beam-pinned-program-investigation.md | 21 ++-- docs/test262-conformance.md | 6 +- lib/quickbeam/vm/program_store.ex | 125 ++++++++++++++++------ test/support/test262.ex | 20 +++- test/vm/program_store_test.exs | 87 +++++++++++++++ test/vm/test262_test.exs | 40 +++++-- 8 files changed, 267 insertions(+), 63 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cca29f97a..c5a319f58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased -- Add explicit bounded immutable program pinning with lightweight `QuickBEAM.VM.PinnedProgram` handles, eight fixed persistent slots, binary identities, single-flight admission, owner-monitored leases, deferred unpinning, restart restoration, and copied-program semantics for ordinary programs. Pinned Vue SSR improves from a 49.21 ms median and 77.15 MiB endpoint process memory to 11.01 ms and 673.3 KiB while preserving exact steps, logical allocation, limits, cancellation, concurrency, and scheduler gates. +- Add explicit bounded immutable program pinning with lightweight `QuickBEAM.VM.PinnedProgram` handles, eight fixed persistent slots, binary identities, single-flight admission, owner-monitored leases, deferred unpinning, restart restoration, 32 MiB per-program and 128 MiB total decoded-term admission bounds, and copied-program semantics for ordinary programs. Pinned Vue SSR improves from a 49.21 ms median and 77.15 MiB endpoint process memory to 11.01 ms and 673.3 KiB while preserving exact steps, logical allocation, limits, cancellation, concurrency, and scheduler gates. - Define the optional BEAM compiler contract with binary artifact identities, a fixed 32-module atom pool, lease/purge/cache lifecycle, versioned runtime ABI boundaries, and validated owner-local before-instruction deoptimization states. Add its supervised module pool, minimal canonical runtime ABI, structured generated-module backend, bounded v26 CFG analysis, specialized fixed-name `:pure_v1` forms, selective bounded nested-function re-entry, guarded opt-in `:scalar_v1` loop/property/global lowering, explicit call actions, exact shared program artifact namespaces, warm artifact/negative-decision caches, and release-quarantined `engine: :compiler` orchestration with native, Preact/Vue/Svelte SSR, selected Test262, resource, concurrency, measurement, and single-scheduler acceptance coverage. - Remove integer-index keys from redundant object insertion-order lists, bulk-build literal arrays in one heap update, and encode default data descriptors compactly in the existing property map. Exact logical accounting is preserved; paired retained fixtures reduce array endpoint process memory by about 62% and ordinary-object retained VM heap size by 31%. - Measure and reject a bounded owner-local hidden-class prototype. Although ideal repeated-shape objects retained 13.4% less VM heap, only 82 of 1,130 final Vue objects were wholly eligible; Vue regressed from 47.07 ms to 50.30 ms with higher reductions and no endpoint memory improvement. diff --git a/docs/beam-interpreter-architecture.md b/docs/beam-interpreter-architecture.md index f8539bef6..efd43d739 100644 --- a/docs/beam-interpreter-architecture.md +++ b/docs/beam-interpreter-architecture.md @@ -146,10 +146,10 @@ end-to-end wall time. ### Evaluate ```elixir -@spec QuickBEAM.VM.eval(QuickBEAM.VM.Program.t(), keyword()) :: - {:ok, term()} | {:error, QuickBEAM.JS.Error.t() | term()} - -@spec QuickBEAM.VM.eval!(QuickBEAM.VM.Program.t(), keyword()) :: term() +@spec QuickBEAM.VM.eval( + QuickBEAM.VM.Program.t() | QuickBEAM.VM.PinnedProgram.t(), + keyword() + ) :: {:ok, term()} | {:error, QuickBEAM.JS.Error.t() | term()} ``` Suggested evaluation options: @@ -170,19 +170,22 @@ failure and timeout boundary. ### Preload in a supervision tree -Applications should be able to compile a program once during startup and keep -it in their own process state. A later convenience cache may expose: +Applications compile and pin long-lived bundles during startup, retain the +lightweight handle in their own supervised state, and unpin it during bundle +replacement or shutdown: ```elixir -children = [ - {QuickBEAM.VM.CompilerPool, name: MyApp.JSCompiler, size: 2}, - {MyApp.Renderer, source: "priv/server.js"} -] +{:ok, source} = File.read("priv/server.js") +{:ok, program} = QuickBEAM.VM.compile(source, filename: "server.js") +{:ok, pinned} = QuickBEAM.VM.pin(program) + +# Store `pinned` in the renderer process state. +# QuickBEAM.VM.unpin(pinned) during terminate/replacement. ``` -The first release does not need a global program registry. Ordinary immutable -Elixir data is already shareable, and application ownership avoids hidden cache -lifecycle rules. +The registry is explicit and bounded rather than a transparent cache. It has no +implicit eviction or copied-program fallback, and active owner-monitored leases +defer unpinning until current evaluations finish. ## Program artifact diff --git a/docs/beam-pinned-program-investigation.md b/docs/beam-pinned-program-investigation.md index 7ff0e746d..e47915595 100644 --- a/docs/beam-pinned-program-investigation.md +++ b/docs/beam-pinned-program-investigation.md @@ -57,9 +57,11 @@ The store is deliberately not an automatic cache: - sharing is explicit; - the default capacity is eight and the hard maximum is 32; - a pinned serialized bytecode input is at most 2 MiB; +- the supported external-term size is at most 32 MiB per decoded program and + 128 MiB across installed and in-flight admissions; - slots use fixed integer keys and binary program identities; - there is no input-derived atom and no implicit eviction; -- concurrent first admission is single-flight; +- concurrent first admission is single-flight and idempotent by program identity; - every lease has a unique reference and owner monitor; - owner death returns its lease; - explicit release waits for active leases and then erases the slot; @@ -90,9 +92,10 @@ QuickBEAM.VM.unpin(pinned) ## Admission cost Sharing is an initialization operation, not part of the warm-render numbers. -Three fresh-VM Vue admissions took 11.307, 14.298, and 10.995 ms (11.307 ms -median). Admission reserves a slot through the store but installs the persistent -term in the caller, avoiding a giant GenServer message. The program retained +After adding the decoded-term residency check, three fresh-VM Vue admissions +took 17.317, 22.615, and 18.448 ms (18.448 ms median). Admission reserves a slot +through the store but measures and installs the persistent term in the caller, +avoiding a giant GenServer message. The program retained about 310,582 words (approximately 2.37 MiB) in the persistent slot according to the unsupported ERTS debug size diagnostic, while its process-copy flat size was 7,370,007 words (approximately 56.23 MiB). @@ -100,9 +103,13 @@ The handle's external encoding was 95 bytes. The store process itself retained only 3,088 bytes because program terms never enter its mailbox or state. These are OTP implementation observations, not logical JavaScript memory or a -stable public size API. Applications should share long-lived bundles during -startup and release them during replacement or shutdown, not churn slots per -request. +stable public size API. Admission uses the supported external-term size as a +conservative bound; Preact, Vue, and Svelte measured 466,461, 10,947,914, and +2,155,557 bytes respectively. It deliberately counts repeated references rather +than relying on retained sharing diagnostics. Applications should pin long-lived +bundles through one lifecycle owner during startup and unpin them during +replacement or shutdown, not churn slots per request. Repeated pins of the same +identity return the same global handle rather than independent ownership counts. ## Pinned SSR result diff --git a/docs/test262-conformance.md b/docs/test262-conformance.md index eb9992441..f5a4749f1 100644 --- a/docs/test262-conformance.md +++ b/docs/test262-conformance.md @@ -43,9 +43,9 @@ external corpus gate is reported as skipped. ## Classification -Each selected path is run in a fresh native QuickJS runtime, the isolated BEAM -interpreter, and release-quarantined `:pure_v1` and `:scalar_v1` compiler-tier -evaluations. Results are +Each selected path is run in a fresh native QuickJS runtime and through six BEAM +gates: copied and pinned interpreter evaluation, plus copied and pinned +release-quarantined `:pure_v1` and `:scalar_v1` compiler evaluation. Results are classified as: - `pass` — native QuickJS and the selected BEAM tier satisfy the expectation; diff --git a/lib/quickbeam/vm/program_store.ex b/lib/quickbeam/vm/program_store.ex index 5a7e33aa5..9f4836fda 100644 --- a/lib/quickbeam/vm/program_store.ex +++ b/lib/quickbeam/vm/program_store.ex @@ -25,7 +25,8 @@ defmodule QuickBEAM.VM.ProgramStore do 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, and does not evict programs implicitly. Programs can be + 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. """ @@ -37,6 +38,8 @@ defmodule QuickBEAM.VM.ProgramStore do @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." @@ -48,7 +51,9 @@ defmodule QuickBEAM.VM.ProgramStore do @doc "Pins a verified program explicitly and returns its lightweight handle." @spec pin(Program.t(), GenServer.server()) :: - {:ok, PinnedProgram.t()} | :unavailable | {:error, :program_too_large} + {:ok, PinnedProgram.t()} + | :unavailable + | {:error, :program_too_large | :residency_budget} def pin(program, server \\ __MODULE__) def pin( @@ -56,13 +61,19 @@ defmodule QuickBEAM.VM.ProgramStore do server ) when is_binary(key) and is_integer(size) and size <= @maximum_pinned_bytecode_bytes do - case reserve_and_install(key, program, server) do - {:ok, lease} -> - checkin(lease, server) - {:ok, %PinnedProgram{key: key}} + 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, %PinnedProgram{key: key}} + + result -> + result + end - :unavailable -> - :unavailable + _oversized_or_invalid -> + {:error, :program_too_large} end end @@ -121,14 +132,15 @@ defmodule QuickBEAM.VM.ProgramStore do if not is_integer(capacity) or capacity <= 0 or capacity > @maximum_capacity do {:stop, {:invalid_capacity, capacity}} else - {entries, slots} = restore_slots(capacity) + {entries, slots, residency_bytes} = restore_slots(capacity) {:ok, %{ capacity: capacity, entries: entries, slots: slots, - pending: %{} + pending: %{}, + residency_bytes: residency_bytes }} end end @@ -145,14 +157,14 @@ defmodule QuickBEAM.VM.ProgramStore do end end - def handle_call({:reserve, key}, from, state) do + def handle_call({:reserve, key, residency_bytes}, from, state) do case state.entries do %{^key => entry} -> {lease, entry} = grant_lease(key, entry, from) {:reply, {:ok, lease}, put_in(state.entries[key], entry)} _entries -> - reserve_missing(key, from, state) + reserve_missing(key, residency_bytes, from, state) end end @@ -223,9 +235,9 @@ defmodule QuickBEAM.VM.ProgramStore do end end - defp reserve_and_install(key, program, server) do + defp reserve_and_install(key, program, residency_bytes, server) do if GenServer.whereis(server) do - case safe_store_call(server, {:reserve, key}) do + case safe_store_call(server, {:reserve, key, residency_bytes}) do {:install, token, slot} -> install_reserved(server, key, token, slot, program) result -> result end @@ -234,18 +246,21 @@ defmodule QuickBEAM.VM.ProgramStore do end end - defp reserve_missing(key, from, state) do + 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) do - nil -> + case {free_slot(state), residency_available?(state, residency_bytes)} do + {nil, _available?} -> {:reply, :unavailable, state} - slot -> + {_slot, false} -> + {:reply, {:error, :residency_budget}, state} + + {slot, true} -> owner = elem(from, 0) token = make_ref() @@ -254,7 +269,8 @@ defmodule QuickBEAM.VM.ProgramStore do token: token, owner: owner, monitor: Process.monitor(owner), - waiters: [] + waiters: [], + residency_bytes: residency_bytes } {:reply, {:install, token, slot}, put_in(state.pending[key], pending)} @@ -270,29 +286,65 @@ defmodule QuickBEAM.VM.ProgramStore do 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), {%{}, %{}}, fn slot, {entries, slots} -> + 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) + restore_program_slot( + program, + key, + token, + slot, + entries, + slots, + residency_bytes + ) :missing -> - {entries, slots} + {entries, slots, residency_bytes} _other -> :persistent_term.erase(storage_key(slot)) - {entries, slots} + {entries, slots, residency_bytes} end end) end - defp restore_program_slot(program, key, token, slot, entries, slots) do - if Verifier.verify_identity(program) == :ok do - entry = %{key: key, slot: slot, token: token, leases: %{}, unpin?: false} - {Map.put(entries, key, entry), Map.put(slots, slot, key)} + 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 - :persistent_term.erase(storage_key(slot)) - {entries, slots} + _invalid_or_oversized -> + :persistent_term.erase(storage_key(slot)) + {entries, slots, total_bytes} end end @@ -304,7 +356,8 @@ defmodule QuickBEAM.VM.ProgramStore do slot: pending.slot, token: token, leases: %{}, - unpin?: false + unpin?: false, + residency_bytes: pending.residency_bytes } {lease, entry} = grant_lease(key, entry, {owner, nil}) @@ -320,7 +373,8 @@ defmodule QuickBEAM.VM.ProgramStore do state | entries: Map.put(state.entries, key, entry), slots: Map.put(state.slots, pending.slot, key), - pending: Map.delete(state.pending, key) + pending: Map.delete(state.pending, key), + residency_bytes: state.residency_bytes + pending.residency_bytes } {:reply, {:ok, lease}, state} @@ -406,7 +460,14 @@ defmodule QuickBEAM.VM.ProgramStore do end defp remove_entry(state, key, slot) do - %{state | entries: Map.delete(state.entries, key), slots: Map.delete(state.slots, slot)} + 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 diff --git a/test/support/test262.ex b/test/support/test262.ex index 143aacb1b..4baeb1a6d 100644 --- a/test/support/test262.ex +++ b/test/support/test262.ex @@ -169,10 +169,11 @@ defmodule QuickBEAM.Test262 do 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 = QuickBEAM.VM.eval(program, engine: engine, compiler_profile: compiler_profile) + result = eval_program(program, engine, compiler_profile, pinned_programs) classify_execution(result, negative, :runtime) {:error, error} -> @@ -180,6 +181,23 @@ defmodule QuickBEAM.Test262 do end end + defp eval_program(program, engine, compiler_profile, false), + do: QuickBEAM.VM.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 + QuickBEAM.VM.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} -> diff --git a/test/vm/program_store_test.exs b/test/vm/program_store_test.exs index e9eb1430b..e0120e243 100644 --- a/test/vm/program_store_test.exs +++ b/test/vm/program_store_test.exs @@ -4,6 +4,26 @@ defmodule QuickBEAM.VM.ProgramStoreTest do alias QuickBEAM.VM.Program alias QuickBEAM.VM.ProgramStore + 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 -> ProgramStore.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} = ProgramStore.checkout(handle) + assert {:ok, ^program} = ProgramStore.fetch(lease) + ProgramStore.checkin(lease) + assert :ok = ProgramStore.unpin(handle) + assert eventually(fn -> ProgramStore.unpin(handle) == :not_pinned end) + end + test "pins one immutable program under concurrent bounded leases" do program = program(:crypto.strong_rand_bytes(32)) @@ -62,6 +82,73 @@ defmodule QuickBEAM.VM.ProgramStoreTest do assert :not_pinned = ProgramStore.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} = ProgramStore.pin(program) + assert :not_pinned = ProgramStore.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} = ProgramStore.pin(candidate) + pinned + end) + + on_exit(fn -> Enum.each(pinned, &ProgramStore.unpin/1) end) + + candidate = %{program(:crypto.strong_rand_bytes(32)) | root: payload} + assert {:error, :residency_budget} = ProgramStore.pin(candidate) + end + + test "rejects a ninth pinned program without evicting fixed slots" do + pinned = + Enum.map(1..8, fn _index -> + assert {:ok, pinned} = ProgramStore.pin(program(:crypto.strong_rand_bytes(32))) + pinned + end) + + on_exit(fn -> Enum.each(pinned, &ProgramStore.unpin/1) end) + + ninth = program(:crypto.strong_rand_bytes(32)) + assert :unavailable = ProgramStore.pin(ninth) + + leases = + Enum.map(pinned, fn handle -> + assert {:ok, lease} = ProgramStore.checkout(handle) + lease + end) + + Enum.each(leases, &ProgramStore.checkin/1) + end + + test "owner death returns a lease and completes deferred unpinning" do + program = program(:crypto.strong_rand_bytes(32)) + assert {:ok, pinned} = ProgramStore.pin(program) + parent = self() + + {owner, monitor} = + spawn_monitor(fn -> + {:ok, lease} = ProgramStore.checkout(pinned) + send(parent, {:leased, lease}) + Process.sleep(:infinity) + end) + + assert_receive {:leased, lease} + assert :ok = ProgramStore.unpin(pinned) + assert {:ok, ^program} = ProgramStore.fetch(lease) + Process.exit(owner, :kill) + assert_receive {:DOWN, ^monitor, :process, ^owner, :killed} + + assert eventually(fn -> ProgramStore.unpin(pinned) == :not_pinned end) + assert {:error, :stale_lease} = ProgramStore.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) diff --git a/test/vm/test262_test.exs b/test/vm/test262_test.exs index 604c8f145..c35d002e2 100644 --- a/test/vm/test262_test.exs +++ b/test/vm/test262_test.exs @@ -101,12 +101,28 @@ defmodule QuickBEAM.VM.Test262Test do assert_compiler_manifest(:scalar_v1) end - defp assert_compiler_manifest(profile) do - results = - QuickBEAM.Test262.run_manifest(@root, @manifest, - engine: :compiler, - compiler_profile: profile - ) + @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) @@ -152,5 +168,17 @@ defmodule QuickBEAM.VM.Test262Test do @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 From 3155f4101f53d33258e01fcdcbaaf5d74bc2e75f Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 17 Jul 2026 12:19:35 +0200 Subject: [PATCH 72/87] Prepare BEAM VM release candidate --- .github/workflows/ci.yml | 14 ++++++++ CHANGELOG.md | 2 +- README.md | 34 ++++++++++++++++++ examples/vm_ssr.exs | 69 ++++++++++++++++++++++++++++++++++++ lib/quickbeam/application.ex | 7 +++- lib/quickbeam/vm.ex | 27 +++++++++----- mix.exs | 3 +- 7 files changed, 145 insertions(+), 11 deletions(-) create mode 100644 examples/vm_ssr.exs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 374e4a1de..76813814c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,6 +61,20 @@ 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 + + - name: Supervised pinned SSR example + run: mix run examples/vm_ssr.exs + ubsan: name: UBSan + Zig Debug runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index c5a319f58..3c773d2f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased -- Add explicit bounded immutable program pinning with lightweight `QuickBEAM.VM.PinnedProgram` handles, eight fixed persistent slots, binary identities, single-flight admission, owner-monitored leases, deferred unpinning, restart restoration, 32 MiB per-program and 128 MiB total decoded-term admission bounds, and copied-program semantics for ordinary programs. Pinned Vue SSR improves from a 49.21 ms median and 77.15 MiB endpoint process memory to 11.01 ms and 673.3 KiB while preserving exact steps, logical allocation, limits, cancellation, concurrency, and scheduler gates. +- Add explicit bounded immutable program pinning with lightweight `QuickBEAM.VM.PinnedProgram` handles, eight fixed persistent slots, binary identities, single-flight admission, owner-monitored leases, deferred unpinning, restart restoration, 32 MiB per-program and 128 MiB total decoded-term admission bounds, and copied-program semantics for ordinary programs. Pinned Vue SSR improves from a 49.21 ms median and 77.15 MiB endpoint process memory to 11.01 ms and 673.3 KiB while preserving exact steps, logical allocation, limits, cancellation, concurrency, and scheduler gates. Copied and pinned interpreter, `:pure_v1`, and `:scalar_v1` Test262 gates plus a runnable supervised pinned-SSR lifecycle example are included. - Define the optional BEAM compiler contract with binary artifact identities, a fixed 32-module atom pool, lease/purge/cache lifecycle, versioned runtime ABI boundaries, and validated owner-local before-instruction deoptimization states. Add its supervised module pool, minimal canonical runtime ABI, structured generated-module backend, bounded v26 CFG analysis, specialized fixed-name `:pure_v1` forms, selective bounded nested-function re-entry, guarded opt-in `:scalar_v1` loop/property/global lowering, explicit call actions, exact shared program artifact namespaces, warm artifact/negative-decision caches, and release-quarantined `engine: :compiler` orchestration with native, Preact/Vue/Svelte SSR, selected Test262, resource, concurrency, measurement, and single-scheduler acceptance coverage. - Remove integer-index keys from redundant object insertion-order lists, bulk-build literal arrays in one heap update, and encode default data descriptors compactly in the existing property map. Exact logical accounting is preserved; paired retained fixtures reduce array endpoint process memory by about 62% and ordinary-object retained VM heap size by 31%. - Measure and reject a bounded owner-local hidden-class prototype. Although ideal repeated-shape objects retained 13.4% less VM heap, only 82 of 1,130 final Vue objects were wholly eligible; Vue regressed from 47.07 ms to 50.30 ms with higher reductions and no endpoint memory improvement. diff --git a/README.md b/README.md index a15634693..8298dccd2 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,40 @@ 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.eval(pinned, + 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. See the runnable +[`examples/vm_ssr.exs`](examples/vm_ssr.exs) and the +[BEAM interpreter architecture](docs/beam-interpreter-architecture.md). + +The optional `engine: :compiler` tiers remain explicitly supervised and +release-quarantined; the interpreter is the production default. + ## BEAM integration JS can call Elixir functions and access OTP libraries: diff --git a/examples/vm_ssr.exs b/examples/vm_ssr.exs new file mode 100644 index 000000000..19458c24e --- /dev/null +++ b/examples/vm_ssr.exs @@ -0,0 +1,69 @@ +defmodule QuickBEAM.Examples.VMRenderer do + @moduledoc """ + Owns the explicit lifecycle of one immutable pinned SSR bundle. + """ + + use GenServer + + @source """ + globalThis.__quickbeamSSRResult = (async function render() { + const props = await Beam.call("load_props"); + const items = props.products + .map((product) => `
  • ${product.name}
  • `) + .join(""); + + return `

    ${props.title}

      ${items}
    `; + })(); + + globalThis.__quickbeamSSRResult; + """ + + def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__) + + @doc "Returns the lightweight handle shared by isolated request processes." + def pinned_program(server \\ __MODULE__), do: GenServer.call(server, :pinned_program) + + @impl true + def init(_opts) do + with {:ok, program} <- QuickBEAM.VM.compile(@source, filename: "vm_ssr.js"), + {:ok, pinned} <- QuickBEAM.VM.pin(program) do + {:ok, pinned} + end + end + + @impl true + def handle_call(:pinned_program, _from, pinned), do: {:reply, pinned, pinned} + + @impl true + def terminate(_reason, pinned) do + QuickBEAM.VM.unpin(pinned) + :ok + end +end + +children = [QuickBEAM.Examples.VMRenderer] +{:ok, supervisor} = Supervisor.start_link(children, strategy: :one_for_one) +pinned = QuickBEAM.Examples.VMRenderer.pinned_program() + +renders = + 1..8 + |> Enum.map(fn id -> + Task.async(fn -> + props = %{ + "title" => "Catalog #{id}", + "products" => [%{"id" => id, "name" => "Product #{id}"}] + } + + QuickBEAM.VM.eval(pinned, + profile: :ssr, + handlers: %{"load_props" => fn [] -> props end}, + max_steps: 100_000, + memory_limit: 16 * 1024 * 1024, + timeout: 2_000 + ) + end) + end) + |> Task.await_many(5_000) + +Enum.each(renders, fn {:ok, html} -> IO.puts(html) end) +Supervisor.stop(supervisor) diff --git a/lib/quickbeam/application.ex b/lib/quickbeam/application.ex index 89414b0e6..70b9dc59e 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 diff --git a/lib/quickbeam/vm.ex b/lib/quickbeam/vm.ex index 6d83fabae..34f94718a 100644 --- a/lib/quickbeam/vm.ex +++ b/lib/quickbeam/vm.ex @@ -31,8 +31,8 @@ defmodule QuickBEAM.VM do Compiles JavaScript with the vendored QuickJS compiler and returns a verified immutable program. - A bare temporary native runtime is used until the dedicated compiler pool is - introduced. Use `decode/2` when bytecode has already been compiled. + Compilation uses a short-lived bare native runtime. Use `decode/2` when + bytecode has already been compiled by this exact QuickJS build. """ @spec compile(String.t(), keyword()) :: {:ok, program()} | {:error, term()} def compile(source, opts \\ []) when is_binary(source) and is_list(opts) do @@ -59,7 +59,14 @@ defmodule QuickBEAM.VM do end end - @doc "Pins a verified program in bounded storage and returns a lightweight handle." + @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. + """ @spec pin(Program.t()) :: {:ok, PinnedProgram.t()} | {:error, term()} def pin(%Program{} = program) do program = Program.put_pin_key(program) @@ -438,11 +445,15 @@ defmodule QuickBEAM.VM do } end - @doc "Unpins a bounded program slot after its current evaluations finish." - @spec unpin(Program.t() | PinnedProgram.t()) :: :ok | :not_pinned - def unpin(program) - when is_struct(program, Program) or is_struct(program, PinnedProgram), - do: ProgramStore.unpin(program) + @doc """ + Unpins a handle after its current evaluations finish. + + Returns `:not_pinned` when the handle is already stale. Because pins are + idempotent by program identity rather than ownership-counted, one lifecycle + owner should coordinate pinning and unpinning. + """ + @spec unpin(PinnedProgram.t()) :: :ok | :not_pinned + def unpin(%PinnedProgram{} = pinned), do: ProgramStore.unpin(pinned) @doc "Returns the monitored worker spawn options for an evaluation memory limit." def worker_spawn_options(:infinity), do: [:monitor] diff --git a/mix.exs b/mix.exs index 14d2a8d00..c9e136ac4 100644 --- a/mix.exs +++ b/mix.exs @@ -195,6 +195,7 @@ defmodule QuickBEAM.MixProject do QuickBEAM.VM.Variable ] - not String.starts_with?(inspect(module), "QuickBEAM.VM.") or module in public_vm_modules + module != QuickBEAM.Application and + (not String.starts_with?(inspect(module), "QuickBEAM.VM.") or module in public_vm_modules) end end From 11f656dd845f0862677f0761aa5d551e96cb2a22 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 17 Jul 2026 13:21:53 +0200 Subject: [PATCH 73/87] Remove VM review artifacts and regex parsers --- .github/workflows/ci.yml | 3 - CHANGELOG.md | 2 +- README.md | 4 +- docs/beam-compiler-contract.md | 376 ------- .../beam-compiler-performance-measurements.md | 118 --- docs/beam-compiler-region-hotspots.md | 85 -- docs/beam-compiler-region-investigation.md | 74 -- docs/beam-compiler-region-probe.md | 32 - docs/beam-compiler-region-ssr-measurements.md | 85 -- ...-compiler-scalar-scheduler-measurements.md | 37 - docs/beam-compiler-scalar-ssr-measurements.md | 91 -- docs/beam-compiler-scheduler-measurements.md | 37 - docs/beam-compiler-ssr-measurements.md | 91 -- docs/beam-interpreter-architecture.md | 970 ------------------ .../beam-interpreter-hotspot-investigation.md | 155 --- docs/beam-object-literal-investigation.md | 124 --- docs/beam-object-memory-investigation.md | 195 ---- docs/beam-object-memory-measurements.md | 29 - docs/beam-object-shape-investigation.md | 144 --- docs/beam-pinned-program-investigation.md | 139 --- docs/beam-pinned-program-measurements.md | 80 -- ...m-pinned-program-scheduler-measurements.md | 38 - docs/beam-scheduler-measurements.md | 36 - docs/beam-ssr-measurements.md | 77 -- docs/prototype-delta-audit.md | 492 --------- docs/test262-conformance.md | 77 -- examples/vm_ssr.exs | 69 -- lib/quickbeam/vm/abi.ex | 12 +- lib/quickbeam/vm/abi/generator.ex | 137 +++ lib/quickbeam/vm/abi/source.ex | 114 ++ lib/quickbeam/vm/abi_generator.ex | 77 -- lib/quickbeam/vm/fuzz.ex | 18 +- mix.exs | 43 +- test/support/test262.ex | 22 +- test/vm/abi_test.exs | 27 + 35 files changed, 323 insertions(+), 3787 deletions(-) delete mode 100644 docs/beam-compiler-contract.md delete mode 100644 docs/beam-compiler-performance-measurements.md delete mode 100644 docs/beam-compiler-region-hotspots.md delete mode 100644 docs/beam-compiler-region-investigation.md delete mode 100644 docs/beam-compiler-region-probe.md delete mode 100644 docs/beam-compiler-region-ssr-measurements.md delete mode 100644 docs/beam-compiler-scalar-scheduler-measurements.md delete mode 100644 docs/beam-compiler-scalar-ssr-measurements.md delete mode 100644 docs/beam-compiler-scheduler-measurements.md delete mode 100644 docs/beam-compiler-ssr-measurements.md delete mode 100644 docs/beam-interpreter-architecture.md delete mode 100644 docs/beam-interpreter-hotspot-investigation.md delete mode 100644 docs/beam-object-literal-investigation.md delete mode 100644 docs/beam-object-memory-investigation.md delete mode 100644 docs/beam-object-memory-measurements.md delete mode 100644 docs/beam-object-shape-investigation.md delete mode 100644 docs/beam-pinned-program-investigation.md delete mode 100644 docs/beam-pinned-program-measurements.md delete mode 100644 docs/beam-pinned-program-scheduler-measurements.md delete mode 100644 docs/beam-scheduler-measurements.md delete mode 100644 docs/beam-ssr-measurements.md delete mode 100644 docs/prototype-delta-audit.md delete mode 100644 docs/test262-conformance.md delete mode 100644 examples/vm_ssr.exs create mode 100644 lib/quickbeam/vm/abi/generator.ex create mode 100644 lib/quickbeam/vm/abi/source.ex delete mode 100644 lib/quickbeam/vm/abi_generator.ex diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 76813814c..9e6604d09 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,9 +72,6 @@ jobs: TEST262_PATH: /tmp/quickbeam-test262 run: mix test test/vm/test262_test.exs --include test262 - - name: Supervised pinned SSR example - run: mix run examples/vm_ssr.exs - ubsan: name: UBSan + Zig Debug runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index 18ae97533..5db131fa8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased -- Add explicit bounded immutable program pinning with lightweight `QuickBEAM.VM.PinnedProgram` handles, eight fixed persistent slots, binary identities, single-flight admission, owner-monitored leases, deferred unpinning, restart restoration, 32 MiB per-program and 128 MiB total decoded-term admission bounds, and copied-program semantics for ordinary programs. Pinned Vue SSR improves from a 49.21 ms median and 77.15 MiB endpoint process memory to 11.01 ms and 673.3 KiB while preserving exact steps, logical allocation, limits, cancellation, concurrency, and scheduler gates. Copied and pinned interpreter, `:pure_v1`, and `:scalar_v1` Test262 gates plus a runnable supervised pinned-SSR lifecycle example are included. +- Add explicit bounded immutable program pinning with lightweight `QuickBEAM.VM.PinnedProgram` handles, eight fixed persistent slots, binary identities, single-flight admission, owner-monitored leases, deferred unpinning, restart restoration, 32 MiB per-program and 128 MiB total decoded-term admission bounds, and copied-program semantics for ordinary programs. Pinned Vue SSR improves from a 49.21 ms median and 77.15 MiB endpoint process memory to 11.01 ms and 673.3 KiB while preserving exact steps, logical allocation, limits, cancellation, concurrency, and scheduler gates. Copied and pinned interpreter, `:pure_v1`, and `:scalar_v1` Test262 gates are included. - Define the optional BEAM compiler contract with binary artifact identities, a fixed 32-module atom pool, lease/purge/cache lifecycle, versioned runtime ABI boundaries, and validated owner-local before-instruction deoptimization states. Add its supervised module pool, minimal canonical runtime ABI, structured generated-module backend, bounded v26 CFG analysis, specialized fixed-name `:pure_v1` forms, selective bounded nested-function re-entry, guarded opt-in `:scalar_v1` loop/property/global lowering, explicit call actions, exact shared program artifact namespaces, warm artifact/negative-decision caches, and release-quarantined `engine: :compiler` orchestration with native, Preact/Vue/Svelte SSR, selected Test262, resource, concurrency, measurement, and single-scheduler acceptance coverage. - Remove integer-index keys from redundant object insertion-order lists, bulk-build literal arrays in one heap update, and encode default data descriptors compactly in the existing property map. Exact logical accounting is preserved; paired retained fixtures reduce array endpoint process memory by about 62% and ordinary-object retained VM heap size by 31%. - Measure and reject a bounded owner-local hidden-class prototype. Although ideal repeated-shape objects retained 13.4% less VM heap, only 82 of 1,130 final Vue objects were wholly eligible; Vue regressed from 47.07 ms to 50.30 ms with higher reductions and no endpoint memory improvement. diff --git a/README.md b/README.md index 8d720c05f..c72461f42 100644 --- a/README.md +++ b/README.md @@ -58,9 +58,7 @@ 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. See the runnable -[`examples/vm_ssr.exs`](examples/vm_ssr.exs) and the -[BEAM interpreter architecture](docs/beam-interpreter-architecture.md). +startup and unpin it during replacement or normal shutdown. The optional `engine: :compiler` tiers remain explicitly supervised and release-quarantined; the interpreter is the production default. diff --git a/docs/beam-compiler-contract.md b/docs/beam-compiler-contract.md deleted file mode 100644 index ec1a6860a..000000000 --- a/docs/beam-compiler-contract.md +++ /dev/null @@ -1,376 +0,0 @@ -# Bounded BEAM compiler contract - -Status: implemented experimental gate for the optional compiler. The tier remains -release-quarantined, and no prototype compiler runtime is approved for copying. - -## Scope - -The compiler is an optimization tier for an already verified -`QuickBEAM.VM.Program`. It is not a JavaScript parser, a third execution engine, -or a native fallback. QuickJS still produces bytecode, the current decoder and -verifier remain authoritative, and all mutable JavaScript state remains owned by -one evaluation process. - -The first extraction slice is intentionally narrow: verified basic blocks -containing literals, stack movement, local reads/writes, primitive value -operations, and terminal branches. Lowering first builds a bounded immutable -block plan, then emits fixed-name generated forms. The generic path uses -`run/3` and `block/4` with bounded canonical runtime block calls. Eligible scalar -loops use `run/3` and `block/7`, retain stack values as BEAM expressions, carry -locals as bounded tuples, and rebuild canonical frames only at deoptimization. -The conservative `:pure_v1` profile remains the default. The explicitly selected -`:scalar_v1` profile additionally lets ordinary scalar `call` and `call_method` -instructions return canonical invocation actions and lets non-accessor property -reads continue in generated code. Accessors deopt before invocation. -Constructors, iterators, exceptions, -Promise operations, host calls, and `await` still deopt before their instruction -until their resumable compiler ABI exists. - -The root frame always receives an eligibility attempt. Nested frames are -selected by a 32-operation entry prefix on the generic path; scalar-eligible -functions may instead qualify by total lowered size or a verified backward CFG -edge. Owner-local compile or skip decisions are cached for at most 256 -function IDs per evaluation. The module pool also keeps at most 256 binary-keyed -negative decisions, avoiding repeated warm lowering without creating atoms. - -Scalar lowering is deliberately narrower: at most 64 lowered operations, 16 -blocks, 32 operations per block, eight arguments, eight locals, and stack depth -64. Locals captured by nested functions are excluded. Lexical checked-local -reads require a successful initialization dataflow proof; otherwise the generic -before-instruction deoptimization path remains authoritative. - -## Non-negotiable invariants - -1. Compiling unbounded user input never creates an unbounded number of atoms. -2. Generated code never owns a heap, globals, cells, Promise state, jobs, or - handler tasks. It receives and returns canonical `%Frame{}` and - `%Execution{}` values. -3. Generated code calls one versioned compiler runtime ABI and never calls heap - internals. Allowlisted BEAM primitives may specialize proven or guarded - primitive values; every guard miss calls the canonical runtime semantics. -4. Every transition to the interpreter is an explicit, validated deoptimization - at a verified instruction boundary. -5. A timeout, memory failure, step failure, throw, or suspension has the same - external result and owner cleanup as interpreter execution. -6. No compiler failure invokes native QuickJS. An explicitly selected compiler - mode returns a typed error or deoptimization result. -7. Loaded code is reused only while protected by a live pool lease. Stale - module/function tuples are never valid execution handles. - -## Versioned artifact identity - -`QuickBEAM.VM.Compiler.Contract.artifact_key/3` returns a binary SHA-256 key. -The compiler hashes the exact immutable program namespace once per evaluation, -then combines it with each function payload and profile. The identity includes: - -- the compiler contract version; -- the runtime ABI version; -- the exact QuickJS/QuickBEAM ABI fingerprint and bytecode version; -- the SHA-256 serialized-bytecode digest and source digest when source is available; -- the immutable function IR, constants, and source positions; -- the exact program atom table in the shared namespace rather than repeated in every function payload; -- the lowering profile and semantic feature flags. - -Keys remain binaries. They are never converted to atoms. Changing any ABI, -opcode, value representation, lowering rule, or semantic profile increments a -contract version and invalidates all artifacts. - -The initial implementation has no persistent BEAM-binary cache. Compilation is -process-local and bounded by the module pool. A future disk cache must be opt-in, -size- and entry-bounded, use atomic replacement, validate a JSONCodec metadata -envelope plus the binary digest, and treat cached BEAM files as trusted -executable code. It must never deserialize arbitrary Erlang terms. - -## Static module pool - -The code server is global and module atoms are permanent. The pool therefore -uses exactly the statically declared names returned by -`QuickBEAM.VM.Compiler.Contract.pool_modules/0`. The initial contract reserves -32 names. Configuration may use fewer slots but cannot create names or exceed -that ceiling. - -`QuickBEAM.VM.Compiler.ModulePool` now implements the lifecycle as a -supervisor-compatible singleton behind the `Compiler.ModulePool.Backend` -behavior. Compile tasks have bounded wall time and a configurable BEAM heap -ceiling. Lifecycle tests use a fake backend. The production -`Compiler.GeneratedModule` backend coordinates bounded template emission, -import validation, artifact installation, and soft-purge code retirement. - -A singleton supervised pool owns these states: - -```text -free -compiling(key, waiters, task) -ready(key, generation, lease_count, last_used) -retiring(key, generation) -quarantined(reason) -``` - -### Checkout - -1. Compute the binary artifact key. -2. A ready hit increments its lease count and returns a lease containing the - fixed module, generation, opaque token, key, and owner PID. -3. A compiling hit joins the single-flight waiter set. -4. A miss reserves a free slot or the least-recently-used ready slot with zero - leases. If none exists, return `{:error, :compiler_pool_busy}`. -5. Lowering runs under a supervised task with instruction, form, byte, and wall - limits. Only the pool process may install the resulting module. - -The evaluation process invokes code only while holding the lease and returns it -in `after`. The pool monitors every lease owner, so timeout, memory termination, -or owner death releases leases without relying on cleanup code in that process. -A lease is valid only for its owner, key, slot generation, and pool epoch. - -### Eviction and purge - -Eviction is allowed only at lease count zero. The pool: - -1. soft-purges any old code version; -2. deletes the current version; -3. soft-purges the deleted version; -4. increments the generation; -5. loads the new binary under the same fixed module atom. - -Hard purge is forbidden because it can kill arbitrary processes. If either soft -purge reports a live code reference, the slot becomes quarantined and is not -reused. If all slots are busy or quarantined, checkout returns a typed capacity -error. Generated code must not expose BEAM funs or `{module, function}` tuples as -JavaScript values; JavaScript closures remain canonical VM function values and -acquire a fresh lease when invoked. - -Pool restart increments an epoch, invalidating every old lease, and attempts to -soft-retire every reserved static slot before admitting work, including slots -outside the currently configured capacity. A slot that may still be referenced -by pre-restart code is quarantined rather than treated as free. Shutdown stops -compile tasks, rejects waiters, waits for leases for a bounded grace period, and -then applies only soft purge. Code that cannot be -safely purged remains loaded until VM shutdown. - -## Compiler runtime ABI - -Generated modules may call `:erlang` guard/BIF operations approved by a -BEAM-disassembly test and one module, `QuickBEAM.VM.Compiler.Runtime`. That -module is runtime ABI version 5 and delegates semantics to the existing -canonical layers: - -- `Value` for primitive coercion and operators; -- `Properties` for descriptors, prototypes, and accessor actions; -- `Invocation` for call classification; -- `Async` and Promise state transitions; -- `Exceptions` for JavaScript throws and stacks; -- existing opcode-family modules for unspecialized operations. - -No generated external call to `Heap`, builtin installers, process dictionaries, -or prototype compiler helpers is allowed. ABI functions return explicit actions -rather than recursively invoking JavaScript. - -The current ABI contains only: - -- exact canonical-frame and compact-state charging at basic-block boundaries; -- verified local/argument/stack transforms shared with opcode-family modules; -- guarded primitive operations with canonical `Value` fallback; -- canonical non-accessor property reads, owner-local global reads/writes, and explicit invocation actions; -- truthiness and verified branch selection; -- reconstruction of canonical frames and typed `%Compiler.Deopt{}` values; -- bounded runtime tuple updates for generated regions that end before the full scalar CFG. - -`Compiler.GeneratedModule.ImportPolicy` inspects every generated module import -before installation. The closed allowlist contains this ABI, a fixed set of -numeric/tuple guard primitives, and the two Erlang `get_module_info` imports -emitted for every generated module. Generated variable names come only from a -fixed compiler-owned bank; no source value becomes an atom. Properties, calls, -throws, and suspension are added only with differential and resource-limit tests -for their action protocol. - -## Steps, memory, timeout, and scheduling - -A compiled basic block may debit its instruction count once only when every -instruction in the block is guaranteed to execute and cannot throw or suspend. -If insufficient steps remain, it deopts before the block without charging any of -its instructions. A terminal conditional still executes exactly once after the -preceding straight-line operations. This preserves the interpreter's exact `remaining_steps` contract -and `measure/2` counters. Potentially deoptimizing property and strict-global -reads occupy isolated preflight blocks: lookup classification happens without an -observable effect, the instruction is charged only after a successful preflight, -and a deoptimization resumes the still-uncharged instruction in the interpreter. - -All allocation goes through canonical runtime layers and their logical memory -charges. The compiled path runs in the same monitored evaluation process, so -process heap limits, outer timeout, handler ownership, and cancellation remain -unchanged. - -Generated generic blocks are capped at 256 QuickJS instructions and one function -artifact at 4,096 blocks and 4,096 lowered instructions. Generic blocks still -deoptimize at control-flow edges. The quarantined `compiler_regions: true` experiment admits binary -`{program, function, entry_pc, profile}` identities only after three encounters. -Its shared observation table is capped at `capacity * 8` (at most 256 entries), -the stable admitted set is capped at half the module pool, owner-local decisions -remain capped at 256, and generated artifacts still use only the fixed module -pool. The current executor deliberately compiles only the -first straight-line block at function entry, with at most 32 operations; it -removes a terminal branch and deoptimizes before the next instruction. Runtime -tuple updates avoid invalid early-boundary BEAM tuple-update optimization. This -experiment is disabled by default: measured Vue overhead and module churn reject -it as a production tier even though exact steps, logical memory, and results -remain canonical. - -The narrower scalar profile may tail-call at -most 16 generated successor blocks while preserving per-block charging and -outer process containment. The existing `+S 1:1` ticker-gap and timeout report -remains a regression gate for compiled execution. Measurement-only compiler -instrumentation uses one fixed-size owner-local OTP `:counters` reference. It -records generated/interpreted opcode counts and fixed deoptimization/action -fields, snapshots only at evaluation completion, and never creates keys from -user input or runs exporters in generated code. - -## Deoptimization state - -A `%QuickBEAM.VM.Compiler.Deopt{}` contains: - -- contract version and binary artifact key; -- pool generation; -- reason; -- owner PID; -- canonical `%Frame{}` and `%Execution{}`. - -The initial contract permits only **before-instruction** deoptimization. The -frame PC points at the next unexecuted verified instruction; its operand stack, -locals, arguments, closure references, callers, heap, jobs, and counters are -canonical and complete. The current instruction has not been charged and has -performed no observable effect. - -The dispatcher validates owner PID, contract version, artifact key width, -generation, function identity, PC range, and the active lease before calling the -interpreter. Invalid or stale state is a typed compiler infrastructure error, -not a JavaScript exception. - -Initial reasons are: - -- `:unsupported_opcode`; -- `:unsupported_semantics`; -- `:step_boundary`; -- `:suspension_boundary`; -- `{:guard_failed, guard}`. - -After-instruction deoptimization is excluded initially because property writes, -calls, iterator steps, and Promise actions cannot be duplicated. It may be added -only as a distinct protocol carrying an explicit completed semantic action. - -## Public execution policy - -`QuickBEAM.VM.eval/2` remains interpreter-first. The optional tier is selected -with `engine: :compiler` after supervising `QuickBEAM.VM.Compiler`; the default -remains `engine: :interpreter`. A compiled basic block may return the documented -deoptimization action because that transition is part of the selected compiler -engine. Capacity, compile-task, load, stale-lease, and purge failures remain -typed compiler errors rather than silently restarting the whole program in the -interpreter. - -The orchestration API is present for acceptance testing but remains release -quarantined until the pinned resource, scheduler, and broader native -differential gates pass. - -An adaptive policy, if added, must be explicitly selected by the caller and -reported by measurement/telemetry. It still may never fall back to native -QuickJS. - -The current `+S 1:1` compiler-tier Vue probe reports a 33.57 ms maximum ticker -gap against the 75 ms bound and a 51.15 ms timeout p95 against the 60 ms bound. -The opt-in scalar profile reports 35.52 ms and 51.10 ms respectively. -The pinned compiler SSR report covers 30 sequential samples plus concurrency -1/4/8 for Preact, Vue, and Svelte, with 100/100 isolated Preact renders and -successful step, memory, timeout, and cancellation checks. The Vue parity gate -also requires more than the root generated module, proving selected nested-frame -coverage. The selected Test262 gate passes 65/65 supported tests through the -interpreter and both compiler profiles. - -The separate warm loop report now measures 8.23× arithmetic-loop, 7.65× -branch-loop, 5.69× local-arithmetic, 1.71× array-sum, and 5.26× object-property -speedups over the interpreter after cold compilation costs of 10.77–25.65 ms. -One-time host-profile initialization is reported separately. Those bounded -micro-workloads demonstrate that scalar generated execution can amortize -compilation; they do not replace the SSR release gate. - -On the published runs, default compiler-tier sequential medians are 9.74 ms for -Preact, 60.13 ms for Vue, and 15.70 ms for Svelte. The scalar profile reports -9.66 ms, 64.45 ms, and 15.45 ms, versus interpreter medians of 8.22 ms, -49.21 ms, and 15.15 ms respectively. Vue generated-step coverage remains only -0.4% for `:pure_v1` and 1.1% for `:scalar_v1`. These separate reproducible runs -are not a paired statistical comparison, and the low useful coverage keeps the -compiler out of the release path. - -### Release policy - -The compiler remains release-quarantined despite those compatibility results: - -- `engine: :interpreter` remains the documented default; -- compiler selection and supervision must always be explicit; -- compiler infrastructure failures never restart in another engine; -- scalar loop speedups do not imply that unsupported SSR-heavy opcode families - have a production performance benefit; -- stable release promotion requires broader useful compiled coverage, a - non-regressing compiler/interpreter performance comparison, and no regression - in the existing safety gates; -- until promotion, compiler API compatibility may change within the development - major release. - -## Prototype analysis map - -Prototype code is not copied as a subsystem. Each part has one bounded target: - -| Prototype concept | Decision and canonical target | -| --- | --- | -| CFG/basic-block discovery | Adapt the pure graph algorithm to current v26 instruction tuples. Targets remain verified instruction indexes. | -| Stack analysis | Do not extract. `StackVerifier` remains authoritative; lowering consumes its verified heights and joins. | -| Opcode support analysis | Keep the conservative `:pure_v1` allowlist and add an explicit `:scalar_v1` property/call extension. Every other opcode emits a before-instruction deopt. | -| Local/stack lowering | Adapt scalar block arguments only under fixed stack/slot/form bounds. Rebuild canonical `%Frame{}` at deoptimization and exclude captured locals. | -| Value lowering | Use allowlisted guarded BEAM primitives with canonical `Value` fallback. Do not retain prototype coercion or object tags. | -| Property lowering | Initially deopt. Later call `Properties` and preserve its accessor boundary actions. | -| Call lowering | Initially deopt. Later call `Invocation`; calls never recursively enter generated code or the interpreter. | -| Promise/async lowering | Initially deopt before Promise, host-call, and `await` instructions. Later preserve `Async`, Promise jobs, and typed suspension boundaries. | -| Throw/catch lowering | Initially deopt before potentially throwing instructions. Later route explicit actions through `Exceptions`. | -| Runtime helpers/heap | Reject. The prototype process dictionary, heap, object tags, and runtime state have no compiler ABI role. | -| Form optimizer | Defer until the unspecialized pure compiler is differential-test clean; every optimization needs equivalent deopt points and accounting. | -| Diagnostics/disassembly tests | Adapt with v26 fixtures, source positions, artifact keys, and the external-call allowlist. | - -This mapping permits reuse of isolated algorithms and form-emission techniques, -not prototype runtime modules or fallback behavior. - -## Extraction order - -1. **Complete:** land contract types, static slot names, and invariant tests. -2. **Complete:** implement the supervised pool with a fake backend; prove bounds, - owner monitoring, generations, single-flight compilation, bounded shutdown, - and quarantine. -3. **Complete:** add the minimal runtime ABI, generated-module emitter and import - policy, and production soft-purge code lifecycle. -4. **Complete:** adapt bounded v26 CFG/basic-block analysis and deterministic - `:pure_v1` block plans. -5. **Complete:** execute literals, stack/local operations, primitive values, and - terminal branches through the canonical ABI. -6. **Complete:** resume validated before-instruction deoptimization in the - interpreter. -7. **Complete for the initial pure subset:** emit specialized fixed-name block - and step clauses without dynamic atoms, and cover decoded expressions plus - function arguments/locals differentially against the interpreter. -8. Expand decoded JavaScript and native differential coverage, then run exact - limit and scheduler gates. -9. Expand one resumable semantic family at a time. - -## Acceptance gates - -Before enabling compiler execution outside tests: - -- 100,000 unique artifact keys leave atom count unchanged after pool startup; -- loaded compiler modules never exceed the configured static capacity; -- active leases cannot be evicted and owner death releases them; -- duplicate concurrent compilation is single-flight; -- stale generation/epoch leases are rejected; -- soft-purge failure quarantines a slot and never hard-purges; -- interpreter and compiler results, JavaScript errors, steps, and logical memory - match for every compiled opcode family; -- step, memory, timeout, cancellation, and `+S 1:1` gates pass; -- async/host operations deopt and resume without duplicate effects; -- generated-module disassembly contains only allowed external calls; -- no compiler path reaches native QuickJS. diff --git a/docs/beam-compiler-performance-measurements.md b/docs/beam-compiler-performance-measurements.md deleted file mode 100644 index 5a7af0e92..000000000 --- a/docs/beam-compiler-performance-measurements.md +++ /dev/null @@ -1,118 +0,0 @@ -# BEAM compiler warm-execution measurements - -This report separates one-time host-runtime initialization, cold compilation, -and repeated owner-local execution for five bounded lexical/property-loop -workloads. It measures the experimental BEAM compiler against the canonical -interpreter through `QuickBEAM.VM.eval/2` with `isolation: :caller`; native -QuickJS is not part of this comparison. - -## Reproduction - -```sh -COMPILER_PERF_ITERATIONS=2000 MIX_ENV=bench \ - mix run bench/vm_compiler_perf.exs -``` - -- Git base: `bc1aaf50` -- Working tree at measurement: modified -- Generated: 2026-07-16T09:40:00Z -- Elixir: 1.20.2 -- OTP: 29 -- OS: Linux 7.0.0-27-generic -- CPU: AMD Ryzen 9 9950X 16-Core Processor -- Iterations per warm tier: 2,000 - -## Results - -The first core-profile evaluation took **50.62 ms**. This one-time runtime -initialization is reported separately and is not folded into the first -workload's compiler time. - -| workload | compiled functions | cold compiler eval | warm compiler average | interpreter average | warm speedup | -|---|---:|---:|---:|---:|---:| -| arithmetic loop | 1 | 15.95 ms | 38.31 µs | 315.17 µs | 8.23× | -| branch loop | 1 | 11.08 ms | 52.40 µs | 400.73 µs | 7.65× | -| local arithmetic loop | 1 | 25.65 ms | 91.74 µs | 522.34 µs | 5.69× | -| array sum | 1 | 12.74 ms | 54.11 µs | 92.58 µs | 1.71× | -| object property loop | 1 | 10.77 ms | 55.91 µs | 294.28 µs | 5.26× | - -Cold time includes bounded CFG/dataflow analysis, Erlang form compilation, -module installation, lease orchestration, and the first evaluation after the -host template is warm. Warm time includes public VM option handling, root -interpreter dispatch, compiler pool lookup, one generated nested-function -execution, and result completion. It does not hide compiler orchestration by -calling a generated module directly. Both warm tiers seed each evaluation from -the same immutable, profile-specific host template and charge its full logical -allocation. Mutations still remain owner-local through immutable-map -copy-on-write semantics. - -## CPU profile - -The warm object-property workload was also profiled with `:eprof` for 200 -public `eval/2` calls: - -```sh -COMPILER_EPROF_PHASE=execution COMPILER_EPROF_ENGINE=compiler \ - COMPILER_EPROF_WORKLOAD=object_property_loop COMPILER_EPROF_ITERATIONS=200 \ - MIX_ENV=bench mix run bench/vm_compiler_eprof.exs -COMPILER_EPROF_PHASE=execution COMPILER_EPROF_ENGINE=interpreter \ - COMPILER_EPROF_WORKLOAD=object_property_loop COMPILER_EPROF_ITERATIONS=200 \ - MIX_ENV=bench mix run bench/vm_compiler_eprof.exs -``` - -Profiling overhead is substantial, so these are CPU attribution results rather -than latency numbers. The compiler profile recorded 76.82 ms total traced CPU, -including 12.05 ms in generated `block/7` and 7.07 ms in exact scalar step -charging. The interpreter profile recorded 386.12 ms total traced CPU, -including 40.30 ms in `execute_current_opcode/4`, 34.83 ms in opcode expansion, -26.68 ms in `run/2`, and 23.63 ms in instruction execution. Property reads use -separate preflight blocks so accessor/error deoptimization does not pre-charge -or duplicate the instruction; this correctness boundary explains part of the -property-heavy overhead. - -A fresh Mix VM can isolate first-profile initialization: - -```sh -COMPILER_EPROF_PHASE=initialization COMPILER_EPROF_ENGINE=interpreter \ - MIX_ENV=bench mix run bench/vm_compiler_eprof.exs -``` - -That single minimal evaluation recorded 19.67 ms traced CPU; 17.05 ms was OTP -module loading (`erts_internal:prepare_loading/2`). Builtin heap construction, -logical accounting, and the persistent-template write were individually below -0.21 ms in that traced run. Initialization and warm generated execution are -therefore reported as separate phases. - -The pinned Vue fixture can be profiled separately from micro-workloads: - -```sh -COMPILER_SSR_EPROF_ITERATIONS=3 COMPILER_SSR_EPROF_PROFILE=interpreter \ - MIX_ENV=bench mix run bench/vm_compiler_ssr_eprof.exs -COMPILER_SSR_EPROF_ITERATIONS=3 COMPILER_SSR_EPROF_PROFILE=pure_v1 \ - MIX_ENV=bench mix run bench/vm_compiler_ssr_eprof.exs -COMPILER_SSR_EPROF_ITERATIONS=3 COMPILER_SSR_EPROF_PROFILE=scalar_v1 \ - MIX_ENV=bench mix run bench/vm_compiler_ssr_eprof.exs -``` - -The three traced runs recorded 281.72 ms for the interpreter, 300.34 ms for -`:pure_v1`, and 292.91 ms for `:scalar_v1`. The compiler runs spent about -11.8 ms across three renders in `term_to_binary/2`; artifact identities now -hash the exact program namespace once per evaluation and omit repeated atom -tables from per-function payloads. These profiles also motivated fixed OTP -`:counters` at the measurement boundary: Vue currently executes only 0.4% of -steps through `:pure_v1` and 1.1% through `:scalar_v1`, so bounded coverage—not -scalar primitive speed—is the remaining SSR bottleneck. - -The optimized tier keeps verified stack values as generated BEAM expressions, -uses bounded tuple locals, tail-calls generated successor blocks, specializes -numeric operations behind guards, performs non-accessor property reads through -canonical `Properties`, threads canonical global reads/writes through owner-local -execution state, and reconstructs `%QuickBEAM.VM.Frame{}` only at -explicit deoptimization. Fallback clauses still call canonical -`QuickBEAM.VM.Compiler.Runtime` value semantics. - -These micro-workload gains do not promote the compiler for release. The pinned -Preact/Vue/Svelte report remains the end-to-end compatibility and performance -gate; object writes, constructors, iterators, Promise operations, and exception -regions still deoptimize to the interpreter. Ordinary calls use explicit -interpreter-owned actions rather than recursive generated execution. diff --git a/docs/beam-compiler-region-hotspots.md b/docs/beam-compiler-region-hotspots.md deleted file mode 100644 index 80a511056..000000000 --- a/docs/beam-compiler-region-hotspots.md +++ /dev/null @@ -1,85 +0,0 @@ -# Bounded compiler dynamic region hotspots - -This opt-in diagnostic samples every 16th interpreted instruction into at -most 64 owner-local Space-Saving heavy hitters. Windows are aligned to 64 -instruction PCs. `samples-error` is the conservative frequency lower bound; -fixed-pool potential applies each window's statically supported ratio to the -32 strongest lower bounds. Generated instructions are not sampled. Results -are fixture/profile-specific and are not production telemetry or speedup -claims. - -- Git base: `0a332b81` -- Working tree at measurement: modified -- Generated: 2026-07-16T13:22:35Z -- Elixir: 1.20.2 -- OTP: 29 -- CPU: AMD Ryzen 9 9950X 16-Core Processor -- Sampling interval: 16 interpreted instructions -- Heavy-hitter capacity: 64 regions -- Fixed generated-module slots: 32 - -| Fixture | profile | VM steps | existing generated steps | samples | retained regions | fixed-pool supported lower bound | -|---|---|---:|---:|---:|---:|---:| -| Preact 10.29.7 | `pure_v1` | 3651 | 0 | 228 | 41 | 61.8% | -| Preact 10.29.7 | `scalar_v1` | 3651 | 0 | 228 | 41 | 85.2% | -| Vue 3.5.39 | `pure_v1` | 11957 | 50 | 744 | 64 | 37.0% | -| Vue 3.5.39 | `scalar_v1` | 11957 | 126 | 739 | 64 | 46.9% | -| Svelte 5.56.4 | `pure_v1` | 1777 | 24 | 109 | 34 | 34.0% | -| Svelte 5.56.4 | `scalar_v1` | 1777 | 0 | 111 | 35 | 77.7% | - -## Leading sampled windows - -| Fixture | profile | function@pc | samples | error | lower bound | supported instructions | -|---|---|---|---:|---:|---:|---:| -| Preact 10.29.7 | `pure_v1` | `f56@0` | 28 | 0 | 28 | 46/64 | -| Preact 10.29.7 | `pure_v1` | `f4@0` | 19 | 0 | 19 | 35/57 | -| Preact 10.29.7 | `pure_v1` | `f3@0` | 18 | 0 | 18 | 48/64 | -| Preact 10.29.7 | `pure_v1` | `f56@64` | 13 | 0 | 13 | 51/64 | -| Preact 10.29.7 | `pure_v1` | `f56@1024` | 12 | 0 | 12 | 52/64 | -| Preact 10.29.7 | `pure_v1` | `f56@128` | 10 | 0 | 10 | 41/64 | -| Preact 10.29.7 | `pure_v1` | `f56@1088` | 10 | 0 | 10 | 49/64 | -| Preact 10.29.7 | `pure_v1` | `f56@768` | 9 | 0 | 9 | 50/64 | -| Preact 10.29.7 | `scalar_v1` | `f56@0` | 28 | 0 | 28 | 59/64 | -| Preact 10.29.7 | `scalar_v1` | `f4@0` | 19 | 0 | 19 | 42/57 | -| Preact 10.29.7 | `scalar_v1` | `f3@0` | 18 | 0 | 18 | 57/64 | -| Preact 10.29.7 | `scalar_v1` | `f56@64` | 13 | 0 | 13 | 58/64 | -| Preact 10.29.7 | `scalar_v1` | `f56@1024` | 12 | 0 | 12 | 62/64 | -| Preact 10.29.7 | `scalar_v1` | `f56@128` | 10 | 0 | 10 | 60/64 | -| Preact 10.29.7 | `scalar_v1` | `f56@1088` | 10 | 0 | 10 | 61/64 | -| Preact 10.29.7 | `scalar_v1` | `f56@768` | 9 | 0 | 9 | 62/64 | -| Vue 3.5.39 | `pure_v1` | `f1@0` | 165 | 0 | 165 | 21/35 | -| Vue 3.5.39 | `pure_v1` | `f493@0` | 20 | 1 | 19 | 62/64 | -| Vue 3.5.39 | `pure_v1` | `f492@64` | 20 | 3 | 17 | 39/64 | -| Vue 3.5.39 | `pure_v1` | `f652@0` | 20 | 5 | 15 | 31/53 | -| Vue 3.5.39 | `pure_v1` | `f175@0` | 15 | 0 | 15 | 11/20 | -| Vue 3.5.39 | `pure_v1` | `f660@0` | 18 | 4 | 14 | 50/64 | -| Vue 3.5.39 | `pure_v1` | `f658@0` | 16 | 4 | 12 | 47/64 | -| Vue 3.5.39 | `pure_v1` | `f492@0` | 15 | 3 | 12 | 63/64 | -| Vue 3.5.39 | `scalar_v1` | `f1@0` | 165 | 0 | 165 | 27/35 | -| Vue 3.5.39 | `scalar_v1` | `f493@0` | 20 | 1 | 19 | 64/64 | -| Vue 3.5.39 | `scalar_v1` | `f492@64` | 20 | 3 | 17 | 44/64 | -| Vue 3.5.39 | `scalar_v1` | `f175@0` | 15 | 0 | 15 | 13/20 | -| Vue 3.5.39 | `scalar_v1` | `f660@0` | 18 | 4 | 14 | 60/64 | -| Vue 3.5.39 | `scalar_v1` | `f652@0` | 17 | 5 | 12 | 42/53 | -| Vue 3.5.39 | `scalar_v1` | `f492@0` | 15 | 3 | 12 | 64/64 | -| Vue 3.5.39 | `scalar_v1` | `f658@0` | 15 | 4 | 11 | 61/64 | -| Svelte 5.56.4 | `pure_v1` | `f1@0` | 8 | 0 | 8 | 46/64 | -| Svelte 5.56.4 | `pure_v1` | `f199@0` | 5 | 0 | 5 | 31/64 | -| Svelte 5.56.4 | `pure_v1` | `f0@0` | 4 | 0 | 4 | 0/64 | -| Svelte 5.56.4 | `pure_v1` | `f0@64` | 4 | 0 | 4 | 0/64 | -| Svelte 5.56.4 | `pure_v1` | `f0@128` | 4 | 0 | 4 | 0/64 | -| Svelte 5.56.4 | `pure_v1` | `f0@192` | 4 | 0 | 4 | 0/64 | -| Svelte 5.56.4 | `pure_v1` | `f0@256` | 4 | 0 | 4 | 0/64 | -| Svelte 5.56.4 | `pure_v1` | `f0@320` | 4 | 0 | 4 | 0/64 | -| Svelte 5.56.4 | `scalar_v1` | `f1@0` | 10 | 0 | 10 | 63/64 | -| Svelte 5.56.4 | `scalar_v1` | `f207@0` | 6 | 0 | 6 | 10/13 | -| Svelte 5.56.4 | `scalar_v1` | `f0@0` | 4 | 0 | 4 | 64/64 | -| Svelte 5.56.4 | `scalar_v1` | `f0@64` | 4 | 0 | 4 | 64/64 | -| Svelte 5.56.4 | `scalar_v1` | `f0@128` | 4 | 0 | 4 | 64/64 | -| Svelte 5.56.4 | `scalar_v1` | `f0@192` | 4 | 0 | 4 | 52/64 | -| Svelte 5.56.4 | `scalar_v1` | `f0@256` | 4 | 0 | 4 | 37/64 | -| Svelte 5.56.4 | `scalar_v1` | `f0@320` | 4 | 0 | 4 | 42/64 | - -The next implementation gate is positive only when a small fixed region set -has a meaningful conservative dynamic lower bound. Otherwise region -compilation would add artifact churn without enough warm execution. diff --git a/docs/beam-compiler-region-investigation.md b/docs/beam-compiler-region-investigation.md deleted file mode 100644 index ae48d9020..000000000 --- a/docs/beam-compiler-region-investigation.md +++ /dev/null @@ -1,74 +0,0 @@ -# Bounded compiler region investigation - -This investigation tests whether bounded region compilation can overcome the -whole-function admission bottleneck without weakening the compiler contract. -It does **not** enable a production compiler tier. `QuickBEAM.VM.eval/2` still -defaults to the interpreter, and `compiler_regions: false` is the compiler -default. - -## Coverage probes - -The reproducible static inventory in -[`beam-compiler-region-probe.md`](beam-compiler-region-probe.md) found -86.0–89.9% scalar-regionizable instructions across the pinned fixtures, but the -32 largest static regions covered only 2.9–6.1% of fixture bytecode. The opt-in -dynamic Space-Saving probe in -[`beam-compiler-region-hotspots.md`](beam-compiler-region-hotspots.md) estimated -fixed-pool supported lower bounds of 85.2% for Preact, 46.9% for Vue, and 77.7% -for Svelte. Those estimates count supported instructions in sampled 64-PC -windows; they are opportunity bounds, not generated-code coverage or speedup -predictions. - -## Bounded executor experiment - -The opt-in `compiler_regions: true` path preserves the existing safety bounds: - -- binary program/function/entry/profile admission and artifact identities; -- three encounters before admission; -- at most `capacity * 8`, and never more than 256, shared observation entries; -- a stable admitted set capped at half the module pool (16 regions at capacity 32), - preventing admitted regions alone from churning the pool; -- at most 256 owner-local decisions and the existing fixed 32-module pool; -- exact lease validation, soft-purge-only eviction, and before-instruction deopt; -- one straight-line entry block of at most 32 operations; -- runtime tuple updates at early boundaries, avoiding unsafe generated - `setelement` optimization; -- exact interpreter parity for result, steps, logical allocation, limits, and - cancellation. - -The executor currently enters regions only at function PC 0. It deoptimizes -before a terminal branch, unsupported instruction, suspension, or the next -uncompiled block. It does not recursively execute generated regions. - -## Measured result: reject for release - -The pinned 10-sample report is -[`beam-compiler-region-ssr-measurements.md`](beam-compiler-region-ssr-measurements.md). -Warm sequential medians were: - -| Fixture | scalar compiler without regions | bounded regions | generated step coverage | -|---|---:|---:|---:| -| Preact 10.29.7 | 9.66 ms | 17.01 ms | 2.4% | -| Vue 3.5.39 | 64.45 ms | 112.70 ms | 0.7% | -| Svelte 5.56.4 | 15.45 ms | 15.97 ms | 0.0% | - -The stable 16-region cap prevents region-driven module-pool churn, but a simple -three-encounter policy admits early regions rather than the strongest dynamic -heavy hitters. Vue entered generated code only eight times and executed 79 of -11,957 steps in generated code. Preact entered 23 small regions and -immediately deoptimized each time. Boundary, lease, frame-reconstruction, and -admission costs therefore remain larger than the generated work. - -A warm three-render `:eprof` comparison attributed 371.04 ms of CPU with regions -versus 337.28 ms without them, a 10.0% increase. Deterministic artifact and -admission serialization (`:erlang.term_to_binary/2`) rose from 13.55 ms across -144 calls to 18.22 ms across 831 calls, while synchronous `gen:do_call/4` rose -from 0.21 ms to 0.84 ms. The region profile made 639 admission calls and 1,035 -region-frame attempts. This identifies repeated admission identity work and -small-region transitions as measured CPU costs after pool churn is bounded. - -The experiment therefore fails the SSR release gate and remains explicitly -quarantined. A future design should not expand opcode coverage or admit more -regions until it can select materially longer hot traces, amortize entry/deopt -cost, and demonstrate lower Vue wall time with the same resource and scheduler -contracts. diff --git a/docs/beam-compiler-region-probe.md b/docs/beam-compiler-region-probe.md deleted file mode 100644 index c8e0f2a67..000000000 --- a/docs/beam-compiler-region-probe.md +++ /dev/null @@ -1,32 +0,0 @@ -# Bounded compiler region coverage probe - -This static probe partitions each verified basic block into independently -compilable regions of at most 64 operations. Property -and strict-global reads remain isolated preflight regions, calls terminate a -region, and unsupported instructions are excluded. The fixed module-pool -estimate retains only the 32 largest regions. These figures are -an instruction-inventory bound, not dynamic execution coverage or a speedup -claim. - -- Git base: `0a332b81` -- Working tree at measurement: modified -- Generated: 2026-07-16T13:22:33Z -- Elixir: 1.20.2 -- OTP: 29 -- CPU: AMD Ryzen 9 9950X 16-Core Processor -- Maximum operations per region: 64 -- Fixed generated-module slots: 32 - -| Fixture | profile | functions | instructions | region functions | bounded regions | regionizable instructions | static region coverage | largest 32 instructions | fixed-pool static coverage | -|---|---|---:|---:|---:|---:|---:|---:|---:|---:| -| Preact 10.29.7 | `pure_v1` | 64 | 6648 | 60 | 2213 | 4332 | 65.2% | 224 | 3.4% | -| Preact 10.29.7 | `scalar_v1` | 64 | 6648 | 62 | 3188 | 5977 | 89.9% | 408 | 6.1% | -| Vue 3.5.39 | `pure_v1` | 685 | 33916 | 641 | 10104 | 21095 | 62.2% | 604 | 1.8% | -| Vue 3.5.39 | `scalar_v1` | 685 | 33916 | 670 | 15237 | 29184 | 86.0% | 998 | 2.9% | -| Svelte 5.56.4 | `pure_v1` | 209 | 12199 | 199 | 3664 | 6902 | 56.6% | 329 | 2.7% | -| Svelte 5.56.4 | `scalar_v1` | 209 | 12199 | 208 | 5882 | 10561 | 86.6% | 628 | 5.1% | - -A region tier is useful only if dynamic hot-region measurements show that a -small fixed set is executed repeatedly. Static coverage alone cannot justify -compiling every listed region or exceeding the existing module and decision -bounds. diff --git a/docs/beam-compiler-region-ssr-measurements.md b/docs/beam-compiler-region-ssr-measurements.md deleted file mode 100644 index cd6d7288c..000000000 --- a/docs/beam-compiler-region-ssr-measurements.md +++ /dev/null @@ -1,85 +0,0 @@ -# Bounded region compiler SSR measurements - -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. -The region experiment has no scheduler-gate claim because it fails the SSR latency gate. - -## Environment - -- Engine: compiler -- Compiler profile: scalar_v1 -- Compiler regions: true -- Git base: `0a332b81` -- Working tree at measurement: modified -- Generated: 2026-07-16T13:26:26Z -- Elixir: 1.20.2 -- OTP: 29 -- ERTS: 17.0.2 -- OS: Linux 7.0.0-27-generic -- Architecture: x86_64-pc-linux-gnu -- CPU: AMD Ryzen 9 9950X 16-Core Processor -- Logical schedulers: 32 -- Mix environment: `bench` -- Samples per fixture: 10 after 3 warmups -- Concurrency levels: 1 - -## Sequential isolated renders - -| Fixture | wall median | wall p95 | VM steps | logical memory | endpoint process memory | reductions median | -|---|---:|---:|---:|---:|---:|---:| -| Preact 10.29.7 | 17.01 ms | 24.87 ms | 3651 | 266.2 KiB | 4.5 MiB | 295627 | -| Vue 3.5.39 | 112.7 ms | 129.6 ms | 11957 | 991.9 KiB | 77.15 MiB | 1416407 | -| Svelte 5.56.4 | 15.97 ms | 17.85 ms | 1777 | 397.1 KiB | 15.61 MiB | 273454 | - -`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 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 | -|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---|---| -| Preact 10.29.7 | 73 | 50 | 0/10/16 | 86 | 2.4% | 23 | 23 | 0 | 23 | `get_length`=7, `if_false8`=7, `get_field`=4, `get_var`=2, `check_define_var`=1, `push_atom_value`=1, `to_object`=1 | `if_false8`=269, `get_loc8`=234, `push_atom_value`=234, `dup`=223, `drop`=193, `get_var`=179, `get_arg0`=161, `strict_eq`=109 | -| Vue 3.5.39 | 306 | 298 | 0/1/92 | 79 | 0.7% | 8 | 2 | 6 | 2 | `return_undef`=2 | `swap`=936, `dup`=664, `get_loc_check`=598, `if_false8`=582, `check_define_var`=480, `get_var`=468, `get_arg0`=464, `set_loc_uninitialized`=450 | -| Svelte 5.56.4 | 34 | 34 | 0/0/23 | 0 | 0.0% | 0 | 0 | 0 | 0 | | `check_define_var`=202, `fclosure8`=154, `define_func`=117, `define_var`=85, `set_loc_uninitialized`=74, `get_var`=65, `get_loc_check`=64, `put_var_init`=63 | - - -## Concurrent isolated renders - -| Fixture | concurrency | renders | throughput | per-render wall median | per-render wall p95 | -|---|---:|---:|---:|---:|---:| -| Preact 10.29.7 | 1 | 10 | 52.1 renders/s | 14.44 ms | 17.68 ms | -| Vue 3.5.39 | 1 | 10 | 4.7 renders/s | 124.4 ms | 153.57 ms | -| Svelte 5.56.4 | 1 | 10 | 33.6 renders/s | 19.09 ms | 22.0 ms | - -## 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 | -|---:|---:|---:|---:| -| 100/100 | 192.4 renders/s | -929.3 KiB | 0 | - -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 | -|---|---:|---:|---:|---:|---:| -| Preact 10.29.7 | limit:steps at 3650 | limit:memory_bytes at 133.1 KiB | limit:timeout at 200 ms | 201.19 ms | 42 µs | -| Vue 3.5.39 | limit:steps at 11956 | limit:memory_bytes at 495.9 KiB | limit:timeout at 200 ms | 223.41 ms | 25 µs | -| Svelte 5.56.4 | limit:steps at 1776 | limit:memory_bytes at 198.6 KiB | limit:timeout at 200 ms | 206.26 ms | 64 µs | - -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. diff --git a/docs/beam-compiler-scalar-scheduler-measurements.md b/docs/beam-compiler-scalar-scheduler-measurements.md deleted file mode 100644 index 6b897a298..000000000 --- a/docs/beam-compiler-scalar-scheduler-measurements.md +++ /dev/null @@ -1,37 +0,0 @@ -# BEAM scalar compiler single-scheduler probe - -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 compiler work. - -- Engine: compiler -- Compiler profile: scalar_v1 -- Git base: `bc1aaf50` -- Working tree at measurement: modified -- Generated: 2026-07-16T09:54:48Z -- Elixir: 1.20.2 -- OTP: 29 -- ERTS: 17.0.2 -- OS: Linux 7.0.0-27-generic -- Architecture: x86_64-pc-linux-gnu -- CPU: AMD Ryzen 9 9950X 16-Core Processor -- Online schedulers: 1 -- Vue probe memory limit: 512 MB -- Samples: 10 - -| workload | wall median | wall p95 | ticker gap median | ticker gap p95 | ticker gap max | ticks median | -|---|---:|---:|---:|---:|---:|---:| -| Vue SSR | 177.57 ms | 205.3 ms | 2.0 ms | 8.53 ms | 35.52 ms | 65 | -| sleep baseline (178 ms target) | 178.95 ms | 178.98 ms | 2.0 ms | 2.01 ms | 3.8 ms | 89 | - -Acceptance bound: Vue SSR ticker gap ≤ 75.0 ms. - -## 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 | 50.98 ms | 51.1 ms | 51.1 ms | 983 µs | - -Acceptance bound: timeout p95 ≤ 60.0 ms. diff --git a/docs/beam-compiler-scalar-ssr-measurements.md b/docs/beam-compiler-scalar-ssr-measurements.md deleted file mode 100644 index 9b0a0cf93..000000000 --- a/docs/beam-compiler-scalar-ssr-measurements.md +++ /dev/null @@ -1,91 +0,0 @@ -# BEAM scalar compiler SSR measurements - -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. The -single-scheduler fairness and timeout gate is published separately in -[`beam-compiler-scalar-scheduler-measurements.md`](beam-compiler-scalar-scheduler-measurements.md). - -## Environment - -- Engine: compiler -- Compiler profile: scalar_v1 -- Git base: `bc1aaf50` -- Working tree at measurement: modified -- Generated: 2026-07-16T09:53:54Z -- Elixir: 1.20.2 -- OTP: 29 -- ERTS: 17.0.2 -- OS: Linux 7.0.0-27-generic -- Architecture: x86_64-pc-linux-gnu -- CPU: AMD Ryzen 9 9950X 16-Core Processor -- Logical schedulers: 32 -- Mix environment: `bench` -- Samples per fixture: 30 after 3 warmups -- Concurrency levels: 1, 4, 8 - -## Sequential isolated renders - -| Fixture | wall median | wall p95 | VM steps | logical memory | endpoint process memory | reductions median | -|---|---:|---:|---:|---:|---:|---:| -| Preact 10.29.7 | 9.66 ms | 11.15 ms | 3651 | 266.2 KiB | 7.28 MiB | 221871 | -| Vue 3.5.39 | 64.45 ms | 69.78 ms | 11957 | 991.9 KiB | 77.15 MiB | 1275315 | -| Svelte 5.56.4 | 15.45 ms | 19.98 ms | 1777 | 397.1 KiB | 15.61 MiB | 271145 | - -`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 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 | -|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---|---| -| Preact 10.29.7 | 50 | 49 | 0/1/15 | 0 | 0.0% | 1 | 1 | 0 | 0 | `check_define_var`=1 | `if_false8`=269, `get_loc8`=234, `push_atom_value`=234, `dup`=230, `drop`=193, `get_var`=182, `get_arg0`=180, `strict_eq`=109 | -| Vue 3.5.39 | 306 | 296 | 0/3/90 | 126 | 1.1% | 10 | 4 | 6 | 2 | `return_undef`=2, `check_define_var`=1, `get_var`=1 | `swap`=936, `dup`=664, `get_loc_check`=598, `if_false8`=582, `check_define_var`=480, `get_var`=468, `get_arg0`=464, `set_loc_uninitialized`=403 | -| Svelte 5.56.4 | 34 | 33 | 0/1/22 | 0 | 0.0% | 1 | 1 | 0 | 0 | `check_define_var`=1 | `check_define_var`=202, `fclosure8`=154, `define_func`=117, `define_var`=85, `set_loc_uninitialized`=74, `get_var`=65, `get_loc_check`=64, `put_var_init`=63 | - - -## Concurrent isolated renders - -| Fixture | concurrency | renders | throughput | per-render wall median | per-render wall p95 | -|---|---:|---:|---:|---:|---:| -| Preact 10.29.7 | 1 | 30 | 73.6 renders/s | 10.2 ms | 11.55 ms | -| Preact 10.29.7 | 4 | 30 | 239.6 renders/s | 10.98 ms | 12.28 ms | -| Preact 10.29.7 | 8 | 30 | 395.3 renders/s | 12.62 ms | 14.06 ms | -| Vue 3.5.39 | 1 | 30 | 7.6 renders/s | 70.51 ms | 88.52 ms | -| Vue 3.5.39 | 4 | 30 | 16.4 renders/s | 126.54 ms | 151.01 ms | -| Vue 3.5.39 | 8 | 30 | 18.3 renders/s | 219.96 ms | 264.18 ms | -| Svelte 5.56.4 | 1 | 30 | 37.3 renders/s | 16.67 ms | 21.02 ms | -| Svelte 5.56.4 | 4 | 30 | 95.3 renders/s | 23.99 ms | 27.05 ms | -| Svelte 5.56.4 | 8 | 30 | 116.7 renders/s | 33.25 ms | 41.72 ms | - -## 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 | -|---:|---:|---:|---:| -| 100/100 | 468.6 renders/s | -929.3 KiB | 0 | - -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 | -|---|---:|---:|---:|---:|---:| -| Preact 10.29.7 | limit:steps at 3650 | limit:memory_bytes at 133.1 KiB | limit:timeout at 200 ms | 200.88 ms | 34 µs | -| Vue 3.5.39 | limit:steps at 11956 | limit:memory_bytes at 495.9 KiB | limit:timeout at 200 ms | 213.9 ms | 37 µs | -| Svelte 5.56.4 | limit:steps at 1776 | limit:memory_bytes at 198.6 KiB | limit:timeout at 200 ms | 202.46 ms | 30 µs | - -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. diff --git a/docs/beam-compiler-scheduler-measurements.md b/docs/beam-compiler-scheduler-measurements.md deleted file mode 100644 index 1751ba9e2..000000000 --- a/docs/beam-compiler-scheduler-measurements.md +++ /dev/null @@ -1,37 +0,0 @@ -# BEAM VM single-scheduler probe - -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 compiler work. - -- Engine: compiler -- Compiler profile: pure_v1 -- Git base: `bc1aaf50` -- Working tree at measurement: modified -- Generated: 2026-07-16T09:54:39Z -- Elixir: 1.20.2 -- OTP: 29 -- ERTS: 17.0.2 -- OS: Linux 7.0.0-27-generic -- Architecture: x86_64-pc-linux-gnu -- CPU: AMD Ryzen 9 9950X 16-Core Processor -- Online schedulers: 1 -- Vue probe memory limit: 512 MB -- Samples: 10 - -| workload | wall median | wall p95 | ticker gap median | ticker gap p95 | ticker gap max | ticks median | -|---|---:|---:|---:|---:|---:|---:| -| Vue SSR | 166.46 ms | 200.73 ms | 2.0 ms | 8.75 ms | 33.57 ms | 60 | -| sleep baseline (166 ms target) | 166.95 ms | 166.96 ms | 2.0 ms | 2.01 ms | 2.58 ms | 83 | - -Acceptance bound: Vue SSR ticker gap ≤ 75.0 ms. - -## 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 | 50.97 ms | 51.15 ms | 51.15 ms | 974 µs | - -Acceptance bound: timeout p95 ≤ 60.0 ms. diff --git a/docs/beam-compiler-ssr-measurements.md b/docs/beam-compiler-ssr-measurements.md deleted file mode 100644 index 7d3356a4e..000000000 --- a/docs/beam-compiler-ssr-measurements.md +++ /dev/null @@ -1,91 +0,0 @@ -# BEAM compiler SSR measurements - -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. The -single-scheduler fairness and timeout gate is published separately in -[`beam-compiler-scheduler-measurements.md`](beam-compiler-scheduler-measurements.md). - -## Environment - -- Engine: compiler -- Compiler profile: pure_v1 -- Git base: `bc1aaf50` -- Working tree at measurement: modified -- Generated: 2026-07-16T09:53:39Z -- Elixir: 1.20.2 -- OTP: 29 -- ERTS: 17.0.2 -- OS: Linux 7.0.0-27-generic -- Architecture: x86_64-pc-linux-gnu -- CPU: AMD Ryzen 9 9950X 16-Core Processor -- Logical schedulers: 32 -- Mix environment: `bench` -- Samples per fixture: 30 after 3 warmups -- Concurrency levels: 1, 4, 8 - -## Sequential isolated renders - -| Fixture | wall median | wall p95 | VM steps | logical memory | endpoint process memory | reductions median | -|---|---:|---:|---:|---:|---:|---:| -| Preact 10.29.7 | 9.74 ms | 10.49 ms | 3651 | 266.2 KiB | 7.28 MiB | 221689 | -| Vue 3.5.39 | 60.13 ms | 68.72 ms | 11957 | 991.9 KiB | 92.58 MiB | 1266415 | -| Svelte 5.56.4 | 15.7 ms | 18.82 ms | 1777 | 397.1 KiB | 15.61 MiB | 270352 | - -`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 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 | -|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---|---| -| Preact 10.29.7 | 50 | 49 | 0/1/15 | 0 | 0.0% | 1 | 1 | 0 | 0 | `check_define_var`=1 | `if_false8`=269, `get_loc8`=234, `push_atom_value`=234, `dup`=230, `drop`=193, `get_var`=182, `get_arg0`=180, `strict_eq`=109 | -| Vue 3.5.39 | 300 | 296 | 0/3/90 | 50 | 0.4% | 4 | 4 | 0 | 0 | `push_empty_string`=3, `get_var`=1 | `swap`=936, `dup`=664, `get_loc_check`=609, `if_false8`=587, `check_define_var`=480, `get_var`=474, `get_arg0`=467, `set_loc_uninitialized`=402 | -| Svelte 5.56.4 | 34 | 28 | 0/1/22 | 24 | 1.4% | 6 | 6 | 0 | 0 | `get_var`=6 | `check_define_var`=202, `fclosure8`=154, `define_func`=117, `define_var`=85, `get_var`=65, `get_loc_check`=64, `put_var_init`=63, `push_atom_value`=61 | - - -## Concurrent isolated renders - -| Fixture | concurrency | renders | throughput | per-render wall median | per-render wall p95 | -|---|---:|---:|---:|---:|---:| -| Preact 10.29.7 | 1 | 30 | 78.0 renders/s | 9.68 ms | 10.52 ms | -| Preact 10.29.7 | 4 | 30 | 244.4 renders/s | 10.81 ms | 12.04 ms | -| Preact 10.29.7 | 8 | 30 | 410.9 renders/s | 11.28 ms | 13.54 ms | -| Vue 3.5.39 | 1 | 30 | 7.8 renders/s | 71.89 ms | 83.18 ms | -| Vue 3.5.39 | 4 | 30 | 14.7 renders/s | 139.53 ms | 157.85 ms | -| Vue 3.5.39 | 8 | 30 | 15.8 renders/s | 246.22 ms | 286.71 ms | -| Svelte 5.56.4 | 1 | 30 | 35.2 renders/s | 17.79 ms | 20.55 ms | -| Svelte 5.56.4 | 4 | 30 | 89.2 renders/s | 23.69 ms | 27.81 ms | -| Svelte 5.56.4 | 8 | 30 | 111.0 renders/s | 37.49 ms | 46.63 ms | - -## 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 | -|---:|---:|---:|---:| -| 100/100 | 418.1 renders/s | -929.3 KiB | 0 | - -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 | -|---|---:|---:|---:|---:|---:| -| Preact 10.29.7 | limit:steps at 3650 | limit:memory_bytes at 133.1 KiB | limit:timeout at 200 ms | 200.83 ms | 29 µs | -| Vue 3.5.39 | limit:steps at 11956 | limit:memory_bytes at 495.9 KiB | limit:timeout at 200 ms | 213.49 ms | 28 µs | -| Svelte 5.56.4 | limit:steps at 1776 | limit:memory_bytes at 198.6 KiB | limit:timeout at 200 ms | 203.06 ms | 26 µs | - -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. diff --git a/docs/beam-interpreter-architecture.md b/docs/beam-interpreter-architecture.md deleted file mode 100644 index efd43d739..000000000 --- a/docs/beam-interpreter-architecture.md +++ /dev/null @@ -1,970 +0,0 @@ -# BEAM Interpreter Architecture - -Status: implementation in progress for the next major QuickBEAM release. - -Implemented on the development branch: version-locked decoding and verification, -process-isolated evaluation, explicit frames and detached async continuations, -closures, exceptions, owner-local objects, Promise reactions and combinators, -asynchronous `Beam.call`, logical and process memory containment, stable -JavaScript errors, pinned Preact, Vue, and Svelte SSR acceptance fixtures, and -bounded deterministic decoder/verifier mutation fuzzing, the bounded optional -compiler contract, its supervised fixed-slot lifecycle, the minimal generated -code ABI, a generated-module backend with import policy and soft-purge code -lifecycle, specialized fixed-name forms for the first bounded `:pure_v1` -lowering, and selective owner-local nested-function re-entry. Broader -ECMAScript conformance, object-model hardening, garbage -collection, compiler coverage, and release hardening remain in progress. - -## Summary - -QuickBEAM should support executing decoded QuickJS bytecode in ordinary BEAM -processes. The first production target is isolated, CPU-bound server-side -rendering: compile a bundle once, share the immutable program, and evaluate it -in many independently scheduled BEAM processes. - -This is a second execution engine, not a replacement implementation of every -feature of the stateful native QuickJS runtime. - -```elixir -{:ok, program} = QuickBEAM.VM.compile(bundle, filename: "server.js") -{:ok, pinned_program} = QuickBEAM.VM.pin(program) - -Task.async_stream(requests, fn props -> - QuickBEAM.VM.eval(pinned_program, - vars: %{"props" => props}, - timeout: 250, - max_steps: 5_000_000, - memory_limit: 64 * 1024 * 1024 - ) -end) - -QuickBEAM.VM.unpin(pinned_program) -``` - -The immutable program is safe to share. The lightweight pinned handle avoids -copying the decoded function graph through each request and worker process. -Every evaluation owns its JavaScript -heap, globals, jobs, and limits. A failed or timed-out render only terminates its -evaluation process. - -## Goals - -1. Execute QuickJS-generated bytecode as reduction-accounted BEAM code. -2. Let long JavaScript work coexist fairly with other BEAM processes. -3. Isolate failures, timeouts, heaps, globals, and microtask queues per render. -4. Suspend and resume `async`/`await` around asynchronous BEAM handlers without - polling or blocking a scheduler. -5. Compile and decode an SSR bundle once and reuse it concurrently. -6. Preserve the existing `QuickBEAM.JS.Error` error shape where practical. -7. Validate bytecode before execution and reject incompatible artifacts. -8. Establish differential conformance tests against the vendored QuickJS build. -9. Keep the interpreter architecture compatible with a future optional BEAM - compiler without making that compiler part of the first release gate. - -## Non-goals for the first release - -- Replacing the existing stateful native runtime. -- Sharing one mutable JavaScript realm concurrently across BEAM processes. -- Full browser, DOM, Node, N-API, WebAssembly, or Web API parity. -- Full Test262 compliance. -- Loading arbitrary bytecode produced by another QuickJS version or build. -- Runtime compilation of JavaScript directly to BEAM modules. -- Persisting decoded programs across QuickBEAM upgrades without validation. - -## Product model: two explicit engines - -### Native runtime - -The existing `QuickBEAM` runtime remains the stateful engine: - -- one GenServer owns one QuickJS context; -- globals and functions persist across calls; -- calls to one realm serialize; -- DOM, Web APIs, N-API, and the existing compatibility surface remain native. - -### BEAM engine - -`QuickBEAM.VM` is an isolated execution engine: - -- a `%QuickBEAM.VM.Program{}` is immutable and reusable; -- an explicit `%QuickBEAM.VM.PinnedProgram{}` uses a bounded fixed-slot store to - avoid copying the decoded graph between request and evaluation processes; -- each evaluation executes in a dedicated BEAM process by default; -- the JavaScript heap is local to that process; -- there is no implicit mutable persistent state between evaluations; -- concurrent evaluations of one program are independent. - -This distinction must be visible in the API. A `mode: :beam` option on a -stateful runtime is misleading if state actually belongs to whichever process -called `eval/3`. - -A stateful BEAM realm may be added later as `QuickBEAM.VM.Session`. Its owner -process would retain the heap and serialize realm operations. Separate sessions -would still be scheduled independently. - -## Public API - -### Compile and decode - -```elixir -@spec QuickBEAM.VM.compile(String.t(), keyword()) :: - {:ok, QuickBEAM.VM.Program.t()} | {:error, term()} - -@spec QuickBEAM.VM.decode(binary(), keyword()) :: - {:ok, QuickBEAM.VM.Program.t()} | {:error, term()} - -@spec QuickBEAM.VM.pin(QuickBEAM.VM.Program.t()) :: - {:ok, QuickBEAM.VM.PinnedProgram.t()} | {:error, term()} - -@spec QuickBEAM.VM.measure( - QuickBEAM.VM.Program.t() | QuickBEAM.VM.PinnedProgram.t(), - keyword() - ) :: {:ok, QuickBEAM.VM.Measurement.t()} | {:error, term()} -``` - -Suggested compile options: - -- `:filename` — source name used in JavaScript stack traces; -- `:source_type` — `:script` or `:module`; -- `:compiler` — compiler service or pool to use; -- `:debug_info` — retain source position data; -- decoder resource limits such as `:max_bytecode_bytes`. - -`compile/2` asks the vendored QuickJS compiler for serialized bytecode, decodes -it, verifies it, and returns an immutable program. Compilation may use a native -compiler pool, but evaluation must not require a QuickJS execution context. -`pin/1` is an explicit bounded optimization for programs reused across -processes; ordinary `%Program{}` evaluation retains process-copy semantics. See -the [pinned-program investigation](beam-pinned-program-investigation.md). - -`decode/2` is an advanced API. It accepts only bytecode matching the running -QuickBEAM build fingerprint. `measure/2` runs the same isolated evaluation as -`eval/2` while retaining deterministic step/logical-memory counters, fixed -owner-local compiler counters when selected, endpoint process observations, and -end-to-end wall time. - -### Evaluate - -```elixir -@spec QuickBEAM.VM.eval( - QuickBEAM.VM.Program.t() | QuickBEAM.VM.PinnedProgram.t(), - keyword() - ) :: {:ok, term()} | {:error, QuickBEAM.JS.Error.t() | term()} -``` - -Suggested evaluation options: - -- `:vars` — per-evaluation global inputs; -- `:handlers` — explicitly allowed BEAM host calls; -- `:timeout` — wall-clock deadline; -- `:max_steps` — deterministic interpreter instruction budget; -- `:memory_limit` — maximum evaluation process memory; -- `:max_stack_depth` — JavaScript call depth; -- `:isolation` — `:process` by default, optionally `:caller` for trusted code; -- `:return` — conversion policy for the result. - -The safe default is `isolation: :process`. QuickBEAM starts a monitored worker, -awaits its result, and terminates it when the deadline is exceeded. Running in -the caller is useful for trusted low-latency code but cannot provide the same -failure and timeout boundary. - -### Preload in a supervision tree - -Applications compile and pin long-lived bundles during startup, retain the -lightweight handle in their own supervised state, and unpin it during bundle -replacement or shutdown: - -```elixir -{:ok, source} = File.read("priv/server.js") -{:ok, program} = QuickBEAM.VM.compile(source, filename: "server.js") -{:ok, pinned} = QuickBEAM.VM.pin(program) - -# Store `pinned` in the renderer process state. -# QuickBEAM.VM.unpin(pinned) during terminate/replacement. -``` - -The registry is explicit and bounded rather than a transparent cache. It has no -implicit eviction or copied-program fallback, and active owner-monitored leases -defer unpinning until current evaluations finish. - -## Program artifact - -A program should contain only immutable data: - -```elixir -%QuickBEAM.VM.Program{ - format_version: 1, - engine_fingerprint: binary, - source_digest: binary, - filename: binary, - atoms: tuple, - root: QuickBEAM.VM.Function.t(), - features: map -} -``` - -It must not contain: - -- process identifiers or process-dictionary references; -- mutable heap objects; -- handler closures; -- native QuickJS pointers; -- dynamically created module names or atoms derived from JavaScript input. - -Function identifiers must be deterministic within the artifact. Runtime object -identifiers are allocated only inside an evaluation. - -## Compilation pipeline - -```text -source or bundled source - | - v -vendored QuickJS compile-only context - | - v -serialized QuickJS bytecode - | - +--> checksum and build-fingerprint validation - v -bounded bytecode decoder - | - v -control-flow and stack verifier - | - v -immutable QuickBEAM.VM.Program -``` - -The compile-only native context should not install browser polyfills, create a -DOM, or retain a user realm. Compiler contexts can be pooled independently from -stateful QuickBEAM runtimes. - -Bundling and TypeScript transformation remain separate source-toolchain stages. -The BEAM interpreter consumes JavaScript or a prebuilt bundle; it does not need -a second JavaScript parser. - -## QuickJS bytecode compatibility - -QuickJS bytecode is a private ABI. `BC_VERSION` alone is insufficient as a -long-lived compatibility promise. - -The engine fingerprint should include at least: - -- QuickJS-NG source revision or release; -- `BC_VERSION`; -- a digest of `quickjs-opcode.h`; -- a digest of `quickjs-atom.h`; -- relevant QuickJS compile feature flags; -- QuickBEAM bytecode decoder format version. - -Opcode, atom, tag, and flag definitions must be generated from the same vendored -QuickJS source used to build the NIF. Hand-maintained duplicate tables are not -an acceptable release boundary. - -A generated manifest should fail compilation when the vendored headers change -without regenerated VM definitions and fixtures. - -Decoded program caching must use the complete engine fingerprint and source -digest. An incompatible artifact returns a structured error; it never falls -back to best-effort decoding. - -## Decoder and verifier - -The decoder processes untrusted binary input and must be bounded before the -interpreter sees it. - -Required checks include: - -- serialized checksum verification; -- exact version and fingerprint match; -- bounded bytecode, atom, constant, function, and debug-info sizes; -- bounded nested-function depth; -- valid atom and constant references; -- known opcode and operand formats; -- branch targets on instruction boundaries; -- valid exception-region targets; -- stack underflow and declared maximum-stack consistency; -- valid local, argument, closure, and constant indexes; -- no trailing serialized data; -- total decoded instruction limit. - -Decoder failures return errors rather than raising or partially installing -state. Unsigned and signed LEB128 fields use the `varint` package, wrapped with -QuickJS's 32-bit width and encoded-length limits. - -`QuickBEAM.VM.Fuzz` runs deterministic checksum-aware mutations over -representative compiled artifacts. Each case executes twice in a monitored -process with a strict wall-clock timeout and BEAM maximum-heap limit. The normal -test suite runs 2,000 decoder mutations and 1,000 deliberately invalid verifier -mutations. The local and scheduled corpus uses: - -```sh -mix quickbeam.vm.fuzz --iterations 10000 --seed 5325389 -``` - -The corpus covers truncation, insertion, deletion, duplication, bit flips, -malformed and overflowing LEB128 fields, oversized counts, unknown bytes, -trailing data, checksum/version corruption, invalid references and opcodes, -branch and exception targets, captures, stack underflow and declared stack -sizes, and structural metadata. Outcomes use -stable coarse classifications while retaining the exact typed rejection. -Crashes, timeouts, nondeterminism, and accepted invalid verifier programs are -findings. The first 10,000-case run found and fixed an untyped truncation path -in variable-definition decoding; its 23-byte input is retained as a regression -fixture. Findings can be deletion-minimized with `QuickBEAM.VM.Fuzz.minimize/2` -and persisted as `.bin` plus replay metadata with -`QuickBEAM.VM.Fuzz.persist/2`; regression fixtures are replayed under the same -bounds. - -## Evaluation process and ownership - -A dedicated evaluation process owns all mutable state: - -```text -Evaluation process - - interpreter frames and operand stacks - - globals and lexical environments - - JavaScript object heap - - closure cells - - prototypes and shapes - - promise jobs and timers allowed by the profile - - host-call context - - step, stack, memory, and time limits -``` - -Process-dictionary storage is acceptable as an implementation detail only when -execution occurs in a dedicated owner process. Process exit then provides a -strong final cleanup boundary. Heap access must remain behind a storage module -so profiling can justify a different backend later. - -Object references are meaningful only inside their owning evaluation. Public -results are converted to ordinary BEAM values before crossing the process -boundary. Returning live JavaScript object handles is out of scope for isolated -evaluation. - -## Scheduling and limits - -### Scheduler cooperation - -The tail-recursive opcode dispatch loop naturally consumes BEAM reductions and -is preempted by the VM. It should not use a self-message after an arbitrary -number of opcodes merely to simulate scheduling. - -Fairness must be tested under `+S 1`, where a CPU-bound render competes with a -latency-sensitive BEAM process on one scheduler. - -All host operations used during evaluation must also cooperate with the BEAM: - -- CPU-heavy native functions must be dirty NIFs or avoided; -- network and file operations must be asynchronous; -- the interpreter must not busy-poll promises or timers. - -### Step budget - -`max_steps` is a deterministic JavaScript execution limit, separate from BEAM -reductions. Every opcode has a cost, with optional higher weights for expensive -operations. Accounting may be batched for speed, but loops cannot avoid it. - -Exhaustion returns a specific limit error and terminates the evaluation worker. -It must not silently resume with a replenished budget. - -### Wall-clock timeout - -For `isolation: :process`, the supervising caller owns the deadline and kills -the evaluation worker if it does not reply. Internal safepoint checks improve -error reporting but are not the authoritative timeout mechanism. - -### Memory limit - -Use both: - -1. a process-level heap limit as a final containment boundary; and -2. explicit owner-local allocation accounting checked at every interpreter - safepoint for a controlled QuickBEAM limit error. - -`memory_limit` is the JavaScript allocation budget. Objects, properties, -closure cells, Promises, injected variables, and host results are charged to it. -The accounting is intentionally conservative and monotonic until garbage -collection is introduced. Isolated workers additionally use BEAM's -`max_heap_size`, with fixed runtime overhead above the JavaScript budget so the -realm can report controlled allocation failures before the final process -boundary is reached. `memory_limit: :infinity` disables both limits for trusted -diagnostics. - -Regardless of controlled accounting, process termination reclaims the entire -evaluation heap. The process ceiling contains interpreter frames and other BEAM -state that is not represented in the JavaScript object-store counters. - -### Stack limit - -JavaScript call depth is tracked explicitly. It must not rely on exhausting the -BEAM process stack or native C stack. - -### Native resource lifecycle - -The native QuickJS engine remains a separate supported execution engine and its -NIF resources must be safe under concurrent scheduler shutdown. Runtime and -context-pool resources use a serialized `running → stopping → joined` lifecycle; -only the transition owner takes and joins the worker thread, while destructors -perform idempotent shutdown before freeing resource data. - -Synchronous host-call slots are stack-owned by worker calls. Result publishers -hold the slot mutex through writing the result and signaling completion, so the -waiter cannot remove the slot while another scheduler still writes through its -pointer. Context pools likewise hold their RuntimeData registry mutex through -slot publication and remove all externally visible pointers before destroying -contexts. Stress coverage includes concurrent explicit stops, owner exit without -stop, callSync shutdown, pool shutdown, UBSan builds, and ordinary parallel test -execution. - -Native addon initialization is also serialized process-wide. A canonical addon -path is initialized once per BEAM instance; additional aliases in the same -runtime reuse owner-local cached exports without invoking native registration -again. Another runtime, or a runtime reset that creates a new JavaScript -context, receives `{:addon_already_initialized, canonical_path}`. The explicit -`allow_reinitialization: true` escape hatch exists only for addons that document -multi-environment initialization support. Registry growth is capped at 256 -canonical native libraries. - -## JavaScript values and heap - -Keep `null` and `undefined` distinct. Common scalar values may use native BEAM -terms, while semantic sentinels and reference types use explicit tagged values. - -The value representation, coercion rules, property semantics, invocation rules, -and built-ins form one canonical semantic layer shared by the interpreter and -any future compiler. A compiled path must not grow a second implementation of -JavaScript semantics. `QuickBEAM.VM.Value` is the canonical boundary for -truthiness, primitive coercion, arithmetic, equality, comparison, bitwise -operations, `typeof`, and UTF-16 string operations. Opcode dispatch and -built-ins select an operation but do not reimplement it. -`QuickBEAM.VM.Properties` is the canonical boundary for get, put, define, -delete, descriptors, enumeration, primitive properties, and prototype -operations; accessor results remain explicit resumable actions. -`QuickBEAM.VM.Invocation` is the corresponding canonical boundary for ordinary, -bound, constructor, built-in, Promise, and host-function calls. It produces -explicit call actions while the interpreter retains frame scheduling and -boundary resumption. - -Opcode-family modules transform explicit frames and return actions to the -interpreter rather than owning its run loop. `QuickBEAM.VM.Opcodes.Stack` now -handles literal and operand-stack operations, while -`QuickBEAM.VM.Opcodes.Values` handles coercion, arithmetic, comparisons, -bitwise operations, value tests, `in`, and `instanceof`. -`QuickBEAM.VM.Opcodes.Control` handles branches, catch markers, returns, throws, -and await classification. `QuickBEAM.VM.Opcodes.Locals` owns argument/local -slots, closure-cell promotion, globals, atom resolution, and function-closure -allocation. `QuickBEAM.VM.Opcodes.Objects` handles object/array/RegExp -construction, field definition, property access, resumable accessors, deletion, -and `for...in` enumeration. `QuickBEAM.VM.Opcodes.Invocation` decodes ordinary, -method, tail, and constructor call stacks into actions consumed by the canonical -invocation planner. Their published opcode lists are also the interpreter's -routing source, preventing dispatch drift. - -Recommended initial representation: - -- numbers — integer/float plus explicit non-finite sentinels where required; -- strings — binaries with explicit UTF-16-aware operations; -- booleans — `true` and `false`; -- null — `nil`; -- undefined — a dedicated sentinel; -- bigint — tagged arbitrary-precision integer; -- symbol — tagged unique identity; -- object/function — owner-local heap references. - -String indexing, lengths, regular expressions, and source positions must follow -JavaScript UTF-16 semantics even though Elixir binaries are UTF-8. The VM now -uses explicit UTF-16 code-unit operations and preserves lone surrogates as -WTF-8 binaries at the BEAM boundary, matching native QuickJS conversion. - -The owner-local heap enforces data and accessor descriptors across prototype -chains, array length truncation and write restrictions, sparse deletion, -ECMAScript own-key ordering, prototype-cycle rejection, constructor return -rules, and `instanceof`. Native Array callback frames retain explicit holes: -`map` preserves their indices, while `filter`, `forEach`, `some`, and `reduce` -skip them; reduction without an initial value starts at the first present -entry. Default data descriptors are encoded compactly as one-tuples in the existing -property map; accessors and non-default descriptors retain full `Property` structs. -Integer indices never enter the separate insertion-order list. This preserves -one OTP map lookup path and avoids descriptor and quadratic ordering allocation; -see the [object-memory investigation](beam-object-memory-investigation.md). Exact -Vue caller attribution confirms that the remaining persistent-map population is -mostly the outer object heap; rejected alternatives and profiler caveats are in -the [interpreter hotspot investigation](beam-interpreter-hotspot-investigation.md). -Getter, setter, and `Object.assign` calls use resumable -boundaries so arbitrary JavaScript and exceptions do not escape the explicit -machine state. Promise resolution reads accessor-backed `then` properties -synchronously and queues invocation of returned then functions as microtasks. - -## Declarative builtins - -Builtin modules use `QuickBEAM.VM.Builtin` to compile constructor, namespace, -intrinsic, static, prototype, function, data-property, constant, accessor, and -property-alias declarations into immutable specs. Handler atoms are primary and JavaScript -names are inferred unless an explicit camelCase `js:` name is required: - -```elixir -builtin "Array", - kind: :constructor, - constructor: :construct, - depends_on: ["Object", "Function"] do - static :is_array, js: "isArray", length: 1 - - prototype kind: :array, extends: "Object", default_for: :array do - method :map, length: 1 - method :for_each, js: "forEach", length: 1 - end -end -``` - -The formatter preserves this parenthesis-free syntax. Macro compilation, -validation, installation, and runtime dispatch are separate modules. Constants -are evaluated in the declaring module, descriptors and accessors have typed -specs, and each builtin declares one or more profiles plus explicit intrinsic -dependencies. A typed prototype spec is compiled from the nested semantic -`prototype` declaration: `extends`, `kind`, `default_for`, `callable`, -`primitive`, and `error_type` describe JavaScript topology without exposing -installer field names. This models the null-rooted Object prototype, callable -Function prototype, constructor cycles, boxed primitives, and Array defaults -without a bootstrap constructor table. Mix records the complete QuickBEAM -module inventory in the compiled application manifest. The profile registry -filters that inventory for immutable builtin specs, orders them by declared -JavaScript dependencies, and caches the result. The first evaluation of each -profile and registry generation validates and installs those specs into an -immutable host template; -subsequent evaluations seed their owner-local heap and globals from that -persistent template. BEAM's immutable maps provide copy-on-write isolation, so -mutating `Object`, `globalThis`, or any other intrinsic in one evaluation cannot -change another evaluation or the template. References are interpreted only -against the receiving evaluation's heap, and no jobs, handlers, continuations, -or mutable owner state enter the template. The template's exact logical -allocation is charged to every evaluation before user code runs, preserving -memory-limit behavior and deterministic measurement while removing repeated -spec validation and topology construction. This avoids both a manually -duplicated module list and code-loading-order-dependent runtime discovery. - -Installed functions are real owner-local function objects carrying stable -module/handler tokens, not captured closures. Calls receive an explicit -`QuickBEAM.VM.Builtin.Call` containing arguments, receiver, caller, tail mode, -and execution state, and are dispatched through the canonical invocation -planner. Resumable results use a typed `QuickBEAM.VM.Builtin.Action`; malformed -handler results raise an infrastructure contract error. Compile-time checks -reject missing handlers, invalid lengths and descriptors, unknown builtin kinds, -empty profiles, malformed dependencies, duplicate declarations, and duplicate -keys. The migrated slice includes `Math`, Promise, Symbol, Set, the complete -Error hierarchy, `Function.prototype.call`/`bind`, all currently supported -Object statics and prototype methods, every currently supported Array prototype -method, `Array.isArray`, `String.fromCharCode`, and all currently supported -String and Number prototype methods. Resumable callbacks, `Object.assign`, -descriptor validation, Symbol aliases, and intrinsic prototype topology all use -the same installation and invocation paths. Primitive strings, numbers, -internal BEAM lists, functions, and owner-local Set values resolve methods from -installed intrinsic prototypes, eliminating their parallel pseudo-method -implementations. Object, Function, Array, Boolean, Number, and String call and -construction semantics are declarative, and no hard-coded constructor table -remains. The small legacy dispatcher is limited to genuinely unmigrated helpers -such as RegExp methods. Real function metadata increases the intrinsic heap -baseline, which remains included in logical memory accounting. - -## Semantic runtime contract - -The interpreter and a future compiler share one internal semantic contract: - -- `Properties` owns property, descriptor, prototype, and accessor outcomes; -- `Invocation` owns callable classification and explicit invocation actions; -- `Value` owns coercion, equality, arithmetic, and UTF-16 value operations; -- `Async` and `Promise` own suspension, jobs, reactions, and settlement; -- `Exceptions` owns materialization and boundary-aware unwinding; -- typed builtin actions and explicit boundary structs are the only resumable - extension mechanism. - -Compiler extraction must target these layers rather than duplicate their logic -or call the interpreter recursively. Changes to action shapes, boundary fields, -owner-local reference rules, or exception results are runtime-contract changes -and require focused contract tests plus the pinned conformance gate. Heap -references, continuations, and mutable execution state remain evaluation-owner -local under both engines. - -## ECMAScript and host profiles - -The first production profile should be explicit rather than implying browser -parity: - -```elixir -QuickBEAM.VM.eval(program, profile: :ssr) -``` - -The `:ssr` profile contains core ECMAScript built-ins plus the inert, evaluation-local -`console` namespace currently demonstrated by the acceptance fixtures. Text encoding, -URL support, timers, streams, Abort APIs, and networking are not implied by this profile -and require separate fixture-driven additions. - -The pinned compatibility matrix currently covers Preact 10.29.7 with -`preact-render-to-string` 6.7.0, a production-defined Vue 3.5.39 functional component, -and a server-compiled Svelte 5.56.4 component. Each fixture returns real HTML after an -asynchronous `Beam.call`, matches the vendored native QuickJS engine, and reuses one -immutable program across isolated concurrent renders. The Svelte fixture invokes current -compiler output through a minimal fixture renderer; it covers synchronous body and head -generation but does not yet claim compatibility with the full `svelte/server` asynchronous -renderer or streaming support. None of these renderers requires a DOM. - -DOM, fetch, sockets, workers, N-API, and WebAssembly remain unsupported unless -added through separately reviewed profiles. Unsupported features fail clearly; -they do not silently delegate arbitrary object operations to the native runtime. - -## Host calls and async work - -Async/await and asynchronous BEAM handlers are part of the first SSR milestone. -`QuickBEAM.VM.Async` is the canonical state-transition layer for async frame -entry, coroutine detachment and resumption, await suspension, thenable and -reaction planning, Promise-boundary completion, and supervised handler startup. -It returns explicit actions; the interpreter remains responsible for executing -frames and unwinding JavaScript exceptions. Handlers are evaluation options, -not fields in the immutable program: - -```elixir -QuickBEAM.VM.eval(program, - vars: %{"request" => request}, - handlers: %{ - "load_props" => fn [request] -> MyApp.Pages.load_props(request) end - } -) -``` - -`Beam.call` dispatches handler work under a supervised task and immediately -returns a pending JavaScript Promise. Handler completion sends an unforgeable -operation reference and a converted result back to the evaluation owner. -Handler exceptions and task exits reject the Promise; they cannot corrupt the -interpreter heap. - -`Beam.callSync` should be excluded from the initial `:ssr` profile. An arbitrary -Elixir handler called inline can block the evaluation process and weakens -cancellation guarantees. - -Async execution uses explicit suspended continuations: - -1. invocation of an async JavaScript function creates its result Promise; -2. the interpreter reaches `await` and captures the resumable frame, operand - stack, lexical context, exception state, and remaining limits; -3. if the awaited Promise is pending, control returns to the owner event loop; -4. the owner drains runnable microtasks and then waits in `receive` for a host - reply, cancellation, or the next timer deadline; -5. settling a Promise enqueues its reactions in FIFO order; -6. a fulfilled await resumes with its value, while a rejected await resumes - through the JavaScript throw path; -7. timeout or owner exit cancels all outstanding handler tasks. - -Continuations and Promise objects never leave the evaluation owner process, -because they contain owner-local heap references. The event loop must not use -`Process.sleep/1`, repeated short receive timeouts, or recursive promise polling. -When no job is runnable it performs one receive whose timeout is the minimum of -the evaluation deadline and next timer deadline. - -Multiple in-flight handlers are allowed. Replies are correlated by operation -reference and may arrive in any order; JavaScript microtask ordering remains -deterministic after each settlement. Stale replies after cancellation are -ignored. - -Async invocation returns its Promise immediately. An async frame that reaches -`await` detaches from its caller and is scheduled as an owner-local coroutine, -so several suspended async functions and host operations can coexist. Awaiting -an already-settled Promise still enqueues resumption as a microtask rather than -resuming inline. - -The Promise constructor, static combinators, and `.then`, `.catch`, and -`.finally` methods are declarative DSL builtins. The runtime provides FIFO -reactions, resolution and rejection propagation, Promise and thenable -assimilation, self-resolution protection, idempotent resolver functions, and -`all`, `allSettled`, `any`, and `race`. Callback results are assimilated before -their reaction Promise settles, including thenables returned from `.finally`. - -Combinators consume `QuickBEAM.VM.Iterator`, the canonical iterable-value -boundary. The core profile supports sparse arrays (holes yield `undefined`), -insertion-ordered sets, strings, internal BEAM lists, and custom -`Symbol.iterator` objects; non-iterables produce a rejected TypeError Promise. -`Symbol.iterator` is an immutable well-known Symbol value and computed Symbol -property keys remain distinct from strings and enumeration names. - -Custom iterators run as an explicit resumable state machine shared by Promise -combinators and the Set constructor. Iterator getters, the iterator factory, the -cached `next` method, and accessor-backed `done` and `value` reads all dispatch -through canonical invocation without recursive JavaScript execution. Throws -become rejected combinator Promises or synchronous Set-construction exceptions -as required. Each resumed JavaScript call remains subject to the evaluation's -shared step, stack, memory, and timeout limits. - -```text -Evaluation owner Host Task Supervisor ----------------- -------------------- -run bytecode - Beam.call(name, args) - create pending Promise - start child -----------------------> run Elixir handler -continue until await -save continuation -run available microtasks -receive <----------------------------- {operation_ref, result} -settle Promise -run reactions -resume continuation -``` - -The evaluation deadline covers JavaScript execution, waiting for handlers, and -result conversion. The worker tracks every host task it starts. On timeout, -cancellation, or worker shutdown, those tasks are terminated before the final -result is returned. Handlers that create external side effects remain -application responsibilities and should use their own idempotency or -cancellation mechanisms. - -## Errors, tracing, and observability - -`QuickBEAM.VM.Exceptions` is the canonical exception layer. It materializes -catchable heap-backed errors, locates catch targets, unwinds frame and native -boundaries, preserves async stack frames, settles Promise-related boundaries, -and returns explicit continuation actions. The interpreter only executes those -actions. - -JavaScript throws become `%QuickBEAM.JSError{}` with JavaScript name, message, -filename, line, column, and structured JavaScript stack frames when debug -metadata is present. Generated errors are owner-local JavaScript objects with -`Error`, `TypeError`, `ReferenceError`, `RangeError`, `SyntaxError`, `EvalError`, -and `URIError` prototype identity while JavaScript can catch them. They become -`%QuickBEAM.JSError{}` only when they escape the evaluation. Elixir handler -stack traces must never appear in the public JavaScript stack. - -Limit and infrastructure failures should remain distinguishable: - -- JavaScript throw; -- bytecode incompatibility or verification failure; -- step limit; -- timeout; -- memory limit; -- stack limit; -- unsupported opcode or host feature; -- compiler service failure. - -Telemetry events should cover compile/decode/evaluate start and stop, duration, -steps, peak memory, result category, and execution profile. Do not include source -or values by default. - -## Conformance strategy - -Correctness is measured against the exact vendored QuickJS build. - -1. **Decoder fixtures** — compile representative source with QuickJS and assert - decoded functions, operands, constants, stack effects, and source positions. -2. **Opcode tests** — at least one valid fixture per supported opcode family. -3. **Differential tests** — run the same source and inputs through native - QuickJS and the BEAM engine and compare values or error shapes. -4. **Property tests** — arithmetic, coercion, comparison, property descriptors, - arrays, strings, closures, and control flow. -5. **Selected Test262** — publish the exact included set and pass rate; never - describe a selected subset as full compliance. -6. **Real SSR fixture** — render a pinned Preact or React-compatible bundle and - compare HTML against native QuickJS. -7. **Fuzzing** — malformed serialized bytecode and differential source corpora. - -Skipped tests need categorized reasons: unsupported profile, unsupported -language feature, native QuickJS mismatch, harness limitation, or known defect. -The initial pinned baseline selects 22 tests, explicitly excludes four async -harness tests, and passes all 18 supported cases (100%); see -[`test262-conformance.md`](test262-conformance.md) for the exact gate. - -## Performance and scheduler acceptance - -Correctness and isolation are release gates; speculative speedups are not. -Benchmark reports must separate: - -- compilation and decoding; -- cold evaluation; -- warm evaluation; -- result conversion; -- native runtime GenServer round trips; -- interpreter-only execution. - -Minimum scheduler acceptance scenarios: - -- under `+S 1`, a long render does not starve a periodic BEAM process beyond a - defined latency bound; -- a timed-out infinite loop is terminated within a defined grace interval; -- terminating one render does not affect concurrent renders; -- 100 concurrent evaluations have isolated globals and heaps; -- evaluation process memory is reclaimed after completion; -- a compiled program can be shared without copying mutable runtime state. - -The reproducible fixture measurements are published in -[`beam-ssr-measurements.md`](beam-ssr-measurements.md), with the `+S 1:1` -fairness and timeout gates in -[`beam-scheduler-measurements.md`](beam-scheduler-measurements.md), the -release-quarantined compiler SSR matrix in -[`beam-compiler-ssr-measurements.md`](beam-compiler-ssr-measurements.md), the -extended profile in -[`beam-compiler-scalar-ssr-measurements.md`](beam-compiler-scalar-ssr-measurements.md), -warm-loop results in -[`beam-compiler-performance-measurements.md`](beam-compiler-performance-measurements.md), -and their single-scheduler runs in -[`beam-compiler-scheduler-measurements.md`](beam-compiler-scheduler-measurements.md) -and -[`beam-compiler-scalar-scheduler-measurements.md`](beam-compiler-scalar-scheduler-measurements.md). -The reports -separate deterministic VM steps/logical allocation from endpoint BEAM process -observations, wall latency, concurrent throughput, and cancellation. Current -single-scheduler acceptance bounds are a maximum 75 ms ticker gap during the -pinned Vue render and timeout p95 no greater than 60 ms for a 50 ms limit. -Future performance gates should continue to use pinned end-to-end SSR fixtures, -not arithmetic microbenchmarks. - -## Rollout - -The BEAM engine should be opt-in for the first major release: - -```elixir -QuickBEAM.VM.compile/2 -QuickBEAM.VM.eval/2 -``` - -Do not automatically fall back between engines. Silent fallback hides semantic -and scheduling differences. If an application wants fallback, it can choose it -explicitly after handling an unsupported-feature error. - -The optional compiler tier has an explicit, release-quarantined orchestration -path: - -```elixir -children = [{QuickBEAM.VM.Compiler, capacity: 8}] -QuickBEAM.VM.eval(program, engine: :compiler) -``` - -The generic compiler path runs one bounded pure block. Eligible lexical loops -use bounded scalar generated forms, tail-call successor blocks, perform -canonical non-accessor property and owner-local global reads/writes, and return -explicit invocation actions. -They reconstruct the canonical frame only at a verified deoptimization or call -boundary. Compiler -infrastructure failures are typed errors and never restart the program or invoke -native QuickJS. The default -`QuickBEAM.VM` path remains the interpreter until compiler resource, scheduler, -and native differential gates are published. - -After the SSR profile is stable, a convenience integration may preload programs -for Phoenix rendering. A stateful `QuickBEAM.VM.Session` and an optimizing -BEAM compiler are later features with separate acceptance gates. - -## Implementation milestones - -### Milestone 0 — branch extraction and contracts - -- Start from current `master`; do not rebase or merge the prototype history. -- Record the supported SSR fixture and API contract. -- Add the engine fingerprint format and generated QuickJS metadata. -- Extract only decoder fixtures and the smallest reusable data structures. - -Exit gate: current QuickJS bytecode compiles, fingerprints, decodes, verifies, -and rejects stale or malformed artifacts. - -### Milestone 1 — isolated interpreter kernel - -- Implement immutable program/function structures. -- Implement frame, stack, control flow, calls, closures, exceptions, and limits. -- Run each evaluation in a monitored process. -- Represent interpreter suspension and resumption explicitly. -- Add single-scheduler fairness, kill, timeout, and isolation tests. - -Exit gate: core language differential suite passes, infinite loops are safely -contained, and a suspended frame can resume with a value or JavaScript throw. - -### Milestone 2 — canonical object model and core runtime - -- Add objects, arrays, prototypes, descriptors, strings, symbols, iterators, - promises, microtasks, async functions, await, and garbage collection. -- Add supervised asynchronous `Beam.call` handlers and cancellation. -- Keep semantics shared rather than embedding behavior in opcode handlers. - -Exit gate: selected language and built-in Test262 suites meet a published pass -threshold with no unexplained skips, and async differential tests match QuickJS -for fulfillment, rejection, ordering, and nested awaits. - -### Milestone 3 — SSR vertical slice - -- Define the `:ssr` profile. -- Compile a pinned `preact` plus `preact-render-to-string` bundle once. -- Load request data through `await Beam.call("load_props", request)` and render - real HTML after the handler Promise settles. -- Render concurrently with independent request state. -- Compare output, Promise ordering, and errors with native QuickJS. -- Publish scheduler, memory, cancellation, and performance benchmark results - (complete; see the pinned measurement reports). - -Exit gate: the async SSR fixture is correct, isolated, bounded, cancellable, and -operationally observable. - -### Milestone 4 — hardening and release - -- Keep deterministic bounded decoder/verifier mutation corpora and minimized regressions green. -- Audit process-dictionary ownership and cleanup. -- Test malformed programs, handler failures, cancellation, and supervisor exits. -- Document unsupported features and migration guidance. -- Run normal CI with warnings treated as errors. - -Exit gate: no fallback to native execution during BEAM evaluation, all release -limits are enforced, and the compatibility matrix is published. - -### Later — optional compiler and stateful sessions - -Only after interpreter correctness is stable: - -- expand the implemented bounded CFG and specialized pure-block lowering across - decoded JavaScript and native differential fixtures; -- retain the module pool, fixed generated names, runtime ABI, and deoptimization - contract specified in [`beam-compiler-contract.md`](beam-compiler-contract.md); -- call the versioned ABI over the same canonical runtime semantics; -- deopt before unsupported instructions into validated owner-local interpreter - state; -- pass differential, resource, atom-bound, purge, and scheduler acceptance - gates; -- add stateful owner-process sessions if demanded by real workloads. - -## Prototype branch extraction map - -The focused subsystem audit and ordered extraction plan are published in -[`prototype-delta-audit.md`](prototype-delta-audit.md). The prototype is a source -of algorithms and tests, not a branch to merge or a runtime to layer underneath -the clean interpreter. - -Potentially salvage after focused review and adaptation: - -- bytecode LEB128 and serialization tests; -- program/function/variable structures; -- instruction decoder and opcode-family fixtures; -- interpreter frame and selected opcode handlers; -- process-owned heap abstraction; -- coercion, invocation, and object-model differential tests; -- the Preact SSR fixture and benchmark harness. - -Do not carry directly into the first implementation: - -- the 3,025-commit branch history; -- hand-maintained QuickJS v25 opcode/atom tables; -- `mode: :beam` state attached to arbitrary caller processes; -- the experimental QuickJS-bytecode-to-BEAM compiler; -- a second JavaScript source parser/compiler; -- broad browser and Node API reimplementations; -- generated parser test volume unrelated to the SSR release gate; -- hardcoded billion-step budgets or ignored timeout options. - -The extraction should happen as small, reviewable vertical slices on a fresh -branch based on current `master`. - -## Decisions required before implementation - -1. Confirm `preact` plus `preact-render-to-string` as the first pinned SSR - acceptance fixture, or select another renderer. -2. Which asynchronous host operations beyond `Beam.call` and timers belong in - the initial profile, if any? -3. What end-to-end slowdown relative to native QuickJS is acceptable initially? -4. What Test262 subsets and pass threshold define the supported language level? -5. Should `isolation: :caller` be public in the first release or remain an - internal benchmark option? -6. Which error shape should represent process-enforced timeout and memory - termination? diff --git a/docs/beam-interpreter-hotspot-investigation.md b/docs/beam-interpreter-hotspot-investigation.md deleted file mode 100644 index 42a7b6bd4..000000000 --- a/docs/beam-interpreter-hotspot-investigation.md +++ /dev/null @@ -1,155 +0,0 @@ -# BEAM interpreter hotspot investigation - -This report attributes one warm render of the pinned Vue 3.5.39 fixture after -the compact-object work. It records rejected experiments as well as retained -conclusions because reductions and instrumented profiler time did not reliably -predict endpoint latency. - -## Method - -The caller graph is reproducible with: - -```sh -VM_INTERPRETER_FPROF_OUTPUT=/tmp/vue.fprof \ - MIX_ENV=bench mix run bench/vm_interpreter_fprof.exs -``` - -The runner warms the interpreter, then profiles one caller-isolated render with -`:fprof`. Candidate changes were evaluated separately with paired, alternating -runs of the process-isolated Vue fixture. Each controlled run used 200 samples -and preserved output, 11,957 VM steps, and 1,015,684 bytes of logical memory. -Single-scheduler runs used `ERL_FLAGS='+S 1:1'` and CPU affinity. Default-scheduler -checks were also made for the constant-pool experiment. - -`:fprof` is useful here for exact call counts and caller attribution. Its elapsed -and own-time columns are not release measurements: tracing magnifies functions -that recurse or make many calls. Endpoint wall time remains the acceptance gate. - -## Persistent-map attribution - -One Vue render made 5,600 `maps:put/3` calls. The caller graph separates them as -follows: - -| category | calls | share | -|---|---:|---:| -| outer object heap updates | 3,126 | 55.8% | -| object property dictionaries | 1,729 | 30.9% | -| globals and closure cells | 730 | 13.0% | -| promises and other state | 15 | 0.3% | - -The outer-heap total consists of 1,001 object insertions, 983 writes after -property definition, 741 writes after ordinary property assignment, 364 array -index writes, 26 generic object updates, nine descriptor writes, and two -prototype writes. Property dictionaries account for 1,166 compact default-data -writes and 563 exceptional/full-descriptor writes. - -This rules out an unmeasured assumption that descriptor maps dominate the -remaining churn. The largest population is the sequential-ID outer heap, whose -OTP `:array` replacement was already rejected: it slightly reduced retained -heap but increased reductions by 8.1% and regressed Vue `:eprof` CPU by 7.1%. -The accepted persistent map remains the better release representation. - -The same render made 5,491 `maps:find/2` calls, including 4,311 direct object -fetches and 1,073 prototype/property-depth lookups. These are canonical semantic -operations rather than duplicate descriptor lookups. - -## Constant-pool experiment - -Caller tracing initially appeared to identify a larger problem than map churn: -567 nested-function lookups through list-backed constant pools produced about -114,000 recursive list-drop calls. Converting every function constant pool to a -tuple removed that recursion and reduced traced calls from roughly 451,000 to -336,000. - -The isolated endpoint results did not support promotion: - -- reductions fell from about 702,000–705,000 to 584,000–587,000; -- endpoint process memory fell from 97,079,584 to 80,899,872 bytes; -- the controlled single-scheduler wall median increased from 46.69 ms to - 49.45 ms in the representative paired run; -- repeated default-scheduler checks were neutral to slower rather than showing - a stable latency gain. - -A full OTP `:array` constant pool sometimes improved median wall time, but raised -endpoint process memory to 102,676,424 bytes and had unstable tail behavior. A -32-entry hybrid retained the baseline heap class but consistently regressed the -controlled median by about 7%. A separate nested-function index duplicated the -recursive function graph during isolated-process copying and exceeded the -worker resource bound. - -The list constant pool is therefore retained. The result also demonstrates why -recursive call count, reductions, and process heap class cannot substitute for -the pinned endpoint gate. - -## Closure-allocation follow-up - -The allocation caller graph provides a larger dynamic population than the -constant lookups. Of 1,001 object allocations in one Vue render: - -- 567 allocate function objects for `fclosure`; -- 253 allocate eager ordinary `prototype` objects for constructable functions; -- the remaining 181 cover arrays, ordinary objects, regular expressions, and - builtin results. - -Function objects and their prototypes therefore account for 820 allocations. -A callable-only `Heap.allocate/3` specialization removed generic keyword-option -parsing, but did not improve endpoint latency. Across five alternating, -200-sample single-scheduler runs, the median-of-run medians was 47.251 ms for -the baseline and 47.378 ms for the specialization. Reductions fell about 1%, -while the median p95 worsened about 4.7%. - -A second prototype bulk-built each constructable function/prototype pair while -preserving function and prototype IDs, default prototypes, full descriptor -flags, property order, allocation order, steps, and logical charges. A direct -equivalence test compared its final heap and accounting with the canonical -incremental sequence. It reduced `maps:put/3` calls from 5,600 to 4,335 -(-22.6%): object insertion calls fell from 1,001 to 495, property-definition -outer writes from 983 to 477, and full-descriptor dictionary writes from 563 to -57. - -The endpoint gate rejected the bulk path despite that call reduction. Five -alternating, 200-sample `+S 1:1` runs produced: - -| metric | baseline median of runs | bulk pair median of runs | -|---|---:|---:| -| wall median | 46.234 ms | 49.600 ms | -| wall p95 | 49.050 ms | 54.602 ms | -| reductions | 702,287 | 677,930 | -| endpoint process memory | 97,079,584 bytes | 97,079,528 bytes | - -The bulk path regressed median wall time by 7.3% and median p95 by 11.3%, with -no meaningful endpoint-memory improvement. Neither closure-allocation change is -retained. This is a stronger example than the constant pool: even removing more -than one fifth of all traced map writes did not improve real latency. - -## Smaller rejected fast paths - -Three local changes reduced profiler work or BEAM reductions but regressed -paired wall time: - -- replacing the opcode metadata map with a dense tuple reduced reductions by - about 3%, while the median increased by about 7%; -- replacing short-opcode alias map lookups with generated function clauses - reduced reductions by about 10%, while the median increased by about 6%; -- bypassing `%Property{}` construction for 425 ordinary default definitions - left reductions effectively unchanged and increased the median by about 7%. - -None is retained. Small map lookups and the current compact-property path are -already effective under BeamAsm; source-level operation counts did not expose a -better endpoint implementation. - -## Decision - -No runtime representation or fast-path change from this phase is promoted. -Release defaults remain: - -- list-backed immutable constant pools; -- compact `{value}` default descriptors in one property map; -- persistent maps for the owner-local outer heap, globals, and cells; -- canonical incremental property semantics; -- interpreter-first execution with compiler regions disabled. - -Further work should not introduce another allocation representation or bulk -construction tier without new endpoint evidence. It must measure pinned Preact, -Vue, and Svelte wall time, reductions, endpoint process memory, scheduler -behavior, exact steps, and logical memory before changing the release path. diff --git a/docs/beam-object-literal-investigation.md b/docs/beam-object-literal-investigation.md deleted file mode 100644 index 0707c5a7b..000000000 --- a/docs/beam-object-literal-investigation.md +++ /dev/null @@ -1,124 +0,0 @@ -# Object-literal allocation investigation - -This report evaluates semantics-aware one-shot construction of proven -non-escaping object-literal prefixes. The prototype is preserved on branch -`object-literal-plans` at commit `4f17cb7f` and is rejected for the release path. - -## Prototype contract - -The decoder attached bounded plans to immutable functions by function-local -program counter. A plan required at least four statically named default fields -and accepted only straight-line stack, scalar, local, and selected value/object -operations. Calls, control flow, computed definitions, methods, accessors, -spread, and any operation capable of consuming the protected object terminated -analysis. Nested eligible literals had independent plans. - -At runtime the implementation: - -- reserved the exact owner-local object ID at the original `object` instruction; -- charged logical object memory at that instruction; -- retained a fixed tuple builder on the explicit frame stack; -- charged each first property definition at its original `define_field`; -- preserved duplicate-key last-value and first-insertion ordering; -- allowed nested literals while preventing the incomplete outer object from - becoming an ordinary JavaScript value; -- constructed the compact property map once and inserted the object into the - owner heap after the final planned field; -- preserved IDs when nested allocations occurred between object creation and - materialization; -- used no process dictionary, ETS, dynamic atoms, native mutable state, or - cross-evaluation cache. - -The physical heap write was delayed, but deterministic steps, logical allocation, -exceptions, and step-limit rejection remained at canonical instruction -boundaries. - -## Static opportunity - -The final conservative analyzer found: - -| fixture | object opcodes | planned prefixes | planned fields | field counts | -|---|---:|---:|---:|---| -| Preact 10.29.7 | 23 | 2 | 16 | 6, 10 | -| Vue 3.5.39 | 111 | 13 | 99 | five 4-field, two 5-field, two 7-field, two 10-field, one 11-field, one 24-field | -| Svelte 5.56.4 | 20 | 3 | 19 | 4, 6, 9 | - -Vue `:eprof` observed 80 planned allocations and 575 planned fields over five -renders: 16 literals and 115 fields per render. The same profile performed about -948 ordinary heap allocations and 1,028 property definitions per render. The -optimization therefore reached roughly 1.7% of allocations and 11.2% of property -definitions. - -Broader variants were also tested. Allowing resumable calls and default methods -raised Vue's static inventory to 32 prefixes and 171 fields, but retaining -builders across invocation boundaries increased endpoint latency and variance. -That variant was discarded before the final conservative measurement. - -## Ideal repeated-literal fixture - -A controlled fixture creates 5,000 objects with the same four default fields. -Two pinned-core 100-sample runs per representation produced stable results: - -| representation | wall median | reductions median | endpoint process memory | steps | logical memory | -|---|---:|---:|---:|---:|---:| -| canonical incremental maps | 51.95 ms | 5,677,983 | 7.28 MiB | 150,022 | 5.62 MiB | -| one-shot literal plans | 46.69 ms | 5,194,294 | 9.13 MiB | 150,022 | 5.62 MiB | - -One-shot construction reduced wall time by 10.1% and reductions by 8.5% in its -ideal workload. The higher endpoint heap class illustrates why endpoint process -memory is not equivalent to retained term size or peak RSS. - -## Pinned Vue gate - -Three pinned-core, single-scheduler runs used 200 samples after ten warmups. Run -order was alternated: - -| run | canonical median | planned median | canonical p95 | planned p95 | -|---:|---:|---:|---:|---:| -| 1 | 53.81 ms | 49.44 ms | 74.21 ms | 53.50 ms | -| 2 | 46.19 ms | 51.58 ms | 50.63 ms | 56.96 ms | -| 3 | 46.07 ms | 49.43 ms | 48.01 ms | 57.18 ms | - -The first run reflected machine warmup. In both subsequent orderings the planned -path was 7–12% slower. Median-of-run medians was 46.19 ms for canonical maps and -49.44 ms for plans, a 7.1% regression. Median reductions improved only about -0.3%, steps and logical memory were exact, and endpoint process-memory classes -were unchanged. - -Five-render `:eprof` totals were effectively neutral: approximately 450 ms for -both implementations. Avoiding a small number of persistent map updates did not -amortize tuple-builder updates, list accumulation, reversal, and final `Map.new/1` -on Vue's short and mostly cold literal prefixes. - -The experimental scheduler probe remained within the existing bounds, but that -does not override failure of the Vue latency gate. - -## Correctness - -The experimental branch passed: - -- 246 VM tests with three explicit skips; -- all six pinned Test262 interpreter/compiler gates; -- duplicate keys, nested literals, caught exceptions, exact resource parity, and - exact step-limit rejection; -- existing Preact, Vue, and Svelte parity, async, cancellation, and concurrency - tests; -- warnings-as-errors and focused new-module Credo checks. - -Correctness was not the reason for rejection. - -## Decision - -Do not merge object-literal builders. Compact incremental property maps remain -the canonical implementation. - -The experiment establishes an important boundary: one-shot construction is -valuable when a repeated literal has at least four fields, but current pinned SSR -executes too few eligible literals to offset builder and finalization overhead. -Expanding across calls improves static coverage while worsening the release -endpoint. - -Further object optimization should begin with exact call-site attribution of the -remaining `maps:put/3` and object-heap updates. Another speculative allocation -tier should not be added unless dynamic measurements identify a substantially -larger hot population than either bounded shapes or object-literal plans reached. diff --git a/docs/beam-object-memory-investigation.md b/docs/beam-object-memory-investigation.md deleted file mode 100644 index ede47d10f..000000000 --- a/docs/beam-object-memory-investigation.md +++ /dev/null @@ -1,195 +0,0 @@ -# BEAM object-memory investigation - -This investigation studies QuickBEAM's JavaScript object heap against Erlang/OTP -29 and current OTP `master`. It is an implementation-specific optimization -record, not a general JavaScript performance claim. - -## What OTP actually optimizes - -BeamAsm performs load-time instruction specialization but deliberately does very -little cross-instruction optimization. The Erlang compiler's SSA, type, alias, -and liveness passes therefore determine which native fast paths are reachable. -The relevant sources are: - -- [`BeamAsm.md`](https://github.com/erlang/otp/blob/master/erts/emulator/internal_doc/BeamAsm.md) -- [`jit/x86/instr_map.cpp`](https://github.com/erlang/otp/blob/master/erts/emulator/beam/jit/x86/instr_map.cpp) -- [`jit/x86/instr_common.cpp`](https://github.com/erlang/otp/blob/master/erts/emulator/beam/jit/x86/instr_common.cpp) -- [`beam_common.c`](https://github.com/erlang/otp/blob/master/erts/emulator/beam/beam_common.c) -- [`erl_map.c`](https://github.com/erlang/otp/blob/master/erts/emulator/beam/erl_map.c) - -Small map literals with literal keys are excellent construction targets. The x86 -JIT emits their headers and values directly at `HTOP`, shares the literal key -tuple, and can move adjacent values with SIMD. Tuple construction is similarly -direct. In contrast, associative and exact map updates generally enter shared C -runtime fragments. Flatmaps contain at most 32 entries; inserting a key copies -keys and values, while exact updates can retain the old key tuple but still copy -the value vector. Larger maps are persistent HAMTs. - -OTP 26 and later can combine fixed-size tuple updates into `update_record`. -Recent compiler alias analysis can mark a dead, unique tuple `inplace`, allowing -the JIT to overwrite it when doing so cannot create an old-to-young pointer. -This is useful for fixed internal state authored as Erlang records. It does not -make dynamic JavaScript property dictionaries mutable. OTP 29 native records are -experimental and require atom field names, so they are not suitable for -user-derived JavaScript keys. - -Inspecting assembly is therefore useful, but only in a measured loop: - -1. inspect BEAM shape with `erlc -S` or `:beam_disasm.file/1`; -2. inspect the matching BeamAsm emitter and runtime helper; -3. use `+JPperf true`, `perf`, reductions, GC observations, and `:eprof` on the - real workload. - -`erts_debug:disassemble/1` returns `false` on BeamAsm because that disassembler -is compiled only for the non-JIT emulator. - -### QuickBEAM's emitted BEAM - -`:beam_disasm.file/1` confirms the shape of compact writes rather than assuming -source-level syntax is cheap. `store_default_array_index/6` and the common -`put_default_property/3` path emit a `test_heap 2` plus `put_tuple2` for -`{value}`, then call `maps:put/3` for the property dictionary. The array -`%Object{}` update is one `put_map_exact` carrying both `:properties` and -`:length`; the ordinary-object path updates `:properties`. The outer heap still -requires another `maps:put/3`, followed by a final exact `%Execution{}` map -update. A new property calls `Memory.charge_property/3`; replacement skips that -charge before allocating the compact tuple. - -The full descriptor path constructs a `%Property{}` flatmap before performing -the same persistent dictionary, object, outer-heap, and execution updates. The -assembly therefore predicts exactly the measured result: compact descriptors -remove substantial per-element retained allocation, but they do not remove the -persistent update cascade. Hidden classes and a different owner-local heap -layout are required to address that remaining CPU cost. - -## Allocation pathology found - -The VM stored every array index twice: - -- as an integer key in `Object.properties`; and -- in `Object.property_order` using `property_order ++ [key]`. - -ECMAScript integer keys are already enumerated from the property dictionary and -sorted numerically. The insertion-order list is consulted only for non-integer -keys. Sequential array growth was therefore performing a useless quadratic list -append and retaining every index twice. - -The first fix removes integer keys from `property_order` and bulk-constructs -literal arrays in one heap update. Exact VM steps and logical memory accounting -are unchanged. - -A second representation improvement encodes every default data descriptor -directly in the existing property map as the one-tuple `{value}`. Accessors and -data properties with non-default descriptor flags remain full -`%QuickBEAM.VM.Property{}` values. Reflection expands the compact form only at -the canonical descriptor boundary. Common writes avoid constructing a transient -full descriptor, and property definition reuses its validated candidate rather -than building it twice. This keeps one OTP map, one lookup path, and canonical -descriptor behavior. No input-derived atoms, ETS table, process dictionary, or -native mutable state is introduced. - -## Measurements - -A synthetic retained workload creating 5,000 outer-array entries and 10,000 -ordinary objects showed why the first fix matters: - -- wall observation: 110.35 ms to 84.22 ms; -- reductions: 8.22 million to 7.83 million; -- retained insertion-order cells: 40,352 to 35,352. - -The dedicated 20,000-element isolated array fixture compares the pre-compaction -layout with the compact default descriptor: - -| layout | wall median | reductions median | endpoint process memory | steps | logical memory | -|---|---:|---:|---:|---:|---:| -| full descriptor per element | 74.87 ms | 9,803,575 | 7.28 MiB | 280,022 | 1.95 MiB | -| compact default descriptor | 72.92 ms | 9,655,746 | 2.78 MiB | 280,022 | 1.95 MiB | - -The compact array representation reduces endpoint process memory by about 62%, -reductions by 1.5%, and observed wall time by 2.6% in that paired run. - -A second paired fixture retains 5,000 ordinary three-property objects: - -| layout | wall median | reductions median | endpoint process memory | retained VM heap | steps | logical memory | -|---|---:|---:|---:|---:|---:|---:| -| full ordinary descriptors | 45.50 ms | 5,011,812 | 9.13 MiB | 3.30 MiB | 130,022 | 5.14 MiB | -| compact ordinary descriptors | 44.97 ms | 4,904,137 | 7.28 MiB | 2.27 MiB | 130,022 | 5.14 MiB | - -This lowers the shared retained VM heap by 31%, reductions by 2.1%, and observed -wall time by 1.2%. Endpoint process memory is less stable because it reports -allocated BEAM heap classes rather than only live terms. The reproducible report -therefore includes both endpoint memory and diagnostic `:erts_debug.size/1` -retained bytes: -[`beam-object-memory-measurements.md`](beam-object-memory-measurements.md). - -Three paired five-render Vue `:eprof` repetitions had median totals of 466.44 ms -before ordinary-object compaction and 462.88 ms after it. The improvement is -small but, together with lower deterministic reductions, rules out hiding a CPU -regression behind the retained-memory win. - -## Rejected representations - -OTP's `array` module is a mature persistent 16-way tuple tree and is excellent -when its API amortizes surrounding work. In a standalone 20,000-entry build it -used 22,705 words and took about 0.60 ms, versus 293,811 words and 4.98 ms for a -map of full descriptors. However, placing `:array` behind every canonical -JavaScript property operation added Erlang call, wrapper, and tree-update costs. -Pinned SSR reductions rose materially and endpoint heap classes increased. The -representation was rejected. - -A separate raw element map plus exceptional descriptor map also reduced retained -terms but required two lookup/update paths and enlarged array objects. It -regressed SSR reductions and was rejected. Encoding compact values in the -existing map retained the memory benefit without a second persistent structure. - -The evaluation's outer object heap has dense, monotonic integer IDs, so OTP -`:array` was also tested there rather than as a JavaScript property store. The -full VM and pinned Test262 gates passed. However, the 5,000-object fixture reduced -retained heap size only from 2.32 MiB to 2.22 MiB while increasing reductions by -8.1%; the 20,000-element fixture had negligible retained improvement and 4.4% -more reductions. Three paired Vue `:eprof` repetitions had median CPU totals of -567.10 ms for the persistent map and 607.12 ms for `:array`, a 7.1% regression. -The outer persistent map remains the better fit. - -Private ETS was not selected. ETS would copy inserted and fetched terms, lose -literal/subterm sharing, move memory outside the evaluation process's -`max_heap_size`, and require separate accounting and cleanup. It remains a -candidate only if a future measured overlay beats the persistent one-map design. - -## Bounded shapes were tested - -Ordinary objects no longer retain a descriptor struct for default fields, but -each new field still performs a property-map update, an object-struct update, -and an outer heap-map update. A bounded owner-local hidden-class prototype tested -shared immutable shapes, compact values tuples, a 256-transition cap, a 32-field -cap, and canonical dictionary fallback. - -The ideal 5,000-object fixture reduced retained VM heap by 13.4%, but increased -reductions by 8.1%. Vue had only 82 wholly eligible objects among 1,130 total; -its 50-sample median regressed from 47.07 ms to 50.30 ms with no endpoint memory -change. The runtime shape representation was rejected. See the -[bounded object-shape investigation](beam-object-shape-investigation.md). - -A decode-time object-literal implementation was subsequently tested with reserved -identity, exact per-instruction accounting, fixed tuple builders, and one-shot -map construction. It improved an ideal repeated four-field fixture by 10.1%, but -only reached 16 literals per Vue render and regressed Vue's controlled median by -7.1%. It was also rejected; see the -[object-literal allocation investigation](beam-object-literal-investigation.md). - -If either technique is revisited, admission must come from a substantially larger -measured dynamic population rather than static opportunity. Fixed metadata -helpers may use Erlang records where OTP's `update_record` optimization is -measurable, but OTP 29 native records remain too experimental for the runtime -contract. - -## Process heap sizing - -OTP's efficiency guide explicitly recommends a larger `min_heap_size` for -short-lived compute processes. QuickBEAM's isolated evaluation owner is a good -match because process termination bulk-reclaims the heap. A controlled Vue probe -found a best inner-evaluation median near a 1 MiB initial heap, but repeated -system-level SSR runs were too noisy and small fixtures retained roughly an -extra MiB heap class. No default was changed. A future adaptive policy must be a -separate, paired benchmark with concurrency, scheduler, peak-memory, binary -retention, and low-memory-limit gates. diff --git a/docs/beam-object-memory-measurements.md b/docs/beam-object-memory-measurements.md deleted file mode 100644 index e07c12d11..000000000 --- a/docs/beam-object-memory-measurements.md +++ /dev/null @@ -1,29 +0,0 @@ -# BEAM VM object-memory measurements - -These fixtures retain a sequential JavaScript array and an array of ordinary -three-property objects. They measure the canonical interpreter path, -including isolated-process startup and result conversion. Endpoint process -memory is not a sampled peak or operating-system RSS value. - -## Environment - -- Git base: `dff74297` -- Working tree at measurement: modified -- Generated: 2026-07-16T15:09:18Z -- Elixir: 1.20.2 -- OTP: 29 -- ERTS: 17.0.2 -- Architecture: x86_64-pc-linux-gnu -- CPU: AMD Ryzen 9 9950X 16-Core Processor -- Samples per fixture after 3 warmups: 30 - -| workload | entries | wall median | wall p95 | reductions median | endpoint process memory | retained VM heap | VM steps | logical memory | -|---|---:|---:|---:|---:|---:|---:|---:|---:| -| sequential array | 20000 | 70.45 ms | 73.14 ms | 9627442 | 4.5 MiB | 1017.3 KiB | 280022 | 1.95 MiB | -| ordinary objects | 5000 | 44.32 ms | 45.27 ms | 4907714 | 9.13 MiB | 2.32 MiB | 130022 | 5.14 MiB | - -VM steps and logical memory are deterministic. Retained VM heap uses -`:erts_debug.size/1` after direct canonical interpretation and includes -shared subterms once; it is diagnostic rather than a supported OTP API. -Endpoint process memory is observational and reflects allocated heap classes, -not only live terms. diff --git a/docs/beam-object-shape-investigation.md b/docs/beam-object-shape-investigation.md deleted file mode 100644 index 45e8503f7..000000000 --- a/docs/beam-object-shape-investigation.md +++ /dev/null @@ -1,144 +0,0 @@ -# Bounded object-shape investigation - -This report evaluates owner-local hidden classes after compact default property -descriptors had already removed most descriptor allocation. The prototype is -preserved on branch `hidden-object-shapes` at commit `fc609df9` and is rejected -for the release path. - -## Opportunity probe - -A temporary completed-evaluation probe grouped final Vue 3.5.39 objects by -ordered default binary keys. It did not mutate shared state and was removed after -measurement. - -- total heap objects: 1,130; -- ordinary objects: 361; -- objects containing only shape-eligible default binary properties: 82; -- distinct eligible final shapes: 40; -- empty eligible objects: 29; -- most frequent non-empty shape: five 27-field Vue VNodes; -- most other eligible shapes occurred once to four times. - -Only 7.3% of all objects and 22.7% of ordinary objects were wholly eligible. -Framework bootstrap objects commonly mix accessors, symbols, non-default flags, -or one-off wide dictionaries. Compact descriptors had therefore already taken -the low-risk portion of the memory win, while a pure slot representation could -cover only a small final-heap subset. - -## Prototype contract - -The prototype deliberately preserved the VM's ownership and boundedness rules: - -- immutable shapes and values tuples owned by one evaluation; -- binary keys and integer shape identities, never input-derived atoms; -- at most 256 transitions and 32 fields per shape; -- transition metadata stored with existing owner-local prototype metadata so the - hot `%Execution{}` struct did not gain fields; -- host templates built with shapes disabled, then evaluation-local transitions - enabled after copy-on-write installation; -- ordinary objects with `internal: nil` only; -- full dictionary fallback for accessors, non-default descriptors, deletion, - non-binary keys, capacity exhaustion, and unsupported layouts; -- canonical `Heap`, `Properties`, descriptor, enumeration, export, and logical - memory behavior; -- no ETS, process dictionary, native mutable state, or cross-evaluation shape - sharing. - -`QuickBEAM.VM.ObjectStorage` isolated dictionary and slot layouts. A shaped -object stored `{:slots, shape, values}` in the existing `properties` field, so -all objects did not pay for extra struct fields. Cached transitions reused -immutable key tuples and key-to-slot maps. Guarded lookups returned compact -`{value}` descriptors and exceptional writes materialized a canonical map before -performing their effect. - -## Correctness - -The experimental branch passed: - -- 244 VM tests with three explicit skips; -- all six pinned Test262 interpreter/compiler gates; -- exact step and logical-memory assertions; -- property ordering, accessors, deletion, reflection, export, SSR parity, - concurrency, and compiler-profile tests; -- focused shape sharing, exceptional fallback, and 256-transition capacity - tests. - -Correctness and boundedness were not the reason for rejection. - -## Measurements - -All comparisons used the same OTP 29 / ERTS 17.0.2 environment and the existing -reproducible benchmark scripts. - -### Retained object fixture - -The 5,000-object fixture creates three default fields per object. - -| representation | wall median | reductions median | retained VM heap | endpoint process memory | -|---|---:|---:|---:|---:| -| compact property maps | 44.59 ms | 4,908,322 | 2.32 MiB | 9.13 MiB | -| bounded shapes and slots | 42.68 ms | 5,307,243 | 2.01 MiB | 7.28 MiB | - -Slots reduced retained VM heap by 13.4% and the wall observation by 4.3%, but -increased deterministic reductions by 8.1%. This synthetic workload gives every -object exactly the same ideal shape and therefore represents an upper-bound use -case rather than SSR behavior. - -### Pinned SSR - -A 50-sample sequential comparison produced: - -| fixture | compact maps | bounded shapes | change | reduction change | endpoint memory change | -|---|---:|---:|---:|---:|---:| -| Preact 10.29.7 | 8.37 ms | 8.39 ms | +0.2% | +5.1% | none | -| Vue 3.5.39 | 47.07 ms | 50.30 ms | +6.9% | +4.4% | none | -| Svelte 5.56.4 | 12.30 ms | 12.25 ms | -0.4% | +5.4% | none | - -Three additional paired 30-sample Vue runs had compact-map medians of 46.36, -46.68, and 52.39 ms. Shape medians were 50.62, 84.17, and 51.75 ms. Discarding -neither outliers nor unfavorable runs, the shape path was slower and more -variable. - -Three five-render `:eprof` repetitions were mixed: median attributed CPU was -470.41 ms for compact maps and 463.10 ms for shapes. That small profiler-only -improvement did not translate into the release-gating endpoint and accompanied -higher reductions. No CPU win is claimed. - -## Why it lost - -The existing compact map performs one property-map lookup and retains only a -one-tuple around default values. The shape path replaces that with: - -- representation dispatch; -- a shape index-map lookup followed by tuple access; -- transition-map admission on new fields; -- values-tuple copying; -- eventual dictionary materialization for mixed objects. - -Those costs are amortized in the ideal repeated-shape fixture, but Vue has few -repeated wholly eligible shapes. A dynamic first-use transition policy also -spends work on one-off bootstrap objects before knowing whether a shape will be -reused. The retained reduction was too small to alter SSR endpoint heap classes. - -## Decision - -Do not merge the runtime shape representation. Compact property maps remain the -canonical release implementation. - -A future shape design must avoid speculative transition work. Viable directions -are narrower and semantics-informed: - -1. decode-time object-literal plans keyed by immutable function/PC identity; -2. bulk final-shape construction only when bytecode proves the object cannot - escape during construction; -3. measured admission after repeated complete key sequences, not first-use - transition creation; -4. hybrid default slots plus an exceptional sidecar, but only if mixed-object - coverage justifies its permanent object overhead; -5. generated monomorphic property access only after a shape representation wins - the interpreter-first SSR gate. - -Any replacement must again preserve bounded binary identities, owner-local -state, dictionary fallback, exact accounting, Test262, SSR parity, and scheduler -containment. It must improve pinned Vue rather than only a synthetic ideal-shape -fixture. diff --git a/docs/beam-pinned-program-investigation.md b/docs/beam-pinned-program-investigation.md deleted file mode 100644 index e47915595..000000000 --- a/docs/beam-pinned-program-investigation.md +++ /dev/null @@ -1,139 +0,0 @@ -# Bounded pinned-program investigation - -This investigation profiles the process-isolated BEAM interpreter without -call tracing and addresses the largest measured endpoint cost. It applies only -to immutable, already verified `QuickBEAM.VM.Program` values. - -## Non-instrumented profile - -The pinned Vue 3.5.39 fixture was sampled on Linux/OTP 29 with: - -```sh -ERL_FLAGS='+S 1:1 +JPperf true' perf record -F 997 -e cycles:u -- \ - env MIX_ENV=bench mix run bench/vm_interpreter_perf.exs --mode copied - -ERL_FLAGS='+S 1:1 +JPperf true' perf record -F 997 -e cycles:u -- \ - env MIX_ENV=bench mix run bench/vm_interpreter_perf.exs --mode pinned -``` - -`kernel.perf_event_paranoid` was lowered only for the profiling command and -restored afterward. `+JPperf true` supplied BeamAsm symbols to `perf`. - -A caller-local render spends its time in interpreter work: map element loads, -map updates, minor collection, opcode dispatch, list append, equality, and hash -map operations. No individual Elixir semantic helper beyond the interpreter -loop owns a large fraction of cycles. - -The ordinary isolated path was different. Its largest flat samples were: - -| runtime symbol | cycles | -|---|---:| -| `sweep_off_heap` | 16.55% | -| `full_sweep_heaps` | 15.96% | -| `copy_struct_x` | 10.68% | -| `sweep_new_heap` | 7.82% | -| `erts_cleanup_offheap_list` | 5.12% | -| `size_object_x` | 2.54% | - -Together these account for about 58.7% of sampled cycles. The immutable decoded -program was captured by the worker closure and copied onto every evaluation -process heap, then traversed again when that process exited. On this fixture the -program has extensive structural sharing but a flat copy size of roughly 7.4 -million words. This explains why reducing interpreter map writes did not improve -the old endpoint: process copying and teardown dominated it. - -## Design - -`QuickBEAM.VM.pin/1` explicitly places a verified immutable program in -`QuickBEAM.VM.ProgramStore` and returns a small `%QuickBEAM.VM.PinnedProgram{}` -handle. Evaluating the handle acquires an owner-monitored lease and fetches the -program from a fixed `:persistent_term` slot before spawning the worker. BeamAsm -retains that literal reference across the spawn instead of copying the graph. -The request process and evaluation worker therefore copy only the handle and -options, not the decoded function graph. - -The store is deliberately not an automatic cache: - -- sharing is explicit; -- the default capacity is eight and the hard maximum is 32; -- a pinned serialized bytecode input is at most 2 MiB; -- the supported external-term size is at most 32 MiB per decoded program and - 128 MiB across installed and in-flight admissions; -- slots use fixed integer keys and binary program identities; -- there is no input-derived atom and no implicit eviction; -- concurrent first admission is single-flight and idempotent by program identity; -- every lease has a unique reference and owner monitor; -- owner death returns its lease; -- explicit release waits for active leases and then erases the slot; -- manager restart restores fixed slots without copying programs into its state; -- failed persistent-term installation returns a typed capacity error rather - than crashing evaluation; -- ordinary `%Program{}` evaluation retains the prior copied-process behavior. - -Program identities include the bytecode ABI/version, bytecode digest, source -digest, and filename. JavaScript heaps, globals, cells, jobs, counters, handlers, -and continuations remain evaluation-owner-local. Pinned program memory is a -bounded global immutable resource and is intentionally separate from endpoint -process memory and logical JavaScript allocation. - -Typical use is: - -```elixir -{:ok, program} = QuickBEAM.VM.compile(bundle, filename: "server.js") -{:ok, pinned} = QuickBEAM.VM.pin(program) - -Task.async_stream(requests, fn props -> - QuickBEAM.VM.eval(pinned, vars: %{"props" => props}) -end) - -QuickBEAM.VM.unpin(pinned) -``` - -## Admission cost - -Sharing is an initialization operation, not part of the warm-render numbers. -After adding the decoded-term residency check, three fresh-VM Vue admissions -took 17.317, 22.615, and 18.448 ms (18.448 ms median). Admission reserves a slot -through the store but measures and installs the persistent term in the caller, -avoiding a giant GenServer message. The program retained -about 310,582 words (approximately 2.37 MiB) in the persistent slot according -to the unsupported ERTS debug size diagnostic, -while its process-copy flat size was 7,370,007 words (approximately 56.23 MiB). -The handle's external encoding was 95 bytes. The store process itself retained -only 3,088 bytes because program terms never enter its mailbox or state. - -These are OTP implementation observations, not logical JavaScript memory or a -stable public size API. Admission uses the supported external-term size as a -conservative bound; Preact, Vue, and Svelte measured 466,461, 10,947,914, and -2,155,557 bytes respectively. It deliberately counts repeated references rather -than relying on retained sharing diagnostics. Applications should pin long-lived -bundles through one lifecycle owner during startup and unpin them during -replacement or shutdown, not churn slots per request. Repeated pins of the same -identity return the same global handle rather than independent ownership counts. - -## Pinned SSR result - -The reproducible report is -[`beam-pinned-program-measurements.md`](beam-pinned-program-measurements.md). -Against the prior copied-program report on the same machine: - -| fixture | copied median | pinned median | copied endpoint memory | pinned endpoint memory | -|---|---:|---:|---:|---:| -| Preact 10.29.7 | 8.22 ms | 7.01 ms | 4.5 MiB | 257.7 KiB | -| Vue 3.5.39 | 49.21 ms | 11.01 ms | 77.15 MiB | 673.3 KiB | -| Svelte 5.56.4 | 15.15 ms | 6.99 ms | 15.61 MiB | 673.3 KiB | - -At concurrency eight, Vue improved from 18.1 renders/s with a 204.55 ms median -to 684.7 renders/s with an 11.07 ms median. Exact VM steps, logical memory, -rendered output, limits, cancellation, and native parity are unchanged. - -The published -[`beam-pinned-program-scheduler-measurements.md`](beam-pinned-program-scheduler-measurements.md) -report measured a 4.91 ms Vue median without the SSR report's fixed 5 ms handler -delay, a 2.04 ms maximum ticker gap against the 75 ms bound, and a 51.0 ms -timeout p95 against the 60 ms bound. - -After sharing, the endpoint `perf` profile returns to interpreter work. Program -copy helpers fall below 0.5% individually; minor collection is 8.48%, map element -loads are the largest execution primitive, and no new semantic representation -is justified by the remaining profile. diff --git a/docs/beam-pinned-program-measurements.md b/docs/beam-pinned-program-measurements.md deleted file mode 100644 index 31893cef2..000000000 --- a/docs/beam-pinned-program-measurements.md +++ /dev/null @@ -1,80 +0,0 @@ -# BEAM VM SSR measurements - -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. -The single-scheduler fairness and timeout gate is published separately in [`beam-scheduler-measurements.md`](beam-scheduler-measurements.md). - -## Environment - -- Engine: interpreter -- Compiler profile: pure_v1 -- Compiler regions: false -- Pinned program handles: true -- Git base: `8f498a5a` -- Working tree at measurement: modified -- Generated: 2026-07-16T21:50:19Z -- Elixir: 1.20.2 -- OTP: 29 -- ERTS: 17.0.2 -- OS: Linux 7.0.0-27-generic -- Architecture: x86_64-pc-linux-gnu -- CPU: AMD Ryzen 9 9950X 16-Core Processor -- Logical schedulers: 32 -- Mix environment: `bench` -- Samples per fixture: 100 after 10 warmups -- Concurrency levels: 1, 4, 8 - -## Sequential isolated renders - -| Fixture | wall median | wall p95 | VM steps | logical memory | endpoint process memory | reductions median | -|---|---:|---:|---:|---:|---:|---:| -| Preact 10.29.7 | 7.01 ms | 8.0 ms | 3651 | 266.2 KiB | 257.7 KiB | 137493 | -| Vue 3.5.39 | 11.01 ms | 12.4 ms | 11957 | 991.9 KiB | 673.3 KiB | 630442 | -| Svelte 5.56.4 | 6.99 ms | 7.23 ms | 1777 | 397.1 KiB | 673.3 KiB | 124663 | - -`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. - - -## Concurrent isolated renders - -| Fixture | concurrency | renders | throughput | per-render wall median | per-render wall p95 | -|---|---:|---:|---:|---:|---:| -| Preact 10.29.7 | 1 | 100 | 142.3 renders/s | 6.98 ms | 7.56 ms | -| Preact 10.29.7 | 4 | 100 | 547.9 renders/s | 7.09 ms | 8.07 ms | -| Preact 10.29.7 | 8 | 100 | 1068.4 renders/s | 7.09 ms | 7.86 ms | -| Vue 3.5.39 | 1 | 100 | 90.0 renders/s | 10.98 ms | 12.16 ms | -| Vue 3.5.39 | 4 | 100 | 361.5 renders/s | 10.95 ms | 12.2 ms | -| Vue 3.5.39 | 8 | 100 | 684.7 renders/s | 11.07 ms | 12.01 ms | -| Svelte 5.56.4 | 1 | 100 | 141.3 renders/s | 6.97 ms | 7.9 ms | -| Svelte 5.56.4 | 4 | 100 | 562.0 renders/s | 6.98 ms | 7.78 ms | -| Svelte 5.56.4 | 8 | 100 | 1072.1 renders/s | 6.99 ms | 7.96 ms | - -## 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 | -|---:|---:|---:|---:| -| 100/100 | 6240.6 renders/s | -12.5 KiB | 0 | - -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 | -|---|---:|---:|---:|---:|---:| -| Preact 10.29.7 | limit:steps at 3650 | limit:memory_bytes at 133.1 KiB | limit:timeout at 200 ms | 200.3 ms | 36 µs | -| Vue 3.5.39 | limit:steps at 11956 | limit:memory_bytes at 495.9 KiB | limit:timeout at 200 ms | 201.08 ms | 5 µs | -| Svelte 5.56.4 | limit:steps at 1776 | limit:memory_bytes at 198.6 KiB | limit:timeout at 200 ms | 200.68 ms | 19 µs | - -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. diff --git a/docs/beam-pinned-program-scheduler-measurements.md b/docs/beam-pinned-program-scheduler-measurements.md deleted file mode 100644 index d47574bd7..000000000 --- a/docs/beam-pinned-program-scheduler-measurements.md +++ /dev/null @@ -1,38 +0,0 @@ -# BEAM VM single-scheduler probe - -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 interpreter work. - -- Engine: interpreter -- Compiler profile: pure_v1 -- Pinned program handles: true -- Git base: `8f498a5a` -- Working tree at measurement: modified -- Generated: 2026-07-16T22:01:33Z -- Elixir: 1.20.2 -- OTP: 29 -- ERTS: 17.0.2 -- OS: Linux 7.0.0-27-generic -- Architecture: x86_64-pc-linux-gnu -- CPU: AMD Ryzen 9 9950X 16-Core Processor -- Online schedulers: 1 -- Vue probe memory limit: 512 MB -- Samples: 30 - -| workload | wall median | wall p95 | ticker gap median | ticker gap p95 | ticker gap max | ticks median | -|---|---:|---:|---:|---:|---:|---:| -| Vue SSR | 4.91 ms | 5.74 ms | 1.73 ms | 2.03 ms | 2.04 ms | 2 | -| sleep baseline (5 ms target) | 6.0 ms | 6.0 ms | 2.0 ms | 2.01 ms | 2.01 ms | 3 | - -Acceptance bound: Vue SSR ticker gap ≤ 75.0 ms. - -## 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 | 50.99 ms | 51.0 ms | 51.02 ms | 990 µs | - -Acceptance bound: timeout p95 ≤ 60.0 ms. diff --git a/docs/beam-scheduler-measurements.md b/docs/beam-scheduler-measurements.md deleted file mode 100644 index d0f7995a2..000000000 --- a/docs/beam-scheduler-measurements.md +++ /dev/null @@ -1,36 +0,0 @@ -# BEAM VM single-scheduler probe - -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 interpreter work. - -- Engine: interpreter -- Git base: `041fd186` -- Working tree at measurement: modified -- Generated: 2026-07-16T08:24:37Z -- Elixir: 1.20.2 -- OTP: 29 -- ERTS: 17.0.2 -- OS: Linux 7.0.0-27-generic -- Architecture: x86_64-pc-linux-gnu -- CPU: AMD Ryzen 9 9950X 16-Core Processor -- Online schedulers: 1 -- Vue probe memory limit: 512 MB -- Samples: 10 - -| workload | wall median | wall p95 | ticker gap median | ticker gap p95 | ticker gap max | ticks median | -|---|---:|---:|---:|---:|---:|---:| -| Vue SSR | 289.32 ms | 374.44 ms | 2.0 ms | 2.19 ms | 43.7 ms | 99 | -| sleep baseline (289 ms target) | 289.87 ms | 289.93 ms | 2.0 ms | 2.01 ms | 7.81 ms | 145 | - -Acceptance bound: Vue SSR ticker gap ≤ 75.0 ms. - -## 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 | 50.96 ms | 51.04 ms | 51.04 ms | 957 µs | - -Acceptance bound: timeout p95 ≤ 60.0 ms. diff --git a/docs/beam-ssr-measurements.md b/docs/beam-ssr-measurements.md deleted file mode 100644 index e5478c527..000000000 --- a/docs/beam-ssr-measurements.md +++ /dev/null @@ -1,77 +0,0 @@ -# BEAM VM SSR measurements - -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. The -single-scheduler fairness and timeout gate is published separately in -[`beam-scheduler-measurements.md`](beam-scheduler-measurements.md). - -## Environment - -- Engine: interpreter -- Git base: `041fd186` -- Working tree at measurement: modified -- Generated: 2026-07-16T08:17:54Z -- Elixir: 1.20.2 -- OTP: 29 -- ERTS: 17.0.2 -- OS: Linux 7.0.0-27-generic -- Architecture: x86_64-pc-linux-gnu -- CPU: AMD Ryzen 9 9950X 16-Core Processor -- Logical schedulers: 32 -- Mix environment: `bench` -- Samples per fixture: 30 after 3 warmups -- Concurrency levels: 1, 4, 8 - -## Sequential isolated renders - -| Fixture | wall median | wall p95 | VM steps | logical memory | endpoint process memory | reductions median | -|---|---:|---:|---:|---:|---:|---:| -| Preact 10.29.7 | 8.22 ms | 9.09 ms | 3651 | 266.2 KiB | 4.5 MiB | 135016 | -| Vue 3.5.39 | 49.21 ms | 50.88 ms | 11957 | 991.9 KiB | 77.15 MiB | 694328 | -| Svelte 5.56.4 | 15.15 ms | 18.05 ms | 1777 | 397.1 KiB | 15.61 MiB | 134963 | - -`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. - -## Concurrent isolated renders - -| Fixture | concurrency | renders | throughput | per-render wall median | per-render wall p95 | -|---|---:|---:|---:|---:|---:| -| Preact 10.29.7 | 1 | 30 | 87.7 renders/s | 8.52 ms | 9.38 ms | -| Preact 10.29.7 | 4 | 30 | 284.5 renders/s | 9.11 ms | 11.4 ms | -| Preact 10.29.7 | 8 | 30 | 466.9 renders/s | 9.8 ms | 11.67 ms | -| Vue 3.5.39 | 1 | 30 | 8.5 renders/s | 62.79 ms | 72.85 ms | -| Vue 3.5.39 | 4 | 30 | 17.5 renders/s | 117.98 ms | 135.12 ms | -| Vue 3.5.39 | 8 | 30 | 18.1 renders/s | 204.55 ms | 269.75 ms | -| Svelte 5.56.4 | 1 | 30 | 38.3 renders/s | 15.9 ms | 17.14 ms | -| Svelte 5.56.4 | 4 | 30 | 105.3 renders/s | 21.34 ms | 26.59 ms | -| Svelte 5.56.4 | 8 | 30 | 124.2 renders/s | 32.42 ms | 41.3 ms | - -## 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 | -|---:|---:|---:|---:| -| 100/100 | 534.1 renders/s | -941.8 KiB | 0 | - -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 | -|---|---:|---:|---:|---:|---:| -| Preact 10.29.7 | limit:steps at 3650 | limit:memory_bytes at 133.1 KiB | limit:timeout at 200 ms | 200.7 ms | 22 µs | -| Vue 3.5.39 | limit:steps at 11956 | limit:memory_bytes at 495.9 KiB | limit:timeout at 200 ms | 214.46 ms | 27 µs | -| Svelte 5.56.4 | limit:steps at 1776 | limit:memory_bytes at 198.6 KiB | limit:timeout at 200 ms | 202.89 ms | 31 µs | - -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. diff --git a/docs/prototype-delta-audit.md b/docs/prototype-delta-audit.md deleted file mode 100644 index 77a5d05d6..000000000 --- a/docs/prototype-delta-audit.md +++ /dev/null @@ -1,492 +0,0 @@ -# Prototype Branch Delta Audit - -This audit compares the clean `beam-interpreter-v2` implementation with -`origin/beam-vm-interpreter`, reviewed in `.worktrees/beam-vm-review`. It is an -extraction guide, not a merge plan. - -The prototype contains useful semantic algorithms and a large test corpus, but -its mutable runtime is built around process-dictionary storage and a different -execution contract. No production source file should be copied without being -adapted to the current owner-local `%QuickBEAM.VM.Execution{}` model, generated -QuickJS v26 ABI, resource limits, and resumable invocation machinery. - -## Executive decision - -| Area | Decision | Reason | -|---|---|---| -| ABI, decoding, verification | Superseded | Current code uses generated QuickJS v26 metadata, fingerprints, checksums, bounds, and structural verification. | -| Interpreter kernel | Superseded structurally | Current explicit frames, boundaries, limits, async coroutines, and owner event loop are the production foundation. | -| Heap storage | Reject prototype implementation | Prototype heap storage uses the process dictionary; current heap is explicit evaluation state. | -| Error semantics | Adapt algorithms and tests | Prototype has useful Error hierarchy/prototype behavior, but construction and stack capture depend on the old heap and runtime macros. | -| Object enumeration | Adapt small algorithms and tests | Own-key filtering/order behavior is useful and maps cleanly onto current descriptors. | -| Arrays | Extract tests first; adapt selected algorithms | Prototype implementation is broad and tightly coupled to its object model. Sparse-hole and length algorithms are valuable. | -| Iterators | Adapt after canonical invocation/Symbol work | Protocol and IteratorClose logic are useful, but direct recursive invocation is incompatible with resumable callbacks. | -| Promises | Reject core implementation; adapt combinator tests/algorithms | Current detached async frames and reaction scheduler supersede the prototype Promise store. Iterable combinator logic remains useful. | -| Coercion/arithmetic | Adapt pure semantic functions and tests | The split into arithmetic, comparison, equality, and coercion is a good target, but object coercion and throws use prototype representations. | -| Test262 support | Superseded harness; extract test ideas | Current runner is pinned, differential, typed with JSONCodec, and classifies failures. Prototype paths and skip rationale remain useful input. | -| SSR fixtures | Superseded | Pinned Preact, Vue, and server-compiled Svelte fixtures perform real async rendering, native parity, and concurrent isolated reuse. | -| Web/Node APIs | Defer | Outside the first explicit SSR profile. | -| BEAM compiler | Quarantine for later extraction | It targets the prototype runtime ABI, creates dynamic module atoms, and relies on interpreter fallback and process-local runtime state. | - -## Scale and coupling - -The prototype has 331 VM source files and 103 VM test files. The clean branch -currently has 43 VM source files and 13 VM test files. Breadth in the prototype -is not evidence that a subsystem is production-ready. - -At least 27 prototype VM modules directly use process-dictionary operations. -Core modules that do not call `Process.get/put` themselves still call the -prototype heap facade, whose low-level store implementation does. For example: - -- `lib/quickbeam/vm/heap/store.ex` stores objects, arrays, shapes, and cells with - `Process.get/put`; -- `lib/quickbeam/vm/runtime/errors.ex` creates errors through that heap; -- `lib/quickbeam/vm/runtime/array.ex`, `runtime/promise.ex`, and - `semantics/iterators.ex` transitively depend on it. - -The five broad prototype runtime files reviewed for this audit total 5,304 lines: - -- `runtime/errors.ex` — 451; -- `runtime/object/enumeration.ex` — 231; -- `runtime/array.ex` — 3,131; -- `runtime/promise.ex` — 1,082; -- `semantics/iterators.ex` — 409. - -They should not be introduced as one extraction change. - -## Detailed extraction map - -### 1. Errors and constructor identity - -Prototype sources: - -- `lib/quickbeam/vm/runtime/errors.ex` -- `lib/quickbeam/vm/heap.ex` (`make_error/2`) -- `lib/quickbeam/vm/js_throw.ex` -- `lib/quickbeam/vm/stacktrace.ex` -- `test/vm/runtime/constructors_test.exs` - -Reusable concepts: - -- the standard error-name set; -- `Error.prototype` plus derived prototype topology; -- non-enumerable `name`, `message`, and stack-related properties; -- constructor-specific prototypes for `instanceof`; -- `AggregateError.errors` ordering; -- preserving catchable JavaScript objects until the API boundary; -- constructor and descriptor tests. - -Do not copy: - -- `Heap.make_error/2`, because it discovers constructors through process-local - global caches and stores objects by raw references; -- `throw({:js_throw, ...})` control flow; -- stack attachment based on prototype interpreter context; -- builtin definition macros as a prerequisite for the first error slice. - -Current adaptation target: - -1. Add an error-object kind or internal slot to the current owner-local heap. -2. Install `Error`, `TypeError`, `ReferenceError`, `RangeError`, and required - prototypes in `Builtins`. -3. Convert VM-generated catchable errors into owner-local references. -4. Resolve name/message/frames from the owning execution only when uncaught. -5. Preserve `%QuickBEAM.JSError{}` as the stable external representation. - -This is the highest-value immediate extraction and closes the remaining selected -Test262 constructor-identity failure. - -### 2. Own-property enumeration - -Prototype sources: - -- `lib/quickbeam/vm/runtime/object/enumeration.ex` -- `lib/quickbeam/vm/object_model/own_property.ex` -- `test/vm/object_descriptors_order_test.exs` -- `test/vm/object_keys_test.exs` -- `test/vm/object_own_property_primitives_test.exs` - -Reusable concepts: - -- numeric keys first, then string insertion order, then symbols; -- distinction between enumerable keys and all own property names; -- descriptor-aware `Object.keys`, values, and entries; -- UTF-16 indexed string own properties; -- tests for non-enumerable properties and descriptor order. - -Current adaptation target: - -- extend current `Heap.own_keys/2` with an all-own-keys operation rather than - copying prototype map/shape traversal; -- implement `Object.getOwnPropertyNames` over current `Property` structs; -- add only the small `propertyHelper.js` dependencies required by the pinned - conformance manifest. - -The prototype implementation is useful as a specification checklist, not as -source code. - -### 3. Arrays and sparse holes - -Prototype sources: - -- `lib/quickbeam/vm/runtime/array.ex` -- `lib/quickbeam/vm/object_model/array_exotic.ex` -- `lib/quickbeam/vm/object_model/array_exotic_get.ex` -- `lib/quickbeam/vm/heap/arrays.ex` -- `test/vm/runtime/array_test.exs` -- typed-array and array descriptor tests - -Reusable concepts: - -- maximum array length and safe-integer bounds; -- hole-aware `HasProperty` before callback invocation; -- inherited values at sparse indices; -- length updates around non-configurable elements; -- iterator and species edge-case tests; -- mutation ordering for push/pop/shift/unshift/splice. - -Do not copy: - -- `:array` plus process-dictionary object storage; -- recursive callback invocation from runtime methods; -- the full 3,131-line API surface before profile demand; -- typed-array branches before ordinary arrays are conformant. - -Extraction sequence: - -1. Port sparse `map/filter/reduce/some/forEach` differential tests. -2. Add a canonical current-heap `has_index`/`get_index` operation. -3. Update resumable native callback frames to skip holes where required. -4. Add precise partial length truncation around non-configurable elements. -5. Add further methods only when selected Test262 or SSR code requires them. - -### 4. Iterators and iterable Promise combinators - -Prototype sources: - -- `lib/quickbeam/vm/semantics/iterators.ex` -- `lib/quickbeam/vm/execution/iterator_state.ex` -- `lib/quickbeam/vm/interpreter/ops/iterators.ex` -- `test/vm/iterator_semantics_test.exs` -- iterator portions of `runtime/promise.ex` - -Reusable concepts: - -- `GetIterator`, `IteratorNext`, result-object validation, and `IteratorClose`; -- close-on-abrupt-completion tests; -- custom array iterator replacement/deletion behavior; -- string iteration by code point while string indexing remains UTF-16; -- Promise combinator ordering and iterator closing. - -Do not copy: - -- recursive `Invocation.invoke_with_receiver` loops; -- prototype tagged values such as `{:obj, ref}` and `{:list_iter, ...}` without - adapting them to current owner-local references; -- synchronous collection of arbitrary iterators, which can bypass limits and - resumable JavaScript callbacks. - -Current adaptation target: - -- represent iterator operations as explicit resumable boundaries/jobs; -- use the same canonical invocation path as getters, setters, reactions, and - `Object.assign`; -- charge steps and memory on every transition; -- make `Promise.all/allSettled/any/race` consume iterables through that layer. - -### 5. Promise runtime - -Prototype source: - -- `lib/quickbeam/vm/runtime/promise.ex` -- `lib/quickbeam/vm/promise.ex` -- `lib/quickbeam/vm/job_queue.ex` - -Current status: - -The clean branch already supersedes the prototype in the production-critical -areas: detached async frames, multiple suspended coroutines, FIFO reactions, -thenable getter ordering, handler tasks, cancellation, Promise adoption, -self-resolution protection, and process-owned event-loop state. - -Potential extraction: - -- constructor/species tests; -- iterable combinator algorithms after iterator support; -- iterator-close-on-rejection cases; -- capability tests for subclassed Promise constructors. - -Reject the prototype Promise store and microtask queue. They use the old heap and -do not provide the current owner/task containment model. - -### 6. Value semantics - -Prototype sources: - -- `lib/quickbeam/vm/semantics/values.ex` -- `lib/quickbeam/vm/semantics/arithmetic.ex` -- `lib/quickbeam/vm/semantics/comparison.ex` -- `lib/quickbeam/vm/semantics/equality.ex` -- `lib/quickbeam/vm/semantics/coercion.ex` - -Reusable concepts: - -- splitting pure semantic families; -- infinity, NaN, negative zero, BigInt, and Symbol cases; -- differential/property tests; -- inlining only after semantics are centralized. - -Adaptation rule: - -Pure number/string clauses may be ported with focused tests. Any clause that -reads prototype objects, throws through `JSThrow`, or invokes user code must be -rewritten against current `Execution`, `Heap`, and resumable invocation. - -This split is a useful model for decomposing the current `Value` and -`Interpreter`, but the prototype module graph is too fragmented to copy. - -### 7. Interpreter decomposition - -Prototype sources include 40-plus opcode modules and dozens of object-model -micro-modules. The separation demonstrates useful boundaries, but it also -created overlapping routes for get/put/call semantics and a very large internal -surface. - -Target decomposition for the clean branch should be smaller: - -1. `Invocation` — ordinary, constructor, bound, builtin, and host calls; -2. `Properties` — get/set/define/delete/prototype and accessor boundaries; -3. `Async` — Promise resolution, jobs, coroutines, and handler completion; -4. `Exceptions` — throw values, catch/finally unwinding, and external errors; -5. `Values` — coercion, arithmetic, equality, comparison, and UTF-16; -6. opcode-family dispatch modules that call those canonical layers. - -No opcode module may implement an alternative property, invocation, or Promise -algorithm. - -### 8. BEAM compiler - -Prototype sources: - -- `lib/quickbeam/vm/compiler.ex` -- `lib/quickbeam/vm/compiler/analysis/*` -- `lib/quickbeam/vm/compiler/lowering/*` -- `lib/quickbeam/vm/compiler/runtime_abi/*` -- `lib/quickbeam/vm/compiler/runtime_helpers/*` -- compiler tests - -Potentially reusable later: - -- CFG and stack analyses; -- basic-block lowering structure; -- Erlang abstract-form emission patterns; -- diagnostics and disassembly tests; -- the concept of one explicit runtime ABI; -- resource caps on instruction, atom, constant, and generated-form counts. - -Release blockers in the prototype compiler: - -- generated module names create atoms from hashes and loaded modules are cached, - so the design needs a bounded reusable module pool and purge strategy; -- generated code targets prototype `RuntimeABI`, object tags, process heap, and - invocation context; -- semantic fallback assumes the prototype interpreter and heap; -- runtime step, stack, memory, async, and cancellation containment are not the - clean interpreter's limits; -- compiler correctness tests mix broad runtime support with lowering behavior; -- QuickJS v25 assumptions must be regenerated for v26. - -Compiler extraction gate: - -1. Current interpreter semantic layers are stable and independently tested. -2. A small versioned runtime ABI is documented. -3. Compiled operations call that ABI rather than prototype helpers. -4. A bounded module pool prevents atom/module leaks. -5. Runtime limits and async suspension have compiled-path acceptance tests. -6. Unsupported compiled regions deopt explicitly to verified interpreter state; - they never fall back to native execution. - -The module-name, cache-lifecycle, runtime-ABI, prototype-analysis, and -deoptimization design is now specified in -[`beam-compiler-contract.md`](beam-compiler-contract.md). Static slot identities -and owner-local deoptimization validation are executable contracts. The -supervisor-compatible fixed-slot pool now proves leases, owner monitoring, -single-flight compilation, LRU reuse, bounded shutdown, and quarantine through a -fake backend. A minimal canonical runtime ABI, bounded generated-module emitter, -import policy, and soft-purge code lifecycle now pass their acceptance tests. -Bounded v26 CFG analysis now produces deterministic `:pure_v1` plans and -specialized fixed-name block/step forms. Canonical pure operations resume -explicit interpreter deoptimization with synthetic, decoded-expression, -argument/local, and exact-step differential tests. Explicit supervised -orchestration now exercises isolated evaluation, measurement, asynchronous -handler deoptimization, native pure-expression parity, timeout/memory/step -containment, and concurrent cache reuse. Preact, Vue, and Svelte now have -compiler/interpreter/native parity; compiler measurements cover sequential and -concurrent SSR, cancellation, reclamation, and `+S 1:1`; and the selected -Test262 compiler gate passes 65/65 supported cases through both compiler -profiles. Selected nested bytecode -frames now re-enter compiled execution through owner-local, bounded eligibility -decisions while preserving stack limits and tail calls. The prototype's useful -performance lesson has also been extracted without its runtime: eligible -uncaptured lexical loops carry scalar stack/tuple state across generated blocks, -use guarded BEAM numeric primitives with canonical fallback, thread canonical -global state, and rebuild frames only at deoptimization. Exact program -namespaces avoid repeatedly serializing atom tables for per-function keys, and -measurement-only OTP `:counters` expose bounded opcode coverage. After one-time -host-profile initialization, warm arithmetic/branch/local loop execution is now -8.23×/7.65×/5.69× faster than the interpreter, with array/property loops at -1.71×/5.26× and cold compilation at 10.77–25.65 ms. The compiler remains -release quarantined because broader -Preact/Vue/Svelte performance and unsupported semantic families remain the -release gate. No prototype compiler runtime is approved for copying. - -## Test extraction policy - -Prototype tests are generally more reusable than prototype implementations. -For each selected area: - -1. copy the JavaScript scenario, not prototype setup helpers; -2. run it through current native QuickJS and `QuickBEAM.VM`; -3. remove `mode: :beam`, process-dictionary resets, and compiler assumptions; -4. preserve a source link in the commit message or test comment when useful; -5. keep only cases in the declared profile or conformance roadmap; -6. classify unsupported cases explicitly rather than weakening assertions. - -High-value test groups to adapt next: - -- error constructor/prototype and catch identity; -- object own names and descriptor order; -- sparse array callback behavior; -- iterator result validation and close-on-abrupt-completion; -- Promise iterable combinator ordering and closing; -- pure coercion and arithmetic sentinels. - -## Ordered long-term plan - -### Phase A — close the current bounded baseline (complete) - -- Adapted Error hierarchy concepts and tests. -- Adapted `Object.getOwnPropertyNames` semantics and required harness helpers. -- Raised the selected Test262 baseline from 88.9% to 100%. - -### Phase B — establish canonical semantic modules (complete) - -- Property semantics now live behind one canonical property layer, including - explicit getter/setter actions used by the interpreter, built-ins, Promise - thenable lookup, and exception conversion. -- Invocation now has one canonical planner for ordinary, closure, bound, - constructor, built-in, Promise, and host calls. The interpreter executes its - explicit actions to preserve resumable scheduling and exception unwinding. -- Async state transitions now have one canonical layer for async frame entry, - coroutine detachment/resumption, await suspension, reactions, thenables, - Promise-boundary completion, and supervised handler startup. -- Exception materialization, catch lookup, frame and native-boundary unwinding, - async stack preservation, Promise-boundary rejection, and public error - conversion now share one canonical exception layer. -- Value coercion, arithmetic, equality, comparison, bitwise operations, - `typeof`, and UTF-16 string operations now share one canonical value layer. -- Keep the current tests and SSR fixture green after each extraction. -- Avoid reproducing the prototype's hundreds of overlapping modules. - -### Interpreter decomposition (complete) - -- Literal and operand-stack opcodes now live in one stack family module. -- Coercion, arithmetic, comparison, bitwise, value-test, `in`, and `instanceof` - opcodes now live in one value family module. -- Control-flow opcodes now classify branches, catches, returns, throws, and - awaits as explicit interpreter actions. -- Local and closure opcodes now own argument/local slots, closure cells, - globals, atom resolution, and function-closure allocation. -- Object opcodes now own object/array/RegExp construction, field definitions, - property access, resumable accessor actions, deletion, and enumeration. -- Invocation opcodes now decode ordinary, method, tail, and constructor call - stacks into explicit canonical invocation actions. -- Family-owned opcode lists are the interpreter's compile-time routing source. -- The interpreter now owns only routing, stepping, resource checks, action - execution, native callback frames, and boundary completion. - -### Declarative builtin migration (in progress) - -- Adapted the prototype's useful spec/installer idea without its process-local - heap, code-loading-order discovery, giant multi-form macro surface, or generated - fallback dispatch. -- Builtin declarations compile to immutable validated specs. The deterministic - profile registry discovers them from Mix's compiled application module - manifest and topologically orders their declared dependencies. -- DSL v2 uses parenthesis-free handler-first declarations with inferred JS - names, typed constants/data/accessors, profile and dependency metadata, and - separate compiler, validator, installer, and runtime-contract modules. -- Handlers use stable module/function tokens and explicit call contexts through - canonical invocation semantics; resumable actions are typed and malformed - results fail as infrastructure contract errors. -- Migrated `Math`, Promise, Symbol, Set, the Error hierarchy, shared Function - methods, `String.fromCharCode`, `Array.isArray`, all currently supported Array - prototype methods, and all currently supported Object statics and prototype - methods with real function objects and descriptor, `name`, and `length` - metadata. -- `Object.assign` and Array callback methods prove that DSL handlers can return - canonical resumable actions without recursively invoking JavaScript. -- Descriptor-heavy Object methods now share canonical descriptor validation; - sparse `slice`/`concat` preserve holes and `join` handles holes/nullish values. -- String and Number primitive methods now resolve through their installed DSL - prototype objects for both primitives and boxed values; numeric radix output - is normalized to JavaScript lowercase form. -- Promise construction, static combinators, and reaction methods now use full - constructor DSL topology. Combinators share a canonical iterable boundary - for sparse arrays, insertion-ordered sets, strings, internal lists, and custom - `Symbol.iterator` objects. -- Custom iterator getters, factories, cached `next` methods, and accessor-backed - `done`/`value` reads resume through explicit boundaries; computed Symbol - methods and fields are supported without recursive JavaScript execution. -- Constructor prototype parents, prototype kind/callability, default and Error - prototype registration, and Symbol-keyed aliases are explicit installer - topology. Object/Function bootstrap cycles and primitive constructors are - declarative; the hard-coded constructor table and legacy object/error/set/ - function pseudo-method tokens have been removed. - -### Phase C — expand conformance by profile demand - -- Added differential sparse-array tests and hole-aware native callback frames; - callbacks skip holes, `map` preserves them, and `reduce` finds the first - present element when no initial value is supplied. -- Expand Symbol APIs and iterator consumers only when required by the selected - compatibility profile. -- Expanded the pinned Test262 manifest to 69 selected tests with 65/65 - supported cases passing and four explicit asynchronous skips. -- Added deterministic checksum-aware decoder mutation fuzzing and deliberately - invalid verifier mutations. Normal CI runs 3,000 heap- and timeout-bounded - cases; the scheduled/local harness runs a larger reproducible corpus and - persists minimized regressions. - -### Phase D — production hardening - -- Runtime and context-pool shutdown now use serialized running → stopping → - joined lifecycle states, and resource creation cannot leave a destructor - pointing at freed startup data. -- Sync-call result publication remains under its slot mutex through wakeup; - pool RuntimeData pointers are removed under the registry mutex before context - destruction. Repeated concurrent lifecycle and callSync shutdown stress tests - pass without crashes. -- Native addon initialization is now serialized and canonical-path keyed. - Same-runtime aliases reuse cached exports; cross-runtime and post-reset loads - return a typed rejection unless the caller explicitly opts into documented - multi-environment addon support. The registry is bounded to 256 libraries. -- Continue auditing cancellation, queued-payload cleanup, and owner-local - shutdown. -- Published reproducible pinned SSR step, logical-memory, endpoint process, - latency, concurrency, timeout, cancellation, and single-scheduler fairness - measurements. -- Freeze the supported SSR compatibility matrix. - -### Phase E — optional compiler extraction - -- Extract analyses and lowering patterns only after the runtime ABI is stable. -- Introduce a bounded module pool. -- Add explicit deoptimization to interpreter states. -- Keep compiler and interpreter differential tests mandatory. - -## Immediate next action - -Specify the bounded module pool, explicit deoptimization states, and compiler -cache lifecycle against the canonical runtime contract before extracting any -prototype compiler code. diff --git a/docs/test262-conformance.md b/docs/test262-conformance.md deleted file mode 100644 index f5a4749f1..000000000 --- a/docs/test262-conformance.md +++ /dev/null @@ -1,77 +0,0 @@ -# Selected Test262 Conformance - -QuickBEAM uses a pinned, bounded Test262 manifest as a differential correctness -gate for `QuickBEAM.VM`. Full Test262 compliance is not claimed. - -## Baseline - -- Test262 revision: `d1d583db95a521218f3eb8341a887fd63eda8ff1` -- Selected tests: 69 -- Explicitly unsupported by flags: 4 asynchronous tests -- Supported tests: 65 -- Interpreter passing: 65 -- Compiler tier passing: 65 -- Known failures: 0 -- Supported-test pass rate: **100%** -- Required pass rate: **100%** - -The exact paths and classified known failures live in -`test/test262/manifest.exs`. A newly passing known failure and an unclassified -new failure both fail the gate, so the manifest cannot silently hide changes. - -The selected supported set currently has no known failures. It covers the -previous `propertyHelper.js` dependency gap and generated `ReferenceError` -constructor identity failure, plus declarative constructor/prototype metadata, -Function calls, Error descriptors, Symbol keys, Set insertion and identity -semantics, and Promise resolution and iterator behavior. - -## Running the gate - -Create a checkout at the pinned revision and provide its path explicitly: - -```sh -git clone --filter=blob:none --sparse https://github.com/tc39/test262.git /tmp/test262 -git -C /tmp/test262 checkout d1d583db95a521218f3eb8341a887fd63eda8ff1 -git -C /tmp/test262 sparse-checkout set harness test - -TEST262_PATH=/tmp/test262 \ - mix test test/vm/test262_test.exs --include test262 -``` - -Without `TEST262_PATH`, metadata-parser and summary tests still run while the -external corpus gate is reported as skipped. - -## Classification - -Each selected path is run in a fresh native QuickJS runtime and through six BEAM -gates: copied and pinned interpreter evaluation, plus copied and pinned -release-quarantined `:pure_v1` and `:scalar_v1` compiler evaluation. Results are -classified as: - -- `pass` — native QuickJS and the selected BEAM tier satisfy the expectation; -- `vm_failure` — native QuickJS passes and the selected BEAM tier fails; -- `native_failure` — the selected test or harness is incompatible with the - vendored native engine; -- `unsupported_flag` — module, raw, or asynchronous harness behavior is outside - the current bounded runner; -- `missing` — the pinned corpus does not contain a manifest path. - -There is no automatic native fallback during BEAM evaluation. The native result -is used only as a differential oracle. - -The selected SSR profile does not currently claim generator or async-generator -opcodes, Proxy/Reflect semantics, weak collections, the global Symbol registry, -constructor species/subclassing, or dynamic `Function` source compilation. -Those features remain explicit profile exclusions until pinned tests and -resource-bounded implementations are added; a passing test carrying a broad -feature tag does not imply blanket support for that feature. - -Test262 YAML front matter is parsed by `YamlElixir` and decoded into strict, -typed metadata structs by `JSONCodec`. Flags, includes, features, negative-test -shape, and the finite phase enum therefore have one explicit boundary contract; -unknown phases produce structured codec errors rather than fallback atoms. - -The runner supplies a small assertion harness for `assert`, `sameValue`, -`notSameValue`, and `compareArray`. Additional official harness includes are -loaded only when requested by a selected test. Tests that depend on unsupported -harness APIs remain explicitly classified rather than being rewritten to pass. diff --git a/examples/vm_ssr.exs b/examples/vm_ssr.exs deleted file mode 100644 index 19458c24e..000000000 --- a/examples/vm_ssr.exs +++ /dev/null @@ -1,69 +0,0 @@ -defmodule QuickBEAM.Examples.VMRenderer do - @moduledoc """ - Owns the explicit lifecycle of one immutable pinned SSR bundle. - """ - - use GenServer - - @source """ - globalThis.__quickbeamSSRResult = (async function render() { - const props = await Beam.call("load_props"); - const items = props.products - .map((product) => `
  • ${product.name}
  • `) - .join(""); - - return `

    ${props.title}

      ${items}
    `; - })(); - - globalThis.__quickbeamSSRResult; - """ - - def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__) - - @doc "Returns the lightweight handle shared by isolated request processes." - def pinned_program(server \\ __MODULE__), do: GenServer.call(server, :pinned_program) - - @impl true - def init(_opts) do - with {:ok, program} <- QuickBEAM.VM.compile(@source, filename: "vm_ssr.js"), - {:ok, pinned} <- QuickBEAM.VM.pin(program) do - {:ok, pinned} - end - end - - @impl true - def handle_call(:pinned_program, _from, pinned), do: {:reply, pinned, pinned} - - @impl true - def terminate(_reason, pinned) do - QuickBEAM.VM.unpin(pinned) - :ok - end -end - -children = [QuickBEAM.Examples.VMRenderer] -{:ok, supervisor} = Supervisor.start_link(children, strategy: :one_for_one) -pinned = QuickBEAM.Examples.VMRenderer.pinned_program() - -renders = - 1..8 - |> Enum.map(fn id -> - Task.async(fn -> - props = %{ - "title" => "Catalog #{id}", - "products" => [%{"id" => id, "name" => "Product #{id}"}] - } - - QuickBEAM.VM.eval(pinned, - profile: :ssr, - handlers: %{"load_props" => fn [] -> props end}, - max_steps: 100_000, - memory_limit: 16 * 1024 * 1024, - timeout: 2_000 - ) - end) - end) - |> Task.await_many(5_000) - -Enum.each(renders, fn {:ok, html} -> IO.puts(html) end) -Supervisor.stop(supervisor) diff --git a/lib/quickbeam/vm/abi.ex b/lib/quickbeam/vm/abi.ex index bbb40ce54..8c0a92f96 100644 --- a/lib/quickbeam/vm/abi.ex +++ b/lib/quickbeam/vm/abi.ex @@ -7,7 +7,7 @@ defmodule QuickBEAM.VM.ABI do cannot silently leave a hand-maintained decoder table behind. """ - alias QuickBEAM.VM.ABIGenerator + alias QuickBEAM.VM.ABI.Generator @decoder_format_version 1 @c_src_dir Application.app_dir(:quickbeam, "priv/c_src") @@ -23,11 +23,11 @@ defmodule QuickBEAM.VM.ABI do @opcode_source File.read!(@opcode_path) @atom_source File.read!(@atom_path) - @bytecode_version ABIGenerator.version!(@quickjs_source) - @tags ABIGenerator.tags!(@quickjs_source) - @opcodes ABIGenerator.opcodes!(@opcode_source) - @atoms ABIGenerator.atoms!(@atom_source) - @fingerprint ABIGenerator.fingerprint( + @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] diff --git a/lib/quickbeam/vm/abi/generator.ex b/lib/quickbeam/vm/abi/generator.ex new file mode 100644 index 000000000..d6679fd9e --- /dev/null +++ b/lib/quickbeam/vm/abi/generator.ex @@ -0,0 +1,137 @@ +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 + + @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() |> String.to_atom() + _other -> raise ArgumentError, "unsupported bytecode tag definition: #{inspect(name)}" + end + end + + defp opcode!([name, size, pops, pushes, format]) do + { + name |> identifier!(:opcode) |> String.to_atom(), + unsigned_integer!(size, name), + unsigned_integer!(pops, name), + unsigned_integer!(pushes, name), + format |> identifier!(:opcode_format) |> String.to_atom() + } + 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_generator.ex b/lib/quickbeam/vm/abi_generator.ex deleted file mode 100644 index f3f076338..000000000 --- a/lib/quickbeam/vm/abi_generator.ex +++ /dev/null @@ -1,77 +0,0 @@ -defmodule QuickBEAM.VM.ABIGenerator 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. - """ - - @opcode_pattern ~r/^\s*DEF\(\s*([A-Za-z0-9_]+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*([A-Za-z0-9_]+)\s*\)/m - @atom_pattern ~r/^\s*DEF\(\s*([A-Za-z0-9_]+)\s*,\s*"([^"]*)"\s*\)/m - - def version!(source) do - case Regex.run(~r/^#define\s+BC_VERSION\s+(\d+)$/m, source) do - [_, version] -> String.to_integer(version) - _ -> raise "BC_VERSION not found in vendored quickjs.c" - end - end - - def tags!(source) do - with [_, body] <- Regex.run(~r/typedef enum BCTagEnum\s*\{(.*?)\}\s*BCTagEnum;/s, source) do - body - |> String.split(",") - |> Enum.map(&String.trim/1) - |> Enum.reject(&(&1 == "")) - |> Enum.reduce({%{}, 0}, fn entry, {tags, previous} -> - case Regex.run(~r/^BC_TAG_([A-Z0-9_]+)(?:\s*=\s*(\d+))?$/, entry) do - [_, name] -> - value = previous + 1 - {Map.put(tags, tag_name(name), value), value} - - [_, name, explicit] -> - value = String.to_integer(explicit) - {Map.put(tags, tag_name(name), value), value} - - _ -> - raise "unsupported bytecode tag definition: #{inspect(entry)}" - end - end) - |> elem(0) - else - _ -> raise "BCTagEnum not found in vendored quickjs.c" - end - end - - def opcodes!(header) do - rows = - @opcode_pattern - |> Regex.scan(header) - |> Enum.with_index() - |> Map.new(fn {[_, name, size, pops, pushes, format], opcode} -> - {opcode, - {String.to_atom(name), String.to_integer(size), String.to_integer(pops), - String.to_integer(pushes), String.to_atom(format)}} - end) - - if map_size(rows) == 0 or map_size(rows) > 256 do - raise "invalid opcode table size: #{map_size(rows)}" - end - - rows - end - - def atoms!(header) do - @atom_pattern - |> Regex.scan(header) - |> Enum.with_index(1) - |> Map.new(fn {[_, _name, value], index} -> {index, value} end) - end - - 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_name(name), do: name |> String.downcase() |> String.to_atom() -end diff --git a/lib/quickbeam/vm/fuzz.ex b/lib/quickbeam/vm/fuzz.ex index 90cefbbc0..4ae715cd4 100644 --- a/lib/quickbeam/vm/fuzz.ex +++ b/lib/quickbeam/vm/fuzz.ex @@ -728,7 +728,17 @@ defmodule QuickBEAM.VM.Fuzz do defp safe_name(name) do name - |> String.replace(~r/[^a-zA-Z0-9_-]+/, "-") + |> 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" @@ -736,6 +746,12 @@ defmodule QuickBEAM.VM.Fuzz do 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), diff --git a/mix.exs b/mix.exs index b7252c534..40823198d 100644 --- a/mix.exs +++ b/mix.exs @@ -107,51 +107,10 @@ defmodule QuickBEAM.MixProject do "README.md", "docs/javascript-api.md", "docs/architecture.md", - "docs/beam-interpreter-architecture.md", - "docs/beam-interpreter-hotspot-investigation.md", - "docs/beam-pinned-program-investigation.md", - "docs/beam-pinned-program-measurements.md", - "docs/beam-pinned-program-scheduler-measurements.md", - "docs/beam-compiler-contract.md", - "docs/beam-compiler-performance-measurements.md", - "docs/beam-compiler-scheduler-measurements.md", - "docs/beam-compiler-scalar-scheduler-measurements.md", - "docs/beam-compiler-ssr-measurements.md", - "docs/beam-compiler-scalar-ssr-measurements.md", - "docs/beam-scheduler-measurements.md", - "docs/beam-ssr-measurements.md", - "docs/prototype-delta-audit.md", - "docs/test262-conformance.md", "CHANGELOG.md" ], groups_for_extras: [ - Guides: [ - "docs/javascript-api.md", - "docs/architecture.md", - "docs/beam-interpreter-architecture.md", - "docs/beam-interpreter-hotspot-investigation.md", - "docs/beam-pinned-program-investigation.md", - "docs/beam-pinned-program-measurements.md", - "docs/beam-pinned-program-scheduler-measurements.md", - "docs/beam-object-memory-investigation.md", - "docs/beam-object-memory-measurements.md", - "docs/beam-object-shape-investigation.md", - "docs/beam-object-literal-investigation.md", - "docs/beam-compiler-contract.md", - "docs/beam-compiler-performance-measurements.md", - "docs/beam-compiler-region-investigation.md", - "docs/beam-compiler-region-probe.md", - "docs/beam-compiler-region-hotspots.md", - "docs/beam-compiler-region-ssr-measurements.md", - "docs/beam-compiler-scheduler-measurements.md", - "docs/beam-compiler-scalar-scheduler-measurements.md", - "docs/beam-compiler-ssr-measurements.md", - "docs/beam-compiler-scalar-ssr-measurements.md", - "docs/beam-scheduler-measurements.md", - "docs/beam-ssr-measurements.md", - "docs/prototype-delta-audit.md", - "docs/test262-conformance.md" - ] + Guides: ["docs/javascript-api.md", "docs/architecture.md"] ], filter_modules: &documented_module?/2, skip_code_autolink_to: &skip_doc_warning?/1, diff --git a/test/support/test262.ex b/test/support/test262.ex index 4baeb1a6d..17cf11a5c 100644 --- a/test/support/test262.ex +++ b/test/support/test262.ex @@ -92,17 +92,33 @@ defmodule QuickBEAM.Test262 do @doc "Parses the metadata fields needed by the bounded runner." @spec parse_metadata(binary()) :: QuickBEAM.Test262.Metadata.t() def parse_metadata(source) do - case Regex.run(~r/\/\*---\s*(.*?)\s*---\*\//s, source, capture: :all_but_first) do - [yaml] -> + case metadata_block(source) do + {:ok, yaml} -> yaml + |> String.trim() |> YamlElixir.read_from_string!() |> QuickBEAM.Test262.Metadata.from_map!() - _no_metadata -> + :missing -> %QuickBEAM.Test262.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 diff --git a/test/vm/abi_test.exs b/test/vm/abi_test.exs index 1f0f6ef45..e7e14c75a 100644 --- a/test/vm/abi_test.exs +++ b/test/vm/abi_test.exs @@ -2,6 +2,7 @@ defmodule QuickBEAM.VM.ABITest do use ExUnit.Case, async: true alias QuickBEAM.VM.{ABI, Opcodes} + alias QuickBEAM.VM.ABI.Source test "metadata is generated from the current vendored QuickJS sources" do assert ABI.bytecode_version() == 26 @@ -12,6 +13,32 @@ defmodule QuickBEAM.VM.ABITest do assert Opcodes.info(Opcodes.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 "predefined atom indexes include QuickJS v26 additions" do atoms = ABI.predefined_atoms() From a791fdadd8a04f819816792aefe7773e7a952e47 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 17 Jul 2026 14:04:23 +0200 Subject: [PATCH 74/87] Normalize BEAM VM module namespaces --- CHANGELOG.md | 15 +- bench/README.md | 111 +------- bench/vm_compiler_eprof.exs | 80 ------ bench/vm_compiler_region_hotspots.exs | 263 ----------------- bench/vm_compiler_region_probe.exs | 225 --------------- bench/vm_compiler_ssr_eprof.exs | 82 ------ bench/vm_interpreter_fprof.exs | 71 ----- bench/vm_object_memory.exs | 217 -------------- lib/quickbeam/application.ex | 2 +- lib/quickbeam/js_error.ex | 2 +- lib/quickbeam/vm.ex | 58 ++-- lib/quickbeam/vm/async_boundary.ex | 27 -- lib/quickbeam/vm/builtin.ex | 14 +- lib/quickbeam/vm/builtin/action.ex | 11 - .../vm/{builtins => builtin}/array.ex | 18 +- .../vm/{builtins => builtin}/boolean.ex | 9 +- lib/quickbeam/vm/builtin/call.ex | 4 +- .../vm/{builtins => builtin}/console.ex | 2 +- lib/quickbeam/vm/builtin/contract/error.ex | 11 + lib/quickbeam/vm/builtin/dsl.ex | 16 +- lib/quickbeam/vm/builtin/error.ex | 26 ++ lib/quickbeam/vm/builtin/error/eval.ex | 4 + lib/quickbeam/vm/builtin/error/range.ex | 4 + lib/quickbeam/vm/builtin/error/reference.ex | 4 + lib/quickbeam/vm/builtin/error/subclass.ex | 31 ++ lib/quickbeam/vm/builtin/error/support.ex | 75 +++++ lib/quickbeam/vm/builtin/error/syntax.ex | 4 + lib/quickbeam/vm/builtin/error/type.ex | 4 + lib/quickbeam/vm/builtin/error/uri.ex | 4 + .../vm/{builtins => builtin}/function.ex | 4 +- lib/quickbeam/vm/builtin/installer.ex | 51 ++-- lib/quickbeam/vm/{builtins => builtin}/map.ex | 36 ++- .../vm/{builtins => builtin}/math.ex | 4 +- .../vm/{builtins => builtin}/number.ex | 10 +- .../vm/{builtins => builtin}/object.ex | 68 +++-- .../vm/{builtins => builtin}/promise.ex | 26 +- .../vm/{builtins.ex => builtin/runtime.ex} | 44 +-- lib/quickbeam/vm/{builtins => builtin}/set.ex | 32 +-- lib/quickbeam/vm/builtin/spec.ex | 97 +------ lib/quickbeam/vm/builtin/spec/accessor.ex | 14 + lib/quickbeam/vm/builtin/spec/alias.ex | 14 + lib/quickbeam/vm/builtin/spec/function.ex | 15 + lib/quickbeam/vm/builtin/spec/property.ex | 14 + lib/quickbeam/vm/builtin/spec/prototype.ex | 19 ++ .../vm/{builtins => builtin}/string.ex | 14 +- .../vm/{builtins => builtin}/symbol.ex | 5 +- .../vm/{builtins => builtin}/uint8_array.ex | 7 +- lib/quickbeam/vm/builtin/validator.ex | 14 +- .../vm/{builtins => builtin}/weak_map.ex | 6 +- .../vm/{builtins => builtin}/weak_set.ex | 6 +- lib/quickbeam/vm/builtins/errors.ex | 163 ----------- .../{predefined_atoms.ex => bytecode/atom.ex} | 2 +- lib/quickbeam/vm/{ => bytecode}/checksum.ex | 4 +- lib/quickbeam/vm/{ => bytecode}/decoder.ex | 62 ++-- .../instruction.ex} | 14 +- .../vm/{opcodes.ex => bytecode/opcode.ex} | 2 +- lib/quickbeam/vm/{ => bytecode}/varint.ex | 6 +- lib/quickbeam/vm/{ => bytecode}/verifier.ex | 15 +- .../verifier/stack.ex} | 9 +- lib/quickbeam/vm/compiler.ex | 121 ++++---- .../analysis/{cfg.ex => control_flow.ex} | 9 +- lib/quickbeam/vm/compiler/code.ex | 38 +++ .../{generated_module => code}/artifact.ex | 2 +- .../{generated_module => code}/emitter.ex | 8 +- .../import_policy.ex => code/import.ex} | 2 +- .../code_lifecycle.ex => code/lifecycle.ex} | 7 +- .../{generated_module => code}/template.ex | 6 +- lib/quickbeam/vm/compiler/context.ex | 11 +- lib/quickbeam/vm/compiler/contract.ex | 3 +- .../vm/compiler/{counters.ex => counter.ex} | 40 +-- lib/quickbeam/vm/compiler/deopt.ex | 11 +- lib/quickbeam/vm/compiler/generated_module.ex | 33 --- .../vm/compiler/{module_pool.ex => pool.ex} | 4 +- .../compiler/{module_pool => pool}/backend.ex | 2 +- .../compiler/{module_pool => pool}/lease.ex | 2 +- .../{lowering/pure_v1.ex => profile/pure.ex} | 22 +- .../scalar_blocks.ex => profile/scalar.ex} | 6 +- .../{region_probe.ex => region/probe.ex} | 17 +- lib/quickbeam/vm/compiler/runtime.ex | 100 +++---- lib/quickbeam/vm/continuation.ex | 12 - lib/quickbeam/vm/fuzz.ex | 70 +---- lib/quickbeam/vm/fuzz/finding.ex | 11 + lib/quickbeam/vm/fuzz/mutation.ex | 21 ++ lib/quickbeam/vm/fuzz/summary.ex | 15 + lib/quickbeam/vm/{ => program}/function.ex | 2 +- .../{pinned_program.ex => program/pinned.ex} | 4 +- .../{source_position.ex => program/source.ex} | 6 +- .../vm/{program_store.ex => program/store.ex} | 40 +-- lib/quickbeam/vm/program/store/lease.ex | 19 ++ lib/quickbeam/vm/{ => program}/variable.ex | 2 +- .../variable/closure.ex} | 2 +- lib/quickbeam/vm/{evaluator.ex => runtime.ex} | 44 +-- lib/quickbeam/vm/{ => runtime}/async.ex | 130 +++++---- .../boundary/accessor.ex} | 4 +- lib/quickbeam/vm/runtime/boundary/async.ex | 27 ++ .../boundary/constructor.ex} | 6 +- .../boundary/iterator.ex} | 6 +- .../boundary/object_assign.ex} | 6 +- .../boundary/promise_executor.ex} | 6 +- .../boundary/reaction.ex} | 4 +- .../boundary/then_getter.ex} | 9 +- .../boundary/thenable.ex} | 4 +- lib/quickbeam/vm/runtime/continuation.ex | 12 + lib/quickbeam/vm/{ => runtime}/coroutine.ex | 8 +- .../{exceptions.ex => runtime/exception.ex} | 125 ++++----- lib/quickbeam/vm/{ => runtime}/frame.ex | 4 +- .../frame/native.ex} | 4 +- lib/quickbeam/vm/{ => runtime}/heap.ex | 135 +++++---- lib/quickbeam/vm/{ => runtime}/interpreter.ex | 265 +++++++++--------- lib/quickbeam/vm/{ => runtime}/invocation.ex | 77 +++-- lib/quickbeam/vm/{ => runtime}/iterator.ex | 80 +++--- lib/quickbeam/vm/{ => runtime}/memory.ex | 22 +- lib/quickbeam/vm/{ => runtime}/object.ex | 19 +- .../vm/{opcodes => runtime/opcode}/control.ex | 25 +- .../{opcodes => runtime/opcode}/invocation.ex | 26 +- .../locals.ex => runtime/opcode/local.ex} | 60 ++-- .../objects.ex => runtime/opcode/object.ex} | 110 ++++---- .../vm/{opcodes => runtime/opcode}/stack.ex | 14 +- .../values.ex => runtime/opcode/value.ex} | 19 +- lib/quickbeam/vm/{ => runtime}/promise.ex | 94 ++++--- .../vm/{ => runtime/promise}/reaction.ex | 4 +- .../promise/reference.ex} | 2 +- .../vm/{properties.ex => runtime/property.ex} | 64 ++--- .../property/descriptor.ex} | 2 +- lib/quickbeam/vm/{ => runtime}/reference.ex | 2 +- lib/quickbeam/vm/{ => runtime}/regexp.ex | 2 +- .../vm/{stack_state.ex => runtime/stack.ex} | 2 +- .../vm/{execution.ex => runtime/state.ex} | 32 +-- .../vm/{ => runtime/string}/utf16.ex | 2 +- lib/quickbeam/vm/{ => runtime}/symbol.ex | 2 +- lib/quickbeam/vm/{ => runtime}/thrown.ex | 2 +- lib/quickbeam/vm/{ => runtime}/value.ex | 16 +- .../vm/{ => runtime/value}/export.ex | 37 +-- mix.exs | 27 +- test/fixtures/vm/fuzz/regressions/README.md | 9 - test/vm/abi_test.exs | 13 +- .../dsl_test.exs} | 110 ++++---- test/vm/{ => bytecode}/decoder_test.exs | 32 ++- .../varint_test.exs} | 4 +- .../code_test.exs} | 62 ++-- .../contract_test.exs} | 12 +- test/vm/compiler/counter_test.exs | 35 +++ .../orchestration_test.exs} | 15 +- .../pool_test.exs} | 206 +++++++------- .../profile/pure_test.exs} | 124 ++++---- .../region/probe_test.exs} | 20 +- .../runtime_test.exs} | 14 +- test/vm/compiler_counters_test.exs | 33 --- test/vm/memory_limit_test.exs | 3 +- .../store_test.exs} | 82 +++--- test/vm/properties_test.exs | 59 ---- .../vm/{ => runtime}/async_semantics_test.exs | 24 +- test/vm/{ => runtime}/async_test.exs | 2 +- test/vm/{ => runtime}/error_test.exs | 2 +- .../exception_test.exs} | 39 ++- test/vm/{ => runtime}/heap_test.exs | 15 +- test/vm/{ => runtime}/interpreter_test.exs | 6 +- test/vm/{ => runtime}/invocation_test.exs | 20 +- .../object_test.exs} | 2 +- .../opcode_test.exs} | 31 +- test/vm/{ => runtime}/promise_test.exs | 2 +- test/vm/runtime/property_test.exs | 64 +++++ test/vm/{ => runtime}/value_test.exs | 5 +- test/vm/vue_ssr_test.exs | 4 +- 164 files changed, 2249 insertions(+), 3160 deletions(-) delete mode 100644 bench/vm_compiler_eprof.exs delete mode 100644 bench/vm_compiler_region_hotspots.exs delete mode 100644 bench/vm_compiler_region_probe.exs delete mode 100644 bench/vm_compiler_ssr_eprof.exs delete mode 100644 bench/vm_interpreter_fprof.exs delete mode 100644 bench/vm_object_memory.exs delete mode 100644 lib/quickbeam/vm/async_boundary.ex rename lib/quickbeam/vm/{builtins => builtin}/array.ex (93%) rename lib/quickbeam/vm/{builtins => builtin}/boolean.ex (81%) rename lib/quickbeam/vm/{builtins => builtin}/console.ex (95%) create mode 100644 lib/quickbeam/vm/builtin/contract/error.ex create mode 100644 lib/quickbeam/vm/builtin/error.ex create mode 100644 lib/quickbeam/vm/builtin/error/eval.ex create mode 100644 lib/quickbeam/vm/builtin/error/range.ex create mode 100644 lib/quickbeam/vm/builtin/error/reference.ex create mode 100644 lib/quickbeam/vm/builtin/error/subclass.ex create mode 100644 lib/quickbeam/vm/builtin/error/support.ex create mode 100644 lib/quickbeam/vm/builtin/error/syntax.ex create mode 100644 lib/quickbeam/vm/builtin/error/type.ex create mode 100644 lib/quickbeam/vm/builtin/error/uri.ex rename lib/quickbeam/vm/{builtins => builtin}/function.ex (96%) rename lib/quickbeam/vm/{builtins => builtin}/map.ex (91%) rename lib/quickbeam/vm/{builtins => builtin}/math.ex (97%) rename lib/quickbeam/vm/{builtins => builtin}/number.ex (91%) rename lib/quickbeam/vm/{builtins => builtin}/object.ex (88%) rename lib/quickbeam/vm/{builtins => builtin}/promise.ex (89%) rename lib/quickbeam/vm/{builtins.ex => builtin/runtime.ex} (75%) rename lib/quickbeam/vm/{builtins => builtin}/set.ex (90%) create mode 100644 lib/quickbeam/vm/builtin/spec/accessor.ex create mode 100644 lib/quickbeam/vm/builtin/spec/alias.ex create mode 100644 lib/quickbeam/vm/builtin/spec/function.ex create mode 100644 lib/quickbeam/vm/builtin/spec/property.ex create mode 100644 lib/quickbeam/vm/builtin/spec/prototype.ex rename lib/quickbeam/vm/{builtins => builtin}/string.ex (95%) rename lib/quickbeam/vm/{builtins => builtin}/symbol.ex (90%) rename lib/quickbeam/vm/{builtins => builtin}/uint8_array.ex (84%) rename lib/quickbeam/vm/{builtins => builtin}/weak_map.ex (88%) rename lib/quickbeam/vm/{builtins => builtin}/weak_set.ex (87%) delete mode 100644 lib/quickbeam/vm/builtins/errors.ex rename lib/quickbeam/vm/{predefined_atoms.ex => bytecode/atom.ex} (90%) rename lib/quickbeam/vm/{ => bytecode}/checksum.ex (84%) rename lib/quickbeam/vm/{ => bytecode}/decoder.ex (95%) rename lib/quickbeam/vm/{instruction_decoder.ex => bytecode/instruction.ex} (96%) rename lib/quickbeam/vm/{opcodes.ex => bytecode/opcode.ex} (99%) rename lib/quickbeam/vm/{ => bytecode}/varint.ex (91%) rename lib/quickbeam/vm/{ => bytecode}/verifier.ex (96%) rename lib/quickbeam/vm/{stack_verifier.ex => bytecode/verifier/stack.ex} (96%) rename lib/quickbeam/vm/compiler/analysis/{cfg.ex => control_flow.ex} (95%) create mode 100644 lib/quickbeam/vm/compiler/code.ex rename lib/quickbeam/vm/compiler/{generated_module => code}/artifact.ex (96%) rename lib/quickbeam/vm/compiler/{generated_module => code}/emitter.ex (94%) rename lib/quickbeam/vm/compiler/{generated_module/import_policy.ex => code/import.ex} (97%) rename lib/quickbeam/vm/compiler/{generated_module/code_lifecycle.ex => code/lifecycle.ex} (92%) rename lib/quickbeam/vm/compiler/{generated_module => code}/template.ex (69%) rename lib/quickbeam/vm/compiler/{counters.ex => counter.ex} (83%) delete mode 100644 lib/quickbeam/vm/compiler/generated_module.ex rename lib/quickbeam/vm/compiler/{module_pool.ex => pool.ex} (99%) rename lib/quickbeam/vm/compiler/{module_pool => pool}/backend.ex (93%) rename lib/quickbeam/vm/compiler/{module_pool => pool}/lease.ex (92%) rename lib/quickbeam/vm/compiler/{lowering/pure_v1.ex => profile/pure.ex} (97%) rename lib/quickbeam/vm/compiler/{lowering/scalar_blocks.ex => profile/scalar.ex} (99%) rename lib/quickbeam/vm/compiler/{region_probe.ex => region/probe.ex} (87%) delete mode 100644 lib/quickbeam/vm/continuation.ex create mode 100644 lib/quickbeam/vm/fuzz/finding.ex create mode 100644 lib/quickbeam/vm/fuzz/mutation.ex create mode 100644 lib/quickbeam/vm/fuzz/summary.ex rename lib/quickbeam/vm/{ => program}/function.ex (95%) rename lib/quickbeam/vm/{pinned_program.ex => program/pinned.ex} (72%) rename lib/quickbeam/vm/{source_position.ex => program/source.ex} (64%) rename lib/quickbeam/vm/{program_store.ex => program/store.ex} (92%) create mode 100644 lib/quickbeam/vm/program/store/lease.ex rename lib/quickbeam/vm/{ => program}/variable.ex (83%) rename lib/quickbeam/vm/{closure_variable.ex => program/variable/closure.ex} (72%) rename lib/quickbeam/vm/{evaluator.ex => runtime.ex} (88%) rename lib/quickbeam/vm/{ => runtime}/async.ex (73%) rename lib/quickbeam/vm/{accessor_boundary.ex => runtime/boundary/accessor.ex} (80%) create mode 100644 lib/quickbeam/vm/runtime/boundary/async.ex rename lib/quickbeam/vm/{constructor_boundary.ex => runtime/boundary/constructor.ex} (65%) rename lib/quickbeam/vm/{iterator_boundary.ex => runtime/boundary/iterator.ex} (83%) rename lib/quickbeam/vm/{object_assign_boundary.ex => runtime/boundary/object_assign.ex} (77%) rename lib/quickbeam/vm/{promise_executor_boundary.ex => runtime/boundary/promise_executor.ex} (67%) rename lib/quickbeam/vm/{reaction_boundary.ex => runtime/boundary/reaction.ex} (80%) rename lib/quickbeam/vm/{then_getter_boundary.ex => runtime/boundary/then_getter.ex} (59%) rename lib/quickbeam/vm/{thenable_boundary.ex => runtime/boundary/thenable.ex} (68%) create mode 100644 lib/quickbeam/vm/runtime/continuation.ex rename lib/quickbeam/vm/{ => runtime}/coroutine.ex (58%) rename lib/quickbeam/vm/{exceptions.ex => runtime/exception.ex} (71%) rename lib/quickbeam/vm/{ => runtime}/frame.ex (89%) rename lib/quickbeam/vm/{native_frame.ex => runtime/frame/native.ex} (86%) rename lib/quickbeam/vm/{ => runtime}/heap.ex (84%) rename lib/quickbeam/vm/{ => runtime}/interpreter.ex (79%) rename lib/quickbeam/vm/{ => runtime}/invocation.ex (77%) rename lib/quickbeam/vm/{ => runtime}/iterator.ex (74%) rename lib/quickbeam/vm/{ => runtime}/memory.ex (75%) rename lib/quickbeam/vm/{ => runtime}/object.ex (63%) rename lib/quickbeam/vm/{opcodes => runtime/opcode}/control.ex (82%) rename lib/quickbeam/vm/{opcodes => runtime/opcode}/invocation.ex (87%) rename lib/quickbeam/vm/{opcodes/locals.ex => runtime/opcode/local.ex} (90%) rename lib/quickbeam/vm/{opcodes/objects.ex => runtime/opcode/object.ex} (85%) rename lib/quickbeam/vm/{opcodes => runtime/opcode}/stack.ex (71%) rename lib/quickbeam/vm/{opcodes/values.ex => runtime/opcode/value.ex} (87%) rename lib/quickbeam/vm/{ => runtime}/promise.ex (83%) rename lib/quickbeam/vm/{ => runtime/promise}/reaction.ex (75%) rename lib/quickbeam/vm/{promise_reference.ex => runtime/promise/reference.ex} (76%) rename lib/quickbeam/vm/{properties.ex => runtime/property.ex} (81%) rename lib/quickbeam/vm/{property.ex => runtime/property/descriptor.ex} (91%) rename lib/quickbeam/vm/{ => runtime}/reference.ex (78%) rename lib/quickbeam/vm/{ => runtime}/regexp.ex (84%) rename lib/quickbeam/vm/{stack_state.ex => runtime/stack.ex} (98%) rename lib/quickbeam/vm/{execution.ex => runtime/state.ex} (68%) rename lib/quickbeam/vm/{ => runtime/string}/utf16.ex (98%) rename lib/quickbeam/vm/{ => runtime}/symbol.ex (92%) rename lib/quickbeam/vm/{ => runtime}/thrown.ex (90%) rename lib/quickbeam/vm/{ => runtime}/value.ex (95%) rename lib/quickbeam/vm/{ => runtime/value}/export.ex (75%) delete mode 100644 test/fixtures/vm/fuzz/regressions/README.md rename test/vm/{builtin_dsl_test.exs => builtin/dsl_test.exs} (72%) rename test/vm/{ => bytecode}/decoder_test.exs (88%) rename test/vm/{leb128_test.exs => bytecode/varint_test.exs} (91%) rename test/vm/{compiler_generated_module_test.exs => compiler/code_test.exs} (76%) rename test/vm/{compiler_contract_test.exs => compiler/contract_test.exs} (95%) create mode 100644 test/vm/compiler/counter_test.exs rename test/vm/{compiler_orchestration_test.exs => compiler/orchestration_test.exs} (97%) rename test/vm/{compiler_module_pool_test.exs => compiler/pool_test.exs} (63%) rename test/vm/{compiler_pure_v1_test.exs => compiler/profile/pure_test.exs} (82%) rename test/vm/{compiler_region_probe_test.exs => compiler/region/probe_test.exs} (69%) rename test/vm/{compiler_runtime_test.exs => compiler/runtime_test.exs} (91%) delete mode 100644 test/vm/compiler_counters_test.exs rename test/vm/{program_store_test.exs => program/store_test.exs} (63%) delete mode 100644 test/vm/properties_test.exs rename test/vm/{ => runtime}/async_semantics_test.exs (86%) rename test/vm/{ => runtime}/async_test.exs (98%) rename test/vm/{ => runtime}/error_test.exs (99%) rename test/vm/{exceptions_test.exs => runtime/exception_test.exs} (71%) rename test/vm/{ => runtime}/heap_test.exs (90%) rename test/vm/{ => runtime}/interpreter_test.exs (98%) rename test/vm/{ => runtime}/invocation_test.exs (80%) rename test/vm/{object_model_test.exs => runtime/object_test.exs} (99%) rename test/vm/{opcode_families_test.exs => runtime/opcode_test.exs} (86%) rename test/vm/{ => runtime}/promise_test.exs (99%) create mode 100644 test/vm/runtime/property_test.exs rename test/vm/{ => runtime}/value_test.exs (95%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5db131fa8..34918c443 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,16 +2,11 @@ ## Unreleased -- Add explicit bounded immutable program pinning with lightweight `QuickBEAM.VM.PinnedProgram` handles, eight fixed persistent slots, binary identities, single-flight admission, owner-monitored leases, deferred unpinning, restart restoration, 32 MiB per-program and 128 MiB total decoded-term admission bounds, and copied-program semantics for ordinary programs. Pinned Vue SSR improves from a 49.21 ms median and 77.15 MiB endpoint process memory to 11.01 ms and 673.3 KiB while preserving exact steps, logical allocation, limits, cancellation, concurrency, and scheduler gates. Copied and pinned interpreter, `:pure_v1`, and `:scalar_v1` Test262 gates are included. -- Define the optional BEAM compiler contract with binary artifact identities, a fixed 32-module atom pool, lease/purge/cache lifecycle, versioned runtime ABI boundaries, and validated owner-local before-instruction deoptimization states. Add its supervised module pool, minimal canonical runtime ABI, structured generated-module backend, bounded v26 CFG analysis, specialized fixed-name `:pure_v1` forms, selective bounded nested-function re-entry, guarded opt-in `:scalar_v1` loop/property/global lowering, explicit call actions, exact shared program artifact namespaces, warm artifact/negative-decision caches, and release-quarantined `engine: :compiler` orchestration with native, Preact/Vue/Svelte SSR, selected Test262, resource, concurrency, measurement, and single-scheduler acceptance coverage. -- Remove integer-index keys from redundant object insertion-order lists, bulk-build literal arrays in one heap update, and encode default data descriptors compactly in the existing property map. Exact logical accounting is preserved; paired retained fixtures reduce array endpoint process memory by about 62% and ordinary-object retained VM heap size by 31%. -- Measure and reject a bounded owner-local hidden-class prototype. Although ideal repeated-shape objects retained 13.4% less VM heap, only 82 of 1,130 final Vue objects were wholly eligible; Vue regressed from 47.07 ms to 50.30 ms with higher reductions and no endpoint memory improvement. -- Measure and reject decode-time object-literal allocation plans with reserved identities, exact accounting, fixed tuple builders, and one-shot compact-map materialization. An ideal repeated four-field fixture improved by 10.1%, but only 16 Vue literals per render were eligible and the controlled Vue median regressed by 7.1%. -- Attribute pinned Vue interpreter churn by exact caller: 55.8% of `maps:put/3` calls update the outer object heap, 30.9% update property dictionaries, and 13.0% update globals or closure cells. Reject tuple/OTP-array constant pools, closure-allocation specialization, bulk function/prototype construction, and smaller opcode/property fast paths because lower call counts or reductions did not produce stable endpoint latency without resource regressions; removing 22.6% of traced map writes regressed the single-scheduler Vue median by 7.3%. -- Add bounded static and dynamic compiler-region probes plus a quarantined `compiler_regions: true` executor experiment with binary admission identities, a fixed 256-entry maximum, three-encounter admission, 32-operation straight-line regions, safe early-boundary tuple updates, exact resource accounting, and reproducible SSR measurements. The experiment remains disabled after Vue reached only 0.7% generated coverage and regressed to a 112.70 ms median. -- Cache validated immutable builtin host templates by profile and registry generation, preserving owner-local mutation isolation and exact logical-memory charging while removing repeated installation from warm evaluations. -- Add `QuickBEAM.VM.measure/2` with deterministic step/logical-memory counters, fixed owner-local OTP compiler counters, and endpoint process observations, plus reproducible pinned SSR concurrency, timeout, cancellation, reclamation, and single-scheduler reports. -- Serialize native addon initialization, reuse cached exports for aliases in one runtime, and reject implicit cross-runtime or post-reset reinitialization with a typed error. Add `allow_reinitialization: true` as an explicit compatibility escape hatch for addons that support multiple environments. +- 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. +- 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 optional bounded BEAM compiler with fixed generated-module slots and explicit deoptimization. Compiler execution remains disabled by default and 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 diff --git a/bench/README.md b/bench/README.md index b8457106c..f2d3594b0 100644 --- a/bench/README.md +++ b/bench/README.md @@ -20,106 +20,21 @@ 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 -# Reproduce the pinned BEAM VM SSR reports -MIX_ENV=bench mix run bench/vm_ssr.exs \ - --output docs/beam-ssr-measurements.md -MIX_ENV=bench mix run bench/vm_ssr.exs --pinned-programs \ - --output docs/beam-pinned-program-measurements.md - -# Reproduce the retained array/object-memory measurement -MIX_ENV=bench mix run bench/vm_object_memory.exs \ - --output docs/beam-object-memory-measurements.md - -# Reproduce runtime-initialization, cold-compilation, and warm loop measurements -COMPILER_PERF_ITERATIONS=2000 MIX_ENV=bench \ - mix run bench/vm_compiler_perf.exs - -# Profile warm generated execution or one-time host-template initialization -COMPILER_EPROF_PHASE=execution COMPILER_EPROF_ENGINE=compiler \ - COMPILER_EPROF_WORKLOAD=object_property_loop COMPILER_EPROF_ITERATIONS=200 \ - MIX_ENV=bench mix run bench/vm_compiler_eprof.exs -COMPILER_EPROF_PHASE=initialization COMPILER_EPROF_ENGINE=interpreter \ - COMPILER_EPROF_WORKLOAD=object_property_loop MIX_ENV=bench \ - mix run bench/vm_compiler_eprof.exs - -# Attribute interpreter calls in one warm pinned Vue render -VM_INTERPRETER_FPROF_OUTPUT=/tmp/vue.fprof \ - MIX_ENV=bench mix run bench/vm_interpreter_fprof.exs - -# Drive non-instrumented BeamAsm/perf sampling -ERL_FLAGS='+S 1:1 +JPperf true' perf record -F 997 -e cycles:u -- \ - env MIX_ENV=bench mix run bench/vm_interpreter_perf.exs --mode pinned - -# Attribute CPU in the pinned Vue fixture -COMPILER_SSR_EPROF_PROFILE=scalar_v1 COMPILER_SSR_EPROF_ITERATIONS=3 \ - MIX_ENV=bench mix run bench/vm_compiler_ssr_eprof.exs -COMPILER_SSR_EPROF_PROFILE=scalar_v1 COMPILER_SSR_EPROF_REGIONS=true \ - COMPILER_SSR_EPROF_ITERATIONS=3 MIX_ENV=bench \ - mix run bench/vm_compiler_ssr_eprof.exs - -# Reproduce the release-quarantined compiler SSR reports -MIX_ENV=bench mix run bench/vm_ssr.exs \ - --engine compiler --output docs/beam-compiler-ssr-measurements.md -MIX_ENV=bench mix run bench/vm_ssr.exs \ - --engine compiler --compiler-profile scalar_v1 \ - --output docs/beam-compiler-scalar-ssr-measurements.md - -# Reproduce bounded-region opportunity and rejected executor measurements -MIX_ENV=bench mix run bench/vm_compiler_region_probe.exs \ - --output docs/beam-compiler-region-probe.md -MIX_ENV=bench mix run bench/vm_compiler_region_hotspots.exs \ - --output docs/beam-compiler-region-hotspots.md -MIX_ENV=bench mix run bench/vm_ssr.exs \ - --engine compiler --compiler-profile scalar_v1 --compiler-regions \ - --output docs/beam-compiler-region-ssr-measurements.md - -# Reproduce the interpreter single-scheduler fairness/timeout probes -ERL_FLAGS='+S 1:1' MIX_ENV=bench mix run bench/vm_scheduler_probe.exs \ - --output docs/beam-scheduler-measurements.md -ERL_FLAGS='+S 1:1' MIX_ENV=bench mix run bench/vm_scheduler_probe.exs \ - --pinned-programs --output docs/beam-pinned-program-scheduler-measurements.md - -# Reproduce the release-quarantined compiler-tier probe -ERL_FLAGS='+S 1:1' MIX_ENV=bench mix run bench/vm_scheduler_probe.exs \ - --engine compiler --output docs/beam-compiler-scheduler-measurements.md -ERL_FLAGS='+S 1:1' MIX_ENV=bench mix run bench/vm_scheduler_probe.exs \ - --engine compiler --compiler-profile scalar_v1 \ - --output docs/beam-compiler-scalar-scheduler-measurements.md +# Isolated BEAM VM SSR and resource gates +MIX_ENV=bench mix run bench/vm_ssr.exs --pinned-programs + +# 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 compiler performance runner reports one-time core-profile initialization -separately before measuring cold compilation and warm execution. The eprof -runner accepts `COMPILER_EPROF_PHASE=execution|initialization`; initialization -profiles exactly one first evaluation in a fresh Mix VM, while execution warms -the profile template and generated artifact before collecting samples. - -The SSR and scheduler runners accept `--compiler-profile pure_v1|scalar_v1`. -The SSR and scheduler runners also accept explicit `--pinned-programs` handles. -The SSR runner accepts `--engine interpreter|compiler`, the quarantined -`--compiler-regions` experiment, `--samples`, `--warmup`, and a comma-separated -`--concurrency` list. It reports deterministic -VM steps and logical allocation, fixed compiler coverage counters, -endpoint BEAM process observations, sequential latency, concurrent throughput, -and timeout/cancellation behavior for the pinned Preact, Vue, and Svelte -fixtures. Published results are in -[`docs/beam-ssr-measurements.md`](../docs/beam-ssr-measurements.md), -[`docs/beam-pinned-program-investigation.md`](../docs/beam-pinned-program-investigation.md), -[`docs/beam-pinned-program-measurements.md`](../docs/beam-pinned-program-measurements.md), -[`docs/beam-pinned-program-scheduler-measurements.md`](../docs/beam-pinned-program-scheduler-measurements.md), -[`docs/beam-object-memory-investigation.md`](../docs/beam-object-memory-investigation.md), -[`docs/beam-object-memory-measurements.md`](../docs/beam-object-memory-measurements.md), -[`docs/beam-object-shape-investigation.md`](../docs/beam-object-shape-investigation.md), -[`docs/beam-object-literal-investigation.md`](../docs/beam-object-literal-investigation.md), -[`docs/beam-interpreter-hotspot-investigation.md`](../docs/beam-interpreter-hotspot-investigation.md), -[`docs/beam-compiler-performance-measurements.md`](../docs/beam-compiler-performance-measurements.md), -[`docs/beam-compiler-ssr-measurements.md`](../docs/beam-compiler-ssr-measurements.md), -[`docs/beam-compiler-scalar-ssr-measurements.md`](../docs/beam-compiler-scalar-ssr-measurements.md), -[`docs/beam-compiler-region-investigation.md`](../docs/beam-compiler-region-investigation.md), -[`docs/beam-compiler-region-ssr-measurements.md`](../docs/beam-compiler-region-ssr-measurements.md), -[`docs/beam-scheduler-measurements.md`](../docs/beam-scheduler-measurements.md), -[`docs/beam-compiler-scheduler-measurements.md`](../docs/beam-compiler-scheduler-measurements.md), -and -[`docs/beam-compiler-scalar-scheduler-measurements.md`](../docs/beam-compiler-scalar-scheduler-measurements.md). +The VM runners accept explicit interpreter or compiler profiles. Compiler +execution remains release-quarantined; the interpreter is the default. ## Results diff --git a/bench/vm_compiler_eprof.exs b/bench/vm_compiler_eprof.exs deleted file mode 100644 index 9d70b4a4b..000000000 --- a/bench/vm_compiler_eprof.exs +++ /dev/null @@ -1,80 +0,0 @@ -Mix.Task.run("app.start") - -alias QuickBEAM.VM.Compiler - -iterations = String.to_integer(System.get_env("COMPILER_EPROF_ITERATIONS", "200")) -engine = System.get_env("COMPILER_EPROF_ENGINE", "compiler") -workload = System.get_env("COMPILER_EPROF_WORKLOAD", "object_property_loop") -phase = System.get_env("COMPILER_EPROF_PHASE", "execution") - -sources = %{ - "arithmetic_loop" => "(function(n){let s=0;for(let i=0;i - "(function(arr){let s=0;for(let i=0;i - "(function(obj,n){let s=0;for(let i=0;i - [ - engine: :compiler, - compiler_profile: :scalar_v1, - isolation: :caller, - max_steps: 1_000_000 - ] - - "interpreter" -> - [isolation: :caller, max_steps: 1_000_000] - - invalid -> - raise "unsupported COMPILER_EPROF_ENGINE=#{inspect(invalid)}" - end - -expected = - case phase do - "execution" -> QuickBEAM.VM.eval(program, options) - "initialization" -> nil - invalid -> raise "unsupported COMPILER_EPROF_PHASE=#{inspect(invalid)}" - end - -pool = Process.whereis(QuickBEAM.VM.Compiler.ModulePool) - -tools_pattern = Path.join([to_string(:code.root_dir()), "lib", "tools-*", "ebin"]) -[tools_ebin | _] = Path.wildcard(tools_pattern) -true = :code.add_patha(String.to_charlist(tools_ebin)) -{:module, :eprof} = :code.ensure_loaded(:eprof) - -:eprof.start() -:eprof.start_profiling([self(), pool]) - -profiled_iterations = - case phase do - "execution" -> - Enum.each(1..iterations, fn _iteration -> - ^expected = QuickBEAM.VM.eval(program, options) - end) - - iterations - - "initialization" -> - {:ok, _value} = QuickBEAM.VM.eval(program, options) - 1 - end - -:eprof.stop_profiling() - -profiled_workload = if phase == "initialization", do: "host_template", else: workload - -IO.puts( - "EPROF phase=#{phase} engine=#{engine} workload=#{profiled_workload} iterations=#{profiled_iterations}" -) - -:eprof.analyze(:total) -:eprof.stop() -GenServer.stop(compiler) diff --git a/bench/vm_compiler_region_hotspots.exs b/bench/vm_compiler_region_hotspots.exs deleted file mode 100644 index e7f0115d1..000000000 --- a/bench/vm_compiler_region_hotspots.exs +++ /dev/null @@ -1,263 +0,0 @@ -defmodule QuickBEAM.Bench.VMCompilerRegionHotspots do - @moduledoc """ - Samples bounded dynamic instruction windows in the pinned SSR fixtures. - """ - - alias QuickBEAM.VM.Compiler - alias QuickBEAM.VM.Compiler.Lowering.PureV1 - alias QuickBEAM.VM.{Function, Opcodes} - - @module_slots 32 - - def run(args) do - {opts, positional, invalid} = OptionParser.parse(args, strict: [output: :string]) - - if positional != [] or invalid != [], - do: raise(ArgumentError, "invalid arguments: #{inspect(positional ++ invalid)}") - - {:ok, _compiler} = Compiler.start_link(capacity: @module_slots) - - results = - for fixture <- fixture_specs(), profile <- [:pure_v1, :scalar_v1] do - analyze_fixture(fixture, profile) - end - - report = report(results) - IO.write(report) - - if output = opts[:output] do - File.mkdir_p!(Path.dirname(output)) - File.write!(output, report) - end - after - if Process.whereis(Compiler.ModulePool), do: GenServer.stop(Compiler.ModulePool) - end - - defp analyze_fixture(fixture, profile) do - {:ok, source} = QuickBEAM.JS.bundle_file(fixture.path, fixture.bundle_opts) - {:ok, program} = QuickBEAM.VM.compile(source, filename: fixture.path) - functions = program.root |> functions() |> Map.new(&{&1.id, &1}) - options = eval_options(fixture, profile) - - {:ok, _value} = QuickBEAM.VM.eval(program, options) - - {:ok, measurement} = - QuickBEAM.VM.measure(program, Keyword.put(options, :compiler_region_probe, true)) - - unless match?({:ok, _value}, measurement.result), - do: raise("#{fixture.name} failed: #{inspect(measurement.result)}") - - probe = measurement.compiler_regions - - regions = - Enum.map(probe.regions, fn region -> - function = Map.fetch!(functions, region.function_id) - support = supported_window(function, region.entry_pc, probe.window_size, profile) - lower_bound = max(region.samples - region.error, 0) - weighted_support = lower_bound * support.supported / max(support.instructions, 1) - - Map.merge(region, %{ - instructions: support.instructions, - supported: support.supported, - lower_bound: lower_bound, - weighted_support: weighted_support - }) - end) - - selected = - regions - |> Enum.sort_by(&{-&1.weighted_support, &1.function_id, &1.entry_pc}) - |> Enum.take(@module_slots) - - lower_bound_coverage = - 100 * Enum.sum(Enum.map(selected, & &1.weighted_support)) / max(probe.total_samples, 1) - - %{ - fixture: fixture.name, - profile: profile, - steps: measurement.steps, - generated_steps: measurement.compiler_counters.generated_steps, - total_samples: probe.total_samples, - retained_regions: length(regions), - lower_bound_coverage: Float.round(lower_bound_coverage, 1), - regions: regions - } - end - - defp supported_window(function, entry_pc, window_size, profile) do - end_pc = min(entry_pc + window_size, tuple_size(function.instructions)) - 1 - - if end_pc < entry_pc do - %{instructions: 0, supported: 0} - else - Enum.reduce(entry_pc..end_pc, %{instructions: 0, supported: 0}, fn pc, counts -> - {opcode, operands} = elem(function.instructions, pc) - {name, _size, _pops, _pushes, _format} = Opcodes.info(opcode) - {name, operands} = Opcodes.expand_short_form(name, operands, function.arg_count) - instruction = {pc, name, operands} - - %{ - instructions: counts.instructions + 1, - supported: - counts.supported + - if(PureV1.supported_instruction?(instruction, profile), do: 1, else: 0) - } - end) - end - end - - defp functions(%Function{} = function) do - nested = - Enum.flat_map(function.constants, fn - %Function{} = child -> functions(child) - _constant -> [] - end) - - [function | nested] - end - - defp eval_options(fixture, compiler_profile) do - [ - engine: :compiler, - compiler_profile: compiler_profile, - isolation: :caller, - profile: :ssr, - handlers: %{"load_props" => fn [] -> props() end}, - max_steps: fixture.max_steps, - memory_limit: fixture.memory_limit, - timeout: 5_000 - ] - end - - defp fixture_specs do - [ - %{ - name: "Preact 10.29.7", - path: "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", - path: "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", - path: "test/fixtures/vm/svelte_ssr.js", - bundle_opts: [format: :esm, minify: true], - max_steps: 20_000_000, - memory_limit: 64_000_000 - } - ] - end - - defp props do - %{ - "title" => "Region probe", - "products" => [ - %{"id" => 1, "name" => "Product 1", "inStock" => true, "priceCents" => 1_299} - ] - } - end - - defp report(results) do - """ - # Bounded compiler dynamic region hotspots - - This opt-in diagnostic samples every 16th interpreted instruction into at - most 64 owner-local Space-Saving heavy hitters. Windows are aligned to 64 - instruction PCs. `samples-error` is the conservative frequency lower bound; - fixed-pool potential applies each window's statically supported ratio to the - 32 strongest lower bounds. Generated instructions are not sampled. Results - are fixture/profile-specific and are not production telemetry or speedup - claims. - - - 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()} - - CPU: #{cpu_model()} - - Sampling interval: 16 interpreted instructions - - Heavy-hitter capacity: 64 regions - - Fixed generated-module slots: #{@module_slots} - - | Fixture | profile | VM steps | existing generated steps | samples | retained regions | fixed-pool supported lower bound | - |---|---|---:|---:|---:|---:|---:| - #{Enum.map_join(results, "\n", &summary_row/1)} - - ## Leading sampled windows - - | Fixture | profile | function@pc | samples | error | lower bound | supported instructions | - |---|---|---|---:|---:|---:|---:| - #{Enum.map_join(results, "\n", &hotspot_rows/1)} - - The next implementation gate is positive only when a small fixed region set - has a meaningful conservative dynamic lower bound. Otherwise region - compilation would add artifact churn without enough warm execution. - """ - end - - defp summary_row(result) do - "| #{result.fixture} | `#{result.profile}` | #{result.steps} | " <> - "#{result.generated_steps} | #{result.total_samples} | #{result.retained_regions} | " <> - "#{result.lower_bound_coverage}% |" - end - - defp hotspot_rows(result) do - result.regions - |> Enum.sort_by(&{-&1.lower_bound, -&1.samples, &1.function_id, &1.entry_pc}) - |> Enum.take(8) - |> Enum.map_join("\n", fn region -> - "| #{result.fixture} | `#{result.profile}` | `f#{region.function_id}@#{region.entry_pc}` | " <> - "#{region.samples} | #{region.error} | #{region.lower_bound} | " <> - "#{region.supported}/#{region.instructions} |" - 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 cpu_model do - case File.read("/proc/cpuinfo") do - {:ok, contents} -> - contents - |> String.split("\n") - |> Enum.find_value("unknown", fn line -> - case String.split(line, ":", parts: 2) do - [key, model] -> if(String.trim(key) == "model name", do: String.trim(model)) - _line -> nil - end - end) - - {:error, _reason} -> - command("sysctl", ["-n", "machdep.cpu.brand_string"]) - end - end -end - -QuickBEAM.Bench.VMCompilerRegionHotspots.run(System.argv()) diff --git a/bench/vm_compiler_region_probe.exs b/bench/vm_compiler_region_probe.exs deleted file mode 100644 index ff8e2b7e0..000000000 --- a/bench/vm_compiler_region_probe.exs +++ /dev/null @@ -1,225 +0,0 @@ -defmodule QuickBEAM.Bench.VMCompilerRegionProbe do - @moduledoc """ - Computes a static upper bound for bounded one-block compiler regions. - """ - - alias QuickBEAM.VM.Compiler.Analysis.CFG - alias QuickBEAM.VM.Compiler.Lowering.PureV1 - alias QuickBEAM.VM.Function - - @max_region_operations 64 - @module_slots 32 - @preflight_operations [:get_array_el, :get_field, :get_field2, :get_length, :get_var] - @invocation_operations [:call, :call_method] - - def run(args) do - {opts, positional, invalid} = OptionParser.parse(args, strict: [output: :string]) - - if positional != [] or invalid != [], - do: raise(ArgumentError, "invalid arguments: #{inspect(positional ++ invalid)}") - - results = - for fixture <- fixture_specs(), profile <- [:pure_v1, :scalar_v1] do - analyze_fixture(fixture, profile) - end - - report = report(results) - IO.write(report) - - if output = opts[:output] do - File.mkdir_p!(Path.dirname(output)) - File.write!(output, report) - end - end - - defp analyze_fixture(fixture, profile) do - {:ok, source} = QuickBEAM.JS.bundle_file(fixture.path, fixture.bundle_opts) - {:ok, program} = QuickBEAM.VM.compile(source, filename: fixture.path) - functions = functions(program.root) - - regions = - Enum.flat_map(functions, fn function -> - {:ok, blocks} = CFG.analyze(function) - - Enum.flat_map(blocks, fn block -> - block.instructions - |> split_regions(profile, [], []) - |> Enum.map(fn instructions -> - %{ - function_id: function.id, - entry_pc: instructions |> hd() |> elem(0), - operations: length(instructions) - } - end) - end) - end) - - instruction_count = Enum.sum(Enum.map(functions, &tuple_size(&1.instructions))) - region_operations = Enum.sum(Enum.map(regions, & &1.operations)) - - slot_operations = - regions - |> Enum.sort_by(&{-&1.operations, &1.function_id, &1.entry_pc}) - |> Enum.take(@module_slots) - |> Enum.reduce(0, &(&1.operations + &2)) - - %{ - fixture: fixture.name, - profile: profile, - functions: length(functions), - instructions: instruction_count, - region_functions: regions |> Enum.map(& &1.function_id) |> Enum.uniq() |> length(), - regions: length(regions), - region_operations: region_operations, - region_coverage: percentage(region_operations, instruction_count), - slot_operations: slot_operations, - slot_coverage: percentage(slot_operations, instruction_count) - } - end - - defp split_regions([], _profile, [], regions), do: Enum.reverse(regions) - - defp split_regions([], _profile, current, regions), - do: Enum.reverse([Enum.reverse(current) | regions]) - - defp split_regions([instruction | instructions], profile, current, regions) do - {_pc, name, _operands} = instruction - - cond do - not PureV1.supported_instruction?(instruction, profile) -> - regions = flush(current, regions) - split_regions(instructions, profile, [], regions) - - name in @preflight_operations -> - regions = [[instruction] | flush(current, regions)] - split_regions(instructions, profile, [], regions) - - name in @invocation_operations -> - current = [instruction | current] - split_regions(instructions, profile, [], [Enum.reverse(current) | regions]) - - length(current) + 1 == @max_region_operations -> - current = [instruction | current] - split_regions(instructions, profile, [], [Enum.reverse(current) | regions]) - - true -> - split_regions(instructions, profile, [instruction | current], regions) - end - end - - defp flush([], regions), do: regions - defp flush(current, regions), do: [Enum.reverse(current) | regions] - - defp functions(%Function{} = function) do - nested = - Enum.flat_map(function.constants, fn - %Function{} = child -> functions(child) - _constant -> [] - end) - - [function | nested] - end - - defp fixture_specs do - [ - %{ - name: "Preact 10.29.7", - path: "test/fixtures/vm/preact_ssr.js", - bundle_opts: [format: :esm, minify: false] - }, - %{ - name: "Vue 3.5.39", - path: "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") - } - ] - }, - %{ - name: "Svelte 5.56.4", - path: "test/fixtures/vm/svelte_ssr.js", - bundle_opts: [format: :esm, minify: true] - } - ] - end - - defp report(results) do - """ - # Bounded compiler region coverage probe - - This static probe partitions each verified basic block into independently - compilable regions of at most #{@max_region_operations} operations. Property - and strict-global reads remain isolated preflight regions, calls terminate a - region, and unsupported instructions are excluded. The fixed module-pool - estimate retains only the #{@module_slots} largest regions. These figures are - an instruction-inventory bound, not dynamic execution coverage or a speedup - claim. - - - 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()} - - CPU: #{cpu_model()} - - Maximum operations per region: #{@max_region_operations} - - Fixed generated-module slots: #{@module_slots} - - | Fixture | profile | functions | instructions | region functions | bounded regions | regionizable instructions | static region coverage | largest 32 instructions | fixed-pool static coverage | - |---|---|---:|---:|---:|---:|---:|---:|---:|---:| - #{Enum.map_join(results, "\n", &row/1)} - - A region tier is useful only if dynamic hot-region measurements show that a - small fixed set is executed repeatedly. Static coverage alone cannot justify - compiling every listed region or exceeding the existing module and decision - bounds. - """ - end - - defp row(result) do - "| #{result.fixture} | `#{result.profile}` | #{result.functions} | " <> - "#{result.instructions} | #{result.region_functions} | #{result.regions} | " <> - "#{result.region_operations} | #{result.region_coverage}% | " <> - "#{result.slot_operations} | #{result.slot_coverage}% |" - end - - defp percentage(value, total), do: Float.round(100 * value / max(total, 1), 1) - - 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 cpu_model do - case File.read("/proc/cpuinfo") do - {:ok, contents} -> - contents - |> String.split("\n") - |> Enum.find_value("unknown", fn line -> - case String.split(line, ":", parts: 2) do - [key, model] -> if(String.trim(key) == "model name", do: String.trim(model)) - _line -> nil - end - end) - - {:error, _reason} -> - command("sysctl", ["-n", "machdep.cpu.brand_string"]) - end - end -end - -QuickBEAM.Bench.VMCompilerRegionProbe.run(System.argv()) diff --git a/bench/vm_compiler_ssr_eprof.exs b/bench/vm_compiler_ssr_eprof.exs deleted file mode 100644 index 601aac4d6..000000000 --- a/bench/vm_compiler_ssr_eprof.exs +++ /dev/null @@ -1,82 +0,0 @@ -Mix.Task.run("app.start") - -alias QuickBEAM.VM.Compiler - -iterations = String.to_integer(System.get_env("COMPILER_SSR_EPROF_ITERATIONS", "5")) -profile = System.get_env("COMPILER_SSR_EPROF_PROFILE", "pure_v1") -regions = System.get_env("COMPILER_SSR_EPROF_REGIONS", "false") == "true" - -{engine, compiler_profile} = - case profile do - "interpreter" -> {:interpreter, :pure_v1} - "pure_v1" -> {:compiler, :pure_v1} - "scalar_v1" -> {:compiler, :scalar_v1} - invalid -> raise "unsupported COMPILER_SSR_EPROF_PROFILE=#{inspect(invalid)}" - end - -{:ok, _compiler} = Compiler.start_link(capacity: 32) - -{:ok, source} = - QuickBEAM.JS.bundle_file("test/fixtures/vm/vue_ssr.js", - 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") - } - ) - -{:ok, program} = QuickBEAM.VM.compile(source, filename: "test/fixtures/vm/vue_ssr.js") - -props = %{ - "title" => "Compiler profile", - "products" => [ - %{"id" => 1, "name" => "Product 1", "inStock" => true, "priceCents" => 1_299} - ] -} - -options = [ - engine: engine, - compiler_profile: compiler_profile, - compiler_regions: regions, - isolation: :caller, - profile: :ssr, - handlers: %{"load_props" => fn [] -> props end}, - max_steps: 50_000_000, - memory_limit: 256_000_000, - timeout: 5_000 -] - -expected = - Enum.reduce(1..if(regions, do: 3, else: 1), nil, fn _iteration, expected -> - result = QuickBEAM.VM.eval(program, options) - if expected != nil and result != expected, do: raise("warmup result changed") - result - end) - -pool = Process.whereis(QuickBEAM.VM.Compiler.ModulePool) - -tools_pattern = Path.join([to_string(:code.root_dir()), "lib", "tools-*", "ebin"]) -[tools_ebin | _] = Path.wildcard(tools_pattern) -true = :code.add_patha(String.to_charlist(tools_ebin)) -{:module, :eprof} = :code.ensure_loaded(:eprof) - -:eprof.start() -:eprof.start_profiling([self(), pool]) - -Enum.each(1..iterations, fn _iteration -> - ^expected = QuickBEAM.VM.eval(program, options) -end) - -:eprof.stop_profiling() - -IO.puts( - "EPROF fixture=vue_ssr engine=#{engine} compiler_profile=#{compiler_profile} " <> - "regions=#{regions} iterations=#{iterations}" -) - -:eprof.analyze(:total) -:eprof.stop() -GenServer.stop(Compiler.ModulePool) diff --git a/bench/vm_interpreter_fprof.exs b/bench/vm_interpreter_fprof.exs deleted file mode 100644 index 0bdabb8cc..000000000 --- a/bench/vm_interpreter_fprof.exs +++ /dev/null @@ -1,71 +0,0 @@ -output = System.get_env("VM_INTERPRETER_FPROF_OUTPUT", "vm-interpreter.fprof") -trace = output <> ".trace" - -tools_ebin = - :code.root_dir() - |> to_string() - |> Path.join("lib/tools-*/ebin") - |> Path.wildcard() - |> List.first() - -runtime_tools_ebin = - :code.root_dir() - |> to_string() - |> Path.join("lib/runtime_tools-*/ebin") - |> Path.wildcard() - |> List.first() - -true = :code.add_patha(String.to_charlist(tools_ebin)) -true = :code.add_patha(String.to_charlist(runtime_tools_ebin)) -{:module, :dbg} = :code.ensure_loaded(:dbg) -{:module, :fprof} = :code.ensure_loaded(:fprof) - -{:ok, source} = - QuickBEAM.JS.bundle_file("test/fixtures/vm/vue_ssr.js", - 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") - } - ) - -{:ok, program} = QuickBEAM.VM.compile(source, filename: "test/fixtures/vm/vue_ssr.js") - -props = %{ - "title" => "Profile", - "products" => [ - %{"id" => 1, "name" => "Product 1", "inStock" => true, "priceCents" => 1299} - ] -} - -handler = fn [] -> props end - -opts = [ - profile: :ssr, - handlers: %{"load_props" => handler}, - max_steps: 50_000_000, - memory_limit: 256_000_000, - timeout: 5_000, - isolation: :caller -] - -{:ok, _html} = QuickBEAM.VM.Evaluator.eval(program, opts) - -:fprof.apply(fn -> QuickBEAM.VM.Evaluator.eval(program, opts) end, [], - file: String.to_charlist(trace) -) - -:fprof.profile(file: String.to_charlist(trace)) - -:fprof.analyse( - dest: String.to_charlist(output), - callers: true, - sort: :own, - totals: true, - details: true -) - -IO.puts("wrote #{output}") diff --git a/bench/vm_object_memory.exs b/bench/vm_object_memory.exs deleted file mode 100644 index df380e1a5..000000000 --- a/bench/vm_object_memory.exs +++ /dev/null @@ -1,217 +0,0 @@ -defmodule QuickBEAM.Bench.VMObjectMemory do - @moduledoc """ - Measures the isolated VM's array-heavy allocation path. - """ - - alias QuickBEAM.VM.{Execution, Interpreter} - - @default_count 20_000 - @default_object_count 5_000 - @default_samples 30 - - def run(args) do - {opts, positional, invalid} = - OptionParser.parse(args, - strict: [count: :integer, object_count: :integer, samples: :integer, output: :string] - ) - - if positional != [] or invalid != [], - do: raise(ArgumentError, "invalid arguments: #{inspect(positional ++ invalid)}") - - count = positive!(Keyword.get(opts, :count, @default_count), :count) - - object_count = - positive!(Keyword.get(opts, :object_count, @default_object_count), :object_count) - - samples = positive!(Keyword.get(opts, :samples, @default_samples), :samples) - - fixtures = [ - {"sequential array", count, 20, compile_array_workload!(count)}, - {"ordinary objects", object_count, 80, compile_object_workload!(object_count)} - ] - - measurements = - Enum.map(fixtures, fn {name, entries, step_factor, program} -> - Enum.each(1..3, fn _iteration -> measure!(program, entries, step_factor) end) - - values = - Enum.map(1..samples, fn _iteration -> measure!(program, entries, step_factor) end) - - retained_heap_bytes = retained_heap_bytes!(program, entries, step_factor) - {name, entries, retained_heap_bytes, values} - end) - - report = report(samples, measurements) - IO.write(report) - - if output = opts[:output] do - File.mkdir_p!(Path.dirname(output)) - File.write!(output, report) - end - end - - defp compile_array_workload!(count) do - source = """ - let values = []; - for (let index = 0; index < #{count}; index++) values.push(index); - globalThis.__quickbeamRetainedArray = values; - values.length; - """ - - {:ok, program} = QuickBEAM.VM.compile(source, filename: "vm_array_memory.js") - program - end - - defp compile_object_workload!(count) do - source = """ - let values = []; - for (let index = 0; index < #{count}; index++) { - values.push({id: index, label: "item-" + index, active: (index & 1) === 0}); - } - globalThis.__quickbeamRetainedObjects = values; - values.length; - """ - - {:ok, program} = QuickBEAM.VM.compile(source, filename: "vm_object_memory.js") - program - end - - defp measure!(program, count, step_factor) do - {:ok, measurement} = - QuickBEAM.VM.measure(program, - max_steps: count * step_factor + 10_000, - memory_limit: 256_000_000, - timeout: 5_000 - ) - - if measurement.result != {:ok, count}, - do: raise("array allocation measurement failed: #{inspect(measurement.result)}") - - measurement - end - - defp retained_heap_bytes!(program, count, step_factor) do - {:ok, ^count, %Execution{} = execution} = - Interpreter.start(program, - max_steps: count * step_factor + 10_000, - memory_limit: 256_000_000 - ) - - :erts_debug.size(execution.heap) * :erlang.system_info(:wordsize) - end - - defp report(samples, fixture_measurements) do - metadata = metadata() - - rows = - Enum.map_join(fixture_measurements, "\n", fn - {name, entries, retained_heap_bytes, measurements} -> - wall = Enum.map(measurements, & &1.wall_time_us) - reductions = Enum.map(measurements, & &1.reductions) - memory = Enum.map(measurements, & &1.process_memory_bytes) - first = hd(measurements) - - "| #{name} | #{entries} | #{format_us(median(wall))} | #{format_us(percentile(wall, 0.95))} | #{median(reductions)} | #{format_bytes(median(memory))} | #{format_bytes(retained_heap_bytes)} | #{first.steps} | #{format_bytes(first.logical_memory_bytes)} |" - end) - - """ - # BEAM VM object-memory measurements - - These fixtures retain a sequential JavaScript array and an array of ordinary - three-property objects. They measure the canonical interpreter path, - including isolated-process startup and result conversion. Endpoint process - memory is not a sampled peak or operating-system RSS value. - - ## Environment - - - Git base: `#{metadata.git}` - - Working tree at measurement: #{metadata.tree_state} - - Generated: #{metadata.generated} - - Elixir: #{metadata.elixir} - - OTP: #{metadata.otp} - - ERTS: #{metadata.erts} - - Architecture: #{metadata.architecture} - - CPU: #{metadata.cpu} - - Samples per fixture after 3 warmups: #{samples} - - | workload | entries | wall median | wall p95 | reductions median | endpoint process memory | retained VM heap | VM steps | logical memory | - |---|---:|---:|---:|---:|---:|---:|---:|---:| - #{rows} - - VM steps and logical memory are deterministic. Retained VM heap uses - `:erts_debug.size/1` after direct canonical interpretation and includes - shared subterms once; it is diagnostic rather than a supported OTP API. - Endpoint process memory is observational and reflects allocated heap classes, - not only live terms. - """ - end - - defp median(values), do: percentile(values, 0.5) - - defp percentile(values, fraction) do - values = Enum.sort(values) - index = (fraction * (length(values) - 1)) |> Float.ceil() |> trunc() - Enum.at(values, index) - end - - defp format_us(value) when value >= 1_000, - do: "#{Float.round(value / 1_000, 2)} ms" - - defp format_us(value), do: "#{value} µs" - - defp format_bytes(value) when value >= 1024 * 1024, - do: "#{Float.round(value / (1024 * 1024), 2)} MiB" - - defp format_bytes(value) when value >= 1024, - do: "#{Float.round(value / 1024, 1)} KiB" - - defp format_bytes(value), do: "#{value} B" - - 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 metadata do - %{ - git: command("git", ["rev-parse", "--short", "HEAD"]), - tree_state: - if(command("git", ["status", "--porcelain"]) == "", do: "clean", else: "modified"), - generated: DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601(), - elixir: System.version(), - otp: System.otp_release(), - erts: to_string(:erlang.system_info(:version)), - architecture: to_string(:erlang.system_info(:system_architecture)), - cpu: cpu_model() - } - end - - defp command(executable, arguments) do - case System.cmd(executable, arguments, stderr_to_stdout: true) do - {output, 0} -> String.trim(output) - _error -> "unknown" - end - end - - defp cpu_model do - case File.read("/proc/cpuinfo") do - {:ok, cpuinfo} -> - cpuinfo - |> String.split("\n") - |> Enum.find_value("unknown", &cpu_model_from_line/1) - - {:error, _reason} -> - command("sysctl", ["-n", "machdep.cpu.brand_string"]) - end - end - - defp cpu_model_from_line(line) do - case String.split(line, ":", parts: 2) do - ["model name", model] -> String.trim(model) - [key, model] -> if(String.trim(key) == "model name", do: String.trim(model)) - _line -> nil - end - end -end - -QuickBEAM.Bench.VMObjectMemory.run(System.argv()) diff --git a/lib/quickbeam/application.ex b/lib/quickbeam/application.ex index 70b9dc59e..18ebf60d1 100644 --- a/lib/quickbeam/application.ex +++ b/lib/quickbeam/application.ex @@ -17,7 +17,7 @@ defmodule QuickBEAM.Application do }, QuickBEAM.LockManager, QuickBEAM.WasmAPI, - QuickBEAM.VM.ProgramStore, + QuickBEAM.VM.Program.Store, {Task.Supervisor, name: QuickBEAM.VM.TaskSupervisor} ] diff --git a/lib/quickbeam/js_error.ex b/lib/quickbeam/js_error.ex index 919a24317..58bcdad3c 100644 --- a/lib/quickbeam/js_error.ex +++ b/lib/quickbeam/js_error.ex @@ -70,7 +70,7 @@ defmodule QuickBEAM.JSError do @doc "Builds an exception from an uncaught VM value and JavaScript stack frames." @spec from_vm(term(), [frame()]) :: t() - def from_vm(%QuickBEAM.VM.Thrown{value: value, frames: async_frames}, frames), + 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 diff --git a/lib/quickbeam/vm.ex b/lib/quickbeam/vm.ex index 34f94718a..982cff2e3 100644 --- a/lib/quickbeam/vm.ex +++ b/lib/quickbeam/vm.ex @@ -6,21 +6,19 @@ defmodule QuickBEAM.VM do heap, Promise state, host operations, and resource limits. """ - alias QuickBEAM.VM.{ - ABI, - Compiler, - Decoder, - Evaluator, - Function, - Measurement, - Program, - ProgramStore, - PinnedProgram, - Verifier - } + alias QuickBEAM.VM.ABI + alias QuickBEAM.VM.Compiler + alias QuickBEAM.VM.Bytecode.Decoder + alias QuickBEAM.VM.Runtime + alias QuickBEAM.VM.Program.Function + alias QuickBEAM.VM.Measurement + alias QuickBEAM.VM.Program + alias QuickBEAM.VM.Program.Store + alias QuickBEAM.VM.Program.Pinned + alias QuickBEAM.VM.Bytecode.Verifier @type program :: QuickBEAM.VM.Program.t() - @type pinned_program :: QuickBEAM.VM.PinnedProgram.t() + @type pinned_program :: QuickBEAM.VM.Program.Pinned.t() @max_bytecode_bytes 16 * 1024 * 1024 @default_timeout 5_000 @@ -67,12 +65,12 @@ defmodule QuickBEAM.VM do residency to 128 MiB. Concurrent pins of the same program identity are idempotent and return the same lightweight handle. """ - @spec pin(Program.t()) :: {:ok, PinnedProgram.t()} | {:error, term()} + @spec pin(Program.t()) :: {:ok, Pinned.t()} | {:error, term()} def pin(%Program{} = program) do program = Program.put_pin_key(program) with :ok <- Verifier.verify(program) do - case ProgramStore.pin(program) do + case Store.pin(program) do {:ok, pinned} -> {:ok, pinned} {:error, reason} -> {:error, reason} :unavailable -> {:error, :pinned_program_capacity} @@ -137,10 +135,10 @@ defmodule QuickBEAM.VM do BEAM process heap ceiling. `isolation: :caller` is available for trusted diagnostics. The compiler engine requires a supervised `QuickBEAM.VM.Compiler`. """ - @spec eval(Program.t() | PinnedProgram.t(), keyword()) :: {:ok, term()} | {:error, term()} + @spec eval(Program.t() | Pinned.t(), keyword()) :: {:ok, term()} | {:error, term()} def eval(program, opts \\ []) - def eval(%PinnedProgram{} = pinned, opts) when is_list(opts) do + def eval(%Pinned{} = pinned, opts) when is_list(opts) do with {:ok, options} <- evaluation_options(opts), {:ok, lease} <- pinned_lease(pinned) do try do @@ -149,7 +147,7 @@ defmodule QuickBEAM.VM do :process -> eval_isolated_pinned(lease, options) end after - ProgramStore.checkin(lease) + Store.checkin(lease) end end end @@ -173,11 +171,11 @@ defmodule QuickBEAM.VM do `measurement.result`. Invalid programs or options are returned directly as `{:error, reason}` because no evaluation was started. """ - @spec measure(Program.t() | PinnedProgram.t(), keyword()) :: + @spec measure(Program.t() | Pinned.t(), keyword()) :: {:ok, Measurement.t()} | {:error, term()} def measure(program, opts \\ []) - def measure(%PinnedProgram{} = pinned, opts) when is_list(opts) do + def measure(%Pinned{} = pinned, opts) when is_list(opts) do with {:ok, options} <- evaluation_options(opts), {:ok, lease} <- pinned_lease(pinned) do started = System.monotonic_time() @@ -189,7 +187,7 @@ defmodule QuickBEAM.VM do :process -> measure_isolated_pinned(lease, options) end after - ProgramStore.checkin(lease) + Store.checkin(lease) end elapsed = System.monotonic_time() - started @@ -241,7 +239,7 @@ defmodule QuickBEAM.VM do 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.ModulePool) + 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) @@ -320,14 +318,14 @@ defmodule QuickBEAM.VM do end defp pinned_lease(pinned) do - case ProgramStore.checkout(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} <- ProgramStore.fetch(lease), + with {:ok, program} <- Store.fetch(lease), :ok <- Verifier.verify_identity(program) do evaluate(program, options) end @@ -343,7 +341,7 @@ defmodule QuickBEAM.VM do defp eval_isolated(program, options), do: eval_isolated_program(program, options) defp eval_isolated_pinned(lease, options) do - with {:ok, program} <- ProgramStore.fetch(lease), + with {:ok, program} <- Store.fetch(lease), :ok <- Verifier.verify_identity(program) do eval_isolated_program(program, options) end @@ -359,7 +357,7 @@ defmodule QuickBEAM.VM do end defp evaluate(program, %{engine: :interpreter, interpreter: options}), - do: Evaluator.eval(program, Map.to_list(options)) + do: Runtime.eval(program, Map.to_list(options)) defp evaluate(program, %{engine: :compiler, interpreter: options}), do: Compiler.eval(program, Map.to_list(options)) @@ -385,7 +383,7 @@ defmodule QuickBEAM.VM do end defp fetch_verified_pinned(lease) do - with {:ok, program} <- ProgramStore.fetch(lease), + with {:ok, program} <- Store.fetch(lease), :ok <- Verifier.verify_identity(program), do: {:ok, program} end @@ -422,7 +420,7 @@ defmodule QuickBEAM.VM do end defp measure_engine(:interpreter, program, options), - do: Evaluator.eval_with_metrics(program, options) + do: Runtime.eval_with_metrics(program, options) defp measure_engine(:compiler, program, options), do: Compiler.eval_with_metrics(program, options) @@ -452,8 +450,8 @@ defmodule QuickBEAM.VM do idempotent by program identity rather than ownership-counted, one lifecycle owner should coordinate pinning and unpinning. """ - @spec unpin(PinnedProgram.t()) :: :ok | :not_pinned - def unpin(%PinnedProgram{} = pinned), do: ProgramStore.unpin(pinned) + @spec unpin(Pinned.t()) :: :ok | :not_pinned + def unpin(%Pinned{} = pinned), do: Store.unpin(pinned) @doc "Returns the monitored worker spawn options for an evaluation memory limit." def worker_spawn_options(:infinity), do: [:monitor] diff --git a/lib/quickbeam/vm/async_boundary.ex b/lib/quickbeam/vm/async_boundary.ex deleted file mode 100644 index 9f0e44415..000000000 --- a/lib/quickbeam/vm/async_boundary.ex +++ /dev/null @@ -1,27 +0,0 @@ -defmodule QuickBEAM.VM.AsyncBoundary 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.PromiseReference.t(), - caller: - QuickBEAM.VM.AccessorBoundary.t() - | QuickBEAM.VM.Frame.t() - | QuickBEAM.VM.ObjectAssignBoundary.t() - | QuickBEAM.VM.NativeFrame.t() - | QuickBEAM.VM.ReactionBoundary.t() - | QuickBEAM.VM.PromiseExecutorBoundary.t() - | QuickBEAM.VM.ThenableBoundary.t() - | QuickBEAM.VM.ThenGetterBoundary.t() - | nil, - depth: pos_integer(), - mode: :push | :return | :reaction | :executor | :thenable | :detached - } -end diff --git a/lib/quickbeam/vm/builtin.ex b/lib/quickbeam/vm/builtin.ex index ee1cf90d1..01be73610 100644 --- a/lib/quickbeam/vm/builtin.ex +++ b/lib/quickbeam/vm/builtin.ex @@ -9,12 +9,14 @@ defmodule QuickBEAM.VM.Builtin do immediate result, JavaScript error, or typed resumable action. """ - alias QuickBEAM.VM.Builtin.{Action, Call, ContractError} - alias QuickBEAM.VM.Execution + 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(), Execution.t()} - | {:error, term(), Execution.t()} + {:ok, term(), State.t()} + | {:error, term(), State.t()} | Action.t() @doc "Installs the declarative builtin DSL in a module." @@ -41,8 +43,8 @@ defmodule QuickBEAM.VM.Builtin do result = apply(module, handler, [call]) case result do - {:ok, _value, %Execution{}} -> result - {:error, _reason, %Execution{}} -> result + {:ok, _value, %State{}} -> result + {:error, _reason, %State{}} -> result %Action{} -> result invalid -> raise ContractError, module: module, handler: handler, result: invalid end diff --git a/lib/quickbeam/vm/builtin/action.ex b/lib/quickbeam/vm/builtin/action.ex index 799d986f1..dd2ce9a46 100644 --- a/lib/quickbeam/vm/builtin/action.ex +++ b/lib/quickbeam/vm/builtin/action.ex @@ -6,14 +6,3 @@ defmodule QuickBEAM.VM.Builtin.Action do @type t :: %__MODULE__{value: term()} end - -defmodule QuickBEAM.VM.Builtin.ContractError do - @moduledoc "Raised when a builtin handler violates the runtime result contract." - - defexception [:module, :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/builtins/array.ex b/lib/quickbeam/vm/builtin/array.ex similarity index 93% rename from lib/quickbeam/vm/builtins/array.ex rename to lib/quickbeam/vm/builtin/array.ex index 8b9ff8299..60c508ac2 100644 --- a/lib/quickbeam/vm/builtins/array.ex +++ b/lib/quickbeam/vm/builtin/array.ex @@ -1,11 +1,15 @@ -defmodule QuickBEAM.VM.Builtins.Array do +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.{Heap, Object, Properties, Reference, Value} + 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, @@ -60,7 +64,7 @@ defmodule QuickBEAM.VM.Builtins.Array do result = case value do - %Reference{} = reference -> Properties.kind(reference, execution) == :array + %Reference{} = reference -> Property.kind(reference, execution) == :array value -> is_list(value) end @@ -127,7 +131,7 @@ defmodule QuickBEAM.VM.Builtins.Array do values |> Enum.with_index(length) |> Enum.reduce(execution, fn {value, index}, execution -> - {:ok, execution} = Properties.put(array, index, value, execution) + {:ok, execution} = Property.put(array, index, value, execution) execution end) @@ -188,7 +192,7 @@ defmodule QuickBEAM.VM.Builtins.Array do values |> Enum.with_index() |> Enum.reduce(execution, fn {value, index}, execution -> - {:ok, execution} = Properties.define(array, index, value, execution) + {:ok, execution} = Property.define(array, index, value, execution) execution end) @@ -231,14 +235,14 @@ defmodule QuickBEAM.VM.Builtins.Array do |> Enum.with_index() |> Enum.reduce(execution, fn {{:present, value}, index}, execution -> - {:ok, execution} = Properties.define(array, index, value, execution) + {:ok, execution} = Property.define(array, index, value, execution) execution {:hole, _index}, execution -> execution end) - {:ok, execution} = Properties.define(array, "length", length(entries), execution) + {:ok, execution} = Property.define(array, "length", length(entries), execution) {array, execution} end diff --git a/lib/quickbeam/vm/builtins/boolean.ex b/lib/quickbeam/vm/builtin/boolean.ex similarity index 81% rename from lib/quickbeam/vm/builtins/boolean.ex rename to lib/quickbeam/vm/builtin/boolean.ex index 2c07cbf1f..2e03eab46 100644 --- a/lib/quickbeam/vm/builtins/boolean.ex +++ b/lib/quickbeam/vm/builtin/boolean.ex @@ -1,10 +1,13 @@ -defmodule QuickBEAM.VM.Builtins.Boolean do +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.{ConstructorBoundary, Heap, Reference, Value} + alias QuickBEAM.VM.Runtime.Boundary + alias QuickBEAM.VM.Runtime.Heap + alias QuickBEAM.VM.Runtime.Reference + alias QuickBEAM.VM.Runtime.Value builtin "Boolean", kind: :constructor, @@ -17,7 +20,7 @@ defmodule QuickBEAM.VM.Builtins.Boolean do @doc "Converts a value to boolean or initializes a boxed Boolean." def construct(%Call{ this: %Reference{} = receiver, - caller: %ConstructorBoundary{}, + caller: %Boundary.Constructor{}, arguments: arguments, execution: execution }) do diff --git a/lib/quickbeam/vm/builtin/call.ex b/lib/quickbeam/vm/builtin/call.ex index 572779a77..1e38a89d8 100644 --- a/lib/quickbeam/vm/builtin/call.ex +++ b/lib/quickbeam/vm/builtin/call.ex @@ -6,7 +6,7 @@ defmodule QuickBEAM.VM.Builtin.Call do and owner-local execution state. They must not fetch mutable state elsewhere. """ - alias QuickBEAM.VM.Execution + alias QuickBEAM.VM.Runtime.State @enforce_keys [:arguments, :this, :caller, :tail?, :execution] defstruct [:arguments, :this, :caller, :tail?, :execution] @@ -16,6 +16,6 @@ defmodule QuickBEAM.VM.Builtin.Call do this: term(), caller: term(), tail?: boolean(), - execution: Execution.t() + execution: State.t() } end diff --git a/lib/quickbeam/vm/builtins/console.ex b/lib/quickbeam/vm/builtin/console.ex similarity index 95% rename from lib/quickbeam/vm/builtins/console.ex rename to lib/quickbeam/vm/builtin/console.ex index 6b9725e7c..7eabb0244 100644 --- a/lib/quickbeam/vm/builtins/console.ex +++ b/lib/quickbeam/vm/builtin/console.ex @@ -1,4 +1,4 @@ -defmodule QuickBEAM.VM.Builtins.Console do +defmodule QuickBEAM.VM.Builtin.Console do @moduledoc "Defines the minimal SSR console namespace." use QuickBEAM.VM.Builtin 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 index 7256e1a78..89d7b0901 100644 --- a/lib/quickbeam/vm/builtin/dsl.ex +++ b/lib/quickbeam/vm/builtin/dsl.ex @@ -1,7 +1,11 @@ defmodule QuickBEAM.VM.Builtin.DSL do @moduledoc "Compiles the declarative builtin syntax into immutable validated specs." - alias QuickBEAM.VM.Builtin.{AccessorSpec, FunctionSpec, PrototypeSpec, Spec, Validator} + 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.Spec + alias QuickBEAM.VM.Builtin.Validator @doc "Installs builtin declaration attributes and macros." defmacro __using__(_opts) do @@ -68,7 +72,7 @@ defmodule QuickBEAM.VM.Builtin.DSL do @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.PropertySpec{ + @quickbeam_builtin_statics %QuickBEAM.VM.Builtin.Spec.Property{ key: key, value: value, writable: Keyword.get(opts, :writable, false), @@ -81,7 +85,7 @@ defmodule QuickBEAM.VM.Builtin.DSL do @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.PropertySpec{ + @quickbeam_builtin_statics %QuickBEAM.VM.Builtin.Spec.Property{ key: key, value: value, writable: false, @@ -94,7 +98,7 @@ defmodule QuickBEAM.VM.Builtin.DSL do @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.PropertySpec{ + @quickbeam_builtin_prototype %QuickBEAM.VM.Builtin.Spec.Property{ key: key, value: value, writable: Keyword.get(opts, :writable, false), @@ -107,7 +111,7 @@ defmodule QuickBEAM.VM.Builtin.DSL do @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.AliasSpec{ + @quickbeam_builtin_prototype %QuickBEAM.VM.Builtin.Spec.Alias{ key: key, target: Keyword.fetch!(opts, :to), writable: Keyword.get(opts, :writable, true), @@ -120,7 +124,7 @@ defmodule QuickBEAM.VM.Builtin.DSL do @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.AliasSpec{ + @quickbeam_builtin_statics %QuickBEAM.VM.Builtin.Spec.Alias{ key: key, target: Keyword.fetch!(opts, :to), writable: Keyword.get(opts, :writable, true), 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/builtins/function.ex b/lib/quickbeam/vm/builtin/function.ex similarity index 96% rename from lib/quickbeam/vm/builtins/function.ex rename to lib/quickbeam/vm/builtin/function.ex index 7f1995de0..28b9519af 100644 --- a/lib/quickbeam/vm/builtins/function.ex +++ b/lib/quickbeam/vm/builtin/function.ex @@ -1,11 +1,11 @@ -defmodule QuickBEAM.VM.Builtins.Function do +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.Invocation + alias QuickBEAM.VM.Runtime.Invocation builtin "Function", kind: :constructor, diff --git a/lib/quickbeam/vm/builtin/installer.ex b/lib/quickbeam/vm/builtin/installer.ex index 7bf26822c..a6e90dc94 100644 --- a/lib/quickbeam/vm/builtin/installer.ex +++ b/lib/quickbeam/vm/builtin/installer.ex @@ -2,24 +2,25 @@ defmodule QuickBEAM.VM.Builtin.Installer do @moduledoc """ Installs declarative builtin specs into one owner-local VM execution. - Installation is deterministic and threads `%QuickBEAM.VM.Execution{}` through + 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.{ - AccessorSpec, - AliasSpec, - FunctionSpec, - PropertySpec, - PrototypeSpec, - 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.Builtin.Spec - alias QuickBEAM.VM.{Execution, Heap, Properties, Reference} + alias QuickBEAM.VM.Runtime.State + alias QuickBEAM.VM.Runtime.Heap + alias QuickBEAM.VM.Runtime.Property + alias QuickBEAM.VM.Runtime.Reference @doc "Installs registered builtin modules for the selected profile." - @spec install_all(Execution.t(), [module()], atom()) :: Execution.t() + @spec install_all(State.t(), [module()], atom()) :: State.t() def install_all(execution, modules, profile \\ :core) do specs = modules @@ -31,7 +32,7 @@ defmodule QuickBEAM.VM.Builtin.Installer do end @doc "Installs one immutable builtin specification." - @spec install(Execution.t(), Spec.t()) :: Execution.t() + @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) @@ -76,14 +77,14 @@ defmodule QuickBEAM.VM.Builtin.Installer do ) {:ok, execution} = - Properties.define(prototype, "constructor", constructor, execution, + Property.define(prototype, "constructor", constructor, execution, writable: true, enumerable: false, configurable: true ) {:ok, execution} = - Properties.define(constructor, "prototype", prototype, execution, + Property.define(constructor, "prototype", prototype, execution, writable: false, enumerable: false, configurable: false @@ -98,7 +99,7 @@ defmodule QuickBEAM.VM.Builtin.Installer do defp install_prototype_entries(execution, _target, %Spec{prototype: []}), do: execution defp install_prototype_entries(execution, %Reference{} = constructor, spec) do - case Properties.get(constructor, "prototype", execution) do + case Property.get(constructor, "prototype", execution) do {:ok, %Reference{} = prototype} -> install_entries(execution, prototype, spec.module, spec.prototype) @@ -118,7 +119,7 @@ defmodule QuickBEAM.VM.Builtin.Installer do {function, execution} = allocate_function(execution, to_string(spec.key), spec.length, token) {:ok, execution} = - Properties.define(target, spec.key, function, execution, + Property.define(target, spec.key, function, execution, writable: spec.writable, enumerable: spec.enumerable, configurable: spec.configurable @@ -132,13 +133,13 @@ defmodule QuickBEAM.VM.Builtin.Installer do {setter, execution} = allocate_optional_function(execution, module, spec.setter, spec.key) {:ok, execution} = - Properties.define_accessor(target, spec.key, :getter, getter, execution, + Property.define_accessor(target, spec.key, :getter, getter, execution, enumerable: spec.enumerable, configurable: spec.configurable ) {:ok, execution} = - Properties.define_accessor(target, spec.key, :setter, setter, execution, + Property.define_accessor(target, spec.key, :setter, setter, execution, enumerable: spec.enumerable, configurable: spec.configurable ) @@ -147,7 +148,7 @@ defmodule QuickBEAM.VM.Builtin.Installer do end defp install_entry(execution, target, _module, %AliasSpec{} = spec) do - {:ok, value} = Properties.get(target, spec.target, execution) + {:ok, value} = Property.get(target, spec.target, execution) if value == :undefined do raise ArgumentError, @@ -155,7 +156,7 @@ defmodule QuickBEAM.VM.Builtin.Installer do end {:ok, execution} = - Properties.define(target, spec.key, value, execution, + Property.define(target, spec.key, value, execution, writable: spec.writable, enumerable: spec.enumerable, configurable: spec.configurable @@ -166,7 +167,7 @@ defmodule QuickBEAM.VM.Builtin.Installer do defp install_entry(execution, target, _module, %PropertySpec{} = spec) do {:ok, execution} = - Properties.define(target, spec.key, spec.value, execution, + Property.define(target, spec.key, spec.value, execution, writable: spec.writable, enumerable: spec.enumerable, configurable: spec.configurable @@ -179,14 +180,14 @@ defmodule QuickBEAM.VM.Builtin.Installer do {function, execution} = Heap.allocate(execution, :function, callable: callable) {:ok, execution} = - Properties.define(function, "name", name, execution, + Property.define(function, "name", name, execution, writable: false, enumerable: false, configurable: true ) {:ok, execution} = - Properties.define(function, "length", length, execution, + Property.define(function, "length", length, execution, writable: false, enumerable: false, configurable: true @@ -210,7 +211,7 @@ defmodule QuickBEAM.VM.Builtin.Installer do defp resolve_prototype_parent(execution, parent_name) do constructor = Map.fetch!(execution.globals, parent_name) - {:ok, %Reference{} = prototype} = Properties.get(constructor, "prototype", execution) + {:ok, %Reference{} = prototype} = Property.get(constructor, "prototype", execution) prototype end @@ -236,7 +237,7 @@ defmodule QuickBEAM.VM.Builtin.Installer do Enum.reduce(execution.heap, execution, fn {id, %{kind: :function, prototype: nil}}, execution -> {:ok, execution} = - Properties.set_prototype(%Reference{id: id}, prototype, execution) + Property.set_prototype(%Reference{id: id}, prototype, execution) execution diff --git a/lib/quickbeam/vm/builtins/map.ex b/lib/quickbeam/vm/builtin/map.ex similarity index 91% rename from lib/quickbeam/vm/builtins/map.ex rename to lib/quickbeam/vm/builtin/map.ex index 79555d7cc..7225ccb02 100644 --- a/lib/quickbeam/vm/builtins/map.ex +++ b/lib/quickbeam/vm/builtin/map.ex @@ -1,20 +1,18 @@ -defmodule QuickBEAM.VM.Builtins.Map do +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.{ - ConstructorBoundary, - Heap, - Iterator, - Object, - Properties, - Reference, - Symbol, - Value - } + 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, @@ -38,7 +36,7 @@ defmodule QuickBEAM.VM.Builtins.Map do @doc "Constructs a Map from an array-like iterable of key-value pairs." def construct(%Call{ this: %Reference{} = receiver, - caller: %ConstructorBoundary{}, + caller: %Boundary.Constructor{}, arguments: arguments, execution: execution }) do @@ -174,8 +172,8 @@ defmodule QuickBEAM.VM.Builtins.Map do end) {result, execution} = Heap.allocate(execution) - {:ok, execution} = Properties.define(result, "value", value, execution) - {:ok, execution} = Properties.define(result, "done", done?, execution) + {:ok, execution} = Property.define(result, "value", value, execution) + {:ok, execution} = Property.define(result, "done", done?, execution) {:ok, result, execution} _other -> @@ -228,8 +226,8 @@ defmodule QuickBEAM.VM.Builtins.Map do defp pair_entries({:ok, values}, execution) do Enum.reduce_while(values, {:ok, []}, fn pair, {:ok, entries} -> - with {:ok, key} <- Properties.get(pair, 0, execution), - {:ok, value} <- Properties.get(pair, 1, execution) do + with {:ok, key} <- Property.get(pair, 0, execution), + {:ok, value} <- Property.get(pair, 1, execution) do {:cont, {:ok, entries ++ [{key, value}]}} else _error -> {:halt, {:error, :invalid_map_entry}} @@ -246,7 +244,7 @@ defmodule QuickBEAM.VM.Builtins.Map do Heap.allocate(execution, :ordinary, internal: {:map_iterator, entries, 0, kind}) {next, execution} = Heap.allocate(execution, :function, callable: declared(:next)) - {:ok, execution} = Properties.define(iterator, "next", next, execution) + {:ok, execution} = Property.define(iterator, "next", next, execution) {:ok, iterator, execution} :error -> @@ -258,8 +256,8 @@ defmodule QuickBEAM.VM.Builtins.Map do defp map_entry_array(key, value, execution) do {entry, execution} = Heap.allocate(execution, :array) - {:ok, execution} = Properties.define(entry, 0, key, execution) - {:ok, execution} = Properties.define(entry, 1, value, execution) + {:ok, execution} = Property.define(entry, 0, key, execution) + {:ok, execution} = Property.define(entry, 1, value, execution) {entry, execution} end diff --git a/lib/quickbeam/vm/builtins/math.ex b/lib/quickbeam/vm/builtin/math.ex similarity index 97% rename from lib/quickbeam/vm/builtins/math.ex rename to lib/quickbeam/vm/builtin/math.ex index 4b3288399..6527a39cd 100644 --- a/lib/quickbeam/vm/builtins/math.ex +++ b/lib/quickbeam/vm/builtin/math.ex @@ -1,10 +1,10 @@ -defmodule QuickBEAM.VM.Builtins.Math do +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.Value + alias QuickBEAM.VM.Runtime.Value builtin "Math", kind: :namespace, depends_on: ["Object", "Function"] do constant "E", :math.exp(1) diff --git a/lib/quickbeam/vm/builtins/number.ex b/lib/quickbeam/vm/builtin/number.ex similarity index 91% rename from lib/quickbeam/vm/builtins/number.ex rename to lib/quickbeam/vm/builtin/number.ex index 24ff9bd4d..94a86cfb6 100644 --- a/lib/quickbeam/vm/builtins/number.ex +++ b/lib/quickbeam/vm/builtin/number.ex @@ -1,10 +1,14 @@ -defmodule QuickBEAM.VM.Builtins.Number do +defmodule QuickBEAM.VM.Builtin.Number do @moduledoc "Defines declarative `Number` prototype methods." use QuickBEAM.VM.Builtin alias QuickBEAM.VM.Builtin.Call - alias QuickBEAM.VM.{ConstructorBoundary, Heap, Object, Reference, Value} + 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, @@ -25,7 +29,7 @@ defmodule QuickBEAM.VM.Builtins.Number do @doc "Implements Number call and boxed-construction semantics." def construct(%Call{ this: %Reference{} = receiver, - caller: %ConstructorBoundary{}, + caller: %Boundary.Constructor{}, arguments: arguments, execution: execution }) do diff --git a/lib/quickbeam/vm/builtins/object.ex b/lib/quickbeam/vm/builtin/object.ex similarity index 88% rename from lib/quickbeam/vm/builtins/object.ex rename to lib/quickbeam/vm/builtin/object.ex index e3515ebfc..cea903580 100644 --- a/lib/quickbeam/vm/builtins/object.ex +++ b/lib/quickbeam/vm/builtin/object.ex @@ -1,4 +1,4 @@ -defmodule QuickBEAM.VM.Builtins.Object do +defmodule QuickBEAM.VM.Builtin.Object do @moduledoc "Defines declarative low-risk and resumable `Object` static methods." use QuickBEAM.VM.Builtin @@ -6,15 +6,13 @@ defmodule QuickBEAM.VM.Builtins.Object do alias QuickBEAM.VM.Builtin alias QuickBEAM.VM.Builtin.Call - alias QuickBEAM.VM.{ - ConstructorBoundary, - Heap, - Invocation, - Properties, - Property, - Reference, - Value - } + 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 @@ -53,7 +51,7 @@ defmodule QuickBEAM.VM.Builtins.Object do end constructor = Map.fetch!(execution.globals, constructor_name) - {:ok, prototype} = Properties.get(constructor, "prototype", execution) + {:ok, prototype} = Property.get(constructor, "prototype", execution) {boxed, execution} = Heap.allocate(execution, :ordinary, @@ -66,7 +64,7 @@ defmodule QuickBEAM.VM.Builtins.Object do def construct(%Call{ this: %Reference{} = receiver, - caller: %ConstructorBoundary{}, + caller: %Boundary.Constructor{}, execution: execution }), do: {:ok, receiver, execution} @@ -84,7 +82,7 @@ defmodule QuickBEAM.VM.Builtins.Object do }) do key = List.first(arguments, :undefined) - case Properties.own_property(object, key, execution) do + case Property.own_property(object, key, execution) do {:ok, property} -> {:ok, not is_nil(property), execution} {:error, reason} -> {:error, reason, execution} end @@ -101,8 +99,8 @@ defmodule QuickBEAM.VM.Builtins.Object do }) do key = List.first(arguments, :undefined) - case Properties.own_property(object, key, execution) do - {:ok, %Property{enumerable: enumerable}} -> {:ok, enumerable, execution} + 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 @@ -140,7 +138,7 @@ defmodule QuickBEAM.VM.Builtins.Object do arguments: [%Reference{} = target, key, descriptor | _], execution: execution }) do - with {:ok, current} <- Properties.own_property(target, key, execution), + 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} @@ -157,7 +155,7 @@ defmodule QuickBEAM.VM.Builtins.Object do arguments: [%Reference{} = target, descriptors | _], execution: execution }) do - with {:ok, keys} <- Properties.enumerable_keys(descriptors, execution), + with {:ok, keys} <- Property.enumerable_keys(descriptors, execution), {:ok, execution} <- define_properties(keys, target, descriptors, execution) do {:ok, target, execution} else @@ -186,7 +184,7 @@ defmodule QuickBEAM.VM.Builtins.Object do arguments: [%Reference{} = target, key | _], execution: execution }) do - case Properties.own_property(target, key, execution) do + case Property.own_property(target, key, execution) do {:ok, nil} -> {:ok, :undefined, execution} @@ -207,7 +205,7 @@ defmodule QuickBEAM.VM.Builtins.Object do arguments: [%Reference{} = target | _], execution: execution }) do - case Properties.own_property_names(target, execution) do + case Property.own_property_names(target, execution) do {:ok, keys} -> {array, execution} = array_from(keys, execution) {:ok, array, execution} @@ -222,7 +220,7 @@ defmodule QuickBEAM.VM.Builtins.Object do @doc "Implements `Object.getPrototypeOf`." def get_prototype_of(%Call{arguments: [%Reference{} = target | _], execution: execution}) do - case Properties.prototype(target, execution) do + case Property.prototype(target, execution) do {:ok, prototype} -> {:ok, prototype, execution} {:error, reason} -> {:error, reason, execution} end @@ -255,7 +253,7 @@ defmodule QuickBEAM.VM.Builtins.Object do execution: execution }) when is_nil(prototype) or is_struct(prototype, Reference) do - case Properties.set_prototype(target, prototype, execution) do + case Property.set_prototype(target, prototype, execution) do {:ok, execution} -> {:ok, target, execution} {:error, reason} -> {:error, reason, execution} end @@ -267,8 +265,8 @@ defmodule QuickBEAM.VM.Builtins.Object do 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) <- - Properties.get(descriptors, key, execution), - {:ok, current} <- Properties.own_property(target, key, execution), + 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}} @@ -290,12 +288,12 @@ defmodule QuickBEAM.VM.Builtins.Object do :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 || %Property{writable: false, enumerable: false, configurable: false} + current = current || %Descriptor{writable: false, enumerable: false, configurable: false} accessor? = getter? or setter? or (not value? and not writable? and accessor?(current)) {:ok, if accessor? do - %Property{ + %Descriptor{ kind: :accessor, value: :undefined, writable: false, @@ -306,7 +304,7 @@ defmodule QuickBEAM.VM.Builtins.Object do setter: if(setter?, do: setter, else: current.setter) } else - %Property{ + %Descriptor{ value: if(value?, do: value, else: current.value), writable: if(writable?, do: Value.truthy?(writable), else: current.writable), enumerable: if(enumerable?, do: Value.truthy?(enumerable), else: current.enumerable), @@ -331,14 +329,14 @@ defmodule QuickBEAM.VM.Builtins.Object do else: {:error, :accessor_not_callable} end - defp accessor?(%Property{kind: :accessor}), do: true + defp accessor?(%Descriptor{kind: :accessor}), do: true defp accessor?(_property), do: false - defp apply_property_definition(execution, target, key, %Property{} = property) do + defp apply_property_definition(execution, target, key, %Descriptor{} = property) do if accessor?(property) do - Properties.define_descriptor(target, key, property, execution) + Property.define_descriptor(target, key, property, execution) else - Properties.define(target, key, property.value, execution, + Property.define(target, key, property.value, execution, writable: property.writable, enumerable: property.enumerable, configurable: property.configurable @@ -347,8 +345,8 @@ defmodule QuickBEAM.VM.Builtins.Object do end defp descriptor_field(%Reference{} = descriptor, key, execution) do - if Properties.has_property?(descriptor, key, execution) do - case Properties.get(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} @@ -388,7 +386,7 @@ defmodule QuickBEAM.VM.Builtins.Object do execution = Enum.reduce(fields, execution, fn {key, value}, execution -> - {:ok, execution} = Properties.define(descriptor, key, value, execution) + {:ok, execution} = Property.define(descriptor, key, value, execution) execution end) @@ -396,7 +394,7 @@ defmodule QuickBEAM.VM.Builtins.Object do end defp own_keys(%Reference{} = reference, execution), - do: Properties.enumerable_keys(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, []} @@ -413,7 +411,7 @@ defmodule QuickBEAM.VM.Builtins.Object do values |> Enum.with_index() |> Enum.reduce(execution, fn {value, index}, execution -> - {:ok, execution} = Properties.define(array, index, value, execution) + {:ok, execution} = Property.define(array, index, value, execution) execution end) diff --git a/lib/quickbeam/vm/builtins/promise.ex b/lib/quickbeam/vm/builtin/promise.ex similarity index 89% rename from lib/quickbeam/vm/builtins/promise.ex rename to lib/quickbeam/vm/builtin/promise.ex index 53abea9ec..962348b1d 100644 --- a/lib/quickbeam/vm/builtins/promise.ex +++ b/lib/quickbeam/vm/builtin/promise.ex @@ -1,4 +1,4 @@ -defmodule QuickBEAM.VM.Builtins.Promise do +defmodule QuickBEAM.VM.Builtin.Promise do @moduledoc "Defines the declarative Promise constructor, statics, and reactions." use QuickBEAM.VM.Builtin @@ -6,15 +6,13 @@ defmodule QuickBEAM.VM.Builtins.Promise do alias QuickBEAM.VM.Builtin alias QuickBEAM.VM.Builtin.Call - alias QuickBEAM.VM.{ - ConstructorBoundary, - Exceptions, - Invocation, - Iterator, - Promise, - PromiseExecutorBoundary, - PromiseReference - } + 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, @@ -38,13 +36,13 @@ defmodule QuickBEAM.VM.Builtins.Promise do @doc "Constructs a Promise and invokes its executor synchronously." def construct(%Call{ arguments: [executor | _], - caller: %ConstructorBoundary{} = caller, + caller: %Boundary.Constructor{} = caller, execution: execution }) do if Invocation.callable?(executor, execution) do {promise, execution} = Promise.new(execution) - boundary = %PromiseExecutorBoundary{ + boundary = %Boundary.PromiseExecutor{ promise: promise, caller: caller, depth: execution.depth, @@ -62,7 +60,7 @@ defmodule QuickBEAM.VM.Builtins.Promise do end end - def construct(%Call{caller: %ConstructorBoundary{}, execution: execution}), + def construct(%Call{caller: %Boundary.Constructor{}, execution: execution}), do: {:error, :missing_promise_executor, execution} def construct(%Call{execution: execution}), @@ -147,7 +145,7 @@ defmodule QuickBEAM.VM.Builtins.Promise do Builtin.action({:promise_iterate, kind, iterable, call.caller, execution, call.tail?}) {:error, :not_iterable} -> - {reason, execution} = Exceptions.materialize({:type_error, :not_iterable}, execution) + {reason, execution} = Exception.materialize({:type_error, :not_iterable}, execution) {promise, execution} = Promise.new(execution) execution = Promise.settle(execution, promise, {:error, reason}) {:ok, promise, execution} diff --git a/lib/quickbeam/vm/builtins.ex b/lib/quickbeam/vm/builtin/runtime.ex similarity index 75% rename from lib/quickbeam/vm/builtins.ex rename to lib/quickbeam/vm/builtin/runtime.ex index 5a3f091a1..5cd7dd2c8 100644 --- a/lib/quickbeam/vm/builtins.ex +++ b/lib/quickbeam/vm/builtin/runtime.ex @@ -1,25 +1,26 @@ -defmodule QuickBEAM.VM.Builtins do +defmodule QuickBEAM.VM.Builtin.Runtime do @moduledoc """ Installs and dispatches the JavaScript built-ins supported by the VM profile. """ - alias QuickBEAM.VM.{ - Execution, - Heap, - Object, - Properties, - Reference, - RegExp, - Value - } + alias QuickBEAM.VM.Runtime.State + 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.Value - alias QuickBEAM.VM.Builtin.{Installer, Registry} + alias QuickBEAM.VM.Builtin.Installer + alias QuickBEAM.VM.Builtin.Registry - @spec install(Execution.t(), :core | :ssr) :: Execution.t() + @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) - @spec callable(Execution.t(), Reference.t()) :: term() | nil + @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 @@ -28,7 +29,7 @@ defmodule QuickBEAM.VM.Builtins do end @doc "Allocates a catchable JavaScript error object in the current evaluation heap." - @spec new_error(Execution.t(), String.t(), String.t() | nil) :: {Reference.t(), Execution.t()} + @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") @@ -41,7 +42,7 @@ defmodule QuickBEAM.VM.Builtins do execution else {:ok, execution} = - Properties.define(error, "message", message, execution, + Property.define(error, "message", message, execution, enumerable: false, configurable: true, writable: true @@ -53,8 +54,9 @@ defmodule QuickBEAM.VM.Builtins do {error, execution} end - @spec call(term(), term(), [term()], Execution.t()) :: - {:ok, term(), Execution.t()} | {:error, term(), Execution.t()} + @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, @@ -68,8 +70,8 @@ defmodule QuickBEAM.VM.Builtins do [{index, length} | _captures] -> matched = value |> Value.to_string_value() |> binary_part(index, length) {result, execution} = Heap.allocate(execution, :array) - {:ok, execution} = Properties.define(result, 0, matched, execution) - {:ok, execution} = Properties.define(result, "index", index, execution) + {:ok, execution} = Property.define(result, 0, matched, execution) + {:ok, execution} = Property.define(result, "index", index, execution) {:ok, result, execution} nil -> @@ -88,11 +90,11 @@ defmodule QuickBEAM.VM.Builtins do ) do with {:ok, %Object{kind: :regexp, internal: %RegExp{} = regexp}} <- Heap.fetch_object(execution, reference), - {:ok, last_index} <- Properties.get(reference, "lastIndex", execution) do + {: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} = Properties.put(reference, "lastIndex", next_index, execution) + {:ok, execution} = Property.put(reference, "lastIndex", next_index, execution) {:ok, matched?, execution} else _other -> {:error, :incompatible_regexp_receiver, execution} diff --git a/lib/quickbeam/vm/builtins/set.ex b/lib/quickbeam/vm/builtin/set.ex similarity index 90% rename from lib/quickbeam/vm/builtins/set.ex rename to lib/quickbeam/vm/builtin/set.ex index 7dde6b7dd..ebb2ce304 100644 --- a/lib/quickbeam/vm/builtins/set.ex +++ b/lib/quickbeam/vm/builtin/set.ex @@ -1,4 +1,4 @@ -defmodule QuickBEAM.VM.Builtins.Set do +defmodule QuickBEAM.VM.Builtin.Set do @moduledoc "Defines the declarative Set constructor, methods, and iterator alias." use QuickBEAM.VM.Builtin @@ -6,16 +6,14 @@ defmodule QuickBEAM.VM.Builtins.Set do alias QuickBEAM.VM.Builtin alias QuickBEAM.VM.Builtin.Call - alias QuickBEAM.VM.{ - ConstructorBoundary, - Heap, - Iterator, - Object, - Properties, - Reference, - Symbol, - Value - } + 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, @@ -36,7 +34,7 @@ defmodule QuickBEAM.VM.Builtins.Set do def construct( %Call{ this: %Reference{} = receiver, - caller: %ConstructorBoundary{}, + caller: %Boundary.Constructor{}, arguments: arguments, execution: execution } = call @@ -155,21 +153,21 @@ defmodule QuickBEAM.VM.Builtins.Set do {next, execution} = Heap.allocate(execution, :function, callable: declared(:next)) {:ok, execution} = - Properties.define(next, "name", "next", execution, + Property.define(next, "name", "next", execution, writable: false, enumerable: false, configurable: true ) {:ok, execution} = - Properties.define(next, "length", 0, execution, + Property.define(next, "length", 0, execution, writable: false, enumerable: false, configurable: true ) {:ok, execution} = - Properties.define(iterator, "next", next, execution, + Property.define(iterator, "next", next, execution, writable: true, enumerable: false, configurable: true @@ -197,8 +195,8 @@ defmodule QuickBEAM.VM.Builtins.Set do end) {result, execution} = Heap.allocate(execution) - {:ok, execution} = Properties.define(result, "value", value, execution) - {:ok, execution} = Properties.define(result, "done", done?, execution) + {:ok, execution} = Property.define(result, "value", value, execution) + {:ok, execution} = Property.define(result, "done", done?, execution) {:ok, result, execution} _other -> diff --git a/lib/quickbeam/vm/builtin/spec.ex b/lib/quickbeam/vm/builtin/spec.ex index 74f3b4221..b67627c1b 100644 --- a/lib/quickbeam/vm/builtin/spec.ex +++ b/lib/quickbeam/vm/builtin/spec.ex @@ -1,23 +1,3 @@ -defmodule QuickBEAM.VM.Builtin.PrototypeSpec 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 - defmodule QuickBEAM.VM.Builtin.Spec do @moduledoc """ Defines immutable, compile-time-validated metadata for one JavaScript builtin. @@ -26,20 +6,14 @@ defmodule QuickBEAM.VM.Builtin.Spec do not contain heap references or captured functions. """ - alias QuickBEAM.VM.Builtin.{ - AccessorSpec, - AliasSpec, - FunctionSpec, - PropertySpec, - PrototypeSpec - } + alias QuickBEAM.VM.Builtin.Spec.{Accessor, Alias, Function, Property, Prototype} @enforce_keys [:name, :module, :kind] defstruct [ :name, :module, :constructor, - prototype_spec: %PrototypeSpec{}, + prototype_spec: %Prototype{}, profiles: [:core], depends_on: [], kind: :namespace, @@ -54,72 +28,11 @@ defmodule QuickBEAM.VM.Builtin.Spec do module: module(), kind: kind(), constructor: atom() | nil, - prototype_spec: PrototypeSpec.t(), + prototype_spec: Prototype.t(), profiles: [atom()], depends_on: [String.t()], length: non_neg_integer(), - statics: [FunctionSpec.t() | PropertySpec.t() | AccessorSpec.t() | AliasSpec.t()], - prototype: [FunctionSpec.t() | PropertySpec.t() | AccessorSpec.t() | AliasSpec.t()] - } -end - -defmodule QuickBEAM.VM.Builtin.FunctionSpec 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 - -defmodule QuickBEAM.VM.Builtin.AccessorSpec 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 - -defmodule QuickBEAM.VM.Builtin.AliasSpec 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 - -defmodule QuickBEAM.VM.Builtin.PropertySpec 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() + 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/builtins/string.ex b/lib/quickbeam/vm/builtin/string.ex similarity index 95% rename from lib/quickbeam/vm/builtins/string.ex rename to lib/quickbeam/vm/builtin/string.ex index 2868f8506..c3b61b0d6 100644 --- a/lib/quickbeam/vm/builtins/string.ex +++ b/lib/quickbeam/vm/builtin/string.ex @@ -1,10 +1,16 @@ -defmodule QuickBEAM.VM.Builtins.String do +defmodule QuickBEAM.VM.Builtin.String do @moduledoc "Defines the declarative core `String` static and prototype methods." use QuickBEAM.VM.Builtin alias QuickBEAM.VM.Builtin.Call - alias QuickBEAM.VM.{ConstructorBoundary, Heap, Object, Properties, Reference, RegExp, Value} + alias QuickBEAM.VM.Runtime.Boundary + 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.Value builtin "String", kind: :constructor, @@ -34,7 +40,7 @@ defmodule QuickBEAM.VM.Builtins.String do @doc "Implements String call and boxed-construction semantics." def construct(%Call{ this: %Reference{} = receiver, - caller: %ConstructorBoundary{}, + caller: %Boundary.Constructor{}, arguments: arguments, execution: execution }) do @@ -213,7 +219,7 @@ defmodule QuickBEAM.VM.Builtins.String do values |> Enum.with_index() |> Enum.reduce(execution, fn {value, index}, execution -> - {:ok, execution} = Properties.define(array, index, value, execution) + {:ok, execution} = Property.define(array, index, value, execution) execution end) diff --git a/lib/quickbeam/vm/builtins/symbol.ex b/lib/quickbeam/vm/builtin/symbol.ex similarity index 90% rename from lib/quickbeam/vm/builtins/symbol.ex rename to lib/quickbeam/vm/builtin/symbol.ex index 9e2b17ca4..83598faca 100644 --- a/lib/quickbeam/vm/builtins/symbol.ex +++ b/lib/quickbeam/vm/builtin/symbol.ex @@ -1,10 +1,11 @@ -defmodule QuickBEAM.VM.Builtins.Symbol do +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.{Symbol, Value} + 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 diff --git a/lib/quickbeam/vm/builtins/uint8_array.ex b/lib/quickbeam/vm/builtin/uint8_array.ex similarity index 84% rename from lib/quickbeam/vm/builtins/uint8_array.ex rename to lib/quickbeam/vm/builtin/uint8_array.ex index f7e3f3a40..71a06c9fc 100644 --- a/lib/quickbeam/vm/builtins/uint8_array.ex +++ b/lib/quickbeam/vm/builtin/uint8_array.ex @@ -1,10 +1,11 @@ -defmodule QuickBEAM.VM.Builtins.Uint8Array do +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.{Heap, Properties} + alias QuickBEAM.VM.Runtime.Heap + alias QuickBEAM.VM.Runtime.Property builtin "Uint8Array", kind: :constructor, @@ -29,7 +30,7 @@ defmodule QuickBEAM.VM.Builtins.Uint8Array do values |> Enum.with_index() |> Enum.reduce(execution, fn {value, index}, execution -> - {:ok, execution} = Properties.define(array, index, value, execution) + {:ok, execution} = Property.define(array, index, value, execution) execution end) diff --git a/lib/quickbeam/vm/builtin/validator.ex b/lib/quickbeam/vm/builtin/validator.ex index 4e0512082..611f0b398 100644 --- a/lib/quickbeam/vm/builtin/validator.ex +++ b/lib/quickbeam/vm/builtin/validator.ex @@ -1,14 +1,12 @@ defmodule QuickBEAM.VM.Builtin.Validator do @moduledoc "Validates builtin declarations and handler contracts at compile time." - alias QuickBEAM.VM.Builtin.{ - AccessorSpec, - AliasSpec, - FunctionSpec, - PropertySpec, - PrototypeSpec, - 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.Builtin.Spec @doc "Validates a compiled builtin spec against its declaring module." @spec validate!(Spec.t(), Macro.Env.t()) :: :ok diff --git a/lib/quickbeam/vm/builtins/weak_map.ex b/lib/quickbeam/vm/builtin/weak_map.ex similarity index 88% rename from lib/quickbeam/vm/builtins/weak_map.ex rename to lib/quickbeam/vm/builtin/weak_map.ex index 863dd5654..da2a967f3 100644 --- a/lib/quickbeam/vm/builtins/weak_map.ex +++ b/lib/quickbeam/vm/builtin/weak_map.ex @@ -1,11 +1,11 @@ -defmodule QuickBEAM.VM.Builtins.WeakMap do +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.Builtins.Map, as: MapBuiltin - alias QuickBEAM.VM.Reference + alias QuickBEAM.VM.Builtin.Map, as: MapBuiltin + alias QuickBEAM.VM.Runtime.Reference builtin "WeakMap", kind: :constructor, diff --git a/lib/quickbeam/vm/builtins/weak_set.ex b/lib/quickbeam/vm/builtin/weak_set.ex similarity index 87% rename from lib/quickbeam/vm/builtins/weak_set.ex rename to lib/quickbeam/vm/builtin/weak_set.ex index c81a4487c..6120b9937 100644 --- a/lib/quickbeam/vm/builtins/weak_set.ex +++ b/lib/quickbeam/vm/builtin/weak_set.ex @@ -1,11 +1,11 @@ -defmodule QuickBEAM.VM.Builtins.WeakSet do +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.Builtins.Set, as: SetBuiltin - alias QuickBEAM.VM.Reference + alias QuickBEAM.VM.Builtin.Set, as: SetBuiltin + alias QuickBEAM.VM.Runtime.Reference builtin "WeakSet", kind: :constructor, diff --git a/lib/quickbeam/vm/builtins/errors.ex b/lib/quickbeam/vm/builtins/errors.ex deleted file mode 100644 index 8de4da224..000000000 --- a/lib/quickbeam/vm/builtins/errors.ex +++ /dev/null @@ -1,163 +0,0 @@ -defmodule QuickBEAM.VM.Builtins.ErrorSupport do - @moduledoc "Provides shared declarative Error constructor and formatting semantics." - - alias QuickBEAM.VM.Builtin.Call - alias QuickBEAM.VM.{Builtins, Heap, Object, Properties, 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} = Builtins.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} <- Properties.get(error, "name", execution), - {:ok, message} <- Properties.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} = - Properties.define(error, "message", message, execution, - enumerable: false, - configurable: true, - writable: true - ) - - execution - end -end - -defmodule QuickBEAM.VM.Builtins.Error do - @moduledoc "Defines the declarative JavaScript Error constructor and prototype." - - use QuickBEAM.VM.Builtin - - alias QuickBEAM.VM.Builtin.Call - alias QuickBEAM.VM.Builtins.ErrorSupport - - 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: ErrorSupport.construct("Error", call) - - @doc "Formats an Error value." - def to_string_method(%Call{} = call), do: ErrorSupport.to_string(call) -end - -defmodule QuickBEAM.VM.Builtins.ErrorSubclass do - @moduledoc "Generates one declarative native Error subclass builtin." - - defmacro __using__(opts) do - name = Keyword.fetch!(opts, :name) - - quote do - use QuickBEAM.VM.Builtin - - alias QuickBEAM.VM.Builtin.Call - alias QuickBEAM.VM.Builtins.ErrorSupport - - @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: ErrorSupport.construct(@error_name, call) - end - end -end - -defmodule QuickBEAM.VM.Builtins.EvalError do - @moduledoc "Defines the declarative EvalError constructor." - use QuickBEAM.VM.Builtins.ErrorSubclass, name: "EvalError" -end - -defmodule QuickBEAM.VM.Builtins.RangeError do - @moduledoc "Defines the declarative RangeError constructor." - use QuickBEAM.VM.Builtins.ErrorSubclass, name: "RangeError" -end - -defmodule QuickBEAM.VM.Builtins.ReferenceError do - @moduledoc "Defines the declarative ReferenceError constructor." - use QuickBEAM.VM.Builtins.ErrorSubclass, name: "ReferenceError" -end - -defmodule QuickBEAM.VM.Builtins.SyntaxError do - @moduledoc "Defines the declarative SyntaxError constructor." - use QuickBEAM.VM.Builtins.ErrorSubclass, name: "SyntaxError" -end - -defmodule QuickBEAM.VM.Builtins.TypeError do - @moduledoc "Defines the declarative TypeError constructor." - use QuickBEAM.VM.Builtins.ErrorSubclass, name: "TypeError" -end - -defmodule QuickBEAM.VM.Builtins.URIError do - @moduledoc "Defines the declarative URIError constructor." - use QuickBEAM.VM.Builtins.ErrorSubclass, name: "URIError" -end diff --git a/lib/quickbeam/vm/predefined_atoms.ex b/lib/quickbeam/vm/bytecode/atom.ex similarity index 90% rename from lib/quickbeam/vm/predefined_atoms.ex rename to lib/quickbeam/vm/bytecode/atom.ex index 366c31599..6b46fc7df 100644 --- a/lib/quickbeam/vm/predefined_atoms.ex +++ b/lib/quickbeam/vm/bytecode/atom.ex @@ -1,4 +1,4 @@ -defmodule QuickBEAM.VM.PredefinedAtoms do +defmodule QuickBEAM.VM.Bytecode.Atom do @moduledoc "QuickJS predefined atoms generated from the vendored atom header." @table QuickBEAM.VM.ABI.predefined_atoms() diff --git a/lib/quickbeam/vm/checksum.ex b/lib/quickbeam/vm/bytecode/checksum.ex similarity index 84% rename from lib/quickbeam/vm/checksum.ex rename to lib/quickbeam/vm/bytecode/checksum.ex index 5befd6459..874ff0d3e 100644 --- a/lib/quickbeam/vm/checksum.ex +++ b/lib/quickbeam/vm/bytecode/checksum.ex @@ -1,4 +1,4 @@ -defmodule QuickBEAM.VM.Checksum do +defmodule QuickBEAM.VM.Bytecode.Checksum do @moduledoc "Computes stable checksums for decoded VM program artifacts." import Bitwise @@ -6,6 +6,7 @@ defmodule QuickBEAM.VM.Checksum do @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} @@ -13,6 +14,7 @@ defmodule QuickBEAM.VM.Checksum do 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) diff --git a/lib/quickbeam/vm/decoder.ex b/lib/quickbeam/vm/bytecode/decoder.ex similarity index 95% rename from lib/quickbeam/vm/decoder.ex rename to lib/quickbeam/vm/bytecode/decoder.ex index c48b42e09..25e9c9cbd 100644 --- a/lib/quickbeam/vm/decoder.ex +++ b/lib/quickbeam/vm/bytecode/decoder.ex @@ -1,4 +1,4 @@ -defmodule QuickBEAM.VM.Decoder do +defmodule QuickBEAM.VM.Bytecode.Decoder do @moduledoc """ Parses QuickJS bytecode binaries into VM program/function instruction IR. @@ -6,36 +6,34 @@ defmodule QuickBEAM.VM.Decoder do in priv/c_src/quickjs.c exactly. """ - alias QuickBEAM.VM.{ - ABI, - Checksum, - Function, - InstructionDecoder, - Opcodes, - PredefinedAtoms, - Program - } - - alias QuickBEAM.VM.Varint, as: LEB128 + alias QuickBEAM.VM.ABI + alias QuickBEAM.VM.Bytecode.Checksum + alias QuickBEAM.VM.Program.Function + alias QuickBEAM.VM.Bytecode.Instruction + alias QuickBEAM.VM.Bytecode.Opcode + alias QuickBEAM.VM.Bytecode.Atom, as: AtomTable + alias QuickBEAM.VM.Program + + alias QuickBEAM.VM.Bytecode.Varint, as: LEB128 import Bitwise # JS_ATOM_NULL=0, plus 228 DEF entries from quickjs-atom.h - @js_atom_end Opcodes.js_atom_end() + @js_atom_end Opcode.js_atom_end() # Pre-compute tag constants for use in match clauses - @tag_null Opcodes.bc_tag_null() - @tag_undefined Opcodes.bc_tag_undefined() - @tag_bool_false Opcodes.bc_tag_bool_false() - @tag_bool_true Opcodes.bc_tag_bool_true() - @tag_int32 Opcodes.bc_tag_int32() - @tag_float64 Opcodes.bc_tag_float64() - @tag_string Opcodes.bc_tag_string() - @tag_function_bytecode Opcodes.bc_tag_function_bytecode() - @tag_object Opcodes.bc_tag_object() - @tag_array Opcodes.bc_tag_array() - @tag_big_int Opcodes.bc_tag_big_int() - @tag_template_object Opcodes.bc_tag_template_object() - @tag_regexp Opcodes.bc_tag_regexp() + @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 @@ -123,7 +121,7 @@ defmodule QuickBEAM.VM.Decoder do end defp predefined_atom(atom_id) do - case PredefinedAtoms.lookup(atom_id) do + case AtomTable.lookup(atom_id) do atom when is_binary(atom) -> {:ok, atom} nil -> {:error, {:unknown_predefined_atom, atom_id}} end @@ -406,7 +404,7 @@ defmodule QuickBEAM.VM.Decoder do end defp validate_version(version) do - if version == Opcodes.bc_version(), do: :ok, else: {:error, {:bad_version, version}} + if version == Opcode.bc_version(), do: :ok, else: {:error, {:bad_version, version}} end defp read_function_body(flags, data, atoms, depth) do @@ -443,7 +441,7 @@ defmodule QuickBEAM.VM.Decoder do else <> = rest - with {:ok, instructions} <- InstructionDecoder.decode(byte_code, arg_count), + with {:ok, instructions} <- Instruction.decode(byte_code, arg_count), {:ok, debug_info, rest} <- read_debug_info(rest, flags_map.has_debug_info, atoms) do fun = %Function{ name: func_name, @@ -535,7 +533,7 @@ defmodule QuickBEAM.VM.Decoder do 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.Variable{ + variable = %QuickBEAM.VM.Program.Variable{ name: name, scope_level: scope_level, scope_next: scope_next, @@ -573,7 +571,7 @@ defmodule QuickBEAM.VM.Decoder do is_lexical = band(bsr(flags, 4), 1) == 1 var_kind = band(bsr(flags, 5), 0xF) - cv = %QuickBEAM.VM.ClosureVariable{ + cv = %QuickBEAM.VM.Program.Variable.Closure{ name: name, var_idx: var_idx, closure_type: closure_type, @@ -681,7 +679,7 @@ defmodule QuickBEAM.VM.Decoder do defp instruction_offsets(byte_code, len, pos, acc) do op = :binary.at(byte_code, pos) - case Opcodes.info(op) do + case Opcode.info(op) do {_name, size, _n_pop, _n_push, _fmt} -> instruction_offsets(byte_code, len, pos + size, [pos | acc]) diff --git a/lib/quickbeam/vm/instruction_decoder.ex b/lib/quickbeam/vm/bytecode/instruction.ex similarity index 96% rename from lib/quickbeam/vm/instruction_decoder.ex rename to lib/quickbeam/vm/bytecode/instruction.ex index f7f502c18..6ca84c984 100644 --- a/lib/quickbeam/vm/instruction_decoder.ex +++ b/lib/quickbeam/vm/bytecode/instruction.ex @@ -1,4 +1,4 @@ -defmodule QuickBEAM.VM.InstructionDecoder do +defmodule QuickBEAM.VM.Bytecode.Instruction do @compile {:inline, get_u8: 2, get_i8: 2, @@ -17,7 +17,7 @@ defmodule QuickBEAM.VM.InstructionDecoder do jump-table dispatch. """ - alias QuickBEAM.VM.Opcodes + alias QuickBEAM.VM.Bytecode.Opcode import Bitwise @type instruction :: {non_neg_integer(), [term()]} @@ -45,7 +45,7 @@ defmodule QuickBEAM.VM.InstructionDecoder do defp build_offset_map(bc, len, pos, idx, acc) do op = :binary.at(bc, pos) - case Opcodes.info(op) do + case Opcode.info(op) do nil -> {:error, {:unknown_opcode, op, pos}} @@ -65,7 +65,7 @@ defmodule QuickBEAM.VM.InstructionDecoder do defp decode_pass2(bc, len, pos, idx, offset_map, acc, ac) do op = :binary.at(bc, pos) - case Opcodes.info(op) do + case Opcode.info(op) do nil -> {:error, {:unknown_opcode, op, pos}} @@ -98,7 +98,7 @@ defmodule QuickBEAM.VM.InstructionDecoder do 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, Opcodes.short_form_operands(op, ac)} + {:ok, Opcode.short_form_operands(op, ac)} end defp operands_for(bc, pos, op, fmt, offset_map, ac) do @@ -108,7 +108,7 @@ defmodule QuickBEAM.VM.InstructionDecoder do end defp normalize_operands(op, [atom_idx, local_idx], ac) do - if op == Opcodes.num(:make_loc_ref) do + if op == Opcode.num(:make_loc_ref) do [atom_idx, local_idx + ac] else [atom_idx, local_idx] @@ -220,7 +220,7 @@ defmodule QuickBEAM.VM.InstructionDecoder do if v >= 0x80000000, do: v - 0x100000000, else: v end - @js_atom_end Opcodes.js_atom_end() + @js_atom_end Opcode.js_atom_end() defp get_atom_u32(bc, pos) do v = get_u32(bc, pos) diff --git a/lib/quickbeam/vm/opcodes.ex b/lib/quickbeam/vm/bytecode/opcode.ex similarity index 99% rename from lib/quickbeam/vm/opcodes.ex rename to lib/quickbeam/vm/bytecode/opcode.ex index ec325e802..3a508417f 100644 --- a/lib/quickbeam/vm/opcodes.ex +++ b/lib/quickbeam/vm/bytecode/opcode.ex @@ -1,4 +1,4 @@ -defmodule QuickBEAM.VM.Opcodes do +defmodule QuickBEAM.VM.Bytecode.Opcode do @moduledoc """ QuickJS opcode metadata generated from the vendored `quickjs-opcode.h`. """ diff --git a/lib/quickbeam/vm/varint.ex b/lib/quickbeam/vm/bytecode/varint.ex similarity index 91% rename from lib/quickbeam/vm/varint.ex rename to lib/quickbeam/vm/bytecode/varint.ex index 1ebf894b8..c31ef58c0 100644 --- a/lib/quickbeam/vm/varint.ex +++ b/lib/quickbeam/vm/bytecode/varint.ex @@ -1,4 +1,4 @@ -defmodule QuickBEAM.VM.Varint do +defmodule QuickBEAM.VM.Bytecode.Varint do @moduledoc """ Bounded QuickJS integer decoding backed by `Varint.LEB128`. @@ -16,6 +16,7 @@ defmodule QuickBEAM.VM.Varint do @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 @@ -29,6 +30,7 @@ defmodule QuickBEAM.VM.Varint do 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 @@ -45,10 +47,12 @@ defmodule QuickBEAM.VM.Varint do 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} diff --git a/lib/quickbeam/vm/verifier.ex b/lib/quickbeam/vm/bytecode/verifier.ex similarity index 96% rename from lib/quickbeam/vm/verifier.ex rename to lib/quickbeam/vm/bytecode/verifier.ex index 4da01225b..65cebcfaa 100644 --- a/lib/quickbeam/vm/verifier.ex +++ b/lib/quickbeam/vm/bytecode/verifier.ex @@ -1,4 +1,4 @@ -defmodule QuickBEAM.VM.Verifier do +defmodule QuickBEAM.VM.Bytecode.Verifier do @moduledoc """ Structurally verifies decoded programs before interpreter execution. @@ -6,9 +6,13 @@ defmodule QuickBEAM.VM.Verifier do behavior before untrusted bytecode reaches mutable evaluation state. """ - alias QuickBEAM.VM.{ABI, Function, Opcodes, Program, StackVerifier} + alias QuickBEAM.VM.ABI + alias QuickBEAM.VM.Program.Function + alias QuickBEAM.VM.Bytecode.Opcode + alias QuickBEAM.VM.Program + alias QuickBEAM.VM.Bytecode.Verifier.Stack - @js_atom_end Opcodes.js_atom_end() + @js_atom_end Opcode.js_atom_end() @default_limits %{ max_atoms: 100_000, @@ -19,6 +23,7 @@ defmodule QuickBEAM.VM.Verifier do 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 \\ []) @@ -165,7 +170,7 @@ defmodule QuickBEAM.VM.Verifier do defp verify_instruction({opcode, operands}, function, atoms) when is_integer(opcode) and is_list(operands) do - case Opcodes.info(opcode) do + case Opcode.info(opcode) do nil -> {:error, {:unknown_opcode, opcode}} @@ -181,7 +186,7 @@ defmodule QuickBEAM.VM.Verifier do do: {:error, {:invalid_shape, instruction}} defp verify_stack(function) do - case StackVerifier.verify(function) do + case Stack.verify(function) do :ok -> :ok {:error, reason} -> {:error, {:invalid_stack, function.id, reason}} end diff --git a/lib/quickbeam/vm/stack_verifier.ex b/lib/quickbeam/vm/bytecode/verifier/stack.ex similarity index 96% rename from lib/quickbeam/vm/stack_verifier.ex rename to lib/quickbeam/vm/bytecode/verifier/stack.ex index 3da6c69a0..05a1674d8 100644 --- a/lib/quickbeam/vm/stack_verifier.ex +++ b/lib/quickbeam/vm/bytecode/verifier/stack.ex @@ -1,4 +1,4 @@ -defmodule QuickBEAM.VM.StackVerifier do +defmodule QuickBEAM.VM.Bytecode.Verifier.Stack do @moduledoc """ Verifies QuickJS operand-stack dataflow over decoded instruction indexes. @@ -8,7 +8,8 @@ defmodule QuickBEAM.VM.StackVerifier do opcode-specific stack depth. """ - alias QuickBEAM.VM.{Function, Opcodes} + alias QuickBEAM.VM.Program.Function + alias QuickBEAM.VM.Bytecode.Opcode @terminal_ops [ :tail_call, @@ -62,7 +63,7 @@ defmodule QuickBEAM.VM.StackVerifier do 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} = Opcodes.info(opcode) + {name, _size, pops, pushes, format} = Opcode.info(opcode) pops = pops + variable_pops(format, operands) if depth < pops do @@ -157,7 +158,7 @@ defmodule QuickBEAM.VM.StackVerifier do defp opcode_name(state, index) do {opcode, _operands} = elem(state.function.instructions, index) - {name, _size, _pops, _pushes, _format} = Opcodes.info(opcode) + {name, _size, _pops, _pushes, _format} = Opcode.info(opcode) name end diff --git a/lib/quickbeam/vm/compiler.ex b/lib/quickbeam/vm/compiler.ex index 03cf8d0e2..eb620728a 100644 --- a/lib/quickbeam/vm/compiler.ex +++ b/lib/quickbeam/vm/compiler.ex @@ -13,18 +13,21 @@ defmodule QuickBEAM.VM.Compiler do as typed compiler errors and never invoke native QuickJS. """ - alias QuickBEAM.VM.Compiler.{ - Context, - Contract, - Counters, - Deopt, - GeneratedModule, - ModulePool, - RegionProbe - } - - alias QuickBEAM.VM.Compiler.Lowering.PureV1 - alias QuickBEAM.VM.{Evaluator, Execution, Frame, Function, Interpreter, Program} + alias QuickBEAM.VM.Compiler.Context + alias QuickBEAM.VM.Compiler.Contract + alias QuickBEAM.VM.Compiler.Counter + alias QuickBEAM.VM.Compiler.Deopt + alias QuickBEAM.VM.Compiler.Code + alias QuickBEAM.VM.Compiler.Pool + alias QuickBEAM.VM.Compiler.Region.Probe + + alias QuickBEAM.VM.Compiler.Profile.Pure + alias QuickBEAM.VM.Runtime + alias QuickBEAM.VM.Runtime.State + alias QuickBEAM.VM.Runtime.Frame + alias QuickBEAM.VM.Program.Function + alias QuickBEAM.VM.Runtime.Interpreter + alias QuickBEAM.VM.Program @type result :: {:ok, term()} | {:error, term()} | {:suspended, term()} @type frame_action :: @@ -37,7 +40,7 @@ defmodule QuickBEAM.VM.Compiler do @spec child_spec(keyword()) :: Supervisor.child_spec() def child_spec(opts) do %{ - id: ModulePool, + id: Pool, start: {__MODULE__, :start_link, [opts]}, type: :worker, restart: :permanent, @@ -50,10 +53,10 @@ defmodule QuickBEAM.VM.Compiler do def start_link(opts \\ []) when is_list(opts) do opts = opts - |> Keyword.put_new(:backend, GeneratedModule) + |> Keyword.put_new(:backend, Code) |> Keyword.put_new(:task_supervisor, QuickBEAM.VM.TaskSupervisor) - ModulePool.start_link(opts) + Pool.start_link(opts) end @doc "Evaluates a verified program through the selected bounded compiler profile." @@ -61,7 +64,7 @@ defmodule QuickBEAM.VM.Compiler do def eval(%Program{} = program, opts \\ []) when is_list(opts) do program |> start(opts) - |> Evaluator.drive() + |> Runtime.drive() end @doc "Evaluates through the compiler while collecting the standard deterministic counters." @@ -81,16 +84,16 @@ defmodule QuickBEAM.VM.Compiler do @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, ModulePool) + pool = Keyword.get(opts, :compiler_pool, Pool) {:ok, artifact_namespace} = Contract.program_identity(program) context = %Context{ artifact_namespace: artifact_namespace, - counters: if(execution.measurement_target, do: Counters.new()), + counters: if(execution.measurement_target, do: Counter.new()), pool: pool, profile: Keyword.get(opts, :compiler_profile, :pure_v1), program: program, - region_probe: if(Keyword.get(opts, :compiler_region_probe) == true, do: RegionProbe.new()), + region_probe: if(Keyword.get(opts, :compiler_region_probe) == true, do: Probe.new()), regions: Keyword.get(opts, :compiler_regions, false) } @@ -106,9 +109,9 @@ defmodule QuickBEAM.VM.Compiler do @spec execute_frame(struct(), struct()) :: frame_action() def execute_frame( %Frame{function: %Function{id: function_id} = function} = frame, - %Execution{compiler_context: %Context{pool: pool}} = execution + %State{compiler_context: %Context{pool: pool}} = execution ) do - execution = Counters.increment(execution, :frame_attempts) + execution = Counter.increment(execution, :frame_attempts) context = execution.compiler_context action = @@ -134,7 +137,7 @@ defmodule QuickBEAM.VM.Compiler do defp prepare_frame( %Function{} = function, frame, - %Execution{ + %State{ compiler_context: %Context{ program: %Program{root: %Function{} = root} = program, min_nested_instructions: nested_minimum, @@ -144,10 +147,10 @@ defmodule QuickBEAM.VM.Compiler do ) do minimum = if function.id == root.id, do: 1, else: nested_minimum - if PureV1.candidate?(function, minimum, profile) do + if Pure.candidate?(function, minimum, profile) do prepare_keyed_frame(program, function, minimum, profile, frame, execution) else - execution = Counters.increment(execution, :skipped_functions) + execution = Counter.increment(execution, :skipped_functions) execution = cache_decision(execution, function.id, :skip) prepare_region_frame(function, frame, execution) end @@ -155,14 +158,14 @@ defmodule QuickBEAM.VM.Compiler do defp prepare_keyed_frame(program, function, minimum, profile, frame, execution) do with {:ok, key} <- artifact_key(execution.compiler_context, program, function, profile) do - case ModulePool.checkout_cached(execution.compiler_context.pool, key) do + case Pool.checkout_cached(execution.compiler_context.pool, key) do {:ok, lease} -> - execution = Counters.increment(execution, :cached_functions) + 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 = Counters.increment(execution, :skipped_functions) + execution = Counter.increment(execution, :skipped_functions) execution = cache_decision(execution, function.id, :skip) prepare_region_frame(function, frame, execution) @@ -196,7 +199,7 @@ defmodule QuickBEAM.VM.Compiler do ) defp prepare_uncached_frame(function, minimum, profile, key, frame, execution) do - case PureV1.prepare(function, minimum, profile) do + case Pure.prepare(function, minimum, profile) do {:ok, template, _count} -> prepare_template(function, profile, key, template, frame, execution) @@ -209,7 +212,7 @@ defmodule QuickBEAM.VM.Compiler do end defp prepare_template(function, :scalar_v1, key, template, frame, execution) do - if PureV1.scalar_template?(template) or not execution.compiler_context.regions 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) @@ -220,26 +223,26 @@ defmodule QuickBEAM.VM.Compiler do do: prepare_compiled_template(function, key, template, frame, execution) defp prepare_compiled_template(function, key, template, frame, execution) do - execution = Counters.increment(execution, :compiled_functions) + execution = Counter.increment(execution, :compiled_functions) execution = cache_decision(execution, function.id, {:compile, key, template}) invoke_frame(execution.compiler_context.pool, key, template, frame, execution) end defp skip_uncached_frame(function, key, frame, execution) do - with :ok <- ModulePool.remember_skip(execution.compiler_context.pool, key) do - execution = Counters.increment(execution, :skipped_functions) + 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 ModulePool.checkout_cached(pool, key) do + case Pool.checkout_cached(pool, key) do {:ok, lease} -> invoke_lease(pool, lease, frame, execution) :skip -> - execution = Counters.increment(execution, :skipped_functions) + execution = Counter.increment(execution, :skipped_functions) execution = cache_decision(execution, function.id, :skip) prepare_region_frame(function, frame, execution) @@ -254,11 +257,11 @@ defmodule QuickBEAM.VM.Compiler do defp prepare_region_frame( %Function{id: function_id} = function, %Frame{pc: 0} = frame, - %Execution{compiler_context: %Context{profile: :scalar_v1, regions: true} = context} = + %State{compiler_context: %Context{profile: :scalar_v1, regions: true} = context} = execution ) do decision_key = {:region, function_id, 0} - execution = Counters.increment(execution, :region_attempts) + execution = Counter.increment(execution, :region_attempts) case Map.fetch(context.decisions, decision_key) do {:ok, :skip} -> @@ -281,7 +284,7 @@ defmodule QuickBEAM.VM.Compiler do decision_key, %Function{id: function_id} = function, frame, - %Execution{ + %State{ compiler_context: %Context{ artifact_namespace: namespace, pool: pool, @@ -291,12 +294,12 @@ defmodule QuickBEAM.VM.Compiler do ) do with {:ok, admission_key} <- Contract.region_admission_key(namespace, function_id, frame.pc, profile) do - case ModulePool.admit_region(pool, admission_key) do + case Pool.admit_region(pool, admission_key) do :cold -> - {:skip, frame, Counters.increment(execution, :region_cold)} + {:skip, frame, Counter.increment(execution, :region_cold)} :hot -> - execution = Counters.increment(execution, :region_hot) + execution = Counter.increment(execution, :region_hot) prepare_region_keyed(decision_key, function, frame, execution) {:error, reason} -> @@ -309,7 +312,7 @@ defmodule QuickBEAM.VM.Compiler do decision_key, function, frame, - %Execution{ + %State{ compiler_context: %Context{ artifact_namespace: namespace, pool: pool, @@ -322,9 +325,9 @@ defmodule QuickBEAM.VM.Compiler do profile: profile, region_entry: frame.pc ) do - case ModulePool.checkout_cached(pool, key) do + case Pool.checkout_cached(pool, key) do {:ok, lease} -> - execution = Counters.increment(execution, :cached_functions) + execution = Counter.increment(execution, :cached_functions) execution = cache_decision(execution, decision_key, {:cached, key}) invoke_lease(pool, lease, frame, execution) @@ -344,10 +347,10 @@ defmodule QuickBEAM.VM.Compiler do defp prepare_uncached_region(decision_key, key, function, frame, execution) do profile = execution.compiler_context.profile - case PureV1.prepare_region(function, frame.pc, profile) do + case Pure.prepare_region(function, frame.pc, profile) do {:ok, template, _count} -> - execution = Counters.increment(execution, :compiled_functions) - execution = Counters.increment(execution, :region_compiled) + 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 @@ -356,7 +359,7 @@ defmodule QuickBEAM.VM.Compiler do end {:skip, _count} -> - with :ok <- ModulePool.remember_skip(execution.compiler_context.pool, key) do + with :ok <- Pool.remember_skip(execution.compiler_context.pool, key) do {:skip, frame, cache_decision(execution, decision_key, :skip)} end @@ -368,7 +371,7 @@ defmodule QuickBEAM.VM.Compiler do defp invoke_cached_region(key, decision_key, function, frame, execution) do pool = execution.compiler_context.pool - case ModulePool.checkout_cached(pool, key) do + 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) @@ -377,22 +380,22 @@ defmodule QuickBEAM.VM.Compiler do end defp invoke_frame(pool, key, template, frame, execution) do - with {:ok, lease} <- ModulePool.checkout(pool, key, template), + 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 = Counters.increment(execution, :generated_entries) + execution = Counter.increment(execution, :generated_entries) remaining_steps = execution.remaining_steps pool - |> GeneratedModule.invoke(lease, frame, execution) + |> Code.invoke(lease, frame, execution) |> add_generated_steps(remaining_steps) after safe_checkin(pool, lease) end - defp cache_decision(%Execution{compiler_context: context} = execution, function_id, decision) do + 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} @@ -402,24 +405,24 @@ defmodule QuickBEAM.VM.Compiler do end defp observe_action({:deopt, %Deopt{} = deopt}) do - execution = Counters.deopt(deopt.execution, deopt.reason, deopt.frame) + 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 = Counters.increment(execution, :invocation_actions) + execution = Counter.increment(execution, :invocation_actions) {:invoke, callable, arguments, this, caller, execution, false} end defp observe_action({:skip, frame, execution}) do - {:skip, frame, Counters.increment(execution, :skipped_frames)} + {: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 -> - Counters.add_generated_steps(execution, max(before - execution.remaining_steps, 0)) + Counter.add_generated_steps(execution, max(before - execution.remaining_steps, 0)) end) end @@ -434,7 +437,7 @@ defmodule QuickBEAM.VM.Compiler do {:invoke, callable, arguments, this, caller, update.(execution), false} end - defp update_action_execution({status, value, %Execution{} = execution}, update) + defp update_action_execution({status, value, %State{} = execution}, update) when status in [:ok, :error], do: {status, value, update.(execution)} @@ -451,7 +454,7 @@ defmodule QuickBEAM.VM.Compiler do defp resume_action({:skip, frame, execution}, _initial), do: Interpreter.run_frame(frame, execution) - defp resume_action({status, _value, %Execution{}} = result, _execution) + defp resume_action({status, _value, %State{}} = result, _execution) when status in [:ok, :error], do: result defp resume_action({:suspended, _continuation} = result, _execution), do: result @@ -473,7 +476,7 @@ defmodule QuickBEAM.VM.Compiler do defp ensure_pool_available(pool), do: {:error, {:compiler_pool_unavailable, pool}} defp safe_checkin(pool, lease) do - ModulePool.checkin_active(pool, lease) + Pool.checkin_active(pool, lease) catch :exit, _reason -> :ok end diff --git a/lib/quickbeam/vm/compiler/analysis/cfg.ex b/lib/quickbeam/vm/compiler/analysis/control_flow.ex similarity index 95% rename from lib/quickbeam/vm/compiler/analysis/cfg.ex rename to lib/quickbeam/vm/compiler/analysis/control_flow.ex index b2c8b8be9..6eb63af6c 100644 --- a/lib/quickbeam/vm/compiler/analysis/cfg.ex +++ b/lib/quickbeam/vm/compiler/analysis/control_flow.ex @@ -1,4 +1,4 @@ -defmodule QuickBEAM.VM.Compiler.Analysis.CFG do +defmodule QuickBEAM.VM.Compiler.Analysis.ControlFlow do @moduledoc """ Builds bounded basic blocks from verified QuickJS instruction tuples. @@ -7,7 +7,8 @@ defmodule QuickBEAM.VM.Compiler.Analysis.CFG do """ alias QuickBEAM.VM.Compiler.Analysis.Block - alias QuickBEAM.VM.{Function, Opcodes} + alias QuickBEAM.VM.Program.Function + alias QuickBEAM.VM.Bytecode.Opcode @conditional_branches [:if_false, :if_false8, :if_true, :if_true8] @unconditional_branches [:goto, :goto8, :goto16] @@ -56,9 +57,9 @@ defmodule QuickBEAM.VM.Compiler.Analysis.CFG do |> Tuple.to_list() |> Enum.with_index() |> Enum.reduce_while({:ok, []}, fn {{opcode, operands}, pc}, {:ok, acc} -> - case Opcodes.info(opcode) do + case Opcode.info(opcode) do {name, _size, _pops, _pushes, _format} when is_list(operands) -> - {name, operands} = Opcodes.expand_short_form(name, operands, function.arg_count) + {name, operands} = Opcode.expand_short_form(name, operands, function.arg_count) {:cont, {:ok, [{pc, name, operands} | acc]}} _invalid -> diff --git a/lib/quickbeam/vm/compiler/code.ex b/lib/quickbeam/vm/compiler/code.ex new file mode 100644 index 000000000..e922e5de9 --- /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.Lifecycle + alias QuickBEAM.VM.Compiler.Code.Emitter + alias QuickBEAM.VM.Compiler.Pool + alias QuickBEAM.VM.Compiler.Pool.Lease + alias QuickBEAM.VM.Runtime.State + alias QuickBEAM.VM.Runtime.Frame + + @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/generated_module/artifact.ex b/lib/quickbeam/vm/compiler/code/artifact.ex similarity index 96% rename from lib/quickbeam/vm/compiler/generated_module/artifact.ex rename to lib/quickbeam/vm/compiler/code/artifact.ex index 5a6616ff9..4232a1108 100644 --- a/lib/quickbeam/vm/compiler/generated_module/artifact.ex +++ b/lib/quickbeam/vm/compiler/code/artifact.ex @@ -1,4 +1,4 @@ -defmodule QuickBEAM.VM.Compiler.GeneratedModule.Artifact do +defmodule QuickBEAM.VM.Compiler.Code.Artifact do @moduledoc """ Represents a validated slot-specific generated module binary. diff --git a/lib/quickbeam/vm/compiler/generated_module/emitter.ex b/lib/quickbeam/vm/compiler/code/emitter.ex similarity index 94% rename from lib/quickbeam/vm/compiler/generated_module/emitter.ex rename to lib/quickbeam/vm/compiler/code/emitter.ex index 96ffcf85d..76c6040c9 100644 --- a/lib/quickbeam/vm/compiler/generated_module/emitter.ex +++ b/lib/quickbeam/vm/compiler/code/emitter.ex @@ -1,4 +1,4 @@ -defmodule QuickBEAM.VM.Compiler.GeneratedModule.Emitter do +defmodule QuickBEAM.VM.Compiler.Code.Emitter do @moduledoc """ Emits bounded slot-specific module binaries from Erlang abstract forms. @@ -8,7 +8,9 @@ defmodule QuickBEAM.VM.Compiler.GeneratedModule.Emitter do """ alias QuickBEAM.VM.Compiler.Contract - alias QuickBEAM.VM.Compiler.GeneratedModule.{Artifact, ImportPolicy, Template} + alias QuickBEAM.VM.Compiler.Code.Artifact + alias QuickBEAM.VM.Compiler.Code.Import + alias QuickBEAM.VM.Compiler.Code.Template @max_form_count 5_000 @max_form_bytes 8 * 1024 * 1024 @@ -23,7 +25,7 @@ defmodule QuickBEAM.VM.Compiler.GeneratedModule.Emitter do :ok <- validate_module(module), {:ok, forms} <- prepare_forms(forms, module), {:ok, binary} <- compile_forms(forms, module), - :ok <- ImportPolicy.validate(binary) do + :ok <- Import.validate(binary) do Artifact.new(module, binary) end end diff --git a/lib/quickbeam/vm/compiler/generated_module/import_policy.ex b/lib/quickbeam/vm/compiler/code/import.ex similarity index 97% rename from lib/quickbeam/vm/compiler/generated_module/import_policy.ex rename to lib/quickbeam/vm/compiler/code/import.ex index ac0fb8c8f..31a5cc6c9 100644 --- a/lib/quickbeam/vm/compiler/generated_module/import_policy.ex +++ b/lib/quickbeam/vm/compiler/code/import.ex @@ -1,4 +1,4 @@ -defmodule QuickBEAM.VM.Compiler.GeneratedModule.ImportPolicy do +defmodule QuickBEAM.VM.Compiler.Code.Import do @moduledoc """ Enforces the generated-module external-call allowlist. diff --git a/lib/quickbeam/vm/compiler/generated_module/code_lifecycle.ex b/lib/quickbeam/vm/compiler/code/lifecycle.ex similarity index 92% rename from lib/quickbeam/vm/compiler/generated_module/code_lifecycle.ex rename to lib/quickbeam/vm/compiler/code/lifecycle.ex index 19c17730d..4a71a95ce 100644 --- a/lib/quickbeam/vm/compiler/generated_module/code_lifecycle.ex +++ b/lib/quickbeam/vm/compiler/code/lifecycle.ex @@ -1,4 +1,4 @@ -defmodule QuickBEAM.VM.Compiler.GeneratedModule.CodeLifecycle do +defmodule QuickBEAM.VM.Compiler.Code.Lifecycle do @moduledoc """ Installs and safely retires generated modules in the Erlang code server. @@ -8,7 +8,8 @@ defmodule QuickBEAM.VM.Compiler.GeneratedModule.CodeLifecycle do """ alias QuickBEAM.VM.Compiler.Contract - alias QuickBEAM.VM.Compiler.GeneratedModule.{Artifact, ImportPolicy} + alias QuickBEAM.VM.Compiler.Code.Artifact + alias QuickBEAM.VM.Compiler.Code.Import @source ~c"quickbeam_compiler" @@ -17,7 +18,7 @@ defmodule QuickBEAM.VM.Compiler.GeneratedModule.CodeLifecycle do def install(module, %Artifact{module: module, binary: binary} = artifact) do with :ok <- validate_module(module), :ok <- Artifact.validate(artifact), - :ok <- ImportPolicy.validate(binary), + :ok <- Import.validate(binary), :ok <- ensure_slot_available(module), {:module, ^module} <- :code.load_binary(module, @source, binary) do :ok diff --git a/lib/quickbeam/vm/compiler/generated_module/template.ex b/lib/quickbeam/vm/compiler/code/template.ex similarity index 69% rename from lib/quickbeam/vm/compiler/generated_module/template.ex rename to lib/quickbeam/vm/compiler/code/template.ex index b828f09bb..6ac290f29 100644 --- a/lib/quickbeam/vm/compiler/generated_module/template.ex +++ b/lib/quickbeam/vm/compiler/code/template.ex @@ -1,13 +1,13 @@ -defmodule QuickBEAM.VM.Compiler.GeneratedModule.Template do +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.GeneratedModule.Placeholder` + 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.GeneratedModule.Placeholder + @placeholder_module QuickBEAM.VM.Compiler.Code.Placeholder @enforce_keys [:forms] defstruct [:forms] diff --git a/lib/quickbeam/vm/compiler/context.ex b/lib/quickbeam/vm/compiler/context.ex index 0ee069d30..490507605 100644 --- a/lib/quickbeam/vm/compiler/context.ex +++ b/lib/quickbeam/vm/compiler/context.ex @@ -3,10 +3,11 @@ defmodule QuickBEAM.VM.Compiler.Context do 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.Execution{}`. + continuations stay in the owning `%QuickBEAM.VM.Runtime.State{}`. """ - alias QuickBEAM.VM.Compiler.{Counters, RegionProbe} + alias QuickBEAM.VM.Compiler.Counter + alias QuickBEAM.VM.Compiler.Region.Probe @enforce_keys [:pool, :program] defstruct [ @@ -23,7 +24,7 @@ defmodule QuickBEAM.VM.Compiler.Context do ] @type t :: %__MODULE__{ - pool: QuickBEAM.VM.Compiler.ModulePool.server(), + pool: QuickBEAM.VM.Compiler.Pool.server(), program: QuickBEAM.VM.Program.t(), artifact_namespace: binary() | nil, decisions: %{ @@ -33,8 +34,8 @@ defmodule QuickBEAM.VM.Compiler.Context do max_decisions: pos_integer(), min_nested_instructions: non_neg_integer(), profile: :pure_v1 | :scalar_v1, - counters: Counters.t() | nil, - region_probe: RegionProbe.t() | nil, + 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 index 7d0c8c5c7..9cf8f4167 100644 --- a/lib/quickbeam/vm/compiler/contract.ex +++ b/lib/quickbeam/vm/compiler/contract.ex @@ -7,7 +7,8 @@ defmodule QuickBEAM.VM.Compiler.Contract do table. Compiler implementations must not construct additional module names. """ - alias QuickBEAM.VM.{Function, Program} + alias QuickBEAM.VM.Program.Function + alias QuickBEAM.VM.Program @contract_version 1 @runtime_abi_version 5 diff --git a/lib/quickbeam/vm/compiler/counters.ex b/lib/quickbeam/vm/compiler/counter.ex similarity index 83% rename from lib/quickbeam/vm/compiler/counters.ex rename to lib/quickbeam/vm/compiler/counter.ex index 2d675cee3..28135555a 100644 --- a/lib/quickbeam/vm/compiler/counters.ex +++ b/lib/quickbeam/vm/compiler/counter.ex @@ -1,4 +1,4 @@ -defmodule QuickBEAM.VM.Compiler.Counters do +defmodule QuickBEAM.VM.Compiler.Counter do @moduledoc """ Owns bounded compiler counters for one evaluation through OTP `:counters`. @@ -10,7 +10,9 @@ defmodule QuickBEAM.VM.Compiler.Counters do module-pool state. """ - alias QuickBEAM.VM.{Execution, Frame, Opcodes} + alias QuickBEAM.VM.Runtime.State + alias QuickBEAM.VM.Runtime.Frame + alias QuickBEAM.VM.Bytecode.Opcode @indexes %{ frame_attempts: 1, @@ -51,9 +53,9 @@ defmodule QuickBEAM.VM.Compiler.Counters do do: %__MODULE__{owner: self(), reference: :counters.new(@counter_count, [])} @doc "Increments one fixed compiler event in an evaluation-owned context." - @spec increment(Execution.t(), atom()) :: Execution.t() + @spec increment(State.t(), atom()) :: State.t() def increment( - %Execution{ + %State{ compiler_context: %{counters: %__MODULE__{owner: owner, reference: reference}} } = execution, event @@ -63,12 +65,12 @@ defmodule QuickBEAM.VM.Compiler.Counters do execution end - def increment(%Execution{} = execution, _event), do: execution + def increment(%State{} = execution, _event), do: execution @doc "Adds an exact generated instruction count to owner-local counters." - @spec add_generated_steps(Execution.t(), non_neg_integer()) :: Execution.t() + @spec add_generated_steps(State.t(), non_neg_integer()) :: State.t() def add_generated_steps( - %Execution{ + %State{ compiler_context: %{counters: %__MODULE__{owner: owner, reference: reference}} } = execution, count @@ -78,12 +80,12 @@ defmodule QuickBEAM.VM.Compiler.Counters do execution end - def add_generated_steps(%Execution{} = execution, _count), do: execution + def add_generated_steps(%State{} = execution, _count), do: execution @doc "Records one interpreted opcode while compiler measurement is enabled." - @spec interpreted_opcode(Execution.t(), non_neg_integer()) :: Execution.t() + @spec interpreted_opcode(State.t(), non_neg_integer()) :: State.t() def interpreted_opcode( - %Execution{ + %State{ compiler_context: %{counters: %__MODULE__{owner: owner, reference: reference}} } = execution, opcode @@ -93,11 +95,11 @@ defmodule QuickBEAM.VM.Compiler.Counters do execution end - def interpreted_opcode(%Execution{} = execution, _opcode), do: execution + def interpreted_opcode(%State{} = execution, _opcode), do: execution @doc "Records one validated compiler deoptimization reason and opcode." - @spec deopt(Execution.t(), term(), Frame.t()) :: Execution.t() - def deopt(%Execution{} = execution, reason, %Frame{} = frame) do + @spec deopt(State.t(), term(), Frame.t()) :: State.t() + def deopt(%State{} = execution, reason, %Frame{} = frame) do execution |> increment(:deoptimizations) |> increment(deopt_event(reason)) @@ -105,8 +107,8 @@ defmodule QuickBEAM.VM.Compiler.Counters do end @doc "Returns a fixed-key compiler counter map for endpoint measurement." - @spec snapshot(Execution.t()) :: map() | nil - def snapshot(%Execution{ + @spec snapshot(State.t()) :: map() | nil + def snapshot(%State{ compiler_context: %{ profile: profile, counters: %__MODULE__{owner: owner, reference: reference} @@ -119,10 +121,10 @@ defmodule QuickBEAM.VM.Compiler.Counters do |> Map.put(:profile, profile) end - def snapshot(%Execution{}), do: nil + def snapshot(%State{}), do: nil defp increment_deopt_opcode( - %Execution{ + %State{ compiler_context: %{counters: %__MODULE__{owner: owner, reference: reference}} } = execution, %Frame{pc: pc, function: %{instructions: instructions}} @@ -137,10 +139,10 @@ defmodule QuickBEAM.VM.Compiler.Counters do execution end - defp increment_deopt_opcode(%Execution{} = execution, _frame), do: execution + defp increment_deopt_opcode(%State{} = execution, _frame), do: execution defp opcode_counts(reference, offset) do - Opcodes.table() + 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) diff --git a/lib/quickbeam/vm/compiler/deopt.ex b/lib/quickbeam/vm/compiler/deopt.ex index 0d884a943..0dc81d245 100644 --- a/lib/quickbeam/vm/compiler/deopt.ex +++ b/lib/quickbeam/vm/compiler/deopt.ex @@ -8,7 +8,8 @@ defmodule QuickBEAM.VM.Compiler.Deopt do """ alias QuickBEAM.VM.Compiler.Contract - alias QuickBEAM.VM.{Execution, Frame} + alias QuickBEAM.VM.Runtime.State + alias QuickBEAM.VM.Runtime.Frame @contract_version Contract.version() @artifact_key_bytes Contract.artifact_key_bytes() @@ -48,13 +49,13 @@ defmodule QuickBEAM.VM.Compiler.Deopt do reason: reason(), owner: pid(), frame: Frame.t(), - execution: Execution.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(), Execution.t()) :: + @spec new(reason(), binary(), non_neg_integer(), non_neg_integer(), Frame.t(), State.t()) :: {:ok, t()} | {:error, term()} def new( reason, @@ -62,7 +63,7 @@ defmodule QuickBEAM.VM.Compiler.Deopt do pool_epoch, generation, %Frame{} = frame, - %Execution{} = execution + %State{} = execution ) do deopt = %__MODULE__{ artifact_key: artifact_key, @@ -139,6 +140,6 @@ defmodule QuickBEAM.VM.Compiler.Deopt do defp validate_boundary(frame), do: {:error, {:invalid_deopt_boundary, frame}} - defp validate_execution(%Execution{}), do: :ok + defp validate_execution(%State{}), do: :ok defp validate_execution(execution), do: {:error, {:invalid_deopt_execution, execution}} end diff --git a/lib/quickbeam/vm/compiler/generated_module.ex b/lib/quickbeam/vm/compiler/generated_module.ex deleted file mode 100644 index 083222b6d..000000000 --- a/lib/quickbeam/vm/compiler/generated_module.ex +++ /dev/null @@ -1,33 +0,0 @@ -defmodule QuickBEAM.VM.Compiler.GeneratedModule 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.ModulePool.Backend - - alias QuickBEAM.VM.Compiler.GeneratedModule.{CodeLifecycle, Emitter} - alias QuickBEAM.VM.Compiler.ModulePool - alias QuickBEAM.VM.Compiler.ModulePool.Lease - alias QuickBEAM.VM.{Execution, Frame} - - @impl true - def compile(key, module, template), do: Emitter.emit(key, module, template) - - @impl true - def install(module, artifact), do: CodeLifecycle.install(module, artifact) - - @impl true - def retire(module), do: CodeLifecycle.retire(module) - - @doc "Invokes generated code after validating its owner-local active lease." - @spec invoke(ModulePool.server(), Lease.t(), Frame.t(), Execution.t()) :: term() - def invoke(pool, %Lease{} = lease, %Frame{} = frame, %Execution{} = execution) do - with :ok <- ModulePool.validate_lease(pool, lease) do - lease.module.run(lease, frame, execution) - end - end -end diff --git a/lib/quickbeam/vm/compiler/module_pool.ex b/lib/quickbeam/vm/compiler/pool.ex similarity index 99% rename from lib/quickbeam/vm/compiler/module_pool.ex rename to lib/quickbeam/vm/compiler/pool.ex index d9fdc7a6d..f3fc1d3ed 100644 --- a/lib/quickbeam/vm/compiler/module_pool.ex +++ b/lib/quickbeam/vm/compiler/pool.ex @@ -1,4 +1,4 @@ -defmodule QuickBEAM.VM.Compiler.ModulePool do +defmodule QuickBEAM.VM.Compiler.Pool do @moduledoc """ Owns a bounded cache of generated BEAM modules. @@ -10,7 +10,7 @@ defmodule QuickBEAM.VM.Compiler.ModulePool do use GenServer alias QuickBEAM.VM.Compiler.Contract - alias QuickBEAM.VM.Compiler.ModulePool.Lease + alias QuickBEAM.VM.Compiler.Pool.Lease @default_compile_timeout 5_000 @default_compile_max_heap_bytes 64 * 1024 * 1024 diff --git a/lib/quickbeam/vm/compiler/module_pool/backend.ex b/lib/quickbeam/vm/compiler/pool/backend.ex similarity index 93% rename from lib/quickbeam/vm/compiler/module_pool/backend.ex rename to lib/quickbeam/vm/compiler/pool/backend.ex index 22ead4bf7..e226f2a9c 100644 --- a/lib/quickbeam/vm/compiler/module_pool/backend.ex +++ b/lib/quickbeam/vm/compiler/pool/backend.ex @@ -1,4 +1,4 @@ -defmodule QuickBEAM.VM.Compiler.ModulePool.Backend do +defmodule QuickBEAM.VM.Compiler.Pool.Backend do @moduledoc """ Defines the generated-module boundary used by the bounded module pool. diff --git a/lib/quickbeam/vm/compiler/module_pool/lease.ex b/lib/quickbeam/vm/compiler/pool/lease.ex similarity index 92% rename from lib/quickbeam/vm/compiler/module_pool/lease.ex rename to lib/quickbeam/vm/compiler/pool/lease.ex index 2ad33afca..7a4f4e508 100644 --- a/lib/quickbeam/vm/compiler/module_pool/lease.ex +++ b/lib/quickbeam/vm/compiler/pool/lease.ex @@ -1,4 +1,4 @@ -defmodule QuickBEAM.VM.Compiler.ModulePool.Lease do +defmodule QuickBEAM.VM.Compiler.Pool.Lease do @moduledoc """ Grants one evaluation process temporary access to a compiled module slot. diff --git a/lib/quickbeam/vm/compiler/lowering/pure_v1.ex b/lib/quickbeam/vm/compiler/profile/pure.ex similarity index 97% rename from lib/quickbeam/vm/compiler/lowering/pure_v1.ex rename to lib/quickbeam/vm/compiler/profile/pure.ex index c80ea6450..05071be5e 100644 --- a/lib/quickbeam/vm/compiler/lowering/pure_v1.ex +++ b/lib/quickbeam/vm/compiler/profile/pure.ex @@ -1,4 +1,4 @@ -defmodule QuickBEAM.VM.Compiler.Lowering.PureV1 do +defmodule QuickBEAM.VM.Compiler.Profile.Pure do @moduledoc """ Lowers verified v26 basic blocks to the first bounded compiler profile. @@ -7,12 +7,14 @@ defmodule QuickBEAM.VM.Compiler.Lowering.PureV1 do instructions remain explicit before-instruction deopt boundaries. """ - alias QuickBEAM.VM.Compiler.Analysis.CFG - alias QuickBEAM.VM.Compiler.GeneratedModule.Template - alias QuickBEAM.VM.Compiler.Lowering.ScalarBlocks + 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.{Function, Opcodes, StackVerifier} - alias QuickBEAM.VM.Opcodes.Invocation + alias QuickBEAM.VM.Program.Function + alias QuickBEAM.VM.Bytecode.Opcode + alias QuickBEAM.VM.Bytecode.Verifier.Stack + alias QuickBEAM.VM.Runtime.Opcode.Invocation @max_block_instruction_count 256 @max_block_count 4_096 @@ -60,7 +62,7 @@ defmodule QuickBEAM.VM.Compiler.Lowering.PureV1 do region = select_region(plan, entry_pc) count = lowered_operation_count(region) - case ScalarBlocks.lower_region(function, region, levels) do + case Scalar.lower_region(function, region, levels) do {:ok, template} when count > 0 -> {:ok, template, count} _not_eligible -> {:skip, count} end @@ -99,7 +101,7 @@ defmodule QuickBEAM.VM.Compiler.Lowering.PureV1 do end defp analyze_plan(function, profile) do - with {:ok, analysis} <- StackVerifier.analyze(function), + with {:ok, analysis} <- Stack.analyze(function), true <- analysis.maximum == function.stack_size, {:ok, blocks} <- CFG.analyze(function), plan <- build_plan(blocks, profile), @@ -151,7 +153,7 @@ defmodule QuickBEAM.VM.Compiler.Lowering.PureV1 do |> Tuple.to_list() |> Enum.with_index() |> Enum.any?(fn {{opcode, operands}, pc} -> - case Opcodes.info(opcode) do + 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) @@ -309,7 +311,7 @@ defmodule QuickBEAM.VM.Compiler.Lowering.PureV1 do end defp template(function, plan, levels, _profile) do - case ScalarBlocks.lower(function, plan, levels) do + case Scalar.lower(function, plan, levels) do {:ok, template} -> template :not_eligible -> generic_template(generic_plan(plan)) end diff --git a/lib/quickbeam/vm/compiler/lowering/scalar_blocks.ex b/lib/quickbeam/vm/compiler/profile/scalar.ex similarity index 99% rename from lib/quickbeam/vm/compiler/lowering/scalar_blocks.ex rename to lib/quickbeam/vm/compiler/profile/scalar.ex index ad21ee0ca..fea2c9a03 100644 --- a/lib/quickbeam/vm/compiler/lowering/scalar_blocks.ex +++ b/lib/quickbeam/vm/compiler/profile/scalar.ex @@ -1,4 +1,4 @@ -defmodule QuickBEAM.VM.Compiler.Lowering.ScalarBlocks do +defmodule QuickBEAM.VM.Compiler.Profile.Scalar do @moduledoc """ Emits bounded scalar block forms for functions whose locals cannot become cells. @@ -7,9 +7,9 @@ defmodule QuickBEAM.VM.Compiler.Lowering.ScalarBlocks do Canonical frames are rebuilt only at explicit deoptimization boundaries. """ - alias QuickBEAM.VM.Compiler.GeneratedModule.Template + alias QuickBEAM.VM.Compiler.Code.Template alias QuickBEAM.VM.Compiler.Runtime - alias QuickBEAM.VM.Function + alias QuickBEAM.VM.Program.Function @line 1 @max_stack_depth 64 diff --git a/lib/quickbeam/vm/compiler/region_probe.ex b/lib/quickbeam/vm/compiler/region/probe.ex similarity index 87% rename from lib/quickbeam/vm/compiler/region_probe.ex rename to lib/quickbeam/vm/compiler/region/probe.ex index 83154a868..3b1cf1d76 100644 --- a/lib/quickbeam/vm/compiler/region_probe.ex +++ b/lib/quickbeam/vm/compiler/region/probe.ex @@ -1,4 +1,4 @@ -defmodule QuickBEAM.VM.Compiler.RegionProbe do +defmodule QuickBEAM.VM.Compiler.Region.Probe do @moduledoc """ Samples bounded owner-local instruction windows for compiler diagnostics. @@ -8,7 +8,8 @@ defmodule QuickBEAM.VM.Compiler.RegionProbe do not enabled by ordinary evaluation or standard measurements. """ - alias QuickBEAM.VM.{Execution, Frame} + alias QuickBEAM.VM.Runtime.State + alias QuickBEAM.VM.Runtime.Frame @sample_interval 16 @window_size 64 @@ -30,9 +31,9 @@ defmodule QuickBEAM.VM.Compiler.RegionProbe do do: %__MODULE__{owner: self(), sample_counter: :counters.new(1, [])} @doc "Samples one canonical interpreted frame without changing VM semantics." - @spec observe(Execution.t(), Frame.t()) :: Execution.t() + @spec observe(State.t(), Frame.t()) :: State.t() def observe( - %Execution{compiler_context: %{region_probe: %__MODULE__{owner: owner} = probe} = context} = + %State{compiler_context: %{region_probe: %__MODULE__{owner: owner} = probe} = context} = execution, %Frame{function: %{id: function_id}, pc: pc} ) @@ -50,11 +51,11 @@ defmodule QuickBEAM.VM.Compiler.RegionProbe do end end - def observe(%Execution{} = execution, %Frame{}), do: execution + def observe(%State{} = execution, %Frame{}), do: execution @doc "Returns bounded heavy hitters and sampling metadata at evaluation completion." - @spec snapshot(Execution.t()) :: map() | nil - def snapshot(%Execution{ + @spec snapshot(State.t()) :: map() | nil + def snapshot(%State{ compiler_context: %{region_probe: %__MODULE__{owner: owner} = probe} }) when owner == self() do @@ -78,7 +79,7 @@ defmodule QuickBEAM.VM.Compiler.RegionProbe do } end - def snapshot(%Execution{}), do: nil + def snapshot(%State{}), do: nil defp increment(entries, key) do case Map.fetch(entries, key) do diff --git a/lib/quickbeam/vm/compiler/runtime.ex b/lib/quickbeam/vm/compiler/runtime.ex index 13330dc26..1e3147b73 100644 --- a/lib/quickbeam/vm/compiler/runtime.ex +++ b/lib/quickbeam/vm/compiler/runtime.ex @@ -8,12 +8,18 @@ defmodule QuickBEAM.VM.Compiler.Runtime do JavaScript semantics. """ - alias QuickBEAM.VM.Compiler.{Contract, Deopt} - alias QuickBEAM.VM.Compiler.ModulePool.Lease - alias QuickBEAM.VM.Execution - alias QuickBEAM.VM.Frame - alias QuickBEAM.VM.Opcodes.{Control, Locals, Stack, Values} - alias QuickBEAM.VM.{Properties, StackState, Value} + alias QuickBEAM.VM.Compiler.Contract + alias QuickBEAM.VM.Compiler.Deopt + alias QuickBEAM.VM.Compiler.Pool.Lease + alias QuickBEAM.VM.Runtime.State + 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.Value @stack_operations Stack.opcodes() @local_operations [ @@ -70,10 +76,10 @@ defmodule QuickBEAM.VM.Compiler.Runtime do @type plan :: %{optional(non_neg_integer()) => {[operation()], block_boundary()}} @type action :: - {:ok, Frame.t(), Execution.t()} + {:ok, Frame.t(), State.t()} | {:deopt, Deopt.t()} - | {:invoke, term(), [term()], term(), Frame.t(), Execution.t(), false} - | {:error, term(), Execution.t()} + | {:invoke, term(), [term()], term(), Frame.t(), State.t(), false} + | {:error, term(), State.t()} | {:error, term()} @doc "Returns the generated-code runtime ABI version." @@ -105,8 +111,8 @@ defmodule QuickBEAM.VM.Compiler.Runtime do 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(), Execution.t(), plan()) :: action() - def execute_plan(%Lease{} = lease, %Frame{} = frame, %Execution{} = execution, plan) + @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}} -> @@ -138,25 +144,25 @@ defmodule QuickBEAM.VM.Compiler.Runtime do 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(), Execution.t(), pos_integer()) :: - {:ok, Execution.t()} | action() + @spec charge_state(Lease.t(), tuple(), State.t(), pos_integer()) :: + {:ok, State.t()} | action() def charge_state(%Lease{owner: owner}, _state, _execution, _count) when owner != self(), do: {:error, :compiler_lease_owner_mismatch} - def charge_state(_lease, _state, %Execution{memory_exceeded: true} = execution, _count), + 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{}, {%Frame{}, pc, args, locals, stack}, - %Execution{remaining_steps: remaining} = execution, + %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: {:ok, %{execution | remaining_steps: remaining - count}} - def charge_state(%Lease{} = lease, state, %Execution{} = execution, count) + def charge_state(%Lease{} = lease, state, %State{} = execution, count) when is_integer(count) and count > 0, do: deopt_state(:step_boundary, lease, state, execution) @@ -164,24 +170,24 @@ defmodule QuickBEAM.VM.Compiler.Runtime do 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(), Execution.t(), pos_integer()) :: action() + @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, %Execution{memory_exceeded: true} = execution, _count), + 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, - %Execution{remaining_steps: remaining} = execution, + %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, %Execution{} = execution, count) + def charge_block(%Lease{} = lease, %Frame{} = frame, %State{} = execution, count) when is_integer(count) and count > 0, do: deopt(:step_boundary, lease, frame, execution) @@ -189,11 +195,11 @@ defmodule QuickBEAM.VM.Compiler.Runtime do 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(), Execution.t()) :: 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, %Execution{} = execution) do + 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}} @@ -201,12 +207,12 @@ defmodule QuickBEAM.VM.Compiler.Runtime do end @doc "Deoptimizes after rebuilding a canonical frame from bounded scalar state." - @spec deopt_state(Deopt.reason(), Lease.t(), tuple(), Execution.t()) :: action() + @spec deopt_state(Deopt.reason(), Lease.t(), tuple(), State.t()) :: action() def deopt_state( reason, %Lease{} = lease, {%Frame{} = frame, pc, args, locals, stack}, - %Execution{} = execution + %State{} = execution ) when is_integer(pc) and pc >= 0 and is_tuple(args) and is_tuple(locals) and is_list(stack) do frame = %{ @@ -225,8 +231,8 @@ defmodule QuickBEAM.VM.Compiler.Runtime do 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(), Execution.t(), [operation()]) :: action() - def execute_fast_block(%Lease{} = lease, %Frame{} = frame, %Execution{} = execution, operations) + @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} <- @@ -248,8 +254,8 @@ defmodule QuickBEAM.VM.Compiler.Runtime do 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(), Execution.t()) :: action() - def execute_stack(name, operands, %Frame{} = frame, %Execution{} = execution) + @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() @@ -262,8 +268,8 @@ defmodule QuickBEAM.VM.Compiler.Runtime do 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(), Execution.t()) :: action() - def execute_local(name, operands, %Frame{} = frame, %Execution{} = execution) + @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() @@ -271,8 +277,8 @@ defmodule QuickBEAM.VM.Compiler.Runtime do 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(), Execution.t()) :: action() - def execute_value(name, operands, %Frame{} = frame, %Execution{} = execution) + @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() @@ -280,8 +286,8 @@ defmodule QuickBEAM.VM.Compiler.Runtime do do: {:error, {:unsupported_compiler_value_operation, name, operands}} @doc "Executes one verified conditional or unconditional branch instruction." - @spec execute_branch(atom(), [term()], Frame.t(), Execution.t()) :: action() - def execute_branch(name, operands, %Frame{} = frame, %Execution{} = execution) + @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() @@ -310,7 +316,7 @@ defmodule QuickBEAM.VM.Compiler.Runtime do function, execution ) do - with {:ok, stack} <- StackState.execute(name, operands, stack, this, function.constants) do + with {:ok, stack} <- OperandStack.execute(name, operands, stack, this, function.constants) do execute_fast_operations( operations, pc + 1, @@ -560,8 +566,8 @@ defmodule QuickBEAM.VM.Compiler.Runtime do 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(), Execution.t()) :: {:ok, term()} | :deopt - def global_get(mode, atom, %Execution{} = execution) when mode in [:get_var, :get_var_undef] do + @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 @@ -572,20 +578,20 @@ defmodule QuickBEAM.VM.Compiler.Runtime do end @doc "Writes one global through the canonical local/global layer." - @spec global_put(term(), term(), Execution.t()) :: Execution.t() - def global_put(atom, value, %Execution{} = execution) do + @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(), Execution.t()) :: term() - def resolve_atom(atom, %Execution{} = execution), do: Locals.resolve_atom(atom, execution) + @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(), Execution.t()) :: {:ok, term()} | :deopt - def property_get(object, key, %Execution{} = execution) do - case Properties.get(object, key, execution) do + @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 @@ -593,13 +599,13 @@ defmodule QuickBEAM.VM.Compiler.Runtime do end @doc "Returns an explicit interpreter-owned invocation from bounded scalar state." - @spec invoke_state(term(), [term()], term(), tuple(), Execution.t()) :: action() + @spec invoke_state(term(), [term()], term(), tuple(), State.t()) :: action() def invoke_state( callable, arguments, this, {%Frame{} = frame, pc, args, locals, stack}, - %Execution{} = execution + %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 @@ -619,7 +625,7 @@ defmodule QuickBEAM.VM.Compiler.Runtime do def invoke_state(_callable, _arguments, _this, state, _execution), do: {:error, {:invalid_compiler_invocation_state, state}} - defp scalar_profile?(%Execution{compiler_context: %{profile: :scalar_v1}}), do: true + 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." diff --git a/lib/quickbeam/vm/continuation.ex b/lib/quickbeam/vm/continuation.ex deleted file mode 100644 index a2dd9acc8..000000000 --- a/lib/quickbeam/vm/continuation.ex +++ /dev/null @@ -1,12 +0,0 @@ -defmodule QuickBEAM.VM.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.Frame.t(), - execution: QuickBEAM.VM.Execution.t(), - awaiting: QuickBEAM.VM.PromiseReference.t() | term() | nil - } -end diff --git a/lib/quickbeam/vm/fuzz.ex b/lib/quickbeam/vm/fuzz.ex index 4ae715cd4..6821ebb4d 100644 --- a/lib/quickbeam/vm/fuzz.ex +++ b/lib/quickbeam/vm/fuzz.ex @@ -1,53 +1,3 @@ -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 - -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 - -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 - defmodule QuickBEAM.VM.Fuzz do @moduledoc """ Deterministic, bounded mutation fuzzing for the VM decoder and verifier. @@ -65,13 +15,13 @@ defmodule QuickBEAM.VM.Fuzz do import Bitwise - alias QuickBEAM.VM.Checksum + alias QuickBEAM.VM.Bytecode.Checksum alias QuickBEAM.VM.Fuzz.Finding alias QuickBEAM.VM.Fuzz.Mutation alias QuickBEAM.VM.Fuzz.Summary - alias QuickBEAM.VM.Opcodes + alias QuickBEAM.VM.Bytecode.Opcode alias QuickBEAM.VM.Program - alias QuickBEAM.VM.Verifier + alias QuickBEAM.VM.Bytecode.Verifier @mask 0xFFFFFFFFFFFFFFFF @default_iterations 1_000 @@ -576,30 +526,30 @@ defmodule QuickBEAM.VM.Fuzz do do: replace_instruction(function, :not_an_instruction) defp mutate_function(function, :invalid_operand_type, _atoms, _state), - do: replace_instruction(function, {Opcodes.num(:push_i32), [:not_an_integer]}) + do: replace_instruction(function, {Opcode.num(:push_i32), [:not_an_integer]}) defp mutate_function(function, :invalid_constant, _atoms, _state) do - instruction = {Opcodes.num(:push_const), [length(function.constants)]} + instruction = {Opcode.num(:push_const), [length(function.constants)]} replace_instruction(function, instruction) end defp mutate_function(function, :invalid_atom, atoms, _state) do - instruction = {Opcodes.num(:get_var), [tuple_size(atoms)]} + instruction = {Opcode.num(:get_var), [tuple_size(atoms)]} replace_instruction(function, instruction) end defp mutate_function(function, :invalid_jump, _atoms, _state) do - instruction = {Opcodes.num(:goto), [tuple_size(function.instructions)]} + instruction = {Opcode.num(:goto), [tuple_size(function.instructions)]} replace_instruction(function, instruction) end defp mutate_function(function, :invalid_exception_target, _atoms, _state) do - instruction = {Opcodes.num(:catch), [tuple_size(function.instructions)]} + 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, {Opcodes.num(:drop), []}) + do: replace_instruction(function, {Opcode.num(:drop), []}) defp mutate_function(function, :stack_size_mismatch, _atoms, _state), do: %{function | stack_size: function.stack_size + 1} @@ -613,7 +563,7 @@ defmodule QuickBEAM.VM.Fuzz do [] -> replace_instruction( function, - {Opcodes.num(:get_var_ref), [length(function.closure_vars)]} + {Opcode.num(:get_var_ref), [length(function.closure_vars)]} ) 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/function.ex b/lib/quickbeam/vm/program/function.ex similarity index 95% rename from lib/quickbeam/vm/function.ex rename to lib/quickbeam/vm/program/function.ex index 29e93211f..89b7f26e2 100644 --- a/lib/quickbeam/vm/function.ex +++ b/lib/quickbeam/vm/program/function.ex @@ -1,4 +1,4 @@ -defmodule QuickBEAM.VM.Function do +defmodule QuickBEAM.VM.Program.Function do @moduledoc "JavaScript function metadata and pre-resolved VM instructions used by the interpreter and BEAM compiler." @type t :: %__MODULE__{} diff --git a/lib/quickbeam/vm/pinned_program.ex b/lib/quickbeam/vm/program/pinned.ex similarity index 72% rename from lib/quickbeam/vm/pinned_program.ex rename to lib/quickbeam/vm/program/pinned.ex index 7aee0d0ef..71e25f9fd 100644 --- a/lib/quickbeam/vm/pinned_program.ex +++ b/lib/quickbeam/vm/program/pinned.ex @@ -1,9 +1,9 @@ -defmodule QuickBEAM.VM.PinnedProgram do +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.ProgramStore` slot until + decoded program remains in a fixed `QuickBEAM.VM.Program.Store` slot until explicitly removed with `QuickBEAM.VM.unpin/1`. """ diff --git a/lib/quickbeam/vm/source_position.ex b/lib/quickbeam/vm/program/source.ex similarity index 64% rename from lib/quickbeam/vm/source_position.ex rename to lib/quickbeam/vm/program/source.ex index 278c2619e..5fac5e9f1 100644 --- a/lib/quickbeam/vm/source_position.ex +++ b/lib/quickbeam/vm/program/source.ex @@ -1,12 +1,12 @@ -defmodule QuickBEAM.VM.SourcePosition do +defmodule QuickBEAM.VM.Program.Source do @moduledoc "Resolves VM instruction positions to source line and column metadata." @doc "Resolves a VM function instruction index to source line and column information." - def source_position(%QuickBEAM.VM.Function{source_positions: positions}, insn_index) + 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.Function{} = fun, _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 similarity index 92% rename from lib/quickbeam/vm/program_store.ex rename to lib/quickbeam/vm/program/store.ex index 9f4836fda..f967d0fb8 100644 --- a/lib/quickbeam/vm/program_store.ex +++ b/lib/quickbeam/vm/program/store.ex @@ -1,24 +1,4 @@ -defmodule QuickBEAM.VM.ProgramStore.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 - -defmodule QuickBEAM.VM.ProgramStore do +defmodule QuickBEAM.VM.Program.Store do @moduledoc """ Keeps a bounded set of large immutable VM programs in fixed persistent slots. @@ -32,8 +12,10 @@ defmodule QuickBEAM.VM.ProgramStore do use GenServer - alias QuickBEAM.VM.{PinnedProgram, Program, Verifier} - alias QuickBEAM.VM.ProgramStore.Lease + alias QuickBEAM.VM.Program.Pinned + alias QuickBEAM.VM.Program + alias QuickBEAM.VM.Bytecode.Verifier + alias QuickBEAM.VM.Program.Store.Lease @default_capacity 8 @maximum_capacity 32 @@ -51,7 +33,7 @@ defmodule QuickBEAM.VM.ProgramStore do @doc "Pins a verified program explicitly and returns its lightweight handle." @spec pin(Program.t(), GenServer.server()) :: - {:ok, PinnedProgram.t()} + {:ok, Pinned.t()} | :unavailable | {:error, :program_too_large | :residency_budget} def pin(program, server \\ __MODULE__) @@ -66,7 +48,7 @@ defmodule QuickBEAM.VM.ProgramStore do case reserve_and_install(key, program, residency_bytes, server) do {:ok, lease} -> checkin(lease, server) - {:ok, %PinnedProgram{key: key}} + {:ok, %Pinned{key: key}} result -> result @@ -84,8 +66,8 @@ defmodule QuickBEAM.VM.ProgramStore do def pin(%Program{}, _server), do: :unavailable @doc "Checks out an explicitly pinned immutable program." - @spec checkout(PinnedProgram.t(), GenServer.server()) :: checkout_result() - def checkout(%PinnedProgram{key: key}, server \\ __MODULE__) do + @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 @@ -108,10 +90,10 @@ defmodule QuickBEAM.VM.ProgramStore do end @doc "Unpins a program slot, deferring erasure while leases remain active." - @spec unpin(Program.t() | PinnedProgram.t(), GenServer.server()) :: :ok | :not_pinned + @spec unpin(Program.t() | Pinned.t(), GenServer.server()) :: :ok | :not_pinned def unpin(program, server \\ __MODULE__) - def unpin(%PinnedProgram{key: key}, server), do: unpin_key(key, server) + 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) 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/variable.ex b/lib/quickbeam/vm/program/variable.ex similarity index 83% rename from lib/quickbeam/vm/variable.ex rename to lib/quickbeam/vm/program/variable.ex index 1a616f728..272a765de 100644 --- a/lib/quickbeam/vm/variable.ex +++ b/lib/quickbeam/vm/program/variable.ex @@ -1,4 +1,4 @@ -defmodule QuickBEAM.VM.Variable do +defmodule QuickBEAM.VM.Program.Variable do @moduledoc "JavaScript local variable definition metadata." defstruct [ diff --git a/lib/quickbeam/vm/closure_variable.ex b/lib/quickbeam/vm/program/variable/closure.ex similarity index 72% rename from lib/quickbeam/vm/closure_variable.ex rename to lib/quickbeam/vm/program/variable/closure.ex index 0b2513421..12bb381c4 100644 --- a/lib/quickbeam/vm/closure_variable.ex +++ b/lib/quickbeam/vm/program/variable/closure.ex @@ -1,4 +1,4 @@ -defmodule QuickBEAM.VM.ClosureVariable do +defmodule QuickBEAM.VM.Program.Variable.Closure do @moduledoc "JavaScript closure capture metadata." defstruct [:name, :var_idx, :closure_type, :is_const, :is_lexical, :var_kind] diff --git a/lib/quickbeam/vm/evaluator.ex b/lib/quickbeam/vm/runtime.ex similarity index 88% rename from lib/quickbeam/vm/evaluator.ex rename to lib/quickbeam/vm/runtime.ex index 003984a0c..b586f67d2 100644 --- a/lib/quickbeam/vm/evaluator.ex +++ b/lib/quickbeam/vm/runtime.ex @@ -1,4 +1,4 @@ -defmodule QuickBEAM.VM.Evaluator do +defmodule QuickBEAM.VM.Runtime do @moduledoc """ Drives an interpreter evaluation and its owner-local event loop. @@ -6,21 +6,21 @@ defmodule QuickBEAM.VM.Evaluator do coroutines, and waits for the final Promise without polling. """ - alias QuickBEAM.VM.Compiler.{Counters, RegionProbe} - - alias QuickBEAM.VM.{ - Async, - Continuation, - Coroutine, - Exceptions, - Execution, - Interpreter, - Program, - Promise, - PromiseReference, - Reaction - } - + alias QuickBEAM.VM.Compiler.Counter + alias QuickBEAM.VM.Compiler.Region.Probe + + alias QuickBEAM.VM.Runtime.Async + alias QuickBEAM.VM.Runtime.Continuation + alias QuickBEAM.VM.Runtime.Coroutine + alias QuickBEAM.VM.Runtime.Exception + alias QuickBEAM.VM.Runtime.State + alias QuickBEAM.VM.Runtime.Interpreter + alias QuickBEAM.VM.Program + alias QuickBEAM.VM.Runtime.Promise + alias QuickBEAM.VM.Runtime.Promise.Reference, as: PromiseReference + alias QuickBEAM.VM.Runtime.Promise.Reaction + + @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 @@ -89,7 +89,7 @@ defmodule QuickBEAM.VM.Evaluator do finish_final({:error, error, execution}) {:rejected, reason} -> - error = Exceptions.to_js_error(reason, execution, []) + error = Exception.to_js_error(reason, execution, []) finish_final({:error, error, execution}) :pending -> @@ -210,7 +210,7 @@ defmodule QuickBEAM.VM.Evaluator do |> drive() end - defp finish_final({status, _value, %Execution{} = execution} = result) + defp finish_final({status, _value, %State{} = execution} = result) when status in [:ok, :error] do Async.cancel_operations(execution) finished = Interpreter.finish(result) @@ -224,9 +224,9 @@ defmodule QuickBEAM.VM.Evaluator do finished end - defp report_measurement(%Execution{measurement_target: nil}), do: :ok + defp report_measurement(%State{measurement_target: nil}), do: :ok - defp report_measurement(%Execution{measurement_target: {pid, ref}} = execution) do + defp report_measurement(%State{measurement_target: {pid, ref}} = execution) do process_memory = process_stat(:memory) reductions = process_stat(:reductions) @@ -236,8 +236,8 @@ defmodule QuickBEAM.VM.Evaluator do %{ steps: execution.step_limit - execution.remaining_steps, logical_memory_bytes: execution.memory_used, - compiler_counters: Counters.snapshot(execution), - compiler_regions: RegionProbe.snapshot(execution), + compiler_counters: Counter.snapshot(execution), + compiler_regions: Probe.snapshot(execution), process_memory_bytes: process_memory, reductions: reductions }} diff --git a/lib/quickbeam/vm/async.ex b/lib/quickbeam/vm/runtime/async.ex similarity index 73% rename from lib/quickbeam/vm/async.ex rename to lib/quickbeam/vm/runtime/async.ex index 19f271b99..4af6d3544 100644 --- a/lib/quickbeam/vm/async.ex +++ b/lib/quickbeam/vm/runtime/async.ex @@ -1,4 +1,4 @@ -defmodule QuickBEAM.VM.Async do +defmodule QuickBEAM.VM.Runtime.Async do @moduledoc """ Defines canonical state transitions for async functions, Promises, and host tasks. @@ -8,45 +8,41 @@ defmodule QuickBEAM.VM.Async do it never recursively runs interpreter frames. """ - alias QuickBEAM.VM.{ - AsyncBoundary, - Continuation, - Coroutine, - Execution, - Frame, - Invocation, - IteratorBoundary, - Memory, - Promise, - PromiseExecutorBoundary, - PromiseReference, - Reaction, - ReactionBoundary, - ThenGetterBoundary, - ThenableBoundary, - Thrown - } + alias QuickBEAM.VM.Runtime.Boundary + alias QuickBEAM.VM.Runtime.Continuation + alias QuickBEAM.VM.Runtime.Coroutine + alias QuickBEAM.VM.Runtime.State + 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.Reference, as: PromiseReference + alias QuickBEAM.VM.Runtime.Promise.Reaction + + alias QuickBEAM.VM.Runtime.Thrown @type result :: - {:run, Frame.t(), Execution.t()} - | {:raise, term(), Frame.t(), Execution.t()} - | {:invoke, term(), [term()], term(), term(), Execution.t(), boolean()} - | {:complete, term(), term(), Execution.t(), boolean()} - | {:return, term(), Execution.t()} - | {:continue_iterator, IteratorBoundary.t(), Execution.t()} - | {:idle, Execution.t()} + {: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(), Execution.t()} + | {:error, term(), State.t()} @doc "Enters an async bytecode function behind an explicit Promise boundary." @spec enter( - QuickBEAM.VM.Function.t(), + QuickBEAM.VM.Program.Function.t(), term(), tuple(), [term()], term(), term(), - Execution.t(), + State.t(), boolean() ) :: result() def enter(function, callable, closure_refs, arguments, this, caller, execution, tail?) do @@ -59,14 +55,14 @@ defmodule QuickBEAM.VM.Async do mode = cond do - match?(%ReactionBoundary{}, caller) -> :reaction - match?(%PromiseExecutorBoundary{}, caller) -> :executor - match?(%ThenableBoundary{}, caller) -> :thenable + match?(%Boundary.Reaction{}, caller) -> :reaction + match?(%Boundary.PromiseExecutor{}, caller) -> :executor + match?(%Boundary.Thenable{}, caller) -> :thenable tail? -> :return true -> :push end - boundary = %AsyncBoundary{ + boundary = %Boundary.Async{ promise: promise, caller: if(tail?, do: nil, else: caller), depth: execution.depth, @@ -80,9 +76,9 @@ defmodule QuickBEAM.VM.Async do end @doc "Restores a detached coroutine and classifies its settlement continuation." - @spec resume_coroutine(Coroutine.t(), {:ok, term()} | {:error, term()}, Execution.t()) :: + @spec resume_coroutine(Coroutine.t(), {:ok, term()} | {:error, term()}, State.t()) :: result() - def resume_coroutine(%Coroutine{} = coroutine, result, %Execution{} = execution) do + 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} @@ -97,7 +93,7 @@ defmodule QuickBEAM.VM.Async do end @doc "Detaches the nearest async boundary while awaiting a Promise." - @spec detach_await(Frame.t(), Execution.t(), PromiseReference.t()) :: + @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 -> @@ -106,7 +102,7 @@ defmodule QuickBEAM.VM.Async do end @doc "Detaches the nearest async boundary and queues an immediate await result." - @spec detach_immediate(Frame.t(), Execution.t(), {:ok, term()} | {:error, term()}) :: + @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 -> @@ -115,7 +111,7 @@ defmodule QuickBEAM.VM.Async do end @doc "Creates a legacy Promise continuation when no async boundary exists." - @spec suspend_promise(Frame.t(), Execution.t(), PromiseReference.t()) :: result() + @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 -> @@ -130,7 +126,7 @@ defmodule QuickBEAM.VM.Async do end @doc "Queues an immediate await result and suspends until its microtask turn." - @spec suspend_microtask(Frame.t(), Execution.t(), {:ok, term()} | {:error, term()}) :: result() + @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)} @@ -142,11 +138,11 @@ defmodule QuickBEAM.VM.Async do PromiseReference.t(), term(), term(), - Frame.t() | IteratorBoundary.t() | nil, - Execution.t() + Frame.t() | Boundary.Iterator.t() | nil, + State.t() ) :: result() def read_thenable(promise, thenable, getter, continuation, execution) do - boundary = %ThenGetterBoundary{ + boundary = %Boundary.ThenGetter{ promise: promise, thenable: thenable, depth: execution.depth, @@ -157,17 +153,17 @@ defmodule QuickBEAM.VM.Async do end @doc "Plans invocation of a callable thenable with idempotent resolver functions." - @spec assimilate_thenable(PromiseReference.t(), term(), term(), Execution.t()) :: result() + @spec assimilate_thenable(PromiseReference.t(), term(), term(), State.t()) :: result() def assimilate_thenable(promise, thenable, callable, execution) do - boundary = %ThenableBoundary{promise: promise, depth: execution.depth} + 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()}, Execution.t()) :: result() - def run_reaction(%Reaction{} = reaction, result, %Execution{} = execution) do + @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 @@ -175,7 +171,7 @@ defmodule QuickBEAM.VM.Async do end if Invocation.callable?(callback, execution) do - boundary = %ReactionBoundary{ + boundary = %Boundary.Reaction{ promise: reaction.result_promise, depth: execution.depth, mode: reaction.kind, @@ -190,7 +186,7 @@ defmodule QuickBEAM.VM.Async do end @doc "Completes an accessor-backed thenable read and resumes its continuation." - @spec complete_then_getter(term(), ThenGetterBoundary.t(), Execution.t()) :: result() + @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 @@ -201,17 +197,17 @@ defmodule QuickBEAM.VM.Async do case boundary.continuation do %Frame{} = frame -> {:run, frame, execution} - %IteratorBoundary{} = iterator -> {:continue_iterator, iterator, execution} + %Boundary.Iterator{} = iterator -> {:continue_iterator, iterator, execution} nil -> {:idle, execution} end end @doc "Completes a Promise reaction boundary." - @spec complete_reaction(ReactionBoundary.t(), term(), Execution.t()) :: result() - def complete_reaction(%ReactionBoundary{mode: :then} = boundary, value, execution), + @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(%ReactionBoundary{mode: :finally} = boundary, value, execution) do + def complete_reaction(%Boundary.Reaction{mode: :finally} = boundary, value, execution) do {completion, execution} = case value do %PromiseReference{} = promise -> @@ -234,15 +230,15 @@ defmodule QuickBEAM.VM.Async do end @doc "Settles an async function Promise and returns its boundary-delivery action." - @spec complete(AsyncBoundary.t(), {:ok, term()} | {:error, term()}, Execution.t()) :: result() - def complete(%AsyncBoundary{} = boundary, result, execution) do + @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(Execution.t(), reference(), {:ok, term()} | {:error, term()}) :: - {:ok, Execution.t()} | :stale + @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} -> @@ -256,8 +252,8 @@ defmodule QuickBEAM.VM.Async do end @doc "Cancels every outstanding handler task owned by an evaluation." - @spec cancel_operations(Execution.t() | map()) :: :ok - def cancel_operations(%Execution{operations: operations}), do: cancel_operations(operations) + @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}} -> @@ -266,8 +262,8 @@ defmodule QuickBEAM.VM.Async do end @doc "Starts an asynchronous BEAM handler and returns its owner-local Promise." - @spec start_host_call([term()], Execution.t()) :: - {:ok, PromiseReference.t(), Execution.t()} | {:error, term(), Execution.t()} + @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 {promise, execution} = Promise.new(execution) @@ -284,8 +280,8 @@ defmodule QuickBEAM.VM.Async do do: {:error, {:type_error, :invalid_beam_call}, execution} defp detach(resume_frame, execution, enqueue_resume) do - case Enum.split_while(execution.callers, &(!match?(%AsyncBoundary{}, &1))) do - {inner_callers, [%AsyncBoundary{} = boundary | outer_callers]} -> + 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, @@ -301,22 +297,22 @@ defmodule QuickBEAM.VM.Async do end end - defp deliver(%AsyncBoundary{mode: :push, caller: caller, promise: promise}, execution), + defp deliver(%Boundary.Async{mode: :push, caller: caller, promise: promise}, execution), do: {:complete, promise, caller, execution, false} - defp deliver(%AsyncBoundary{mode: :return, promise: promise}, execution), + defp deliver(%Boundary.Async{mode: :return, promise: promise}, execution), do: {:return, promise, execution} defp deliver( - %AsyncBoundary{mode: :reaction, caller: boundary, promise: promise}, + %Boundary.Async{mode: :reaction, caller: boundary, promise: promise}, execution ), do: complete_reaction(boundary, promise, execution) - defp deliver(%AsyncBoundary{mode: :executor, caller: boundary}, execution), + defp deliver(%Boundary.Async{mode: :executor, caller: boundary}, execution), do: {:complete, boundary.promise, boundary.caller, execution, boundary.tail?} - defp deliver(%AsyncBoundary{mode: mode}, execution) when mode in [:thenable, :detached], + defp deliver(%Boundary.Async{mode: mode}, execution) when mode in [:thenable, :detached], do: {:idle, execution} defp reaction_argument({:ok, value}), do: value diff --git a/lib/quickbeam/vm/accessor_boundary.ex b/lib/quickbeam/vm/runtime/boundary/accessor.ex similarity index 80% rename from lib/quickbeam/vm/accessor_boundary.ex rename to lib/quickbeam/vm/runtime/boundary/accessor.ex index c899bc567..cb357beb4 100644 --- a/lib/quickbeam/vm/accessor_boundary.ex +++ b/lib/quickbeam/vm/runtime/boundary/accessor.ex @@ -1,4 +1,4 @@ -defmodule QuickBEAM.VM.AccessorBoundary do +defmodule QuickBEAM.VM.Runtime.Boundary.Accessor do @moduledoc """ Tracks completion of a JavaScript property accessor invocation. @@ -11,7 +11,7 @@ defmodule QuickBEAM.VM.AccessorBoundary do @type t :: %__MODULE__{ mode: :get | :set, - caller: QuickBEAM.VM.Frame.t(), + 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/constructor_boundary.ex b/lib/quickbeam/vm/runtime/boundary/constructor.ex similarity index 65% rename from lib/quickbeam/vm/constructor_boundary.ex rename to lib/quickbeam/vm/runtime/boundary/constructor.ex index 047c03f9a..755da1484 100644 --- a/lib/quickbeam/vm/constructor_boundary.ex +++ b/lib/quickbeam/vm/runtime/boundary/constructor.ex @@ -1,4 +1,4 @@ -defmodule QuickBEAM.VM.ConstructorBoundary do +defmodule QuickBEAM.VM.Runtime.Boundary.Constructor do @moduledoc """ Tracks completion of a JavaScript constructor invocation. @@ -10,8 +10,8 @@ defmodule QuickBEAM.VM.ConstructorBoundary do defstruct [:instance, :caller, :depth] @type t :: %__MODULE__{ - instance: QuickBEAM.VM.Reference.t(), - caller: QuickBEAM.VM.Frame.t() | QuickBEAM.VM.NativeFrame.t(), + 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/iterator_boundary.ex b/lib/quickbeam/vm/runtime/boundary/iterator.ex similarity index 83% rename from lib/quickbeam/vm/iterator_boundary.ex rename to lib/quickbeam/vm/runtime/boundary/iterator.ex index e09f55bd3..50bcec1e3 100644 --- a/lib/quickbeam/vm/iterator_boundary.ex +++ b/lib/quickbeam/vm/runtime/boundary/iterator.ex @@ -1,4 +1,4 @@ -defmodule QuickBEAM.VM.IteratorBoundary do +defmodule QuickBEAM.VM.Runtime.Boundary.Iterator do @moduledoc "Defines resumable state while a Promise combinator consumes an iterator." @enforce_keys [:consumer, :iterable, :caller, :depth] @@ -29,8 +29,8 @@ defmodule QuickBEAM.VM.IteratorBoundary do @type t :: %__MODULE__{ consumer: :promise | :set, kind: :all | :all_settled | :any | :race | nil, - promise: QuickBEAM.VM.PromiseReference.t() | nil, - target: QuickBEAM.VM.Reference.t() | nil, + promise: QuickBEAM.VM.Runtime.Promise.Reference.t() | nil, + target: QuickBEAM.VM.Runtime.Reference.t() | nil, iterable: term(), iterator: term() | nil, next: term() | nil, diff --git a/lib/quickbeam/vm/object_assign_boundary.ex b/lib/quickbeam/vm/runtime/boundary/object_assign.ex similarity index 77% rename from lib/quickbeam/vm/object_assign_boundary.ex rename to lib/quickbeam/vm/runtime/boundary/object_assign.ex index e08c56096..67e2cc765 100644 --- a/lib/quickbeam/vm/object_assign_boundary.ex +++ b/lib/quickbeam/vm/runtime/boundary/object_assign.ex @@ -1,4 +1,4 @@ -defmodule QuickBEAM.VM.ObjectAssignBoundary do +defmodule QuickBEAM.VM.Runtime.Boundary.ObjectAssign do @moduledoc """ Tracks resumable `Object.assign` property reads and writes. @@ -20,10 +20,10 @@ defmodule QuickBEAM.VM.ObjectAssignBoundary do ] @type t :: %__MODULE__{ - target: QuickBEAM.VM.Reference.t(), + target: QuickBEAM.VM.Runtime.Reference.t(), sources: [term()], source: term() | nil, - caller: QuickBEAM.VM.Frame.t() | QuickBEAM.VM.NativeFrame.t(), + caller: QuickBEAM.VM.Runtime.Frame.t() | QuickBEAM.VM.Runtime.Frame.Native.t(), depth: non_neg_integer(), phase: :get | :set | nil, key: term() | nil, diff --git a/lib/quickbeam/vm/promise_executor_boundary.ex b/lib/quickbeam/vm/runtime/boundary/promise_executor.ex similarity index 67% rename from lib/quickbeam/vm/promise_executor_boundary.ex rename to lib/quickbeam/vm/runtime/boundary/promise_executor.ex index d345185d6..7837cbb5e 100644 --- a/lib/quickbeam/vm/promise_executor_boundary.ex +++ b/lib/quickbeam/vm/runtime/boundary/promise_executor.ex @@ -1,4 +1,4 @@ -defmodule QuickBEAM.VM.PromiseExecutorBoundary do +defmodule QuickBEAM.VM.Runtime.Boundary.PromiseExecutor do @moduledoc """ Records the caller waiting for synchronous Promise executor completion. @@ -10,8 +10,8 @@ defmodule QuickBEAM.VM.PromiseExecutorBoundary do defstruct [:promise, :caller, :depth, tail?: false] @type t :: %__MODULE__{ - promise: QuickBEAM.VM.PromiseReference.t(), - caller: QuickBEAM.VM.Frame.t() | QuickBEAM.VM.NativeFrame.t(), + 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() } diff --git a/lib/quickbeam/vm/reaction_boundary.ex b/lib/quickbeam/vm/runtime/boundary/reaction.ex similarity index 80% rename from lib/quickbeam/vm/reaction_boundary.ex rename to lib/quickbeam/vm/runtime/boundary/reaction.ex index 5a96e142f..7f7cb398d 100644 --- a/lib/quickbeam/vm/reaction_boundary.ex +++ b/lib/quickbeam/vm/runtime/boundary/reaction.ex @@ -1,4 +1,4 @@ -defmodule QuickBEAM.VM.ReactionBoundary do +defmodule QuickBEAM.VM.Runtime.Boundary.Reaction do @moduledoc """ Tracks completion of a running Promise reaction callback. @@ -10,7 +10,7 @@ defmodule QuickBEAM.VM.ReactionBoundary do defstruct [:promise, :depth, mode: :then, original_result: nil] @type t :: %__MODULE__{ - promise: QuickBEAM.VM.PromiseReference.t(), + promise: QuickBEAM.VM.Runtime.Promise.Reference.t(), depth: non_neg_integer(), mode: :then | :finally, original_result: {:ok, term()} | {:error, term()} | nil diff --git a/lib/quickbeam/vm/then_getter_boundary.ex b/lib/quickbeam/vm/runtime/boundary/then_getter.ex similarity index 59% rename from lib/quickbeam/vm/then_getter_boundary.ex rename to lib/quickbeam/vm/runtime/boundary/then_getter.ex index 6dfb04aa5..915546017 100644 --- a/lib/quickbeam/vm/then_getter_boundary.ex +++ b/lib/quickbeam/vm/runtime/boundary/then_getter.ex @@ -1,4 +1,4 @@ -defmodule QuickBEAM.VM.ThenGetterBoundary do +defmodule QuickBEAM.VM.Runtime.Boundary.ThenGetter do @moduledoc """ Tracks the JavaScript `then` property getter used during Promise resolution. @@ -10,9 +10,10 @@ defmodule QuickBEAM.VM.ThenGetterBoundary do defstruct [:promise, :thenable, :depth, :continuation] @type t :: %__MODULE__{ - promise: QuickBEAM.VM.PromiseReference.t(), - thenable: QuickBEAM.VM.Reference.t(), + promise: QuickBEAM.VM.Runtime.Promise.Reference.t(), + thenable: QuickBEAM.VM.Runtime.Reference.t(), depth: non_neg_integer(), - continuation: QuickBEAM.VM.Frame.t() | QuickBEAM.VM.IteratorBoundary.t() | nil + continuation: + QuickBEAM.VM.Runtime.Frame.t() | QuickBEAM.VM.Runtime.Boundary.Iterator.t() | nil } end diff --git a/lib/quickbeam/vm/thenable_boundary.ex b/lib/quickbeam/vm/runtime/boundary/thenable.ex similarity index 68% rename from lib/quickbeam/vm/thenable_boundary.ex rename to lib/quickbeam/vm/runtime/boundary/thenable.ex index a3a974f96..a54d1928c 100644 --- a/lib/quickbeam/vm/thenable_boundary.ex +++ b/lib/quickbeam/vm/runtime/boundary/thenable.ex @@ -1,4 +1,4 @@ -defmodule QuickBEAM.VM.ThenableBoundary do +defmodule QuickBEAM.VM.Runtime.Boundary.Thenable do @moduledoc """ Tracks invocation of a foreign thenable's `then` method during assimilation. """ @@ -7,7 +7,7 @@ defmodule QuickBEAM.VM.ThenableBoundary do defstruct [:promise, :depth] @type t :: %__MODULE__{ - promise: QuickBEAM.VM.PromiseReference.t(), + promise: QuickBEAM.VM.Runtime.Promise.Reference.t(), depth: non_neg_integer() } 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/coroutine.ex b/lib/quickbeam/vm/runtime/coroutine.ex similarity index 58% rename from lib/quickbeam/vm/coroutine.ex rename to lib/quickbeam/vm/runtime/coroutine.ex index 88f0e34ff..349b03d92 100644 --- a/lib/quickbeam/vm/coroutine.ex +++ b/lib/quickbeam/vm/runtime/coroutine.ex @@ -1,4 +1,4 @@ -defmodule QuickBEAM.VM.Coroutine do +defmodule QuickBEAM.VM.Runtime.Coroutine do @moduledoc """ Captures a detached async frame and its explicit JavaScript caller stack. @@ -10,8 +10,8 @@ defmodule QuickBEAM.VM.Coroutine do defstruct [:frame, :callers, :boundary] @type t :: %__MODULE__{ - frame: QuickBEAM.VM.Frame.t(), - callers: [QuickBEAM.VM.Frame.t() | QuickBEAM.VM.NativeFrame.t()], - boundary: QuickBEAM.VM.AsyncBoundary.t() + 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/exceptions.ex b/lib/quickbeam/vm/runtime/exception.ex similarity index 71% rename from lib/quickbeam/vm/exceptions.ex rename to lib/quickbeam/vm/runtime/exception.ex index 4e0db65f5..dc76be728 100644 --- a/lib/quickbeam/vm/exceptions.ex +++ b/lib/quickbeam/vm/runtime/exception.ex @@ -1,4 +1,4 @@ -defmodule QuickBEAM.VM.Exceptions do +defmodule QuickBEAM.VM.Runtime.Exception do @moduledoc """ Converts VM-generated failures into catchable owner-local JavaScript errors. @@ -6,43 +6,40 @@ defmodule QuickBEAM.VM.Exceptions do to `QuickBEAM.JSError` happens only after a value escapes the evaluation. """ - alias QuickBEAM.VM.{ - AccessorBoundary, - Async, - AsyncBoundary, - Builtins, - ConstructorBoundary, - Execution, - Frame, - Function, - Heap, - Iterator, - IteratorBoundary, - NativeFrame, - Object, - ObjectAssignBoundary, - PredefinedAtoms, - Promise, - PromiseExecutorBoundary, - Properties, - ReactionBoundary, - Reference, - ThenGetterBoundary, - ThenableBoundary, - Thrown - } + alias QuickBEAM.VM.Runtime.Boundary + alias QuickBEAM.VM.Runtime.Async + + alias QuickBEAM.VM.Builtin.Runtime, as: BuiltinRuntime + + alias QuickBEAM.VM.Runtime.State + alias QuickBEAM.VM.Runtime.Frame + alias QuickBEAM.VM.Program.Function + alias QuickBEAM.VM.Runtime.Heap + alias QuickBEAM.VM.Runtime.Iterator + + alias QuickBEAM.VM.Runtime.Frame.Native + alias QuickBEAM.VM.Runtime.Object + + alias QuickBEAM.VM.Bytecode.Atom, as: AtomTable + alias QuickBEAM.VM.Runtime.Promise + + alias QuickBEAM.VM.Runtime.Property + + alias QuickBEAM.VM.Runtime.Reference + + alias QuickBEAM.VM.Runtime.Thrown @type action :: - {:run, Frame.t(), Execution.t()} - | {:resume_then_getter, ThenGetterBoundary.t(), Execution.t()} - | {:complete, term(), term(), Execution.t(), boolean()} + {:run, Frame.t(), State.t()} + | {:resume_then_getter, Boundary.ThenGetter.t(), State.t()} + | {:complete, term(), term(), State.t(), boolean()} | {:async, Async.result()} - | {:idle, Execution.t()} - | {:error, QuickBEAM.JSError.t(), Execution.t()} + | {: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() | NativeFrame.t(), Execution.t()) :: action() - def throw_at(reason, %NativeFrame{caller: caller}, execution) do + @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 @@ -53,20 +50,20 @@ defmodule QuickBEAM.VM.Exceptions do end @doc "Raises a value from an invocation boundary and plans its continuation." - @spec throw_from(term(), term(), Execution.t()) :: action() + @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(), Execution.t()) :: {term(), Execution.t()} + @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"] || "") - Builtins.new_error(execution, name, message) + BuiltinRuntime.new_error(execution, name, message) value -> {value, execution} @@ -74,7 +71,7 @@ defmodule QuickBEAM.VM.Exceptions do end @doc "Converts an uncaught JavaScript value into the stable public error struct." - @spec to_js_error(term(), Execution.t(), [QuickBEAM.JSError.frame()]) :: QuickBEAM.JSError.t() + @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) @@ -88,18 +85,18 @@ defmodule QuickBEAM.VM.Exceptions do 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(), Execution.t()) :: {:ok, map()} | :error + @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 Properties.get(reference, "name", execution) do + 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 Properties.get(reference, "message", execution) do + case Property.get(reference, "message", execution) do {:ok, :undefined} -> "" {:ok, value} -> to_string_value(value) {:error, _reason} -> "" @@ -122,36 +119,36 @@ defmodule QuickBEAM.VM.Exceptions do {value, [], execution} end - defp throw_from_boundary(reason, %ObjectAssignBoundary{} = boundary, execution, trace), + defp throw_from_boundary(reason, %Boundary.ObjectAssign{} = boundary, execution, trace), do: do_throw(reason, boundary.caller, execution, trace, true) - defp throw_from_boundary(reason, %ThenGetterBoundary{} = boundary, execution, trace) do + 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, %AccessorBoundary{} = boundary, execution, trace), + defp throw_from_boundary(reason, %Boundary.Accessor{} = boundary, execution, trace), do: do_throw(reason, boundary.caller, execution, trace, true) - defp throw_from_boundary(reason, %ConstructorBoundary{} = boundary, execution, trace), + defp throw_from_boundary(reason, %Boundary.Constructor{} = boundary, execution, trace), do: do_throw(reason, boundary.caller, execution, trace, true) - defp throw_from_boundary(reason, %ThenableBoundary{} = boundary, execution, trace) do + 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, %PromiseExecutorBoundary{} = boundary, execution, trace) do + 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, - %IteratorBoundary{consumer: :promise} = boundary, + %Boundary.Iterator{consumer: :promise} = boundary, execution, trace ), @@ -159,18 +156,18 @@ defmodule QuickBEAM.VM.Exceptions do defp throw_from_boundary( reason, - %IteratorBoundary{consumer: :set, caller: %ConstructorBoundary{} = constructor}, + %Boundary.Iterator{consumer: :set, caller: %Boundary.Constructor{} = constructor}, execution, trace ), do: do_throw(reason, constructor.caller, execution, trace, true) - defp throw_from_boundary(reason, %ReactionBoundary{} = boundary, execution, trace) do + 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, %NativeFrame{caller: caller}, execution, trace), + 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), @@ -187,14 +184,14 @@ defmodule QuickBEAM.VM.Exceptions do end end - defp unwind_caller(reason, %Execution{callers: []} = execution, trace) do + 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, - %Execution{callers: [%ObjectAssignBoundary{} = boundary | callers]} = execution, + %State{callers: [%Boundary.ObjectAssign{} = boundary | callers]} = execution, trace ) do execution = %{execution | callers: callers, depth: boundary.depth} @@ -203,7 +200,7 @@ defmodule QuickBEAM.VM.Exceptions do defp unwind_caller( reason, - %Execution{callers: [%ThenGetterBoundary{} = boundary | callers]} = execution, + %State{callers: [%Boundary.ThenGetter{} = boundary | callers]} = execution, trace ) do execution = %{execution | callers: callers, depth: boundary.depth} @@ -212,7 +209,7 @@ defmodule QuickBEAM.VM.Exceptions do defp unwind_caller( reason, - %Execution{callers: [%AccessorBoundary{} = boundary | callers]} = execution, + %State{callers: [%Boundary.Accessor{} = boundary | callers]} = execution, trace ) do execution = %{execution | callers: callers, depth: boundary.depth} @@ -221,7 +218,7 @@ defmodule QuickBEAM.VM.Exceptions do defp unwind_caller( reason, - %Execution{callers: [%ConstructorBoundary{} = boundary | callers]} = execution, + %State{callers: [%Boundary.Constructor{} = boundary | callers]} = execution, trace ) do execution = %{execution | callers: callers, depth: boundary.depth} @@ -230,7 +227,7 @@ defmodule QuickBEAM.VM.Exceptions do defp unwind_caller( reason, - %Execution{callers: [%ThenableBoundary{} = boundary | callers]} = execution, + %State{callers: [%Boundary.Thenable{} = boundary | callers]} = execution, trace ) do execution = %{execution | callers: callers, depth: boundary.depth} @@ -239,7 +236,7 @@ defmodule QuickBEAM.VM.Exceptions do defp unwind_caller( reason, - %Execution{callers: [%PromiseExecutorBoundary{} = boundary | callers]} = execution, + %State{callers: [%Boundary.PromiseExecutor{} = boundary | callers]} = execution, trace ) do execution = %{execution | callers: callers, depth: boundary.depth} @@ -248,7 +245,7 @@ defmodule QuickBEAM.VM.Exceptions do defp unwind_caller( reason, - %Execution{callers: [%IteratorBoundary{} = boundary | callers]} = execution, + %State{callers: [%Boundary.Iterator{} = boundary | callers]} = execution, trace ) do execution = %{execution | callers: callers, depth: boundary.depth} @@ -257,7 +254,7 @@ defmodule QuickBEAM.VM.Exceptions do defp unwind_caller( reason, - %Execution{callers: [%ReactionBoundary{} = boundary | callers]} = execution, + %State{callers: [%Boundary.Reaction{} = boundary | callers]} = execution, trace ) do execution = %{execution | callers: callers, depth: boundary.depth} @@ -266,7 +263,7 @@ defmodule QuickBEAM.VM.Exceptions do defp unwind_caller( reason, - %Execution{callers: [%AsyncBoundary{} = boundary | callers]} = execution, + %State{callers: [%Boundary.Async{} = boundary | callers]} = execution, trace ) do execution = %{execution | callers: callers, depth: boundary.depth} @@ -275,7 +272,7 @@ defmodule QuickBEAM.VM.Exceptions do defp unwind_caller( reason, - %Execution{callers: [%NativeFrame{} = native | callers]} = execution, + %State{callers: [%Native{} = native | callers]} = execution, trace ) do execution = %{execution | callers: callers, depth: execution.depth - 1} @@ -284,7 +281,7 @@ defmodule QuickBEAM.VM.Exceptions do defp unwind_caller( reason, - %Execution{callers: [%Frame{} = caller | callers]} = execution, + %State{callers: [%Frame{} = caller | callers]} = execution, trace ) do execution = %{execution | callers: callers, depth: execution.depth - 1} @@ -315,7 +312,7 @@ defmodule QuickBEAM.VM.Exceptions do defp normalize_function_name(name) when name in [nil, ""], do: "" defp normalize_function_name({:predefined, index}), - do: PredefinedAtoms.lookup(index) || "" + do: AtomTable.lookup(index) || "" defp normalize_function_name(name) when is_binary(name), do: name defp normalize_function_name(name), do: inspect(name) @@ -330,5 +327,5 @@ defmodule QuickBEAM.VM.Exceptions do 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: QuickBEAM.VM.Value.to_string_value(value) + defp to_string_value(value), do: QuickBEAM.VM.Runtime.Value.to_string_value(value) end diff --git a/lib/quickbeam/vm/frame.ex b/lib/quickbeam/vm/runtime/frame.ex similarity index 89% rename from lib/quickbeam/vm/frame.ex rename to lib/quickbeam/vm/runtime/frame.ex index d5a070b06..c737fc19b 100644 --- a/lib/quickbeam/vm/frame.ex +++ b/lib/quickbeam/vm/runtime/frame.ex @@ -1,4 +1,4 @@ -defmodule QuickBEAM.VM.Frame do +defmodule QuickBEAM.VM.Runtime.Frame do @moduledoc "Defines an explicit JavaScript bytecode call frame." @enforce_keys [:function, :callable, :locals, :args] @@ -18,7 +18,7 @@ defmodule QuickBEAM.VM.Frame do ] @type t :: %__MODULE__{ - function: QuickBEAM.VM.Function.t(), + function: QuickBEAM.VM.Program.Function.t(), callable: term(), closure_refs: tuple(), compiler_allow_reentry: boolean(), diff --git a/lib/quickbeam/vm/native_frame.ex b/lib/quickbeam/vm/runtime/frame/native.ex similarity index 86% rename from lib/quickbeam/vm/native_frame.ex rename to lib/quickbeam/vm/runtime/frame/native.ex index 19462555a..234fc5841 100644 --- a/lib/quickbeam/vm/native_frame.ex +++ b/lib/quickbeam/vm/runtime/frame/native.ex @@ -1,4 +1,4 @@ -defmodule QuickBEAM.VM.NativeFrame do +defmodule QuickBEAM.VM.Runtime.Frame.Native do @moduledoc "Defines resumable state for a VM-implemented native callback." @enforce_keys [:operation, :values, :callback, :receiver, :caller] @@ -19,7 +19,7 @@ defmodule QuickBEAM.VM.NativeFrame do values: tuple(), callback: term(), receiver: term(), - caller: QuickBEAM.VM.Frame.t(), + caller: QuickBEAM.VM.Runtime.Frame.t(), accumulator: term(), index: non_neg_integer(), results: [term()], diff --git a/lib/quickbeam/vm/heap.ex b/lib/quickbeam/vm/runtime/heap.ex similarity index 84% rename from lib/quickbeam/vm/heap.ex rename to lib/quickbeam/vm/runtime/heap.ex index 4ccfaebb8..beb033949 100644 --- a/lib/quickbeam/vm/heap.ex +++ b/lib/quickbeam/vm/runtime/heap.ex @@ -1,24 +1,29 @@ -defmodule QuickBEAM.VM.Heap do +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.Execution` that owns + 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.{Execution, Memory, Object, Property, Reference} + alias QuickBEAM.VM.Runtime.State + alias QuickBEAM.VM.Runtime.Memory + alias QuickBEAM.VM.Runtime.Object + alias QuickBEAM.VM.Runtime.Property.Descriptor + alias QuickBEAM.VM.Runtime.Reference @max_prototype_depth 1_000 - @spec allocate(Execution.t(), Object.kind(), keyword()) :: {Reference.t(), Execution.t()} + @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(%Execution{} = execution, kind, []) do + 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(%Execution{} = execution, kind, opts) do + def allocate(%State{} = execution, kind, opts) do prototype = case Keyword.fetch(opts, :prototype) do {:ok, prototype} -> prototype @@ -37,8 +42,8 @@ defmodule QuickBEAM.VM.Heap do end @doc "Allocates a dense array in one heap update with canonical default descriptors." - @spec allocate_array(Execution.t(), [term()]) :: {Reference.t(), Execution.t()} - def allocate_array(%Execution{} = execution, values) when is_list(values) do + @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), @@ -73,7 +78,7 @@ defmodule QuickBEAM.VM.Heap do end @doc "Returns enumerable own string and Symbol keys in ECMAScript order." - @spec assignable_keys(Execution.t(), Reference.t()) :: {:ok, [term()]} | {:error, term()} + @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} -> @@ -97,8 +102,9 @@ defmodule QuickBEAM.VM.Heap do end end - @spec fetch_object(Execution.t(), Reference.t()) :: {:ok, Object.t()} | :error - def fetch_object(%Execution{} = execution, %Reference{id: id}), + @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." @@ -109,7 +115,7 @@ defmodule QuickBEAM.VM.Heap do Enum.map(0..(length - 1), fn index -> case Map.get(properties, index) do {value} -> {:present, value} - %Property{value: value} -> {:present, value} + %Descriptor{value: value} -> {:present, value} nil -> :hole end end) @@ -122,12 +128,14 @@ defmodule QuickBEAM.VM.Heap do %{object | properties: properties, length: 0} end - @spec get(Execution.t(), Reference.t(), term()) :: {:ok, term()} | {:error, term()} - def get(%Execution{} = execution, %Reference{} = reference, key) do + @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 - @spec has_property?(Execution.t(), Reference.t(), term()) :: boolean() + @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) @@ -145,9 +153,10 @@ defmodule QuickBEAM.VM.Heap do end end - @spec put(Execution.t(), Reference.t(), term(), term()) :: - {:ok, Execution.t()} | {:error, term()} - def put(%Execution{} = execution, %Reference{id: id} = reference, key, value) do + @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 @@ -155,7 +164,7 @@ defmodule QuickBEAM.VM.Heap do 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) - %Property{} -> put_object(execution, id, object, key, value) + %Descriptor{} -> put_object(execution, id, object, key, value) end {:ok, object} -> @@ -166,9 +175,10 @@ defmodule QuickBEAM.VM.Heap do end end - @spec define(Execution.t(), Reference.t(), term(), term(), keyword()) :: - {:ok, Execution.t()} | {:error, term()} - def define(%Execution{} = execution, %Reference{id: id} = reference, key, value, opts \\ []) do + @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 @@ -193,15 +203,15 @@ defmodule QuickBEAM.VM.Heap do end @doc "Returns an object's own property descriptor without traversing its prototype." - @spec own_property(Execution.t(), Reference.t(), term()) :: - {:ok, Property.t() | nil} | {:error, term()} + @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, - %Property{ + %Descriptor{ value: object.length, writable: object.length_writable, enumerable: false, @@ -217,8 +227,8 @@ defmodule QuickBEAM.VM.Heap do end @doc "Replaces an object's prototype after validating the owner-local chain." - @spec set_prototype(Execution.t(), Reference.t(), Reference.t() | nil) :: - {:ok, Execution.t()} | {:error, term()} + @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), @@ -232,7 +242,7 @@ defmodule QuickBEAM.VM.Heap do end @doc "Tests whether a reference appears in an object's prototype chain." - @spec prototype_chain_contains?(Execution.t(), Reference.t(), Reference.t()) :: boolean() + @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) @@ -241,7 +251,7 @@ defmodule QuickBEAM.VM.Heap do end @doc "Returns an object's direct prototype." - @spec prototype(Execution.t(), Reference.t()) :: + @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 @@ -252,13 +262,13 @@ defmodule QuickBEAM.VM.Heap do @doc "Defines or updates an accessor property on an owner-local object." @spec define_accessor( - Execution.t(), + State.t(), Reference.t(), term(), :getter | :setter, term(), keyword() - ) :: {:ok, Execution.t()} | {:error, term()} + ) :: {: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) @@ -266,7 +276,7 @@ defmodule QuickBEAM.VM.Heap do with {:ok, object} <- fetch_object(execution, reference) do current = own_property_value(object, key) - property = %Property{ + property = %Descriptor{ kind: :accessor, value: :undefined, writable: false, @@ -284,9 +294,9 @@ defmodule QuickBEAM.VM.Heap do end @doc "Defines a complete data or accessor descriptor on an owner-local object." - @spec define_descriptor(Execution.t(), Reference.t(), term(), Property.t()) :: - {:ok, Execution.t()} | {:error, term()} - def define_descriptor(execution, %Reference{id: id} = reference, key, %Property{} = property) do + @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) with {:ok, object} <- fetch_object(execution, reference) do @@ -298,15 +308,16 @@ defmodule QuickBEAM.VM.Heap do end end - @spec delete(Execution.t(), Reference.t(), term()) :: - {:ok, boolean(), Execution.t()} | {:error, term()} - def delete(%Execution{} = execution, %Reference{id: id} = reference, key) do + @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 - %Property{configurable: false} -> + %Descriptor{configurable: false} -> {:ok, false, execution} _property -> @@ -324,9 +335,10 @@ defmodule QuickBEAM.VM.Heap do end end - @spec update_object(Execution.t(), Reference.t(), (Object.t() -> Object.t())) :: - {:ok, Execution.t()} | {:error, term()} - def update_object(%Execution{} = execution, %Reference{id: id} = reference, update) do + @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}} @@ -334,7 +346,7 @@ defmodule QuickBEAM.VM.Heap do end @doc "Returns all own string property names in ECMAScript key order." - @spec own_property_names(Execution.t(), Reference.t()) :: + @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 @@ -355,7 +367,8 @@ defmodule QuickBEAM.VM.Heap do end end - @spec own_keys(Execution.t(), Reference.t()) :: {:ok, [term()]} | {:error, term()} + @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} -> @@ -419,13 +432,13 @@ defmodule QuickBEAM.VM.Heap do {:ok, {value}} -> {:ok, value} - {:ok, %Property{getter: getter}} when not is_nil(getter) -> + {:ok, %Descriptor{getter: getter}} when not is_nil(getter) -> {:ok, {:accessor, getter, receiver}} - {:ok, %Property{setter: setter}} when not is_nil(setter) -> + {:ok, %Descriptor{setter: setter}} when not is_nil(setter) -> {:ok, :undefined} - {:ok, %Property{value: value}} -> + {:ok, %Descriptor{value: value}} -> {:ok, value} :error when is_struct(object.prototype, Reference) -> @@ -474,10 +487,10 @@ defmodule QuickBEAM.VM.Heap do inherited = inherited_property(execution, object.prototype, key, 0) cond do - match?(%Property{setter: setter} when not is_nil(setter), inherited) -> + match?(%Descriptor{setter: setter} when not is_nil(setter), inherited) -> {:error, {:invoke_setter, inherited.setter}} - accessor?(inherited) or match?(%Property{writable: false}, inherited) -> + accessor?(inherited) or match?(%Descriptor{writable: false}, inherited) -> {:error, {:property_not_writable, key}} key >= object.length and not object.length_writable -> @@ -540,13 +553,13 @@ defmodule QuickBEAM.VM.Heap do own_property_value(object, key) || inherited_property(execution, object.prototype, key, 0) cond do - match?(%Property{setter: setter} when not is_nil(setter), descriptor) -> + match?(%Descriptor{setter: setter} when not is_nil(setter), descriptor) -> {:error, {:invoke_setter, descriptor.setter}} accessor?(descriptor) -> {:error, {:property_not_writable, key}} - match?(%Property{writable: false}, descriptor) -> + match?(%Descriptor{writable: false}, descriptor) -> {:error, {:property_not_writable, key}} Map.has_key?(object.properties, key) -> @@ -593,7 +606,7 @@ defmodule QuickBEAM.VM.Heap do defp validate_definition(object, key, candidate) do case own_property_value(object, key) do - %Property{configurable: false} = current -> + %Descriptor{configurable: false} = current -> cond do candidate.configurable -> {:error, {:property_not_configurable, key}} @@ -637,11 +650,11 @@ defmodule QuickBEAM.VM.Heap do end end - defp accessor?(%Property{kind: :accessor}), do: true + defp accessor?(%Descriptor{kind: :accessor}), do: true defp accessor?(_property), do: false defp property(value, opts) do - %Property{ + %Descriptor{ value: value, writable: Keyword.get(opts, :writable, true), enumerable: Keyword.get(opts, :enumerable, true), @@ -652,7 +665,7 @@ defmodule QuickBEAM.VM.Heap do defp put_property(object, key, value) do case Map.get(object.properties, key) do {_old_value} -> put_default_property(object, key, value) - %Property{} = property -> put_property_struct(object, key, %{property | value: value}) + %Descriptor{} = property -> put_property_struct(object, key, %{property | value: value}) nil -> put_default_property(object, key, value) end end @@ -687,7 +700,7 @@ defmodule QuickBEAM.VM.Heap do defp inherited_property(_execution, _prototype, _key, depth) when depth > @max_prototype_depth, - do: %Property{writable: false} + do: %Descriptor{writable: false} defp inherited_property(execution, %Reference{} = prototype, key, depth) do case fetch_object(execution, prototype) do @@ -700,7 +713,7 @@ defmodule QuickBEAM.VM.Heap do end end - defp default_data_property?(%Property{ + defp default_data_property?(%Descriptor{ kind: :data, writable: true, enumerable: true, @@ -715,13 +728,13 @@ defmodule QuickBEAM.VM.Heap do defp own_property_value(object, key), do: Map.get(object.properties, key) defp property_enumerable?({_value}), do: true - defp property_enumerable?(%Property{enumerable: enumerable}), do: enumerable + defp property_enumerable?(%Descriptor{enumerable: enumerable}), do: enumerable - defp current_accessor(%Property{} = property, field), do: Map.fetch!(property, field) + 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(%Property{} = property, field, _default), do: Map.fetch!(property, field) + 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 @@ -747,7 +760,7 @@ defmodule QuickBEAM.VM.Heap do |> Map.keys() |> Enum.filter(&(is_integer(&1) and &1 >= length)) - if Enum.any?(removed, &match?(%Property{configurable: false}, object.properties[&1])) do + if Enum.any?(removed, &match?(%Descriptor{configurable: false}, object.properties[&1])) do {:error, :nonconfigurable_array_element} else {:ok, diff --git a/lib/quickbeam/vm/interpreter.ex b/lib/quickbeam/vm/runtime/interpreter.ex similarity index 79% rename from lib/quickbeam/vm/interpreter.ex rename to lib/quickbeam/vm/runtime/interpreter.ex index f82738c8d..5fbf9cc97 100644 --- a/lib/quickbeam/vm/interpreter.ex +++ b/lib/quickbeam/vm/runtime/interpreter.ex @@ -1,4 +1,4 @@ -defmodule QuickBEAM.VM.Interpreter do +defmodule QuickBEAM.VM.Runtime.Interpreter do @moduledoc """ Executes verified QuickJS bytecode with explicit JavaScript machine state. @@ -7,49 +7,48 @@ defmodule QuickBEAM.VM.Interpreter do Elixir or native call stack. """ - alias QuickBEAM.VM.{ - AccessorBoundary, - Async, - AsyncBoundary, - Builtins, - ConstructorBoundary, - Continuation, - Coroutine, - Exceptions, - Execution, - Export, - Frame, - Heap, - Invocation, - Iterator, - IteratorBoundary, - Memory, - NativeFrame, - ObjectAssignBoundary, - Opcodes, - Program, - Promise, - PromiseExecutorBoundary, - PromiseReference, - Properties, - Reaction, - ReactionBoundary, - Reference, - RegExp, - ThenableBoundary, - ThenGetterBoundary, - Value - } + alias QuickBEAM.VM.Runtime.Boundary + alias QuickBEAM.VM.Runtime.Async + + alias QuickBEAM.VM.Builtin.Runtime, as: BuiltinRuntime + + alias QuickBEAM.VM.Runtime.Continuation + alias QuickBEAM.VM.Runtime.Coroutine + alias QuickBEAM.VM.Runtime.Exception + alias QuickBEAM.VM.Runtime.State + alias QuickBEAM.VM.Runtime.Value.Export + alias QuickBEAM.VM.Runtime.Frame + alias QuickBEAM.VM.Runtime.Heap + alias QuickBEAM.VM.Runtime.Invocation + alias QuickBEAM.VM.Runtime.Iterator + + alias QuickBEAM.VM.Runtime.Memory + alias QuickBEAM.VM.Runtime.Frame.Native + + alias QuickBEAM.VM.Bytecode.Opcode + alias QuickBEAM.VM.Program + alias QuickBEAM.VM.Runtime.Promise + + alias QuickBEAM.VM.Runtime.Promise.Reference, as: PromiseReference + alias QuickBEAM.VM.Runtime.Property + alias QuickBEAM.VM.Runtime.Promise.Reaction + + alias QuickBEAM.VM.Runtime.Reference + alias QuickBEAM.VM.Runtime.RegExp + + alias QuickBEAM.VM.Runtime.Value alias QuickBEAM.VM.Builtin.Registry alias QuickBEAM.VM.Compiler, as: VMCompiler - alias QuickBEAM.VM.Compiler.{Counters, Deopt, RegionProbe} - alias QuickBEAM.VM.Opcodes.Control, as: ControlOpcodes - alias QuickBEAM.VM.Opcodes.Invocation, as: InvocationOpcodes - alias QuickBEAM.VM.Opcodes.Locals, as: LocalOpcodes - alias QuickBEAM.VM.Opcodes.Objects, as: ObjectOpcodes - alias QuickBEAM.VM.Opcodes.Stack, as: StackOpcodes - alias QuickBEAM.VM.Opcodes.Values, as: ValueOpcodes + alias QuickBEAM.VM.Compiler.Counter + alias QuickBEAM.VM.Compiler.Deopt + alias QuickBEAM.VM.Compiler.Region.Probe + 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 @default_max_steps 5_000_000 @default_max_stack_depth 1_000 @@ -69,16 +68,17 @@ defmodule QuickBEAM.VM.Interpreter do | {: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(), Execution.t()} + @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 = %Execution{ + execution = %State{ atoms: program.atoms, globals: vars, handlers: Map.new(Keyword.get(opts, :handlers, %{})), @@ -102,20 +102,21 @@ defmodule QuickBEAM.VM.Interpreter do end @doc "Runs one canonical frame and execution state through the machine loop." - @spec run_frame(Frame.t(), Execution.t()) :: term() - def run_frame(%Frame{} = frame, %Execution{} = execution), do: run(frame, execution) + @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(), Execution.t()) :: term() + @spec resume_compiler_invoke(term(), [term()], term(), Frame.t(), State.t()) :: term() def resume_compiler_invoke( callable, arguments, this, %Frame{} = caller, - %Execution{} = execution + %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() @@ -126,7 +127,7 @@ defmodule QuickBEAM.VM.Interpreter do @doc "Resumes compiler deoptimization without exporting the resulting value." @spec resume_deopt_raw(Deopt.t()) :: - {:ok, term(), Execution.t()} | {:error, term(), Execution.t()} | {:suspended, term()} + {:ok, term(), State.t()} | {:error, term(), State.t()} | {:suspended, term()} def resume_deopt_raw(%Deopt{} = deopt) do case Deopt.validate(deopt) do :ok -> @@ -153,15 +154,15 @@ defmodule QuickBEAM.VM.Interpreter do end @doc "Resumes a detached async coroutine with a Promise settlement." - def resume_coroutine(%Coroutine{} = coroutine, result, %Execution{} = execution), + 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, %Execution{} = execution), + 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(%Execution{} = execution) do + def run_synchronous_job(%State{} = execution) do case :queue.out(execution.sync_jobs) do {{:value, {:read_thenable, promise, thenable, getter}}, sync_jobs} -> execution = %{execution | sync_jobs: sync_jobs} @@ -173,11 +174,11 @@ defmodule QuickBEAM.VM.Interpreter do end @doc "Invokes a thenable and connects its resolver functions to a Promise." - def assimilate_thenable(promise, thenable, callable, %Execution{} = execution), + 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, %Execution{} = execution), + 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." @@ -262,7 +263,7 @@ defmodule QuickBEAM.VM.Interpreter do defp execute_async({:suspended, continuation}), do: {:suspended, continuation} defp execute_async({:error, reason, execution}), do: {:error, reason, execution} - defp run(frame, %Execution{} = execution) when execution.sync_jobs != {[], []} do + defp run(frame, %State{} = execution) when execution.sync_jobs != {[], []} do case :queue.out(execution.sync_jobs) do {{:value, {:read_thenable, promise, thenable, getter}}, sync_jobs} -> execution = %{execution | sync_jobs: sync_jobs} @@ -273,10 +274,10 @@ defmodule QuickBEAM.VM.Interpreter do end end - defp run(_frame, %Execution{memory_exceeded: true} = execution), + defp run(_frame, %State{memory_exceeded: true} = execution), do: {:error, {:limit_exceeded, :memory_bytes, execution.memory_limit}, execution} - defp run(_frame, %Execution{remaining_steps: 0} = 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) @@ -285,16 +286,16 @@ defmodule QuickBEAM.VM.Interpreter do defp run( %Frame{compiler_reentry_after_instruction: true} = frame, - %Execution{} = execution + %State{} = execution ) do frame = %{frame | compiler_entered: false, compiler_reentry_after_instruction: false} - execution = Counters.increment(execution, :reentries) + execution = Counter.increment(execution, :reentries) execute_current(frame, execution) end defp run( %Frame{compiler_entered: false} = frame, - %Execution{compiler_context: compiler_context} = execution + %State{compiler_context: compiler_context} = execution ) when not is_nil(compiler_context) do frame = %{frame | compiler_entered: true} @@ -317,24 +318,24 @@ defmodule QuickBEAM.VM.Interpreter do end end - defp run(%Frame{} = frame, %Execution{} = execution), do: execute_current(frame, execution) + defp run(%Frame{} = frame, %State{} = execution), do: execute_current(frame, execution) defp execute_current( frame, - %Execution{compiler_context: %{region_probe: %RegionProbe{}}} = execution + %State{compiler_context: %{region_probe: %Probe{}}} = execution ) do {opcode, operands} = elem(frame.function.instructions, frame.pc) - execution = RegionProbe.observe(execution, frame) - execution = Counters.interpreted_opcode(execution, opcode) + execution = Probe.observe(execution, frame) + execution = Counter.interpreted_opcode(execution, opcode) execute_current_opcode(opcode, operands, frame, execution) end defp execute_current( frame, - %Execution{compiler_context: %{counters: %Counters{}}} = execution + %State{compiler_context: %{counters: %Counter{}}} = execution ) do {opcode, operands} = elem(frame.function.instructions, frame.pc) - execution = Counters.interpreted_opcode(execution, opcode) + execution = Counter.interpreted_opcode(execution, opcode) execute_current_opcode(opcode, operands, frame, execution) end @@ -344,8 +345,8 @@ defmodule QuickBEAM.VM.Interpreter do end defp execute_current_opcode(opcode, operands, frame, execution) do - {name, _size, _pops, _pushes, _format} = Opcodes.info(opcode) - {name, operands} = Opcodes.expand_short_form(name, operands, frame.function.arg_count) + {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 @@ -409,7 +410,7 @@ defmodule QuickBEAM.VM.Interpreter do do: start_host_call(arguments, caller, execution, tail?) defp execute_invocation({:object_assign, target, sources, caller, execution, tail?}) do - boundary = %ObjectAssignBoundary{ + boundary = %Boundary.ObjectAssign{ target: target, sources: sources, caller: caller, @@ -432,7 +433,7 @@ defmodule QuickBEAM.VM.Interpreter do do: target |> Iterator.start_set(iterable, caller, execution, tail?) |> execute_invocation() defp execute_invocation( - {:iterator_value, value, %IteratorBoundary{consumer: :promise} = boundary, execution} + {:iterator_value, value, %Boundary.Iterator{consumer: :promise} = boundary, execution} ) do {source, execution} = Promise.from_value(execution, value) boundary = %{boundary | values: [source | boundary.values]} @@ -440,21 +441,21 @@ defmodule QuickBEAM.VM.Interpreter do end defp execute_invocation( - {:iterator_value, value, %IteratorBoundary{consumer: :set} = boundary, execution} + {: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, %IteratorBoundary{} = boundary, execution, _tail?), + defp complete_call_result(value, %Boundary.Iterator{} = boundary, execution, _tail?), do: value |> Iterator.resume(boundary, execution) |> execute_invocation() - defp complete_call_result(value, %ReactionBoundary{} = boundary, execution, _tail?), + defp complete_call_result(value, %Boundary.Reaction{} = boundary, execution, _tail?), do: complete_reaction(boundary, value, execution) defp complete_call_result( value, - %ObjectAssignBoundary{phase: :get} = boundary, + %Boundary.ObjectAssign{phase: :get} = boundary, execution, _tail? ), @@ -462,28 +463,28 @@ defmodule QuickBEAM.VM.Interpreter do defp complete_call_result( _value, - %ObjectAssignBoundary{phase: :set} = boundary, + %Boundary.ObjectAssign{phase: :set} = boundary, execution, _tail? ), do: continue_object_assign(%{boundary | phase: nil, key: nil}, execution) - defp complete_call_result(value, %ThenGetterBoundary{} = boundary, execution, _tail?), + defp complete_call_result(value, %Boundary.ThenGetter{} = boundary, execution, _tail?), do: complete_then_getter(value, boundary, execution) - defp complete_call_result(value, %AccessorBoundary{} = boundary, execution, _tail?), + defp complete_call_result(value, %Boundary.Accessor{} = boundary, execution, _tail?), do: complete_accessor(value, boundary, execution) - defp complete_call_result(value, %ConstructorBoundary{} = boundary, execution, _tail?), + defp complete_call_result(value, %Boundary.Constructor{} = boundary, execution, _tail?), do: complete_constructor(value, boundary, execution) - defp complete_call_result(_value, %PromiseExecutorBoundary{} = boundary, execution, _tail?), + defp complete_call_result(_value, %Boundary.PromiseExecutor{} = boundary, execution, _tail?), do: complete_executor(boundary, execution) - defp complete_call_result(_value, %ThenableBoundary{}, execution, _tail?), + defp complete_call_result(_value, %Boundary.Thenable{}, execution, _tail?), do: {:idle, execution} - defp complete_call_result(value, %NativeFrame{} = native, execution, _tail?), + defp complete_call_result(value, %Native{} = native, execution, _tail?), do: resume_native(value, native, execution) defp complete_call_result(value, caller, execution, tail?) do @@ -495,16 +496,19 @@ defmodule QuickBEAM.VM.Interpreter do defp complete_then_getter(value, boundary, execution), do: value |> Async.complete_then_getter(boundary, execution) |> execute_async() - defp continue_after_then_getter(%ThenGetterBoundary{continuation: %Frame{} = frame}, execution), - do: run(frame, execution) + defp continue_after_then_getter( + %Boundary.ThenGetter{continuation: %Frame{} = frame}, + execution + ), + do: run(frame, execution) defp continue_after_then_getter( - %ThenGetterBoundary{continuation: %IteratorBoundary{} = boundary}, + %Boundary.ThenGetter{continuation: %Boundary.Iterator{} = boundary}, execution ), do: continue_iterator_sync(boundary, execution) - defp continue_after_then_getter(%ThenGetterBoundary{continuation: nil}, execution), + defp continue_after_then_getter(%Boundary.ThenGetter{continuation: nil}, execution), do: {:idle, execution} defp continue_iterator_sync(boundary, execution) do @@ -521,10 +525,10 @@ defmodule QuickBEAM.VM.Interpreter do end end - defp complete_accessor(value, %AccessorBoundary{mode: :get} = boundary, execution), + defp complete_accessor(value, %Boundary.Accessor{mode: :get} = boundary, execution), do: run(%{boundary.caller | stack: [value | boundary.caller.stack]}, execution) - defp complete_accessor(_value, %AccessorBoundary{mode: :set} = boundary, execution), + defp complete_accessor(_value, %Boundary.Accessor{mode: :set} = boundary, execution), do: run(boundary.caller, execution) defp complete_constructor(value, boundary, execution) do @@ -548,8 +552,8 @@ defmodule QuickBEAM.VM.Interpreter do defp complete_reaction(boundary, value, execution), do: boundary |> Async.complete_reaction(value, execution) |> execute_async() - defp continue_object_assign(%ObjectAssignBoundary{keys: [key | keys]} = boundary, execution) do - case Properties.get(boundary.source, key, execution) do + 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) @@ -563,10 +567,10 @@ defmodule QuickBEAM.VM.Interpreter do end defp continue_object_assign( - %ObjectAssignBoundary{keys: [], sources: [source | sources]} = boundary, + %Boundary.ObjectAssign{keys: [], sources: [source | sources]} = boundary, execution ) do - case Properties.assignable_keys(source, 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}, @@ -578,11 +582,14 @@ defmodule QuickBEAM.VM.Interpreter do end end - defp continue_object_assign(%ObjectAssignBoundary{keys: [], sources: []} = boundary, execution), - do: complete_call_result(boundary.target, boundary.caller, execution, boundary.tail?) + 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 Properties.put(boundary.target, key, value, execution) do + case Property.put(boundary.target, key, value, execution) do {:ok, execution} -> continue_object_assign(%{boundary | phase: nil, key: nil}, execution) @@ -609,7 +616,7 @@ defmodule QuickBEAM.VM.Interpreter do "reduce" -> :reduce end - native = %NativeFrame{ + native = %Native{ operation: operation, values: values, callback: callback, @@ -631,17 +638,17 @@ defmodule QuickBEAM.VM.Interpreter do end end - defp initialize_native(%NativeFrame{operation: :reduce} = native, [initial | _]), + defp initialize_native(%Native{operation: :reduce} = native, [initial | _]), do: {:ok, %{native | accumulator: initial}} - defp initialize_native(%NativeFrame{operation: :reduce} = native, []) do + 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(%NativeFrame{} = native, _arguments), do: {:ok, native} + defp initialize_native(%Native{} = native, _arguments), do: {:ok, native} defp next_present(values, index) when index >= tuple_size(values), do: :none @@ -652,12 +659,12 @@ defmodule QuickBEAM.VM.Interpreter do end end - defp invoke_native_next(%NativeFrame{} = native, execution) + 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(%NativeFrame{} = native, execution) do + 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 @@ -673,7 +680,7 @@ defmodule QuickBEAM.VM.Interpreter do end end - defp resume_native(value, %NativeFrame{} = native, execution) do + defp resume_native(value, %Native{} = native, execution) do {:present, current} = elem(native.values, native.index) native = @@ -708,17 +715,17 @@ defmodule QuickBEAM.VM.Interpreter do else: invoke_native_next(native, execution) end - defp native_result(%NativeFrame{operation: operation, results: results}, execution) + 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(%NativeFrame{operation: :for_each}, execution), + defp native_result(%Native{operation: :for_each}, execution), do: {:value, :undefined, execution} - defp native_result(%NativeFrame{operation: :some}, execution), do: {:value, false, execution} + defp native_result(%Native{operation: :some}, execution), do: {:value, false, execution} - defp native_result(%NativeFrame{operation: :reduce, accumulator: value}, execution), + defp native_result(%Native{operation: :reduce, accumulator: value}, execution), do: {:value, value, execution} defp finish_native(native, {:value, value, execution}, _old_execution) do @@ -735,14 +742,14 @@ defmodule QuickBEAM.VM.Interpreter do |> Enum.with_index() |> Enum.reduce(execution, fn {{:present, value}, index}, execution -> - {:ok, execution} = Properties.define(array, index, value, execution) + {:ok, execution} = Property.define(array, index, value, execution) execution {:hole, _index}, execution -> execution end) - {:ok, execution} = Properties.define(array, "length", length(entries), execution) + {:ok, execution} = Property.define(array, "length", length(entries), execution) {:value, array, execution} end @@ -751,7 +758,7 @@ defmodule QuickBEAM.VM.Interpreter do defp interpreter_array_values(%Reference{} = reference, execution) do case Heap.fetch_object(execution, reference) do - {:ok, %QuickBEAM.VM.Object{kind: :array} = object} -> + {:ok, %QuickBEAM.VM.Runtime.Object{kind: :array} = object} -> {:ok, Heap.array_entries(object)} _ -> @@ -778,7 +785,7 @@ defmodule QuickBEAM.VM.Interpreter do do: dispatch_call(callable, arguments, this, next_frame(caller), execution, tail?) defp execute_opcode({:invoke_constructor, constructor, arguments, instance, caller, execution}) do - boundary = %ConstructorBoundary{ + boundary = %Boundary.Constructor{ instance: instance, caller: next_frame(caller), depth: execution.depth @@ -790,7 +797,7 @@ defmodule QuickBEAM.VM.Interpreter do defp execute_opcode( {:invoke_super_constructor, constructor, arguments, instance, caller, execution} ) do - boundary = %ConstructorBoundary{ + boundary = %Boundary.Constructor{ instance: instance, caller: next_frame(caller), depth: execution.depth @@ -820,7 +827,7 @@ defmodule QuickBEAM.VM.Interpreter do do: await_immediate(result, frame, execution) defp execute_opcode({:invoke_getter, getter, receiver, frame, execution}) do - boundary = %AccessorBoundary{ + boundary = %Boundary.Accessor{ mode: :get, caller: next_frame(frame), depth: execution.depth @@ -830,7 +837,7 @@ defmodule QuickBEAM.VM.Interpreter do end defp execute_opcode({:invoke_setter, setter, value, object, frame, execution}) do - boundary = %AccessorBoundary{ + boundary = %Boundary.Accessor{ mode: :set, caller: next_frame(frame), depth: execution.depth @@ -908,7 +915,7 @@ defmodule QuickBEAM.VM.Interpreter do end defp build_host_template(profile) do - execution = %Execution{ + execution = %State{ atoms: {}, max_stack_depth: 1, remaining_steps: 1, @@ -919,11 +926,11 @@ defmodule QuickBEAM.VM.Interpreter do end defp build_host_globals(execution, profile) do - execution = Builtins.install(execution, profile) + execution = BuiltinRuntime.install(execution, profile) {beam, execution} = Heap.allocate(execution) {:ok, execution} = - Properties.define(beam, "call", {:host_function, :beam_call}, execution) + Property.define(beam, "call", {:host_function, :beam_call}, execution) {global_this, execution} = Heap.allocate(execution, :ordinary, internal: :global_object) @@ -955,10 +962,10 @@ defmodule QuickBEAM.VM.Interpreter do end defp raise_js(reason, frame, execution), - do: reason |> Exceptions.throw_at(frame, execution) |> execute_exception() + do: reason |> Exception.throw_at(frame, execution) |> execute_exception() defp raise_js_from_caller(reason, caller, execution), - do: reason |> Exceptions.throw_from(caller, execution) |> execute_exception() + do: reason |> Exception.throw_from(caller, execution) |> execute_exception() defp execute_exception({:run, frame, execution}), do: run(frame, execution) @@ -974,7 +981,7 @@ defmodule QuickBEAM.VM.Interpreter do defp complete_async( value, - %Execution{callers: [%AsyncBoundary{} = boundary | callers]} = execution + %State{callers: [%Boundary.Async{} = boundary | callers]} = execution ) do execution = %{execution | callers: callers, depth: boundary.depth} boundary |> Async.complete({:ok, value}, execution) |> execute_async() @@ -982,15 +989,15 @@ defmodule QuickBEAM.VM.Interpreter do defp complete_async(value, execution), do: return_value(value, execution) - defp return_value(value, %Execution{callers: []} = execution), + defp return_value(value, %State{callers: []} = execution), do: {:ok, value, %{execution | depth: 0}} - defp return_value(value, %Execution{callers: [%AsyncBoundary{} | _]} = execution), + defp return_value(value, %State{callers: [%Boundary.Async{} | _]} = execution), do: complete_async(value, execution) defp return_value( value, - %Execution{callers: [%ObjectAssignBoundary{} = boundary | callers]} = execution + %State{callers: [%Boundary.ObjectAssign{} = boundary | callers]} = execution ) do execution = %{execution | callers: callers, depth: boundary.depth} complete_call_result(value, boundary, execution, false) @@ -998,7 +1005,7 @@ defmodule QuickBEAM.VM.Interpreter do defp return_value( value, - %Execution{callers: [%ThenGetterBoundary{} = boundary | callers]} = execution + %State{callers: [%Boundary.ThenGetter{} = boundary | callers]} = execution ) do execution = %{execution | callers: callers, depth: boundary.depth} complete_then_getter(value, boundary, execution) @@ -1006,7 +1013,7 @@ defmodule QuickBEAM.VM.Interpreter do defp return_value( value, - %Execution{callers: [%AccessorBoundary{} = boundary | callers]} = execution + %State{callers: [%Boundary.Accessor{} = boundary | callers]} = execution ) do execution = %{execution | callers: callers, depth: boundary.depth} complete_accessor(value, boundary, execution) @@ -1014,7 +1021,7 @@ defmodule QuickBEAM.VM.Interpreter do defp return_value( value, - %Execution{callers: [%ConstructorBoundary{} = boundary | callers]} = execution + %State{callers: [%Boundary.Constructor{} = boundary | callers]} = execution ) do execution = %{execution | callers: callers, depth: boundary.depth} complete_constructor(value, boundary, execution) @@ -1022,7 +1029,7 @@ defmodule QuickBEAM.VM.Interpreter do defp return_value( value, - %Execution{callers: [%IteratorBoundary{} = boundary | callers]} = execution + %State{callers: [%Boundary.Iterator{} = boundary | callers]} = execution ) do execution = %{execution | callers: callers, depth: boundary.depth} value |> Iterator.resume(boundary, execution) |> execute_invocation() @@ -1030,7 +1037,7 @@ defmodule QuickBEAM.VM.Interpreter do defp return_value( value, - %Execution{callers: [%ReactionBoundary{} = boundary | callers]} = execution + %State{callers: [%Boundary.Reaction{} = boundary | callers]} = execution ) do execution = %{execution | callers: callers, depth: boundary.depth} complete_reaction(boundary, value, execution) @@ -1038,7 +1045,7 @@ defmodule QuickBEAM.VM.Interpreter do defp return_value( _value, - %Execution{callers: [%PromiseExecutorBoundary{} = boundary | callers]} = execution + %State{callers: [%Boundary.PromiseExecutor{} = boundary | callers]} = execution ) do execution = %{execution | callers: callers, depth: boundary.depth} complete_executor(boundary, execution) @@ -1046,17 +1053,17 @@ defmodule QuickBEAM.VM.Interpreter do defp return_value( _value, - %Execution{callers: [%ThenableBoundary{} = boundary | callers]} = execution + %State{callers: [%Boundary.Thenable{} = boundary | callers]} = execution ) do {:idle, %{execution | callers: callers, depth: boundary.depth}} end - defp return_value(value, %Execution{callers: [%NativeFrame{} = native | callers]} = execution) do + 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, %Execution{callers: [%Frame{} = caller | callers]} = execution) do + 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 diff --git a/lib/quickbeam/vm/invocation.ex b/lib/quickbeam/vm/runtime/invocation.ex similarity index 77% rename from lib/quickbeam/vm/invocation.ex rename to lib/quickbeam/vm/runtime/invocation.ex index bfb28a8de..44aed965d 100644 --- a/lib/quickbeam/vm/invocation.ex +++ b/lib/quickbeam/vm/runtime/invocation.ex @@ -1,4 +1,4 @@ -defmodule QuickBEAM.VM.Invocation do +defmodule QuickBEAM.VM.Runtime.Invocation do @moduledoc """ Classifies every JavaScript invocation through one canonical call boundary. @@ -8,38 +8,37 @@ defmodule QuickBEAM.VM.Invocation do owns frame scheduling, exception unwinding, and resumable native boundaries. """ - alias QuickBEAM.VM.{ - Builtin, - Builtins, - ConstructorBoundary, - Execution, - Frame, - Function, - Promise, - Properties, - Reference, - Value - } - - alias QuickBEAM.VM.Builtin.{Action, Call} + alias QuickBEAM.VM.Builtin + alias QuickBEAM.VM.Builtin.Runtime, as: BuiltinRuntime + alias QuickBEAM.VM.Runtime.Boundary + alias QuickBEAM.VM.Runtime.State + alias QuickBEAM.VM.Runtime.Frame + alias QuickBEAM.VM.Program.Function + alias QuickBEAM.VM.Runtime.Promise + alias QuickBEAM.VM.Runtime.Property + alias QuickBEAM.VM.Runtime.Reference + alias QuickBEAM.VM.Runtime.Value + + alias QuickBEAM.VM.Builtin.Action + alias QuickBEAM.VM.Builtin.Call @builtin_tags [:builtin, :declared_builtin, :primitive_method] @type action :: - {:dispatch, term(), [term()], term(), term(), Execution.t(), boolean()} - | {:enter, Function.t(), term(), tuple(), [term()], term(), term(), Execution.t(), + {:dispatch, term(), [term()], term(), term(), State.t(), boolean()} + | {:enter, Function.t(), term(), tuple(), [term()], term(), term(), State.t(), boolean()} - | {:complete, term(), term(), Execution.t(), boolean()} - | {:error, term(), term(), Execution.t()} - | {:host_call, [term()], term(), Execution.t(), boolean()} - | {:object_assign, Reference.t(), [term()], term(), Execution.t(), boolean()} - | {:array_iteration, String.t(), term(), [term()], term(), Execution.t(), boolean()} - | {:promise_iterate, atom(), term(), term(), Execution.t(), boolean()} - | {:set_iterate, Reference.t(), term(), term(), Execution.t(), boolean()} - | {:iterator_value, term(), QuickBEAM.VM.IteratorBoundary.t(), Execution.t()} + | {: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(), Execution.t(), boolean()) :: action() + @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?), @@ -84,7 +83,7 @@ defmodule QuickBEAM.VM.Invocation do {:bound_function, target, _bound_this, bound_arguments}, arguments, this, - %ConstructorBoundary{} = caller, + %Boundary.Constructor{} = caller, execution, tail? ), @@ -101,7 +100,7 @@ defmodule QuickBEAM.VM.Invocation do do: {:dispatch, target, bound_arguments ++ arguments, bound_this, caller, execution, tail?} def plan(%Reference{} = reference, arguments, this, caller, execution, tail?) do - case Builtins.callable(execution, reference) do + case BuiltinRuntime.callable(execution, reference) do nil -> {:error, {:not_callable, reference}, caller, execution} @@ -126,7 +125,7 @@ defmodule QuickBEAM.VM.Invocation do def plan(callable, arguments, this, caller, execution, tail?) when is_tuple(callable) and elem(callable, 0) in @builtin_tags do - case Builtins.call(callable, this, arguments, execution) 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 @@ -136,14 +135,14 @@ defmodule QuickBEAM.VM.Invocation do 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(), Execution.t()) :: boolean() + @spec constructable?(term(), State.t()) :: boolean() def constructable?(%Reference{} = constructor, execution) do - case QuickBEAM.VM.Heap.fetch_object(execution, constructor) do + case QuickBEAM.VM.Runtime.Heap.fetch_object(execution, constructor) do {:ok, %{internal: :class_constructor}} -> true _other -> - case Builtins.callable(execution, constructor) do + case BuiltinRuntime.callable(execution, constructor) do nil -> false callable -> constructable?(callable, execution) end @@ -164,33 +163,33 @@ defmodule QuickBEAM.VM.Invocation do def constructable?(_constructor, _execution), do: false @doc "Returns the object prototype used for a constructor allocation." - @spec constructor_prototype(term(), Execution.t()) :: Reference.t() | nil + @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 Properties.get(constructor, "prototype", 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(), Execution.t()) :: Properties.get_result() + @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: Properties.get(constructor, "prototype", execution) + do: Property.get(constructor, "prototype", execution) @doc "Returns whether a VM value is callable by JavaScript." - @spec callable?(term(), Execution.t()) :: boolean() + @spec callable?(term(), State.t()) :: boolean() def callable?(value, execution), do: typeof(value, execution) == "function" @doc "Returns the JavaScript `typeof` classification for a VM value." - @spec typeof(term(), Execution.t()) :: String.t() + @spec typeof(term(), State.t()) :: String.t() def typeof(%Reference{} = reference, execution) do - if Builtins.callable(execution, reference), do: "function", else: "object" + if BuiltinRuntime.callable(execution, reference), do: "function", else: "object" end def typeof(value, _execution) diff --git a/lib/quickbeam/vm/iterator.ex b/lib/quickbeam/vm/runtime/iterator.ex similarity index 74% rename from lib/quickbeam/vm/iterator.ex rename to lib/quickbeam/vm/runtime/iterator.ex index a8ba6f196..35eef4877 100644 --- a/lib/quickbeam/vm/iterator.ex +++ b/lib/quickbeam/vm/runtime/iterator.ex @@ -1,4 +1,4 @@ -defmodule QuickBEAM.VM.Iterator do +defmodule QuickBEAM.VM.Runtime.Iterator do @moduledoc """ Implements the canonical iterable-value boundary used by Promise combinators. @@ -8,24 +8,22 @@ defmodule QuickBEAM.VM.Iterator do `value`. No JavaScript call is executed recursively. """ - alias QuickBEAM.VM.{ - Exceptions, - Execution, - Heap, - Invocation, - IteratorBoundary, - Object, - Promise, - Properties, - Reference, - Symbol, - Value - } + alias QuickBEAM.VM.Runtime.Exception + alias QuickBEAM.VM.Runtime.State + alias QuickBEAM.VM.Runtime.Heap + alias QuickBEAM.VM.Runtime.Invocation + alias QuickBEAM.VM.Runtime.Boundary + alias QuickBEAM.VM.Runtime.Object + alias QuickBEAM.VM.Runtime.Promise + alias QuickBEAM.VM.Runtime.Property + alias QuickBEAM.VM.Runtime.Reference + alias QuickBEAM.VM.Runtime.Symbol + alias QuickBEAM.VM.Runtime.Value @type action :: Invocation.action() @doc "Collects values from an iterable whose protocol requires no JavaScript calls." - @spec values(term(), Execution.t()) :: {:ok, [term()]} | {:resumable} | {:error, :not_iterable} + @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)} @@ -57,11 +55,11 @@ defmodule QuickBEAM.VM.Iterator do 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(), Execution.t(), boolean()) :: action() + @spec start(atom(), term(), term(), State.t(), boolean()) :: action() def start(kind, iterable, caller, execution, tail?) do {promise, execution} = Promise.new(execution) - boundary = %IteratorBoundary{ + boundary = %Boundary.Iterator{ consumer: :promise, kind: kind, promise: promise, @@ -75,9 +73,9 @@ defmodule QuickBEAM.VM.Iterator do end @doc "Starts resumable consumption for a Set constructor." - @spec start_set(Reference.t(), term(), term(), Execution.t(), boolean()) :: action() + @spec start_set(Reference.t(), term(), term(), State.t(), boolean()) :: action() def start_set(target, iterable, caller, execution, tail?) do - boundary = %IteratorBoundary{ + boundary = %Boundary.Iterator{ consumer: :set, target: target, iterable: iterable, @@ -90,11 +88,11 @@ defmodule QuickBEAM.VM.Iterator do end @doc "Resumes iterator consumption after one getter or method invocation." - @spec resume(term(), IteratorBoundary.t(), Execution.t()) :: action() - def resume(value, %IteratorBoundary{phase: :iterator_getter} = boundary, execution), + @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, %IteratorBoundary{phase: :iterator_factory} = boundary, execution) do + def resume(iterator, %Boundary.Iterator{phase: :iterator_factory} = boundary, execution) do if object?(iterator) do read_next(%{boundary | iterator: iterator, phase: nil}, execution) else @@ -102,10 +100,10 @@ defmodule QuickBEAM.VM.Iterator do end end - def resume(value, %IteratorBoundary{phase: :next_getter} = boundary, execution), + def resume(value, %Boundary.Iterator{phase: :next_getter} = boundary, execution), do: invoke_next(%{boundary | phase: nil}, value, execution) - def resume(result, %IteratorBoundary{phase: :next_call} = boundary, execution) do + def resume(result, %Boundary.Iterator{phase: :next_call} = boundary, execution) do if object?(result) do read_done(%{boundary | result: result, phase: nil}, execution) else @@ -113,32 +111,32 @@ defmodule QuickBEAM.VM.Iterator do end end - def resume(done, %IteratorBoundary{phase: :done_getter} = boundary, execution), + def resume(done, %Boundary.Iterator{phase: :done_getter} = boundary, execution), do: after_done(%{boundary | phase: nil}, done, execution) - def resume(value, %IteratorBoundary{phase: :value_getter} = boundary, 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(IteratorBoundary.t(), Execution.t()) :: action() - def continue(%IteratorBoundary{} = boundary, execution), + @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(IteratorBoundary.t(), term(), Execution.t()) :: action() - def reject(%IteratorBoundary{consumer: :promise} = boundary, reason, execution), + @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(%IteratorBoundary{consumer: :set} = 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(IteratorBoundary.t(), term(), Execution.t()) :: action() - def fail(%IteratorBoundary{} = boundary, reason, execution), + @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 Properties.get(boundary.iterable, Symbol.iterator(), execution) do + case Property.get(boundary.iterable, Symbol.iterator(), execution) do {:ok, {:accessor, getter, receiver}} -> dispatch_getter(boundary, :iterator_getter, getter, receiver, execution) @@ -159,7 +157,7 @@ defmodule QuickBEAM.VM.Iterator do end defp read_next(boundary, execution) do - case Properties.get(boundary.iterator, "next", execution) do + case Property.get(boundary.iterator, "next", execution) do {:ok, {:accessor, getter, receiver}} -> dispatch_getter(boundary, :next_getter, getter, receiver, execution) @@ -183,7 +181,7 @@ defmodule QuickBEAM.VM.Iterator do do: dispatch(boundary, :next_call, boundary.next, [], boundary.iterator, execution) defp read_done(boundary, execution) do - case Properties.get(boundary.result, "done", execution) do + case Property.get(boundary.result, "done", execution) do {:ok, {:accessor, getter, receiver}} -> dispatch_getter(boundary, :done_getter, getter, receiver, execution) @@ -203,16 +201,16 @@ defmodule QuickBEAM.VM.Iterator do end end - defp finish(%IteratorBoundary{consumer: :promise} = boundary, execution) do + 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(%IteratorBoundary{consumer: :set} = boundary, execution) do + defp finish(%Boundary.Iterator{consumer: :set} = boundary, execution) do values = Enum.reverse(boundary.values) - case QuickBEAM.VM.Builtins.Set.initialize(boundary.target, values, execution) do + case QuickBEAM.VM.Builtin.Set.initialize(boundary.target, values, execution) do {:ok, execution} -> {:complete, boundary.target, boundary.caller, execution, boundary.tail?} @@ -222,7 +220,7 @@ defmodule QuickBEAM.VM.Iterator do end defp read_value(boundary, execution) do - case Properties.get(boundary.result, "value", execution) do + case Property.get(boundary.result, "value", execution) do {:ok, {:accessor, getter, receiver}} -> dispatch_getter(boundary, :value_getter, getter, receiver, execution) @@ -235,7 +233,7 @@ defmodule QuickBEAM.VM.Iterator do end defp reject_promise(boundary, reason, execution) do - {reason, execution} = Exceptions.materialize(reason, execution) + {reason, execution} = Exception.materialize(reason, execution) execution = Promise.settle(execution, boundary.promise, {:error, reason}) complete(boundary, execution) end diff --git a/lib/quickbeam/vm/memory.ex b/lib/quickbeam/vm/runtime/memory.ex similarity index 75% rename from lib/quickbeam/vm/memory.ex rename to lib/quickbeam/vm/runtime/memory.ex index 091954143..834fa710f 100644 --- a/lib/quickbeam/vm/memory.ex +++ b/lib/quickbeam/vm/runtime/memory.ex @@ -1,4 +1,4 @@ -defmodule QuickBEAM.VM.Memory do +defmodule QuickBEAM.VM.Runtime.Memory do @moduledoc """ Performs conservative logical memory accounting for VM allocations. @@ -6,15 +6,17 @@ defmodule QuickBEAM.VM.Memory do controlled limit failures before the worker's process heap ceiling. """ - alias QuickBEAM.VM.{Execution, Object} + alias QuickBEAM.VM.Runtime.State + alias QuickBEAM.VM.Runtime.Object @object_bytes 128 @property_bytes 64 @cell_bytes 32 @promise_bytes 64 - @spec charge(Execution.t(), non_neg_integer()) :: Execution.t() - def charge(%Execution{} = execution, bytes) when is_integer(bytes) and bytes >= 0 do + @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 = @@ -24,23 +26,29 @@ defmodule QuickBEAM.VM.Memory do %{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.Reference{}), do: 16 - def estimate(%QuickBEAM.VM.PromiseReference{}), do: 16 - def estimate(%QuickBEAM.VM.Function{}), 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 + diff --git a/lib/quickbeam/vm/object.ex b/lib/quickbeam/vm/runtime/object.ex similarity index 63% rename from lib/quickbeam/vm/object.ex rename to lib/quickbeam/vm/runtime/object.ex index 70b98ddf0..e89518207 100644 --- a/lib/quickbeam/vm/object.ex +++ b/lib/quickbeam/vm/runtime/object.ex @@ -1,9 +1,9 @@ -defmodule QuickBEAM.VM.Object do +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.Property` structs. + non-default data descriptors retain full `QuickBEAM.VM.Runtime.Property.Descriptor` structs. Callers must use the descriptor helpers instead of depending on either layout. """ @@ -18,10 +18,10 @@ defmodule QuickBEAM.VM.Object do internal: nil @type kind :: :ordinary | :array | :function | :map | :promise | :regexp | :set - @type stored_property :: QuickBEAM.VM.Property.t() | {term()} + @type stored_property :: QuickBEAM.VM.Runtime.Property.Descriptor.t() | {term()} @type t :: %__MODULE__{ kind: kind(), - prototype: QuickBEAM.VM.Reference.t() | nil, + prototype: QuickBEAM.VM.Runtime.Reference.t() | nil, properties: %{optional(term()) => stored_property()}, property_order: [term()], extensible: boolean(), @@ -32,13 +32,16 @@ defmodule QuickBEAM.VM.Object do } @doc "Expands a compact default data property into its canonical descriptor." - @spec property_descriptor(stored_property() | nil) :: QuickBEAM.VM.Property.t() | nil - def property_descriptor({value}), do: %QuickBEAM.VM.Property{value: value} - def property_descriptor(%QuickBEAM.VM.Property{} = property), do: property + @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.Property{enumerable: enumerable}), do: enumerable + + def property_enumerable?(%QuickBEAM.VM.Runtime.Property.Descriptor{enumerable: enumerable}), + do: enumerable end diff --git a/lib/quickbeam/vm/opcodes/control.ex b/lib/quickbeam/vm/runtime/opcode/control.ex similarity index 82% rename from lib/quickbeam/vm/opcodes/control.ex rename to lib/quickbeam/vm/runtime/opcode/control.ex index 37d8e9787..43eb8c5eb 100644 --- a/lib/quickbeam/vm/opcodes/control.ex +++ b/lib/quickbeam/vm/runtime/opcode/control.ex @@ -1,4 +1,4 @@ -defmodule QuickBEAM.VM.Opcodes.Control do +defmodule QuickBEAM.VM.Runtime.Opcode.Control do @moduledoc """ Executes branch, catch, return, throw, and await opcode families. @@ -7,7 +7,10 @@ defmodule QuickBEAM.VM.Opcodes.Control do interpreter and their canonical semantic layers. """ - alias QuickBEAM.VM.{Execution, Frame, PromiseReference, Value} + alias QuickBEAM.VM.Runtime.State + alias QuickBEAM.VM.Runtime.Frame + alias QuickBEAM.VM.Runtime.Promise.Reference, as: PromiseReference + alias QuickBEAM.VM.Runtime.Value @opcodes [ :catch, @@ -28,21 +31,21 @@ defmodule QuickBEAM.VM.Opcodes.Control do ] @type action :: - {:next, Frame.t(), Execution.t()} - | {:run, Frame.t(), Execution.t()} - | {:return, term(), Execution.t()} - | {:return_async, term(), Execution.t()} - | {:throw, term(), Frame.t(), Execution.t()} - | {:await_promise, PromiseReference.t(), Frame.t(), Execution.t()} - | {:await_legacy, reference(), Frame.t(), Execution.t()} - | {:await_immediate, {:ok, term()} | {:error, term()}, Frame.t(), Execution.t()} + {: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(), Execution.t()) :: action() + @spec execute(atom(), [term()], Frame.t(), State.t()) :: action() def execute(:catch, [target], frame, execution), do: next(%{frame | stack: [{:catch, target} | frame.stack]}, execution) diff --git a/lib/quickbeam/vm/opcodes/invocation.ex b/lib/quickbeam/vm/runtime/opcode/invocation.ex similarity index 87% rename from lib/quickbeam/vm/opcodes/invocation.ex rename to lib/quickbeam/vm/runtime/opcode/invocation.ex index 702004935..6649394db 100644 --- a/lib/quickbeam/vm/opcodes/invocation.ex +++ b/lib/quickbeam/vm/runtime/opcode/invocation.ex @@ -1,4 +1,4 @@ -defmodule QuickBEAM.VM.Opcodes.Invocation do +defmodule QuickBEAM.VM.Runtime.Opcode.Invocation do @moduledoc """ Executes ordinary, method, tail, and constructor call opcodes. @@ -8,7 +8,11 @@ defmodule QuickBEAM.VM.Opcodes.Invocation do continuation frames and executing invocation actions. """ - alias QuickBEAM.VM.{Execution, Frame, Heap, Invocation, Iterator} + alias QuickBEAM.VM.Runtime.State + alias QuickBEAM.VM.Runtime.Frame + alias QuickBEAM.VM.Runtime.Heap + alias QuickBEAM.VM.Runtime.Invocation + alias QuickBEAM.VM.Runtime.Iterator @opcodes [ :apply, @@ -22,22 +26,22 @@ defmodule QuickBEAM.VM.Opcodes.Invocation do ] @type action :: - {:invoke, term(), [term()], term(), Frame.t(), Execution.t(), boolean()} - | {:invoke_constructor, term(), [term()], term(), Frame.t(), Execution.t()} - | {:invoke_super_constructor, term(), [term()], term(), Frame.t(), Execution.t()} - | {:throw, term(), Frame.t(), Execution.t()} - | {:error, term(), Execution.t()} + {: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(), Execution.t()) :: action() + @spec execute(atom(), [term()], Frame.t(), State.t()) :: action() def execute(:check_ctor, [], %Frame{callable: callable, this: this} = frame, execution) do - with %QuickBEAM.VM.Reference{} <- callable, + with %QuickBEAM.VM.Runtime.Reference{} <- callable, {:ok, %{internal: :class_constructor}} <- Heap.fetch_object(execution, callable), - %QuickBEAM.VM.Reference{} <- this, + %QuickBEAM.VM.Runtime.Reference{} <- this, {:ok, %{internal: :constructor_instance}} <- Heap.fetch_object(execution, this) do {:next, frame, execution} else @@ -111,7 +115,7 @@ defmodule QuickBEAM.VM.Opcodes.Invocation do def execute( :init_ctor, [], - %Frame{callable: %QuickBEAM.VM.Reference{} = callable} = frame, + %Frame{callable: %QuickBEAM.VM.Runtime.Reference{} = callable} = frame, execution ) do with {:ok, parent} <- Heap.prototype(execution, callable), diff --git a/lib/quickbeam/vm/opcodes/locals.ex b/lib/quickbeam/vm/runtime/opcode/local.ex similarity index 90% rename from lib/quickbeam/vm/opcodes/locals.ex rename to lib/quickbeam/vm/runtime/opcode/local.ex index c00596c53..125e4d0f1 100644 --- a/lib/quickbeam/vm/opcodes/locals.ex +++ b/lib/quickbeam/vm/runtime/opcode/local.ex @@ -1,4 +1,4 @@ -defmodule QuickBEAM.VM.Opcodes.Locals do +defmodule QuickBEAM.VM.Runtime.Opcode.Local do @moduledoc """ Executes argument, local, closure-cell, function-closure, and global opcodes. @@ -6,16 +6,14 @@ defmodule QuickBEAM.VM.Opcodes.Locals do The module returns explicit frame actions and never owns interpreter stepping. """ - alias QuickBEAM.VM.{ - Execution, - Frame, - Function, - Heap, - Memory, - PredefinedAtoms, - Properties, - Value - } + alias QuickBEAM.VM.Runtime.State + alias QuickBEAM.VM.Runtime.Frame + alias QuickBEAM.VM.Program.Function + alias QuickBEAM.VM.Runtime.Heap + alias QuickBEAM.VM.Runtime.Memory + alias QuickBEAM.VM.Bytecode.Atom, as: AtomTable + alias QuickBEAM.VM.Runtime.Property + alias QuickBEAM.VM.Runtime.Value @get_reference_ops [ :get_var_ref, @@ -83,15 +81,15 @@ defmodule QuickBEAM.VM.Opcodes.Locals do ] ++ @get_reference_ops ++ @put_reference_ops @type action :: - {:next, Frame.t(), Execution.t()} - | {:throw, term(), Frame.t(), Execution.t()} + {: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(), Execution.t()) :: action() + @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)) @@ -105,14 +103,14 @@ defmodule QuickBEAM.VM.Opcodes.Locals do values |> Enum.with_index() |> Enum.reduce(execution, fn {value, index}, execution -> - {:ok, execution} = Properties.define(array, index, value, execution) + {:ok, execution} = Property.define(array, index, value, execution) execution end) push(frame, execution, array) end - def execute(name, operands, %Frame{} = frame, %Execution{} = execution) + 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) @@ -187,8 +185,8 @@ defmodule QuickBEAM.VM.Opcodes.Locals do end @doc "Instantiates a function constant and captures its owner-local closure cells." - @spec instantiate_function(Function.t(), Frame.t(), Execution.t(), keyword()) :: - {Reference.t(), Frame.t(), Execution.t()} + @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) @@ -203,8 +201,8 @@ defmodule QuickBEAM.VM.Opcodes.Locals do end @doc "Executes a verified local/argument operation over compact frame fields." - @spec execute_compact(atom(), [term()], tuple(), tuple(), [term()], Execution.t()) :: - {:ok, tuple(), tuple(), [term()], Execution.t()} + @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} @@ -269,18 +267,18 @@ defmodule QuickBEAM.VM.Opcodes.Locals do 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(), Execution.t()) :: term() + @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(), Execution.t()) :: term() + @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: PredefinedAtoms.lookup(index) || {:predefined, index} + 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), @@ -291,11 +289,11 @@ defmodule QuickBEAM.VM.Opcodes.Locals do def resolve_atom(value, _execution), do: value @doc "Reads a resolved global name through the canonical global-object fallback." - @spec read_global(Execution.t(), term()) :: {:ok, term()} | :error + @spec read_global(State.t(), term()) :: {:ok, term()} | :error def read_global(execution, name) do case Map.get(execution.globals, "globalThis") do - %QuickBEAM.VM.Reference{} = global_this -> - case Properties.get(global_this, name, execution) 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) @@ -307,13 +305,13 @@ defmodule QuickBEAM.VM.Opcodes.Locals do end @doc "Writes a resolved global name and its canonical global-object property." - @spec write_global(Execution.t(), term(), term()) :: Execution.t() + @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.Reference{} = global_this -> - case Properties.put(global_this, name, value, execution) do + %QuickBEAM.VM.Runtime.Reference{} = global_this -> + case Property.put(global_this, name, value, execution) do {:ok, execution} -> execution {:error, _reason} -> execution end @@ -330,13 +328,13 @@ defmodule QuickBEAM.VM.Opcodes.Locals do {prototype, execution} = Heap.allocate(execution) {:ok, execution} = - Properties.define(prototype, "constructor", reference, execution, + Property.define(prototype, "constructor", reference, execution, enumerable: false, configurable: true ) {:ok, execution} = - Properties.define(reference, "prototype", prototype, execution, + Property.define(reference, "prototype", prototype, execution, enumerable: false, configurable: false ) diff --git a/lib/quickbeam/vm/opcodes/objects.ex b/lib/quickbeam/vm/runtime/opcode/object.ex similarity index 85% rename from lib/quickbeam/vm/opcodes/objects.ex rename to lib/quickbeam/vm/runtime/opcode/object.ex index 259224a5d..7276fddc6 100644 --- a/lib/quickbeam/vm/opcodes/objects.ex +++ b/lib/quickbeam/vm/runtime/opcode/object.ex @@ -1,30 +1,28 @@ -defmodule QuickBEAM.VM.Opcodes.Objects do +defmodule QuickBEAM.VM.Runtime.Opcode.Object do @moduledoc """ Executes object construction, property access, descriptors, and enumeration opcodes. - Property behavior delegates to `QuickBEAM.VM.Properties`. Accessor-backed + 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.{ - Execution, - Frame, - Function, - Heap, - Invocation, - Iterator, - Properties, - Property, - Reference, - RegExp, - Symbol, - Value - } - - alias QuickBEAM.VM.Opcodes.Locals + alias QuickBEAM.VM.Runtime.State + alias QuickBEAM.VM.Runtime.Frame + alias QuickBEAM.VM.Program.Function + 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.Symbol + alias QuickBEAM.VM.Runtime.Value + + alias QuickBEAM.VM.Runtime.Opcode.Local, as: Locals @opcodes [ :regexp, @@ -62,22 +60,22 @@ defmodule QuickBEAM.VM.Opcodes.Objects do ] @type action :: - {:next, Frame.t(), Execution.t()} - | {:throw, term(), Frame.t(), Execution.t()} - | {:invoke_getter, term(), term(), Frame.t(), Execution.t()} - | {:invoke_setter, term(), term(), term(), Frame.t(), Execution.t()} + {: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(), Execution.t()) :: action() + @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} = Properties.define(regexp, "lastIndex", 0, execution) + {:ok, execution} = Property.define(regexp, "lastIndex", 0, execution) push(%{frame | stack: stack}, execution, regexp) end @@ -102,7 +100,7 @@ defmodule QuickBEAM.VM.Opcodes.Objects do execution ) do case Heap.own_property(execution, object, key) do - {:ok, %Property{value: value}} -> next(%{frame | stack: [value | stack]}, execution) + {: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 @@ -115,8 +113,8 @@ defmodule QuickBEAM.VM.Opcodes.Objects do execution ) do case Heap.own_property(execution, object, key) do - {:ok, %Property{}} -> - case Properties.put(object, key, value, execution) 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 @@ -137,12 +135,12 @@ defmodule QuickBEAM.VM.Opcodes.Objects do ) do case Heap.own_property(execution, object, key) do {:ok, nil} -> - case Properties.define(object, key, value, execution) do + case Property.define(object, key, value, execution) do {:ok, execution} -> next(%{frame | stack: [object | stack]}, execution) {:error, reason} -> {:throw, {:type_error, reason}, frame, execution} end - {:ok, %Property{}} -> + {:ok, %Descriptor{}} -> {:throw, {:type_error, :duplicate_private_field}, frame, execution} {:error, reason} -> @@ -156,7 +154,7 @@ defmodule QuickBEAM.VM.Opcodes.Objects do %{stack: [%Reference{} = function, %Reference{} = home | _stack]} = frame, execution ) do - case Properties.define(function, :home_object, home, 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 @@ -173,7 +171,7 @@ defmodule QuickBEAM.VM.Opcodes.Objects do result = case object do - %Reference{} -> Properties.define(object, {:private_brand, brand}, true, execution) + %Reference{} -> Property.define(object, {:private_brand, brand}, true, execution) object when object in [nil, :undefined] -> {:ok, execution} end @@ -189,11 +187,11 @@ defmodule QuickBEAM.VM.Opcodes.Objects do %{stack: [%Reference{} = function, %Reference{} = object | _stack]} = frame, execution ) do - with {:ok, %Property{value: %Reference{} = home}} <- + with {:ok, %Descriptor{value: %Reference{} = home}} <- Heap.own_property(execution, function, :home_object), - {:ok, %Property{value: %Symbol{} = brand}} <- + {:ok, %Descriptor{value: %Symbol{} = brand}} <- Heap.own_property(execution, home, :private_brand_token), - {:ok, %Property{}} <- + {:ok, %Descriptor{}} <- Heap.own_property(execution, object, {:private_brand, brand}) do next(frame, execution) else @@ -211,7 +209,7 @@ defmodule QuickBEAM.VM.Opcodes.Objects do |> Enum.with_index() |> Enum.reduce(execution, fn {value, index}, execution -> {:ok, execution} = - Properties.define(arguments, index, Locals.read_slot(value, execution), execution) + Property.define(arguments, index, Locals.read_slot(value, execution), execution) execution end) @@ -230,7 +228,7 @@ defmodule QuickBEAM.VM.Opcodes.Objects do case frame.callable do %Reference{} = callable -> case Heap.own_property(execution, callable, :home_object) do - {:ok, %Property{value: home}} -> home + {:ok, %Descriptor{value: home}} -> home _other -> :undefined end @@ -329,7 +327,7 @@ defmodule QuickBEAM.VM.Opcodes.Objects do ) do key = Locals.resolve_atom(atom, execution) - case Properties.define(object, key, value, execution) do + case Property.define(object, key, value, execution) do {:ok, execution} -> next(%{frame | stack: [object | stack]}, execution) {:error, reason} -> {:throw, {:type_error, reason}, frame, execution} end @@ -341,7 +339,7 @@ defmodule QuickBEAM.VM.Opcodes.Objects do %{stack: [value, key, %Reference{} = object | stack]} = frame, execution ) do - case Properties.define(object, key, value, 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 @@ -361,7 +359,7 @@ defmodule QuickBEAM.VM.Opcodes.Objects do values |> Enum.with_index(index) |> Enum.reduce(execution, fn {value, position}, execution -> - {:ok, execution} = Properties.define(array, position, value, execution) + {:ok, execution} = Property.define(array, position, value, execution) execution end) @@ -381,7 +379,7 @@ defmodule QuickBEAM.VM.Opcodes.Objects do excluded = Enum.at(stack, band(mask >>> 5, 3)) with %Reference{} <- target, - {:ok, keys} <- Properties.enumerable_keys(source, execution), + {: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 @@ -419,14 +417,14 @@ defmodule QuickBEAM.VM.Opcodes.Objects do do: put(object, key, value, stack, frame, execution) def execute(:delete, [], %{stack: [key, %Reference{} = object | stack]} = frame, execution) do - case Properties.delete(object, key, 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 Properties.enumerable_keys(object, 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 @@ -480,16 +478,16 @@ defmodule QuickBEAM.VM.Opcodes.Objects do do: next(%{frame | stack: stack}, execution) defp copy_excluded_keys(value, _execution) when value in [:undefined, nil], do: {:ok, []} - defp copy_excluded_keys(value, execution), do: Properties.enumerable_keys(value, execution) + 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} -> - case Properties.get(source, key, execution) do + case Property.get(source, key, execution) do {:ok, {:accessor, _getter, _receiver}} -> {:halt, {:error, :unsupported_copy_accessor}} {:ok, value} -> - case Properties.put(target, key, value, execution) do + case Property.put(target, key, value, execution) do {:ok, execution} -> {:cont, {:ok, execution}} {:error, reason} -> {:halt, {:error, reason}} end @@ -502,14 +500,14 @@ defmodule QuickBEAM.VM.Opcodes.Objects do defp private_brand(home, execution) do case Heap.own_property(execution, home, :private_brand_token) do - {:ok, %Property{value: %Symbol{} = brand}} -> + {: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} = Properties.define(home, :private_brand_token, brand, execution) + {:ok, execution} = Property.define(home, :private_brand_token, brand, execution) {brand, execution} end end @@ -518,9 +516,9 @@ defmodule QuickBEAM.VM.Opcodes.Objects do opts = [enumerable: band(flags, 4) != 0] case band(flags, 3) do - 0 -> Properties.define(object, key, callable, execution, opts) - 1 -> Properties.define_accessor(object, key, :getter, callable, execution, opts) - 2 -> Properties.define_accessor(object, key, :setter, callable, execution, opts) + 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 @@ -533,14 +531,14 @@ defmodule QuickBEAM.VM.Opcodes.Objects do {prototype, execution} = Heap.allocate(execution, prototype: parent_prototype) {:ok, execution} = - Properties.define(prototype, "constructor", constructor, execution, + Property.define(prototype, "constructor", constructor, execution, writable: true, enumerable: false, configurable: true ) {:ok, execution} = - Properties.define(constructor, "prototype", prototype, execution, + Property.define(constructor, "prototype", prototype, execution, writable: false, enumerable: false, configurable: false @@ -552,7 +550,7 @@ defmodule QuickBEAM.VM.Opcodes.Objects do end) {:ok, execution} = - Properties.define(constructor, "name", name, execution, + Property.define(constructor, "name", name, execution, writable: false, enumerable: false, configurable: true @@ -596,7 +594,7 @@ defmodule QuickBEAM.VM.Opcodes.Objects do defp class_parent_prototype(nil, _execution), do: {:ok, nil} defp class_parent_prototype(parent, execution) do - case Properties.get(parent, "prototype", execution) do + case Property.get(parent, "prototype", execution) do {:ok, %Reference{} = prototype} -> {:ok, prototype} {:ok, nil} -> {:ok, nil} _other -> {:error, :invalid_class_parent_prototype} @@ -606,7 +604,7 @@ defmodule QuickBEAM.VM.Opcodes.Objects do defp get(object, key, stack, frame, execution) do frame = %{frame | stack: stack} - case Properties.get(object, key, execution) do + case Property.get(object, key, execution) do {:ok, {:accessor, getter, receiver}} -> {:invoke_getter, getter, receiver, frame, execution} @@ -621,7 +619,7 @@ defmodule QuickBEAM.VM.Opcodes.Objects do defp put(object, key, value, stack, frame, execution) do frame = %{frame | stack: stack} - case Properties.put(object, key, value, execution) do + case Property.put(object, key, value, execution) do {:ok, execution} -> next(frame, execution) diff --git a/lib/quickbeam/vm/opcodes/stack.ex b/lib/quickbeam/vm/runtime/opcode/stack.ex similarity index 71% rename from lib/quickbeam/vm/opcodes/stack.ex rename to lib/quickbeam/vm/runtime/opcode/stack.ex index 9ec48e468..ec0721f7b 100644 --- a/lib/quickbeam/vm/opcodes/stack.ex +++ b/lib/quickbeam/vm/runtime/opcode/stack.ex @@ -1,4 +1,4 @@ -defmodule QuickBEAM.VM.Opcodes.Stack do +defmodule QuickBEAM.VM.Runtime.Opcode.Stack do @moduledoc """ Executes literal and operand-stack QuickJS opcode families. @@ -7,7 +7,9 @@ defmodule QuickBEAM.VM.Opcodes.Stack do stepping and resource accounting. """ - alias QuickBEAM.VM.{Execution, Frame, StackState} + alias QuickBEAM.VM.Runtime.State + alias QuickBEAM.VM.Runtime.Frame + alias QuickBEAM.VM.Runtime.Stack, as: OperandStack @opcodes [ :push_i32, @@ -42,18 +44,18 @@ defmodule QuickBEAM.VM.Opcodes.Stack do :insert3 ] - @type action :: {:next, Frame.t(), Execution.t()} + @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(), Execution.t()) :: action() - def execute(name, operands, %Frame{} = frame, %Execution{} = execution) + @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} = - StackState.execute(name, operands, frame.stack, frame.this, frame.function.constants) + OperandStack.execute(name, operands, frame.stack, frame.this, frame.function.constants) {:next, %{frame | stack: stack}, execution} end diff --git a/lib/quickbeam/vm/opcodes/values.ex b/lib/quickbeam/vm/runtime/opcode/value.ex similarity index 87% rename from lib/quickbeam/vm/opcodes/values.ex rename to lib/quickbeam/vm/runtime/opcode/value.ex index 672ad6b98..ffe9a4081 100644 --- a/lib/quickbeam/vm/opcodes/values.ex +++ b/lib/quickbeam/vm/runtime/opcode/value.ex @@ -1,4 +1,4 @@ -defmodule QuickBEAM.VM.Opcodes.Values do +defmodule QuickBEAM.VM.Runtime.Opcode.Value do @moduledoc """ Executes coercion, comparison, arithmetic, and value-test opcode families. @@ -7,7 +7,12 @@ defmodule QuickBEAM.VM.Opcodes.Values do interpreter. """ - alias QuickBEAM.VM.{Execution, Frame, Invocation, Properties, Reference, Value} + alias QuickBEAM.VM.Runtime.State + alias QuickBEAM.VM.Runtime.Frame + alias QuickBEAM.VM.Runtime.Invocation + alias QuickBEAM.VM.Runtime.Property + alias QuickBEAM.VM.Runtime.Reference + alias QuickBEAM.VM.Runtime.Value @binary_operations [ :add, @@ -61,15 +66,15 @@ defmodule QuickBEAM.VM.Opcodes.Values do ] @type action :: - {:next, Frame.t(), Execution.t()} - | {:throw, term(), Frame.t(), Execution.t()} + {: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(), Execution.t()) :: action() + @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) @@ -115,7 +120,7 @@ defmodule QuickBEAM.VM.Opcodes.Values do def execute(:in, [], %{stack: [object, key | stack]} = frame, execution), do: next( - %{frame | stack: [Properties.has_property?(object, key, execution) | stack]}, + %{frame | stack: [Property.has_property?(object, key, execution) | stack]}, execution ) @@ -130,7 +135,7 @@ defmodule QuickBEAM.VM.Opcodes.Values do Invocation.instanceof_prototype(constructor, execution) do result = is_struct(object, Reference) and - Properties.prototype_chain_contains?(object, prototype, execution) + Property.prototype_chain_contains?(object, prototype, execution) next(%{frame | stack: [result | stack]}, execution) else diff --git a/lib/quickbeam/vm/promise.ex b/lib/quickbeam/vm/runtime/promise.ex similarity index 83% rename from lib/quickbeam/vm/promise.ex rename to lib/quickbeam/vm/runtime/promise.ex index af8a85599..0d91d1603 100644 --- a/lib/quickbeam/vm/promise.ex +++ b/lib/quickbeam/vm/runtime/promise.ex @@ -1,26 +1,25 @@ -defmodule QuickBEAM.VM.Promise do +defmodule QuickBEAM.VM.Runtime.Promise do @moduledoc """ Implements owner-local Promise state, reactions, adoption, and combinators. - Promise state and jobs live in `QuickBEAM.VM.Execution`; this module only + 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.{ - Coroutine, - Execution, - Invocation, - Memory, - Properties, - PromiseReference, - Reaction, - Reference - } + alias QuickBEAM.VM.Runtime.Coroutine + alias QuickBEAM.VM.Runtime.State + alias QuickBEAM.VM.Runtime.Invocation + alias QuickBEAM.VM.Runtime.Memory + alias QuickBEAM.VM.Runtime.Property + alias QuickBEAM.VM.Runtime.Promise.Reference, as: PromiseReference + alias QuickBEAM.VM.Runtime.Promise.Reaction + alias QuickBEAM.VM.Runtime.Reference @type state :: :pending | {:fulfilled, term()} | {:rejected, term()} - @spec new(Execution.t()) :: {PromiseReference.t(), Execution.t()} - def new(%Execution{} = execution) do + @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) @@ -34,15 +33,17 @@ defmodule QuickBEAM.VM.Promise do {reference, execution} end - @spec state(Execution.t(), PromiseReference.t()) :: state() - def state(%Execution{} = execution, %PromiseReference{id: id}) do + @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 - @spec await(Execution.t(), PromiseReference.t(), Coroutine.t()) :: Execution.t() + @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) @@ -51,8 +52,9 @@ defmodule QuickBEAM.VM.Promise do end end - @spec react(Execution.t(), PromiseReference.t(), term(), term()) :: - {PromiseReference.t(), Execution.t()} + @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) @@ -73,11 +75,12 @@ defmodule QuickBEAM.VM.Promise do end @doc "Creates or reuses the Promise produced by resolving one value." - @spec from_value(Execution.t(), term()) :: {PromiseReference.t(), Execution.t()} + @spec from_value(State.t(), term()) :: {PromiseReference.t(), State.t()} def from_value(execution, value), do: promise_from_value(execution, value) - @spec aggregate(Execution.t(), :all | :all_settled | :any | :race, [term()]) :: - {PromiseReference.t(), Execution.t()} + @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) @@ -86,11 +89,11 @@ defmodule QuickBEAM.VM.Promise do @doc "Aggregates iterable values into an existing owner-local Promise." @spec aggregate_into( - Execution.t(), + State.t(), PromiseReference.t(), :all | :all_settled | :any | :race, [term()] - ) :: Execution.t() + ) :: State.t() def aggregate_into(execution, result_promise, kind, values) do if values == [] do result = @@ -129,13 +132,14 @@ defmodule QuickBEAM.VM.Promise do end end + @doc "Records one settlement in an active Promise aggregate." @spec settle_aggregate( - Execution.t(), + State.t(), reference(), non_neg_integer(), {:ok, term()} | {:error, term()} ) :: - Execution.t() + State.t() def settle_aggregate(execution, id, index, result) do case Map.fetch(execution.promise_aggregates, id) do :error -> @@ -146,8 +150,9 @@ defmodule QuickBEAM.VM.Promise do end end - @spec finally(Execution.t(), PromiseReference.t(), term()) :: - {PromiseReference.t(), Execution.t()} + @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) @@ -168,12 +173,13 @@ defmodule QuickBEAM.VM.Promise do {result_promise, execution} end + @doc "Settles a target Promise after a `finally` callback result." @spec settle_after_finally( - Execution.t(), + State.t(), PromiseReference.t(), PromiseReference.t(), {:ok, term()} | {:error, term()} - ) :: Execution.t() + ) :: 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}) @@ -183,18 +189,20 @@ defmodule QuickBEAM.VM.Promise do end @doc "Enqueues invocation of a callable `then` result as a Promise microtask." - @spec enqueue_assimilation(Execution.t(), PromiseReference.t(), Reference.t(), term()) :: - Execution.t() + @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}) - @spec enqueue_coroutine(Execution.t(), Coroutine.t(), {:ok, term()} | {:error, term()}) :: - Execution.t() + @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}) - @spec settle(Execution.t(), PromiseReference.t(), {:ok, term()} | {:error, term()}) :: - Execution.t() + @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}}) @@ -239,7 +247,7 @@ defmodule QuickBEAM.VM.Promise do end end - def settle(%Execution{} = execution, %PromiseReference{} = promise, result), + def settle(%State{} = execution, %PromiseReference{} = promise, result), do: settle_result(execution, promise, result) @doc """ @@ -248,8 +256,8 @@ defmodule QuickBEAM.VM.Promise do The adopted value is recursively resolved according to the Promise resolution procedure, including Promise and thenable assimilation. """ - @spec settle_assimilated(Execution.t(), PromiseReference.t(), {:ok, term()} | {:error, term()}) :: - Execution.t() + @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 -> @@ -262,7 +270,7 @@ defmodule QuickBEAM.VM.Promise do end @doc "Fulfills a resolving Promise after a non-callable `then` getter result." - @spec fulfill_assimilated(Execution.t(), PromiseReference.t(), term()) :: Execution.t() + @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 -> @@ -274,7 +282,7 @@ defmodule QuickBEAM.VM.Promise do end end - defp settle_result(%Execution{} = execution, %PromiseReference{id: id}, result) do + defp settle_result(%State{} = execution, %PromiseReference{id: id}, result) do case Map.fetch!(execution.promises, id) do :pending -> state = result_state(result) @@ -296,7 +304,7 @@ defmodule QuickBEAM.VM.Promise do end defp then_callable(execution, reference) do - case Properties.get(reference, "then", execution) do + case Property.get(reference, "then", execution) do {:ok, {:accessor, getter, receiver}} -> {:getter, getter, receiver} @@ -411,7 +419,7 @@ defmodule QuickBEAM.VM.Promise do "errors" => errors } - defp unwrap_reason(%QuickBEAM.VM.Thrown{value: value}), do: value + 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} diff --git a/lib/quickbeam/vm/reaction.ex b/lib/quickbeam/vm/runtime/promise/reaction.ex similarity index 75% rename from lib/quickbeam/vm/reaction.ex rename to lib/quickbeam/vm/runtime/promise/reaction.ex index d24240a2a..9496e5dc2 100644 --- a/lib/quickbeam/vm/reaction.ex +++ b/lib/quickbeam/vm/runtime/promise/reaction.ex @@ -1,4 +1,4 @@ -defmodule QuickBEAM.VM.Reaction do +defmodule QuickBEAM.VM.Runtime.Promise.Reaction do @moduledoc """ Defines a queued Promise reaction and the Promise produced by that reaction. """ @@ -7,7 +7,7 @@ defmodule QuickBEAM.VM.Reaction do defstruct [:result_promise, kind: :then, on_fulfilled: :undefined, on_rejected: :undefined] @type t :: %__MODULE__{ - result_promise: QuickBEAM.VM.PromiseReference.t(), + result_promise: QuickBEAM.VM.Runtime.Promise.Reference.t(), kind: :then | :finally, on_fulfilled: term(), on_rejected: term() diff --git a/lib/quickbeam/vm/promise_reference.ex b/lib/quickbeam/vm/runtime/promise/reference.ex similarity index 76% rename from lib/quickbeam/vm/promise_reference.ex rename to lib/quickbeam/vm/runtime/promise/reference.ex index 4b3b57442..8d7ffd673 100644 --- a/lib/quickbeam/vm/promise_reference.ex +++ b/lib/quickbeam/vm/runtime/promise/reference.ex @@ -1,4 +1,4 @@ -defmodule QuickBEAM.VM.PromiseReference do +defmodule QuickBEAM.VM.Runtime.Promise.Reference do @moduledoc "Identifies a Promise in one evaluation's Promise store." @enforce_keys [:id] diff --git a/lib/quickbeam/vm/properties.ex b/lib/quickbeam/vm/runtime/property.ex similarity index 81% rename from lib/quickbeam/vm/properties.ex rename to lib/quickbeam/vm/runtime/property.ex index 0b2200376..5af711d1e 100644 --- a/lib/quickbeam/vm/properties.ex +++ b/lib/quickbeam/vm/runtime/property.ex @@ -1,4 +1,4 @@ -defmodule QuickBEAM.VM.Properties do +defmodule QuickBEAM.VM.Runtime.Property do @moduledoc """ Provides the canonical JavaScript property semantic boundary for the VM. @@ -8,15 +8,13 @@ defmodule QuickBEAM.VM.Properties do `{:ok, {:accessor, getter, receiver}}` action for the interpreter to resume. """ - alias QuickBEAM.VM.{ - Execution, - Heap, - PromiseReference, - Property, - Reference, - RegExp, - Value - } + alias QuickBEAM.VM.Runtime.State + 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.Value @function_tags [ :builtin, @@ -32,7 +30,7 @@ defmodule QuickBEAM.VM.Properties do | {:error, term()} @doc "Reads a JavaScript property or returns an accessor invocation action." - @spec get(term(), term(), Execution.t()) :: get_result() + @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 -> @@ -98,16 +96,16 @@ defmodule QuickBEAM.VM.Properties do def get(_object, _key, _execution), do: {:ok, :undefined} @doc "Writes a JavaScript property or returns an accessor setter action." - @spec put(term(), term(), term(), Execution.t()) :: - {:ok, Execution.t()} | {:error, term()} + @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(), Execution.t(), keyword()) :: - {:ok, Execution.t()} | {:error, term()} + @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) @@ -117,26 +115,26 @@ defmodule QuickBEAM.VM.Properties do term(), :getter | :setter, term(), - Execution.t(), + State.t(), keyword() ) :: - {:ok, Execution.t()} | {:error, term()} + {: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(), Property.t(), Execution.t()) :: - {:ok, Execution.t()} | {:error, term()} - def define_descriptor(%Reference{} = object, key, %Property{} = property, execution), + @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(), Execution.t()) :: - {:ok, boolean(), Execution.t()} | {:error, term()} + @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(), Execution.t()) :: {:ok, [term()]} | {:error, term()} + @spec enumerable_keys(term(), State.t()) :: {:ok, [term()]} | {:error, term()} def enumerable_keys(%Reference{} = reference, execution), do: Heap.own_keys(execution, reference) @@ -150,14 +148,14 @@ defmodule QuickBEAM.VM.Properties do def enumerable_keys(_value, _execution), do: {:ok, []} @doc "Returns enumerable own string and Symbol keys copied by `Object.assign`." - @spec assignable_keys(term(), Execution.t()) :: {:ok, [term()]} | {:error, term()} + @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(), Execution.t()) :: boolean() + @spec has_property?(term(), term(), State.t()) :: boolean() def has_property?(%Reference{} = reference, key, execution), do: Heap.has_property?(execution, reference, key) @@ -172,35 +170,35 @@ defmodule QuickBEAM.VM.Properties do def has_property?(_value, _key, _execution), do: false @doc "Returns an object's own descriptor without prototype traversal." - @spec own_property(Reference.t(), term(), Execution.t()) :: - {:ok, Property.t() | nil} | {:error, term()} + @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(), Execution.t()) :: + @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(), Execution.t()) :: + @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, Execution.t()) :: - {:ok, Execution.t()} | {:error, term()} + @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(), Execution.t()) :: boolean() + @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(), Execution.t()) :: QuickBEAM.VM.Object.kind() | nil + @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 diff --git a/lib/quickbeam/vm/property.ex b/lib/quickbeam/vm/runtime/property/descriptor.ex similarity index 91% rename from lib/quickbeam/vm/property.ex rename to lib/quickbeam/vm/runtime/property/descriptor.ex index 61445d10a..3a57bf39e 100644 --- a/lib/quickbeam/vm/property.ex +++ b/lib/quickbeam/vm/runtime/property/descriptor.ex @@ -1,4 +1,4 @@ -defmodule QuickBEAM.VM.Property do +defmodule QuickBEAM.VM.Runtime.Property.Descriptor do @moduledoc "Defines a JavaScript property value and its descriptor flags." defstruct kind: :data, diff --git a/lib/quickbeam/vm/reference.ex b/lib/quickbeam/vm/runtime/reference.ex similarity index 78% rename from lib/quickbeam/vm/reference.ex rename to lib/quickbeam/vm/runtime/reference.ex index fc51712d8..9d6bea698 100644 --- a/lib/quickbeam/vm/reference.ex +++ b/lib/quickbeam/vm/runtime/reference.ex @@ -1,4 +1,4 @@ -defmodule QuickBEAM.VM.Reference do +defmodule QuickBEAM.VM.Runtime.Reference do @moduledoc "Identifies an object in one evaluation-owned VM heap." @enforce_keys [:id] diff --git a/lib/quickbeam/vm/regexp.ex b/lib/quickbeam/vm/runtime/regexp.ex similarity index 84% rename from lib/quickbeam/vm/regexp.ex rename to lib/quickbeam/vm/runtime/regexp.ex index 09d36aebb..e33048ea0 100644 --- a/lib/quickbeam/vm/regexp.ex +++ b/lib/quickbeam/vm/runtime/regexp.ex @@ -1,4 +1,4 @@ -defmodule QuickBEAM.VM.RegExp do +defmodule QuickBEAM.VM.Runtime.RegExp do @moduledoc "Defines the VM representation of a JavaScript regular expression." @enforce_keys [:source, :bytecode] diff --git a/lib/quickbeam/vm/stack_state.ex b/lib/quickbeam/vm/runtime/stack.ex similarity index 98% rename from lib/quickbeam/vm/stack_state.ex rename to lib/quickbeam/vm/runtime/stack.ex index 3f78636eb..d60f90cd6 100644 --- a/lib/quickbeam/vm/stack_state.ex +++ b/lib/quickbeam/vm/runtime/stack.ex @@ -1,4 +1,4 @@ -defmodule QuickBEAM.VM.StackState do +defmodule QuickBEAM.VM.Runtime.Stack do @moduledoc """ Applies verified literal and operand-stack transformations to compact stack state. diff --git a/lib/quickbeam/vm/execution.ex b/lib/quickbeam/vm/runtime/state.ex similarity index 68% rename from lib/quickbeam/vm/execution.ex rename to lib/quickbeam/vm/runtime/state.ex index 963f76312..1e71dc610 100644 --- a/lib/quickbeam/vm/execution.ex +++ b/lib/quickbeam/vm/runtime/state.ex @@ -1,4 +1,4 @@ -defmodule QuickBEAM.VM.Execution do +defmodule QuickBEAM.VM.Runtime.State do @moduledoc """ Defines all mutable state owned by one isolated VM evaluation. @@ -40,27 +40,27 @@ defmodule QuickBEAM.VM.Execution do @type t :: %__MODULE__{ atoms: tuple(), callers: [ - QuickBEAM.VM.Frame.t() - | QuickBEAM.VM.NativeFrame.t() - | QuickBEAM.VM.ObjectAssignBoundary.t() - | QuickBEAM.VM.AccessorBoundary.t() - | QuickBEAM.VM.AsyncBoundary.t() - | QuickBEAM.VM.ReactionBoundary.t() - | QuickBEAM.VM.ConstructorBoundary.t() - | QuickBEAM.VM.PromiseExecutorBoundary.t() - | QuickBEAM.VM.ThenableBoundary.t() - | QuickBEAM.VM.ThenGetterBoundary.t() + 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: QuickBEAM.VM.Compiler.Context.t() | nil, depth: non_neg_integer(), default_prototypes: %{ - optional(QuickBEAM.VM.Object.kind()) => QuickBEAM.VM.Reference.t() + optional(QuickBEAM.VM.Runtime.Object.kind()) => QuickBEAM.VM.Runtime.Reference.t() }, - error_prototypes: %{optional(String.t()) => QuickBEAM.VM.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.Object.t()}, + heap: %{optional(non_neg_integer()) => QuickBEAM.VM.Runtime.Object.t()}, jobs: :queue.queue(term()), sync_jobs: :queue.queue(term()), max_stack_depth: pos_integer(), @@ -73,12 +73,12 @@ defmodule QuickBEAM.VM.Execution do next_promise_id: non_neg_integer(), next_symbol_id: non_neg_integer(), operations: %{ - optional(reference()) => {QuickBEAM.VM.PromiseReference.t(), pid()} + 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.Promise.state() | :resolving + optional(non_neg_integer()) => QuickBEAM.VM.Runtime.Promise.state() | :resolving }, remaining_steps: non_neg_integer(), step_limit: pos_integer() diff --git a/lib/quickbeam/vm/utf16.ex b/lib/quickbeam/vm/runtime/string/utf16.ex similarity index 98% rename from lib/quickbeam/vm/utf16.ex rename to lib/quickbeam/vm/runtime/string/utf16.ex index 9136aaefa..b27973f5b 100644 --- a/lib/quickbeam/vm/utf16.ex +++ b/lib/quickbeam/vm/runtime/string/utf16.ex @@ -1,4 +1,4 @@ -defmodule QuickBEAM.VM.UTF16 do +defmodule QuickBEAM.VM.Runtime.String.UTF16 do @moduledoc """ Implements JavaScript string indexing over UTF-16 code units. diff --git a/lib/quickbeam/vm/symbol.ex b/lib/quickbeam/vm/runtime/symbol.ex similarity index 92% rename from lib/quickbeam/vm/symbol.ex rename to lib/quickbeam/vm/runtime/symbol.ex index 402fb851e..af841853e 100644 --- a/lib/quickbeam/vm/symbol.ex +++ b/lib/quickbeam/vm/runtime/symbol.ex @@ -1,4 +1,4 @@ -defmodule QuickBEAM.VM.Symbol do +defmodule QuickBEAM.VM.Runtime.Symbol do @moduledoc "Defines an owner-independent well-known JavaScript Symbol value." @enforce_keys [:id, :description] diff --git a/lib/quickbeam/vm/thrown.ex b/lib/quickbeam/vm/runtime/thrown.ex similarity index 90% rename from lib/quickbeam/vm/thrown.ex rename to lib/quickbeam/vm/runtime/thrown.ex index 0cf457093..0df2f63e7 100644 --- a/lib/quickbeam/vm/thrown.ex +++ b/lib/quickbeam/vm/runtime/thrown.ex @@ -1,4 +1,4 @@ -defmodule QuickBEAM.VM.Thrown do +defmodule QuickBEAM.VM.Runtime.Thrown do @moduledoc """ Carries a raw JavaScript thrown value together with preserved async frames. diff --git a/lib/quickbeam/vm/value.ex b/lib/quickbeam/vm/runtime/value.ex similarity index 95% rename from lib/quickbeam/vm/value.ex rename to lib/quickbeam/vm/runtime/value.ex index ddd86c696..cb3125d04 100644 --- a/lib/quickbeam/vm/value.ex +++ b/lib/quickbeam/vm/runtime/value.ex @@ -1,4 +1,4 @@ -defmodule QuickBEAM.VM.Value do +defmodule QuickBEAM.VM.Runtime.Value do @moduledoc """ Implements canonical JavaScript value coercion and primitive operations. @@ -9,7 +9,7 @@ defmodule QuickBEAM.VM.Value do import Bitwise - alias QuickBEAM.VM.UTF16 + alias QuickBEAM.VM.Runtime.String.UTF16 @doc "Returns JavaScript boolean coercion for a VM value." @spec truthy?(term()) :: boolean() @@ -185,12 +185,12 @@ defmodule QuickBEAM.VM.Value do do: "number" def typeof(value) when is_binary(value), do: "string" - def typeof(%QuickBEAM.VM.Symbol{}), do: "symbol" - def typeof(%QuickBEAM.VM.Function{}), do: "function" - def typeof(%QuickBEAM.VM.Reference{}), do: "object" - def typeof(%QuickBEAM.VM.PromiseReference{}), do: "object" - def typeof(%QuickBEAM.VM.RegExp{}), do: "object" - def typeof({:closure, %QuickBEAM.VM.Function{}, _captures}), do: "function" + 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." diff --git a/lib/quickbeam/vm/export.ex b/lib/quickbeam/vm/runtime/value/export.ex similarity index 75% rename from lib/quickbeam/vm/export.ex rename to lib/quickbeam/vm/runtime/value/export.ex index c084596a3..6edbeb789 100644 --- a/lib/quickbeam/vm/export.ex +++ b/lib/quickbeam/vm/runtime/value/export.ex @@ -1,4 +1,4 @@ -defmodule QuickBEAM.VM.Export do +defmodule QuickBEAM.VM.Runtime.Value.Export do @moduledoc """ Converts owner-local JavaScript values into safe ordinary BEAM values. @@ -6,25 +6,24 @@ defmodule QuickBEAM.VM.Export do references outside their evaluation process. """ - alias QuickBEAM.VM.{ - Exceptions, - Execution, - Heap, - Object, - Promise, - PromiseReference, - Property, - Reference, - Symbol - } - - @spec value(term(), Execution.t()) :: {:ok, term()} | {:error, term()} - def value(value, %Execution{} = execution), do: convert(value, execution, MapSet.new()) + alias QuickBEAM.VM.Runtime.Exception + alias QuickBEAM.VM.Runtime.State + 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.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, MapSet.new()) defp convert(%PromiseReference{} = promise, execution, seen) do case Promise.state(execution, promise) do {:fulfilled, value} -> convert(value, execution, seen) - {:rejected, reason} -> {:error, Exceptions.to_js_error(reason, execution, [])} + {:rejected, reason} -> {:error, Exception.to_js_error(reason, execution, [])} :pending -> {:error, :pending_promise_result} end end @@ -43,7 +42,9 @@ defmodule QuickBEAM.VM.Export do defp convert({:closure, _function, _references}, _execution, _seen), do: {:error, :function_result} - defp convert(%QuickBEAM.VM.Function{}, _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 @@ -80,7 +81,7 @@ defmodule QuickBEAM.VM.Export do Object.property_enumerable?(property) and not is_struct(key, Symbol) end) |> Enum.reduce_while({:ok, %{}}, fn {key, property}, {:ok, result} -> - %Property{value: value} = Object.property_descriptor(property) + %Descriptor{value: value} = Object.property_descriptor(property) case convert(value, execution, seen) do {:ok, value} -> {:cont, {:ok, Map.put(result, key, value)}} diff --git a/mix.exs b/mix.exs index 40823198d..36f2a47e7 100644 --- a/mix.exs +++ b/mix.exs @@ -123,35 +123,28 @@ defmodule QuickBEAM.MixProject do defp skip_doc_warning?(reference) do internal_vm_modules = [ - "QuickBEAM.VM.Async", + "QuickBEAM.VM.ABI", "QuickBEAM.VM.Builtin", + "QuickBEAM.VM.Bytecode", "QuickBEAM.VM.Compiler", - "QuickBEAM.VM.Exceptions", "QuickBEAM.VM.Fuzz", - "QuickBEAM.VM.Invocation", - "QuickBEAM.VM.Iterator", - "QuickBEAM.VM.Opcodes", - "QuickBEAM.VM.ProgramStore", - "QuickBEAM.VM.Properties", - "QuickBEAM.VM.Value" + "QuickBEAM.VM.Program.Store", + "QuickBEAM.VM.Runtime" ] String.starts_with?(reference, "QuickBEAM.Runtime") or - Enum.any?(internal_vm_modules, &String.starts_with?(reference, &1)) or - String.ends_with?(reference, "beam-interpreter-architecture.md") + Enum.any?(internal_vm_modules, &String.starts_with?(reference, &1)) end defp documented_module?(module, _metadata) do public_vm_modules = [ - QuickBEAM.VM.ABI, - QuickBEAM.VM.ClosureVariable, - QuickBEAM.VM.Compiler, - QuickBEAM.VM.Function, + QuickBEAM.VM.Program.Variable.Closure, + QuickBEAM.VM.Program.Function, QuickBEAM.VM.Measurement, QuickBEAM.VM.Program, - QuickBEAM.VM.PinnedProgram, - QuickBEAM.VM.SourcePosition, - QuickBEAM.VM.Variable + QuickBEAM.VM.Program.Pinned, + QuickBEAM.VM.Program.Source, + QuickBEAM.VM.Program.Variable ] module != QuickBEAM.Application and diff --git a/test/fixtures/vm/fuzz/regressions/README.md b/test/fixtures/vm/fuzz/regressions/README.md deleted file mode 100644 index 3736b6f2c..000000000 --- a/test/fixtures/vm/fuzz/regressions/README.md +++ /dev/null @@ -1,9 +0,0 @@ -# VM bytecode fuzzing regressions - -Persist minimized decoder safety findings here as paired `.bin` and `.txt` -files using `QuickBEAM.VM.Fuzz.persist/2`. The test suite replays every `.bin` -file twice under strict timeout and BEAM heap bounds and requires a stable typed -rejection. - -The `.txt` sidecar records the corpus name, seed, iteration, mutation operation, -original outcome, and SHA-256 digest needed to reproduce and audit the input. diff --git a/test/vm/abi_test.exs b/test/vm/abi_test.exs index e7e14c75a..0446e8430 100644 --- a/test/vm/abi_test.exs +++ b/test/vm/abi_test.exs @@ -1,16 +1,17 @@ defmodule QuickBEAM.VM.ABITest do use ExUnit.Case, async: true - alias QuickBEAM.VM.{ABI, Opcodes} + alias QuickBEAM.VM.ABI + alias QuickBEAM.VM.Bytecode.Opcode alias QuickBEAM.VM.ABI.Source test "metadata is generated from the current vendored QuickJS sources" do assert ABI.bytecode_version() == 26 assert byte_size(ABI.fingerprint()) == 64 - assert Opcodes.bc_version() == ABI.bytecode_version() - assert Opcodes.num(:check_object) != nil - assert Opcodes.num(:using_dispose) != nil - assert Opcodes.info(Opcodes.num(:await)) == {:await, 1, 1, 1, :none} + 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 @@ -44,6 +45,6 @@ defmodule QuickBEAM.VM.ABITest do assert "using" in Map.values(atoms) assert "Symbol.dispose" in Map.values(atoms) - assert Opcodes.js_atom_end() == map_size(atoms) + 1 + assert Opcode.js_atom_end() == map_size(atoms) + 1 end end diff --git a/test/vm/builtin_dsl_test.exs b/test/vm/builtin/dsl_test.exs similarity index 72% rename from test/vm/builtin_dsl_test.exs rename to test/vm/builtin/dsl_test.exs index e3e0c0520..631c9667e 100644 --- a/test/vm/builtin_dsl_test.exs +++ b/test/vm/builtin/dsl_test.exs @@ -1,19 +1,21 @@ -defmodule QuickBEAM.VM.BuiltinDSLTest do +defmodule QuickBEAM.VM.Builtin.DSLTest do use ExUnit.Case, async: true - alias QuickBEAM.VM.Builtin.{ - AccessorSpec, - Call, - ContractError, - FunctionSpec, - Installer, - PropertySpec, - Registry, - Spec, - Validator - } - - alias QuickBEAM.VM.{Builtin, Execution, Invocation, Properties, Reference} + alias QuickBEAM.VM.Builtin.Spec.Accessor, as: AccessorSpec + alias QuickBEAM.VM.Builtin.Call + alias QuickBEAM.VM.Builtin.Contract.Error, as: ContractError + alias QuickBEAM.VM.Builtin.Spec.Function, as: FunctionSpec + alias QuickBEAM.VM.Builtin.Installer + alias QuickBEAM.VM.Builtin.Spec.Property, as: PropertySpec + alias QuickBEAM.VM.Builtin.Registry + alias QuickBEAM.VM.Builtin.Spec + alias QuickBEAM.VM.Builtin.Validator + + alias QuickBEAM.VM.Builtin + alias QuickBEAM.VM.Runtime.State + alias QuickBEAM.VM.Runtime.Invocation + alias QuickBEAM.VM.Runtime.Property + alias QuickBEAM.VM.Runtime.Reference defmodule Fixture do use QuickBEAM.VM.Builtin @@ -60,12 +62,12 @@ defmodule QuickBEAM.VM.BuiltinDSLTest do execution = Installer.install_all(execution(), [Fixture], :test) fixture = Map.fetch!(execution.globals, "Fixture") - assert {:ok, 42} = Properties.get(fixture, "answer", execution) - assert {:ok, %Reference{} = echo} = Properties.get(fixture, "echo", execution) - assert {:ok, "echo"} = Properties.get(echo, "name", execution) + 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}} = - Properties.get(fixture, "version", execution) + Property.get(fixture, "version", execution) assert Invocation.callable?(getter, execution) end @@ -86,8 +88,8 @@ defmodule QuickBEAM.VM.BuiltinDSLTest do end test "compiles declarative modules into immutable validated specs" do - math = QuickBEAM.VM.Builtins.Math.builtin_spec() - array = QuickBEAM.VM.Builtins.Array.builtin_spec() + math = QuickBEAM.VM.Builtin.Math.builtin_spec() + array = QuickBEAM.VM.Builtin.Array.builtin_spec() assert math.name == "Math" assert math.kind == :namespace @@ -110,70 +112,70 @@ defmodule QuickBEAM.VM.BuiltinDSLTest do ~w(concat filter forEach includes join map push reduce slice some sort) assert Registry.modules(:core) == [ - QuickBEAM.VM.Builtins.Object, - QuickBEAM.VM.Builtins.Function, - QuickBEAM.VM.Builtins.Array, - QuickBEAM.VM.Builtins.Boolean, - QuickBEAM.VM.Builtins.Error, - QuickBEAM.VM.Builtins.Math, - QuickBEAM.VM.Builtins.Number, - QuickBEAM.VM.Builtins.String, - QuickBEAM.VM.Builtins.Symbol, - QuickBEAM.VM.Builtins.Uint8Array, - QuickBEAM.VM.Builtins.WeakMap, - QuickBEAM.VM.Builtins.EvalError, - QuickBEAM.VM.Builtins.Map, - QuickBEAM.VM.Builtins.Promise, - QuickBEAM.VM.Builtins.RangeError, - QuickBEAM.VM.Builtins.ReferenceError, - QuickBEAM.VM.Builtins.Set, - QuickBEAM.VM.Builtins.SyntaxError, - QuickBEAM.VM.Builtins.TypeError, - QuickBEAM.VM.Builtins.URIError, - QuickBEAM.VM.Builtins.WeakSet + 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.Builtins.Console in Registry.modules(:core) - assert QuickBEAM.VM.Builtins.Console in Registry.modules(:ssr) + 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 QuickBEAM.VM.Builtins.String.builtin_spec().kind == :constructor + assert QuickBEAM.VM.Builtin.String.builtin_spec().kind == :constructor - object = QuickBEAM.VM.Builtins.Object.builtin_spec() + object = QuickBEAM.VM.Builtin.Object.builtin_spec() assert object.prototype_spec.extends == nil assert object.prototype_spec.default_for == :ordinary - function = QuickBEAM.VM.Builtins.Function.builtin_spec() + function = QuickBEAM.VM.Builtin.Function.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 = QuickBEAM.VM.Builtins.Promise.builtin_spec() + promise = QuickBEAM.VM.Builtin.Promise.builtin_spec() assert promise.kind == :constructor assert promise.constructor == :construct assert promise.depends_on == ["Object", "Function", "Symbol"] - error = QuickBEAM.VM.Builtins.TypeError.builtin_spec() + error = QuickBEAM.VM.Builtin.Error.Type.builtin_spec() assert error.prototype_spec.extends == "Error" assert error.prototype_spec.error_type == "TypeError" - set = QuickBEAM.VM.Builtins.Set.builtin_spec() + set = QuickBEAM.VM.Builtin.Set.builtin_spec() assert set.kind == :constructor - assert Enum.any?(set.prototype, &match?(%QuickBEAM.VM.Builtin.AliasSpec{}, &1)) + assert Enum.any?(set.prototype, &match?(%QuickBEAM.VM.Builtin.Spec.Alias{}, &1)) - symbol = QuickBEAM.VM.Builtins.Symbol.builtin_spec() + symbol = QuickBEAM.VM.Builtin.Symbol.builtin_spec() assert symbol.kind == :function assert symbol.constructor == nil assert Enum.any?( symbol.statics, - &match?(%{key: "iterator", value: %QuickBEAM.VM.Symbol{id: :iterator}}, &1) + &match?(%{key: "iterator", value: %QuickBEAM.VM.Runtime.Symbol{id: :iterator}}, &1) ) - assert Enum.map(QuickBEAM.VM.Builtins.Object.builtin_spec().statics, & &1.key) == + assert Enum.map(QuickBEAM.VM.Builtin.Object.builtin_spec().statics, & &1.key) == ~w(assign create defineProperty defineProperties freeze getOwnPropertyDescriptor getOwnPropertyNames getPrototypeOf keys setPrototypeOf) end @@ -279,6 +281,6 @@ defmodule QuickBEAM.VM.BuiltinDSLTest do end defp execution do - %Execution{atoms: {}, max_stack_depth: 10, remaining_steps: 100, step_limit: 100} + %State{atoms: {}, max_stack_depth: 10, remaining_steps: 100, step_limit: 100} end end diff --git a/test/vm/decoder_test.exs b/test/vm/bytecode/decoder_test.exs similarity index 88% rename from test/vm/decoder_test.exs rename to test/vm/bytecode/decoder_test.exs index 87befe3fd..426c5e37a 100644 --- a/test/vm/decoder_test.exs +++ b/test/vm/bytecode/decoder_test.exs @@ -1,7 +1,13 @@ -defmodule QuickBEAM.VM.DecoderTest do +defmodule QuickBEAM.VM.Bytecode.DecoderTest do use ExUnit.Case, async: true - alias QuickBEAM.VM.{ABI, Checksum, Function, InstructionDecoder, Opcodes, Program, Verifier} + alias QuickBEAM.VM.ABI + alias QuickBEAM.VM.Bytecode.Checksum + alias QuickBEAM.VM.Program.Function + alias QuickBEAM.VM.Bytecode.Instruction + alias QuickBEAM.VM.Bytecode.Opcode + alias QuickBEAM.VM.Program + alias QuickBEAM.VM.Bytecode.Verifier setup do {:ok, runtime} = QuickBEAM.start(apis: false) @@ -97,17 +103,17 @@ defmodule QuickBEAM.VM.DecoderTest do test "decodes serialized object keys as atom references" do key = "key" - atom_index = Opcodes.js_atom_end() + 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) ]) @@ -135,14 +141,16 @@ defmodule QuickBEAM.VM.DecoderTest do end test "rejects a truncated variable definition with a typed error" do - fixture = Path.expand("../fixtures/vm/fuzz/regressions/truncated-vardef-flags.bin", __DIR__) + 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 = Opcodes.num(:push_const) + opcode = Opcode.num(:push_const) bad_function = %{ program.root @@ -164,7 +172,7 @@ defmodule QuickBEAM.VM.DecoderTest do bad_jump = %{ program.root | instructions: - put_elem(program.root.instructions, 0, {Opcodes.num(:goto), [instruction_count]}) + put_elem(program.root.instructions, 0, {Opcode.num(:goto), [instruction_count]}) } assert {:error, {:invalid_instruction, 0, 0, {:invalid_index, :label, ^instruction_count}}} = @@ -172,7 +180,7 @@ defmodule QuickBEAM.VM.DecoderTest do underflow = %{ program.root - | instructions: {{Opcodes.num(:drop), []}, {Opcodes.num(:return_undef), []}}, + | instructions: {{Opcode.num(:drop), []}, {Opcode.num(:return_undef), []}}, source_positions: {{1, 1}, {1, 1}}, stack_size: 0 } @@ -188,7 +196,7 @@ defmodule QuickBEAM.VM.DecoderTest do invalid_operand = %{ program.root | instructions: - put_elem(program.root.instructions, 0, {Opcodes.num(:push_i32), [:not_an_integer]}) + put_elem(program.root.instructions, 0, {Opcode.num(:push_i32), [:not_an_integer]}) } assert {:error, {:invalid_instruction, 0, 0, :invalid_operand_type}} = @@ -196,10 +204,10 @@ defmodule QuickBEAM.VM.DecoderTest do end test "instruction decoder rejects labels inside an instruction" do - goto = Opcodes.num(:goto) + goto = Opcode.num(:goto) assert {:error, {:invalid_label, 1}} = - InstructionDecoder.decode(<>) + Instruction.decode(<>) end defp encoded_string(value), diff --git a/test/vm/leb128_test.exs b/test/vm/bytecode/varint_test.exs similarity index 91% rename from test/vm/leb128_test.exs rename to test/vm/bytecode/varint_test.exs index 42209d4cb..d1448b641 100644 --- a/test/vm/leb128_test.exs +++ b/test/vm/bytecode/varint_test.exs @@ -1,7 +1,7 @@ -defmodule QuickBEAM.VM.VarintTest do +defmodule QuickBEAM.VM.Bytecode.VarintTest do use ExUnit.Case, async: true - alias QuickBEAM.VM.Varint, as: VMVarint + alias QuickBEAM.VM.Bytecode.Varint, as: VMVarint test "delegates unsigned decoding to Varint.LEB128 and preserves the remainder" do encoded = Varint.LEB128.encode(300) diff --git a/test/vm/compiler_generated_module_test.exs b/test/vm/compiler/code_test.exs similarity index 76% rename from test/vm/compiler_generated_module_test.exs rename to test/vm/compiler/code_test.exs index 96663965b..a781bcd6e 100644 --- a/test/vm/compiler_generated_module_test.exs +++ b/test/vm/compiler/code_test.exs @@ -1,17 +1,21 @@ -defmodule QuickBEAM.VM.CompilerGeneratedModuleTest do +defmodule QuickBEAM.VM.Compiler.CodeTest do use ExUnit.Case, async: false - alias QuickBEAM.VM.Compiler.{Contract, Deopt, GeneratedModule, ModulePool, Runtime} + alias QuickBEAM.VM.Compiler.Contract + alias QuickBEAM.VM.Compiler.Deopt + alias QuickBEAM.VM.Compiler.Code + alias QuickBEAM.VM.Compiler.Pool + alias QuickBEAM.VM.Compiler.Runtime - alias QuickBEAM.VM.Compiler.GeneratedModule.{ - Artifact, - CodeLifecycle, - Emitter, - ImportPolicy, - Template - } + alias QuickBEAM.VM.Compiler.Code.Artifact + alias QuickBEAM.VM.Compiler.Code.Lifecycle + alias QuickBEAM.VM.Compiler.Code.Emitter + alias QuickBEAM.VM.Compiler.Code.Import + alias QuickBEAM.VM.Compiler.Code.Template - alias QuickBEAM.VM.{Execution, Frame, Function} + alias QuickBEAM.VM.Runtime.State + alias QuickBEAM.VM.Runtime.Frame + alias QuickBEAM.VM.Program.Function test "compiles a slot-specific module with only allowlisted runtime imports" do module = hd(Contract.pool_modules()) @@ -21,7 +25,7 @@ defmodule QuickBEAM.VM.CompilerGeneratedModuleTest do assert artifact.module == module assert artifact.digest == :crypto.hash(:sha256, artifact.binary) - assert {:ok, imports} = ImportPolicy.imports(artifact.binary) + assert {:ok, imports} = Import.imports(artifact.binary) assert imports == [ {Runtime, :deopt, 4}, @@ -29,14 +33,14 @@ defmodule QuickBEAM.VM.CompilerGeneratedModuleTest do {:erlang, :get_module_info, 2} ] - assert :ok = ImportPolicy.validate(artifact.binary) + 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} = ModulePool.checkout(pool, key, deopt_template()) - assert :ok = ModulePool.validate_lease(pool, lease) + assert {:ok, lease} = Pool.checkout(pool, key, deopt_template()) + assert :ok = Pool.validate_lease(pool, lease) frame = frame() execution = execution() @@ -48,12 +52,12 @@ defmodule QuickBEAM.VM.CompilerGeneratedModuleTest do assert deopt.generation == lease.generation assert deopt.frame == frame - assert :ok = ModulePool.checkin(pool, lease) + assert :ok = Pool.checkin(pool, lease) assert {:error, :stale_compiler_lease} = - GeneratedModule.invoke(pool, lease, frame, execution) + Code.invoke(pool, lease, frame, execution) - assert :ok = ModulePool.drain(pool) + assert :ok = Pool.drain(pool) assert :code.is_loaded(lease.module) == false end @@ -62,18 +66,18 @@ defmodule QuickBEAM.VM.CompilerGeneratedModuleTest do modules = for id <- 1..25 do - assert {:ok, lease} = ModulePool.checkout(pool, key(id), deopt_template()) + 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 = ModulePool.checkin(pool, lease) + assert :ok = Pool.checkin(pool, lease) lease.module end assert Enum.uniq(modules) == [hd(Contract.pool_modules())] - assert ModulePool.stats(pool).counts == %{ready: 1} + assert Pool.stats(pool).counts == %{ready: 1} end test "rejects a generated external call outside the runtime ABI" do @@ -102,7 +106,7 @@ defmodule QuickBEAM.VM.CompilerGeneratedModuleTest do assert {:ok, artifact} = Emitter.emit(key(1), module, deopt_template()) tampered = %{artifact | digest: <<0::256>>} - assert {:error, :artifact_digest_mismatch} = CodeLifecycle.install(module, tampered) + assert {:error, :artifact_digest_mismatch} = Lifecycle.install(module, tampered) end test "soft purge quarantines a slot instead of killing a live code reference" do @@ -111,30 +115,30 @@ defmodule QuickBEAM.VM.CompilerGeneratedModuleTest do runner = spawn(fn -> - {:ok, lease} = ModulePool.checkout(pool, key(1), blocking_template()) - :ok = ModulePool.checkin(pool, lease) + {: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} = ModulePool.checkout(pool, key(2), deopt_template()) + 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}}] = - ModulePool.stats(pool).slots + Pool.stats(pool).slots monitor = Process.monitor(runner) send(runner, :release) assert_receive {:DOWN, ^monitor, :process, ^runner, :normal} - assert :ok = CodeLifecycle.retire(module) + assert :ok = Lifecycle.retire(module) end defp start_pool(opts) do start_supervised!( - {ModulePool, + {Pool, Keyword.merge( - [backend: GeneratedModule, task_supervisor: QuickBEAM.VM.TaskSupervisor], + [backend: Code, task_supervisor: QuickBEAM.VM.TaskSupervisor], opts )} ) @@ -205,7 +209,7 @@ defmodule QuickBEAM.VM.CompilerGeneratedModuleTest do end defp execution do - %Execution{ + %State{ atoms: {}, max_stack_depth: 32, remaining_steps: 100, diff --git a/test/vm/compiler_contract_test.exs b/test/vm/compiler/contract_test.exs similarity index 95% rename from test/vm/compiler_contract_test.exs rename to test/vm/compiler/contract_test.exs index 716954117..d5cf8b228 100644 --- a/test/vm/compiler_contract_test.exs +++ b/test/vm/compiler/contract_test.exs @@ -1,8 +1,12 @@ -defmodule QuickBEAM.VM.CompilerContractTest do +defmodule QuickBEAM.VM.Compiler.ContractTest do use ExUnit.Case, async: false - alias QuickBEAM.VM.Compiler.{Contract, Deopt} - alias QuickBEAM.VM.{Execution, Frame, Function, Program} + alias QuickBEAM.VM.Compiler.Contract + alias QuickBEAM.VM.Compiler.Deopt + alias QuickBEAM.VM.Runtime.State + alias QuickBEAM.VM.Runtime.Frame + alias QuickBEAM.VM.Program.Function + alias QuickBEAM.VM.Program test "uses one fixed unique module atom set" do modules = Contract.pool_modules() @@ -153,7 +157,7 @@ defmodule QuickBEAM.VM.CompilerContractTest do end defp execution(program) do - %Execution{ + %State{ atoms: program.atoms, max_stack_depth: 32, remaining_steps: 100, diff --git a/test/vm/compiler/counter_test.exs b/test/vm/compiler/counter_test.exs new file mode 100644 index 000000000..76199eed6 --- /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.Runtime.State + alias QuickBEAM.VM.Program + + 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 similarity index 97% rename from test/vm/compiler_orchestration_test.exs rename to test/vm/compiler/orchestration_test.exs index d5578386a..d0cf7cd2b 100644 --- a/test/vm/compiler_orchestration_test.exs +++ b/test/vm/compiler/orchestration_test.exs @@ -1,13 +1,14 @@ -defmodule QuickBEAM.VM.CompilerOrchestrationTest do +defmodule QuickBEAM.VM.Compiler.OrchestrationTest do use ExUnit.Case, async: false - alias QuickBEAM.VM.{Compiler, Measurement} - alias QuickBEAM.VM.Compiler.ModulePool + alias QuickBEAM.VM.Compiler + alias QuickBEAM.VM.Measurement + alias QuickBEAM.VM.Compiler.Pool test "requires an explicitly supervised compiler service" do assert {:ok, program} = QuickBEAM.VM.compile("40 + 2") - assert {:error, {:compiler_error, {:compiler_pool_unavailable, ModulePool}}} = + assert {:error, {:compiler_error, {:compiler_pool_unavailable, Pool}}} = QuickBEAM.VM.eval(program, engine: :compiler) end @@ -53,7 +54,7 @@ defmodule QuickBEAM.VM.CompilerOrchestrationTest do assert {:ok, program} = QuickBEAM.VM.compile(source) assert {:ok, 40} = QuickBEAM.VM.eval(program, engine: :compiler) - stats = ModulePool.stats(ModulePool) + stats = Pool.stats(Pool) assert stats.counts.ready >= 1 assert stats.leases == 0 end @@ -278,7 +279,7 @@ defmodule QuickBEAM.VM.CompilerOrchestrationTest do assert cached.compiler_counters.generated_steps == 32 assert cached.compiler_counters.region_hot > 0 assert cached.compiler_counters.region_compiled == 0 - assert ModulePool.stats(ModulePool).region_admissions == 2 + assert Pool.stats(Pool).region_admissions == 2 Enum.each([1, 16, 32, 33, interpreted.steps - 1], fn step_limit -> interpreter_result = QuickBEAM.VM.eval(program, max_steps: step_limit) @@ -298,7 +299,7 @@ defmodule QuickBEAM.VM.CompilerOrchestrationTest do assert Task.await_many(tasks, 5_000) == List.duplicate({:ok, 42}, 40) - stats = ModulePool.stats(ModulePool) + stats = Pool.stats(Pool) assert stats.counts.ready >= 1 assert stats.leases == 0 assert stats.compilations == 0 diff --git a/test/vm/compiler_module_pool_test.exs b/test/vm/compiler/pool_test.exs similarity index 63% rename from test/vm/compiler_module_pool_test.exs rename to test/vm/compiler/pool_test.exs index 0943fac5a..169858798 100644 --- a/test/vm/compiler_module_pool_test.exs +++ b/test/vm/compiler/pool_test.exs @@ -1,12 +1,13 @@ -defmodule QuickBEAM.VM.CompilerModulePoolTest do +defmodule QuickBEAM.VM.Compiler.PoolTest do use ExUnit.Case, async: false - alias QuickBEAM.VM.Compiler.{Contract, ModulePool} + 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.ModulePool.Backend + @behaviour QuickBEAM.VM.Compiler.Pool.Backend @state __MODULE__.State @@ -113,13 +114,13 @@ defmodule QuickBEAM.VM.CompilerModulePoolTest do owners = for _ <- 1..20 do spawn_link(fn -> - result = ModulePool.checkout(pool, key, {:block, parent}) + result = Pool.checkout(pool, key, {:block, parent}) send(parent, {:checkout_result, self(), result}) receive do :release -> {:ok, lease} = result - send(parent, {:checkin_result, self(), ModulePool.checkin(pool, lease)}) + send(parent, {:checkin_result, self(), Pool.checkin(pool, lease)}) end end) end @@ -138,7 +139,7 @@ defmodule QuickBEAM.VM.CompilerModulePoolTest do assert MapSet.size(MapSet.new(results, & &1.token)) == 20 assert FakeBackend.state().compiles[key] == 1 - assert ModulePool.stats(pool).leases == 20 + assert Pool.stats(pool).leases == 20 Enum.each(owners, &send(&1, :release)) @@ -146,7 +147,7 @@ defmodule QuickBEAM.VM.CompilerModulePoolTest do assert_receive {:checkin_result, ^owner, :ok} end - assert eventually(fn -> ModulePool.stats(pool).leases == 0 end) + assert eventually(fn -> Pool.stats(pool).leases == 0 end) end test "removes a dead single-flight waiter without creating an orphan lease" do @@ -154,36 +155,36 @@ defmodule QuickBEAM.VM.CompilerModulePoolTest do key = key(1) parent = self() - waiter = spawn(fn -> ModulePool.checkout(pool, key, {:block, parent}) end) + 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 -> ModulePool.stats(pool).counts == %{ready: 1} end) - assert ModulePool.stats(pool).leases == 0 - assert {:ok, lease} = ModulePool.checkout(pool, 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 = ModulePool.checkin(pool, lease) + 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 = ModulePool.checkout_cached(pool, key) - assert :ok = ModulePool.remember_skip(pool, key(2)) - assert :skip = ModulePool.checkout_cached(pool, key(2)) - assert ModulePool.stats(pool).skips == 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} = ModulePool.checkout(pool, key, :cold_input) - assert :ok = ModulePool.checkin(pool, cold_lease) - assert {:ok, warm_lease} = ModulePool.checkout_cached(pool, key) + 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 = ModulePool.checkin(pool, warm_lease) + assert :ok = Pool.checkin(pool, warm_lease) end test "bounds shared negative decisions without allocating key atoms" do @@ -191,12 +192,12 @@ defmodule QuickBEAM.VM.CompilerModulePoolTest do atom_count = :erlang.system_info(:atom_count) for id <- 1..300 do - assert :ok = ModulePool.remember_skip(pool, key(id)) + assert :ok = Pool.remember_skip(pool, key(id)) end - assert ModulePool.stats(pool).skips == 256 - assert :miss = ModulePool.checkout_cached(pool, key(1)) - assert :skip = ModulePool.checkout_cached(pool, key(300)) + 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 @@ -204,23 +205,23 @@ defmodule QuickBEAM.VM.CompilerModulePoolTest do pool = start_pool(capacity: 1) atom_count = :erlang.system_info(:atom_count) - assert :cold = ModulePool.admit_region(pool, key(1)) - assert :cold = ModulePool.admit_region(pool, key(1)) - assert :hot = ModulePool.admit_region(pool, key(1)) - assert :hot = ModulePool.admit_region(pool, key(1)) + 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 = ModulePool.admit_region(pool, key(id)) + assert :cold = Pool.admit_region(pool, key(id)) end - stats = ModulePool.stats(pool) + stats = Pool.stats(pool) assert stats.region_admissions == 256 assert stats.region_hot == 1 assert stats.region_hot_capacity == 1 - assert :hot = ModulePool.admit_region(pool, key(1)) - assert :cold = ModulePool.admit_region(pool, key(2)) - assert :cold = ModulePool.admit_region(pool, key(2)) - assert :cold = ModulePool.admit_region(pool, key(2)) + 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 @@ -229,10 +230,10 @@ defmodule QuickBEAM.VM.CompilerModulePoolTest do pool = start_pool(capacity: 1) assert {:error, {:compiler_compile_failed, {:compile_task_exit, :lowering_crash}}} = - ModulePool.checkout(pool, key(1), {:exit, :lowering_crash}) + Pool.checkout(pool, key(1), {:exit, :lowering_crash}) - assert ModulePool.stats(pool).counts == %{free: 1} - assert {:ok, _lease} = ModulePool.checkout(pool, key(2)) + 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 @@ -240,11 +241,11 @@ defmodule QuickBEAM.VM.CompilerModulePoolTest do atom_count = :erlang.system_info(:atom_count) for id <- 1..100 do - assert {:ok, lease} = ModulePool.checkout(pool, key(id)) - assert :ok = ModulePool.checkin(pool, lease) + assert {:ok, lease} = Pool.checkout(pool, key(id)) + assert :ok = Pool.checkin(pool, lease) end - stats = ModulePool.stats(pool) + 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))) @@ -255,11 +256,11 @@ defmodule QuickBEAM.VM.CompilerModulePoolTest do test "does not evict an actively leased module" do pool = start_pool(capacity: 1) - assert {:ok, lease} = ModulePool.checkout(pool, key(1)) - assert {:error, :compiler_pool_busy} = ModulePool.checkout(pool, key(2)) - assert :ok = ModulePool.validate_lease(pool, lease) - assert :ok = ModulePool.checkin(pool, lease) - assert {:ok, replacement} = ModulePool.checkout(pool, key(2)) + 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 @@ -269,63 +270,63 @@ defmodule QuickBEAM.VM.CompilerModulePoolTest do owner = spawn(fn -> - result = ModulePool.checkout(pool, key(1)) + result = Pool.checkout(pool, key(1)) send(parent, {:owner_checkout, self(), result}) Process.sleep(:infinity) end) assert_receive {:owner_checkout, ^owner, {:ok, lease}} - assert ModulePool.stats(pool).leases == 1 + assert Pool.stats(pool).leases == 1 Process.exit(owner, :kill) - assert eventually(fn -> ModulePool.stats(pool).leases == 0 end) - assert {:error, :compiler_lease_owner_mismatch} = ModulePool.validate_lease(pool, lease) - assert {:ok, replacement} = ModulePool.checkout(pool, key(2)) + 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} = ModulePool.checkout(pool, key(1)) + assert {:ok, first} = Pool.checkout(pool, key(1)) - task = Task.async(fn -> ModulePool.validate_lease(pool, first) end) + task = Task.async(fn -> Pool.validate_lease(pool, first) end) assert {:error, :compiler_lease_owner_mismatch} = Task.await(task) - assert :ok = ModulePool.checkin(pool, first) - assert {:ok, second} = ModulePool.checkout(pool, key(2)) + assert :ok = Pool.checkin(pool, first) + assert {:ok, second} = Pool.checkout(pool, key(2)) assert second.generation > first.generation - assert {:error, :stale_compiler_lease} = ModulePool.validate_lease(pool, first) - assert {:error, :stale_compiler_lease} = ModulePool.checkin(pool, first) + 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 = ModulePool + name = Pool pool = start_pool(capacity: 1) - assert {:ok, lease} = ModulePool.checkout(pool, key(1)) - first_epoch = ModulePool.stats(pool).epoch + 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 ModulePool.stats(restarted).epoch == first_epoch - assert {:error, :stale_compiler_lease} = ModulePool.validate_lease(restarted, lease) + refute Pool.stats(restarted).epoch == first_epoch + assert {:error, :stale_compiler_lease} = Pool.validate_lease(restarted, lease) assert [%{status: :quarantined, reason: :live_code_reference}] = - ModulePool.stats(restarted).slots + Pool.stats(restarted).slots end test "quarantines a slot when soft retirement fails" do pool = start_pool(capacity: 1) - assert {:ok, lease} = ModulePool.checkout(pool, key(1)) - assert :ok = ModulePool.checkin(pool, lease) + 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} = ModulePool.checkout(pool, key(2)) + assert {:error, :compiler_pool_busy} = Pool.checkout(pool, key(2)) assert [%{status: :quarantined, reason: :live_code_reference}] = - ModulePool.stats(pool).slots + Pool.stats(pool).slots assert FakeBackend.state().retires == [lease.module] end @@ -335,10 +336,10 @@ defmodule QuickBEAM.VM.CompilerModulePoolTest do pool = start_pool(capacity: 1, compile_max_heap_bytes: 128 * 1024) assert {:error, {:compiler_compile_failed, {:compile_task_exit, :killed}}} = - ModulePool.checkout(pool, key(1), {:allocate, 1_000_000}) + Pool.checkout(pool, key(1), {:allocate, 1_000_000}) - assert ModulePool.stats(pool).counts == %{free: 1} - assert {:ok, _lease} = ModulePool.checkout(pool, key(2)) + 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 @@ -346,116 +347,115 @@ defmodule QuickBEAM.VM.CompilerModulePoolTest do key = key(1) parent = self() - task = Task.async(fn -> ModulePool.checkout(pool, key, {:block, parent}) end) + 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 ModulePool.stats(pool).counts == %{free: 1} - assert {:ok, _lease} = ModulePool.checkout(pool, key(2)) + 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}}} = - ModulePool.checkout(pool, key(1), {:install_error, :bad_beam}) + Pool.checkout(pool, key(1), {:install_error, :bad_beam}) assert [%{status: :quarantined, reason: {:install_failed, :bad_beam}}] = - ModulePool.stats(pool).slots + Pool.stats(pool).slots - assert {:error, :compiler_pool_busy} = ModulePool.checkout(pool, key(2)) + 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 -> ModulePool.checkout(pool, key, {:block, parent}) end) + waiter = Task.async(fn -> Pool.checkout(pool, key, {:block, parent}) end) assert_receive {:compile_started, ^key, _module, compiler_pid} - assert :ok = ModulePool.drain(pool, 1_000) + assert :ok = Pool.drain(pool, 1_000) assert {:error, {:compiler_compile_failed, :compiler_pool_stopping}} = Task.await(waiter) refute Process.alive?(compiler_pid) - assert ModulePool.stats(pool).mode == :drained - assert ModulePool.stats(pool).counts == %{free: 1} + 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} = ModulePool.checkout(pool, key(1)) + assert {:ok, lease} = Pool.checkout(pool, key(1)) - drain = Task.async(fn -> ModulePool.drain(pool, 1_000) end) - assert eventually(fn -> ModulePool.stats(pool).mode == :draining end) - assert {:error, :compiler_pool_stopping} = ModulePool.checkout(pool, key(2)) + 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 = ModulePool.checkin(pool, lease) + assert :ok = Pool.checkin(pool, lease) assert :ok = Task.await(drain) - assert ModulePool.stats(pool).mode == :drained - assert ModulePool.stats(pool).counts == %{free: 1} + 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} = ModulePool.checkout(pool, key(1)) + assert {:ok, lease} = Pool.checkout(pool, key(1)) - assert {:error, {:compiler_pool_shutdown_timeout, 1}} = ModulePool.drain(pool, 20) + assert {:error, {:compiler_pool_shutdown_timeout, 1}} = Pool.drain(pool, 20) assert FakeBackend.state().retires == [] - assert ModulePool.stats(pool).mode == :draining + assert Pool.stats(pool).mode == :draining - assert :ok = ModulePool.checkin(pool, lease) - assert eventually(fn -> ModulePool.stats(pool).mode == :drained end) + 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!( - {ModulePool, - backend: FakeBackend, task_supervisor: QuickBEAM.VM.TaskSupervisor, capacity: 1} + {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 ModulePool.stats(pool).counts == %{free: 1} + 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}} = - ModulePool.start_link(backend: FakeBackend, capacity: 1) + Pool.start_link(backend: FakeBackend, capacity: 1) assert {:error, {:invalid_option, :name, :another_pool}} = - ModulePool.start_link(backend: FakeBackend, 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>>}} = - ModulePool.checkout(self(), <<1>>) + Pool.checkout(self(), <<1>>) assert {:error, {{:missing_option, :backend}, _child}} = - start_supervised({ModulePool, []}) + start_supervised({Pool, []}) assert {:error, {{:invalid_compiler_backend, String}, _child}} = - start_supervised({ModulePool, backend: String}) + start_supervised({Pool, backend: String}) assert {:error, {{:invalid_option, :capacity, 33}, _child}} = - start_supervised({ModulePool, backend: FakeBackend, capacity: 33}) + start_supervised({Pool, backend: FakeBackend, capacity: 33}) assert {:error, {{:invalid_option, :compile_timeout, 0}, _child}} = - start_supervised({ModulePool, backend: FakeBackend, compile_timeout: 0}) + start_supervised({Pool, backend: FakeBackend, compile_timeout: 0}) assert {:error, {{:invalid_option, :compile_max_heap_bytes, 0}, _child}} = - start_supervised({ModulePool, backend: FakeBackend, compile_max_heap_bytes: 0}) + start_supervised({Pool, backend: FakeBackend, compile_max_heap_bytes: 0}) end defp start_pool(opts) do pool = start_supervised!( - {ModulePool, + {Pool, Keyword.merge( [backend: FakeBackend, task_supervisor: QuickBEAM.VM.TaskSupervisor], opts diff --git a/test/vm/compiler_pure_v1_test.exs b/test/vm/compiler/profile/pure_test.exs similarity index 82% rename from test/vm/compiler_pure_v1_test.exs rename to test/vm/compiler/profile/pure_test.exs index 1a5a86472..79314cef9 100644 --- a/test/vm/compiler_pure_v1_test.exs +++ b/test/vm/compiler/profile/pure_test.exs @@ -1,11 +1,22 @@ -defmodule QuickBEAM.VM.CompilerPureV1Test do +defmodule QuickBEAM.VM.Compiler.Profile.PureTest do use ExUnit.Case, async: false - alias QuickBEAM.VM.Compiler.Analysis.CFG - alias QuickBEAM.VM.Compiler.{Contract, Deopt, GeneratedModule, ModulePool, Runtime} - alias QuickBEAM.VM.Compiler.GeneratedModule.{Emitter, ImportPolicy} - alias QuickBEAM.VM.Compiler.Lowering.PureV1 - alias QuickBEAM.VM.{Execution, Frame, Function, Interpreter, Invocation, Opcodes, Program} + alias QuickBEAM.VM.Compiler.Analysis.ControlFlow, as: CFG + alias QuickBEAM.VM.Compiler.Contract + alias QuickBEAM.VM.Compiler.Deopt + alias QuickBEAM.VM.Compiler.Code + alias QuickBEAM.VM.Compiler.Pool + alias QuickBEAM.VM.Compiler.Runtime + alias QuickBEAM.VM.Compiler.Code.Emitter + alias QuickBEAM.VM.Compiler.Code.Import + alias QuickBEAM.VM.Compiler.Profile.Pure + alias QuickBEAM.VM.Runtime.State + alias QuickBEAM.VM.Runtime.Frame + alias QuickBEAM.VM.Program.Function + alias QuickBEAM.VM.Runtime.Interpreter + alias QuickBEAM.VM.Runtime.Invocation + alias QuickBEAM.VM.Bytecode.Opcode + alias QuickBEAM.VM.Program test "builds deterministic CFG blocks with canonical successors and predecessors" do function = branch_function() @@ -46,16 +57,16 @@ defmodule QuickBEAM.VM.CompilerPureV1Test do 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 PureV1.candidate?(small, 32, :pure_v1) - refute PureV1.candidate?(small, 32, :scalar_v1) - assert PureV1.candidate?(small, 1, :pure_v1) + 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 PureV1.candidate?(loop, 10_000, :pure_v1) - assert PureV1.candidate?(loop, 10_000, :scalar_v1) + assert Pure.candidate?(loop, 10_000, :pure_v1) + assert Pure.candidate?(loop, 10_000, :scalar_v1) end test "extracts a bounded scalar entry region from an oversized function" do @@ -66,11 +77,11 @@ defmodule QuickBEAM.VM.CompilerPureV1Test do function = Enum.find(program.root.constants, &is_struct(&1, Function)) - assert {:ok, whole_template, _count} = PureV1.prepare(function, 32, :scalar_v1) - refute PureV1.scalar_template?(whole_template) + assert {:ok, whole_template, _count} = Pure.prepare(function, 32, :scalar_v1) + refute Pure.scalar_template?(whole_template) - assert {:ok, region_template, 32} = PureV1.prepare_region(function, 0, :scalar_v1) - assert PureV1.scalar_template?(region_template) + assert {:ok, region_template, 32} = Pure.prepare_region(function, 0, :scalar_v1) + assert Pure.scalar_template?(region_template) assert [{:function, _, :block, 7, clauses}] = Enum.filter(region_template.forms, &match?({:function, _, :block, 7, _}, &1)) @@ -89,11 +100,11 @@ defmodule QuickBEAM.VM.CompilerPureV1Test do {:stack, :push_i32, [2]}, {:value, :add, []} ], :unsupported_opcode} - }} = PureV1.plan(function) + }} = Pure.plan(function) - assert {:skip, 3} = PureV1.prepare(function, 4) - assert {:ok, prepared, 3} = PureV1.prepare(function, 3) - assert {:ok, template} = PureV1.lower(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] == @@ -102,7 +113,7 @@ defmodule QuickBEAM.VM.CompilerPureV1Test do module = hd(Contract.pool_modules()) assert {:ok, artifact} = Emitter.emit(key(1), module, template) - assert {:ok, imports} = ImportPolicy.imports(artifact.binary) + 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 @@ -116,14 +127,14 @@ defmodule QuickBEAM.VM.CompilerPureV1Test do for value <- 1..100 do instructions = put_elem(function.instructions, 0, instruction(:push_i32, [value])) - assert {:ok, _template} = PureV1.lower(%{function | id: value, instructions: instructions}) + 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} = PureV1.lower(%{function | id: value, instructions: instructions}) + assert {:ok, _template} = Pure.lower(%{function | id: value, instructions: instructions}) end assert :erlang.system_info(:atom_count) == atom_count @@ -136,10 +147,10 @@ defmodule QuickBEAM.VM.CompilerPureV1Test do emit = fn value -> instructions = put_elem(function.instructions, 0, instruction(:push_i32, [value])) function = %{function | id: value, instructions: instructions} - assert {:ok, template} = PureV1.lower(function) + assert {:ok, template} = Pure.lower(function) assert {:ok, artifact_key} = Contract.artifact_key(program(function), function) - assert {:ok, lease} = ModulePool.checkout(pool, artifact_key, template) - assert :ok = ModulePool.checkin(pool, lease) + assert {:ok, lease} = Pool.checkout(pool, artifact_key, template) + assert :ok = Pool.checkin(pool, lease) end Enum.each(1..5, emit) @@ -164,7 +175,7 @@ defmodule QuickBEAM.VM.CompilerPureV1Test do %{ 0 => {[{:stack, :undefined, []}], :suspension_boundary}, 2 => {[], :unsupported_opcode} - }} = PureV1.plan(function) + }} = Pure.plan(function) end test "caps one generated block plan at 256 instructions" do @@ -172,7 +183,7 @@ defmodule QuickBEAM.VM.CompilerPureV1Test do 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}}} = PureV1.plan(function) + assert {:ok, %{0 => {operations, :unsupported_semantics}}} = Pure.plan(function) assert length(operations) == 256 end @@ -188,7 +199,7 @@ defmodule QuickBEAM.VM.CompilerPureV1Test do } assert {:error, {:compiler_resource_limit, :blocks, 4_098, 4_096}} = - PureV1.lower(function) + Pure.lower(function) too_many_operations = Enum.flat_map(0..16, fn block -> @@ -199,7 +210,7 @@ defmodule QuickBEAM.VM.CompilerPureV1Test do function = %{function | instructions: List.to_tuple(too_many_operations), stack_size: 4_352} assert {:error, {:compiler_resource_limit, :lowered_instructions, 4_352, 4_096}} = - PureV1.lower(function) + Pure.lower(function) end test "runs a lowered pure prefix and resumes the interpreter before return" do @@ -207,19 +218,19 @@ defmodule QuickBEAM.VM.CompilerPureV1Test do program = program(function) pool = start_pool() - assert {:ok, template} = PureV1.lower(function) + assert {:ok, template} = Pure.lower(function) assert {:ok, artifact_key} = Contract.artifact_key(program, function) - assert {:ok, lease} = ModulePool.checkout(pool, artifact_key, template) + assert {:ok, lease} = Pool.checkout(pool, artifact_key, template) frame = frame(function) execution = execution(4) - assert {:ok, plan} = PureV1.plan(function) + assert {:ok, plan} = Pure.plan(function) assert {:deopt, %Deopt{} = unspecialized} = Runtime.execute_plan(lease, frame, execution, plan) assert {:deopt, %Deopt{} = deopt} = - GeneratedModule.invoke(pool, lease, frame, execution) + Code.invoke(pool, lease, frame, execution) assert %{deopt.frame | compiler_allow_reentry: false} == unspecialized.frame assert deopt.execution == unspecialized.execution @@ -227,7 +238,7 @@ defmodule QuickBEAM.VM.CompilerPureV1Test do assert deopt.frame.pc == 3 assert deopt.frame.stack == [42] assert deopt.execution.remaining_steps == 1 - assert :ok = ModulePool.checkin(pool, lease) + assert :ok = Pool.checkin(pool, lease) assert Interpreter.resume_deopt(deopt) == {:ok, 42} assert Interpreter.eval(program, max_steps: 4) == {:ok, 42} @@ -238,15 +249,15 @@ defmodule QuickBEAM.VM.CompilerPureV1Test do program = program(function) pool = start_pool() - assert {:ok, template} = PureV1.lower(function) + assert {:ok, template} = Pure.lower(function) assert {:ok, artifact_key} = Contract.artifact_key(program, function) - assert {:ok, lease} = ModulePool.checkout(pool, artifact_key, template) + assert {:ok, lease} = Pool.checkout(pool, artifact_key, template) assert {:deopt, %Deopt{} = deopt} = - GeneratedModule.invoke(pool, lease, frame(function), execution(3)) + Code.invoke(pool, lease, frame(function), execution(3)) assert deopt.execution.remaining_steps == 0 - assert :ok = ModulePool.checkin(pool, lease) + assert :ok = Pool.checkin(pool, lease) expected = {:error, {:limit_exceeded, :steps, 3}} assert Interpreter.resume_deopt(deopt) == expected @@ -257,14 +268,14 @@ defmodule QuickBEAM.VM.CompilerPureV1Test do source = "(function(n){let s=0; 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} = PureV1.lower(function) + assert {:ok, template} = Pure.lower(function) assert {:ok, artifact_key} = Contract.artifact_key(program, function) - assert {:ok, lease} = ModulePool.checkout(pool, artifact_key, template) + 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} = - GeneratedModule.invoke(pool, lease, frame, execution) + Code.invoke(pool, lease, frame, execution) assert deopt.frame.pc > 0 - assert :ok = ModulePool.checkin(pool, lease) + assert :ok = Pool.checkin(pool, lease) assert Interpreter.resume_deopt(deopt) == QuickBEAM.VM.eval(program, max_steps: 100) end end @@ -415,18 +426,18 @@ defmodule QuickBEAM.VM.CompilerPureV1Test do assert %Function{} = function = Enum.find(program.root.constants, &is_struct(&1, Function)) pool = start_pool() - assert {:ok, template} = PureV1.lower(function) + assert {:ok, template} = Pure.lower(function) assert {:ok, artifact_key} = Contract.artifact_key(program, function) - assert {:ok, lease} = ModulePool.checkout(pool, artifact_key, template) + 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} = - GeneratedModule.invoke(pool, lease, frame, execution) + Code.invoke(pool, lease, frame, execution) assert deopt.frame.pc > 0 - assert :ok = ModulePool.checkin(pool, lease) + assert :ok = Pool.checkin(pool, lease) assert Interpreter.resume_deopt(deopt) == {:ok, 42} end @@ -435,18 +446,18 @@ defmodule QuickBEAM.VM.CompilerPureV1Test do program = program(function) pool = start_pool() - assert {:ok, template} = PureV1.lower(function) + assert {:ok, template} = Pure.lower(function) assert {:ok, artifact_key} = Contract.artifact_key(program, function) - assert {:ok, lease} = ModulePool.checkout(pool, artifact_key, template) + assert {:ok, lease} = Pool.checkout(pool, artifact_key, template) assert {:deopt, %Deopt{} = deopt} = - GeneratedModule.invoke(pool, lease, frame(function), execution(10)) + 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 = ModulePool.checkin(pool, lease) + assert :ok = Pool.checkin(pool, lease) assert Interpreter.resume_deopt(deopt) == {:ok, 10} assert Interpreter.eval(program, max_steps: 10) == {:ok, 10} @@ -454,8 +465,7 @@ defmodule QuickBEAM.VM.CompilerPureV1Test do defp start_pool do start_supervised!( - {ModulePool, - backend: GeneratedModule, task_supervisor: QuickBEAM.VM.TaskSupervisor, capacity: 1} + {Pool, backend: Code, task_supervisor: QuickBEAM.VM.TaskSupervisor, capacity: 1} ) end @@ -489,7 +499,7 @@ defmodule QuickBEAM.VM.CompilerPureV1Test do } end - defp instruction(name, operands \\ []), do: {Opcodes.num(name), operands} + defp instruction(name, operands \\ []), do: {Opcode.num(name), operands} defp program(function) do %Program{ @@ -511,7 +521,7 @@ defmodule QuickBEAM.VM.CompilerPureV1Test do end defp execution(steps) do - %Execution{ + %State{ atoms: {}, max_stack_depth: 32, remaining_steps: steps, diff --git a/test/vm/compiler_region_probe_test.exs b/test/vm/compiler/region/probe_test.exs similarity index 69% rename from test/vm/compiler_region_probe_test.exs rename to test/vm/compiler/region/probe_test.exs index 89526f76c..9bd235267 100644 --- a/test/vm/compiler_region_probe_test.exs +++ b/test/vm/compiler/region/probe_test.exs @@ -1,8 +1,12 @@ -defmodule QuickBEAM.VM.CompilerRegionProbeTest do +defmodule QuickBEAM.VM.Compiler.Region.ProbeTest do use ExUnit.Case, async: true - alias QuickBEAM.VM.Compiler.{Context, RegionProbe} - alias QuickBEAM.VM.{Execution, Frame, Function, Program} + alias QuickBEAM.VM.Compiler.Context + alias QuickBEAM.VM.Compiler.Region.Probe + alias QuickBEAM.VM.Runtime.State + alias QuickBEAM.VM.Runtime.Frame + alias QuickBEAM.VM.Program.Function + alias QuickBEAM.VM.Program test "samples fixed-capacity integer regions in the evaluation owner" do execution = execution() @@ -12,11 +16,11 @@ defmodule QuickBEAM.VM.CompilerRegionProbeTest do frame = frame(region, region * 64) Enum.reduce(1..16, execution, fn _sample, execution -> - RegionProbe.observe(execution, frame) + Probe.observe(execution, frame) end) end) - snapshot = RegionProbe.snapshot(execution) + snapshot = Probe.snapshot(execution) assert snapshot.sample_interval == 16 assert snapshot.window_size == 64 assert snapshot.total_samples == 65 @@ -24,16 +28,16 @@ defmodule QuickBEAM.VM.CompilerRegionProbeTest do 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 -> RegionProbe.snapshot(execution) end)) == nil + assert Task.await(Task.async(fn -> Probe.snapshot(execution) end)) == nil end defp execution do - %Execution{ + %State{ atoms: {}, compiler_context: %Context{ pool: self(), program: %Program{version: 26, fingerprint: "test", atoms: {}, root: nil}, - region_probe: RegionProbe.new() + region_probe: Probe.new() }, max_stack_depth: 8, remaining_steps: 8, diff --git a/test/vm/compiler_runtime_test.exs b/test/vm/compiler/runtime_test.exs similarity index 91% rename from test/vm/compiler_runtime_test.exs rename to test/vm/compiler/runtime_test.exs index 20078da45..57746eee1 100644 --- a/test/vm/compiler_runtime_test.exs +++ b/test/vm/compiler/runtime_test.exs @@ -1,9 +1,13 @@ -defmodule QuickBEAM.VM.CompilerRuntimeTest do +defmodule QuickBEAM.VM.Compiler.RuntimeTest do use ExUnit.Case, async: true - alias QuickBEAM.VM.Compiler.{Contract, Deopt, Runtime} - alias QuickBEAM.VM.Compiler.ModulePool.Lease - alias QuickBEAM.VM.{Execution, Frame, Function} + alias QuickBEAM.VM.Compiler.Contract + alias QuickBEAM.VM.Compiler.Deopt + alias QuickBEAM.VM.Compiler.Runtime + alias QuickBEAM.VM.Compiler.Pool.Lease + alias QuickBEAM.VM.Runtime.State + alias QuickBEAM.VM.Runtime.Frame + alias QuickBEAM.VM.Program.Function test "charges guaranteed blocks exactly and deoptimizes before a partial block" do frame = frame() @@ -106,7 +110,7 @@ defmodule QuickBEAM.VM.CompilerRuntimeTest do end defp execution(steps) do - %Execution{ + %State{ atoms: {}, max_stack_depth: 32, remaining_steps: steps, diff --git a/test/vm/compiler_counters_test.exs b/test/vm/compiler_counters_test.exs deleted file mode 100644 index a9b6602ea..000000000 --- a/test/vm/compiler_counters_test.exs +++ /dev/null @@ -1,33 +0,0 @@ -defmodule QuickBEAM.VM.CompilerCountersTest do - use ExUnit.Case, async: true - - alias QuickBEAM.VM.Compiler.{Context, Counters} - alias QuickBEAM.VM.{Execution, Program} - - test "keeps fixed OTP counters in the evaluation owner" do - execution = %Execution{ - atoms: {}, - compiler_context: %Context{ - counters: Counters.new(), - pool: self(), - program: %Program{version: 26, fingerprint: "test", atoms: {}, root: nil} - }, - max_stack_depth: 8, - remaining_steps: 8, - step_limit: 8 - } - - execution = Counters.increment(execution, :frame_attempts) - assert Counters.snapshot(execution).frame_attempts == 1 - - task = - Task.async(fn -> - foreign = Counters.increment(execution, :frame_attempts) - Counters.snapshot(foreign) - end) - - assert Task.await(task) == nil - assert Counters.snapshot(execution).frame_attempts == 1 - assert map_size(Counters.snapshot(execution).deopt_opcodes) == 0 - end -end diff --git a/test/vm/memory_limit_test.exs b/test/vm/memory_limit_test.exs index 69767c4e3..b8e670562 100644 --- a/test/vm/memory_limit_test.exs +++ b/test/vm/memory_limit_test.exs @@ -1,7 +1,8 @@ defmodule QuickBEAM.VM.MemoryLimitTest do use ExUnit.Case, async: false - alias QuickBEAM.VM.{Memory, Object} + 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 diff --git a/test/vm/program_store_test.exs b/test/vm/program/store_test.exs similarity index 63% rename from test/vm/program_store_test.exs rename to test/vm/program/store_test.exs index e0120e243..ab2a9b8b3 100644 --- a/test/vm/program_store_test.exs +++ b/test/vm/program/store_test.exs @@ -1,15 +1,15 @@ -defmodule QuickBEAM.VM.ProgramStoreTest do +defmodule QuickBEAM.VM.Program.StoreTest do use ExUnit.Case, async: false alias QuickBEAM.VM.Program - alias QuickBEAM.VM.ProgramStore + 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 -> ProgramStore.pin(program) end, + |> Task.async_stream(fn _index -> Store.pin(program) end, max_concurrency: 20, ordered: false ) @@ -17,27 +17,27 @@ defmodule QuickBEAM.VM.ProgramStoreTest do assert length(Enum.uniq(pinned)) == 1 [handle | _rest] = pinned - assert {:ok, lease} = ProgramStore.checkout(handle) - assert {:ok, ^program} = ProgramStore.fetch(lease) - ProgramStore.checkin(lease) - assert :ok = ProgramStore.unpin(handle) - assert eventually(fn -> ProgramStore.unpin(handle) == :not_pinned end) + 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} = ProgramStore.pin(program) + assert {:ok, pinned} = Store.pin(program) parent = self() tasks = Enum.map(1..20, fn _index -> Task.async(fn -> - {:ok, lease} = ProgramStore.checkout(pinned) + {:ok, lease} = Store.checkout(pinned) send(parent, {:lease, lease}) receive do - :finish -> ProgramStore.checkin(lease) + :finish -> Store.checkin(lease) end end) end) @@ -50,44 +50,44 @@ defmodule QuickBEAM.VM.ProgramStoreTest do end) assert Enum.uniq_by(leases, &{&1.slot, &1.token}) |> length() == 1 - assert Enum.all?(leases, fn lease -> ProgramStore.fetch(lease) == {:ok, program} end) - assert :ok = ProgramStore.unpin(pinned) - assert Enum.all?(leases, fn lease -> ProgramStore.fetch(lease) == {:ok, program} end) + 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) Enum.each(tasks, &send(&1.pid, :finish)) Enum.each(tasks, &Task.await/1) - assert eventually(fn -> ProgramStore.unpin(pinned) == :not_pinned end) - assert Enum.all?(leases, fn lease -> ProgramStore.fetch(lease) == {:error, :stale_lease} end) + 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} = ProgramStore.pin(program) + assert {:ok, pinned} = Store.pin(program) - old_store = Process.whereis(ProgramStore) + 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(ProgramStore)) end) + assert eventually(fn -> is_pid(Process.whereis(Store)) end) - assert {:ok, lease} = ProgramStore.checkout(pinned) - assert {:ok, ^program} = ProgramStore.fetch(lease) - ProgramStore.checkin(lease) - assert :ok = ProgramStore.unpin(pinned) + 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} = ProgramStore.pin(program) - assert :not_pinned = ProgramStore.unpin(program) + 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} = ProgramStore.pin(program) - assert :not_pinned = ProgramStore.unpin(program) + 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 @@ -96,57 +96,57 @@ defmodule QuickBEAM.VM.ProgramStoreTest do pinned = Enum.map(1..4, fn _index -> candidate = %{program(:crypto.strong_rand_bytes(32)) | root: payload} - assert {:ok, pinned} = ProgramStore.pin(candidate) + assert {:ok, pinned} = Store.pin(candidate) pinned end) - on_exit(fn -> Enum.each(pinned, &ProgramStore.unpin/1) end) + on_exit(fn -> Enum.each(pinned, &Store.unpin/1) end) candidate = %{program(:crypto.strong_rand_bytes(32)) | root: payload} - assert {:error, :residency_budget} = ProgramStore.pin(candidate) + 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} = ProgramStore.pin(program(:crypto.strong_rand_bytes(32))) + assert {:ok, pinned} = Store.pin(program(:crypto.strong_rand_bytes(32))) pinned end) - on_exit(fn -> Enum.each(pinned, &ProgramStore.unpin/1) end) + on_exit(fn -> Enum.each(pinned, &Store.unpin/1) end) ninth = program(:crypto.strong_rand_bytes(32)) - assert :unavailable = ProgramStore.pin(ninth) + assert :unavailable = Store.pin(ninth) leases = Enum.map(pinned, fn handle -> - assert {:ok, lease} = ProgramStore.checkout(handle) + assert {:ok, lease} = Store.checkout(handle) lease end) - Enum.each(leases, &ProgramStore.checkin/1) + 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} = ProgramStore.pin(program) + assert {:ok, pinned} = Store.pin(program) parent = self() {owner, monitor} = spawn_monitor(fn -> - {:ok, lease} = ProgramStore.checkout(pinned) + {:ok, lease} = Store.checkout(pinned) send(parent, {:leased, lease}) Process.sleep(:infinity) end) assert_receive {:leased, lease} - assert :ok = ProgramStore.unpin(pinned) - assert {:ok, ^program} = ProgramStore.fetch(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 -> ProgramStore.unpin(pinned) == :not_pinned end) - assert {:error, :stale_lease} = ProgramStore.fetch(lease) + 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 diff --git a/test/vm/properties_test.exs b/test/vm/properties_test.exs deleted file mode 100644 index 3d4116d41..000000000 --- a/test/vm/properties_test.exs +++ /dev/null @@ -1,59 +0,0 @@ -defmodule QuickBEAM.VM.PropertiesTest do - use ExUnit.Case, async: true - - alias QuickBEAM.VM.{Builtins, Execution, Heap, Invocation, Properties, Reference} - - 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} = - Properties.define_accessor(prototype, "value", :getter, getter, execution) - - {:ok, execution} = - Properties.define_accessor(prototype, "value", :setter, setter, execution) - - assert {:ok, {:accessor, ^getter, ^object}} = Properties.get(object, "value", execution) - assert {:error, {:invoke_setter, ^setter}} = Properties.put(object, "value", 42, execution) - end - - test "resolves primitive and callable properties through intrinsic prototypes" do - execution = Builtins.install(execution()) - {callable, execution} = Heap.allocate(execution, :function, callable: {:builtin, "callable"}) - - assert {:ok, %Reference{} = bind} = Properties.get(callable, "bind", execution) - assert Invocation.callable?(bind, execution) - assert {:ok, "bind"} = Properties.get(bind, "name", execution) - assert {:ok, 2} = Properties.get("😀", "length", execution) - assert {:ok, <<0xED, 0xA0, 0xBD>>} = Properties.get("😀", 0, execution) - - assert {:ok, %Reference{} = to_string} = Properties.get(42, "toString", execution) - assert Invocation.callable?(to_string, execution) - assert {:ok, "toString"} = Properties.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} = - Properties.define(object, "hidden", 1, execution, enumerable: false) - - {:ok, execution} = Properties.define(object, 2, 2, execution) - {:ok, execution} = Properties.set_prototype(object, prototype, execution) - - assert {:ok, [2]} = Properties.enumerable_keys(object, execution) - assert {:ok, ["2", "hidden"]} = Properties.own_property_names(object, execution) - assert {:ok, ^prototype} = Properties.prototype(object, execution) - assert Properties.prototype_chain_contains?(object, prototype, execution) - assert Properties.has_property?(object, "hidden", execution) - end - - defp execution do - %Execution{atoms: {}, max_stack_depth: 10, remaining_steps: 100, step_limit: 100} - end -end diff --git a/test/vm/async_semantics_test.exs b/test/vm/runtime/async_semantics_test.exs similarity index 86% rename from test/vm/async_semantics_test.exs rename to test/vm/runtime/async_semantics_test.exs index 9f2270614..a0710de2a 100644 --- a/test/vm/async_semantics_test.exs +++ b/test/vm/runtime/async_semantics_test.exs @@ -1,16 +1,14 @@ -defmodule QuickBEAM.VM.AsyncSemanticsTest do +defmodule QuickBEAM.VM.Runtime.AsyncSemanticsTest do use ExUnit.Case, async: true - alias QuickBEAM.VM.{ - Async, - AsyncBoundary, - Execution, - Frame, - Function, - Invocation, - Promise, - Reaction - } + alias QuickBEAM.VM.Runtime.Async + alias QuickBEAM.VM.Runtime.Boundary + alias QuickBEAM.VM.Runtime.State + alias QuickBEAM.VM.Runtime.Frame + alias QuickBEAM.VM.Program.Function + alias QuickBEAM.VM.Runtime.Invocation + alias QuickBEAM.VM.Runtime.Promise + alias QuickBEAM.VM.Runtime.Promise.Reaction test "enters async functions through an owner-local Promise boundary" do execution = execution() @@ -20,7 +18,7 @@ defmodule QuickBEAM.VM.AsyncSemanticsTest do assert {:run, %Frame{function: ^function, args: {7}}, execution} = Async.enter(function, function, {}, [7], :receiver, caller, execution, false) - assert %AsyncBoundary{mode: :push, caller: ^caller, promise: promise} = + assert %Boundary.Async{mode: :push, caller: ^caller, promise: promise} = hd(execution.callers) assert Promise.state(execution, promise) == :pending @@ -92,7 +90,7 @@ defmodule QuickBEAM.VM.AsyncSemanticsTest do end defp execution do - %Execution{atoms: {}, max_stack_depth: 10, remaining_steps: 100, step_limit: 100} + %State{atoms: {}, max_stack_depth: 10, remaining_steps: 100, step_limit: 100} end defp frame do diff --git a/test/vm/async_test.exs b/test/vm/runtime/async_test.exs similarity index 98% rename from test/vm/async_test.exs rename to test/vm/runtime/async_test.exs index d31a2b6c1..8b84a0fab 100644 --- a/test/vm/async_test.exs +++ b/test/vm/runtime/async_test.exs @@ -1,4 +1,4 @@ -defmodule QuickBEAM.VM.AsyncTest do +defmodule QuickBEAM.VM.Runtime.AsyncTest do use ExUnit.Case, async: true test "awaits an asynchronous BEAM handler without blocking the evaluation process" do diff --git a/test/vm/error_test.exs b/test/vm/runtime/error_test.exs similarity index 99% rename from test/vm/error_test.exs rename to test/vm/runtime/error_test.exs index 943d7ccbe..83c33b34d 100644 --- a/test/vm/error_test.exs +++ b/test/vm/runtime/error_test.exs @@ -1,4 +1,4 @@ -defmodule QuickBEAM.VM.ErrorTest do +defmodule QuickBEAM.VM.Runtime.ErrorTest do use ExUnit.Case, async: true test "returns source-mapped JavaScript errors with JavaScript call frames" do diff --git a/test/vm/exceptions_test.exs b/test/vm/runtime/exception_test.exs similarity index 71% rename from test/vm/exceptions_test.exs rename to test/vm/runtime/exception_test.exs index c833c6f9f..70308e0b1 100644 --- a/test/vm/exceptions_test.exs +++ b/test/vm/runtime/exception_test.exs @@ -1,23 +1,20 @@ -defmodule QuickBEAM.VM.ExceptionsTest do +defmodule QuickBEAM.VM.Runtime.ExceptionTest do use ExUnit.Case, async: true - alias QuickBEAM.VM.{ - AsyncBoundary, - Execution, - Exceptions, - Frame, - Function, - Promise, - PromiseExecutorBoundary, - ThenableBoundary, - Thrown - } + alias QuickBEAM.VM.Runtime.Boundary + alias QuickBEAM.VM.Runtime.State + alias QuickBEAM.VM.Runtime.Exception + alias QuickBEAM.VM.Runtime.Frame + alias QuickBEAM.VM.Program.Function + alias QuickBEAM.VM.Runtime.Promise + + 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} = Exceptions.throw_at("boom", frame, execution) + assert {:run, resumed, ^execution} = Exception.throw_at("boom", frame, execution) assert resumed.pc == 7 assert resumed.stack == ["boom", :below] end @@ -28,7 +25,7 @@ defmodule QuickBEAM.VM.ExceptionsTest do execution = %{execution() | callers: [caller], depth: 2} assert {:error, %QuickBEAM.JSError{} = error, execution} = - Exceptions.throw_at({:type_error, :not_callable}, current, 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"] @@ -44,14 +41,14 @@ defmodule QuickBEAM.VM.ExceptionsTest do | promises: Map.put(execution.promises, thenable_promise.id, :resolving) } - thenable = %ThenableBoundary{promise: thenable_promise, depth: 1} - assert {:idle, execution} = Exceptions.throw_from("then failed", thenable, execution) + 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 = %PromiseExecutorBoundary{ + boundary = %Boundary.PromiseExecutor{ promise: executor_promise, caller: caller, depth: 1, @@ -59,7 +56,7 @@ defmodule QuickBEAM.VM.ExceptionsTest do } assert {:complete, ^executor_promise, ^caller, execution, false} = - Exceptions.throw_from("executor failed", boundary, execution) + Exception.throw_from("executor failed", boundary, execution) assert {:rejected, %Thrown{value: "executor failed"}} = Promise.state(execution, executor_promise) @@ -69,11 +66,11 @@ defmodule QuickBEAM.VM.ExceptionsTest do execution = execution() {promise, execution} = Promise.new(execution) caller = frame("caller.js", "caller") - boundary = %AsyncBoundary{promise: promise, caller: caller, depth: 1, mode: :push} + boundary = %Boundary.Async{promise: promise, caller: caller, depth: 1, mode: :push} execution = %{execution | callers: [boundary], depth: 2} assert {:async, {:complete, ^promise, ^caller, execution, false}} = - Exceptions.throw_at("async failed", frame("async.js", "load"), execution) + Exception.throw_at("async failed", frame("async.js", "load"), execution) assert {:rejected, %Thrown{value: "async failed", frames: [async_frame]}} = Promise.state(execution, promise) @@ -84,7 +81,7 @@ defmodule QuickBEAM.VM.ExceptionsTest do end defp execution do - %Execution{atoms: {}, max_stack_depth: 10, remaining_steps: 100, step_limit: 100} + %State{atoms: {}, max_stack_depth: 10, remaining_steps: 100, step_limit: 100} end defp frame(filename, name) do diff --git a/test/vm/heap_test.exs b/test/vm/runtime/heap_test.exs similarity index 90% rename from test/vm/heap_test.exs rename to test/vm/runtime/heap_test.exs index 7a6f19c38..8e5000ab5 100644 --- a/test/vm/heap_test.exs +++ b/test/vm/runtime/heap_test.exs @@ -1,7 +1,10 @@ -defmodule QuickBEAM.VM.HeapTest do +defmodule QuickBEAM.VM.Runtime.HeapTest do use ExUnit.Case, async: true - alias QuickBEAM.VM.{Execution, Export, Heap, Property} + alias QuickBEAM.VM.Runtime.State + alias QuickBEAM.VM.Runtime.Value.Export + alias QuickBEAM.VM.Runtime.Heap + alias QuickBEAM.VM.Runtime.Property.Descriptor test "resolves inherited properties through the prototype chain" do execution = execution() @@ -45,13 +48,13 @@ defmodule QuickBEAM.VM.HeapTest do assert {:ok, ordinary_object} = Heap.fetch_object(execution, ordinary) assert ordinary_object.properties["answer"] == {42} - assert {:ok, %Property{value: 42}} = Heap.own_property(execution, ordinary, "answer") + 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 %Property{value: "fixed", writable: false} = array_object.properties[1] - assert {:ok, %Property{value: :undefined}} = Heap.own_property(execution, array, 0) + 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 @@ -123,6 +126,6 @@ defmodule QuickBEAM.VM.HeapTest do end defp execution do - %Execution{atoms: {}, max_stack_depth: 10, remaining_steps: 10, step_limit: 10} + %State{atoms: {}, max_stack_depth: 10, remaining_steps: 10, step_limit: 10} end end diff --git a/test/vm/interpreter_test.exs b/test/vm/runtime/interpreter_test.exs similarity index 98% rename from test/vm/interpreter_test.exs rename to test/vm/runtime/interpreter_test.exs index 9ca0f8932..29a9ee450 100644 --- a/test/vm/interpreter_test.exs +++ b/test/vm/runtime/interpreter_test.exs @@ -1,7 +1,9 @@ -defmodule QuickBEAM.VM.InterpreterTest do +defmodule QuickBEAM.VM.Runtime.InterpreterTest do use ExUnit.Case, async: true - alias QuickBEAM.VM.{Continuation, Interpreter, Program} + alias QuickBEAM.VM.Runtime.Continuation + alias QuickBEAM.VM.Runtime.Interpreter + alias QuickBEAM.VM.Program test "evaluates arithmetic and comparisons in an isolated BEAM process" do assert {:ok, program} = QuickBEAM.VM.compile("(2 + 3 * 4) === 14") diff --git a/test/vm/invocation_test.exs b/test/vm/runtime/invocation_test.exs similarity index 80% rename from test/vm/invocation_test.exs rename to test/vm/runtime/invocation_test.exs index fc8999856..abd139358 100644 --- a/test/vm/invocation_test.exs +++ b/test/vm/runtime/invocation_test.exs @@ -1,8 +1,14 @@ -defmodule QuickBEAM.VM.InvocationTest do +defmodule QuickBEAM.VM.Runtime.InvocationTest do use ExUnit.Case, async: true - alias QuickBEAM.VM.Builtin.{Action, Call} - alias QuickBEAM.VM.{ConstructorBoundary, Execution, Frame, Function, Heap, Invocation} + alias QuickBEAM.VM.Builtin.Action + alias QuickBEAM.VM.Builtin.Call + alias QuickBEAM.VM.Runtime.Boundary + alias QuickBEAM.VM.Runtime.State + alias QuickBEAM.VM.Runtime.Frame + alias QuickBEAM.VM.Program.Function + alias QuickBEAM.VM.Runtime.Heap + alias QuickBEAM.VM.Runtime.Invocation test "plans ordinary and closure calls as explicit frame entries" do execution = execution() @@ -31,7 +37,7 @@ defmodule QuickBEAM.VM.InvocationTest do assert {:dispatch, :target, [1, 2], :bound_receiver, ^caller, ^execution, false} = Invocation.plan(bound, [2], :ignored, caller, execution) - boundary = %ConstructorBoundary{instance: :instance, caller: caller, depth: 1} + 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) @@ -64,15 +70,15 @@ defmodule QuickBEAM.VM.InvocationTest do } assert {:ok, {:bound_function, ^target, :receiver, [1, 2]}, ^execution} = - QuickBEAM.VM.Builtins.Function.bind(call) + QuickBEAM.VM.Builtin.Function.bind(call) assert %Action{ value: {:dispatch, ^target, [1, 2], :receiver, ^caller, ^execution, true} - } = QuickBEAM.VM.Builtins.Function.call(%{call | tail?: true}) + } = QuickBEAM.VM.Builtin.Function.call(%{call | tail?: true}) end defp execution do - %Execution{atoms: {}, max_stack_depth: 10, remaining_steps: 100, step_limit: 100} + %State{atoms: {}, max_stack_depth: 10, remaining_steps: 100, step_limit: 100} end defp frame do diff --git a/test/vm/object_model_test.exs b/test/vm/runtime/object_test.exs similarity index 99% rename from test/vm/object_model_test.exs rename to test/vm/runtime/object_test.exs index 540e82534..986cc5c09 100644 --- a/test/vm/object_model_test.exs +++ b/test/vm/runtime/object_test.exs @@ -1,4 +1,4 @@ -defmodule QuickBEAM.VM.ObjectModelTest do +defmodule QuickBEAM.VM.Runtime.ObjectTest do use ExUnit.Case, async: false setup do diff --git a/test/vm/opcode_families_test.exs b/test/vm/runtime/opcode_test.exs similarity index 86% rename from test/vm/opcode_families_test.exs rename to test/vm/runtime/opcode_test.exs index 822a0f1cc..f5c4e09ce 100644 --- a/test/vm/opcode_families_test.exs +++ b/test/vm/runtime/opcode_test.exs @@ -1,9 +1,18 @@ -defmodule QuickBEAM.VM.OpcodeFamiliesTest do +defmodule QuickBEAM.VM.Runtime.OpcodeTest do use ExUnit.Case, async: true - alias QuickBEAM.VM.{Execution, Frame, Function, Heap, Object, Properties} - alias QuickBEAM.VM.Opcodes.{Control, Locals, Objects, Stack, Values} - alias QuickBEAM.VM.Opcodes.Invocation, as: CallOpcodes + alias QuickBEAM.VM.Runtime.State + alias QuickBEAM.VM.Runtime.Frame + alias QuickBEAM.VM.Program.Function + alias QuickBEAM.VM.Runtime.Heap + alias QuickBEAM.VM.Runtime.Object + alias QuickBEAM.VM.Runtime.Property + alias QuickBEAM.VM.Runtime.Opcode.Control + 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.Opcode.Invocation, as: CallOpcodes test "opcode families publish non-overlapping routing tables" do opcodes = @@ -136,7 +145,7 @@ defmodule QuickBEAM.VM.OpcodeFamiliesTest do function = %Function{id: 3, has_prototype: true} {constructor, execution} = Heap.allocate(execution, :function, callable: function) {prototype, execution} = Heap.allocate(execution) - {:ok, execution} = Properties.define(constructor, "prototype", prototype, execution) + {:ok, execution} = Property.define(constructor, "prototype", prototype, execution) assert {:invoke_constructor, ^constructor, [:argument], instance, %Frame{stack: [:rest]}, execution} = @@ -157,8 +166,8 @@ defmodule QuickBEAM.VM.OpcodeFamiliesTest do getter = {:builtin, "getter"} setter = {:builtin, "setter"} - {:ok, execution} = Properties.define_accessor(object, "value", :getter, getter, execution) - {:ok, execution} = Properties.define_accessor(object, "value", :setter, setter, execution) + {: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) @@ -173,8 +182,8 @@ defmodule QuickBEAM.VM.OpcodeFamiliesTest do assert {:next, %Frame{stack: [array, :rest]}, execution} = Objects.execute(:array_from, [2], frame([2, 1, :rest]), execution) - assert {:ok, 1} = Properties.get(array, 0, execution) - assert {:ok, 2} = Properties.get(array, 1, 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) @@ -186,7 +195,7 @@ defmodule QuickBEAM.VM.OpcodeFamiliesTest do {constructor, execution} = Heap.allocate(execution, :function, callable: function) {prototype, execution} = Heap.allocate(execution) {object, execution} = Heap.allocate(execution, :ordinary, prototype: prototype) - {:ok, execution} = Properties.define(constructor, "prototype", prototype, execution) + {:ok, execution} = Property.define(constructor, "prototype", prototype, execution) assert {:next, %Frame{stack: [true]}, ^execution} = Values.execute(:is_function, [], frame([constructor]), execution) @@ -196,7 +205,7 @@ defmodule QuickBEAM.VM.OpcodeFamiliesTest do end defp execution do - %Execution{atoms: {}, max_stack_depth: 10, remaining_steps: 100, step_limit: 100} + %State{atoms: {}, max_stack_depth: 10, remaining_steps: 100, step_limit: 100} end defp frame(stack) do diff --git a/test/vm/promise_test.exs b/test/vm/runtime/promise_test.exs similarity index 99% rename from test/vm/promise_test.exs rename to test/vm/runtime/promise_test.exs index d10005151..f4a3a8427 100644 --- a/test/vm/promise_test.exs +++ b/test/vm/runtime/promise_test.exs @@ -1,4 +1,4 @@ -defmodule QuickBEAM.VM.PromiseTest do +defmodule QuickBEAM.VM.Runtime.PromiseTest do use ExUnit.Case, async: false setup do diff --git a/test/vm/runtime/property_test.exs b/test/vm/runtime/property_test.exs new file mode 100644 index 000000000..fbf043f7f --- /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.State + alias QuickBEAM.VM.Runtime.Heap + alias QuickBEAM.VM.Runtime.Invocation + alias QuickBEAM.VM.Runtime.Property + alias QuickBEAM.VM.Runtime.Reference + + 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/value_test.exs b/test/vm/runtime/value_test.exs similarity index 95% rename from test/vm/value_test.exs rename to test/vm/runtime/value_test.exs index d9ac44c7b..4a9e13cf2 100644 --- a/test/vm/value_test.exs +++ b/test/vm/runtime/value_test.exs @@ -1,7 +1,8 @@ -defmodule QuickBEAM.VM.ValueTest do +defmodule QuickBEAM.VM.Runtime.ValueTest do use ExUnit.Case, async: true - alias QuickBEAM.VM.{Symbol, Value} + 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 diff --git a/test/vm/vue_ssr_test.exs b/test/vm/vue_ssr_test.exs index ad91f63c5..08c30d439 100644 --- a/test/vm/vue_ssr_test.exs +++ b/test/vm/vue_ssr_test.exs @@ -2,7 +2,7 @@ defmodule QuickBEAM.VM.VueSSRTest do use ExUnit.Case, async: false alias QuickBEAM.VM.Compiler - alias QuickBEAM.VM.Compiler.ModulePool + alias QuickBEAM.VM.Compiler.Pool @fixture "test/fixtures/vm/vue_ssr.js" @bundle_opts [ @@ -75,7 +75,7 @@ defmodule QuickBEAM.VM.VueSSRTest do assert compiler_html == native_html assert scalar_html == native_html assert pinned_compiler_html == native_html - stats = ModulePool.stats(ModulePool) + stats = Pool.stats(Pool) assert stats.counts.ready >= 1 assert stats.skips >= 1 after From e958cf2115bf88ddf5094f8f665d8583e92eabe2 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 17 Jul 2026 15:14:16 +0200 Subject: [PATCH 75/87] Use complete OTP 27 release in CI --- .github/workflows/ci.yml | 8 ++++---- .github/workflows/vm-fuzz.yml | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e6604d09..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 @@ -85,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 @@ -98,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 index 2dca753d2..7c5a3ca0d 100644 --- a/.github/workflows/vm-fuzz.yml +++ b/.github/workflows/vm-fuzz.yml @@ -17,7 +17,7 @@ jobs: - uses: erlef/setup-beam@v1 with: - otp-version: '27.0' + otp-version: '27.3' elixir-version: '1.18' - name: Cache dependencies @@ -26,8 +26,8 @@ jobs: path: | deps _build - key: ${{ runner.os }}-vm-fuzz-27.0-1.18-${{ hashFiles('mix.lock') }} - restore-keys: ${{ runner.os }}-vm-fuzz-27.0-1.18- + 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 From b4d5252cc01c841a068e49e4df3621d782abc343 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 17 Jul 2026 15:40:46 +0200 Subject: [PATCH 76/87] Stabilize the BEAM VM public API --- bench/vm_compiler_perf.exs | 11 +- bench/vm_scheduler_probe.exs | 5 +- bench/vm_ssr.exs | 5 +- lib/quickbeam/vm.ex | 617 ++++++------------ lib/quickbeam/vm/bytecode/verifier.ex | 10 +- lib/quickbeam/vm/measurement.ex | 12 +- lib/quickbeam/vm/program.ex | 18 +- lib/quickbeam/vm/program/function.ex | 35 +- lib/quickbeam/vm/program/identity.ex | 22 + lib/quickbeam/vm/program/pinned.ex | 6 +- lib/quickbeam/vm/program/source.ex | 3 + lib/quickbeam/vm/program/store.ex | 7 + lib/quickbeam/vm/program/variable.ex | 11 + lib/quickbeam/vm/program/variable/closure.ex | 9 + lib/quickbeam/vm/runtime/engine.ex | 404 ++++++++++++ .../vm/runtime/engine/measurement.ex | 29 + mix.exs | 1 + test/support/test262.ex | 13 +- test/vm/api_test.exs | 61 ++ test/vm/compiler/orchestration_test.exs | 96 +-- test/vm/compiler/profile/pure_test.exs | 51 +- test/vm/memory_limit_test.exs | 10 - test/vm/program/store_test.exs | 9 +- test/vm/svelte_ssr_test.exs | 16 +- test/vm/vue_ssr_test.exs | 18 +- 25 files changed, 933 insertions(+), 546 deletions(-) create mode 100644 lib/quickbeam/vm/program/identity.ex create mode 100644 lib/quickbeam/vm/runtime/engine.ex create mode 100644 lib/quickbeam/vm/runtime/engine/measurement.ex create mode 100644 test/vm/api_test.exs diff --git a/bench/vm_compiler_perf.exs b/bench/vm_compiler_perf.exs index 7a0ec0aea..71ca89e41 100644 --- a/bench/vm_compiler_perf.exs +++ b/bench/vm_compiler_perf.exs @@ -1,6 +1,7 @@ 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 @@ -32,7 +33,7 @@ end {runtime_init_us, {:ok, 0}} = :timer.tc(fn -> - QuickBEAM.VM.eval(initialization_program, isolation: :caller, max_steps: 1_000_000) + Engine.eval(initialization_program, isolation: :caller, max_steps: 1_000_000) end) IO.puts("COMPILER_PERF runtime_initialization_us=#{runtime_init_us} profile=core") @@ -49,8 +50,8 @@ try do max_steps: 1_000_000 ] - {cold_us, compiled_result} = :timer.tc(fn -> QuickBEAM.VM.eval(program, compiler_opts) end) - interpreted_result = QuickBEAM.VM.eval(program, interpreter_opts) + {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) @@ -65,10 +66,10 @@ try do end compiler_us = - average_us.(fn -> ^compiled_result = QuickBEAM.VM.eval(program, compiler_opts) end) + average_us.(fn -> ^compiled_result = Engine.eval(program, compiler_opts) end) interpreter_us = - average_us.(fn -> ^interpreted_result = QuickBEAM.VM.eval(program, interpreter_opts) end) + average_us.(fn -> ^interpreted_result = Engine.eval(program, interpreter_opts) end) speedup = interpreter_us / compiler_us diff --git a/bench/vm_scheduler_probe.exs b/bench/vm_scheduler_probe.exs index 776c0be00..314ae4398 100644 --- a/bench/vm_scheduler_probe.exs +++ b/bench/vm_scheduler_probe.exs @@ -2,6 +2,7 @@ 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 [ @@ -70,7 +71,7 @@ defmodule QuickBEAM.Bench.VMSchedulerProbe do timeout_wall = Enum.map(1..samples, fn _iteration -> {:ok, measurement} = - QuickBEAM.VM.measure(timeout_program, + Engine.measure(timeout_program, engine: engine, compiler_profile: compiler_profile, max_steps: 1_000_000_000, @@ -132,7 +133,7 @@ defmodule QuickBEAM.Bench.VMSchedulerProbe do handler = fn [] -> fixture.props end {:ok, measurement} = - QuickBEAM.VM.measure( + Engine.measure( fixture.program, [ engine: engine, diff --git a/bench/vm_ssr.exs b/bench/vm_ssr.exs index 1d437e28f..fed60c5bf 100644 --- a/bench/vm_ssr.exs +++ b/bench/vm_ssr.exs @@ -4,6 +4,7 @@ defmodule QuickBEAM.Bench.VMSSR do """ alias QuickBEAM.VM.Compiler + alias QuickBEAM.VM.Runtime.Engine @default_samples 30 @default_warmup 3 @@ -282,7 +283,7 @@ defmodule QuickBEAM.Bench.VMSSR do |> Keyword.put(:handlers, %{"load_props" => handler}) |> Keyword.put(:timeout, timeout_ms) - {:ok, measurement} = QuickBEAM.VM.measure(fixture.program, opts) + {:ok, measurement} = Engine.measure(fixture.program, opts) handler_pid = receive do @@ -323,7 +324,7 @@ defmodule QuickBEAM.Bench.VMSSR do |> Keyword.merge(Keyword.drop(overrides, [:handler_delay])) |> Keyword.put(:handlers, %{"load_props" => handler}) - {:ok, measurement} = QuickBEAM.VM.measure(fixture.program, eval_opts) + {:ok, measurement} = Engine.measure(fixture.program, eval_opts) measurement end diff --git a/lib/quickbeam/vm.ex b/lib/quickbeam/vm.ex index 982cff2e3..dd44c32e2 100644 --- a/lib/quickbeam/vm.ex +++ b/lib/quickbeam/vm.ex @@ -1,45 +1,103 @@ defmodule QuickBEAM.VM do @moduledoc """ - Compile and validate QuickJS bytecode for execution by the BEAM engine. + 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. + 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. """ alias QuickBEAM.VM.ABI - alias QuickBEAM.VM.Compiler alias QuickBEAM.VM.Bytecode.Decoder - alias QuickBEAM.VM.Runtime - alias QuickBEAM.VM.Program.Function + alias QuickBEAM.VM.Bytecode.Verifier alias QuickBEAM.VM.Measurement alias QuickBEAM.VM.Program - alias QuickBEAM.VM.Program.Store + alias QuickBEAM.VM.Program.Function + alias QuickBEAM.VM.Program.Identity alias QuickBEAM.VM.Program.Pinned - alias QuickBEAM.VM.Bytecode.Verifier - - @type program :: QuickBEAM.VM.Program.t() - @type pinned_program :: QuickBEAM.VM.Program.Pinned.t() + 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_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 - @default_timeout 5_000 - @default_memory_limit 64 * 1024 * 1024 - @worker_heap_overhead 4 * 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. Use `decode/2` when - bytecode has already been compiled by this exact QuickJS build. + 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(), keyword()) :: {:ok, program()} | {:error, term()} - def compile(source, opts \\ []) when is_binary(source) and is_list(opts) do - {runtime_options, opts} = Keyword.pop(opts, :runtime_options, []) - {filename, decode_options} = Keyword.pop(opts, :filename) - runtime_options = Keyword.put(runtime_options, :apis, false) - - with :ok <- validate_filename(filename), - {:ok, runtime} <- QuickBEAM.start(runtime_options) do + @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 <- validate_options(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 @@ -47,7 +105,7 @@ defmodule QuickBEAM.VM do program |> maybe_put_filename(filename) |> Map.put(:source_digest, :crypto.hash(:sha256, source)) - |> Program.put_pin_key() + |> Identity.put() {:ok, program} end @@ -57,459 +115,174 @@ defmodule QuickBEAM.VM do 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 <- validate_options(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. + 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()) :: {:ok, Pinned.t()} | {:error, term()} + @spec pin(Program.t()) :: result(Pinned.t()) def pin(%Program{} = program) do - program = Program.put_pin_key(program) + 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 - @doc "Decodes and verifies bytecode from this exact QuickJS build." - @spec decode(binary(), keyword()) :: {:ok, program()} | {:error, term()} - def decode(bytecode, opts \\ []) when is_binary(bytecode) and is_list(opts) do - {max_bytecode_bytes, verifier_options} = - Keyword.pop(opts, :max_bytecode_bytes, @max_bytecode_bytes) - - with :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, Program.put_pin_key(program)} - end - 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 + def pin(_program), do: {:error, :invalid_program} @doc """ - Evaluates a verified program in an isolated BEAM process. - - Supported options include the explicit `:engine` (`:interpreter` or - `:compiler`), the experimental compiler `:compiler_profile` (`:pure_v1` by - default or opt-in `:scalar_v1`), the quarantined `:compiler_regions` experiment, - `:vars`, asynchronous `:handlers`, the builtin `:profile` - (`:core` or `:ssr`), `:timeout`, `:max_steps`, `:max_stack_depth`, and the - JavaScript allocation budget `:memory_limit`. Isolated workers also receive a - BEAM process heap ceiling. `isolation: :caller` is available for trusted - diagnostics. The compiler engine requires a supervised `QuickBEAM.VM.Compiler`. + 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(), keyword()) :: {:ok, term()} | {:error, term()} + @spec eval(Program.t() | Pinned.t(), [evaluation_option()]) :: result(term()) def eval(program, opts \\ []) - def eval(%Pinned{} = pinned, opts) when is_list(opts) do - with {:ok, options} <- evaluation_options(opts), - {:ok, lease} <- pinned_lease(pinned) do - try do - case options.isolation do - :caller -> evaluate_pinned_caller(lease, options) - :process -> eval_isolated_pinned(lease, options) - end - after - Store.checkin(lease) - end + def eval(program, opts) when is_list(opts) do + with :ok <- validate_options(opts, @evaluation_options) do + Engine.eval(program, Keyword.put(opts, :engine, :interpreter)) end end - def eval(%Program{} = program, opts) when is_list(opts) do - with :ok <- Verifier.verify(program), - {:ok, options} <- evaluation_options(opts) do - case options.isolation do - :caller -> evaluate(program, options) - :process -> eval_isolated(program, options) - end - end - end + def eval(_program, opts), do: {:error, {:invalid_options, opts}} @doc """ - Evaluates a program with the same isolation and limits as `eval/2`, returning - its result together with deterministic step/logical-memory counters, bounded - compiler telemetry when selected, and endpoint process observations. + Evaluates with the same isolation and limits as `eval/2` and returns resource + observations in a `QuickBEAM.VM.Measurement`. - Evaluation failures, including resource limits, are stored in - `measurement.result`. Invalid programs or options are returned directly as - `{:error, reason}` because no evaluation was started. + 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(), keyword()) :: - {:ok, Measurement.t()} | {:error, term()} + @spec measure(Program.t() | Pinned.t(), [evaluation_option()]) :: result(Measurement.t()) def measure(program, opts \\ []) - def measure(%Pinned{} = pinned, opts) when is_list(opts) do - with {:ok, options} <- evaluation_options(opts), - {:ok, lease} <- pinned_lease(pinned) do - 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 - - def measure(%Program{} = program, opts) when is_list(opts) do - with :ok <- Verifier.verify(program), - {:ok, options} <- evaluation_options(opts) do - 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 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 - ] - - case Keyword.keys(opts) -- allowed do - [] -> validate_evaluation_options(opts) - [unknown | _] -> {:error, {:unknown_option, unknown}} - 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, %{}) - - cond do - isolation not in [:caller, :process] -> - {:error, {:invalid_option, :isolation, isolation}} - - engine not in [:interpreter, :compiler] -> - {:error, {:invalid_option, :engine, engine}} - - not (is_atom(compiler_pool) or is_pid(compiler_pool)) -> - {:error, {:invalid_option, :compiler_pool, compiler_pool}} - - compiler_profile not in [:pure_v1, :scalar_v1] -> - {:error, {:invalid_option, :compiler_profile, compiler_profile}} - - not is_boolean(compiler_region_probe) -> - {:error, {:invalid_option, :compiler_region_probe, compiler_region_probe}} - - not is_boolean(compiler_regions) -> - {:error, {:invalid_option, :compiler_regions, compiler_regions}} - - timeout != :infinity and (not is_integer(timeout) or timeout <= 0) -> - {:error, {:invalid_option, :timeout, timeout}} - - not is_integer(max_steps) or max_steps <= 0 -> - {:error, {:invalid_option, :max_steps, max_steps}} - - not is_integer(max_stack_depth) or max_stack_depth <= 0 -> - {:error, {:invalid_option, :max_stack_depth, max_stack_depth}} - - memory_limit != :infinity and (not is_integer(memory_limit) or memory_limit <= 0) -> - {:error, {:invalid_option, :memory_limit, memory_limit}} - - profile not in [:core, :ssr] -> - {:error, {:invalid_option, :profile, profile}} - - not is_map(vars) -> - {:error, {:invalid_option, :vars, vars}} - - not is_map(handlers) or - not Enum.all?(handlers, fn {name, handler} -> - is_binary(name) and is_function(handler, 1) - end) -> - {:error, {:invalid_option, :handlers, handlers}} - - true -> - {: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 - } - }} + def measure(program, opts) when is_list(opts) do + with :ok <- validate_options(opts, @evaluation_options), + {:ok, measurement} <- + Engine.measure(program, Keyword.put(opts, :engine, :interpreter)) do + {:ok, public_measurement(measurement)} end end - 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}), - do: Runtime.eval(program, Map.to_list(options)) - - defp evaluate(program, %{engine: :compiler, interpreter: options}), - do: Compiler.eval(program, Map.to_list(options)) - - 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 + def measure(_program, opts), do: {:error, {:invalid_options, opts}} - 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 + @doc """ + Unpins a handle after its current evaluations finish. - 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} + 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 - defp safe_measure(program, %{engine: engine, interpreter: options}) do - {result, metrics} = measure_engine(engine, program, Map.to_list(options)) + def unpin(_pinned), do: {:error, :invalid_pinned_program} - 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), - do: Runtime.eval_with_metrics(program, options) - - defp measure_engine(:compiler, program, options), - do: Compiler.eval_with_metrics(program, options) - - defp engine_crash(:interpreter), do: :interpreter_crash - defp engine_crash(:compiler), do: :compiler_crash + @doc "Returns the exact vendored QuickJS bytecode ABI fingerprint." + @spec fingerprint() :: String.t() + defdelegate fingerprint(), to: ABI - defp measurement({:measured, result, metrics}, wall_time_us) do - metrics = metrics || %{} + @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: 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) + 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 - @doc """ - Unpins a handle after its current evaluations finish. - - Returns `:not_pinned` when the handle is already 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 | :not_pinned - def unpin(%Pinned{} = pinned), do: Store.unpin(pinned) - - @doc "Returns the monitored worker spawn options for an evaluation memory limit." - def worker_spawn_options(:infinity), do: [:monitor] + defp validate_options(opts, allowed) do + if Keyword.keyword?(opts) do + case Keyword.keys(opts) -- allowed do + [] -> :ok + [unknown | _] -> {:error, {:unknown_option, unknown}} + end + else + {:error, {:invalid_options, opts}} + end + end - def 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) + defp validate_max_bytecode_bytes(limit) + when is_integer(limit) and limit > 0 and limit <= @max_bytecode_bytes, + do: :ok - [ - :monitor, - {:max_heap_size, %{size: max_heap_words, kill: true, error_logger: false}} - ] - end + defp validate_max_bytecode_bytes(limit), + do: {:error, {:invalid_option, :max_bytecode_bytes, limit}} - defp await_evaluation(pid, monitor_ref, reply_ref, :infinity, memory_limit) do - receive do - {^reply_ref, result} -> - Process.demonitor(monitor_ref, [:flush]) - result + defp within_bytecode_limit(bytecode, limit) when byte_size(bytecode) <= limit, do: :ok - {:DOWN, ^monitor_ref, :process, ^pid, reason} -> - evaluation_exit(reason, memory_limit) - end - end + defp within_bytecode_limit(bytecode, _limit), + do: {:error, {:limit_exceeded, :bytecode_bytes, byte_size(bytecode)}} - 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) - {:error, {:limit_exceeded, :timeout, timeout}} - end - end + 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 evaluation_exit(:killed, memory_limit) when is_integer(memory_limit), - do: {:error, {:limit_exceeded, :memory_bytes, memory_limit}} + defp maybe_put_filename(program, nil), do: program - defp evaluation_exit(reason, _memory_limit), - do: {:error, {:evaluation_process_exit, reason}} + defp maybe_put_filename(program, filename) when is_binary(filename), + do: update_filename(program, filename) - 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 + defp update_filename(%Function{} = function, filename) do + constants = Enum.map(function.constants, &update_filename(&1, filename)) + %{function | filename: filename, constants: constants} end - @doc "Returns the exact vendored QuickJS bytecode ABI fingerprint." - defdelegate fingerprint(), to: ABI + defp update_filename(%{root: root} = program, filename), + do: %{program | root: update_filename(root, filename)} - @doc "Returns the vendored QuickJS bytecode version." - defdelegate bytecode_version(), to: ABI + defp update_filename(value, _filename), do: value end diff --git a/lib/quickbeam/vm/bytecode/verifier.ex b/lib/quickbeam/vm/bytecode/verifier.ex index 65cebcfaa..561735ab4 100644 --- a/lib/quickbeam/vm/bytecode/verifier.ex +++ b/lib/quickbeam/vm/bytecode/verifier.ex @@ -46,12 +46,12 @@ defmodule QuickBEAM.VM.Bytecode.Verifier do defp limits(opts) do Enum.reduce_while(opts, {:ok, @default_limits}, fn - {key, value}, {:ok, limits} - when is_map_key(@default_limits, key) and is_integer(value) and value > 0 -> - {:cont, {:ok, Map.put(limits, key, value)}} + {key, value}, {:ok, limits} when is_map_key(@default_limits, key) -> + maximum = Map.fetch!(@default_limits, key) - {key, _value}, _acc when is_map_key(@default_limits, key) -> - {:halt, {:error, {:invalid_limit, 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}}} diff --git a/lib/quickbeam/vm/measurement.ex b/lib/quickbeam/vm/measurement.ex index 643435343..cd2054d07 100644 --- a/lib/quickbeam/vm/measurement.ex +++ b/lib/quickbeam/vm/measurement.ex @@ -3,12 +3,8 @@ defmodule QuickBEAM.VM.Measurement do Resource and timing observations for one isolated VM evaluation. `steps` and `logical_memory_bytes` come from deterministic VM accounting. - `compiler_counters` snapshots fixed-size owner-local OTP `:counters` when the - compiler engine is selected. Its compilation/cache decision fields - are lifecycle observations. `compiler_regions` contains bounded sampled - heavy hitters only when the explicit region probe is enabled. - `process_memory_bytes` and `reductions` are - endpoint observations from the evaluation process, not sampled peaks. `wall_time_us` includes isolated + `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. """ @@ -18,8 +14,6 @@ defmodule QuickBEAM.VM.Measurement do :wall_time_us, :steps, :logical_memory_bytes, - :compiler_counters, - :compiler_regions, :process_memory_bytes, :reductions ] @@ -29,8 +23,6 @@ defmodule QuickBEAM.VM.Measurement do 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 } diff --git a/lib/quickbeam/vm/program.ex b/lib/quickbeam/vm/program.ex index a13b92c8c..91885fca1 100644 --- a/lib/quickbeam/vm/program.ex +++ b/lib/quickbeam/vm/program.ex @@ -6,6 +6,10 @@ defmodule QuickBEAM.VM.Program do 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] @@ -24,22 +28,10 @@ defmodule QuickBEAM.VM.Program do version: non_neg_integer(), fingerprint: String.t(), atoms: tuple(), - root: term(), + root: QuickBEAM.VM.Program.Function.t(), bytecode_digest: binary() | nil, bytecode_size: non_neg_integer() | nil, source_digest: binary() | nil, pin_key: binary() | nil } - - @doc "Derives the binary identity used by bounded immutable program sharing." - @spec put_pin_key(t()) :: t() - def put_pin_key(%__MODULE__{} = 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/function.ex b/lib/quickbeam/vm/program/function.ex index 89b7f26e2..239a480cb 100644 --- a/lib/quickbeam/vm/program/function.ex +++ b/lib/quickbeam/vm/program/function.ex @@ -1,8 +1,6 @@ defmodule QuickBEAM.VM.Program.Function do @moduledoc "JavaScript function metadata and pre-resolved VM instructions used by the interpreter and BEAM compiler." - @type t :: %__MODULE__{} - defstruct [ :id, :name, @@ -21,7 +19,6 @@ defmodule QuickBEAM.VM.Program.Function do closure_vars: [], constants: [], atoms: nil, - extra_atoms: [], instructions: nil, has_prototype: false, has_simple_parameter_list: false, @@ -35,4 +32,36 @@ defmodule QuickBEAM.VM.Program.Function do 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 index 71e25f9fd..d96e749d1 100644 --- a/lib/quickbeam/vm/program/pinned.ex +++ b/lib/quickbeam/vm/program/pinned.ex @@ -4,11 +4,13 @@ defmodule QuickBEAM.VM.Program.Pinned do 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`. + 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()} + @opaque t :: %__MODULE__{key: binary()} end diff --git a/lib/quickbeam/vm/program/source.ex b/lib/quickbeam/vm/program/source.ex index 5fac5e9f1..c79caf145 100644 --- a/lib/quickbeam/vm/program/source.ex +++ b/lib/quickbeam/vm/program/source.ex @@ -1,7 +1,10 @@ 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), diff --git a/lib/quickbeam/vm/program/store.ex b/lib/quickbeam/vm/program/store.ex index f967d0fb8..d1695927a 100644 --- a/lib/quickbeam/vm/program/store.ex +++ b/lib/quickbeam/vm/program/store.ex @@ -35,6 +35,7 @@ defmodule QuickBEAM.VM.Program.Store do @spec pin(Program.t(), GenServer.server()) :: {:ok, Pinned.t()} | :unavailable + | :retiring | {:error, :program_too_large | :residency_budget} def pin(program, server \\ __MODULE__) @@ -130,6 +131,9 @@ defmodule QuickBEAM.VM.Program.Store do @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)} @@ -141,6 +145,9 @@ defmodule QuickBEAM.VM.Program.Store do 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)} diff --git a/lib/quickbeam/vm/program/variable.ex b/lib/quickbeam/vm/program/variable.ex index 272a765de..ad59a56d5 100644 --- a/lib/quickbeam/vm/program/variable.ex +++ b/lib/quickbeam/vm/program/variable.ex @@ -11,4 +11,15 @@ defmodule QuickBEAM.VM.Program.Variable do :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 index 12bb381c4..6d9052891 100644 --- a/lib/quickbeam/vm/program/variable/closure.ex +++ b/lib/quickbeam/vm/program/variable/closure.ex @@ -2,4 +2,13 @@ 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/engine.ex b/lib/quickbeam/vm/runtime/engine.ex new file mode 100644 index 000000000..fa8c148d0 --- /dev/null +++ b/lib/quickbeam/vm/runtime/engine.ex @@ -0,0 +1,404 @@ +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.Compiler + alias QuickBEAM.VM.Program + alias QuickBEAM.VM.Program.Pinned + alias QuickBEAM.VM.Program.Store + alias QuickBEAM.VM.Runtime + alias QuickBEAM.VM.Runtime.Engine.Measurement + alias QuickBEAM.VM.Bytecode.Verifier + + @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 \\ []) + + def eval(%Pinned{} = pinned, opts) when is_list(opts) do + with {:ok, options} <- evaluation_options(opts), + {:ok, lease} <- pinned_lease(pinned) do + 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 + + def eval(%Program{} = program, opts) when is_list(opts) do + with :ok <- Verifier.verify(program), + {:ok, options} <- evaluation_options(opts) do + case options.isolation do + :caller -> evaluate(program, options) + :process -> eval_isolated(program, options) + end + end + end + + def eval(program, _opts) + when not is_struct(program, Program) and not is_struct(program, Pinned), + do: {:error, :invalid_program} + + def eval(_program, opts), do: {:error, {:invalid_options, opts}} + + @doc "Measures an explicitly selected internal engine evaluation." + @spec measure(Program.t() | Pinned.t(), [option()]) :: QuickBEAM.VM.result(Measurement.t()) + def measure(program, opts \\ []) + + def measure(%Pinned{} = pinned, opts) when is_list(opts) do + with {:ok, options} <- evaluation_options(opts), + {:ok, lease} <- pinned_lease(pinned) do + 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 + + def measure(%Program{} = program, opts) when is_list(opts) do + with :ok <- Verifier.verify(program), + {:ok, options} <- evaluation_options(opts) do + 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 + + def measure(program, _opts) + when not is_struct(program, Program) and not is_struct(program, Pinned), + do: {:error, :invalid_program} + + def measure(_program, opts), 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 <- validate_options(opts, allowed) do + validate_evaluation_options(opts) + end + end + + defp validate_options(opts, allowed) do + if Keyword.keyword?(opts) do + case Keyword.keys(opts) -- allowed do + [] -> :ok + [unknown | _] -> {:error, {:unknown_option, unknown}} + end + else + {:error, {:invalid_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, %{}) + + cond do + isolation not in [:caller, :process] -> + {:error, {:invalid_option, :isolation, isolation}} + + engine not in [:interpreter, :compiler] -> + {:error, {:invalid_option, :engine, engine}} + + not (is_atom(compiler_pool) or is_pid(compiler_pool)) -> + {:error, {:invalid_option, :compiler_pool, compiler_pool}} + + compiler_profile not in [:pure_v1, :scalar_v1] -> + {:error, {:invalid_option, :compiler_profile, compiler_profile}} + + not is_boolean(compiler_region_probe) -> + {:error, {:invalid_option, :compiler_region_probe, compiler_region_probe}} + + not is_boolean(compiler_regions) -> + {:error, {:invalid_option, :compiler_regions, compiler_regions}} + + timeout != :infinity and (not is_integer(timeout) or timeout <= 0) -> + {:error, {:invalid_option, :timeout, timeout}} + + not is_integer(max_steps) or max_steps <= 0 -> + {:error, {:invalid_option, :max_steps, max_steps}} + + not is_integer(max_stack_depth) or max_stack_depth <= 0 -> + {:error, {:invalid_option, :max_stack_depth, max_stack_depth}} + + memory_limit != :infinity and (not is_integer(memory_limit) or memory_limit <= 0) -> + {:error, {:invalid_option, :memory_limit, memory_limit}} + + profile not in [:core, :ssr] -> + {:error, {:invalid_option, :profile, profile}} + + not is_map(vars) -> + {:error, {:invalid_option, :vars, vars}} + + not is_map(handlers) or + not Enum.all?(handlers, fn {name, handler} -> + is_binary(name) and is_function(handler, 1) + end) -> + {:error, {:invalid_option, :handlers, handlers}} + + true -> + {: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 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}), + do: Runtime.eval(program, Map.to_list(options)) + + defp evaluate(program, %{engine: :compiler, interpreter: options}), + do: Compiler.eval(program, Map.to_list(options)) + + 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}) do + {result, metrics} = measure_engine(engine, program, Map.to_list(options)) + + 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), + do: Runtime.eval_with_metrics(program, options) + + defp measure_engine(:compiler, program, options), + do: Compiler.eval_with_metrics(program, options) + + 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) + {: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 +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/mix.exs b/mix.exs index 36f2a47e7..f67b36a11 100644 --- a/mix.exs +++ b/mix.exs @@ -128,6 +128,7 @@ defmodule QuickBEAM.MixProject do "QuickBEAM.VM.Bytecode", "QuickBEAM.VM.Compiler", "QuickBEAM.VM.Fuzz", + "QuickBEAM.VM.Program.Identity", "QuickBEAM.VM.Program.Store", "QuickBEAM.VM.Runtime" ] diff --git a/test/support/test262.ex b/test/support/test262.ex index 17cf11a5c..def8bcbf3 100644 --- a/test/support/test262.ex +++ b/test/support/test262.ex @@ -38,6 +38,8 @@ defmodule QuickBEAM.Test262 do than silently falling back to the native engine. """ + alias QuickBEAM.VM.Runtime.Engine + @minimal_harness """ function Test262Error(message) { this.name = "Test262Error"; @@ -198,13 +200,20 @@ defmodule QuickBEAM.Test262 do end defp eval_program(program, engine, compiler_profile, false), - do: QuickBEAM.VM.eval(program, engine: engine, compiler_profile: compiler_profile) + 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 - QuickBEAM.VM.eval(pinned, engine: engine, compiler_profile: compiler_profile) + Engine.eval(pinned, + engine: engine, + compiler_profile: compiler_profile + ) after QuickBEAM.VM.unpin(pinned) end diff --git a/test/vm/api_test.exs b/test/vm/api_test.exs new file mode 100644 index 000000000..c4cb09435 --- /dev/null +++ b/test/vm/api_test.exs @@ -0,0 +1,61 @@ +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.measure(:not_program) + 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) + 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.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/compiler/orchestration_test.exs b/test/vm/compiler/orchestration_test.exs index d0cf7cd2b..64ec2efc2 100644 --- a/test/vm/compiler/orchestration_test.exs +++ b/test/vm/compiler/orchestration_test.exs @@ -2,14 +2,15 @@ defmodule QuickBEAM.VM.Compiler.OrchestrationTest do use ExUnit.Case, async: false alias QuickBEAM.VM.Compiler - alias QuickBEAM.VM.Measurement 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}}} = - QuickBEAM.VM.eval(program, engine: :compiler) + Engine.eval(program, engine: :compiler) end test "matches interpreter and native QuickJS for decoded pure fixtures" do @@ -20,8 +21,8 @@ defmodule QuickBEAM.VM.Compiler.OrchestrationTest 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 QuickBEAM.VM.eval(program) == {:ok, expected} - assert QuickBEAM.VM.eval(program, engine: :compiler) == {:ok, expected} + assert Engine.eval(program) == {:ok, expected} + assert Engine.eval(program, engine: :compiler) == {:ok, expected} end after QuickBEAM.stop(runtime) @@ -36,8 +37,10 @@ defmodule QuickBEAM.VM.Compiler.OrchestrationTest do try do assert {:error, native_error} = QuickBEAM.eval(runtime, source) - assert {:error, interpreted_error} = QuickBEAM.VM.eval(program) - assert {:error, compiled_error} = QuickBEAM.VM.eval(program, engine: :compiler) + 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 @@ -52,7 +55,7 @@ defmodule QuickBEAM.VM.Compiler.OrchestrationTest do expression = Enum.join(List.duplicate("value", 40), " + ") source = "(function add(value) { return #{expression} })(1)" assert {:ok, program} = QuickBEAM.VM.compile(source) - assert {:ok, 40} = QuickBEAM.VM.eval(program, engine: :compiler) + assert {:ok, 40} = Engine.eval(program, engine: :compiler) stats = Pool.stats(Pool) assert stats.counts.ready >= 1 @@ -103,8 +106,10 @@ defmodule QuickBEAM.VM.Compiler.OrchestrationTest do QuickBEAM.VM.compile("(function recurse(n){return 1+recurse(n+1)})(0)") expected = {:error, {:limit_exceeded, :stack_depth, 6}} - assert QuickBEAM.VM.eval(recursive, max_stack_depth: 5) == expected - assert QuickBEAM.VM.eval(recursive, engine: :compiler, max_stack_depth: 5) == expected + 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( @@ -112,7 +117,10 @@ defmodule QuickBEAM.VM.Compiler.OrchestrationTest do ) assert {:ok, 0} = - QuickBEAM.VM.eval(tail_recursive, engine: :compiler, max_stack_depth: 2) + Engine.eval(tail_recursive, + engine: :compiler, + max_stack_depth: 2 + ) end test "deoptimizes into asynchronous handlers without duplicating effects" do @@ -126,7 +134,7 @@ defmodule QuickBEAM.VM.Compiler.OrchestrationTest do end assert {:ok, 42} = - QuickBEAM.VM.eval(program, + Engine.eval(program, engine: :compiler, handlers: %{"double" => handler} ) @@ -140,7 +148,7 @@ defmodule QuickBEAM.VM.Compiler.OrchestrationTest do assert {:ok, program} = QuickBEAM.VM.compile("Beam.call('wait')") assert {:ok, 1} = - QuickBEAM.VM.eval(program, + Engine.eval(program, engine: :compiler, handlers: %{"wait" => fn [] -> 1 end}, timeout: 1_000 @@ -154,7 +162,7 @@ defmodule QuickBEAM.VM.Compiler.OrchestrationTest do end assert {:error, {:limit_exceeded, :timeout, 200}} = - QuickBEAM.VM.eval(program, + Engine.eval(program, engine: :compiler, handlers: %{"wait" => handler}, timeout: 200 @@ -169,10 +177,10 @@ defmodule QuickBEAM.VM.Compiler.OrchestrationTest do start_compiler() assert {:ok, program} = QuickBEAM.VM.compile("({answer: 40 + 2})") - assert {:ok, %Measurement{} = interpreted} = QuickBEAM.VM.measure(program) + assert {:ok, %Measurement{} = interpreted} = Engine.measure(program) assert {:ok, %Measurement{} = compiled} = - QuickBEAM.VM.measure(program, engine: :compiler) + Engine.measure(program, engine: :compiler) assert compiled.result == interpreted.result assert compiled.steps == interpreted.steps @@ -190,17 +198,19 @@ defmodule QuickBEAM.VM.Compiler.OrchestrationTest do assert {:ok, finite} = QuickBEAM.VM.compile("40 + 2") expected = {:error, {:limit_exceeded, :steps, 2}} - assert QuickBEAM.VM.eval(finite, max_steps: 2) == expected - assert QuickBEAM.VM.eval(finite, engine: :compiler, max_steps: 2) == expected + 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 QuickBEAM.VM.eval(finite, memory_limit: 1) == memory_error - assert QuickBEAM.VM.eval(finite, engine: :compiler, memory_limit: 1) == memory_error + 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}} = - QuickBEAM.VM.eval(loop, + Engine.eval(loop, engine: :compiler, max_steps: 100_000_000, timeout: 50 @@ -231,10 +241,10 @@ defmodule QuickBEAM.VM.Compiler.OrchestrationTest do timeout: 2_000 ] - expected = QuickBEAM.VM.eval(program, options) - assert QuickBEAM.VM.eval(program, [engine: :compiler] ++ options) == expected + expected = Engine.eval(program, options) + assert Engine.eval(program, [engine: :compiler] ++ options) == expected - assert QuickBEAM.VM.eval( + assert Engine.eval( program, [engine: :compiler, compiler_profile: :scalar_v1] ++ options ) == expected @@ -248,7 +258,7 @@ defmodule QuickBEAM.VM.Compiler.OrchestrationTest do QuickBEAM.VM.compile("(function(){let value=0;#{body}return value})()") assert {:ok, 100} = - QuickBEAM.VM.eval(program, + Engine.eval(program, engine: :compiler, compiler_profile: :scalar_v1, max_steps: 10_000 @@ -261,18 +271,18 @@ defmodule QuickBEAM.VM.Compiler.OrchestrationTest do max_steps: 10_000 ] - assert {:ok, 100} = QuickBEAM.VM.eval(program, opts) - assert {:ok, 100} = QuickBEAM.VM.eval(program, opts) + assert {:ok, 100} = Engine.eval(program, opts) + assert {:ok, 100} = Engine.eval(program, opts) - assert {:ok, interpreted} = QuickBEAM.VM.measure(program, max_steps: 10_000) - assert {:ok, admitted} = QuickBEAM.VM.measure(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} = QuickBEAM.VM.measure(program, opts) + 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 @@ -282,8 +292,11 @@ defmodule QuickBEAM.VM.Compiler.OrchestrationTest do assert Pool.stats(Pool).region_admissions == 2 Enum.each([1, 16, 32, 33, interpreted.steps - 1], fn step_limit -> - interpreter_result = QuickBEAM.VM.eval(program, max_steps: step_limit) - compiler_result = QuickBEAM.VM.eval(program, Keyword.put(opts, :max_steps, 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 @@ -294,7 +307,7 @@ defmodule QuickBEAM.VM.Compiler.OrchestrationTest do tasks = for _ <- 1..40 do - Task.async(fn -> QuickBEAM.VM.eval(program, engine: :compiler) end) + Task.async(fn -> Engine.eval(program, engine: :compiler) end) end assert Task.await_many(tasks, 5_000) == List.duplicate({:ok, 42}, 40) @@ -309,19 +322,28 @@ defmodule QuickBEAM.VM.Compiler.OrchestrationTest do assert {:ok, program} = QuickBEAM.VM.compile("42") assert {:error, {:invalid_option, :engine, :native}} = - QuickBEAM.VM.eval(program, engine: :native) + Engine.eval(program, engine: :native) assert {:error, {:invalid_option, :compiler_pool, "pool"}} = - QuickBEAM.VM.eval(program, engine: :compiler, compiler_pool: "pool") + Engine.eval(program, engine: :compiler, compiler_pool: "pool") assert {:error, {:invalid_option, :compiler_profile, :unbounded}} = - QuickBEAM.VM.eval(program, engine: :compiler, compiler_profile: :unbounded) + Engine.eval(program, + engine: :compiler, + compiler_profile: :unbounded + ) assert {:error, {:invalid_option, :compiler_region_probe, :always}} = - QuickBEAM.VM.eval(program, engine: :compiler, compiler_region_probe: :always) + Engine.eval(program, + engine: :compiler, + compiler_region_probe: :always + ) assert {:error, {:invalid_option, :compiler_regions, :always}} = - QuickBEAM.VM.eval(program, engine: :compiler, compiler_regions: :always) + Engine.eval(program, + engine: :compiler, + compiler_regions: :always + ) end defp start_compiler do diff --git a/test/vm/compiler/profile/pure_test.exs b/test/vm/compiler/profile/pure_test.exs index 79314cef9..cef362af7 100644 --- a/test/vm/compiler/profile/pure_test.exs +++ b/test/vm/compiler/profile/pure_test.exs @@ -10,6 +10,7 @@ defmodule QuickBEAM.VM.Compiler.Profile.PureTest do alias QuickBEAM.VM.Compiler.Code.Emitter alias QuickBEAM.VM.Compiler.Code.Import alias QuickBEAM.VM.Compiler.Profile.Pure + alias QuickBEAM.VM.Runtime.Engine alias QuickBEAM.VM.Runtime.State alias QuickBEAM.VM.Runtime.Frame alias QuickBEAM.VM.Program.Function @@ -289,15 +290,18 @@ defmodule QuickBEAM.VM.Compiler.Profile.PureTest do assert Enum.any?(local_template.forms, &match?({:function, _, :block, 7, _}, &1)) start_pool() - assert {:ok, interpreter} = QuickBEAM.VM.measure(program, max_steps: 10_000) - assert {:ok, compiler} = QuickBEAM.VM.measure(program, engine: :compiler, max_steps: 10_000) + assert {:ok, interpreter} = Engine.measure(program, max_steps: 10_000) + + assert {:ok, compiler} = + Engine.measure(program, engine: :compiler, max_steps: 10_000) + assert compiler.result == interpreter.result assert compiler.steps == interpreter.steps assert compiler.logical_memory_bytes == interpreter.logical_memory_bytes for limit <- [1, 7, 50, interpreter.steps - 1] do - assert QuickBEAM.VM.eval(program, engine: :compiler, max_steps: limit) == - QuickBEAM.VM.eval(program, max_steps: limit) + assert Engine.eval(program, engine: :compiler, max_steps: limit) == + Engine.eval(program, max_steps: limit) end end @@ -314,10 +318,10 @@ defmodule QuickBEAM.VM.Compiler.Profile.PureTest do for source <- sources do assert {:ok, program} = QuickBEAM.VM.compile(source) - assert {:ok, interpreted} = QuickBEAM.VM.measure(program) + assert {:ok, interpreted} = Engine.measure(program) assert {:ok, compiled} = - QuickBEAM.VM.measure(program, + Engine.measure(program, engine: :compiler, compiler_profile: :scalar_v1 ) @@ -327,11 +331,11 @@ defmodule QuickBEAM.VM.Compiler.Profile.PureTest do assert compiled.logical_memory_bytes == interpreted.logical_memory_bytes for limit <- [1, max(interpreted.steps - 1, 1)] do - assert QuickBEAM.VM.eval(program, + assert Engine.eval(program, engine: :compiler, compiler_profile: :scalar_v1, max_steps: limit - ) == QuickBEAM.VM.eval(program, max_steps: limit) + ) == Engine.eval(program, max_steps: limit) end end end @@ -344,8 +348,8 @@ defmodule QuickBEAM.VM.Compiler.Profile.PureTest do assert {:ok, program} = QuickBEAM.VM.compile(source) opts = [engine: :compiler, compiler_profile: :scalar_v1, max_steps: 10_000] - assert {:ok, interpreted} = QuickBEAM.VM.measure(program, max_steps: 10_000) - assert {:ok, compiled} = QuickBEAM.VM.measure(program, opts) + assert {:ok, interpreted} = Engine.measure(program, max_steps: 10_000) + assert {:ok, compiled} = Engine.measure(program, opts) assert compiled.result == interpreted.result assert compiled.steps == interpreted.steps assert compiled.logical_memory_bytes == interpreted.logical_memory_bytes @@ -355,8 +359,8 @@ defmodule QuickBEAM.VM.Compiler.Profile.PureTest do assert compiled.compiler_counters.invocation_actions > 0 for limit <- [1, 10, div(interpreted.steps, 2), interpreted.steps - 1] do - assert QuickBEAM.VM.eval(program, Keyword.put(opts, :max_steps, limit)) == - QuickBEAM.VM.eval(program, max_steps: limit) + assert Engine.eval(program, Keyword.put(opts, :max_steps, limit)) == + Engine.eval(program, max_steps: limit) end end @@ -368,8 +372,8 @@ defmodule QuickBEAM.VM.Compiler.Profile.PureTest do assert {:ok, program} = QuickBEAM.VM.compile(source) opts = [engine: :compiler, compiler_profile: :scalar_v1, max_steps: 10_000] - assert {:ok, interpreted} = QuickBEAM.VM.measure(program, max_steps: 10_000) - assert {:ok, compiled} = QuickBEAM.VM.measure(program, opts) + assert {:ok, interpreted} = Engine.measure(program, max_steps: 10_000) + assert {:ok, compiled} = Engine.measure(program, opts) assert compiled.result == interpreted.result assert compiled.steps == interpreted.steps assert compiled.logical_memory_bytes == interpreted.logical_memory_bytes @@ -378,14 +382,14 @@ defmodule QuickBEAM.VM.Compiler.Profile.PureTest do assert compiled.compiler_counters.interpreted_opcodes[:put_var] == 1 for limit <- [1, 20, div(interpreted.steps, 2), interpreted.steps - 1] do - assert QuickBEAM.VM.eval(program, Keyword.put(opts, :max_steps, limit)) == - QuickBEAM.VM.eval(program, max_steps: limit) + 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 0 assert :ok = Pool.checkin(pool, lease) - assert Interpreter.resume_deopt(deopt) == QuickBEAM.VM.eval(program, max_steps: 100) + + assert Interpreter.resume_deopt(deopt) == + Engine.eval(program, max_steps: 100) end end diff --git a/test/vm/memory_limit_test.exs b/test/vm/memory_limit_test.exs index b8e670562..5639b2e8e 100644 --- a/test/vm/memory_limit_test.exs +++ b/test/vm/memory_limit_test.exs @@ -41,16 +41,6 @@ defmodule QuickBEAM.VM.MemoryLimitTest do QuickBEAM.VM.eval(program, memory_limit: 20_000, max_steps: 100_000) end - test "isolated workers receive a BEAM max-heap containment boundary" do - assert [:monitor, {:max_heap_size, heap_limit}] = - QuickBEAM.VM.worker_spawn_options(1_000_000) - - assert heap_limit.kill - refute heap_limit.error_logger - assert heap_limit.size > div(1_000_000, :erlang.system_info(:wordsize)) - assert QuickBEAM.VM.worker_spawn_options(:infinity) == [:monitor] - end - test "contains oversized host results and reclaims the evaluation owner" do test_process = self() source = "(async function(){return await Beam.call('large_result')})()" diff --git a/test/vm/program/store_test.exs b/test/vm/program/store_test.exs index ab2a9b8b3..668008f40 100644 --- a/test/vm/program/store_test.exs +++ b/test/vm/program/store_test.exs @@ -2,6 +2,7 @@ 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 @@ -53,6 +54,8 @@ defmodule QuickBEAM.VM.Program.StoreTest do 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) @@ -167,13 +170,13 @@ defmodule QuickBEAM.VM.Program.StoreTest do source_digest: :crypto.strong_rand_bytes(32) } - first = Program.put_pin_key(base) + first = Identity.put(base) second = - base |> put_in([Access.key(:root), :filename], "second.js") |> Program.put_pin_key() + base |> put_in([Access.key(:root), :filename], "second.js") |> Identity.put() changed_source = - %{base | source_digest: :crypto.strong_rand_bytes(32)} |> Program.put_pin_key() + %{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 diff --git a/test/vm/svelte_ssr_test.exs b/test/vm/svelte_ssr_test.exs index 13c6493c7..20045bafd 100644 --- a/test/vm/svelte_ssr_test.exs +++ b/test/vm/svelte_ssr_test.exs @@ -2,6 +2,7 @@ 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 [ @@ -42,13 +43,17 @@ defmodule QuickBEAM.VM.SvelteSSRTest do assert {:ok, native_rendered} = QuickBEAM.eval(runtime, "await globalThis.__quickbeamSSRResult", timeout: 5_000) - assert {:ok, beam_rendered} = QuickBEAM.VM.eval(program, [handlers: handlers] ++ @eval_opts) + assert {:ok, beam_rendered} = + Engine.eval(program, [handlers: handlers] ++ @eval_opts) assert {:ok, compiler_rendered} = - QuickBEAM.VM.eval(program, [engine: :compiler, handlers: handlers] ++ @eval_opts) + Engine.eval( + program, + [engine: :compiler, handlers: handlers] ++ @eval_opts + ) assert {:ok, scalar_rendered} = - QuickBEAM.VM.eval( + Engine.eval( program, [engine: :compiler, compiler_profile: :scalar_v1, handlers: handlers] ++ @eval_opts @@ -82,7 +87,10 @@ defmodule QuickBEAM.VM.SvelteSSRTest do props end - QuickBEAM.VM.eval(program, [handlers: %{"load_props" => handler}] ++ @eval_opts) + Engine.eval( + program, + [handlers: %{"load_props" => handler}] ++ @eval_opts + ) end defp props(title, id) do diff --git a/test/vm/vue_ssr_test.exs b/test/vm/vue_ssr_test.exs index 08c30d439..4bef86b77 100644 --- a/test/vm/vue_ssr_test.exs +++ b/test/vm/vue_ssr_test.exs @@ -3,6 +3,7 @@ defmodule QuickBEAM.VM.VueSSRTest do alias QuickBEAM.VM.Compiler alias QuickBEAM.VM.Compiler.Pool + alias QuickBEAM.VM.Runtime.Engine @fixture "test/fixtures/vm/vue_ssr.js" @bundle_opts [ @@ -53,20 +54,24 @@ defmodule QuickBEAM.VM.VueSSRTest do assert {:ok, native_html} = QuickBEAM.eval(runtime, "await globalThis.__quickbeamSSRResult", timeout: 5_000) - assert {:ok, beam_html} = QuickBEAM.VM.eval(program, [handlers: handlers] ++ @eval_opts) + assert {:ok, beam_html} = + Engine.eval(program, [handlers: handlers] ++ @eval_opts) assert {:ok, compiler_html} = - QuickBEAM.VM.eval(program, [engine: :compiler, handlers: handlers] ++ @eval_opts) + Engine.eval( + program, + [engine: :compiler, handlers: handlers] ++ @eval_opts + ) assert {:ok, scalar_html} = - QuickBEAM.VM.eval( + Engine.eval( program, [engine: :compiler, compiler_profile: :scalar_v1, handlers: handlers] ++ @eval_opts ) assert {:ok, pinned_compiler_html} = - QuickBEAM.VM.eval( + Engine.eval( pinned_program, [engine: :compiler, handlers: handlers] ++ @eval_opts ) @@ -104,7 +109,10 @@ defmodule QuickBEAM.VM.VueSSRTest do props end - QuickBEAM.VM.eval(program, [handlers: %{"load_props" => handler}] ++ @eval_opts) + Engine.eval( + program, + [handlers: %{"load_props" => handler}] ++ @eval_opts + ) end defp props(title, id) do From 53177f9523f3566f18f1830f85b8f14b7598e35e Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 17 Jul 2026 15:47:19 +0200 Subject: [PATCH 77/87] Decouple VM runtime from compiler internals --- lib/quickbeam/vm/compiler.ex | 4 + lib/quickbeam/vm/compiler/context.ex | 6 ++ lib/quickbeam/vm/compiler/instrumentation.ex | 32 ++++++++ lib/quickbeam/vm/runtime.ex | 28 ++++--- lib/quickbeam/vm/runtime/callable.ex | 36 +++++++++ lib/quickbeam/vm/runtime/exception.ex | 8 +- lib/quickbeam/vm/runtime/interpreter.ex | 58 ++++++++------- lib/quickbeam/vm/runtime/invocation.ex | 22 +----- lib/quickbeam/vm/runtime/iterator.ex | 15 ++-- lib/quickbeam/vm/runtime/optimization.ex | 77 ++++++++++++++++++++ lib/quickbeam/vm/runtime/promise.ex | 4 +- lib/quickbeam/vm/runtime/state.ex | 9 ++- 12 files changed, 222 insertions(+), 77 deletions(-) create mode 100644 lib/quickbeam/vm/compiler/instrumentation.ex create mode 100644 lib/quickbeam/vm/runtime/callable.ex create mode 100644 lib/quickbeam/vm/runtime/optimization.ex diff --git a/lib/quickbeam/vm/compiler.ex b/lib/quickbeam/vm/compiler.ex index eb620728a..580f4875c 100644 --- a/lib/quickbeam/vm/compiler.ex +++ b/lib/quickbeam/vm/compiler.ex @@ -17,6 +17,7 @@ defmodule QuickBEAM.VM.Compiler do alias QuickBEAM.VM.Compiler.Contract alias QuickBEAM.VM.Compiler.Counter alias QuickBEAM.VM.Compiler.Deopt + alias QuickBEAM.VM.Compiler.Instrumentation alias QuickBEAM.VM.Compiler.Code alias QuickBEAM.VM.Compiler.Pool alias QuickBEAM.VM.Compiler.Region.Probe @@ -90,6 +91,9 @@ defmodule QuickBEAM.VM.Compiler do context = %Context{ artifact_namespace: artifact_namespace, counters: if(execution.measurement_target, do: Counter.new()), + deopt_module: Deopt, + executor: __MODULE__, + instrumentation: Instrumentation, pool: pool, profile: Keyword.get(opts, :compiler_profile, :pure_v1), program: program, diff --git a/lib/quickbeam/vm/compiler/context.ex b/lib/quickbeam/vm/compiler/context.ex index 490507605..ede356987 100644 --- a/lib/quickbeam/vm/compiler/context.ex +++ b/lib/quickbeam/vm/compiler/context.ex @@ -11,6 +11,9 @@ defmodule QuickBEAM.VM.Compiler.Context do @enforce_keys [:pool, :program] defstruct [ + :deopt_module, + :executor, + :instrumentation, :pool, :program, artifact_namespace: nil, @@ -24,6 +27,9 @@ defmodule QuickBEAM.VM.Compiler.Context do ] @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, 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/runtime.ex b/lib/quickbeam/vm/runtime.ex index b586f67d2..1884c3f3c 100644 --- a/lib/quickbeam/vm/runtime.ex +++ b/lib/quickbeam/vm/runtime.ex @@ -6,15 +6,13 @@ defmodule QuickBEAM.VM.Runtime do coroutines, and waits for the final Promise without polling. """ - alias QuickBEAM.VM.Compiler.Counter - alias QuickBEAM.VM.Compiler.Region.Probe - alias QuickBEAM.VM.Runtime.Async alias QuickBEAM.VM.Runtime.Continuation alias QuickBEAM.VM.Runtime.Coroutine alias QuickBEAM.VM.Runtime.Exception alias QuickBEAM.VM.Runtime.State alias QuickBEAM.VM.Runtime.Interpreter + alias QuickBEAM.VM.Runtime.Optimization alias QuickBEAM.VM.Program alias QuickBEAM.VM.Runtime.Promise alias QuickBEAM.VM.Runtime.Promise.Reference, as: PromiseReference @@ -230,18 +228,18 @@ defmodule QuickBEAM.VM.Runtime do process_memory = process_stat(:memory) reductions = process_stat(:reductions) - send( - pid, - {:quickbeam_vm_measurement, ref, - %{ - steps: execution.step_limit - execution.remaining_steps, - logical_memory_bytes: execution.memory_used, - compiler_counters: Counter.snapshot(execution), - compiler_regions: Probe.snapshot(execution), - process_memory_bytes: process_memory, - reductions: 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 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/exception.ex b/lib/quickbeam/vm/runtime/exception.ex index dc76be728..9cce53012 100644 --- a/lib/quickbeam/vm/runtime/exception.ex +++ b/lib/quickbeam/vm/runtime/exception.ex @@ -15,7 +15,6 @@ defmodule QuickBEAM.VM.Runtime.Exception do alias QuickBEAM.VM.Runtime.Frame alias QuickBEAM.VM.Program.Function alias QuickBEAM.VM.Runtime.Heap - alias QuickBEAM.VM.Runtime.Iterator alias QuickBEAM.VM.Runtime.Frame.Native alias QuickBEAM.VM.Runtime.Object @@ -151,8 +150,11 @@ defmodule QuickBEAM.VM.Runtime.Exception do %Boundary.Iterator{consumer: :promise} = boundary, execution, trace - ), - do: Iterator.fail(boundary, thrown(reason, trace), execution) + ) 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, diff --git a/lib/quickbeam/vm/runtime/interpreter.ex b/lib/quickbeam/vm/runtime/interpreter.ex index 5fbf9cc97..79ade2ffd 100644 --- a/lib/quickbeam/vm/runtime/interpreter.ex +++ b/lib/quickbeam/vm/runtime/interpreter.ex @@ -11,6 +11,7 @@ defmodule QuickBEAM.VM.Runtime.Interpreter do alias QuickBEAM.VM.Runtime.Async alias QuickBEAM.VM.Builtin.Runtime, as: BuiltinRuntime + alias QuickBEAM.VM.Builtin.Set, as: SetBuiltin alias QuickBEAM.VM.Runtime.Continuation alias QuickBEAM.VM.Runtime.Coroutine @@ -39,10 +40,7 @@ defmodule QuickBEAM.VM.Runtime.Interpreter do alias QuickBEAM.VM.Runtime.Value alias QuickBEAM.VM.Builtin.Registry - alias QuickBEAM.VM.Compiler, as: VMCompiler - alias QuickBEAM.VM.Compiler.Counter - alias QuickBEAM.VM.Compiler.Deopt - alias QuickBEAM.VM.Compiler.Region.Probe + alias QuickBEAM.VM.Runtime.Optimization alias QuickBEAM.VM.Runtime.Opcode.Control, as: ControlOpcodes alias QuickBEAM.VM.Runtime.Opcode.Invocation, as: InvocationOpcodes alias QuickBEAM.VM.Runtime.Opcode.Local, as: LocalOpcodes @@ -122,14 +120,15 @@ defmodule QuickBEAM.VM.Runtime.Interpreter do do: continuation |> resume_raw(result) |> finish() @doc "Resumes a validated owner-local compiler deoptimization." - @spec resume_deopt(Deopt.t()) :: result() - def resume_deopt(%Deopt{} = deopt), do: deopt |> resume_deopt_raw() |> finish() + @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(Deopt.t()) :: + @spec resume_deopt_raw(struct()) :: {:ok, term(), State.t()} | {:error, term(), State.t()} | {:suspended, term()} - def resume_deopt_raw(%Deopt{} = deopt) do - case Deopt.validate(deopt) do + def resume_deopt_raw(%{frame: %Frame{}, execution: %State{}} = deopt) do + case Optimization.validate_deopt(deopt) do :ok -> frame = if deopt.frame.compiler_allow_reentry, @@ -289,7 +288,7 @@ defmodule QuickBEAM.VM.Runtime.Interpreter do %State{} = execution ) do frame = %{frame | compiler_entered: false, compiler_reentry_after_instruction: false} - execution = Counter.increment(execution, :reentries) + execution = Optimization.increment(execution, :reentries) execute_current(frame, execution) end @@ -300,9 +299,11 @@ defmodule QuickBEAM.VM.Runtime.Interpreter do when not is_nil(compiler_context) do frame = %{frame | compiler_entered: true} - case VMCompiler.execute_frame(frame, execution) do - {:deopt, %Deopt{} = deopt} -> - resume_deopt_raw(deopt) + 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) @@ -320,22 +321,15 @@ defmodule QuickBEAM.VM.Runtime.Interpreter do defp run(%Frame{} = frame, %State{} = execution), do: execute_current(frame, execution) - defp execute_current( - frame, - %State{compiler_context: %{region_probe: %Probe{}}} = execution - ) do + defp execute_current(frame, %State{compiler_context: context} = execution) + when not is_nil(context) do {opcode, operands} = elem(frame.function.instructions, frame.pc) - execution = Probe.observe(execution, frame) - execution = Counter.interpreted_opcode(execution, opcode) - execute_current_opcode(opcode, operands, frame, execution) - end - defp execute_current( - frame, - %State{compiler_context: %{counters: %Counter{}}} = execution - ) do - {opcode, operands} = elem(frame.function.instructions, frame.pc) - execution = Counter.interpreted_opcode(execution, opcode) + execution = + execution + |> Optimization.observe(frame) + |> Optimization.interpreted_opcode(opcode) + execute_current_opcode(opcode, operands, frame, execution) end @@ -432,6 +426,16 @@ defmodule QuickBEAM.VM.Runtime.Interpreter do 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 diff --git a/lib/quickbeam/vm/runtime/invocation.ex b/lib/quickbeam/vm/runtime/invocation.ex index 44aed965d..a8bc74733 100644 --- a/lib/quickbeam/vm/runtime/invocation.ex +++ b/lib/quickbeam/vm/runtime/invocation.ex @@ -11,13 +11,13 @@ defmodule QuickBEAM.VM.Runtime.Invocation do alias QuickBEAM.VM.Builtin alias QuickBEAM.VM.Builtin.Runtime, as: BuiltinRuntime alias QuickBEAM.VM.Runtime.Boundary + alias QuickBEAM.VM.Runtime.Callable alias QuickBEAM.VM.Runtime.State alias QuickBEAM.VM.Runtime.Frame alias QuickBEAM.VM.Program.Function alias QuickBEAM.VM.Runtime.Promise alias QuickBEAM.VM.Runtime.Property alias QuickBEAM.VM.Runtime.Reference - alias QuickBEAM.VM.Runtime.Value alias QuickBEAM.VM.Builtin.Action alias QuickBEAM.VM.Builtin.Call @@ -184,27 +184,11 @@ defmodule QuickBEAM.VM.Runtime.Invocation do @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" + defdelegate callable?(value, execution), to: Callable @doc "Returns the JavaScript `typeof` classification for a VM 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 [ - :builtin, - :declared_builtin, - :bound_function, - :host_function, - :primitive_method, - :promise_resolver - ], - do: "function" - - def typeof(value, _execution), do: Value.typeof(value) + 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() diff --git a/lib/quickbeam/vm/runtime/iterator.ex b/lib/quickbeam/vm/runtime/iterator.ex index 35eef4877..1f0fe9944 100644 --- a/lib/quickbeam/vm/runtime/iterator.ex +++ b/lib/quickbeam/vm/runtime/iterator.ex @@ -20,7 +20,9 @@ defmodule QuickBEAM.VM.Runtime.Iterator do alias QuickBEAM.VM.Runtime.Symbol alias QuickBEAM.VM.Runtime.Value - @type action :: Invocation.action() + @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} @@ -208,15 +210,8 @@ defmodule QuickBEAM.VM.Runtime.Iterator do end defp finish(%Boundary.Iterator{consumer: :set} = boundary, execution) do - values = Enum.reverse(boundary.values) - - case QuickBEAM.VM.Builtin.Set.initialize(boundary.target, values, execution) do - {:ok, execution} -> - {:complete, boundary.target, boundary.caller, execution, boundary.tail?} - - {:error, reason} -> - {:error, reason, boundary.caller, execution} - end + {:initialize_set, boundary.target, Enum.reverse(boundary.values), boundary.caller, execution, + boundary.tail?} end defp read_value(boundary, execution) do diff --git a/lib/quickbeam/vm/runtime/optimization.ex b/lib/quickbeam/vm/runtime/optimization.ex new file mode 100644 index 000000000..b98f92aaa --- /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), + 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 index 0d91d1603..3b73ff103 100644 --- a/lib/quickbeam/vm/runtime/promise.ex +++ b/lib/quickbeam/vm/runtime/promise.ex @@ -6,9 +6,9 @@ defmodule QuickBEAM.VM.Runtime.Promise do transforms that explicit state and never starts independent processes. """ + alias QuickBEAM.VM.Runtime.Callable alias QuickBEAM.VM.Runtime.Coroutine alias QuickBEAM.VM.Runtime.State - alias QuickBEAM.VM.Runtime.Invocation alias QuickBEAM.VM.Runtime.Memory alias QuickBEAM.VM.Runtime.Property alias QuickBEAM.VM.Runtime.Promise.Reference, as: PromiseReference @@ -309,7 +309,7 @@ defmodule QuickBEAM.VM.Runtime.Promise do {:getter, getter, receiver} {:ok, %Reference{} = callable} -> - if Invocation.callable?(callable, execution), do: {:ok, callable}, else: :none + if Callable.callable?(callable, execution), do: {:ok, callable}, else: :none {:ok, callable} when is_tuple(callable) and diff --git a/lib/quickbeam/vm/runtime/state.ex b/lib/quickbeam/vm/runtime/state.ex index 1e71dc610..59d267700 100644 --- a/lib/quickbeam/vm/runtime/state.ex +++ b/lib/quickbeam/vm/runtime/state.ex @@ -52,7 +52,14 @@ defmodule QuickBEAM.VM.Runtime.State do | QuickBEAM.VM.Runtime.Boundary.ThenGetter.t() ], cells: %{optional(non_neg_integer()) => term()}, - compiler_context: QuickBEAM.VM.Compiler.Context.t() | nil, + 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() From 8cdebe13159bc5ee44cd66da213864eb774be721 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 17 Jul 2026 15:55:16 +0200 Subject: [PATCH 78/87] Quarantine the internal VM compiler facade --- CHANGELOG.md | 2 +- README.md | 5 +- lib/quickbeam/vm/bytecode/verifier.ex | 4 +- lib/quickbeam/vm/compiler.ex | 14 +-- lib/quickbeam/vm/program/store.ex | 4 +- lib/quickbeam/vm/runtime.ex | 6 +- lib/quickbeam/vm/runtime/engine.ex | 114 ++++++++++++------------ lib/quickbeam/vm/runtime/exception.ex | 21 ++--- lib/quickbeam/vm/runtime/interpreter.ex | 40 ++++----- lib/quickbeam/vm/runtime/invocation.ex | 12 +-- lib/quickbeam/vm/runtime/iterator.ex | 4 +- lib/quickbeam/vm/runtime/promise.ex | 6 +- 12 files changed, 109 insertions(+), 123 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34918c443..07b32d8ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ - 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. - 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 optional bounded BEAM compiler with fixed generated-module slots and explicit deoptimization. Compiler execution remains disabled by default and release-quarantined. +- 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. diff --git a/README.md b/README.md index c72461f42..4ab86c188 100644 --- a/README.md +++ b/README.md @@ -60,8 +60,9 @@ 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. -The optional `engine: :compiler` tiers remain explicitly supervised and -release-quarantined; the interpreter is the production default. +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 diff --git a/lib/quickbeam/vm/bytecode/verifier.ex b/lib/quickbeam/vm/bytecode/verifier.ex index 561735ab4..3415c72d5 100644 --- a/lib/quickbeam/vm/bytecode/verifier.ex +++ b/lib/quickbeam/vm/bytecode/verifier.ex @@ -7,10 +7,10 @@ defmodule QuickBEAM.VM.Bytecode.Verifier do """ alias QuickBEAM.VM.ABI - alias QuickBEAM.VM.Program.Function alias QuickBEAM.VM.Bytecode.Opcode - alias QuickBEAM.VM.Program alias QuickBEAM.VM.Bytecode.Verifier.Stack + alias QuickBEAM.VM.Program + alias QuickBEAM.VM.Program.Function @js_atom_end Opcode.js_atom_end() diff --git a/lib/quickbeam/vm/compiler.ex b/lib/quickbeam/vm/compiler.ex index 580f4875c..5964c4322 100644 --- a/lib/quickbeam/vm/compiler.ex +++ b/lib/quickbeam/vm/compiler.ex @@ -2,7 +2,8 @@ defmodule QuickBEAM.VM.Compiler do @moduledoc """ Supervises and orchestrates the optional bounded BEAM compiler tier. - Add this module to a supervision tree before selecting `engine: :compiler`: + Internal benchmarks add this module to a supervision tree before selecting + `engine: :compiler` through `QuickBEAM.VM.Runtime.Engine`: children = [ {QuickBEAM.VM.Compiler, capacity: 8} @@ -13,22 +14,21 @@ defmodule QuickBEAM.VM.Compiler do 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.Code alias QuickBEAM.VM.Compiler.Pool - alias QuickBEAM.VM.Compiler.Region.Probe - 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.State alias QuickBEAM.VM.Runtime.Frame - alias QuickBEAM.VM.Program.Function alias QuickBEAM.VM.Runtime.Interpreter - alias QuickBEAM.VM.Program + alias QuickBEAM.VM.Runtime.State @type result :: {:ok, term()} | {:error, term()} | {:suspended, term()} @type frame_action :: diff --git a/lib/quickbeam/vm/program/store.ex b/lib/quickbeam/vm/program/store.ex index d1695927a..9f7d43547 100644 --- a/lib/quickbeam/vm/program/store.ex +++ b/lib/quickbeam/vm/program/store.ex @@ -12,9 +12,9 @@ defmodule QuickBEAM.VM.Program.Store do use GenServer - alias QuickBEAM.VM.Program.Pinned - alias QuickBEAM.VM.Program alias QuickBEAM.VM.Bytecode.Verifier + alias QuickBEAM.VM.Program + alias QuickBEAM.VM.Program.Pinned alias QuickBEAM.VM.Program.Store.Lease @default_capacity 8 diff --git a/lib/quickbeam/vm/runtime.ex b/lib/quickbeam/vm/runtime.ex index 1884c3f3c..dbf1cdbfc 100644 --- a/lib/quickbeam/vm/runtime.ex +++ b/lib/quickbeam/vm/runtime.ex @@ -6,17 +6,17 @@ defmodule QuickBEAM.VM.Runtime do 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.State alias QuickBEAM.VM.Runtime.Interpreter alias QuickBEAM.VM.Runtime.Optimization - alias QuickBEAM.VM.Program alias QuickBEAM.VM.Runtime.Promise - alias QuickBEAM.VM.Runtime.Promise.Reference, as: PromiseReference 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() diff --git a/lib/quickbeam/vm/runtime/engine.ex b/lib/quickbeam/vm/runtime/engine.ex index fa8c148d0..37e87f096 100644 --- a/lib/quickbeam/vm/runtime/engine.ex +++ b/lib/quickbeam/vm/runtime/engine.ex @@ -7,13 +7,13 @@ defmodule QuickBEAM.VM.Runtime.Engine do release-quarantined and filtered from public ExDoc output. """ + alias QuickBEAM.VM.Bytecode.Verifier alias QuickBEAM.VM.Compiler alias QuickBEAM.VM.Program alias QuickBEAM.VM.Program.Pinned alias QuickBEAM.VM.Program.Store alias QuickBEAM.VM.Runtime alias QuickBEAM.VM.Runtime.Engine.Measurement - alias QuickBEAM.VM.Bytecode.Verifier @default_timeout 5_000 @default_memory_limit 64 * 1024 * 1024 @@ -157,72 +157,70 @@ defmodule QuickBEAM.VM.Runtime.Engine do vars = Keyword.get(opts, :vars, %{}) handlers = Keyword.get(opts, :handlers, %{}) - cond do - isolation not in [:caller, :process] -> - {:error, {:invalid_option, :isolation, isolation}} - - engine not in [:interpreter, :compiler] -> - {:error, {:invalid_option, :engine, engine}} - - not (is_atom(compiler_pool) or is_pid(compiler_pool)) -> - {:error, {:invalid_option, :compiler_pool, compiler_pool}} - - compiler_profile not in [:pure_v1, :scalar_v1] -> - {:error, {:invalid_option, :compiler_profile, compiler_profile}} - - not is_boolean(compiler_region_probe) -> - {:error, {:invalid_option, :compiler_region_probe, compiler_region_probe}} - - not is_boolean(compiler_regions) -> - {:error, {:invalid_option, :compiler_regions, compiler_regions}} - - timeout != :infinity and (not is_integer(timeout) or timeout <= 0) -> - {:error, {:invalid_option, :timeout, timeout}} + 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 - not is_integer(max_steps) or max_steps <= 0 -> - {:error, {:invalid_option, :max_steps, max_steps}} + defp validate_member(name, value, allowed) do + if value in allowed, do: :ok, else: {:error, {:invalid_option, name, value}} + end - not is_integer(max_stack_depth) or max_stack_depth <= 0 -> - {:error, {:invalid_option, :max_stack_depth, max_stack_depth}} + 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}} - memory_limit != :infinity and (not is_integer(memory_limit) or memory_limit <= 0) -> - {:error, {:invalid_option, :memory_limit, memory_limit}} + defp validate_boolean(_name, value) when is_boolean(value), do: :ok + defp validate_boolean(name, value), do: {:error, {:invalid_option, name, value}} - profile not in [:core, :ssr] -> - {:error, {:invalid_option, :profile, profile}} + defp validate_limit(_name, :infinity), do: :ok + defp validate_limit(name, value), do: validate_positive(name, value) - not is_map(vars) -> - {:error, {:invalid_option, :vars, vars}} + 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}} - not is_map(handlers) or - not Enum.all?(handlers, fn {name, handler} -> - is_binary(name) and is_function(handler, 1) - end) -> - {:error, {:invalid_option, :handlers, handlers}} + defp validate_map(_name, value) when is_map(value), do: :ok + defp validate_map(name, value), do: {:error, {:invalid_option, name, value}} - true -> - {: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 + 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} diff --git a/lib/quickbeam/vm/runtime/exception.ex b/lib/quickbeam/vm/runtime/exception.ex index 9cce53012..f8de87040 100644 --- a/lib/quickbeam/vm/runtime/exception.ex +++ b/lib/quickbeam/vm/runtime/exception.ex @@ -6,25 +6,20 @@ defmodule QuickBEAM.VM.Runtime.Exception do to `QuickBEAM.JSError` happens only after a value escapes the evaluation. """ - alias QuickBEAM.VM.Runtime.Boundary - alias QuickBEAM.VM.Runtime.Async - alias QuickBEAM.VM.Builtin.Runtime, as: BuiltinRuntime - - alias QuickBEAM.VM.Runtime.State - alias QuickBEAM.VM.Runtime.Frame + alias QuickBEAM.VM.Bytecode.Atom, as: AtomTable alias QuickBEAM.VM.Program.Function - alias QuickBEAM.VM.Runtime.Heap - + 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.Bytecode.Atom, as: AtomTable 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 @@ -329,5 +324,5 @@ defmodule QuickBEAM.VM.Runtime.Exception do 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: QuickBEAM.VM.Runtime.Value.to_string_value(value) + defp to_string_value(value), do: Value.to_string_value(value) end diff --git a/lib/quickbeam/vm/runtime/interpreter.ex b/lib/quickbeam/vm/runtime/interpreter.ex index 79ade2ffd..2e935c9a9 100644 --- a/lib/quickbeam/vm/runtime/interpreter.ex +++ b/lib/quickbeam/vm/runtime/interpreter.ex @@ -7,46 +7,38 @@ defmodule QuickBEAM.VM.Runtime.Interpreter do Elixir or native call stack. """ - alias QuickBEAM.VM.Runtime.Boundary - alias QuickBEAM.VM.Runtime.Async - + 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.State - alias QuickBEAM.VM.Runtime.Value.Export 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.Frame.Native - - alias QuickBEAM.VM.Bytecode.Opcode - alias QuickBEAM.VM.Program - alias QuickBEAM.VM.Runtime.Promise - - alias QuickBEAM.VM.Runtime.Promise.Reference, as: PromiseReference - alias QuickBEAM.VM.Runtime.Property - alias QuickBEAM.VM.Runtime.Promise.Reaction - - alias QuickBEAM.VM.Runtime.Reference - alias QuickBEAM.VM.Runtime.RegExp - - alias QuickBEAM.VM.Runtime.Value - - alias QuickBEAM.VM.Builtin.Registry - alias QuickBEAM.VM.Runtime.Optimization 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 diff --git a/lib/quickbeam/vm/runtime/invocation.ex b/lib/quickbeam/vm/runtime/invocation.ex index a8bc74733..254307bc7 100644 --- a/lib/quickbeam/vm/runtime/invocation.ex +++ b/lib/quickbeam/vm/runtime/invocation.ex @@ -9,18 +9,18 @@ defmodule QuickBEAM.VM.Runtime.Invocation do """ 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.State alias QuickBEAM.VM.Runtime.Frame - alias QuickBEAM.VM.Program.Function + alias QuickBEAM.VM.Runtime.Heap alias QuickBEAM.VM.Runtime.Promise alias QuickBEAM.VM.Runtime.Property alias QuickBEAM.VM.Runtime.Reference - - alias QuickBEAM.VM.Builtin.Action - alias QuickBEAM.VM.Builtin.Call + alias QuickBEAM.VM.Runtime.State @builtin_tags [:builtin, :declared_builtin, :primitive_method] @@ -137,7 +137,7 @@ defmodule QuickBEAM.VM.Runtime.Invocation do @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 QuickBEAM.VM.Runtime.Heap.fetch_object(execution, constructor) do + case Heap.fetch_object(execution, constructor) do {:ok, %{internal: :class_constructor}} -> true diff --git a/lib/quickbeam/vm/runtime/iterator.ex b/lib/quickbeam/vm/runtime/iterator.ex index 1f0fe9944..a8febbc0c 100644 --- a/lib/quickbeam/vm/runtime/iterator.ex +++ b/lib/quickbeam/vm/runtime/iterator.ex @@ -8,15 +8,15 @@ defmodule QuickBEAM.VM.Runtime.Iterator do `value`. No JavaScript call is executed recursively. """ + alias QuickBEAM.VM.Runtime.Boundary alias QuickBEAM.VM.Runtime.Exception - alias QuickBEAM.VM.Runtime.State alias QuickBEAM.VM.Runtime.Heap alias QuickBEAM.VM.Runtime.Invocation - alias QuickBEAM.VM.Runtime.Boundary 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 diff --git a/lib/quickbeam/vm/runtime/promise.ex b/lib/quickbeam/vm/runtime/promise.ex index 3b73ff103..012cf80b3 100644 --- a/lib/quickbeam/vm/runtime/promise.ex +++ b/lib/quickbeam/vm/runtime/promise.ex @@ -8,12 +8,12 @@ defmodule QuickBEAM.VM.Runtime.Promise do alias QuickBEAM.VM.Runtime.Callable alias QuickBEAM.VM.Runtime.Coroutine - alias QuickBEAM.VM.Runtime.State alias QuickBEAM.VM.Runtime.Memory - alias QuickBEAM.VM.Runtime.Property - alias QuickBEAM.VM.Runtime.Promise.Reference, as: PromiseReference 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()} From 42c5774a5116e491d7b6b005ece1c6b68d29491e Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 17 Jul 2026 16:10:40 +0200 Subject: [PATCH 79/87] Compare native and BEAM VM SSR latency --- bench/README.md | 9 +- bench/vm_ssr_compare.exs | 304 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 312 insertions(+), 1 deletion(-) create mode 100644 bench/vm_ssr_compare.exs diff --git a/bench/README.md b/bench/README.md index f2d3594b0..d14773bcc 100644 --- a/bench/README.md +++ b/bench/README.md @@ -23,6 +23,9 @@ 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 @@ -34,7 +37,11 @@ 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. +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. Compilation and service initialization are excluded from warm +latency. ## Results diff --git a/bench/vm_ssr_compare.exs b/bench/vm_ssr_compare.exs new file mode 100644 index 000000000..5ce94db46 --- /dev/null +++ b/bench/vm_ssr_compare.exs @@ -0,0 +1,304 @@ +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 BEAM + modes execute the same request bytecode with a fresh owner-local JavaScript + heap for every sample. 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, program} = QuickBEAM.VM.decode(request_bytecode) + {:ok, pinned} = QuickBEAM.VM.pin(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.eval(pinned, vm_opts) end, + compiler_pure: fn -> compiler_eval(pinned, :pure_v1, vm_opts) end, + compiler_scalar: fn -> compiler_eval(pinned, :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) + 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()) From 95a229f207ed280255598d191e40730143cb5730 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 17 Jul 2026 16:39:16 +0200 Subject: [PATCH 80/87] Reduce compiler overhead on interpreted SSR --- lib/quickbeam/vm/compiler.ex | 18 ++++++++------ lib/quickbeam/vm/compiler/context.ex | 4 +-- lib/quickbeam/vm/compiler/contract.ex | 27 ++++++++++++++------ lib/quickbeam/vm/compiler/profile/pure.ex | 9 ++++--- lib/quickbeam/vm/runtime/interpreter.ex | 8 ++++++ lib/quickbeam/vm/runtime/optimization.ex | 2 +- test/vm/compiler/contract_test.exs | 18 +++++++++++--- test/vm/compiler/orchestration_test.exs | 30 +++++++++++++++++++---- test/vm/compiler/profile/pure_test.exs | 19 +++++++------- 9 files changed, 95 insertions(+), 40 deletions(-) diff --git a/lib/quickbeam/vm/compiler.ex b/lib/quickbeam/vm/compiler.ex index 5964c4322..955474126 100644 --- a/lib/quickbeam/vm/compiler.ex +++ b/lib/quickbeam/vm/compiler.ex @@ -88,16 +88,20 @@ defmodule QuickBEAM.VM.Compiler do 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: if(execution.measurement_target, do: Counter.new()), + counters: counters, deopt_module: Deopt, executor: __MODULE__, - instrumentation: Instrumentation, + instrumentation: if(counters || region_probe, do: Instrumentation), pool: pool, - profile: Keyword.get(opts, :compiler_profile, :pure_v1), + profile: profile, program: program, - region_probe: if(Keyword.get(opts, :compiler_region_probe) == true, do: Probe.new()), + region_probe: region_probe, regions: Keyword.get(opts, :compiler_regions, false) } @@ -143,14 +147,12 @@ defmodule QuickBEAM.VM.Compiler do frame, %State{ compiler_context: %Context{ - program: %Program{root: %Function{} = root} = program, - min_nested_instructions: nested_minimum, + program: %Program{root: %Function{}} = program, + min_function_instructions: minimum, profile: profile } } = execution ) do - minimum = if function.id == root.id, do: 1, else: nested_minimum - if Pure.candidate?(function, minimum, profile) do prepare_keyed_frame(program, function, minimum, profile, frame, execution) else diff --git a/lib/quickbeam/vm/compiler/context.ex b/lib/quickbeam/vm/compiler/context.ex index ede356987..545667127 100644 --- a/lib/quickbeam/vm/compiler/context.ex +++ b/lib/quickbeam/vm/compiler/context.ex @@ -19,7 +19,7 @@ defmodule QuickBEAM.VM.Compiler.Context do artifact_namespace: nil, decisions: %{}, max_decisions: 256, - min_nested_instructions: 32, + min_function_instructions: 32, profile: :pure_v1, counters: nil, region_probe: nil, @@ -38,7 +38,7 @@ defmodule QuickBEAM.VM.Compiler.Context do :skip | {:cached, binary()} | {:compile, binary(), term()} }, max_decisions: pos_integer(), - min_nested_instructions: non_neg_integer(), + min_function_instructions: non_neg_integer(), profile: :pure_v1 | :scalar_v1, counters: Counter.t() | nil, region_probe: Probe.t() | nil, diff --git a/lib/quickbeam/vm/compiler/contract.ex b/lib/quickbeam/vm/compiler/contract.ex index 9cf8f4167..493477ec3 100644 --- a/lib/quickbeam/vm/compiler/contract.ex +++ b/lib/quickbeam/vm/compiler/contract.ex @@ -7,8 +7,8 @@ defmodule QuickBEAM.VM.Compiler.Contract do table. Compiler implementations must not construct additional module names. """ - alias QuickBEAM.VM.Program.Function alias QuickBEAM.VM.Program + alias QuickBEAM.VM.Program.Function @contract_version 1 @runtime_abi_version 5 @@ -146,7 +146,7 @@ defmodule QuickBEAM.VM.Compiler.Contract do :ok <- validate_region_preferred(region_preferred) do payload = { program_identity, - strip_repeated_atoms(function), + artifact_function_identity(function), profile, region_entry, region_preferred @@ -159,13 +159,26 @@ defmodule QuickBEAM.VM.Compiler.Contract do def artifact_key_from_identity(program_identity, function, _opts), do: {:error, {:invalid_artifact_identity, program_identity, function}} - defp strip_repeated_atoms(%Function{} = function) do - constants = Enum.map(function.constants, &strip_repeated_atoms/1) - %{function | atoms: nil, constants: constants} + 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 strip_repeated_atoms(value), do: value - defp digest(value) do binary = :erlang.term_to_binary(value, [:deterministic]) :crypto.hash(:sha256, binary) diff --git a/lib/quickbeam/vm/compiler/profile/pure.ex b/lib/quickbeam/vm/compiler/profile/pure.ex index 05071be5e..5d7368ed8 100644 --- a/lib/quickbeam/vm/compiler/profile/pure.ex +++ b/lib/quickbeam/vm/compiler/profile/pure.ex @@ -7,13 +7,13 @@ defmodule QuickBEAM.VM.Compiler.Profile.Pure do 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.Bytecode.Opcode - alias QuickBEAM.VM.Bytecode.Verifier.Stack alias QuickBEAM.VM.Runtime.Opcode.Invocation @max_block_instruction_count 256 @@ -89,10 +89,11 @@ defmodule QuickBEAM.VM.Compiler.Profile.Pure do def prepare(%Function{} = function, minimum, profile) when is_integer(minimum) and minimum >= 0 and profile in [:pure_v1, :scalar_v1] do - with {:ok, plan, levels} <- analyze_plan(function, profile) 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) - template = template(function, plan, levels, profile) if eligible_template?(template, plan, count, entry_count, minimum), do: {:ok, template, count}, diff --git a/lib/quickbeam/vm/runtime/interpreter.ex b/lib/quickbeam/vm/runtime/interpreter.ex index 2e935c9a9..2c1cd8108 100644 --- a/lib/quickbeam/vm/runtime/interpreter.ex +++ b/lib/quickbeam/vm/runtime/interpreter.ex @@ -313,6 +313,14 @@ defmodule QuickBEAM.VM.Runtime.Interpreter do 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) diff --git a/lib/quickbeam/vm/runtime/optimization.ex b/lib/quickbeam/vm/runtime/optimization.ex index b98f92aaa..9eff0e1e8 100644 --- a/lib/quickbeam/vm/runtime/optimization.ex +++ b/lib/quickbeam/vm/runtime/optimization.ex @@ -70,7 +70,7 @@ defmodule QuickBEAM.VM.Runtime.Optimization do arguments, _default ) - when is_atom(instrumentation), + when is_atom(instrumentation) and not is_nil(instrumentation), do: apply(instrumentation, function, arguments) defp instrument(%State{}, _function, _arguments, default), do: default diff --git a/test/vm/compiler/contract_test.exs b/test/vm/compiler/contract_test.exs index d5cf8b228..355d4425f 100644 --- a/test/vm/compiler/contract_test.exs +++ b/test/vm/compiler/contract_test.exs @@ -3,10 +3,10 @@ defmodule QuickBEAM.VM.Compiler.ContractTest do alias QuickBEAM.VM.Compiler.Contract alias QuickBEAM.VM.Compiler.Deopt - alias QuickBEAM.VM.Runtime.State - alias QuickBEAM.VM.Runtime.Frame - alias QuickBEAM.VM.Program.Function 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() @@ -69,10 +69,22 @@ defmodule QuickBEAM.VM.Compiler.ContractTest do 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) diff --git a/test/vm/compiler/orchestration_test.exs b/test/vm/compiler/orchestration_test.exs index 64ec2efc2..67ef7e5fb 100644 --- a/test/vm/compiler/orchestration_test.exs +++ b/test/vm/compiler/orchestration_test.exs @@ -2,6 +2,8 @@ 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 @@ -72,11 +74,27 @@ defmodule QuickBEAM.VM.Compiler.OrchestrationTest do 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() - assert {:ok, program} = QuickBEAM.VM.compile("40 + 2") - assert {:ok, 42, cold_execution} = Compiler.start(program) - assert {:ok, 42, warm_execution} = Compiler.start(program) + 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) @@ -303,14 +321,16 @@ defmodule QuickBEAM.VM.Compiler.OrchestrationTest do test "shares cached generated code across isolated evaluation owners" do start_compiler() - assert {:ok, program} = QuickBEAM.VM.compile("(20 + 1) * 2") + 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, 42}, 40) + assert Task.await_many(tasks, 5_000) == List.duplicate({:ok, 40}, 40) stats = Pool.stats(Pool) assert stats.counts.ready >= 1 diff --git a/test/vm/compiler/profile/pure_test.exs b/test/vm/compiler/profile/pure_test.exs index cef362af7..aac9c6614 100644 --- a/test/vm/compiler/profile/pure_test.exs +++ b/test/vm/compiler/profile/pure_test.exs @@ -1,23 +1,23 @@ 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.Contract - alias QuickBEAM.VM.Compiler.Deopt alias QuickBEAM.VM.Compiler.Code - alias QuickBEAM.VM.Compiler.Pool - alias QuickBEAM.VM.Compiler.Runtime 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.State alias QuickBEAM.VM.Runtime.Frame - alias QuickBEAM.VM.Program.Function alias QuickBEAM.VM.Runtime.Interpreter alias QuickBEAM.VM.Runtime.Invocation - alias QuickBEAM.VM.Bytecode.Opcode - alias QuickBEAM.VM.Program + alias QuickBEAM.VM.Runtime.State test "builds deterministic CFG blocks with canonical successors and predecessors" do function = branch_function() @@ -78,8 +78,7 @@ defmodule QuickBEAM.VM.Compiler.Profile.PureTest do function = Enum.find(program.root.constants, &is_struct(&1, Function)) - assert {:ok, whole_template, _count} = Pure.prepare(function, 32, :scalar_v1) - refute Pure.scalar_template?(whole_template) + 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) From e6402380c54c4513cffcdf94164c9c2658feead5 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 17 Jul 2026 17:01:46 +0200 Subject: [PATCH 81/87] Expose bounded scalar eligibility diagnostics --- lib/quickbeam/vm/compiler/profile/pure.ex | 10 +++ lib/quickbeam/vm/compiler/profile/scalar.ex | 72 ++++++++++++++------- test/vm/compiler/profile/pure_test.exs | 17 +++++ 3 files changed, 75 insertions(+), 24 deletions(-) diff --git a/lib/quickbeam/vm/compiler/profile/pure.ex b/lib/quickbeam/vm/compiler/profile/pure.ex index 5d7368ed8..c3127052c 100644 --- a/lib/quickbeam/vm/compiler/profile/pure.ex +++ b/lib/quickbeam/vm/compiler/profile/pure.ex @@ -75,6 +75,16 @@ defmodule QuickBEAM.VM.Compiler.Profile.Pure 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 diff --git a/lib/quickbeam/vm/compiler/profile/scalar.ex b/lib/quickbeam/vm/compiler/profile/scalar.ex index fea2c9a03..e5f82c438 100644 --- a/lib/quickbeam/vm/compiler/profile/scalar.ex +++ b/lib/quickbeam/vm/compiler/profile/scalar.ex @@ -13,6 +13,8 @@ defmodule QuickBEAM.VM.Compiler.Profile.Scalar do @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( @@ -38,36 +40,58 @@ defmodule QuickBEAM.VM.Compiler.Profile.Scalar do do: lower(function, plan, levels, :runtime) defp lower(function, plan, levels, tuple_mode) do - if eligible?(function, plan, levels) do - {:ok, - %Template{ - forms: [ - {:attribute, @line, :module, Template.placeholder_module()}, - {:attribute, @line, :export, [run: 3]}, - run_form(), - block_form(plan, levels, tuple_mode), - {:eof, @line} - ] - }} - else - :not_eligible + 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 - defp eligible?(function, plan, levels) do - bounded_shape?(function, plan, levels) and eligible_constants?(function.constants) and - checked_locals_initialized?(function, plan) + @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 bounded_shape?(function, plan, levels) do - function.stack_size <= @max_stack_depth and function.arg_count <= 8 and - function.var_count <= 8 and map_size(plan) <= @max_scalar_blocks and - scalar_operation_count(plan) <= @max_scalar_operations and - Enum.all?(plan, fn {_pc, {operations, _reason}} -> length(operations) <= 32 end) and - Enum.all?(levels, fn {_pc, {depth, _catch}} -> depth <= @max_stack_depth 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 eligible_constants?(constants), do: not captured_frame_slots?(constants) + 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) diff --git a/test/vm/compiler/profile/pure_test.exs b/test/vm/compiler/profile/pure_test.exs index aac9c6614..b357bd774 100644 --- a/test/vm/compiler/profile/pure_test.exs +++ b/test/vm/compiler/profile/pure_test.exs @@ -70,6 +70,23 @@ defmodule QuickBEAM.VM.Compiler.Profile.PureTest do 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) From 07573dfa99b141df6dff3f9ed2960d7f5785666b Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 17 Jul 2026 17:50:38 +0200 Subject: [PATCH 82/87] Harden scalar generated register liveness --- lib/quickbeam/vm/compiler.ex | 6 +- lib/quickbeam/vm/compiler/code/emitter.ex | 11 +- lib/quickbeam/vm/compiler/contract.ex | 2 +- lib/quickbeam/vm/compiler/profile/pure.ex | 2 +- lib/quickbeam/vm/compiler/profile/scalar.ex | 357 +++++++++++++++----- lib/quickbeam/vm/compiler/runtime.ex | 14 +- test/vm/compiler/profile/pure_test.exs | 35 +- 7 files changed, 317 insertions(+), 110 deletions(-) diff --git a/lib/quickbeam/vm/compiler.ex b/lib/quickbeam/vm/compiler.ex index 955474126..c90d1c6a4 100644 --- a/lib/quickbeam/vm/compiler.ex +++ b/lib/quickbeam/vm/compiler.ex @@ -231,7 +231,11 @@ defmodule QuickBEAM.VM.Compiler do 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}) - invoke_frame(execution.compiler_context.pool, key, template, frame, execution) + + 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 diff --git a/lib/quickbeam/vm/compiler/code/emitter.ex b/lib/quickbeam/vm/compiler/code/emitter.ex index 76c6040c9..b9cb27057 100644 --- a/lib/quickbeam/vm/compiler/code/emitter.ex +++ b/lib/quickbeam/vm/compiler/code/emitter.ex @@ -4,13 +4,16 @@ defmodule QuickBEAM.VM.Compiler.Code.Emitter do 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. + 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.Contract 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 @@ -98,7 +101,9 @@ defmodule QuickBEAM.VM.Compiler.Code.Emitter do defp replace_module_attribute(form, _module), do: form defp compile_forms(forms, module) do - case :compile.forms(forms, [:binary, :deterministic, :return_errors, :return_warnings]) do + options = [:binary, :deterministic, :no_ssa_opt, :return_errors, :return_warnings] + + case :compile.forms(forms, options) do {:ok, ^module, binary} -> {:ok, binary} diff --git a/lib/quickbeam/vm/compiler/contract.ex b/lib/quickbeam/vm/compiler/contract.ex index 493477ec3..1a0923e12 100644 --- a/lib/quickbeam/vm/compiler/contract.ex +++ b/lib/quickbeam/vm/compiler/contract.ex @@ -11,7 +11,7 @@ defmodule QuickBEAM.VM.Compiler.Contract do alias QuickBEAM.VM.Program.Function @contract_version 1 - @runtime_abi_version 5 + @runtime_abi_version 6 @artifact_key_bytes 32 @profiles [:pure_v1, :scalar_v1] diff --git a/lib/quickbeam/vm/compiler/profile/pure.ex b/lib/quickbeam/vm/compiler/profile/pure.ex index c3127052c..fc82cce3b 100644 --- a/lib/quickbeam/vm/compiler/profile/pure.ex +++ b/lib/quickbeam/vm/compiler/profile/pure.ex @@ -139,7 +139,7 @@ defmodule QuickBEAM.VM.Compiler.Profile.Pure do @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, 7, _}, &1)) + 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) diff --git a/lib/quickbeam/vm/compiler/profile/scalar.ex b/lib/quickbeam/vm/compiler/profile/scalar.ex index e5f82c438..dbcaa96df 100644 --- a/lib/quickbeam/vm/compiler/profile/scalar.ex +++ b/lib/quickbeam/vm/compiler/profile/scalar.ex @@ -20,12 +20,27 @@ defmodule QuickBEAM.VM.Compiler.Profile.Scalar do @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() @@ -209,7 +224,7 @@ defmodule QuickBEAM.VM.Compiler.Profile.Scalar do case_expression(state, [ clause( [tuple([pc, args, locals, stack])], - [local_call(:block, [pc, lease, frame, args, locals, stack, execution])] + [local_call(:block, [pc, lease, tuple([frame, args, locals, stack, execution])])] ) ]) @@ -227,16 +242,18 @@ defmodule QuickBEAM.VM.Compiler.Profile.Scalar do [ variable(:_PC), variable(:Lease), - variable(:Frame), - variable(:Args), - variable(:Locals), - variable(:Stack), - variable(:Execution) + tuple([ + variable(:Frame), + variable(:Args), + variable(:Locals), + variable(:Stack), + variable(:Execution) + ]) ], [deopt_from_arguments(:unsupported_semantics, variable(:_PC), variable(:Stack))] ) - function(:block, 7, clauses ++ [fallback]) + function(:block, 3, clauses ++ [fallback]) end defp block_clause(pc, {[], reason}, levels, _tuple_mode) do @@ -260,7 +277,9 @@ defmodule QuickBEAM.VM.Compiler.Profile.Scalar do locals: variable(:Locals), stack: stack_values(depth), execution: variable(:Execution), - tuple_mode: tuple_mode + tuple_mode: tuple_mode, + bindings: [], + materialization_counts: %{} } clause(block_arguments(pc, depth), [lower_operations(operations, reason, state)]) @@ -273,18 +292,51 @@ defmodule QuickBEAM.VM.Compiler.Profile.Scalar do 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: lease, - frame: frame, - args: args, - locals: locals, - stack: stack_values(depth), + lease: charged_lease, + frame: charged_frame, + args: charged_args, + locals: charged_locals, + stack: charged_stack, execution: charged_execution, - tuple_mode: tuple_mode + tuple_mode: tuple_mode, + bindings: charged_bindings, + materialization_counts: %{} } lowered = lower_operations(operations, reason, state) @@ -294,12 +346,14 @@ defmodule QuickBEAM.VM.Compiler.Profile.Scalar do charge = remote_call(Runtime, :charge_state, [lease, compact, execution, integer(length(operations))]) - body = - case_expression(charge, [ - clause([tuple([atom(:ok), charged_execution])], [lowered]), + 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 @@ -307,31 +361,40 @@ defmodule QuickBEAM.VM.Compiler.Profile.Scalar do [ integer(pc), variable(:Lease), - variable(:Frame), - variable(:Args), - variable(:Locals), - stack_expression(depth), - variable(:Execution) + tuple([ + variable(:Frame), + variable(:Args), + variable(:Locals), + stack_expression(depth), + variable(:Execution) + ]) ] end - defp lower_operations([], reason, state), do: boundary_expression(reason, state) + defp lower_operations([], reason, state), + do: with_bindings(state, boundary_expression(reason, %{state | bindings: []})) defp lower_operations([{:global, name, operands} | operations], reason, state) do - lower_global(name, operands, operations, reason, state) + with_bindings( + state, + lower_global(name, operands, operations, reason, %{state | bindings: []}) + ) end defp lower_operations([{:object, name, operands} | operations], reason, state) do - lower_property(name, operands, operations, reason, state) + with_bindings( + state, + lower_property(name, operands, operations, reason, %{state | bindings: []}) + ) end defp lower_operations([{:invocation, name, operands}], _reason, state), - do: lower_invocation(name, operands, 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} -> expression + {:terminal, expression, state} -> with_bindings(state, expression) end end @@ -350,14 +413,20 @@ defmodule QuickBEAM.VM.Compiler.Profile.Scalar do 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]) - next_state = %{state | pc: state.pc + 1, stack: [value | state.stack]} success = if name == :get_var do - charge_preflight(state, fn execution -> - lower_operations(operations, reason, %{next_state | execution: execution}) + 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 @@ -379,39 +448,58 @@ defmodule QuickBEAM.VM.Compiler.Profile.Scalar do defp lower_invocation(:call, [argument_count], state) do {arguments, [callable | stack]} = Enum.split(state.stack, argument_count) - arguments = arguments |> Enum.reverse() |> list() state = %{state | pc: state.pc + 1, stack: stack} - invoke_call(callable, arguments, literal(:undefined), state) + 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) - arguments = arguments |> Enum.reverse() |> list() state = %{state | pc: state.pc + 1, stack: stack} - invoke_call(callable, arguments, this, state) + invoke_call(callable, Enum.reverse(arguments), this, state) end defp invoke_call(callable, arguments, this, state) do - compact = tuple([state.frame, integer(state.pc), state.args, state.locals, list(state.stack)]) + 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 - remote_call(Runtime, :invoke_state, [ - callable, - arguments, - this, - compact, - state.execution + 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) + {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]) - next_state = %{state | pc: state.pc + 1, stack: [property_value | result_stack]} success = - charge_preflight(state, fn execution -> - lower_operations(operations, reason, %{next_state | execution: execution}) + 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, [ @@ -421,15 +509,47 @@ defmodule QuickBEAM.VM.Compiler.Profile.Scalar do 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), execution])], [continuation.(execution)]), + clause([tuple([atom(:ok), continuation_state])], [continuation.(charged_state)]), clause([action], [action]) ]) end @@ -480,6 +600,7 @@ defmodule QuickBEAM.VM.Compiler.Profile.Scalar do :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 @@ -487,11 +608,13 @@ defmodule QuickBEAM.VM.Compiler.Profile.Scalar do 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 @@ -512,12 +635,12 @@ defmodule QuickBEAM.VM.Compiler.Profile.Scalar do 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})} + 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 @@ -597,23 +720,22 @@ defmodule QuickBEAM.VM.Compiler.Profile.Scalar do 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 - | pc: state.pc + 1, - args: tuple_put(state, state.args, index, value), - stack: stack - } + 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 | pc: state.pc + 1, args: tuple_put(state, state.args, index, value)} + 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 = tuple_element(state.locals, first) - second = tuple_element(state.locals, second) + {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 @@ -621,39 +743,34 @@ defmodule QuickBEAM.VM.Compiler.Profile.Scalar do operation = if name == :inc_loc, do: :inc, else: :dec current = tuple_element(state.locals, index) value = unary_expression(operation, current, state.pc) - %{state | pc: state.pc + 1, locals: tuple_put(state, state.locals, index, value)} + {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) - - %{ - state - | pc: state.pc + 1, - locals: tuple_put(state, state.locals, index, value), - stack: stack - } + {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 - | pc: state.pc + 1, - locals: tuple_put(state, state.locals, index, value), - stack: stack - } - - defp lower_local(:set_loc, [index], %{stack: [value | _]} = state), - do: %{state | pc: state.pc + 1, locals: tuple_put(state, state.locals, index, value)} - - defp lower_local(:set_loc_uninitialized, [index], state), - do: %{ - state - | pc: state.pc + 1, - locals: tuple_put(state, state.locals, index, atom(:uninitialized)) - } + 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) @@ -662,11 +779,7 @@ defmodule QuickBEAM.VM.Compiler.Profile.Scalar do local_call(:block, [ integer(state.pc), state.lease, - state.frame, - state.args, - state.locals, - list(state.stack), - state.execution + tuple([state.frame, state.args, state.locals, list(state.stack), state.execution]) ]) end @@ -696,12 +809,24 @@ defmodule QuickBEAM.VM.Compiler.Profile.Scalar 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: %{state | pc: state.pc + 1, stack: [expression | state.stack]} + 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} @@ -810,12 +935,57 @@ defmodule QuickBEAM.VM.Compiler.Profile.Scalar do defp tuple_element(tuple, index), do: remote_call(:erlang, :element, [integer(index + 1), tuple]) - defp tuple_put(%{tuple_mode: :beam}, tuple, index, value), + 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_put(%{tuple_mode: :runtime}, tuple, index, 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} @@ -824,6 +994,7 @@ defmodule QuickBEAM.VM.Compiler.Profile.Scalar do 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} diff --git a/lib/quickbeam/vm/compiler/runtime.ex b/lib/quickbeam/vm/compiler/runtime.ex index 1e3147b73..d20230be7 100644 --- a/lib/quickbeam/vm/compiler/runtime.ex +++ b/lib/quickbeam/vm/compiler/runtime.ex @@ -11,7 +11,6 @@ defmodule QuickBEAM.VM.Compiler.Runtime do alias QuickBEAM.VM.Compiler.Contract alias QuickBEAM.VM.Compiler.Deopt alias QuickBEAM.VM.Compiler.Pool.Lease - alias QuickBEAM.VM.Runtime.State alias QuickBEAM.VM.Runtime.Frame alias QuickBEAM.VM.Runtime.Opcode.Control alias QuickBEAM.VM.Runtime.Opcode.Local, as: Locals @@ -19,6 +18,7 @@ defmodule QuickBEAM.VM.Compiler.Runtime do 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() @@ -145,7 +145,7 @@ defmodule QuickBEAM.VM.Compiler.Runtime do @doc "Charges a scalar block or deoptimizes with its reconstructed before-block state." @spec charge_state(Lease.t(), tuple(), State.t(), pos_integer()) :: - {:ok, State.t()} | action() + {:ok, tuple()} | action() def charge_state(%Lease{owner: owner}, _state, _execution, _count) when owner != self(), do: {:error, :compiler_lease_owner_mismatch} @@ -153,14 +153,16 @@ defmodule QuickBEAM.VM.Compiler.Runtime do do: {:error, {:limit_exceeded, :memory_bytes, execution.memory_limit}, execution} def charge_state( - %Lease{}, - {%Frame{}, pc, args, locals, stack}, + %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: {:ok, %{execution | remaining_steps: remaining - count}} + 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, diff --git a/test/vm/compiler/profile/pure_test.exs b/test/vm/compiler/profile/pure_test.exs index b357bd774..646a42f8b 100644 --- a/test/vm/compiler/profile/pure_test.exs +++ b/test/vm/compiler/profile/pure_test.exs @@ -100,8 +100,8 @@ defmodule QuickBEAM.VM.Compiler.Profile.PureTest do assert {:ok, region_template, 32} = Pure.prepare_region(function, 0, :scalar_v1) assert Pure.scalar_template?(region_template) - assert [{:function, _, :block, 7, clauses}] = - Enum.filter(region_template.forms, &match?({:function, _, :block, 7, _}, &1)) + assert [{:function, _, :block, 3, clauses}] = + Enum.filter(region_template.forms, &match?({:function, _, :block, 3, _}, &1)) assert length(clauses) <= 17 end @@ -287,8 +287,8 @@ defmodule QuickBEAM.VM.Compiler.Profile.PureTest do assert %Function{} = function = Enum.find(program.root.constants, &is_struct(&1, Function)) assert {:ok, template, _count} = Pure.prepare(function, 32) - assert [{:function, _, :block, 7, _}] = - Enum.filter(template.forms, &match?({:function, _, :block, 7, _}, &1)) + assert [{:function, _, :block, 3, _}] = + Enum.filter(template.forms, &match?({:function, _, :block, 3, _}, &1)) module = hd(Contract.pool_modules()) assert {:ok, artifact} = Emitter.emit(key(77), module, template) @@ -303,7 +303,7 @@ defmodule QuickBEAM.VM.Compiler.Profile.PureTest do assert {:ok, local_program} = QuickBEAM.VM.compile(local_source) local_function = Enum.find(local_program.root.constants, &is_struct(&1, Function)) assert {:ok, local_template, _count} = Pure.prepare(local_function, 32) - assert Enum.any?(local_template.forms, &match?({:function, _, :block, 7, _}, &1)) + assert Enum.any?(local_template.forms, &match?({:function, _, :block, 3, _}, &1)) start_pool() assert {:ok, interpreter} = Engine.measure(program, max_steps: 10_000) @@ -380,6 +380,31 @@ defmodule QuickBEAM.VM.Compiler.Profile.PureTest do 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() From 2c3213b1f1c097efb7fae3bd2a641f1f84846d12 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 17 Jul 2026 19:07:39 +0200 Subject: [PATCH 83/87] Add isolated VM global calls --- CHANGELOG.md | 2 +- README.md | 26 +++- bench/README.md | 5 +- bench/vm_ssr_compare.exs | 26 ++-- lib/quickbeam/vm.ex | 83 +++++++++++ lib/quickbeam/vm/runtime.ex | 173 ++++++++++++++--------- lib/quickbeam/vm/runtime/engine.ex | 97 ++++++++++--- lib/quickbeam/vm/runtime/interpreter.ex | 21 +++ test/vm/api_test.exs | 13 ++ test/vm/call_test.exs | 177 ++++++++++++++++++++++++ test/vm/preact_ssr_test.exs | 58 +++++++- 11 files changed, 581 insertions(+), 100 deletions(-) create mode 100644 test/vm/call_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index 07b32d8ce..58b05dd9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## 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. +- 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. diff --git a/README.md b/README.md index 4ab86c188..2f411f065 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ request processes copy only a lightweight handle: {:ok, pinned} = QuickBEAM.VM.pin(program) try do - QuickBEAM.VM.eval(pinned, + QuickBEAM.VM.call(pinned, "render", [%{title: "Catalog"}], profile: :ssr, handlers: %{"load_props" => fn [] -> %{title: "Catalog"} end}, max_steps: 20_000_000, @@ -60,6 +60,30 @@ 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`. +- `: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. diff --git a/bench/README.md b/bench/README.md index d14773bcc..643c9d6b7 100644 --- a/bench/README.md +++ b/bench/README.md @@ -40,8 +40,9 @@ 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. Compilation and service initialization are excluded from warm -latency. +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 diff --git a/bench/vm_ssr_compare.exs b/bench/vm_ssr_compare.exs index 5ce94db46..ac4e42b0c 100644 --- a/bench/vm_ssr_compare.exs +++ b/bench/vm_ssr_compare.exs @@ -4,10 +4,11 @@ defmodule QuickBEAM.Bench.VMSSRCompare do 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 BEAM - modes execute the same request bytecode with a fresh owner-local JavaScript - heap for every sample. Source compilation and compiler-service startup are - excluded; all returned values must match exactly. + 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 @@ -60,8 +61,10 @@ defmodule QuickBEAM.Bench.VMSSRCompare do {:ok, request_bytecode} = QuickBEAM.compile(compile_runtime, request_source) :ok = QuickBEAM.stop(compile_runtime) - {:ok, program} = QuickBEAM.VM.decode(request_bytecode) - {:ok, pinned} = QuickBEAM.VM.pin(program) + {: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, @@ -79,9 +82,11 @@ defmodule QuickBEAM.Bench.VMSSRCompare do QuickBEAM.call(warm_runtime, "__quickbeamRender", [], timeout: 10_000) end, native_isolated: fn -> native_isolated(request_bytecode, handlers) end, - interpreter: fn -> QuickBEAM.VM.eval(pinned, vm_opts) end, - compiler_pure: fn -> compiler_eval(pinned, :pure_v1, vm_opts) end, - compiler_scalar: fn -> compiler_eval(pinned, :scalar_v1, vm_opts) 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 @@ -91,7 +96,8 @@ defmodule QuickBEAM.Bench.VMSSRCompare do report(spec.name, samples) after QuickBEAM.stop(warm_runtime) - QuickBEAM.VM.unpin(pinned) + QuickBEAM.VM.unpin(pinned_call) + QuickBEAM.VM.unpin(pinned_request) end end diff --git a/lib/quickbeam/vm.ex b/lib/quickbeam/vm.ex index dd44c32e2..214d8ec12 100644 --- a/lib/quickbeam/vm.ex +++ b/lib/quickbeam/vm.ex @@ -6,6 +6,21 @@ defmodule QuickBEAM.VM do 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. Pinned + storage is fixed-capacity, explicitly retired, and never implicitly evicted. """ alias QuickBEAM.VM.ABI @@ -44,6 +59,8 @@ defmodule QuickBEAM.VM do :invalid_program | :invalid_source | :invalid_bytecode + | :invalid_function_name + | :invalid_arguments | :invalid_pinned_program | :pinned_program_capacity | :pinned_program_unavailable @@ -187,6 +204,34 @@ defmodule QuickBEAM.VM do 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 <- validate_options(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`. @@ -207,6 +252,44 @@ defmodule QuickBEAM.VM do 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 <- validate_options(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. diff --git a/lib/quickbeam/vm/runtime.ex b/lib/quickbeam/vm/runtime.ex index dbf1cdbfc..849320cea 100644 --- a/lib/quickbeam/vm/runtime.ex +++ b/lib/quickbeam/vm/runtime.ex @@ -26,155 +26,189 @@ defmodule QuickBEAM.VM.Runtime do |> 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 - ref = make_ref() - result = eval(program, Keyword.put(opts, :measurement_target, {self(), ref})) + measured(fn measured_opts -> eval(program, measured_opts) end, opts) + end - receive do - {:quickbeam_vm_measurement, ^ref, metrics} -> {result, metrics} - after - 0 -> {result, nil} - 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({:ok, %PromiseReference{} = promise, execution}), - do: await_final_promise(promise, execution) + 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) - def drive({:suspended, %Continuation{awaiting: :microtask} = continuation}) do + 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) + then_resume(result, continuation, finish) {:empty, _jobs} -> - finish_final({:error, :missing_microtask, continuation.execution}) + finish.({:error, :missing_microtask, continuation.execution}) end end - def drive({:suspended, %Continuation{awaiting: %PromiseReference{}} = continuation}) do - await_legacy_promise(continuation) + defp drive_with( + {:suspended, %Continuation{awaiting: %PromiseReference{}} = continuation}, + finish + ) do + await_legacy_promise(continuation, finish) end - def drive({:suspended, %Continuation{} = continuation} = suspended), + defp drive_with({:suspended, %Continuation{} = continuation} = suspended, _finish), do: finish_suspended(suspended, continuation.execution) - def drive({status, _value, _execution} = result) when status in [:ok, :error], - do: finish_final(result) + defp drive_with({status, _value, _execution} = result, finish) when status in [:ok, :error], + do: finish.(result) - def drive({:idle, execution}), - do: finish_final({:error, :idle_evaluation, execution}) + defp drive_with({:idle, execution}, finish), + do: finish.({:error, :idle_evaluation, execution}) - defp await_final_promise(%PromiseReference{} = promise, execution) do + defp await_final_promise(%PromiseReference{} = promise, execution, finish) do if :queue.is_empty(execution.sync_jobs) do - await_settled_promise(promise, execution) + await_settled_promise(promise, execution, finish) else execution |> Interpreter.run_synchronous_job() - |> continue_final(promise) + |> continue_final(promise, finish) end end - defp await_settled_promise(promise, execution) do + defp await_settled_promise(promise, execution, finish) do case Promise.state(execution, promise) do {:fulfilled, value} -> - finish_final({:ok, value, execution}) + finish.({:ok, value, execution}) {:rejected, %QuickBEAM.JSError{} = error} -> - finish_final({:error, error, execution}) + finish.({:error, error, execution}) {:rejected, reason} -> error = Exception.to_js_error(reason, execution, []) - finish_final({:error, error, execution}) + finish.({:error, error, execution}) :pending -> - drive_event_loop(promise, execution) + drive_event_loop(promise, execution, finish) end end - defp drive_event_loop(final_promise, execution) do + 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) + run_job(job, final_promise, execution, finish) {:empty, _jobs} when map_size(execution.operations) > 0 -> - receive_host_reply(final_promise, execution) + receive_host_reply(final_promise, execution, finish) {:empty, _jobs} -> - finish_final({:error, {:promise_deadlock, final_promise.id}, execution}) + finish.({:error, {:promise_deadlock, final_promise.id}, execution}) end end - defp run_job({:resume_coroutine, %Coroutine{} = coroutine, result}, final_promise, execution) do + defp run_job( + {:resume_coroutine, %Coroutine{} = coroutine, result}, + final_promise, + execution, + finish + ) do coroutine |> Interpreter.resume_coroutine(result, execution) - |> continue_final(final_promise) + |> continue_final(final_promise, finish) end - defp run_job({:read_thenable, promise, thenable, getter}, final_promise, execution) do + defp run_job({:read_thenable, promise, thenable, getter}, final_promise, execution, finish) do promise |> Interpreter.read_thenable(thenable, getter, execution) - |> continue_final(final_promise) + |> continue_final(final_promise, finish) end defp run_job( {:assimilate_thenable, promise, thenable, callable}, final_promise, - execution + execution, + finish ) do promise |> Interpreter.assimilate_thenable(thenable, callable, execution) - |> continue_final(final_promise) + |> continue_final(final_promise, finish) end - defp run_job({:run_reaction, %Reaction{} = reaction, result}, final_promise, execution) do + defp run_job( + {:run_reaction, %Reaction{} = reaction, result}, + final_promise, + execution, + finish + ) do reaction |> Interpreter.run_reaction(result, execution) - |> continue_final(final_promise) + |> continue_final(final_promise, finish) end - defp run_job({:aggregate_settle, id, index, result}, final_promise, execution) do + 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) + await_final_promise(final_promise, execution, finish) end - defp run_job({:settle_assimilated, promise, result}, final_promise, execution) do + 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) + await_final_promise(final_promise, execution, finish) end - defp run_job({:settle_promise, promise, result}, final_promise, execution) do + defp run_job({:settle_promise, promise, result}, final_promise, execution, finish) do execution = Promise.settle(execution, promise, result) - await_final_promise(final_promise, execution) + await_final_promise(final_promise, execution, finish) end - defp continue_final({:idle, execution}, final_promise), - do: await_final_promise(final_promise, execution) + defp continue_final({:idle, execution}, final_promise, finish), + do: await_final_promise(final_promise, execution, finish) - defp continue_final({:ok, _value, execution}, final_promise), - do: await_final_promise(final_promise, execution) + 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), - do: finish_final(error) + defp continue_final({:error, _reason, _execution} = error, _final_promise, finish), + do: finish.(error) - defp continue_final({:suspended, continuation}, _final_promise), - do: drive({:suspended, continuation}) + defp continue_final({:suspended, continuation}, _final_promise, finish), + do: drive_with({:suspended, continuation}, finish) - defp receive_host_reply(final_promise, execution) do + 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) - :stale -> receive_host_reply(final_promise, execution) + {: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) do + 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 @@ -182,15 +216,19 @@ defmodule QuickBEAM.VM.Runtime do continuation = %{continuation | execution: execution} if Promise.state(execution, continuation.awaiting) == :pending do - await_legacy_promise(continuation) + await_legacy_promise(continuation, finish) else result = settled_result(continuation.awaiting, execution) execution = %{execution | jobs: :queue.in(result, execution.jobs)} - drive({:suspended, %{continuation | execution: execution, awaiting: :microtask}}) + + drive_with( + {:suspended, %{continuation | execution: execution, awaiting: :microtask}}, + finish + ) end :stale -> - await_legacy_promise(continuation) + await_legacy_promise(continuation, finish) end end end @@ -202,10 +240,21 @@ defmodule QuickBEAM.VM.Runtime do end end - defp then_resume(result, continuation) do + defp then_resume(result, continuation, finish) do continuation |> Interpreter.resume_raw(result) - |> drive() + |> 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) diff --git a/lib/quickbeam/vm/runtime/engine.ex b/lib/quickbeam/vm/runtime/engine.ex index 37e87f096..6b12dc075 100644 --- a/lib/quickbeam/vm/runtime/engine.ex +++ b/lib/quickbeam/vm/runtime/engine.ex @@ -29,11 +29,47 @@ defmodule QuickBEAM.VM.Runtime.Engine do @doc "Evaluates through an explicitly selected internal engine." @spec eval(Program.t() | Pinned.t(), [option()]) :: QuickBEAM.VM.result(term()) - def eval(program, opts \\ []) + def eval(program, opts \\ []), do: execute(program, opts, :eval) - def eval(%Pinned{} = pinned, opts) when is_list(opts) do + @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) @@ -45,9 +81,11 @@ defmodule QuickBEAM.VM.Runtime.Engine do end end - def eval(%Program{} = program, opts) when is_list(opts) do + 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) @@ -55,19 +93,16 @@ defmodule QuickBEAM.VM.Runtime.Engine do end end - def eval(program, _opts) - when not is_struct(program, Program) and not is_struct(program, Pinned), - do: {:error, :invalid_program} + defp execute(program, _opts, _request) + when not is_struct(program, Program) and not is_struct(program, Pinned), + do: {:error, :invalid_program} - def eval(_program, opts), do: {:error, {:invalid_options, opts}} - - @doc "Measures an explicitly selected internal engine evaluation." - @spec measure(Program.t() | Pinned.t(), [option()]) :: QuickBEAM.VM.result(Measurement.t()) - def measure(program, opts \\ []) + defp execute(_program, opts, _request), do: {:error, {:invalid_options, opts}} - def measure(%Pinned{} = pinned, opts) when is_list(opts) do + 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 = @@ -86,9 +121,10 @@ defmodule QuickBEAM.VM.Runtime.Engine do end end - def measure(%Program{} = program, opts) when is_list(opts) do + 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 = @@ -103,11 +139,11 @@ defmodule QuickBEAM.VM.Runtime.Engine do end end - def measure(program, _opts) - when not is_struct(program, Program) and not is_struct(program, Pinned), - do: {:error, :invalid_program} + defp measure_request(program, _opts, _request) + when not is_struct(program, Program) and not is_struct(program, Pinned), + do: {:error, :invalid_program} - def measure(_program, opts), do: {:error, {:invalid_options, opts}} + defp measure_request(_program, opts, _request), do: {:error, {:invalid_options, opts}} defp evaluation_options(opts) do allowed = [ @@ -260,12 +296,21 @@ defmodule QuickBEAM.VM.Runtime.Engine do await_evaluation(pid, monitor_ref, reply_ref, options.timeout, options.memory_limit) end - defp evaluate(program, %{engine: :interpreter, interpreter: options}), + defp evaluate(program, %{engine: :interpreter, interpreter: options, request: :eval}), do: Runtime.eval(program, Map.to_list(options)) - defp evaluate(program, %{engine: :compiler, interpreter: 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}} @@ -307,8 +352,8 @@ defmodule QuickBEAM.VM.Runtime.Engine do end end - defp safe_measure(program, %{engine: engine, interpreter: options}) do - {result, metrics} = measure_engine(engine, program, Map.to_list(options)) + 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), @@ -323,12 +368,18 @@ defmodule QuickBEAM.VM.Runtime.Engine do {:measured, {:error, {engine_crash(engine), {kind, reason}, __STACKTRACE__}}, nil} end - defp measure_engine(:interpreter, program, options), + defp measure_engine(:interpreter, program, options, :eval), do: Runtime.eval_with_metrics(program, options) - defp measure_engine(:compiler, 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 diff --git a/lib/quickbeam/vm/runtime/interpreter.ex b/lib/quickbeam/vm/runtime/interpreter.ex index 2c1cd8108..012ad35d3 100644 --- a/lib/quickbeam/vm/runtime/interpreter.ex +++ b/lib/quickbeam/vm/runtime/interpreter.ex @@ -91,6 +91,27 @@ defmodule QuickBEAM.VM.Runtime.Interpreter do 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) diff --git a/test/vm/api_test.exs b/test/vm/api_test.exs index c4cb09435..b5622848d 100644 --- a/test/vm/api_test.exs +++ b/test/vm/api_test.exs @@ -8,7 +8,13 @@ defmodule QuickBEAM.VM.APITest do 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 @@ -28,6 +34,11 @@ defmodule QuickBEAM.VM.APITest do 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 @@ -51,6 +62,8 @@ defmodule QuickBEAM.VM.APITest 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 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/preact_ssr_test.exs b/test/vm/preact_ssr_test.exs index 7c524cc40..d7ab407df 100644 --- a/test/vm/preact_ssr_test.exs +++ b/test/vm/preact_ssr_test.exs @@ -6,7 +6,9 @@ defmodule QuickBEAM.VM.PreactSSRTest do setup_all do assert {:ok, source} = QuickBEAM.JS.bundle_file(@fixture, format: :esm, minify: false) assert {:ok, program} = QuickBEAM.VM.compile(source, filename: @fixture) - {:ok, source: source, program: program} + 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 @@ -53,6 +55,34 @@ defmodule QuickBEAM.VM.PreactSSRTest do 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 @@ -76,6 +106,32 @@ defmodule QuickBEAM.VM.PreactSSRTest do 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, From 2d90c0ef8e6d56f5e3883edd201e272fc9e92890 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 17 Jul 2026 19:33:36 +0200 Subject: [PATCH 84/87] Exclude generated Zig from Hex packages --- mix.exs | 1 + 1 file changed, 1 insertion(+) diff --git a/mix.exs b/mix.exs index f67b36a11..4b1f1ef3a 100644 --- a/mix.exs +++ b/mix.exs @@ -87,6 +87,7 @@ defmodule QuickBEAM.MixProject do defp package do [ + exclude_patterns: [~r{^lib/.*/\.Elixir\..*\.zig$}], licenses: ["MIT"], links: %{ "GitHub" => @source_url From c3e0b8def1112fcdd59e136d0880629cb959af1a Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 17 Jul 2026 19:35:01 +0200 Subject: [PATCH 85/87] Narrow generated Zig package exclusion --- mix.exs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mix.exs b/mix.exs index 4b1f1ef3a..d5b59a6a9 100644 --- a/mix.exs +++ b/mix.exs @@ -87,7 +87,8 @@ defmodule QuickBEAM.MixProject do defp package do [ - exclude_patterns: [~r{^lib/.*/\.Elixir\..*\.zig$}], + # Zigler regenerates this ignored intermediate during source builds. + exclude_patterns: ["lib/quickbeam/.Elixir.QuickBEAM.Native.zig"], licenses: ["MIT"], links: %{ "GitHub" => @source_url From 0cdbbcf568eafd3db94b7c92a79aee8797859318 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 17 Jul 2026 22:37:32 +0200 Subject: [PATCH 86/87] Harden and polish BEAM VM implementation --- README.md | 3 +- lib/mix/tasks/quickbeam.vm.fuzz.ex | 6 +- lib/quickbeam/js/package_resolver.ex | 14 +- lib/quickbeam/js_error.ex | 48 ++- lib/quickbeam/vm.ex | 30 +- lib/quickbeam/vm/builtin/array.ex | 146 +++++---- lib/quickbeam/vm/builtin/dsl.ex | 66 ++-- lib/quickbeam/vm/builtin/installer.ex | 39 +-- lib/quickbeam/vm/builtin/map.ex | 142 +++++---- lib/quickbeam/vm/builtin/number.ex | 2 +- lib/quickbeam/vm/builtin/object.ex | 88 +++--- lib/quickbeam/vm/builtin/runtime.ex | 35 ++- lib/quickbeam/vm/builtin/set.ex | 55 +++- lib/quickbeam/vm/builtin/string.ex | 49 +-- lib/quickbeam/vm/builtin/validator.ex | 150 +++++---- lib/quickbeam/vm/builtin/weak_map.ex | 27 +- lib/quickbeam/vm/builtin/weak_set.ex | 24 +- lib/quickbeam/vm/bytecode/decoder.ex | 208 +++++++------ lib/quickbeam/vm/bytecode/instruction.ex | 44 +-- lib/quickbeam/vm/bytecode/varint.ex | 10 +- lib/quickbeam/vm/bytecode/verifier.ex | 173 ++++++++++- lib/quickbeam/vm/bytecode/verifier/stack.ex | 2 +- lib/quickbeam/vm/compiler.ex | 7 +- .../vm/compiler/analysis/control_flow.ex | 2 +- lib/quickbeam/vm/compiler/code.ex | 4 +- lib/quickbeam/vm/compiler/code/lifecycle.ex | 2 +- lib/quickbeam/vm/compiler/counter.ex | 4 +- lib/quickbeam/vm/compiler/deopt.ex | 2 +- lib/quickbeam/vm/compiler/region/probe.ex | 2 +- lib/quickbeam/vm/fuzz.ex | 4 +- lib/quickbeam/vm/options.ex | 26 ++ lib/quickbeam/vm/program/pinned.ex | 2 +- lib/quickbeam/vm/runtime/async.ex | 50 ++- lib/quickbeam/vm/runtime/engine.ex | 23 +- lib/quickbeam/vm/runtime/heap.ex | 228 ++++++++------ lib/quickbeam/vm/runtime/interpreter.ex | 84 +++-- lib/quickbeam/vm/runtime/memory.ex | 2 +- lib/quickbeam/vm/runtime/opcode/control.ex | 2 +- lib/quickbeam/vm/runtime/opcode/invocation.ex | 2 +- lib/quickbeam/vm/runtime/opcode/local.ex | 7 +- lib/quickbeam/vm/runtime/opcode/object.ex | 84 ++--- lib/quickbeam/vm/runtime/opcode/stack.ex | 2 +- lib/quickbeam/vm/runtime/opcode/value.ex | 2 +- lib/quickbeam/vm/runtime/promise.ex | 26 +- lib/quickbeam/vm/runtime/property.ex | 41 +-- lib/quickbeam/vm/runtime/state.ex | 19 +- lib/quickbeam/vm/runtime/value.ex | 290 +++++++++++++----- lib/quickbeam/vm/runtime/value/export.ex | 14 +- mix.exs | 2 +- test/support/test262.ex | 18 +- test/vm/abi_test.exs | 2 +- test/vm/builtin/dsl_test.exs | 48 +-- test/vm/bytecode/decoder_test.exs | 34 +- test/vm/compiler/code_test.exs | 16 +- test/vm/compiler/counter_test.exs | 2 +- test/vm/compiler/region/probe_test.exs | 6 +- test/vm/compiler/runtime_test.exs | 6 +- test/vm/runtime/async_semantics_test.exs | 4 +- test/vm/runtime/async_test.exs | 28 ++ test/vm/runtime/error_test.exs | 25 ++ test/vm/runtime/exception_test.exs | 5 +- test/vm/runtime/heap_test.exs | 4 +- test/vm/runtime/interpreter_test.exs | 2 +- test/vm/runtime/invocation_test.exs | 4 +- test/vm/runtime/object_test.exs | 16 + test/vm/runtime/opcode_test.exs | 8 +- test/vm/runtime/property_test.exs | 2 +- test/vm/runtime/value_test.exs | 52 ++++ 68 files changed, 1614 insertions(+), 962 deletions(-) create mode 100644 lib/quickbeam/vm/options.ex diff --git a/README.md b/README.md index 2f411f065..51f64e746 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,8 @@ global function. 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`. + `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, diff --git a/lib/mix/tasks/quickbeam.vm.fuzz.ex b/lib/mix/tasks/quickbeam.vm.fuzz.ex index ca985dbfe..1c3948e84 100644 --- a/lib/mix/tasks/quickbeam.vm.fuzz.ex +++ b/lib/mix/tasks/quickbeam.vm.fuzz.ex @@ -56,10 +56,8 @@ defmodule Mix.Tasks.Quickbeam.Vm.Fuzz do try do Enum.map(Fuzz.default_sources(), fn {name, source} -> - case QuickBEAM.compile(runtime, source) do - {:ok, bytecode} -> {name, bytecode} - {:error, reason} -> Mix.raise("failed to compile #{name}: #{inspect(reason)}") - end + {:ok, bytecode} = QuickBEAM.compile(runtime, source) + {name, bytecode} end) after QuickBEAM.stop(runtime) diff --git a/lib/quickbeam/js/package_resolver.ex b/lib/quickbeam/js/package_resolver.ex index 33c1a204f..832104bd5 100644 --- a/lib/quickbeam/js/package_resolver.ex +++ b/lib/quickbeam/js/package_resolver.ex @@ -128,18 +128,14 @@ defmodule QuickBEAM.JS.PackageResolver do |> find_package_root(package_name) end - defp find_package_root("/", package_name) do - package_dir = Path.join(["/", "node_modules", package_name]) - if File.dir?(package_dir), do: {:ok, package_dir}, else: :error - end - defp find_package_root(dir, package_name) do package_dir = Path.join([dir, "node_modules", package_name]) + parent = Path.dirname(dir) - if File.dir?(package_dir) do - {:ok, package_dir} - else - find_package_root(Path.dirname(dir), package_name) + 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 58bcdad3c..d4dc22808 100644 --- a/lib/quickbeam/js_error.ex +++ b/lib/quickbeam/js_error.ex @@ -33,13 +33,13 @@ defmodule QuickBEAM.JSError do @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"), + 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: value[:frames] || value["frames"] || [] + frames: normalize_frames(value[:frames] || value["frames"]) } end @@ -75,14 +75,15 @@ defmodule QuickBEAM.JSError do def from_vm(reason, frames) do {name, message} = vm_name_and_message(reason) - first = List.first(frames) || %{} + frames = normalize_frames(frames) + first = List.first(frames) %__MODULE__{ name: name, message: message, - filename: first[:filename], - line: first[:line], - column: first[:column], + filename: frame_value(first, :filename), + line: frame_value(first, :line), + column: frame_value(first, :column), frames: frames, stack: format_stack(name, message, frames) } @@ -92,8 +93,8 @@ defmodule QuickBEAM.JSError do defp vm_name_and_message(%{} = value) when not is_struct(value) do { - to_string(value[:name] || value["name"] || "Error"), - to_string(value[:message] || value["message"] || inspect(value)) + error_text(value[:name] || value["name"] || "Error"), + error_text(value[:message] || value["message"] || inspect(value)) } end @@ -115,7 +116,7 @@ defmodule QuickBEAM.JSError do do: {"Error", "Unknown BEAM handler #{inspect(name)}"} defp vm_name_and_message({:handler_exception, exception, _stacktrace}), - do: {"Error", Exception.message(exception)} + do: {"Error", handler_exception_message(exception)} defp vm_name_and_message(reason), do: {"Error", format_reason(reason)} @@ -131,20 +132,37 @@ defmodule QuickBEAM.JSError do defp format_stack(name, message, frames) do rendered = Enum.map_join(frames, "\n", fn frame -> - function = frame.function || "" - filename = frame.filename || "" - line = frame.line || 1 - column = frame.column || 1 + 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 - stack -> to_string(stack) + stack -> error_text(stack) end end end diff --git a/lib/quickbeam/vm.ex b/lib/quickbeam/vm.ex index 214d8ec12..707b8f098 100644 --- a/lib/quickbeam/vm.ex +++ b/lib/quickbeam/vm.ex @@ -19,14 +19,17 @@ defmodule QuickBEAM.VM do 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. Pinned - storage is fixed-capacity, explicitly retired, and never implicitly evicted. + 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 @@ -111,7 +114,7 @@ defmodule QuickBEAM.VM do def compile(source, opts \\ []) def compile(source, opts) when is_binary(source) and is_list(opts) do - with :ok <- validate_options(opts, @compile_options), + 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 @@ -145,7 +148,7 @@ defmodule QuickBEAM.VM do def decode(bytecode, opts \\ []) def decode(bytecode, opts) when is_binary(bytecode) and is_list(opts) do - with :ok <- validate_options(opts, @decode_options), + 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), @@ -197,7 +200,7 @@ defmodule QuickBEAM.VM do def eval(program, opts \\ []) def eval(program, opts) when is_list(opts) do - with :ok <- validate_options(opts, @evaluation_options) do + with :ok <- Options.validate(opts, @evaluation_options) do Engine.eval(program, Keyword.put(opts, :engine, :interpreter)) end end @@ -219,7 +222,7 @@ defmodule QuickBEAM.VM do def call(program, name, arguments, opts) when is_binary(name) and is_list(arguments) and is_list(opts) do - with :ok <- validate_options(opts, @evaluation_options) do + with :ok <- Options.validate(opts, @evaluation_options) do Engine.call(program, name, arguments, Keyword.put(opts, :engine, :interpreter)) end end @@ -243,7 +246,7 @@ defmodule QuickBEAM.VM do def measure(program, opts \\ []) def measure(program, opts) when is_list(opts) do - with :ok <- validate_options(opts, @evaluation_options), + with :ok <- Options.validate(opts, @evaluation_options), {:ok, measurement} <- Engine.measure(program, Keyword.put(opts, :engine, :interpreter)) do {:ok, public_measurement(measurement)} @@ -269,7 +272,7 @@ defmodule QuickBEAM.VM do def measure_call(program, name, arguments, opts) when is_binary(name) and is_list(arguments) and is_list(opts) do - with :ok <- validate_options(opts, @evaluation_options), + with :ok <- Options.validate(opts, @evaluation_options), {:ok, measurement} <- Engine.measure_call( program, @@ -327,17 +330,6 @@ defmodule QuickBEAM.VM do } end - defp validate_options(opts, allowed) do - if Keyword.keyword?(opts) do - case Keyword.keys(opts) -- allowed do - [] -> :ok - [unknown | _] -> {:error, {:unknown_option, unknown}} - end - else - {:error, {:invalid_options, opts}} - end - end - defp validate_max_bytecode_bytes(limit) when is_integer(limit) and limit > 0 and limit <= @max_bytecode_bytes, do: :ok diff --git a/lib/quickbeam/vm/builtin/array.ex b/lib/quickbeam/vm/builtin/array.ex index 60c508ac2..46e1a7d0a 100644 --- a/lib/quickbeam/vm/builtin/array.ex +++ b/lib/quickbeam/vm/builtin/array.ex @@ -16,7 +16,7 @@ defmodule QuickBEAM.VM.Builtin.Array do constructor: :construct, length: 1, depends_on: ["Object", "Function"] do - static :is_array, js: "isArray", length: 1 + static :array?, js: "isArray", length: 1 prototype kind: :array, extends: "Object", default_for: :array do method :concat, length: 1 @@ -54,12 +54,12 @@ defmodule QuickBEAM.VM.Builtin.Array do def construct(%Call{arguments: arguments, execution: execution}) do entries = Enum.map(arguments, &{:present, &1}) - {array, execution} = array_from_entries(entries, execution) + {array, execution} = from_entries(entries, execution) {:ok, array, execution} end @doc "Implements `Array.isArray`." - def is_array(%Call{arguments: arguments, execution: execution}) do + def array?(%Call{arguments: arguments, execution: execution}) do value = List.first(arguments, :undefined) result = @@ -73,12 +73,14 @@ defmodule QuickBEAM.VM.Builtin.Array do @doc "Implements sparse `Array.prototype.concat`." def concat(%Call{this: receiver, arguments: arguments, execution: execution}) do - with {:ok, entries} <- array_entries(receiver, execution) do - entries = Enum.reduce(arguments, entries, &(&2 ++ concat_entries(&1, execution))) - {array, execution} = array_from_entries(entries, execution) - {:ok, array, execution} - else - {:error, reason} -> {:error, reason, execution} + 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 @@ -86,17 +88,19 @@ defmodule QuickBEAM.VM.Builtin.Array do def includes(%Call{this: receiver, arguments: arguments, execution: execution}) do searched = List.first(arguments, :undefined) - with {:ok, entries} <- array_entries(receiver, execution) do - included? = - Enum.any?(entries, fn - :hole -> searched == :undefined - {:present, :nan} -> searched == :nan - {:present, value} -> Value.strict_equal?(value, searched) - end) - - {:ok, included?, execution} - else - {:error, reason} -> {:error, reason, execution} + 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 @@ -109,17 +113,19 @@ defmodule QuickBEAM.VM.Builtin.Array do [value | _] -> Value.to_string_value(value) end - with {:ok, entries} <- array_entries(receiver, execution) do - 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} - else - {:error, reason} -> {:error, reason, execution} + 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 @@ -146,12 +152,14 @@ defmodule QuickBEAM.VM.Builtin.Array do @doc "Implements sparse `Array.prototype.slice`." def slice(%Call{this: receiver, arguments: arguments, execution: execution}) do - with {:ok, entries} <- array_entries(receiver, execution) do - {start, length} = slice_range(length(entries), arguments) - {array, execution} = array_from_entries(Enum.slice(entries, start, length), execution) - {:ok, array, execution} - else - {:error, reason} -> {:error, reason, execution} + 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 @@ -172,12 +180,24 @@ defmodule QuickBEAM.VM.Builtin.Array do @doc "Sorts present array values lexicographically when no comparator is supplied." def sort(%Call{this: %Reference{} = array, arguments: arguments, execution: execution}) do - comparator = List.first(arguments, :undefined) + 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 - if comparator != :undefined do - {:error, :unsupported_array_sort_comparator, execution} - else - with {:ok, entries} <- array_entries(array, execution) do + defp sort_default(array, execution) do + case array_entries(array, execution) do + {:ok, entries} -> values = entries |> Enum.flat_map(fn @@ -197,14 +217,12 @@ defmodule QuickBEAM.VM.Builtin.Array do end) {:ok, array, execution} - else - {:error, reason} -> {:error, reason, execution} - end + + {:error, reason} -> + {:error, reason, execution} end end - def sort(%Call{execution: execution}), do: {:error, :not_an_array, execution} - defp array_entries(value, _execution) when is_list(value), do: {:ok, Enum.map(value, &{:present, &1})} @@ -227,7 +245,10 @@ defmodule QuickBEAM.VM.Builtin.Array do end end - defp array_from_entries(entries, execution) do + @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 = @@ -246,35 +267,6 @@ defmodule QuickBEAM.VM.Builtin.Array do {array, execution} end - defp slice_range(size, arguments) do - start = - case arguments do - [start | _] -> normalize_slice_index(start, size) - [] -> 0 - end - - finish = - case arguments do - [_start, finish | _] -> normalize_slice_index(finish, size) - _ -> size - end - - {start, max(finish - start, 0)} - end - - defp normalize_slice_index(value, size) do - case Value.to_number(value) do - :infinity -> size - :neg_infinity -> 0 - :nan -> 0 - index when is_number(index) -> normalize_index(trunc(index), size) - _value -> 0 - end - end - - defp normalize_index(index, size) when index < 0, do: max(size + index, 0) - defp normalize_index(index, size), do: min(index, size) - defp iteration_action(method, %Call{ arguments: arguments, this: receiver, diff --git a/lib/quickbeam/vm/builtin/dsl.ex b/lib/quickbeam/vm/builtin/dsl.ex index 89d7b0901..fc0eca7fd 100644 --- a/lib/quickbeam/vm/builtin/dsl.ex +++ b/lib/quickbeam/vm/builtin/dsl.ex @@ -1,10 +1,10 @@ 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.Spec alias QuickBEAM.VM.Builtin.Validator @doc "Installs builtin declaration attributes and macros." @@ -72,65 +72,35 @@ defmodule QuickBEAM.VM.Builtin.DSL do @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.Spec.Property{ - key: key, - value: value, - writable: Keyword.get(opts, :writable, false), - enumerable: Keyword.get(opts, :enumerable, false), - configurable: Keyword.get(opts, :configurable, false) - } + @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.Spec.Property{ - key: key, - value: value, - writable: false, - enumerable: false, - configurable: false - } + @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.Spec.Property{ - key: key, - value: value, - writable: Keyword.get(opts, :writable, false), - enumerable: Keyword.get(opts, :enumerable, false), - configurable: Keyword.get(opts, :configurable, false) - } + @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.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) - } + @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.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) - } + @quickbeam_builtin_statics QuickBEAM.VM.Builtin.DSL.alias_spec(key, opts) end end @@ -213,6 +183,30 @@ defmodule QuickBEAM.VM.Builtin.DSL do 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)), diff --git a/lib/quickbeam/vm/builtin/installer.ex b/lib/quickbeam/vm/builtin/installer.ex index a6e90dc94..707735fc6 100644 --- a/lib/quickbeam/vm/builtin/installer.ex +++ b/lib/quickbeam/vm/builtin/installer.ex @@ -7,17 +7,16 @@ defmodule QuickBEAM.VM.Builtin.Installer do 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.Builtin.Spec - - alias QuickBEAM.VM.Runtime.State 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() @@ -263,26 +262,28 @@ defmodule QuickBEAM.VM.Builtin.Installer do raise ArgumentError, "duplicate builtin specs: #{inspect(Enum.uniq(duplicates))}" end - Enum.reduce(specs, MapSet.new(Map.keys(execution.globals)), fn spec, available -> - missing = Enum.reject(spec.depends_on, &MapSet.member?(available, &1)) + validate_dependencies!(specs, MapSet.new(Map.keys(execution.globals))) + end - if missing != [] do - raise ArgumentError, - "builtin #{spec.name} has unavailable dependencies: #{inspect(missing)}" - end + defp validate_dependencies!([], _available), do: :ok - if spec.kind == :intrinsic and not MapSet.member?(available, spec.name) do - raise ArgumentError, "builtin intrinsic #{spec.name} is not installed" - end + defp validate_dependencies!([spec | specs], available) do + missing = Enum.reject(spec.depends_on, &MapSet.member?(available, &1)) - 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 + if missing != [] do + raise ArgumentError, + "builtin #{spec.name} has unavailable dependencies: #{inspect(missing)}" + end - MapSet.put(available, spec.name) - 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 - :ok + 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 index 7225ccb02..869338ebe 100644 --- a/lib/quickbeam/vm/builtin/map.ex +++ b/lib/quickbeam/vm/builtin/map.ex @@ -65,12 +65,14 @@ defmodule QuickBEAM.VM.Builtin.Map do def delete(%Call{this: %Reference{} = map, arguments: arguments, execution: execution}) do key = List.first(arguments, :undefined) - with {:ok, entries} <- entries(map, execution) do - 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?) - else - :error -> incompatible(execution) + 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 @@ -85,18 +87,7 @@ defmodule QuickBEAM.VM.Builtin.Map do case entries(map, execution) do {:ok, entries} -> - value = - Enum.find_value(entries, :undefined, fn {candidate, value} -> - if same_key?(candidate, key), do: {:found, value} - end) - - value = - case value do - {:found, found} -> found - other -> other - end - - {:ok, value, execution} + {:ok, find_entry(entries, key), execution} :error -> incompatible(execution) @@ -132,12 +123,7 @@ defmodule QuickBEAM.VM.Builtin.Map do update_map( map, execution, - fn entries -> - 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, + fn entries -> put_entry(entries, key, value) end, map ) end @@ -148,33 +134,7 @@ defmodule QuickBEAM.VM.Builtin.Map do def next(%Call{this: %Reference{} = iterator, execution: execution}) do case Heap.fetch_object(execution, iterator) do {:ok, %Object{internal: {:map_iterator, entries, index, kind}}} -> - done? = index >= length(entries) - - {value, execution} = - if done? do - {:undefined, execution} - else - {key, value} = Enum.at(entries, index) - - case kind do - :keys -> {key, execution} - :values -> {value, execution} - :entries -> map_entry_array(key, value, execution) - end - end - - {:ok, execution} = - Heap.update_object(execution, iterator, fn object -> - %{ - object - | internal: {:map_iterator, entries, if(done?, do: index, else: index + 1), 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} + advance_iterator(iterator, entries, index, kind, execution) _other -> {:error, :incompatible_map_iterator_receiver, execution} @@ -200,12 +160,9 @@ defmodule QuickBEAM.VM.Builtin.Map do @doc "Initializes a Map receiver from insertion-ordered entries." def initialize(receiver, entries, execution) do entries = - Enum.reduce(entries, [], fn {key, value}, accumulated -> - case Enum.find_index(accumulated, fn {candidate, _value} -> same_key?(candidate, key) end) do - nil -> accumulated ++ [{key, value}] - index -> List.replace_at(accumulated, index, {key, value}) - end - end) + 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}} @@ -225,18 +182,23 @@ defmodule QuickBEAM.VM.Builtin.Map do do: iterable |> Iterator.values(execution) |> pair_entries(execution) defp pair_entries({:ok, values}, execution) do - Enum.reduce_while(values, {:ok, []}, fn pair, {:ok, entries} -> - with {:ok, key} <- Property.get(pair, 0, execution), - {:ok, value} <- Property.get(pair, 1, execution) do - {:cont, {:ok, entries ++ [{key, value}]}} - else - _error -> {:halt, {:error, :invalid_map_entry}} - end - end) + 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} -> @@ -287,6 +249,56 @@ defmodule QuickBEAM.VM.Builtin.Map do 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) diff --git a/lib/quickbeam/vm/builtin/number.ex b/lib/quickbeam/vm/builtin/number.ex index 94a86cfb6..7e1af28ab 100644 --- a/lib/quickbeam/vm/builtin/number.ex +++ b/lib/quickbeam/vm/builtin/number.ex @@ -15,7 +15,7 @@ defmodule QuickBEAM.VM.Builtin.Number do constructor: :construct, length: 1, depends_on: ["Object", "Function"] do - constant "MAX_VALUE", 1.7976931348623157e308 + 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 diff --git a/lib/quickbeam/vm/builtin/object.ex b/lib/quickbeam/vm/builtin/object.ex index cea903580..8344d3839 100644 --- a/lib/quickbeam/vm/builtin/object.ex +++ b/lib/quickbeam/vm/builtin/object.ex @@ -4,6 +4,7 @@ defmodule QuickBEAM.VM.Builtin.Object do 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 @@ -207,7 +208,7 @@ defmodule QuickBEAM.VM.Builtin.Object do }) do case Property.own_property_names(target, execution) do {:ok, keys} -> - {array, execution} = array_from(keys, execution) + {array, execution} = ArrayBuiltin.from_values(keys, execution) {:ok, array, execution} {:error, reason} -> @@ -236,12 +237,14 @@ defmodule QuickBEAM.VM.Builtin.Object do @doc "Implements `Object.keys` with canonical enumerable-key ordering." def keys(%Call{arguments: [value | _], execution: execution}) do - with {:ok, keys} <- own_keys(value, execution) do - keys = Enum.map(keys, &Value.to_string_value/1) - {array, execution} = array_from(keys, execution) - {:ok, array, execution} - else - {:error, reason} -> {:error, reason, execution} + 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 @@ -292,31 +295,44 @@ defmodule QuickBEAM.VM.Builtin.Object do accessor? = getter? or setter? or (not value? and not writable? and accessor?(current)) {:ok, - if accessor? do - %Descriptor{ - kind: :accessor, - value: :undefined, - writable: false, - enumerable: if(enumerable?, do: Value.truthy?(enumerable), else: current.enumerable), - configurable: - if(configurable?, do: Value.truthy?(configurable), else: current.configurable), - getter: if(getter?, do: getter, else: current.getter), - setter: if(setter?, do: setter, else: current.setter) - } - else - %Descriptor{ - value: if(value?, do: value, else: current.value), - writable: if(writable?, do: Value.truthy?(writable), else: current.writable), - enumerable: if(enumerable?, do: Value.truthy?(enumerable), else: current.enumerable), - configurable: - if(configurable?, do: Value.truthy?(configurable), else: current.configurable) - } - end} - else - {:error, reason} -> {:error, reason} + 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 @@ -403,18 +419,4 @@ defmodule QuickBEAM.VM.Builtin.Object do do: {:ok, Enum.to_list(0..(length(value) - 1))} defp own_keys(_value, _execution), do: {:ok, []} - - defp array_from(values, execution) do - {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) - - {array, execution} - end end diff --git a/lib/quickbeam/vm/builtin/runtime.ex b/lib/quickbeam/vm/builtin/runtime.ex index 5cd7dd2c8..aa69c9bcd 100644 --- a/lib/quickbeam/vm/builtin/runtime.ex +++ b/lib/quickbeam/vm/builtin/runtime.ex @@ -3,12 +3,12 @@ defmodule QuickBEAM.VM.Builtin.Runtime do Installs and dispatches the JavaScript built-ins supported by the VM profile. """ - alias QuickBEAM.VM.Runtime.State 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 @@ -109,25 +109,32 @@ defmodule QuickBEAM.VM.Builtin.Runtime do defp regex_match_from(%RegExp{} = regexp, value, last_index) do stateful? = regexp_flag?(regexp, 1) or regexp_flag?(regexp, 32) - current_index = if is_integer(last_index) and last_index >= 0, do: last_index, else: 0 + current_index = normalized_last_index(last_index) start = if stateful?, do: current_index, else: 0 case compile_regexp(regexp) do - {:ok, regex} -> - 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, if(stateful?, do: 0, else: current_index)} - end - - {:error, _reason} -> - {false, if(stateful?, do: 0, else: current_index)} + {: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) diff --git a/lib/quickbeam/vm/builtin/set.ex b/lib/quickbeam/vm/builtin/set.ex index ebb2ce304..2fd1cf8a6 100644 --- a/lib/quickbeam/vm/builtin/set.ex +++ b/lib/quickbeam/vm/builtin/set.ex @@ -63,17 +63,7 @@ defmodule QuickBEAM.VM.Builtin.Set do def add(%Call{this: %Reference{} = set, arguments: arguments, execution: execution}) do value = List.first(arguments, :undefined) - case Heap.update_object(execution, set, fn - %Object{kind: :set, internal: %{values: values, index: index}} = object -> - if MapSet.member?(index, value) do - object - else - %{object | internal: %{values: values ++ [value], index: MapSet.put(index, value)}} - end - - object -> - object - end) do + 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} @@ -93,15 +83,16 @@ defmodule QuickBEAM.VM.Builtin.Set do case Heap.fetch_object(execution, set) do {:ok, %Object{kind: :set, internal: %{values: values, index: index}}} -> - found? = MapSet.member?(index, value) + key = set_key(value) + found? = MapSet.member?(index, key) {:ok, execution} = Heap.update_object(execution, set, fn object -> %{ object | internal: %{ - values: Enum.reject(values, &Value.strict_equal?(&1, value)), - index: MapSet.delete(index, value) + values: Enum.reject(values, &same_value_zero?(&1, value)), + index: MapSet.delete(index, key) } } end) @@ -121,7 +112,7 @@ defmodule QuickBEAM.VM.Builtin.Set do case Heap.fetch_object(execution, set) do {:ok, %Object{kind: :set, internal: %{index: entries}}} -> - {:ok, MapSet.member?(entries, value), execution} + {:ok, MapSet.member?(entries, set_key(value)), execution} _other -> {:error, :incompatible_set_receiver, execution} @@ -213,12 +204,42 @@ defmodule QuickBEAM.VM.Builtin.Set do @doc "Initializes a Set receiver from values in iteration order." def initialize(receiver, values, execution) do - values = Enum.uniq(values) + {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: values, index: MapSet.new(values)}} + %{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/string.ex b/lib/quickbeam/vm/builtin/string.ex index c3b61b0d6..03aff2f5f 100644 --- a/lib/quickbeam/vm/builtin/string.ex +++ b/lib/quickbeam/vm/builtin/string.ex @@ -3,11 +3,11 @@ defmodule QuickBEAM.VM.Builtin.String do 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.Property alias QuickBEAM.VM.Runtime.Reference alias QuickBEAM.VM.Runtime.RegExp alias QuickBEAM.VM.Runtime.Value @@ -137,7 +137,7 @@ defmodule QuickBEAM.VM.Builtin.String do @doc "Implements UTF-16 `String.prototype.slice`." def slice(%Call{} = call) do with_string(call, fn value, arguments, execution -> - {start, length} = slice_range(Value.string_length(value), arguments) + {start, length} = Value.slice_range(Value.string_length(value), arguments) {:ok, Value.string_slice(value, start, length), execution} end) end @@ -152,7 +152,7 @@ defmodule QuickBEAM.VM.Builtin.String do [separator | _] -> String.split(value, Value.to_string_value(separator)) end - {array, execution} = array_from(parts, execution) + {array, execution} = ArrayBuiltin.from_values(parts, execution) {:ok, array, execution} end) end @@ -212,36 +212,6 @@ defmodule QuickBEAM.VM.Builtin.String do defp string_value(_value, _execution), do: :error - defp array_from(values, execution) do - {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) - - {array, execution} - end - - defp slice_range(size, arguments) do - start = - case arguments do - [start | _] -> normalize_slice_index(start, size) - [] -> 0 - end - - finish = - case arguments do - [_start, finish | _] -> normalize_slice_index(finish, size) - _ -> size - end - - {start, max(finish - start, 0)} - end - defp substring_index(value, size) do case Value.to_number(value) do :infinity -> size @@ -250,19 +220,6 @@ defmodule QuickBEAM.VM.Builtin.String do end end - defp normalize_slice_index(value, size) do - case Value.to_number(value) do - :infinity -> size - :neg_infinity -> 0 - :nan -> 0 - index when is_number(index) -> normalize_index(trunc(index), size) - _value -> 0 - end - end - - defp normalize_index(index, size) when index < 0, do: max(size + index, 0) - defp normalize_index(index, size), do: min(index, size) - defp replace_string(value, %RegExp{source: source}, replacement) do case Regex.compile(source) do {:ok, regex} -> Regex.replace(regex, value, replacement) diff --git a/lib/quickbeam/vm/builtin/validator.ex b/lib/quickbeam/vm/builtin/validator.ex index 611f0b398..c816f6443 100644 --- a/lib/quickbeam/vm/builtin/validator.ex +++ b/lib/quickbeam/vm/builtin/validator.ex @@ -1,91 +1,118 @@ 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 - alias QuickBEAM.VM.Builtin.Spec @doc "Validates a compiled builtin spec against its declaring module." @spec validate!(Spec.t(), Macro.Env.t()) :: :ok def validate!(%Spec{} = spec, env) do - unless is_binary(spec.name) and spec.name != "" do - compile_error!(env, "builtin name must be a non-empty string") - end - - unless spec.kind in [:namespace, :function, :constructor, :intrinsic] do - compile_error!(env, "unsupported builtin kind: #{inspect(spec.kind)}") - end - - unless is_integer(spec.length) and spec.length >= 0 do - compile_error!(env, "builtin length must be a non-negative integer") - end - - unless spec.profiles != [] and Enum.all?(spec.profiles, &is_atom/1) do - compile_error!(env, "builtin profiles must be a non-empty atom list") - end - - unless Enum.all?(spec.depends_on, &(is_binary(&1) and &1 != "")) do - compile_error!(env, "builtin dependencies must be non-empty strings") - end - - if spec.kind == :constructor and not is_atom(spec.constructor) do - compile_error!(env, "constructor builtins require a :constructor handler") - end - + validate_spec!(spec, env) %PrototypeSpec{} = prototype = spec.prototype_spec + validate_prototype!(prototype, spec, env) - unless prototype.extends in [:default, nil] or - (is_binary(prototype.extends) and prototype.extends in spec.depends_on) do - compile_error!(env, "prototype :extends must name a declared dependency or be nil") - end + 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 - unless prototype.kind in [:ordinary, :array, :function] do - compile_error!(env, "unsupported prototype kind: #{inspect(prototype.kind)}") - end + defp validate_spec!(spec, env) do + validate!( + is_binary(spec.name) and spec.name != "", + env, + "builtin name must be a non-empty string" + ) - unless is_nil(prototype.default_for) or is_atom(prototype.default_for) do - compile_error!(env, "prototype :default_for must be an atom") - end + validate!(spec.kind in [:namespace, :function, :constructor, :intrinsic], env, fn -> + "unsupported builtin kind: #{inspect(spec.kind)}" + end) - unless is_nil(prototype.error_type) or is_binary(prototype.error_type) do - compile_error!(env, "prototype :error_type must be a string") - 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 - unless is_nil(prototype.primitive) or - match?({kind, _value} when is_atom(kind), prototype.primitive) do - compile_error!(env, "prototype :primitive must be a {kind, value} pair") - 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) - if prototype.kind == :function and not is_atom(prototype.callable) do - compile_error!(env, "function prototypes require a :callable handler") - end + validate!(valid_parent?, env, "prototype :extends must name a declared dependency or be nil") - entries = spec.statics ++ spec.prototype - duplicate_keys!(spec.statics, :static, env) - duplicate_keys!(spec.prototype, :prototype, env) + 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) - |> then(fn handlers -> - handlers = if spec.constructor, do: [spec.constructor | handlers], else: handlers - handlers = if spec.kind == :function, do: [:call | handlers], else: handlers - if prototype.callable, do: [prototype.callable | handlers], else: handlers - end) + |> maybe_prepend(spec.constructor) + |> maybe_prepend(if(spec.kind == :function, do: :call)) + |> maybe_prepend(prototype.callable) |> Enum.uniq() Enum.each(handlers, fn handler -> - unless is_atom(handler) and Module.defines?(env.module, {handler, 1}, :def) do - compile_error!(env, "builtin handler #{inspect(handler)}/1 must be a public function") - end + 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) - - Enum.each(entries, &validate_entry!(&1, env)) 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}), @@ -132,6 +159,13 @@ defmodule QuickBEAM.VM.Builtin.Validator do 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 diff --git a/lib/quickbeam/vm/builtin/weak_map.ex b/lib/quickbeam/vm/builtin/weak_map.ex index da2a967f3..9bd8e26e5 100644 --- a/lib/quickbeam/vm/builtin/weak_map.ex +++ b/lib/quickbeam/vm/builtin/weak_map.ex @@ -5,6 +5,8 @@ defmodule QuickBEAM.VM.Builtin.WeakMap do 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", @@ -21,7 +23,12 @@ defmodule QuickBEAM.VM.Builtin.WeakMap do end @doc "Constructs an evaluation-local weak-key map." - def construct(%Call{} = call), do: MapBuiltin.construct(call) + 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) @@ -35,5 +42,21 @@ defmodule QuickBEAM.VM.Builtin.WeakMap do @doc "Adds or replaces a WeakMap entry." def set(%Call{arguments: [%Reference{} | _]} = call), do: MapBuiltin.set(call) - def set(%Call{execution: execution}), do: {:error, :invalid_weak_map_key, execution} + 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 index 6120b9937..0aa4a83c7 100644 --- a/lib/quickbeam/vm/builtin/weak_set.ex +++ b/lib/quickbeam/vm/builtin/weak_set.ex @@ -5,6 +5,8 @@ defmodule QuickBEAM.VM.Builtin.WeakSet do 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", @@ -20,16 +22,34 @@ defmodule QuickBEAM.VM.Builtin.WeakSet do end @doc "Constructs an evaluation-local weak-value set." - def construct(%Call{} = call), do: SetBuiltin.construct(call) + 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, :invalid_weak_set_value, execution} + 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/decoder.ex b/lib/quickbeam/vm/bytecode/decoder.ex index 25e9c9cbd..e93487381 100644 --- a/lib/quickbeam/vm/bytecode/decoder.ex +++ b/lib/quickbeam/vm/bytecode/decoder.ex @@ -7,12 +7,12 @@ defmodule QuickBEAM.VM.Bytecode.Decoder do """ alias QuickBEAM.VM.ABI + alias QuickBEAM.VM.Bytecode.Atom, as: AtomTable alias QuickBEAM.VM.Bytecode.Checksum - alias QuickBEAM.VM.Program.Function alias QuickBEAM.VM.Bytecode.Instruction alias QuickBEAM.VM.Bytecode.Opcode - alias QuickBEAM.VM.Bytecode.Atom, as: AtomTable alias QuickBEAM.VM.Program + alias QuickBEAM.VM.Program.Function alias QuickBEAM.VM.Bytecode.Varint, as: LEB128 import Bitwise @@ -107,16 +107,20 @@ defmodule QuickBEAM.VM.Bytecode.Decoder do defp read_atom_list(data, count, acc) do with {:ok, type, rest} <- LEB128.read_u8(data) do - if type == 0 do - with {:ok, atom_id, rest2} <- LEB128.read_fixed_u32(rest), - {:ok, atom} <- predefined_atom(atom_id) do - read_atom_list(rest2, count - 1, [atom | acc]) - end - else - with {:ok, str, rest2} <- read_string_raw(rest) do - read_atom_list(rest2, count - 1, [str | acc]) - end - end + 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 @@ -133,72 +137,63 @@ defmodule QuickBEAM.VM.Bytecode.Decoder do # 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, v, rest} <- LEB128.read_unsigned(data) do - if band(v, 1) == 1 do - {:ok, {:tagged_int, bsr(v, 1)}, rest} - else - idx = bsr(v, 1) + with {:ok, value, rest} <- LEB128.read_unsigned(data) do + decode_atom_ref(value, atoms, rest) + end + end - name = - case idx do - 0 -> - "" + defp decode_atom_ref(value, _atoms, rest) when band(value, 1) == 1, + do: {:ok, {:tagged_int, bsr(value, 1)}, rest} - n when n < @js_atom_end -> - {:predefined, n} + 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 - _ -> - local_idx = idx - @js_atom_end + defp atom_name(0, _atoms), do: "" + defp atom_name(index, _atoms) when index < @js_atom_end, do: {:predefined, index} - if local_idx < tuple_size(atoms), - do: elem(atoms, local_idx), - else: {:error, {:unknown_atom, idx}} - end + defp atom_name(index, atoms) do + local_index = index - @js_atom_end - case name do - {:error, reason} -> {:error, reason} - name -> {:ok, name, rest} - end - end - 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, len_encoded, rest} <- LEB128.read_unsigned(data) do - byte_len = bsr(len_encoded, 1) - - if byte_size(rest) < byte_len do - {:error, :unexpected_end} - else - <> = rest - {:ok, raw, rest2} - end + 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, len_encoded, rest} <- LEB128.read_unsigned(data) do - is_wide = band(len_encoded, 1) == 1 - char_len = bsr(len_encoded, 1) - byte_len = if is_wide, do: char_len * 2, else: char_len + 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 - if byte_size(rest) < byte_len do - {:error, :unexpected_end} - else - <> = rest + 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) - if is_wide do - {:ok, wide_to_utf8(str), rest2} - else - {:ok, latin1_to_utf8(str), rest2} - end - end - end + 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 @@ -436,49 +431,64 @@ defmodule QuickBEAM.VM.Bytecode.Decoder do {: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 - if byte_size(rest) < byte_code_len do - {:error, :unexpected_end} - else - <> = rest - - with {:ok, instructions} <- Instruction.decode(byte_code, arg_count), - {:ok, debug_info, rest} <- read_debug_info(rest, flags_map.has_debug_info, atoms) do - fun = %Function{ - 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, - 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: strict > 0, - has_prototype: flags_map.has_prototype, - has_simple_parameter_list: flags_map.has_simple_parameter_list, - is_derived_class_constructor: flags_map.is_derived_class_constructor, - need_home_object: flags_map.need_home_object, - func_kind: flags_map.func_kind, - new_target_allowed: flags_map.new_target_allowed, - super_call_allowed: flags_map.super_call_allowed, - super_allowed: flags_map.super_allowed, - arguments_allowed: flags_map.arguments_allowed, - has_debug_info: flags_map.has_debug_info - } - - {:ok, fun, rest} - end - end + 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 diff --git a/lib/quickbeam/vm/bytecode/instruction.ex b/lib/quickbeam/vm/bytecode/instruction.ex index 6ca84c984..3d04a7c4c 100644 --- a/lib/quickbeam/vm/bytecode/instruction.ex +++ b/lib/quickbeam/vm/bytecode/instruction.ex @@ -70,27 +70,29 @@ defmodule QuickBEAM.VM.Bytecode.Instruction do {:error, {:unknown_opcode, op, pos}} {_name, size, _n_pop, _n_push, fmt} -> - if pos + size > len do - {:error, {:truncated_instruction, op, pos}} - else - case operands_for(bc, pos + 1, op, fmt, offset_map, ac) do - {:ok, operands} -> - decode_pass2( - bc, - len, - pos + size, - idx + 1, - offset_map, - [ - {op, operands} | acc - ], - ac - ) - - {:error, _} = err -> - err - end - end + 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 diff --git a/lib/quickbeam/vm/bytecode/varint.ex b/lib/quickbeam/vm/bytecode/varint.ex index c31ef58c0..47d88da9f 100644 --- a/lib/quickbeam/vm/bytecode/varint.ex +++ b/lib/quickbeam/vm/bytecode/varint.ex @@ -59,12 +59,10 @@ defmodule QuickBEAM.VM.Bytecode.Varint do def read_fixed_u32(_binary), do: {:error, :unexpected_end} defp decode_unsigned(binary) do - try do - {value, rest} = Varint.LEB128.decode(binary) - {:ok, value, rest} - rescue - ArgumentError -> {:error, :bad_leb128} - end + {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} diff --git a/lib/quickbeam/vm/bytecode/verifier.ex b/lib/quickbeam/vm/bytecode/verifier.ex index 3415c72d5..981556476 100644 --- a/lib/quickbeam/vm/bytecode/verifier.ex +++ b/lib/quickbeam/vm/bytecode/verifier.ex @@ -11,6 +11,8 @@ defmodule QuickBEAM.VM.Bytecode.Verifier do 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() @@ -30,7 +32,9 @@ defmodule QuickBEAM.VM.Bytecode.Verifier do 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 @@ -44,7 +48,7 @@ defmodule QuickBEAM.VM.Bytecode.Verifier do def verify_identity(%Program{} = program), do: verify_header(program) def verify_identity(_program), do: {:error, :invalid_program} - defp limits(opts) do + 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) @@ -55,9 +59,14 @@ defmodule QuickBEAM.VM.Bytecode.Verifier do {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}} @@ -68,7 +77,8 @@ defmodule QuickBEAM.VM.Bytecode.Verifier do end defp verify_value(%Function{} = function, atoms, limits, depth, counts) do - with :ok <- within(depth, limits.max_function_depth, :function_depth), + 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), @@ -79,18 +89,38 @@ defmodule QuickBEAM.VM.Bytecode.Verifier do end end - defp verify_value({:array, values}, atoms, limits, depth, counts), - do: verify_values(values, atoms, limits, depth, counts) + 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: verify_values(Map.values(values), atoms, limits, depth, counts) + 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) do - with {:ok, counts} <- verify_values(values, atoms, limits, depth, counts) do - verify_value(raw, atoms, limits, depth, counts) + 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 @@ -102,6 +132,59 @@ defmodule QuickBEAM.VM.Bytecode.Verifier do 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 @@ -110,6 +193,9 @@ defmodule QuickBEAM.VM.Bytecode.Verifier 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}} @@ -122,11 +208,12 @@ defmodule QuickBEAM.VM.Bytecode.Verifier do tuple_size(function.source_positions) != instruction_count -> {:error, {:invalid_function, function.id, :source_positions}} - invalid_non_negative_fields?(function) -> - {:error, {:invalid_function, function.id, :negative_count}} - true -> - verify_capture_indexes(function) + 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 @@ -144,6 +231,66 @@ defmodule QuickBEAM.VM.Bytecode.Verifier do ) 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 diff --git a/lib/quickbeam/vm/bytecode/verifier/stack.ex b/lib/quickbeam/vm/bytecode/verifier/stack.ex index 05a1674d8..9e931efee 100644 --- a/lib/quickbeam/vm/bytecode/verifier/stack.ex +++ b/lib/quickbeam/vm/bytecode/verifier/stack.ex @@ -8,8 +8,8 @@ defmodule QuickBEAM.VM.Bytecode.Verifier.Stack do opcode-specific stack depth. """ - alias QuickBEAM.VM.Program.Function alias QuickBEAM.VM.Bytecode.Opcode + alias QuickBEAM.VM.Program.Function @terminal_ops [ :tail_call, diff --git a/lib/quickbeam/vm/compiler.ex b/lib/quickbeam/vm/compiler.ex index c90d1c6a4..e90850c72 100644 --- a/lib/quickbeam/vm/compiler.ex +++ b/lib/quickbeam/vm/compiler.ex @@ -114,7 +114,7 @@ defmodule QuickBEAM.VM.Compiler do end @doc "Compiles and invokes one entry block for a canonical bytecode frame." - @spec execute_frame(struct(), struct()) :: frame_action() + @spec execute_frame(struct(), struct()) :: term() def execute_frame( %Frame{function: %Function{id: function_id} = function} = frame, %State{compiler_context: %Context{pool: pool}} = execution @@ -464,11 +464,6 @@ defmodule QuickBEAM.VM.Compiler do defp resume_action({:skip, frame, execution}, _initial), do: Interpreter.run_frame(frame, execution) - defp resume_action({status, _value, %State{}} = result, _execution) - when status in [:ok, :error], do: result - - defp resume_action({:suspended, _continuation} = result, _execution), do: result - defp resume_action({:error, reason}, execution), do: {:error, {:compiler_error, reason}, execution} diff --git a/lib/quickbeam/vm/compiler/analysis/control_flow.ex b/lib/quickbeam/vm/compiler/analysis/control_flow.ex index 6eb63af6c..1fd894d04 100644 --- a/lib/quickbeam/vm/compiler/analysis/control_flow.ex +++ b/lib/quickbeam/vm/compiler/analysis/control_flow.ex @@ -6,9 +6,9 @@ defmodule QuickBEAM.VM.Compiler.Analysis.ControlFlow do 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 - alias QuickBEAM.VM.Bytecode.Opcode @conditional_branches [:if_false, :if_false8, :if_true, :if_true8] @unconditional_branches [:goto, :goto8, :goto16] diff --git a/lib/quickbeam/vm/compiler/code.ex b/lib/quickbeam/vm/compiler/code.ex index e922e5de9..392697931 100644 --- a/lib/quickbeam/vm/compiler/code.ex +++ b/lib/quickbeam/vm/compiler/code.ex @@ -9,12 +9,12 @@ defmodule QuickBEAM.VM.Compiler.Code do @behaviour QuickBEAM.VM.Compiler.Pool.Backend - alias QuickBEAM.VM.Compiler.Code.Lifecycle 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.State alias QuickBEAM.VM.Runtime.Frame + alias QuickBEAM.VM.Runtime.State @doc "Compiles a validated template for one fixed generated-module slot." @impl true diff --git a/lib/quickbeam/vm/compiler/code/lifecycle.ex b/lib/quickbeam/vm/compiler/code/lifecycle.ex index 4a71a95ce..b537ef3df 100644 --- a/lib/quickbeam/vm/compiler/code/lifecycle.ex +++ b/lib/quickbeam/vm/compiler/code/lifecycle.ex @@ -7,9 +7,9 @@ defmodule QuickBEAM.VM.Compiler.Code.Lifecycle do hard purge. """ - alias QuickBEAM.VM.Compiler.Contract alias QuickBEAM.VM.Compiler.Code.Artifact alias QuickBEAM.VM.Compiler.Code.Import + alias QuickBEAM.VM.Compiler.Contract @source ~c"quickbeam_compiler" diff --git a/lib/quickbeam/vm/compiler/counter.ex b/lib/quickbeam/vm/compiler/counter.ex index 28135555a..9cea0d5ac 100644 --- a/lib/quickbeam/vm/compiler/counter.ex +++ b/lib/quickbeam/vm/compiler/counter.ex @@ -10,9 +10,9 @@ defmodule QuickBEAM.VM.Compiler.Counter do module-pool state. """ - alias QuickBEAM.VM.Runtime.State - alias QuickBEAM.VM.Runtime.Frame alias QuickBEAM.VM.Bytecode.Opcode + alias QuickBEAM.VM.Runtime.Frame + alias QuickBEAM.VM.Runtime.State @indexes %{ frame_attempts: 1, diff --git a/lib/quickbeam/vm/compiler/deopt.ex b/lib/quickbeam/vm/compiler/deopt.ex index 0dc81d245..707982a1a 100644 --- a/lib/quickbeam/vm/compiler/deopt.ex +++ b/lib/quickbeam/vm/compiler/deopt.ex @@ -8,8 +8,8 @@ defmodule QuickBEAM.VM.Compiler.Deopt do """ alias QuickBEAM.VM.Compiler.Contract - alias QuickBEAM.VM.Runtime.State alias QuickBEAM.VM.Runtime.Frame + alias QuickBEAM.VM.Runtime.State @contract_version Contract.version() @artifact_key_bytes Contract.artifact_key_bytes() diff --git a/lib/quickbeam/vm/compiler/region/probe.ex b/lib/quickbeam/vm/compiler/region/probe.ex index 3b1cf1d76..127f03e9e 100644 --- a/lib/quickbeam/vm/compiler/region/probe.ex +++ b/lib/quickbeam/vm/compiler/region/probe.ex @@ -8,8 +8,8 @@ defmodule QuickBEAM.VM.Compiler.Region.Probe do not enabled by ordinary evaluation or standard measurements. """ - alias QuickBEAM.VM.Runtime.State alias QuickBEAM.VM.Runtime.Frame + alias QuickBEAM.VM.Runtime.State @sample_interval 16 @window_size 64 diff --git a/lib/quickbeam/vm/fuzz.ex b/lib/quickbeam/vm/fuzz.ex index 6821ebb4d..1981e93b7 100644 --- a/lib/quickbeam/vm/fuzz.ex +++ b/lib/quickbeam/vm/fuzz.ex @@ -16,12 +16,12 @@ defmodule QuickBEAM.VM.Fuzz do 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.Bytecode.Opcode alias QuickBEAM.VM.Program - alias QuickBEAM.VM.Bytecode.Verifier @mask 0xFFFFFFFFFFFFFFFF @default_iterations 1_000 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/pinned.ex b/lib/quickbeam/vm/program/pinned.ex index d96e749d1..80d3fbae8 100644 --- a/lib/quickbeam/vm/program/pinned.ex +++ b/lib/quickbeam/vm/program/pinned.ex @@ -12,5 +12,5 @@ defmodule QuickBEAM.VM.Program.Pinned do @enforce_keys [:key] defstruct [:key] - @opaque t :: %__MODULE__{key: binary()} + @type t :: %__MODULE__{key: binary()} end diff --git a/lib/quickbeam/vm/runtime/async.ex b/lib/quickbeam/vm/runtime/async.ex index 4af6d3544..e8d1a4800 100644 --- a/lib/quickbeam/vm/runtime/async.ex +++ b/lib/quickbeam/vm/runtime/async.ex @@ -11,18 +11,17 @@ defmodule QuickBEAM.VM.Runtime.Async do alias QuickBEAM.VM.Runtime.Boundary alias QuickBEAM.VM.Runtime.Continuation alias QuickBEAM.VM.Runtime.Coroutine - alias QuickBEAM.VM.Runtime.State 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.Reference, as: PromiseReference 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()} @@ -265,15 +264,19 @@ defmodule QuickBEAM.VM.Runtime.Async do @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 - {promise, execution} = Promise.new(execution) + 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 + 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} + {:ok, promise, execution} + else + {:error, {:limit_exceeded, :host_operations, @max_host_operations}, execution} + end end def start_host_call(_arguments, execution), @@ -324,9 +327,7 @@ defmodule QuickBEAM.VM.Runtime.Async do owner = self() case Task.Supervisor.start_child(QuickBEAM.VM.TaskSupervisor, fn -> - Process.link(owner) - result = invoke_handler(handler, arguments) - send(owner, {:quickbeam_vm_host_reply, operation, result}) + coordinate_handler(owner, operation, handler, arguments) end) do {:ok, pid} -> %{execution | operations: Map.put(execution.operations, operation, {promise, pid})} @@ -336,6 +337,25 @@ defmodule QuickBEAM.VM.Runtime.Async do 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 diff --git a/lib/quickbeam/vm/runtime/engine.ex b/lib/quickbeam/vm/runtime/engine.ex index 6b12dc075..55c8dcccb 100644 --- a/lib/quickbeam/vm/runtime/engine.ex +++ b/lib/quickbeam/vm/runtime/engine.ex @@ -9,6 +9,7 @@ defmodule QuickBEAM.VM.Runtime.Engine do 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 @@ -162,22 +163,11 @@ defmodule QuickBEAM.VM.Runtime.Engine do :vars ] - with :ok <- validate_options(opts, allowed) do + with :ok <- Options.validate(opts, allowed) do validate_evaluation_options(opts) end end - defp validate_options(opts, allowed) do - if Keyword.keyword?(opts) do - case Keyword.keys(opts) -- allowed do - [] -> :ok - [unknown | _] -> {:error, {:unknown_option, unknown}} - end - else - {:error, {:invalid_options, opts}} - end - end - defp validate_evaluation_options(opts) do isolation = Keyword.get(opts, :isolation, :process) engine = Keyword.get(opts, :engine, :interpreter) @@ -433,6 +423,7 @@ defmodule QuickBEAM.VM.Runtime.Engine do timeout -> Process.exit(pid, :kill) await_down(monitor_ref, pid) + flush_reply(reply_ref) {:error, {:limit_exceeded, :timeout, timeout}} end end @@ -450,4 +441,12 @@ defmodule QuickBEAM.VM.Runtime.Engine do 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/heap.ex b/lib/quickbeam/vm/runtime/heap.ex index beb033949..f36514eee 100644 --- a/lib/quickbeam/vm/runtime/heap.ex +++ b/lib/quickbeam/vm/runtime/heap.ex @@ -6,11 +6,11 @@ defmodule QuickBEAM.VM.Runtime.Heap do them and are exported to ordinary BEAM values at the evaluation boundary. """ - alias QuickBEAM.VM.Runtime.State 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 @@ -273,23 +273,25 @@ defmodule QuickBEAM.VM.Runtime.Heap do when kind in [:getter, :setter] do key = normalize_key(key) - with {:ok, object} <- fetch_object(execution, reference) do - 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) - else - :error -> {:error, {:invalid_reference, id}} + 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 @@ -299,12 +301,15 @@ defmodule QuickBEAM.VM.Runtime.Heap do def define_descriptor(execution, %Reference{id: id} = reference, key, %Descriptor{} = property) do key = normalize_key(key) - with {:ok, object} <- fetch_object(execution, reference) do - if object.kind == :array and key == "length", - do: {:error, {:property_not_configurable, "length"}}, - else: store_descriptor(execution, id, object, key, property) - else - :error -> {:error, {:invalid_reference, id}} + 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 @@ -395,10 +400,9 @@ defmodule QuickBEAM.VM.Runtime.Heap do defp valid_prototype?(_execution, _reference, nil), do: :ok defp valid_prototype?(execution, reference, prototype) do - case prototype_contains?(execution, prototype, reference, 0) do - true -> {:error, :cyclic_prototype} - false -> :ok - end + if prototype_contains?(execution, prototype, reference, 0), + do: {:error, :cyclic_prototype}, + else: :ok end defp prototype_contains?(_execution, nil, _reference, _depth), do: false @@ -424,35 +428,45 @@ defmodule QuickBEAM.VM.Runtime.Heap do defp get_with_depth(execution, %Reference{id: id} = reference, key, receiver, depth) do case fetch_object(execution, reference) do - {:ok, %Object{kind: :array, length: length}} when key == "length" -> - {:ok, length} - - {:ok, object} -> - case Map.fetch(object.properties, key) do - {:ok, {value}} -> - {:ok, value} + {:ok, object} -> get_from_object(execution, object, key, receiver, depth) + :error -> {:error, {:invalid_reference, id}} + end + end - {:ok, %Descriptor{getter: getter}} when not is_nil(getter) -> - {:ok, {:accessor, getter, receiver}} + defp get_from_object( + _execution, + %Object{kind: :array, length: length}, + "length", + _receiver, + _depth + ), + do: {:ok, length} - {:ok, %Descriptor{setter: setter}} when not is_nil(setter) -> - {:ok, :undefined} + defp get_from_object(execution, object, key, receiver, depth) do + case Map.fetch(object.properties, key) do + {:ok, {value}} -> + {:ok, value} - {:ok, %Descriptor{value: value}} -> - {:ok, value} + {:ok, %Descriptor{getter: getter}} when not is_nil(getter) -> + {:ok, {:accessor, getter, receiver}} - :error when is_struct(object.prototype, Reference) -> - get_with_depth(execution, object.prototype, key, receiver, depth + 1) + {:ok, %Descriptor{setter: setter}} when not is_nil(setter) -> + {:ok, :undefined} - :error -> - {:ok, :undefined} - end + {:ok, %Descriptor{value: value}} -> + {:ok, value} :error -> - {:error, {:invalid_reference, id}} + 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} -> @@ -552,31 +566,38 @@ defmodule QuickBEAM.VM.Runtime.Heap do descriptor = own_property_value(object, key) || inherited_property(execution, object.prototype, key, 0) - cond do - match?(%Descriptor{setter: setter} when not is_nil(setter), descriptor) -> - {:error, {:invoke_setter, descriptor.setter}} - - accessor?(descriptor) -> - {:error, {:property_not_writable, key}} + with :ok <- writable_descriptor(descriptor, key) do + put_new_or_existing_value(object, key) + end + end - match?(%Descriptor{writable: false}, descriptor) -> - {:error, {:property_not_writable, key}} + defp writable_descriptor(%Descriptor{setter: setter}, _key) when not is_nil(setter), + do: {:error, {:invoke_setter, setter}} - Map.has_key?(object.properties, key) -> - {:ok, object} + defp writable_descriptor(%Descriptor{kind: :accessor}, key), + do: {:error, {:property_not_writable, key}} - object.kind == :array and is_integer(key) and key >= object.length and - not object.length_writable -> - {:error, {:property_not_writable, "length"}} + defp writable_descriptor(%Descriptor{writable: false}, key), + do: {:error, {:property_not_writable, key}} - object.extensible -> - {:ok, object} + defp writable_descriptor(_descriptor, _key), do: :ok - true -> - {:error, {:object_not_extensible, key}} - end + 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) -> @@ -607,41 +628,68 @@ defmodule QuickBEAM.VM.Runtime.Heap do defp validate_definition(object, key, candidate) do case own_property_value(object, key) do %Descriptor{configurable: false} = current -> - cond do - candidate.configurable -> - {:error, {:property_not_configurable, key}} + 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 - property_enumerable?(candidate) != property_enumerable?(current) -> - {:error, {:property_not_configurable, key}} + 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 - accessor?(candidate) != accessor?(current) -> - {:error, {:property_not_configurable, key}} + defp keep_nonconfigurable(%Descriptor{configurable: true}, key), + do: {:error, {:property_not_configurable, key}} - accessor?(current) and - (candidate.getter != current.getter or candidate.setter != current.setter) -> - {:error, {:property_not_configurable, key}} + defp keep_nonconfigurable(_candidate, _key), do: :ok - not accessor?(current) and not current.writable and candidate.writable -> - {:error, {:property_not_writable, key}} + defp keep_enumerability(current, candidate, key) do + if property_enumerable?(candidate) == property_enumerable?(current), + do: :ok, + else: {:error, {:property_not_configurable, key}} + end - not accessor?(current) and not current.writable and candidate.value != current.value -> - {:error, {:property_not_writable, key}} + defp keep_descriptor_kind(current, candidate, key) do + if accessor?(candidate) == accessor?(current), + do: :ok, + else: {:error, {:property_not_configurable, key}} + end - true -> - {:ok, object} - 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 - nil when not object.extensible -> - {:error, {:object_not_extensible, key}} + defp keep_accessor_functions(_current, _candidate, _key), do: :ok - _current -> - if object.kind == :array and is_integer(key) and key >= object.length and - not object.length_writable, - do: {:error, {:property_not_writable, "length"}}, - else: {:ok, object} - end + 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) diff --git a/lib/quickbeam/vm/runtime/interpreter.ex b/lib/quickbeam/vm/runtime/interpreter.ex index 012ad35d3..3e7fe5e0a 100644 --- a/lib/quickbeam/vm/runtime/interpreter.ex +++ b/lib/quickbeam/vm/runtime/interpreter.ex @@ -7,6 +7,7 @@ defmodule QuickBEAM.VM.Runtime.Interpreter do 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 @@ -68,16 +69,17 @@ defmodule QuickBEAM.VM.Runtime.Interpreter do max_steps = Keyword.get(opts, :max_steps, @default_max_steps) vars = Map.new(Keyword.get(opts, :vars, %{})) - execution = %State{ - 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 = + 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)) @@ -177,11 +179,11 @@ defmodule QuickBEAM.VM.Runtime.Interpreter do def run_synchronous_job(%State{} = execution) do case :queue.out(execution.sync_jobs) do {{:value, {:read_thenable, promise, thenable, getter}}, sync_jobs} -> - execution = %{execution | sync_jobs: sync_jobs} + execution = put_sync_jobs(execution, sync_jobs) promise |> Async.read_thenable(thenable, getter, nil, execution) |> execute_async() - {:empty, _sync_jobs} -> - {:none, execution} + {:empty, sync_jobs} -> + {:none, put_sync_jobs(execution, sync_jobs)} end end @@ -275,14 +277,14 @@ defmodule QuickBEAM.VM.Runtime.Interpreter do defp execute_async({:suspended, continuation}), do: {:suspended, continuation} defp execute_async({:error, reason, execution}), do: {:error, reason, execution} - defp run(frame, %State{} = execution) when execution.sync_jobs != {[], []} do + 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 = %{execution | sync_jobs: sync_jobs} + execution = put_sync_jobs(execution, sync_jobs) promise |> Async.read_thenable(thenable, getter, frame, execution) |> execute_async() - {:empty, _sync_jobs} -> - run(frame, %{execution | sync_jobs: {[], []}}) + {:empty, sync_jobs} -> + run(frame, put_sync_jobs(execution, sync_jobs)) end end @@ -539,14 +541,14 @@ defmodule QuickBEAM.VM.Runtime.Interpreter do defp continue_iterator_sync(boundary, execution) do case :queue.out(execution.sync_jobs) do {{:value, {:read_thenable, promise, thenable, getter}}, sync_jobs} -> - execution = %{execution | sync_jobs: 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(%{execution | sync_jobs: {[], []}}) |> execute_invocation() + {:empty, sync_jobs} -> + boundary |> Iterator.continue(put_sync_jobs(execution, sync_jobs)) |> execute_invocation() end end @@ -760,21 +762,7 @@ defmodule QuickBEAM.VM.Runtime.Interpreter do end defp allocate_array(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} = ArrayBuiltin.from_entries(entries, execution) {:value, array, execution} end @@ -793,6 +781,9 @@ defmodule QuickBEAM.VM.Runtime.Interpreter do 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} @@ -940,12 +931,13 @@ defmodule QuickBEAM.VM.Runtime.Interpreter do end defp build_host_template(profile) do - execution = %State{ - atoms: {}, - max_stack_depth: 1, - remaining_steps: 1, - step_limit: 1 - } + execution = + State.new( + atoms: {}, + max_stack_depth: 1, + remaining_steps: 1, + step_limit: 1 + ) build_host_globals(execution, profile) end @@ -981,8 +973,14 @@ defmodule QuickBEAM.VM.Runtime.Interpreter do 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, reason, execution} -> raise_js_from_caller(reason, caller, execution) + {: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 diff --git a/lib/quickbeam/vm/runtime/memory.ex b/lib/quickbeam/vm/runtime/memory.ex index 834fa710f..b1e516bbc 100644 --- a/lib/quickbeam/vm/runtime/memory.ex +++ b/lib/quickbeam/vm/runtime/memory.ex @@ -6,8 +6,8 @@ defmodule QuickBEAM.VM.Runtime.Memory do controlled limit failures before the worker's process heap ceiling. """ - alias QuickBEAM.VM.Runtime.State alias QuickBEAM.VM.Runtime.Object + alias QuickBEAM.VM.Runtime.State @object_bytes 128 @property_bytes 64 diff --git a/lib/quickbeam/vm/runtime/opcode/control.ex b/lib/quickbeam/vm/runtime/opcode/control.ex index 43eb8c5eb..e857b43df 100644 --- a/lib/quickbeam/vm/runtime/opcode/control.ex +++ b/lib/quickbeam/vm/runtime/opcode/control.ex @@ -7,9 +7,9 @@ defmodule QuickBEAM.VM.Runtime.Opcode.Control do interpreter and their canonical semantic layers. """ - alias QuickBEAM.VM.Runtime.State alias QuickBEAM.VM.Runtime.Frame alias QuickBEAM.VM.Runtime.Promise.Reference, as: PromiseReference + alias QuickBEAM.VM.Runtime.State alias QuickBEAM.VM.Runtime.Value @opcodes [ diff --git a/lib/quickbeam/vm/runtime/opcode/invocation.ex b/lib/quickbeam/vm/runtime/opcode/invocation.ex index 6649394db..c2caa1e53 100644 --- a/lib/quickbeam/vm/runtime/opcode/invocation.ex +++ b/lib/quickbeam/vm/runtime/opcode/invocation.ex @@ -8,11 +8,11 @@ defmodule QuickBEAM.VM.Runtime.Opcode.Invocation do continuation frames and executing invocation actions. """ - alias QuickBEAM.VM.Runtime.State 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, diff --git a/lib/quickbeam/vm/runtime/opcode/local.ex b/lib/quickbeam/vm/runtime/opcode/local.ex index 125e4d0f1..89a411129 100644 --- a/lib/quickbeam/vm/runtime/opcode/local.ex +++ b/lib/quickbeam/vm/runtime/opcode/local.ex @@ -6,13 +6,14 @@ defmodule QuickBEAM.VM.Runtime.Opcode.Local do The module returns explicit frame actions and never owns interpreter stepping. """ - alias QuickBEAM.VM.Runtime.State - alias QuickBEAM.VM.Runtime.Frame + 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.Bytecode.Atom, as: AtomTable alias QuickBEAM.VM.Runtime.Property + alias QuickBEAM.VM.Runtime.Reference + alias QuickBEAM.VM.Runtime.State alias QuickBEAM.VM.Runtime.Value @get_reference_ops [ diff --git a/lib/quickbeam/vm/runtime/opcode/object.ex b/lib/quickbeam/vm/runtime/opcode/object.ex index 7276fddc6..d0596d2dd 100644 --- a/lib/quickbeam/vm/runtime/opcode/object.ex +++ b/lib/quickbeam/vm/runtime/opcode/object.ex @@ -9,9 +9,8 @@ defmodule QuickBEAM.VM.Runtime.Opcode.Object do import Bitwise - alias QuickBEAM.VM.Runtime.State - alias QuickBEAM.VM.Runtime.Frame 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 @@ -19,6 +18,7 @@ defmodule QuickBEAM.VM.Runtime.Opcode.Object do 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 @@ -135,10 +135,12 @@ defmodule QuickBEAM.VM.Runtime.Opcode.Object do ) do case Heap.own_property(execution, object, key) do {:ok, nil} -> - case Property.define(object, key, value, execution) do - {:ok, execution} -> next(%{frame | stack: [object | stack]}, execution) - {:error, reason} -> {:throw, {:type_error, reason}, frame, execution} - end + continue_definition( + Property.define(object, key, value, execution), + [object | stack], + frame, + execution + ) {:ok, %Descriptor{}} -> {:throw, {:type_error, :duplicate_private_field}, frame, execution} @@ -327,10 +329,12 @@ defmodule QuickBEAM.VM.Runtime.Opcode.Object do ) do key = Locals.resolve_atom(atom, execution) - case Property.define(object, key, value, execution) do - {:ok, execution} -> next(%{frame | stack: [object | stack]}, execution) - {:error, reason} -> {:throw, {:type_error, reason}, frame, execution} - end + continue_definition( + Property.define(object, key, value, execution), + [object | stack], + frame, + execution + ) end def execute( @@ -477,27 +481,37 @@ defmodule QuickBEAM.VM.Runtime.Opcode.Object do ), 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} -> - case Property.get(source, key, execution) do - {:ok, {:accessor, _getter, _receiver}} -> - {:halt, {:error, :unsupported_copy_accessor}} - - {:ok, value} -> - case Property.put(target, key, value, execution) do - {:ok, execution} -> {:cont, {:ok, execution}} - {:error, reason} -> {:halt, {:error, reason}} - end - - {:error, reason} -> - {:halt, {:error, reason}} - end + 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}} -> @@ -528,7 +542,8 @@ defmodule QuickBEAM.VM.Runtime.Opcode.Object do with :ok <- validate_class_parent(parent, execution), {:ok, parent_prototype} <- class_parent_prototype(parent, execution) do - {prototype, execution} = Heap.allocate(execution, prototype: parent_prototype) + {prototype, execution} = + Heap.allocate(execution, :ordinary, prototype: parent_prototype) {:ok, execution} = Property.define(prototype, "constructor", constructor, execution, @@ -556,17 +571,7 @@ defmodule QuickBEAM.VM.Runtime.Opcode.Object do configurable: true ) - execution = - case parent do - %Reference{} = parent -> - case Heap.set_prototype(execution, constructor, parent) do - {:ok, execution} -> execution - {:error, _reason} -> execution - end - - _other -> - execution - end + execution = set_constructor_parent(execution, constructor, parent) next(%{frame | stack: [prototype, constructor | stack]}, execution) else @@ -574,6 +579,15 @@ defmodule QuickBEAM.VM.Runtime.Opcode.Object do 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) diff --git a/lib/quickbeam/vm/runtime/opcode/stack.ex b/lib/quickbeam/vm/runtime/opcode/stack.ex index ec0721f7b..41eb43583 100644 --- a/lib/quickbeam/vm/runtime/opcode/stack.ex +++ b/lib/quickbeam/vm/runtime/opcode/stack.ex @@ -7,9 +7,9 @@ defmodule QuickBEAM.VM.Runtime.Opcode.Stack do stepping and resource accounting. """ - alias QuickBEAM.VM.Runtime.State alias QuickBEAM.VM.Runtime.Frame alias QuickBEAM.VM.Runtime.Stack, as: OperandStack + alias QuickBEAM.VM.Runtime.State @opcodes [ :push_i32, diff --git a/lib/quickbeam/vm/runtime/opcode/value.ex b/lib/quickbeam/vm/runtime/opcode/value.ex index ffe9a4081..65ba20815 100644 --- a/lib/quickbeam/vm/runtime/opcode/value.ex +++ b/lib/quickbeam/vm/runtime/opcode/value.ex @@ -7,11 +7,11 @@ defmodule QuickBEAM.VM.Runtime.Opcode.Value do interpreter. """ - alias QuickBEAM.VM.Runtime.State 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 [ diff --git a/lib/quickbeam/vm/runtime/promise.ex b/lib/quickbeam/vm/runtime/promise.ex index 012cf80b3..a8c1a0ddb 100644 --- a/lib/quickbeam/vm/runtime/promise.ex +++ b/lib/quickbeam/vm/runtime/promise.ex @@ -64,14 +64,7 @@ defmodule QuickBEAM.VM.Runtime.Promise do on_rejected: on_rejected } - execution = - 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 - - {result_promise, execution} + {result_promise, attach_reaction(execution, source, id, reaction)} end @doc "Creates or reuses the Promise produced by resolving one value." @@ -163,14 +156,15 @@ defmodule QuickBEAM.VM.Runtime.Promise do on_rejected: callback } - execution = - 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 + {result_promise, attach_reaction(execution, source, id, reaction)} + end - {result_promise, execution} + 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." @@ -451,5 +445,5 @@ defmodule QuickBEAM.VM.Runtime.Promise do 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)} + do: %{execution | sync_jobs: :queue.in(job, execution.sync_jobs), sync_jobs_pending?: true} end diff --git a/lib/quickbeam/vm/runtime/property.ex b/lib/quickbeam/vm/runtime/property.ex index 5af711d1e..757e6e8a6 100644 --- a/lib/quickbeam/vm/runtime/property.ex +++ b/lib/quickbeam/vm/runtime/property.ex @@ -8,12 +8,12 @@ defmodule QuickBEAM.VM.Runtime.Property do `{:ok, {:accessor, getter, receiver}}` action for the interpreter to resume. """ - alias QuickBEAM.VM.Runtime.State 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 [ @@ -33,23 +33,8 @@ defmodule QuickBEAM.VM.Runtime.Property do @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 -> - case Heap.fetch_object(execution, object) do - {:ok, %{internal: :global_object}} -> - case Map.fetch(execution.globals, key) do - {:ok, value} -> {:ok, value} - :error -> missing - end - - {:ok, %{kind: :regexp}} when key in ["exec", "test"] -> - {:ok, {:primitive_method, :regexp, key}} - - _other -> - missing - end - - result -> - result + {:ok, :undefined} = missing -> missing_reference_property(object, key, execution, missing) + result -> result end end @@ -95,6 +80,26 @@ defmodule QuickBEAM.VM.Runtime.Property do 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()} diff --git a/lib/quickbeam/vm/runtime/state.ex b/lib/quickbeam/vm/runtime/state.ex index 59d267700..bdd222831 100644 --- a/lib/quickbeam/vm/runtime/state.ex +++ b/lib/quickbeam/vm/runtime/state.ex @@ -6,6 +6,8 @@ defmodule QuickBEAM.VM.Runtime.State do 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, @@ -19,8 +21,9 @@ defmodule QuickBEAM.VM.Runtime.State do globals: %{}, handlers: %{}, heap: %{}, - jobs: {[], []}, - sync_jobs: {[], []}, + jobs: @empty_queue, + sync_jobs: @empty_queue, + sync_jobs_pending?: false, max_stack_depth: 1_000, memory_exceeded: false, memory_limit: :infinity, @@ -70,6 +73,7 @@ defmodule QuickBEAM.VM.Runtime.State do 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, @@ -90,4 +94,15 @@ defmodule QuickBEAM.VM.Runtime.State do 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/value.ex b/lib/quickbeam/vm/runtime/value.ex index cb3125d04..0bbd7a9f3 100644 --- a/lib/quickbeam/vm/runtime/value.ex +++ b/lib/quickbeam/vm/runtime/value.ex @@ -65,10 +65,10 @@ defmodule QuickBEAM.VM.Runtime.Value do 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, &Kernel./2) - def binary(:gte, left, right), do: compare(left, right, &Kernel.>=/2) + 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) @@ -83,75 +83,87 @@ defmodule QuickBEAM.VM.Runtime.Value do @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: numeric_binary(a, b, &Kernel.+/2) + 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: numeric_binary(a, b, &Kernel.-/2) + 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: numeric_binary(a, b, &Kernel.*/2) + 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 - a = to_number(a) - b = to_number(b) - - cond do - a == :nan or b == :nan -> :nan - b == 0 and a == 0 -> :nan - b == 0 and a > 0 -> :infinity - b == 0 and a < 0 -> :neg_infinity - true -> a / b - end - end + 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 - a = to_number(a) - b = to_number(b) - - cond do - a == :nan or b == :nan or b == 0 -> :nan - is_integer(a) and is_integer(b) -> rem(a, b) - true -> :math.fmod(a, b) + 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 - case {to_number(a), to_number(b)} do - {:nan, _} -> :nan - {_, :nan} -> :nan - {left, right} -> :math.pow(left, right) - end - end + 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 - case to_number(value) do - :nan -> :nan - number -> -number - end - end + def negate(value), do: negate_number(to_number(value)) @doc "Compares strings lexically or other values numerically." - @spec compare(term(), term(), (term(), term() -> boolean())) :: boolean() + @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 - {a, b} = - if is_binary(a) and is_binary(b), - do: {a, b}, - else: {to_number(a), to_number(b)} - - if a == :nan or b == :nan do - false - else - operation.(a, b) + case {to_number(a), to_number(b)} do + {:nan, _right} -> false + {_left, :nan} -> false + {left, right} -> compare_order(operation, ordering(left, right)) end end @@ -205,20 +217,7 @@ defmodule QuickBEAM.VM.Runtime.Value do 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(value) - - case Integer.parse(value) do - {integer, ""} -> - integer - - _ -> - case Float.parse(value) do - {float, ""} -> float - _ -> :nan - end - end - end + def to_number(value) when is_binary(value), do: value |> String.trim() |> parse_number() def to_number(_value), do: :nan @@ -242,6 +241,12 @@ defmodule QuickBEAM.VM.Runtime.Value do 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]" @@ -274,14 +279,161 @@ defmodule QuickBEAM.VM.Runtime.Value do |> string_from_units() end - defp numeric_binary(a, b, operation) do - case {to_number(a), to_number(b)} do - {:nan, _} -> :nan - {_, :nan} -> :nan - {left, right} -> operation.(left, right) + 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 diff --git a/lib/quickbeam/vm/runtime/value/export.ex b/lib/quickbeam/vm/runtime/value/export.ex index 6edbeb789..4005d16c8 100644 --- a/lib/quickbeam/vm/runtime/value/export.ex +++ b/lib/quickbeam/vm/runtime/value/export.ex @@ -7,19 +7,21 @@ defmodule QuickBEAM.VM.Runtime.Value.Export do """ alias QuickBEAM.VM.Runtime.Exception - alias QuickBEAM.VM.Runtime.State 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, MapSet.new()) + 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) @@ -29,11 +31,11 @@ defmodule QuickBEAM.VM.Runtime.Value.Export do end defp convert(%Reference{id: id} = reference, execution, seen) do - if MapSet.member?(seen, id) 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, MapSet.put(seen, id)) + {:ok, object} -> convert_object(object, execution, Map.put(seen, id, true)) :error -> {:error, {:invalid_reference, id}} end end @@ -62,6 +64,8 @@ defmodule QuickBEAM.VM.Runtime.Value.Export do 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} @@ -90,6 +94,8 @@ defmodule QuickBEAM.VM.Runtime.Value.Export do 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 diff --git a/mix.exs b/mix.exs index d5b59a6a9..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.", diff --git a/test/support/test262.ex b/test/support/test262.ex index def8bcbf3..6115e2a62 100644 --- a/test/support/test262.ex +++ b/test/support/test262.ex @@ -38,6 +38,8 @@ defmodule QuickBEAM.Test262 do than silently falling back to the native engine. """ + alias QuickBEAM.JSError + alias QuickBEAM.Test262.Metadata alias QuickBEAM.VM.Runtime.Engine @minimal_harness """ @@ -92,17 +94,17 @@ defmodule QuickBEAM.Test262 do def configured_root, do: System.get_env("TEST262_PATH") @doc "Parses the metadata fields needed by the bounded runner." - @spec parse_metadata(binary()) :: QuickBEAM.Test262.Metadata.t() + @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!() - |> QuickBEAM.Test262.Metadata.from_map!() + |> Metadata.from_map!() :missing -> - %QuickBEAM.Test262.Metadata{} + %Metadata{} end end @@ -255,7 +257,7 @@ defmodule QuickBEAM.Test262 do defp normalize_phase(:early), do: :parse defp normalize_phase(phase) when phase in [:parse, :resolution, :runtime], do: phase - defp error_name(%QuickBEAM.JSError{name: name}), do: name + 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 @@ -269,10 +271,8 @@ defmodule QuickBEAM.Test262 do do: %{path: path, classification: classification, vm: vm, native: native, metadata: metadata} defp safe_stop(runtime) do - try do - QuickBEAM.stop(runtime) - catch - :exit, _reason -> :ok - end + QuickBEAM.stop(runtime) + catch + :exit, _reason -> :ok end end diff --git a/test/vm/abi_test.exs b/test/vm/abi_test.exs index 0446e8430..7d4144827 100644 --- a/test/vm/abi_test.exs +++ b/test/vm/abi_test.exs @@ -2,8 +2,8 @@ defmodule QuickBEAM.VM.ABITest do use ExUnit.Case, async: true alias QuickBEAM.VM.ABI - alias QuickBEAM.VM.Bytecode.Opcode 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 diff --git a/test/vm/builtin/dsl_test.exs b/test/vm/builtin/dsl_test.exs index 631c9667e..744e15ab4 100644 --- a/test/vm/builtin/dsl_test.exs +++ b/test/vm/builtin/dsl_test.exs @@ -1,21 +1,31 @@ defmodule QuickBEAM.VM.Builtin.DSLTest do use ExUnit.Case, async: true - alias QuickBEAM.VM.Builtin.Spec.Accessor, as: AccessorSpec + 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.Spec.Function, as: FunctionSpec + alias QuickBEAM.VM.Builtin.Error.Type, as: TypeErrorBuiltin + alias QuickBEAM.VM.Builtin.Function, as: FunctionBuiltin alias QuickBEAM.VM.Builtin.Installer - alias QuickBEAM.VM.Builtin.Spec.Property, as: PropertySpec + 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.Builtin - alias QuickBEAM.VM.Runtime.State 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 @@ -88,8 +98,8 @@ defmodule QuickBEAM.VM.Builtin.DSLTest do end test "compiles declarative modules into immutable validated specs" do - math = QuickBEAM.VM.Builtin.Math.builtin_spec() - array = QuickBEAM.VM.Builtin.Array.builtin_spec() + math = MathBuiltin.builtin_spec() + array = ArrayBuiltin.builtin_spec() assert math.name == "Math" assert math.kind == :namespace @@ -106,7 +116,7 @@ defmodule QuickBEAM.VM.Builtin.DSLTest do assert array.kind == :constructor assert array.prototype_spec.kind == :array assert array.prototype_spec.default_for == :array - assert [%FunctionSpec{key: "isArray", handler: :is_array, length: 1}] = array.statics + 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) @@ -141,41 +151,41 @@ defmodule QuickBEAM.VM.Builtin.DSLTest do assert Registry.modules(:core) == Enum.map(Registry.refresh()[:core], & &1.module) assert Registry.generation() == generation + 1 - assert QuickBEAM.VM.Builtin.String.builtin_spec().kind == :constructor + assert StringBuiltin.builtin_spec().kind == :constructor - object = QuickBEAM.VM.Builtin.Object.builtin_spec() + object = ObjectBuiltin.builtin_spec() assert object.prototype_spec.extends == nil assert object.prototype_spec.default_for == :ordinary - function = QuickBEAM.VM.Builtin.Function.builtin_spec() + 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 = QuickBEAM.VM.Builtin.Promise.builtin_spec() + promise = PromiseBuiltin.builtin_spec() assert promise.kind == :constructor assert promise.constructor == :construct assert promise.depends_on == ["Object", "Function", "Symbol"] - error = QuickBEAM.VM.Builtin.Error.Type.builtin_spec() + error = TypeErrorBuiltin.builtin_spec() assert error.prototype_spec.extends == "Error" assert error.prototype_spec.error_type == "TypeError" - set = QuickBEAM.VM.Builtin.Set.builtin_spec() + set = SetBuiltin.builtin_spec() assert set.kind == :constructor - assert Enum.any?(set.prototype, &match?(%QuickBEAM.VM.Builtin.Spec.Alias{}, &1)) + assert Enum.any?(set.prototype, &match?(%AliasSpec{}, &1)) - symbol = QuickBEAM.VM.Builtin.Symbol.builtin_spec() + symbol = SymbolBuiltin.builtin_spec() assert symbol.kind == :function assert symbol.constructor == nil assert Enum.any?( symbol.statics, - &match?(%{key: "iterator", value: %QuickBEAM.VM.Runtime.Symbol{id: :iterator}}, &1) + &match?(%{key: "iterator", value: %RuntimeSymbol{id: :iterator}}, &1) ) - assert Enum.map(QuickBEAM.VM.Builtin.Object.builtin_spec().statics, & &1.key) == + assert Enum.map(ObjectBuiltin.builtin_spec().statics, & &1.key) == ~w(assign create defineProperty defineProperties freeze getOwnPropertyDescriptor getOwnPropertyNames getPrototypeOf keys setPrototypeOf) end diff --git a/test/vm/bytecode/decoder_test.exs b/test/vm/bytecode/decoder_test.exs index 426c5e37a..1f7b97628 100644 --- a/test/vm/bytecode/decoder_test.exs +++ b/test/vm/bytecode/decoder_test.exs @@ -3,11 +3,12 @@ defmodule QuickBEAM.VM.Bytecode.DecoderTest do alias QuickBEAM.VM.ABI alias QuickBEAM.VM.Bytecode.Checksum - alias QuickBEAM.VM.Program.Function + alias QuickBEAM.VM.Bytecode.Decoder alias QuickBEAM.VM.Bytecode.Instruction alias QuickBEAM.VM.Bytecode.Opcode - alias QuickBEAM.VM.Program alias QuickBEAM.VM.Bytecode.Verifier + alias QuickBEAM.VM.Program + alias QuickBEAM.VM.Program.Function setup do {:ok, runtime} = QuickBEAM.start(apis: false) @@ -117,8 +118,9 @@ defmodule QuickBEAM.VM.Bytecode.DecoderTest do Varint.LEB128.encode(84) ]) - assert {:ok, %Program{root: {:object, %{^key => 42}}}} = - payload |> bytecode_envelope() |> QuickBEAM.VM.decode() + 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 @@ -203,6 +205,30 @@ defmodule QuickBEAM.VM.Bytecode.DecoderTest do 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) diff --git a/test/vm/compiler/code_test.exs b/test/vm/compiler/code_test.exs index a781bcd6e..91dbe297a 100644 --- a/test/vm/compiler/code_test.exs +++ b/test/vm/compiler/code_test.exs @@ -1,21 +1,19 @@ defmodule QuickBEAM.VM.Compiler.CodeTest do use ExUnit.Case, async: false - alias QuickBEAM.VM.Compiler.Contract - alias QuickBEAM.VM.Compiler.Deopt alias QuickBEAM.VM.Compiler.Code - alias QuickBEAM.VM.Compiler.Pool - alias QuickBEAM.VM.Compiler.Runtime - alias QuickBEAM.VM.Compiler.Code.Artifact - alias QuickBEAM.VM.Compiler.Code.Lifecycle 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.Runtime.State - alias QuickBEAM.VM.Runtime.Frame + 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()) diff --git a/test/vm/compiler/counter_test.exs b/test/vm/compiler/counter_test.exs index 76199eed6..0e3fed1ae 100644 --- a/test/vm/compiler/counter_test.exs +++ b/test/vm/compiler/counter_test.exs @@ -3,8 +3,8 @@ defmodule QuickBEAM.VM.Compiler.CounterTest do alias QuickBEAM.VM.Compiler.Context alias QuickBEAM.VM.Compiler.Counter - alias QuickBEAM.VM.Runtime.State alias QuickBEAM.VM.Program + alias QuickBEAM.VM.Runtime.State test "keeps fixed OTP counters in the evaluation owner" do execution = %State{ diff --git a/test/vm/compiler/region/probe_test.exs b/test/vm/compiler/region/probe_test.exs index 9bd235267..bb9dca052 100644 --- a/test/vm/compiler/region/probe_test.exs +++ b/test/vm/compiler/region/probe_test.exs @@ -3,10 +3,10 @@ defmodule QuickBEAM.VM.Compiler.Region.ProbeTest do alias QuickBEAM.VM.Compiler.Context alias QuickBEAM.VM.Compiler.Region.Probe - alias QuickBEAM.VM.Runtime.State - alias QuickBEAM.VM.Runtime.Frame - alias QuickBEAM.VM.Program.Function 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() diff --git a/test/vm/compiler/runtime_test.exs b/test/vm/compiler/runtime_test.exs index 57746eee1..c015bcc10 100644 --- a/test/vm/compiler/runtime_test.exs +++ b/test/vm/compiler/runtime_test.exs @@ -3,11 +3,11 @@ defmodule QuickBEAM.VM.Compiler.RuntimeTest do alias QuickBEAM.VM.Compiler.Contract alias QuickBEAM.VM.Compiler.Deopt - alias QuickBEAM.VM.Compiler.Runtime alias QuickBEAM.VM.Compiler.Pool.Lease - alias QuickBEAM.VM.Runtime.State - alias QuickBEAM.VM.Runtime.Frame + 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() diff --git a/test/vm/runtime/async_semantics_test.exs b/test/vm/runtime/async_semantics_test.exs index a0710de2a..0a91aed4b 100644 --- a/test/vm/runtime/async_semantics_test.exs +++ b/test/vm/runtime/async_semantics_test.exs @@ -1,14 +1,14 @@ 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.State alias QuickBEAM.VM.Runtime.Frame - alias QuickBEAM.VM.Program.Function 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() diff --git a/test/vm/runtime/async_test.exs b/test/vm/runtime/async_test.exs index 8b84a0fab..519078465 100644 --- a/test/vm/runtime/async_test.exs +++ b/test/vm/runtime/async_test.exs @@ -84,6 +84,26 @@ defmodule QuickBEAM.VM.Runtime.AsyncTest do 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')})()" @@ -102,6 +122,14 @@ defmodule QuickBEAM.VM.Runtime.AsyncTest do 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") diff --git a/test/vm/runtime/error_test.exs b/test/vm/runtime/error_test.exs index 83c33b34d..6c286cac7 100644 --- a/test/vm/runtime/error_test.exs +++ b/test/vm/runtime/error_test.exs @@ -110,6 +110,31 @@ defmodule QuickBEAM.VM.Runtime.ErrorTest do 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) {}") diff --git a/test/vm/runtime/exception_test.exs b/test/vm/runtime/exception_test.exs index 70308e0b1..61207ac40 100644 --- a/test/vm/runtime/exception_test.exs +++ b/test/vm/runtime/exception_test.exs @@ -1,13 +1,12 @@ 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.State alias QuickBEAM.VM.Runtime.Exception alias QuickBEAM.VM.Runtime.Frame - alias QuickBEAM.VM.Program.Function 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 diff --git a/test/vm/runtime/heap_test.exs b/test/vm/runtime/heap_test.exs index 8e5000ab5..0a6709d33 100644 --- a/test/vm/runtime/heap_test.exs +++ b/test/vm/runtime/heap_test.exs @@ -1,10 +1,10 @@ defmodule QuickBEAM.VM.Runtime.HeapTest do use ExUnit.Case, async: true - alias QuickBEAM.VM.Runtime.State - alias QuickBEAM.VM.Runtime.Value.Export 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() diff --git a/test/vm/runtime/interpreter_test.exs b/test/vm/runtime/interpreter_test.exs index 29a9ee450..8a4ad78ac 100644 --- a/test/vm/runtime/interpreter_test.exs +++ b/test/vm/runtime/interpreter_test.exs @@ -1,9 +1,9 @@ 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 - alias QuickBEAM.VM.Program test "evaluates arithmetic and comparisons in an isolated BEAM process" do assert {:ok, program} = QuickBEAM.VM.compile("(2 + 3 * 4) === 14") diff --git a/test/vm/runtime/invocation_test.exs b/test/vm/runtime/invocation_test.exs index abd139358..ea9478881 100644 --- a/test/vm/runtime/invocation_test.exs +++ b/test/vm/runtime/invocation_test.exs @@ -3,12 +3,12 @@ defmodule QuickBEAM.VM.Runtime.InvocationTest do alias QuickBEAM.VM.Builtin.Action alias QuickBEAM.VM.Builtin.Call + alias QuickBEAM.VM.Program.Function alias QuickBEAM.VM.Runtime.Boundary - alias QuickBEAM.VM.Runtime.State alias QuickBEAM.VM.Runtime.Frame - alias QuickBEAM.VM.Program.Function 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() diff --git a/test/vm/runtime/object_test.exs b/test/vm/runtime/object_test.exs index 986cc5c09..00268bfc0 100644 --- a/test/vm/runtime/object_test.exs +++ b/test/vm/runtime/object_test.exs @@ -145,6 +145,7 @@ defmodule QuickBEAM.VM.Runtime.ObjectTest do "(()=>{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}})()" @@ -155,6 +156,21 @@ defmodule QuickBEAM.VM.Runtime.ObjectTest do 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]})()", diff --git a/test/vm/runtime/opcode_test.exs b/test/vm/runtime/opcode_test.exs index f5c4e09ce..63a5efb48 100644 --- a/test/vm/runtime/opcode_test.exs +++ b/test/vm/runtime/opcode_test.exs @@ -1,18 +1,18 @@ defmodule QuickBEAM.VM.Runtime.OpcodeTest do use ExUnit.Case, async: true - alias QuickBEAM.VM.Runtime.State - alias QuickBEAM.VM.Runtime.Frame alias QuickBEAM.VM.Program.Function + alias QuickBEAM.VM.Runtime.Frame alias QuickBEAM.VM.Runtime.Heap alias QuickBEAM.VM.Runtime.Object - alias QuickBEAM.VM.Runtime.Property 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.Opcode.Invocation, as: CallOpcodes + alias QuickBEAM.VM.Runtime.Property + alias QuickBEAM.VM.Runtime.State test "opcode families publish non-overlapping routing tables" do opcodes = diff --git a/test/vm/runtime/property_test.exs b/test/vm/runtime/property_test.exs index fbf043f7f..b70ec5e09 100644 --- a/test/vm/runtime/property_test.exs +++ b/test/vm/runtime/property_test.exs @@ -2,11 +2,11 @@ defmodule QuickBEAM.VM.Runtime.PropertyTest do use ExUnit.Case, async: true alias QuickBEAM.VM.Builtin.Runtime, as: BuiltinRuntime - alias QuickBEAM.VM.Runtime.State 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() diff --git a/test/vm/runtime/value_test.exs b/test/vm/runtime/value_test.exs index 4a9e13cf2..97763e733 100644 --- a/test/vm/runtime/value_test.exs +++ b/test/vm/runtime/value_test.exs @@ -19,6 +19,14 @@ defmodule QuickBEAM.VM.Runtime.ValueTest do 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 @@ -33,14 +41,58 @@ defmodule QuickBEAM.VM.Runtime.ValueTest do 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 From 782277141512f1edc4ae15f4d451b057a435460a Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 17 Jul 2026 23:00:20 +0200 Subject: [PATCH 87/87] Use fixed VM ABI atom vocabulary --- lib/quickbeam/vm/abi/generator.ex | 15 +- lib/quickbeam/vm/abi/vocabulary.ex | 316 +++++++++++++++++++++++++++++ test/vm/abi_test.exs | 15 ++ 3 files changed, 342 insertions(+), 4 deletions(-) create mode 100644 lib/quickbeam/vm/abi/vocabulary.ex diff --git a/lib/quickbeam/vm/abi/generator.ex b/lib/quickbeam/vm/abi/generator.ex index d6679fd9e..f84f15f4a 100644 --- a/lib/quickbeam/vm/abi/generator.ex +++ b/lib/quickbeam/vm/abi/generator.ex @@ -7,6 +7,10 @@ defmodule QuickBEAM.VM.ABI.Generator do """ 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() @@ -70,18 +74,21 @@ defmodule QuickBEAM.VM.ABI.Generator do defp tag_name!(name) do case String.trim(name) do - "BC_TAG_" <> suffix -> suffix |> identifier!(:tag) |> String.downcase() |> String.to_atom() - _other -> raise ArgumentError, "unsupported bytecode tag definition: #{inspect(name)}" + "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) |> String.to_atom(), + name |> identifier!(:opcode) |> Vocabulary.fetch!(:opcode), unsigned_integer!(size, name), unsigned_integer!(pops, name), unsigned_integer!(pushes, name), - format |> identifier!(:opcode_format) |> String.to_atom() + format |> identifier!(:opcode_format) |> Vocabulary.fetch!(:opcode_format) } 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/test/vm/abi_test.exs b/test/vm/abi_test.exs index 7d4144827..0cfd7affe 100644 --- a/test/vm/abi_test.exs +++ b/test/vm/abi_test.exs @@ -2,6 +2,7 @@ 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 @@ -40,6 +41,20 @@ defmodule QuickBEAM.VM.ABITest do 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()