Skip to content

BEAM-native JS engine and compiler#5

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

BEAM-native JS engine and compiler#5
dannote wants to merge 560 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 2 times, most recently from 75fdba5 to 527d5b9 Compare April 20, 2026 08:45
@dannote dannote changed the title BEAM-native JS interpreter BEAM-native JS engine and compiler Apr 21, 2026
dannote added 30 commits April 24, 2026 16:17
Replace call_callback (which swallows {:js_throw,_}) with
invoke_callback_or_throw (which lets throws propagate) in:
- for_of_start: Symbol.iterator call
- for_of_next: next() call
- iterator_next: next(val) call
- collect_iterator: spread operator iteration

This ensures errors thrown by custom iterators (Symbol.iterator
function, next() method) are properly propagated to the catch
handler instead of being silently swallowed.

test262: 103 → 100 (3 tests fixed, 6 total this session)
93.7% pass rate (1,482 / 1,582)
collect_iterator (spread operator) needs call_callback's error
swallowing — spread on undefined is valid JS behavior. Only
for_of_start, for_of_next, and iterator_next should propagate
iterator throws.

test262: 100 → 98 (2 more fixed, net 8 fixed this session from
both shape key sort + iterator error propagation)
93.8% pass rate (1,484 / 1,582)
1. op_append (spread in function args): use invoke_callback_or_throw
   for Symbol.iterator call instead of call_callback (which swallows).

2. Handle accessor descriptors on Symbol.iterator: when the iterator
   property is defined via Object.defineProperty with a getter, invoke
   the getter before calling the iterator function.

3. Same accessor handling in for_of_start.

test262: 98 → 97 (1 test fixed, 9 total this session)
93.9% pass rate (1,485 / 1,582)
Per spec 7.4.1 GetIterator step 3: 'If Type(iterator) is not Object,
throw a TypeError exception.'

When Symbol.iterator returns null, undefined, or a primitive,
throw TypeError instead of silently proceeding.

test262: 97 → 96 (1 test fixed, 10 total this session)
93.9% pass rate (1,486 / 1,582)
Throw TypeError per spec 7.4.1 step 3 when Symbol.iterator
returns a non-object value in for_of_start (matching the same
check already in op_append for spread).

test262: 96 failures (unchanged), 93.9% pass rate
Catch entries pushed during finally block execution (via gosub)
now get trimmed when ret returns. This prevents stale catch
entries from leaking to the outer scope and causing nested
try-catch-finally to re-dispatch to the wrong handlers.

Uses a catch_depth counter in Context to avoid expensive
length() calls on every ret — only trims when mismatch detected.

test262: 96 → 95 (S12.14_A7_T3.js fixed — nested try/finally)
93.9% pass rate (1,487 / 1,582)
Save catch_stack reference only when non-empty (common case is
empty catch_stack). Compare by Erlang term identity (===) in
ret for O(1) fast path.

test262: 95 failures (11 fixed this session, 94.0% pass rate)
1. enumerable_string_props: resolve accessor descriptors to
   getter return values when copying from shape-backed objects.

2. gosub/ret catch_stack scoping: save reference only when
   non-empty, compare by identity for O(1) fast path.

test262: 95 failures (11 fixed this session, 94.0% pass rate)
1,487 / 1,582 non-skipped tests passing
The interpreter fallback for closure callbacks works for closure
variable mutation but doesn't help test262 because the failing
tests have closure/global variable propagation issues where
closure cell writes don't sync to global scope variables.

test262: 95 failures (unchanged). Remaining failures require:
- Closure/global variable propagation (~13 tests)
- Compiled try-catch-finally fix (2 tests)
- Array.prototype[Symbol.iterator] override (8 tests)
- Closure identity in instanceof (4 tests)
- Unicode surrogate comparison (4 tests)
- Private fields (2 tests)
- Spread with value getter timeout (2 tests)

94.0% pass rate (1,487 / 1,582)
Closure side effects in iterator callbacks (return/next) now
propagate to global scope. After each callback, merge persistent
globals into ctx.globals.

Optimization: only merge when persistent globals is non-empty
(map_size check). This avoids overhead for the common case
(Preact SSR benchmark has no persistent globals during iteration).

test262: 95 → 93 (2 tests fixed, 13 total this session)
94.1% pass rate (1,489 / 1,582), no perf regression.
Rest spread with getters ({...x} from objects with get accessors)
invokes getters that may modify closure-captured globals. Refresh
persistent globals after copy_data_properties to propagate these
side effects.

test262: 93 → 91 (2 more, 15 total this session)
94.2% pass rate (1,491 / 1,582)
Defensive fix: compiled path for_of_start now throws TypeError
for null, undefined, and non-iterable values instead of silently
returning empty iterator.

test262: 91 failures (unchanged but correctness improved)
94.2% pass rate (1,491 / 1,582)

Session total: 15 tests fixed (106 → 91)
Add default Symbol.iterator to Array.prototype. In for_of_start,
check if the array's prototype iterator has been deleted or
overridden before using the built-in fast path.

- Deleted: throw TypeError per spec
- Overridden: use custom iterator
- Default: use built-in list iterator (fast path)

Also add Heap.wrap_iterator for creating JS-visible iterator
objects and Heap.get_array_proto for prototype chain lookup.

test262: 91 → 87 (4 tests fixed)
94.5% pass rate (1,495 / 1,582)
test262: 87 failures (94.5%), 82s runtime
Session progress: 91 → 87 = 4 tests fixed
Array.prototype[Symbol.iterator] fix resolved the iter-get-err tests.
try/finally double-execution investigation ongoing.

94.5% pass rate (1,495 / 1,582)
Fix catch_js_throw and catch_js_throw_refresh_globals to use
explicit try/catch around fun.() only, preventing the function-
level catch from intercepting throws from run(pc+1,...) continuation.

This is a correctness fix that prevents accidental catch of throws
from subsequent opcode execution. Combined with the Array.prototype
Symbol.iterator fix, resolves 4 additional tests.

test262: 87 failures (94.5% pass rate, 1,495 / 1,582)
Separate Put.put from run(pc+1,...) in put_field's try/catch block.
Previously, the try block wrapped both the operation AND the
continuation, causing throws from subsequent opcodes to be caught
with a stale ctx and re-dispatched via throw_or_catch. This caused
the finally block to execute twice when the catch body re-threw.

Fix: wrap only Put.put in try, use :ok/{:throw,error} result tuple
for branching. The continuation run(pc+1,...) executes AFTER the
try block, so subsequent throws propagate naturally to the function-
level catch_js_throw_refresh_globals handler.

test262: 87 → 85 (2 tests fixed: completion-values-fn-finally)
94.6% pass rate (1,497 / 1,582)
Custom iterators on objects now get correct 'this' binding when
called from for..of loops. Fixes non-generator custom iterators.
Generator this binding is a separate issue.

Also fix invoke_custom_iterator in both interpreter and compiled
runtime_helpers to use invoke_with_receiver.

test262: 85 failures (94.6% pass rate, 1,497 / 1,582)
Compiled path's compiled_gen_invoke doesn't preserve 'this' in
generator continuations. Fix: compiled_method_callable? only
returns true for func_kind=0 (regular functions), forcing
generators/async to use the interpreter path which correctly
passes 'this' through the context.

test262: 85 → 81 (4 tests fixed: all iter-val-array-prototype)
94.9% pass rate (1,501 / 1,582)
1. Bytecode decoder: encode lone surrogates (U+D800-U+DFFF) as
   CESU-8 (3-byte) instead of returning error tuples. This allows
   JS strings with unpaired surrogates to be stored as binaries.

2. String comparison: implement utf16_compare that converts strings
   to UTF-16 code unit sequences for comparison. JS compares by
   UTF-16 code units, not UTF-8 bytes. Supplementary characters
   (U+10000+) encode as surrogate pairs (D800-DBFF) which sort
   BEFORE BMP chars in UTF-16 but AFTER in UTF-8.

3. Fast path: strings without bytes >= 0xED use direct binary
   comparison (UTF-8 and UTF-16 give same order for BMP-only).

test262: 81 → 77 (4 tests fixed: all surrogate comparison tests)
95.1% pass rate (1,505 / 1,582)
1. instanceof: use Get.get for Symbol.hasInstance (invokes accessor
   getters from defineProperty). Use catch_js_throw_refresh_globals
   for globals propagation. Check {:obj,_} callable via 'call' property.

2. Unicode: CESU-8 encoding for lone surrogates + UTF-16 code unit
   comparison for strings with supplementary characters.

3. Generator this: compiled_method_callable only for func_kind=0
   (regular functions), generators use interpreter path.

4. Function.prototype.constructor set during globals init.

5. Array.prototype[Symbol.iterator]: check for deletion/override,
   invoke_with_receiver for this binding, separate put_field try/catch.

test262: 91 → 75 (16 tests fixed this session)
95.3% pass rate (1,507 / 1,582)
When closures have accessor properties set via Object.defineProperty
(stored in ctor_statics), get_own now invokes the getter instead of
returning the raw {:accessor, ...} tuple.

test262: 75 failures (95.3% pass rate, 1,507 / 1,582)
Use Get.get instead of raw Map.get for reading 'done' and 'value'
from iterator result objects. This properly invokes accessor getters
set via Object.defineProperty on iterator results.

test262: 75 → 73 (2 tests fixed: spread-err-*-itr-value timeout)
95.4% pass rate (1,509 / 1,582)
Values.shr matched shr(_, {:bigint, _}) before shr({:obj, _}, b),
causing TypeError to fire before ToPrimitive could throw the
correct error. Move object coercion clauses before BigInt clauses.

test262: 73 → 72 (bigint-toprimitive.js fixed)
95.4% pass rate (1,510 / 1,582)
Result: {"status":"keep","failures":72,"passing_tests":1510,"skipped_tests":912}
…ch handlers, extract for_of_start throws to throw_or_catch

Result: {"status":"keep","failures":68,"passing_tests":1514,"skipped_tests":912}
Result: {"status":"keep","failures":68,"passing_tests":1514,"skipped_tests":912}
…ith configurable:false

Result: {"status":"keep","failures":67,"passing_tests":1515,"skipped_tests":912}
…port correct constructor identity

Result: {"status":"keep","failures":66,"passing_tests":1516,"skipped_tests":912}
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