Skip to content

Architecture Overview

Roger Johansson edited this page Jan 14, 2026 · 3 revisions

Architecture Overview

This document provides a deep-dive into the Asynkron.JsEngine execution model.


Execution Model

The engine has two execution paths:

  1. IR Execution (primary) - AST is lowered to a flat instruction sequence with explicit jumps
  2. AST Walking (fallback) - Direct recursive evaluation for functions with with/eval
flowchart LR
    subgraph Input
        JS[/"JavaScript Source"/]
    end
    
    subgraph Parsing
        JS --> Parser((Parser))
        Parser --> AST[/"AST"/]
    end
    
    subgraph Analysis
        AST --> SA((Scope Analysis))
        SA --> AAST[/"Annotated AST<br/>(slots, scopes)"/]
    end
    
    subgraph Execution
        AAST --> Decision{Has with/eval?}
        Decision -->|No| IR[["IR Execution<br/>ExecutionPlanRunner"]]
        Decision -->|Yes| Walk[["AST Walking<br/>Recursive Eval"]]
    end
    
    subgraph Output
        IR --> Result[/"JsValue Result"/]
        Walk --> Result
    end
Loading

AST Layer

AST Cache

File: Ast/AstCache.cs

AST nodes cache computed metadata to avoid repeated work using thread-safe lazy initialization:

internal static TCache GetOrCreate<TCache>(ref TCache? field, Func<TCache> factory)
{
    var existing = Volatile.Read(ref field);
    if (existing is not null) return existing;

    var created = factory();
    var prior = Interlocked.CompareExchange(ref field, created, null);
    return prior ?? created;
}

Cached data includes:

  • HoistPlan - variable hoisting analysis
  • HoistableDeclarationsPlan - function declarations to hoist
  • ExecutionPlan - lowered IR for generators/async

AST Walking Evaluation

Files: Ast/*Extensions.cs

Functions with with statements or direct eval use recursive evaluation:

internal static JsValue EvaluateForJsValue(
    this BlockStatement block,
    ref JsEnvironment environment,
    EvaluationContext context)
{
    foreach (var statement in block.Body)
    {
        var result = statement.EvaluateForJsValue(ref environment, context);
        // handle completion signals...
    }
    return result;
}

Completion Signals

File: CompletionSignals.cs

Control flow (return, break, continue, throw, yield) is modeled as typed signals rather than exceptions:

interface ICompletionSignal { }

record BreakCompletionSignal(Symbol? Label) : ICompletionSignal;
record ContinueCompletionSignal(Symbol? Label) : ICompletionSignal;
class ReturnCompletionSignal(JsValue value) : ICompletionSignal;
class ThrowFlowCompletionSignal(JsValue value) : ICompletionSignal;
class YieldCompletionSignal(JsValue value) : ICompletionSignal;
class PendingAwaitCompletionSignal : ICompletionSignal;

Why signals instead of exceptions:

  • Signals are faster than throwing/catching exceptions
  • They carry typed data (the return value, label, etc.)
  • AST walkers check for signals after each statement and propagate them

Pattern in evaluators:

var result = child.EvaluateForJsValue(ref env, ctx);
if (ctx.CompletionSignal is ReturnCompletionSignal ret)
    return ret.JsValue;
if (ctx.CompletionSignal is BreakCompletionSignal { Label: null })
    break;

IR Layer

Lowering Pipeline

Files: Execution/ExecutionPlanBuilder.cs, Execution/Emitters/*.cs

Lowering transforms nested AST into a linear instruction stream with explicit jumps:

flowchart TB
    subgraph AST["AST (nested tree)"]
        IF((if))
        IF --> COND((x))
        IF --> THEN((then))
        IF --> ELSE((else))
        THEN --> A((a))
        ELSE --> B((b))
    end
    
    subgraph IR["IR (linear instructions)"]
        I0["0: Branch(x, then=1, else=3)"]
        I1["1: Statement(a())"]
        I2["2: Jump(4)"]
        I3["3: Statement(b())"]
        I4["4: ..."]
        
        I0 --> I1
        I0 -.->|false| I3
        I1 --> I2
        I2 -.->|jump| I4
        I3 --> I4
    end
    
    AST ==>|"Lowering"| IR
Loading

Emitters handle specific AST constructs:

Emitter Handles
LoopEmitter for/while/do-while loops
ForOfEmitter for-of/for-in iteration
TryEmitter try/catch/finally
SwitchEmitter switch statements
BlockEmitter block scopes
YieldEmitter yield/yield*

ExecutionPlan

File: Execution/ExecutionPlan.cs

The result of lowering contains:

record ExecutionPlan(
    ImmutableArray<ExecutionInstruction> Instructions,
    int EntryPoint,
    int SlotCount,
    ImmutableArray<Symbol> SlotSymbols,
    int RootSlotCount,
    ImmutableDictionary<Symbol, int>? RootSlotMap,
    int FlatSlotCount,
    ImmutableDictionary<int, ImmutableArray<(int SlotIndex, int FlatSlotId)>>? FlatSlotMappings
);

InstructionKind Enum

File: Execution/Instructions/InstructionKind.cs

Enables fast dispatch (jump table) instead of type pattern matching:

enum InstructionKind : byte {
    // Statements & Expressions
    Statement, Throw, EvaluateAndDiscard, BinaryOp,
    IncrementSlot, CompoundAssignmentSlot,

    // Scopes
    PushEnvironment, PopEnvironment,

    // Generator/Async
    Yield, YieldStar, StoreResumeValue,

    // Exception Handling
    EnterTry, EnterCatch, LeaveTry, EndFinally,

    // Loops
    IteratorInit, IteratorMoveNext, IteratorClose,

    // Control Flow
    Jump, Branch, Break, Continue, Return,
    // ...40+ instruction types total
}

ExecutionPlanRunner

File: Ast/TypedAstEvaluator.ExecutionPlanRunner.cs

The interpreter for IR. Key fields:

private sealed partial class ExecutionPlanRunner
{
    private readonly ExecutionPlan? _plan;
    private int _programCounter;
    private GeneratorState _state = GeneratorState.Start;
    private JsVariable[]? _flatSlots;  // O(1) variable storage

    // Lazy-allocated state
    private TryCatchState? _tryCatchState;
    private AsyncState AsyncStateRef;
    private YieldState YieldStateRef;
}

Execution Loop

while ((uint)_programCounter < (uint)instructionsLength)
{
    var instruction = instructions[_programCounter];
    var handler = InstructionHandlers[(int)instruction.Kind];
    var result = handler(this, instruction, ref environment, context, out returnValue);

    switch (result) {
        case InstructionResult.Continue:
            _programCounter = instruction.Next;
            break;
        case InstructionResult.Return:
            return returnValue;
    }
}

Dispatch Table

Handlers stored in delegate array indexed by InstructionKind:

private static readonly InstructionHandler[] InstructionHandlers = InitializeHandlers();

private static InstructionHandler[] InitializeHandlers()
{
    var handlers = new InstructionHandler[40];
    handlers[(int)InstructionKind.Statement] = HandleStatement;
    handlers[(int)InstructionKind.IncrementSlot] = HandleIncrementSlot;
    handlers[(int)InstructionKind.Jump] = HandleJump;
    // ...
    return handlers;
}

Hot Path Optimization

Jump and Branch are checked before dispatch table lookup:

// HOT PATH: Jump (simplest instruction)
if (instructionKind == InstructionKind.Jump)
{
    _programCounter = instruction.TargetIndex;
    continue;  // Skip dispatch table lookup
}

// HOT PATH: Branch (conditional jump)
if (instructionKind == InstructionKind.Branch)
{
    var result = HandleBranchFastPath(...);
    if (result == InstructionResult.Return) return returnValue;
    continue;
}

// All other instructions via dispatch table
var loopResult = InstructionHandlers[(int)instructionKind](...);

Function Execution Variants

Type File Description
Sync SyncFunctionInvoker.cs Normal functions, uses IR with AST fallback
Generator SyncGeneratorInvoker.cs Pause/resume via yield, returns iterator
Async AsyncFunctionInvoker.cs Reuses generator IR, driven internally
Async Generator AsyncGeneratorInvoker.cs Iterator where .next() returns Promise
flowchart LR
    subgraph Sync["Sync Function"]
        S1((Invoke)) --> S2((Execute IR))
        S2 --> S3((Return JsValue))
    end
    
    subgraph Gen["Generator"]
        G1((Invoke)) --> G2((Create Iterator))
        G2 --> G3((".next()"))
        G3 --> G4((Execute until yield))
        G4 --> G5(("{value, done}"))
        G5 -.->|"not done"| G3
    end
    
    subgraph Async["Async Function"]
        A1((Invoke)) --> A2((Return Promise))
        A2 --> A3((Execute IR))
        A3 --> A4{await?}
        A4 -->|"resolved"| A3
        A4 -->|"pending"| A5((Suspend))
        A5 -.->|".then()"| A3
        A3 --> A6((Resolve Promise))
    end
Loading

Sync Functions

Uses IR execution by default, falls back to AST walking for with/eval:

SyncFunctionInvoker.Invoke()
  -> Try IR: ExecutionPlanRunner
  -> Fallback: EvaluateBody() [AST walking]

Generators

Create iterator with .next()/.return()/.throw() methods:

var runner = CreateRunner(arguments, thisValue);
runner.Initialize();  // Sets up environment, doesn't execute
return runner.CreateGeneratorObject();  // Returns iterator

Async Functions

Reuse generator IR but drive to completion internally:

var promise = new Promise((resolve, reject) => {
    runner.Initialize();
    DriveToCompletion(ResumeMode.Next, undefined, resolve, reject);
});
return promise;

Async Generators

Combine both patterns - external iterator interface with internal await handling:

asyncGen.next(value)
  -> Return Promise immediately
  -> Resume ExecutionPlanRunner
  -> On yield: resolve Promise with { value, done: false }
  -> On await pending: attach .then(), suspend
  -> On return: resolve Promise with { value, done: true }

Script Completion Value (ES Spec)

The engine tracks completion values per ECMAScript specification:

// Sentinel Pattern: JsValue.Unit means "no value produced yet"
private JsValue _scriptCompletionValue = JsValue.Unit;

// Rules:
// 1. Script start: _scriptCompletionValue = Unit
// 2. Expression statement (e.g., 5+5;): _scriptCompletionValue = 10
// 3. At script end: if still Unit -> return undefined, else return the value
// 4. Loops/Try/Catch reset: _scriptCompletionValue = Unit on ENTER

Examples:

  • eval('7; for (...) {}') returns undefined (loop resets)
  • eval('7; for (...) { 9; }') returns 9 (loop body updates)
  • eval('try { 7; } finally { 8; }') returns 7 (finally discarded)

See Also

Clone this wiki locally