Skip to main content
The Arc VM is a stack-based bytecode interpreter that executes compiled JavaScript. It implements ES2023+ semantics including generators, async/await, promises, and exception handling.

VM architecture

Location: src/arc/vm/vm.gleam (5000+ lines) The VM operates as a classic stack machine with heap-allocated objects:

VM state

pub type State {
  State(
    // Execution state
    stack: List(JsValue),              // Operand stack
    locals: Array(JsValue),            // Local variable slots
    constants: Array(JsValue),         // Constant pool
    pc: Int,                           // Program counter
    code: Array(Op),                   // Bytecode array
    func: FuncTemplate,                // Current function template
    
    // Call stack
    call_stack: List(SavedFrame),      // Saved frames for returns
    
    // Exception handling
    try_stack: List(TryFrame),         // Catch handlers
    finally_stack: List(FinallyCompletion),  // Finally blocks
    
    // Memory
    heap: Heap,                        // Object heap
    
    // Globals
    global_object: Ref,                // globalThis reference
    lexical_globals: Dict(String, JsValue),  // let/const globals
    const_lexical_globals: Set(String),      // Const global names
    
    // Built-ins
    builtins: Builtins,                // Object, Array, etc.
    
    // Runtime context
    this_binding: JsValue,             // Current 'this'
    callee_ref: Option(Ref),           // Current function ref
    call_args: List(JsValue),          // Current call arguments
    
    // Async/promises
    job_queue: List(PromiseReaction),  // Microtask queue
    
    // Symbols
    symbol_descriptions: Dict(SymbolId, String),
    symbol_registry: Dict(String, SymbolId),
    
    // Re-entrant helpers
    js_to_string: fn(State, JsValue) -> Result(...),
    call_fn: fn(State, JsValue, JsValue, List(JsValue)) -> Result(...)
  )
}

Execution model

The VM executes bytecode in a tail-recursive loop:
fn execute_inner(state: State) -> Result(#(Completion, State), VmError) {
  case array.get(state.pc, state.code) {
    None -> {
      // End of bytecode — return top of stack or undefined
      case state.stack {
        [top, ..] -> Ok(#(NormalCompletion(top, state.heap), state))
        [] -> Ok(#(NormalCompletion(JsUndefined, state.heap), state))
      }
    }
    Some(op) -> {
      case step(state, op) {
        Ok(new_state) -> execute_inner(new_state)  // Continue
        Error(#(Done, value, heap)) -> // Normal return
        Error(#(Thrown, value, heap)) -> // Uncaught exception
        Error(#(Yielded, value, heap)) -> // Generator yield
        Error(#(VmError(err), _, _)) -> // VM bug
      }
    }
  }
}
1

Fetch

Read opcode at state.code[state.pc]
2

Decode

Pattern match on opcode variant
3

Execute

Perform operation (update stack, heap, pc, etc.)
4

Loop

Tail-recurse with new state

Completion types

pub type Completion {
  NormalCompletion(value: JsValue, heap: Heap)
  ThrowCompletion(value: JsValue, heap: Heap)
  YieldCompletion(value: JsValue, heap: Heap)
}
  • NormalCompletion: Execution finished successfully
  • ThrowCompletion: Uncaught exception (no catch handler)
  • YieldCompletion: Generator suspended (yield or await)

Stack machine operations

The VM implements 80+ opcodes covering all ES2023+ semantics.

Stack manipulation

PushConst(index) -> {
  case array.get(index, state.constants) {
    Some(value) ->
      Ok(State(
        ..state,
        stack: [value, ..state.stack],
        pc: state.pc + 1
      ))
  }
}

Variable access

GetLocal(index) -> {
  case array.get(index, state.locals) {
    Some(JsUninitialized) -> {
      // TDZ violation
      throw_reference_error(state, "Cannot access variable before initialization")
    }
    Some(value) -> Ok(State(
      ..state,
      stack: [value, ..state.stack],
      pc: state.pc + 1
    ))
  }
}

Binary operations

BinOp(kind) -> {
  case state.stack {
    [right, left, ..rest] -> {
      case exec_binop(kind, left, right) {
        Ok(result) -> Ok(State(
          ..state,
          stack: [result, ..rest],
          pc: state.pc + 1
        ))
        Error(msg) -> throw_type_error(state, msg)
      }
    }
  }
}

fn exec_binop(kind: BinOpKind, left: JsValue, right: JsValue) -> Result(JsValue, String) {
  case kind {
    Add -> {
      case left, right {
        JsString(a), _ | _, JsString(b) -> {
          // String concatenation
          let a_str = to_string(left)
          let b_str = to_string(right)
          Ok(JsString(a_str <> b_str))
        }
        _, _ -> {
          // Numeric addition
          case to_number(left), to_number(right) {
            Ok(a), Ok(b) -> Ok(JsNumber(num_add(a, b)))
          }
        }
      }
    }
    Sub | Mul | Div | Mod -> // Numeric ops
    Eq | StrictEq | NotEq | StrictNotEq -> // Equality
    Lt | Gt | LtEq | GtEq -> // Relational
    // ... 20+ binary operators
  }
}

Control flow

Jump(target) -> Ok(State(
  ..state,
  pc: target
))

Function calls

Call(arity) -> {
  // Stack: [arg_n, ..., arg_1, callee, ...rest]
  case pop_n(state.stack, arity) {
    Some(#(args, [JsObject(callee_ref), ..rest])) -> {
      case heap.read(state.heap, callee_ref) {
        Some(ObjectSlot(
          kind: FunctionObject(func_template:, env:),
          ..
        )) -> {
          call_function(
            state,
            callee_ref,
            env,
            func_template,
            args,
            rest,
            JsUndefined,  // this = undefined (non-method call)
            None,         // constructor_this
            None          // new_target
          )
        }
        Some(ObjectSlot(kind: NativeFunction(native), ..)) -> {
          call_native(state, native, args, rest, JsUndefined)
        }
      }
    }
  }
}
Call function logic:
1

Save current frame

let saved_frame = SavedFrame(
  func: state.func,
  locals: state.locals,
  stack: rest,  // Caller's stack (after popping args + callee)
  pc: state.pc + 1,
  try_stack: state.try_stack,
  this_binding: state.this_binding,
  constructor_this: None,
  callee_ref: state.callee_ref,
  call_args: state.call_args
)
2

Set up new frame

let new_locals = array.repeat(JsUndefined, func_template.local_count)

// Bind parameters
let new_locals = list.index_fold(args, new_locals, fn(locals, arg, idx) {
  array.set(idx, arg, locals)
})

// Restore captured env
let new_locals = case env {
  Some(env_ref) -> {
    case heap.read(state.heap, env_ref) {
      Some(EnvSlot(captures)) -> {
        // Captures are already boxed refs from parent
        list.index_fold(captures, new_locals, fn(locals, capture, idx) {
          array.set(idx, capture, locals)
        })
      }
    }
  }
  None -> new_locals
}
3

Enter new function

State(
  ..state,
  func: func_template,
  code: func_template.bytecode,
  constants: func_template.constants,
  locals: new_locals,
  stack: [],  // Fresh stack for new frame
  pc: 0,      // Start at beginning of function
  call_stack: [saved_frame, ..state.call_stack],
  try_stack: [],  // Fresh try stack
  this_binding: this_val,
  callee_ref: Some(callee_ref),
  call_args: args
)

Return

Return -> {
  let return_value = case state.stack {
    [value, ..] -> value
    [] -> JsUndefined
  }
  
  case state.call_stack {
    [] -> Error(#(Done, return_value, state.heap))  // Top-level return
    
    [SavedFrame(func:, locals:, stack:, pc:, try_stack:, this_binding:, ...), ..rest] -> {
      // Restore caller's frame
      Ok(State(
        ..state,
        stack: [return_value, ..stack],  // Push return value onto caller's stack
        locals: locals,
        func: func,
        code: func.bytecode,
        constants: func.constants,
        pc: pc,
        call_stack: rest,
        try_stack: try_stack,
        this_binding: this_binding
      ))
    }
  }
}

Exception handling

The VM implements JavaScript’s try/catch/finally via separate stacks:

Try stack

pub type TryFrame {
  TryFrame(
    catch_target: Int,   // PC address of catch handler
    stack_depth: Int     // Stack depth when try was entered
  )
}
1

Enter try block

PushTry(catch_target) -> {
  let frame = TryFrame(
    catch_target: catch_target,
    stack_depth: list.length(state.stack)
  )
  Ok(State(
    ..state,
    try_stack: [frame, ..state.try_stack],
    pc: state.pc + 1
  ))
}
2

Throw exception

Throw -> {
  case state.stack {
    [value, ..] -> Error(#(Thrown, value, state.heap))
  }
}
The execute_inner loop catches Thrown and calls unwind_to_catch.
3

Unwind to catch

fn unwind_to_catch(state: State, thrown_value: JsValue) -> Option(State) {
  case state.try_stack {
    [] -> None  // Uncaught exception
    
    [TryFrame(catch_target:, stack_depth:), ..rest] -> {
      // Restore stack to try-entry depth
      let restored_stack = truncate_stack(state.stack, stack_depth)
      
      Some(State(
        ..state,
        stack: [thrown_value, ..restored_stack],  // Push exception
        try_stack: rest,
        pc: catch_target  // Jump to catch block
      ))
    }
  }
}
4

Exit try block (no exception)

PopTry -> {
  case state.try_stack {
    [_, ..rest] -> Ok(State(
      ..state,
      try_stack: rest,
      pc: state.pc + 1
    ))
  }
}

Finally stack

Finally blocks execute regardless of exception or normal completion:
pub type FinallyCompletion {
  NormalCompletion
  ThrowCompletion(value: JsValue)
  ReturnCompletion(value: JsValue)
}
1

Enter finally (normal)

EnterFinally -> Ok(State(
  ..state,
  finally_stack: [NormalCompletion, ..state.finally_stack],
  pc: state.pc + 1
))
2

Enter finally (thrown)

EnterFinallyThrow -> {
  case state.stack {
    [thrown, ..rest] -> Ok(State(
      ..state,
      stack: rest,
      finally_stack: [ThrowCompletion(thrown), ..state.finally_stack],
      pc: state.pc + 1
    ))
  }
}
3

Leave finally

LeaveFinally -> {
  case state.finally_stack {
    [NormalCompletion, ..rest] -> 
      Ok(State(..state, finally_stack: rest, pc: state.pc + 1))
    
    [ThrowCompletion(value), ..] -> 
      Error(#(Thrown, value, state.heap))  // Re-throw
    
    [ReturnCompletion(value), ..] -> 
      Error(#(Done, value, state.heap))    // Resume return
  }
}

Heap management

Location: src/arc/vm/heap.gleam The heap is an immutable dictionary arena with mark-and-sweep GC:
pub opaque type Heap {
  Heap(
    data: Dict(Int, HeapSlot),   // ID → slot
    free: List(Int),             // Recycled IDs
    next: Int,                   // Next fresh ID
    roots: Set(Int)              // Persistent roots
  )
}

Heap operations

pub fn alloc(heap: Heap, slot: HeapSlot) -> #(Heap, Ref) {
  case heap.free {
    [id, ..rest] -> {
      // Recycle freed ID
      let data = dict.insert(heap.data, id, slot)
      #(Heap(..heap, data:, free: rest), Ref(id))
    }
    [] -> {
      // Bump allocate
      let id = heap.next
      let data = dict.insert(heap.data, id, slot)
      #(Heap(..heap, data:, next: id + 1), Ref(id))
    }
  }
}

Garbage collection

Trace reachable objects from roots:
fn mark_from(heap: Heap, roots: Set(Int)) -> Set(Int) {
  let frontier = set.to_list(roots)
  mark_loop(heap, frontier, set.new())
}

fn mark_loop(
  heap: Heap,
  frontier: List(Int),
  visited: Set(Int)
) -> Set(Int) {
  case frontier {
    [] -> visited
    [id, ..rest] -> {
      case set.contains(visited, id) {
        True -> mark_loop(heap, rest, visited)
        False -> {
          let visited = set.insert(visited, id)
          case dict.get(heap.data, id) {
            Ok(slot) -> {
              let child_refs = value.refs_in_slot(slot)
              let child_ids = list.map(child_refs, fn(r) { r.id })
              mark_loop(heap, list.append(child_ids, rest), visited)
            }
            Error(_) -> mark_loop(heap, rest, visited)
          }
        }
      }
    }
  }
}
Recycle unreachable slots:
fn sweep(heap: Heap, live: Set(Int)) -> Heap {
  let all_ids = dict.keys(heap.data)
  let dead_ids = list.filter(all_ids, fn(id) {
    !set.contains(live, id)
  })
  
  let data = list.fold(dead_ids, heap.data, fn(data, id) {
    dict.delete(data, id)
  })
  
  let free = list.append(dead_ids, heap.free)
  
  Heap(..heap, data:, free:)
}
GC is manual — the VM doesn’t automatically collect. Call heap.collect() explicitly when needed. Future: incremental/generational GC.

Generators and async/await

Generators

Generators are suspended functions that can yield values:
Yield -> {
  case state.stack {
    [yielded_value, ..rest] -> {
      // Return YieldCompletion with suspended state
      Error(#(Yielded, yielded_value, state.heap))
    }
  }
}
The caller receives YieldCompletion and can resume by calling the generator again with generator.next(value).

Async/await

Async functions return promises and can await other promises:
Await -> {
  case state.stack {
    [promise_value, ..rest] -> {
      // Suspend execution until promise resolves
      Error(#(Yielded, promise_value, state.heap))
    }
  }
}
The runtime enqueues a promise reaction and resumes the async function when the awaited promise settles.

Job queue (microtasks)

Promise reactions are queued as microtasks:
pub type PromiseReaction {
  PromiseReaction(
    promise: Ref,
    on_fulfilled: Option(Ref),  // Handler function
    on_rejected: Option(Ref),
    result_promise: Ref
  )
}
After each script/module execution, the VM drains the job queue:
fn drain_jobs(state: State) -> State {
  case state.job_queue {
    [] -> state
    [reaction, ..rest] -> {
      // Execute reaction
      let state = State(..state, job_queue: rest)
      let state = execute_promise_reaction(state, reaction)
      drain_jobs(state)  // Recurse until queue empty
    }
  }
}

Performance

Arc is a proof-of-concept runtime. Performance is adequate for educational use but not production workloads.
Current bottlenecks:
  • Immutable data structures: Every operation copies state (Gleam’s persistent collections)
  • No JIT: Pure bytecode interpretation
  • Heap pressure: Object allocation per operation
  • No inline caching: Every property lookup is a full traversal
Typical performance: ~100K ops/sec on modern hardware (vs. V8’s ~1B ops/sec). Future optimizations:
  • Mutable VM state (unsafe but faster)
  • Inline caching for property access
  • Primitive type specialization (tagged integers)
  • JIT for hot paths

Entry points

Run a script

import arc/vm/vm
import arc/vm/heap
import arc/vm/builtins

let heap = heap.new()
let #(heap, builtins) = builtins.init(heap)
let #(heap, global_object) = builtins.global_object(heap, builtins)

case vm.run_and_drain(template, heap, builtins, global_object) {
  Ok(vm.NormalCompletion(result, heap)) -> // Success
  Ok(vm.ThrowCompletion(thrown, heap)) -> // Uncaught exception
  Error(vm.PcOutOfBounds(pc)) -> // VM bug
}

Run a module

case vm.run_module(template, heap, builtins, global_object) {
  vm.ModuleOk(value:, heap:, locals:) -> {
    // Extract exports from locals array
  }
  vm.ModuleThrow(value:, heap:) -> // Exception
  vm.ModuleError(error:) -> // VM error
}

REPL mode

let env = vm.ReplEnv(
  global_object: global_object,
  lexical_globals: dict.new(),
  const_lexical_globals: set.new(),
  symbol_descriptions: dict.new(),
  symbol_registry: dict.new(),
  realms: dict.new()
)

case vm.run_and_drain_repl(template, heap, builtins, env) {
  Ok(#(vm.NormalCompletion(result, heap), new_env)) -> {
    // new_env persists globals for next REPL input
  }
}

Further reading

Built-ins

Native JavaScript objects and Arc namespace

Compiler

How bytecode is generated

Heap (source)

Heap implementation details

Opcodes (source)

Complete opcode reference

Build docs developers (and LLMs) love