Skip to content

BEAM-native JS engine and compiler#5

Open
dannote wants to merge 348 commits intomasterfrom
beam-vm-interpreter
Open

BEAM-native JS engine and compiler#5
dannote wants to merge 348 commits intomasterfrom
beam-vm-interpreter

Conversation

@dannote
Copy link
Copy Markdown
Member

@dannote dannote commented Apr 15, 2026

Adds a second QuickJS execution backend on the BEAM.

What’s in here

  • QuickJS bytecode decoder in Elixir
  • interpreter for QuickJS bytecode on the BEAM
  • hybrid compiler from QuickJS bytecode to BEAM modules
  • raw BEAM disassembly for the :beam backend via QuickBEAM.disasm/2
  • mode: :beam support in the public API
  • require(), module loading, dynamic import, globals, handlers, and interop for the VM path
  • stack traces, source positions, and Error.captureStackTrace

Runtime coverage

  • Object, Array, Function, String, Number, Boolean
  • Math, JSON, Date, RegExp
  • Map, Set, WeakMap, WeakSet, Symbol
  • Promise, async/await, generators, async generators
  • Proxy, Reflect
  • TypedArray, ArrayBuffer, BigInt
  • classes, inheritance, super, private fields, private methods, private accessors, static private members, brand checks

Validation

  • QUICKBEAM_BUILD=1 MIX_ENV=test mix test
  • MIX_ENV=test QUICKBEAM_BUILD=1 mix test test/vm/js_engine_test.exs --include js_engine --seed 0
  • mix compile --warnings-as-errors
  • mix format --check-formatted
  • mix credo --strict
  • mix dialyzer
  • mix ex_dna
  • zlint lib/quickbeam/*.zig lib/quickbeam/napi/*.zig
  • bunx oxlint -c oxlint.json --type-aware --type-check priv/ts/
  • bunx jscpd lib/quickbeam/*.zig priv/ts/*.ts --min-tokens 50 --threshold 0

Current local result:

  • 2363 tests, 0 failures, 1 skipped, 54 excluded

@dannote dannote force-pushed the beam-vm-interpreter branch from 0eb3475 to 7c1c574 Compare April 15, 2026 14:06
@dannote dannote changed the title BEAM-native JS interpreter (Phase 0-1) BEAM-native JS interpreter Apr 16, 2026
@dannote dannote marked this pull request as ready for review April 16, 2026 08:41
dannote added 27 commits April 18, 2026 16:19
Port QuickJS-ng's own test suite (test_builtin.js, test_language.js)
as an Elixir test harness. Each test_*() function from the JS files
becomes an ExUnit test case.

Results (NIF mode): 20/37 pass, 17 fail
Results (BEAM mode): 4/37 pass, 33 fail

Failures are mostly Object.isExtensible/freeze/seal (not implemented),
gc() (QuickJS-specific), and language edge cases in BEAM mode.

Tagged :js_engine — excluded by default, opt in with:
  mix test test/js_engine/ --include js_engine
  QUICKBEAM_MODE=beam mix test test/js_engine/ --include js_engine

The 16 tests that pass in NIF but fail in BEAM show exactly where the
BEAM interpreter needs work (type coercion, delete, prototype chain,
class edge cases, template literals, spread, etc).
Bitwise operations:
- to_int32 now properly wraps to 32-bit signed range (JS ToInt32)
- shl wraps result to int32 (1 << 31 === -2147483648)
- bnot wraps result to int32 (~0 === -1)
- to_int32/to_uint32 handle string→number conversion ("12345" | 0)
- to_number supports hex (0x), octal (0o), binary (0b) string literals

Test runner:
- Extract individual functions instead of evaling entire file
- Eliminates cross-contamination between test functions
- Uses brace-matching to find function boundaries

Results:
  NIF:  24/37 pass (was 20)
  BEAM: 6/37 pass (was 4)
test_language.js defines its own assert and assert_throws functions.
The test runner now extracts the preamble (everything before the first
test_ function) and prepends it to each test, providing the helper
functions tests depend on.

Results:
  NIF:  28/37 pass (was 24)
  BEAM: 6/37 pass (was 6)

Remaining NIF failures: QuickJS-specific APIs (gc, os, qjs, F, my_func,
test - helper functions defined elsewhere in test files).

Remaining BEAM failures: abstract equality with wrappers, post-increment
on properties, TDZ/uninitialized locals, prototype chain, template tag
functions, float-to-string precision, Object.keys for arrays,
Symbol.toString, charCodeAt unicode.
…rmatting

- charCodeAt: use String.to_charlist for proper Unicode codepoints
  instead of raw byte access (€ returns 8364, not 226)
- String(Symbol('abc')): js_to_string handles symbol tuples
- Object.keys([1,2,3]): returns ['0','1','2'] instead of crashing
  on list data. Object.values/entries also fixed to handle new
  keys return format
- Float-to-string: use :erlang.float_to_binary(:short) with JS
  Number.toString spec formatting. Handles exponential notation
  cutoffs (1e-6..1e21), strips mantissa .0

735 beam_vm tests pass, 0 failures.
JS engine: NIF 28/37, BEAM 7/37.
- Include non-test helper functions (my_func, test, F, rope_concat,
  test_expr, test_name) alongside each test function
- Only treat test_*() with no parameters as standalone test functions
- test_expr(expr, err) and test_name(name, err) become helpers
  included for tests that reference them

NIF: 29/35 pass. BEAM: 7/35. 733 beam_vm tests, 0 failures.
…totype

- Function.prototype: auto-created with constructor back-reference
  when first accessed. Both get_property and call_constructor use it.
  Makes instanceof work for plain function constructors.
- typeof_is_undefined: properly checks if value is undefined/nil
  instead of always returning false. Fixed arg pattern ([] not [_]).
- typeof_is_function: checks for builtin/closure/function values
  instead of always returning false. Fixed arg pattern.

NIF: 29/35. BEAM: 8/35. 733 beam_vm tests, 0 failures.
….set

- String.prototype.codePointAt: proper Unicode codepoint access
- Math.clz32: count leading zeros in 32-bit integer
- Math.fround: round to 32-bit float
- Math.imul: 32-bit integer multiplication
- Math trig: asin, acos, atan, atan2, exp, cbrt, hypot
- TypedArray.prototype.set: copy array data into typed array

NIF: 29/35. BEAM: 8/35. 733 beam_vm tests, 0 failures.
Interpreter fixes:
- perm3/4/5: fix stack rotation direction (rotate down, not up).
  Fixes post-increment on properties (a.x++ returns old value)
- Abstract equality (==): ToPrimitive coercion for objects vs
  primitives. new Number(1) == 1 is now true. Guard against
  infinite loops when ToPrimitive returns an object
- Template objects: bytecode decoder preserves raw array,
  push_const materializes {:template_object, elems, raw} as
  heap object with .raw property
- new Number/String/Boolean: store primitive value and add
  valueOf/toString methods on wrapper objects
- typeof_is_undefined/typeof_is_function: fix arg pattern [],
  implement actual type checks
- invoke/3: handle builtins, nil, undefined with proper errors
- Uninitialized locals throw ReferenceError instead of crash
- gc() added as no-op global

Runtime fixes:
- Object.keys: handle list (array) data, guard keys_from_map
- to_js_string: handle list-stored objects (arrays)
- Symbol toString in js_to_string
- Helper function extraction: fix set subtraction precedence

733 beam_vm tests pass, 0 failures.
…o_primitive

Stack rotation ops (verified against QuickJS C source):
- perm3: [a,b,c] → [a,c,b] (swap sp[-2] and sp[-3])
- perm4: [a,b,c,d] → [a,c,d,b] (rotate sp[-2]..sp[-4])
- perm5: [a,b,c,d,e] → [a,c,d,e,b] (rotate sp[-2]..sp[-5])
- rot3l: [a,b,c] → [c,a,b] (bottom to top)
- rot3r: [a,b,c] → [b,c,a] (top to bottom)
- rot4l: [a,b,c,d] → [d,a,b,c]
- rot5l: [a,b,c,d,e] → [e,a,b,c,d]

put_field: pops 2 values, pushes 0 (was incorrectly pushing obj back).
define_field: correctly keeps obj on stack (stack_in=2, stack_out=1).

post_inc/post_dec: coerce value via to_number before returning old
value. true++ now returns 1 (not true).

convert_beam_value: filter internal keys by __prefix__ AND __suffix__
pattern, not just __proto__. Prevents __buffer__, __promise_state__,
__typed_array__ etc from leaking into Elixir results.

to_primitive: try valueOf then toString on both own properties and
prototype. Return nil (not obj) when method returns an object,
preventing infinite recursion in abstract_eq.

format_js_exponential: handle negative mantissa properly by extracting
sign prefix before digit manipulation.

733 beam_vm tests pass, 0 failures, 0 warnings.
NIF: 29/35 JS engine tests. BEAM: 11/35.
…ames

Template objects:
- materialize_constant creates map-based objects with indexed string
  access and .raw property (was list-based, broke property access)
- Handle {:array, list}, :undefined, and plain list formats for raw

New builtins:
- String.raw: tagged template literal support
- TypedArray.prototype.subarray: extract sub-range
- TypedArray.prototype.fill: fill with value

Fixes:
- getOwnPropertyNames: handle list-backed arrays, return obj ref,
  filter internal __ keys
- post_inc/post_dec: coerce via to_number (true++ returns 1)

733 beam_vm tests, 0 failures. NIF: 29/35. BEAM: 12/35.
Math.min() returns Infinity, Math.max() returns -Infinity per spec.
Previously crashed with empty error on Enum.min/max([]).

733 beam_vm tests, 0 failures. NIF: 29/35. BEAM: 12/35.
… more

Interpreter:
- set_proto: actually sets __proto__ on object (was no-op)
- get_or_create_prototype: check Heap.get_class_proto first so class
  constructors and .prototype access return the same object
- append opcode: iterate via Symbol.iterator for non-array iterables
  (Set, Map, custom iterables). Added collect_iterator helper.

Builtins:
- Number.toString(16): lowercase hex output
- Number.toExponential: proper mantissa/exponent calculation
- Number.toPrecision: significant digit rounding
- Math.min/max: handle NaN, type coerce args via to_float
- Date.UTC: static method for UTC timestamp construction
- Set: Symbol.iterator/values/keys for for-of and spread support
- Set iterator: guard against non-list data

Removed reach dependency (accidentally added).

733 beam_vm tests, 0 failures. NIF: 29/35. BEAM: 12/35.
… toExponential fix

Math: log1p, expm1, cosh, sinh, tanh, acosh, asinh, atanh
Date: getTimezoneOffset, getDay, getUTCFullYear, setTime,
  toLocaleDateString, toLocaleTimeString, toLocaleString, Date.UTC
Set: add, entries, Symbol.iterator for spread support
Number: toExponential proper mantissa/exponent calculation
Spread: collect_iterator for non-array iterables (Set, Map, custom)

733 beam_vm tests, 0 failures.
…, JSON undefined

Date: setFullYear/Month/Date/Hours/Minutes/Seconds/Milliseconds,
  parse (ISO 8601), getTimezoneOffset, getDay, getUTCFullYear,
  toLocaleDateString/TimeString/String
TypedArray: join, forEach, map, filter, every, some, reduce,
  indexOf, find, sort, reverse, slice (13 methods)
Math: sumPrecise, log1p, expm1, cosh/sinh/tanh, acosh/asinh/atanh
JSON: stringify omits undefined values and internal __ keys
Set: add, entries (additional methods)
Objects: normalize float property keys to integer strings

733 beam_vm tests, 0 failures. Diag: 13/35 PASS.
TDZ (Temporal Dead Zone):
- Use :__tdz__ sentinel instead of :undefined for uninitialized let/const
- get_loc_check/put_loc_check/get_var_ref_check only throw for :__tdz__
- let x; return x now correctly returns undefined instead of throwing

Object.prototype:
- Created shared Object.prototype with toString, valueOf,
  hasOwnProperty, isPrototypeOf, propertyIsEnumerable, constructor
- Objects created via :object opcode get __proto__ pointing to it
- {}.constructor === Object is now true
- {}.toString() returns '[object Object]'
- Array constructor property accessible via prototype chain

delete operator:
- Returns false for non-configurable properties
- Checks Heap.get_prop_desc before deleting

JSON.stringify:
- Resolves accessor (getter) values when serializing
- Filters undefined values from output

NIF: 29/35. BEAM diag: 14/35. 733 beam_vm tests, 0 failures.
get_property now resolves accessors on the prototype chain using the
original receiver object as 'this', not the prototype. Uses a raw
prototype walk (get_prototype_raw) that returns accessor tuples without
invoking them, then invokes at the top level with the correct receiver.

Fixes: class C { get y(){return this.x} } — new C(3).y now returns 6.

Also: {set x(v){...}, get x(){...}} correctly invokes getters/setters
through the prototype chain.

733 beam_vm tests, 0 failures. NIF: 29/35. BEAM diag: 14/35.
- Generator functions throw TypeError on new (func_kind check)
- make_error_obj sets __proto__ to Error constructor's prototype,
  enabling e instanceof TypeError to work correctly
- JSON.stringify invokes getters with correct this via invoke_getter
- Prototype chain getter resolution uses get_prototype_raw to
  return accessor tuples, invokes with original receiver

733 beam_vm tests, 0 failures. NIF: 29/35. BEAM: 13/35.
Number.toString(radix): proper float-to-radix conversion for bases 2-36.
Handles fractional parts with iterative digit extraction.

Function.bind: returns {:bound, length, inner} with correct length
calculation. {:bound, ...} handled in call_function, tail_call,
call_method, invoke, typeof, call_builtin_callback.

Set methods (ES2025): difference, intersection, union,
symmetricDifference, isSubsetOf, isSupersetOf, isDisjointFrom,
delete, clear.

Date methods: toDateString, toTimeString, toUTCString.

Math.sumPrecise: Kahan summation for floating-point precision.

JSON.stringify: invoke getters with correct this via invoke_getter.

Generator new: throws TypeError (generators are not constructors).

Error objects: __proto__ set to constructor's prototype for instanceof.

733 beam_vm tests, 0 failures. NIF: 29/35. BEAM: 13/35.
…e dead code

Review items:
- perm3/4/5, rot3l/3r/4l/5l: VERIFIED CORRECT against QuickJS C source.
  The reviewer confused TOS-first (our notation) with bottom-first
  (QuickJS comment notation). All opcodes produce correct results.
  Tested: a.x++ returns [old_val, new_val] correctly.

Fixes:
- get_prototype_raw: add depth limit (max 20) to prevent infinite
  loops on circular prototype chains
- global_bindings: cache entire result in :qb_global_bindings_cache
  PD key. Object.prototype creation was already cached, now the
  full bindings map is cached too. Eliminates re-building the large
  globals map on every eval call.
- Remove dead write_back_eval_locals no-op function and its call

733 beam_vm tests, 0 failures, 0 warnings.
Heap module:
- wrap(data): creates heap object, returns {:obj, ref} — replaces
  the 69 instances of make_ref/put_obj/return pattern
- to_list(val): coerces JS value to Elixir list — replaces 8+
  copy-paste blocks across builtins/typed_array/array
- iter_result(val, done): creates iterator result object
- make_error(message, name): creates error object with stack
- get_or_create_prototype(ctor): canonical implementation, removed
  duplicate copies from interpreter.ex and runtime.ex
- PD accessors: get/put_object_prototype, get/put_global_cache,
  get/put_atoms, get/put_persistent_globals, get/put_handler_globals,
  get/put_runtime_mode — replaces direct Process.get/put calls
- sweep_keys(marked): extracted from gc/0, eliminates duplicated
  sweep logic between fast-path and slow-path

Other:
- @moduledoc false added to Ctx, Frame, TypedArray, Date
- All direct Process.get/put for PD keys routed through Heap

733 tests pass in both modes, 0 warnings.
Mechanical replacement of the 3-line heap object creation pattern:
  ref = make_ref()
  Heap.put_obj(ref, data)
  {:obj, ref}
with:
  Heap.wrap(data)

Across interpreter.ex, runtime.ex, builtins.ex, object.ex,
typed_array.ex, date.ex, json.ex, array.ex.

Remaining make_ref calls (54) are cases where the ref is captured
in closures or used after creation and can't be simplified.

733 tests pass in both modes, 0 warnings.
…to_list

register_builtin(name, constructor, opts):
- Single function replaces register_error_builtin, register_date_statics,
  register_promise_statics, register_symbol_statics
- Accepts statics: [...] and prototype: %{...} options
- Error types, Symbol, Promise, Date all use the same pattern now

Opcodes:
- Replace 40 lines of @bc_tag_X N / def bc_tag_X boilerplate with
  a @bc_tags map and for comprehension that generates all functions

Date setters:
- Replace 7 copy-paste setFullYear/Month/Date/Hours/Minutes/Seconds
  with single set_date_field(this, field, value) helper

Heap.to_list:
- Used in set_constructor to replace inline coerce-to-list block

date_statics():
- Extracted Date.UTC/parse/now registration from inline anonymous
  functions in register_date_statics into a clean data function

733 tests pass in both modes, 0 warnings.
Runtime.js_to_string: now a single-clause delegation to
Values.to_js_string. Removed 25 lines of duplicated conversion
logic that differed from Values in subtle ways.

StringProto.index_of: replaced :binary.match (byte offsets) with
String.slice + String.split (char offsets). Fixes incorrect results
for multibyte UTF-8 strings like '€abc'.indexOf('a').

733 tests pass in both modes, 0 warnings.
…eys, bytecode with fix

PD access:
- All Process.get/put in interpreter.ex routed through Heap
  (persistent_globals, promise_waiters, atoms)
- Added Heap.get/put/delete_promise_waiters
- 0 direct Process calls remain in runtime.ex, 0 in interpreter.ex

Conversion consolidation:
- Runtime.to_number delegates to Values.to_number with BigInt guard
  (Values throws TypeError for BigInt, Runtime returns the number)
- Runtime.js_to_string was already consolidated in previous commit

InternalKeys module:
- Central registry for all __dunder__ string constants
- internal?/1 predicate for filtering

Bytecode:
- Replaced with anti-pattern (true <- expr || error) with
  validate_version/1 helper

Frame:
- Documented unused stack_size slot in tuple layout

733 tests pass in both modes, 0 warnings.
New Dispatch module (interpreter/dispatch.ex):
- call_builtin(fun, args, this): single dispatch for all builtin
  callback patterns ({:builtin, _, cb}, {:bound, _, _}, plain fns)
- callable?(val): single predicate for function type checks

Replaced dispatch in:
- call_function, call_method, tail_call, tail_call_method (interpreter)
- invoke (interpreter)
- typeof_is_function (interpreter)
- call_builtin_callback (runtime)

Callback dispatch copies: 19 → 12 (remaining are in call_constructor
and invoke_callback with special semantics).

733 tests pass in both modes, 0 warnings.
interpreter/promise.ex (232 lines):
  make_resolved_promise, make_rejected_promise, make_then_fn,
  make_catch_fn, drain_microtask_queue, resolve_promise,
  generator_next, generator_return, yield_result, done_result

interpreter/generator.ex (131 lines):
  invoke_generator, invoke_async_generator, invoke_async

interpreter.ex: 2802 → 2460 lines (-342).
Thin delegation functions in interpreter.ex forward to the new modules.
Public run_frame/4 and invoke_callback/2 added for cross-module access.

733 tests pass in both modes, 0 warnings.
@dannote dannote changed the title BEAM-native JS interpreter BEAM-native JS engine and compiler Apr 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant