Skip to content

BEAM-native JS engine and compiler#5

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

BEAM-native JS engine and compiler#5
dannote wants to merge 432 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 dannote force-pushed the beam-vm-interpreter branch from a3ae334 to 7ee5139 Compare April 19, 2026 08:59
dannote added 26 commits April 19, 2026 13:45
- strip_exports/1 parses with OXC, extracts inner declarations
- Import removal: OXC AST filters out :import_declaration nodes
- No more String.replace("export ", "") or ~r/^import .*/
- Removed @assert_js_path — assert.js loaded via strip_exports in setup
Skip list: 21 → 7 (4 source-position, 3 QuickJS C engine bugs)

- Eval helpers and stubs into runtime separately so test function body
  keeps its original line numbers (fixes test isolation)
- Stubs (gc, os, qjs) condensed to single line to minimize position shift
- All 5 language tests now pass (test_reserved_names, test_syntax, etc.)
- 9 builtin tests now pass (test_weak_map, test_proxy_iter, test_rope, etc.)

Remaining skips:
- 4 source-position tests: require original file layout, line numbers
  shift when functions are extracted and evaluated individually
- test_cur_pc/test_eval/test_array: QuickJS NIF engine limitations
  (defineProperty on arrays, eval var scoping)
- gc, os, qjs defined as proper builtins in Runtime.Globals (beam mode)
- Test setup registers them via QuickBEAM.eval for NIF mode compatibility
- Removed @stubs_js inline JS string
Fixed tests:
- parseInt('0xff', 16): strip 0x prefix when radix is 16
- ArrayBuffer byteLength: fix constructor arity (/1 → /2)
- 'hello'['length']: add string-key clause to Objects.get_element
- Array.flat nested objects: convert_beam_value now recurses into plain lists

2 remaining pending_beam:
- let-scoped closure in for loop (per-iteration binding not implemented)
- Nested forEach closure mutation (deep closure capture issue)
close_loc: implement per-iteration cell snapshotting for let-scoped
loop variables. Creates a fresh cell with the current value, so closures
from the previous iteration keep the frozen value. Also fix put_loc_check
to write through to closure cells (was only updating locals tuple).

build_closure: handle closure_type 2 (grandparent scope). When an inner
function captures a variable from its grandparent via the parent's
var_refs, read directly from var_refs[var_idx] instead of locals[var_idx].
This fixes nested forEach where the innermost callback captures a variable
from two scopes up.

All 6 pending_beam tests now pass. Zero remaining.
Thread :filename option through QuickBEAM.eval → Runtime.eval → NIF.
The QuickJS NIF already accepted a filename parameter but it was always
passed as empty string. Now tests can pass the original JS filename.

Pad test function bodies with newlines to preserve original line numbers
when evaluating extracted functions individually.

Skip list: 21 → 3 (test_cur_pc, test_eval, test_array — QuickJS C bugs)
@skip_language is empty. 51 JS engine tests pass.
Reflect.apply, Error.captureStackTrace, Object.getOwnPropertySymbols,
FinalizationRegistry stub, WeakMap/Set key validation

Beam mode JS engine tests: 17 → 21 passing (test_weak_map, test_weak_set,
test_exception_capture_stack_trace_filter, test_generator now pass)
- Scope.resolve_atom: safely stringify unknown atom tuples instead of
  crashing with Protocol.UndefinedError
- FinalizationRegistry: return object with register/unregister methods
  (no-op stubs, proper GC finalization requires BEAM-level integration)
eval syntax error throwing, eval_code SyntaxError propagation

- Add String.fromCodePoint static method
- parseFloat('Infinity') now returns :infinity (was :nan)
- for_in_start checks Proxy handler's ownKeys trap
- eval of invalid syntax now throws SyntaxError instead of returning undefined
- eval opcode wraps eval_code in catch_js_throw for proper try/catch

Beam mode JS engine: 22→26 passing (4 more tests)
…numerability

- String.match with global regex now returns all matches (was only first)
- for-in now walks prototype chain to include inherited enumerable properties
- Object.prototype methods marked non-enumerable via property descriptors
- constructor property filtered from for-in prototype enumeration

These fixes don't flip full JS engine tests because each test has
multiple assertions, but they fix the underlying issues that many
tests depend on.
…() from helpers

- new Function('a','b','return a+b') now works via eval compilation
- Reflect.apply throws TypeError when args is undefined (per spec)
- call_constructor allows function return values (not just objects)
- Exclude test() runner from JS engine test helpers (was causing garbled output)
fix predefined atom fallback serialization

- Error.captureStackTrace() with no args now throws TypeError (was crash)
- Added Error.prepareStackTrace static property
- Implement apply_eval opcode (used by new Function with eval)
- Fix PredefinedAtoms fallback: return string instead of tuple (was crash)
Iterative proxy unwrapping instead of single-level check.
Handles the test_proxy_is_array test case which creates 331,072
nested proxies and checks Array.isArray. Throws RangeError at 500K depth.
…h opcode

- fn.bind() now preserves original function name ('bound f' not 'bound ')
- Property.get_own handles {:bound, _, _} → delegates to Function.proto_property
- get_length opcode handles {:bound, len, _}
- parseFloat handles Infinity prefix (Infinity1, etc.)
…properties

- JSON.stringify: support replacer array (filter keys) and space parameter
  (indentation with proper colon spacing)
- Symbol + number/string: throw TypeError with properly wrapped error object
- Catch Symbol TypeError in :add opcode when catch_stack is active
- Fix bound function name to include original function name ('bound f')
Replace hand-rolled string manipulation (indent_json, add_colon_space,
indent_lines) with Jason.encode/2 which handles pretty-printing,
indentation, and key ordering via Jason.OrderedObject.

Parsing still uses OTP :json.decode (fastest available).
Encoding uses Jason for its mature pretty-printing support.
…r fn + toJSON

- throw_error: decode atom_idx + error_type args, create proper error objects
- delete on null/undefined now throws TypeError (was returning true)
- JSON.stringify: support replacer functions (filter keys by callback)
- JSON.stringify: support toJSON method on objects
QuickJS throw_error opcodes encode reason codes, not error type indices:
0=TypeError (read-only), 1=SyntaxError (redecl), 2=ReferenceError (TDZ),
3=ReferenceError (delete super), 4=TypeError (iterator throw)
dannote added 21 commits April 23, 2026 11:42
Addition (Values.add):
- new String('1') + 1 now correctly returns '11' instead of 2
- Objects are coerced via ToPrimitive before the string-or-number check,
  matching the ES spec for the addition operator
- Only calls to_primitive for {:obj, _} values to avoid overhead on
  primitives (no perf regression for the common int/string paths)

make_loc_ref/make_var_ref/make_arg_ref/make_var_ref_ref:
- Fixed pattern match to accept 2-arg form [idx | _] from atom_u16
  decoder (was [idx] which silently failed to match)
Same atom_u16 2-arg fix as the compiler, plus implement the missing
make_var_ref_ref opcode (122) in the interpreter. This opcode creates
a cell reference to a closure variable, needed by with-statement
scope chains.
Bitwise operations:
- bnot(BigInt) returns {:bigint, -(n+1)} instead of truncating to int32
- shr (>>>) throws TypeError for BigInt (per spec)
- shl with negative BigInt shift does right-shift
- All bitwise ops (band/bor/bxor/shl/sar/shr) throw TypeError for
  mixed BigInt/Number operands
- All bitwise ops call to_numeric on object operands (for Object(1n))

Arithmetic:
- sub/mul/div/mod throw TypeError for mixed BigInt/Number
- All arithmetic ops call to_numeric on object operands
- add recursively re-dispatches after to_primitive (handles
  Object(1n) + Object(2n) = 3n)
- Renamed div to js_div to avoid Kernel.div conflict

Comparisons:
- lt/lte/gt/gte handle BigInt vs Number cross-comparison
- BigInt vs String comparison via Integer.parse
- NaN comparisons with BigInt return false

Increment/Decrement:
- inc/dec/post_inc/post_dec/inc_loc/dec_loc handle BigInt directly
  ({:bigint, n+1}) without going through add/sub

Interpreter:
- make_var_ref_ref opcode (122) implemented
- make_loc_ref/make_arg_ref fixed for atom_u16 2-arg decoder format

test262: 628 → 517 failures (111 tests fixed)
- Use QuickBEAM.start() (with APIs) instead of apis: false, giving
  tests access to the proper global object where this.x = val works
- Pass test source directly to eval instead of wrapping in IIFE,
  so `this` at top level refers to the global object
- Regenerate skip list with matching eval semantics

test262: 517 → 508 failures
to_number({:obj, _}) now calls to_primitive first (which tries valueOf
then toString on the prototype chain), then converts the result to a
number. This fixes comparison operators (>, <, >=, <=) for objects
with custom toString but no valueOf.

Remaining test262 gap: 508 failures. Major root causes:
- Global scope mutation not propagating from toPrimitive callbacks (43)
  The interpreter's functional architecture doesn't propagate mutable
  state from type coercion callbacks back to the caller's scope.
- with statement scope chain + global this (42)
- Error constructor identity in compiled closures (58)
- new expression edge cases (27)
- instanceof Symbol.hasInstance (22)
…andler

make_loc_ref, make_arg_ref, make_var_ref_ref use atom_u16 format which
decodes to [atom_idx, var_idx]. The SECOND arg is the variable slot
index, not the first (which is the variable name atom). Fixed both
interpreter and compiler to use the correct argument.

make_var_ref (opcode 123) uses :atom format which decodes to [atom_idx]
only — it looks up the variable by NAME in global scope. Added handler
in both interpreter (GlobalEnv.get by name) and compiler
(RuntimeHelpers.make_var_ref).

Also fixed if/else ambiguity warnings in BigInt comparison clauses.

This fixes 45 :badarg crashes from accessing locals with invalid
indices, and 21 unimplemented_opcode errors for make_var_ref.

test262: 508 → 506 failures (with many more now passing that were
previously crashing with :badarg)
make_var_ref in the interpreter was calling the private GlobalEnv.get/4.
Changed to use Map.get on ctx.globals directly. Also added
RuntimeHelpers.make_var_ref for the compiler path.
Crashes:
- put_element, define_property (3 locations), names.ex: replaced
  Kernel.to_string with Values.stringify for JS value keys that may
  be tuples ({:symbol, _}, {:obj, _}, etc.)
- delete error message: stringify key before interpolation

Infinite loop:
- add({:obj, _}, _) could loop infinitely when to_primitive returns
  the object. Now falls through to stringify for unconvertible objects.
- shr({:obj, _}) used to_primitive instead of to_numeric.

test262: 506 → 496 failures (10 more passing)
- numeric_compare: handle :infinity/:neg_infinity atoms properly
  in all comparison operators (lt, lte, gt, gte)
- safe_arith: wrap float arithmetic to catch ArithmeticError from
  BEAM float overflow (BEAM doesn't support IEEE infinity floats)
- Add Number.MAX_VALUE (1.7976931348623157e+308) constant
- Fix add/sub/mul to use safe_arith wrapper

test262: 506 → 464 failures (42 more passing)
new operator:
- Throw TypeError for non-constructable values (true, 1, 'str',
  null, undefined). Previously returned empty object.
- Check for generators/async generators (not constructable)

instanceof operator:
- Throw TypeError when right-hand side is not callable
- Throw TypeError when constructor.prototype is not an object
  (e.g. F.prototype = 42)
- Fixes S11.2.2_A3/A4 and S15.3.5.3_A2 test families

test262: 464 → 448 failures (16 more passing)
Allow {:obj, _} as RHS of instanceof (for proxy-like objects).
Throw different errors for non-object RHS vs non-object prototype.
Fix match? usage in case clause (not allowed in guard position).
- get_own for {:builtin, name, _} now returns the builtin's name
  for the 'name' property. Fixes ReferenceError.name, TypeError.name
  etc. returning empty string.
- instanceof: allow {:obj, _} RHS, differentiate error messages
  for non-callable RHS vs non-object prototype.

test262 error messages now show actual constructor names instead of
empty strings in assert.throws failures.
…tring

Iterator:
- for-of/destructuring on null throws TypeError with proper message
- for-of/destructuring on undefined throws TypeError
- for-of on non-iterable values throws TypeError instead of silently
  using empty iterator

Function.prototype.toString:
- Returns source code for closures/bytecode functions
- Returns 'function name() { [native code] }' for builtins

test262: 451 → 444 failures (7 more passing)
Route toPrimitive valueOf/toString calls through Invocation instead
of Interpreter directly. Invocation properly manages context save/
restore and globals refresh after callbacks.
BigInt values now support .toString() returning the decimal string
representation and .valueOf() returning the BigInt value. Fixes
template literals and string coercion for BigInt values (e.g.
\`${1n}n\` in test262 harness assert._formatIdentityFreeValue).
Symbol.toPrimitive:
- to_primitive now checks @@toPrimitive on the object first (per spec
  7.1.1 ToPrimitive), before falling back to valueOf/toString

BigInt methods:
- (1n).toString() returns decimal string
- (1n).valueOf() returns the BigInt value

test262: 444 → 445 (1 regression in symbol coercion error identity)
get_or_create_prototype now sets __proto__ to Object.prototype on
auto-created function prototypes. This ensures objects created via
`new F()` inherit toString/valueOf from Object.prototype through
the prototype chain: obj -> F.prototype -> Object.prototype.

Also: Symbol.toPrimitive support in to_primitive, BigInt.toString/
valueOf methods, Function.prototype.toString.

test262: 445 → 443 (2 more passing: this/instanceof tests)
- Infinity == Infinity now returns true (was false because :infinity
  atom didn't match is_number guard)
- NaN == anything returns false (explicit clause)
- 0n == '' returns true (empty string treated as 0 for BigInt comparison)
- BigInt string comparison trims whitespace

test262: 443 → 439 (4 more passing)
mod now calls to_number on non-numeric operands before computing.
true % true = 0, null % 1 = 0, '1' % '1' = 0 now work correctly.

Also added numeric_mod helper for infinity/NaN/zero cases:
- x % ±Infinity = x (not NaN)
- ±Infinity % x = NaN
- x % 0 = NaN

test262: 439 → 430 (9 more passing)
- safe_mul: determines overflow sign from operand signs
  (-1.1 * MAX_VALUE → neg_infinity, not infinity)
- safe_add: determines overflow sign from operand signs
- add/sub for numbers now use safe_add to handle overflow
- div_inf: Infinity / 0 → Infinity (was NaN because 0 didn't match
  the n > 0 guard)

test262: 430 → 427 (3 more passing)
BigInt comparisons:
- 1n < true, 0n < true, 1n > false etc. now work by coercing
  booleans to numbers before comparing with BigInt
- Added boolean clauses for lt/lte/gt/gte with BigInt

Equality:
- Infinity == Infinity is true (atoms match)
- NaN == anything is false (explicit clauses)
- 0n == '' is true (empty string → 0)

Float overflow:
- safe_mul determines overflow sign from operand signs
- safe_add determines overflow sign from operand signs
- Infinity / 0 returns Infinity (not NaN)

test262: 430 → 425 (5 more passing from this batch)
dannote added 8 commits April 23, 2026 20:53
- has_property now checks prototype chain via Get.get fallback
  (fixes 'toString' in {}, 'valueOf' in {}, 'MAX_VALUE' in Number)
- has_property added for {:builtin, _, _} values
- 'in' operator throws TypeError for non-object RHS
  (fixes 'x' in true, 'x' in 42, etc.)

test262: 425 → 420 (5 more passing)
typeof:
- {:builtin, _, map} when is_map(map) returns 'object' instead of
  'function'. Fixes typeof Math === 'object', typeof JSON === 'object'.
  Callable builtins (functions) still return 'function'.

new:
- {:builtin, _, map} namespace objects (Math, JSON) throw TypeError
  when used with 'new'. Only callable builtins can be constructors.

test262: 420 → 418 (2 more passing)
When valueOf/toString throws during type coercion (e.g.
{valueOf: function(){throw 'x'}} & 1), the JS throw must be
caught by the interpreter's try/catch handling, not propagated
through the Elixir call stack.

Previously only op_add had a catch_js_throw wrapper. Now ALL
operators that can trigger toPrimitive coercion are wrapped:
- Arithmetic: add, sub, mul, div, mod, pow
- Bitwise: band, bor, bxor, shl, sar, shr, bnot
- Comparison: lt, lte, gt, gte, eq, neq
- Unary: neg, plus (to_number)

This fixes 14 test262 tests where valueOf/toString throws inside
try/catch blocks.

test262: 418 → 404 (14 more passing)
…t32 for objects

- lt/lte/gt/gte: handle BigInt vs :infinity/:neg_infinity/:nan
- abstract_eq: BigInt vs boolean coercion (0n == false → true)
- to_int32/to_uint32: call to_number for {:obj, _} and handle infinity/NaN

test262: 404 → 400
- typeof :neg_infinity returns 'number' (was falling to 'object')
- isNaN: convert non-number args via to_number before checking
  (isNaN(true) now correctly returns false)
- isFinite: same to_number coercion for non-number args
- BigInt vs infinity/NaN: explicit comparison clauses for all operators
- BigInt vs boolean: abstract_eq handles 0n == false → true
- to_int32/to_uint32: call to_number for {:obj, _} values

test262: 404 → 398 (6 more passing from batched fixes)
- truthy?(-0.0) returns false (Elixir distinguishes +0.0 from -0.0
  in pattern matching)
- op_truthy inline helper also handles -0.0
- isNaN: converts non-number args via to_number before checking
- isFinite: same to_number coercion

test262: 400 → 397
- Register Function constructor with auto_proto: true, creating
  Function.prototype with __proto__ → Object.prototype
- Fixes 'MyFunct instanceof Function' and similar checks
- Only Function gets auto_proto (Boolean/Number/String cause
  regressions due to __proto__ interference with property resolution)

Also includes: typeof :neg_infinity, -0.0 falsy, isNaN/isFinite
coercion, BigInt vs infinity/NaN comparisons.

test262: 404 → 396 (8 more passing from all batched fixes)
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