Skip to content

Error Signaling

Roger Johansson edited this page Jan 14, 2026 · 1 revision

Error Signaling: ThrowSignal vs CompletionSignal

The engine uses two distinct mechanisms for error/control flow propagation, each optimized for different scenarios.


Overview

flowchart TB
    subgraph Internal["Internal (Fast Path)"]
        Context((EvaluationContext))
        Signal((ICompletionSignal))
        SetThrow["ctx.SetThrow(value)"]
        IsThrow["ctx.IsThrow"]
    end
    
    subgraph Boundary["Boundary Crossing"]
        ThrowSignal((ThrowSignal))
        Exception["C# Exception"]
        Catch["try/catch"]
    end
    
    Context --> Signal
    Signal --> SetThrow
    SetThrow --> IsThrow
    
    ThrowSignal --> Exception
    Exception --> Catch
    
    Internal -->|"Escapes function"| Boundary
Loading

Key Files

File Purpose
CompletionSignals.cs Internal control flow signals
ThrowSignal.cs Exception for boundary crossing
EvaluationContext.cs Signal state management
CompletionState.cs Save/restore for try-finally

Why Two Mechanisms?

Performance Trade-offs

Mechanism Speed Use Case
ICompletionSignal Very fast (no stack unwinding) Within evaluator loops
ThrowSignal Slower (exception overhead) Crossing C# call boundaries

The Problem

JavaScript throw can occur:

  1. Inside tight loops - Must be fast (millions per second)
  2. Across function boundaries - Must cross C# call stacks
  3. Into host code - Must be catchable by .NET

A single mechanism can't optimize for all cases.


ICompletionSignal (Internal)

Typed signals for control flow within the evaluator.

Signal Types

public interface ICompletionSignal;

// Loop control
internal sealed record BreakCompletionSignal(Symbol? Label = null) : ICompletionSignal;
internal sealed record ContinueCompletionSignal(Symbol? Label = null) : ICompletionSignal;

// Value-carrying signals
internal sealed class ThrowFlowCompletionSignal(JsValue jsValue) : ICompletionSignal
{
    public JsValue JsValue { get; } = jsValue;
}

internal sealed class YieldCompletionSignal(JsValue jsValue, IJsObjectLike? iteratorResultObject = null)
    : ICompletionSignal
{
    public JsValue JsValue { get; } = jsValue;
    public IJsObjectLike? IteratorResultObject { get; } = iteratorResultObject;
}

// Async suspension
internal sealed class PendingAwaitCompletionSignal : ICompletionSignal
{
    internal static readonly PendingAwaitCompletionSignal Instance = new();
    private PendingAwaitCompletionSignal() { }
}

Signal Flow

flowchart TD
    Throw["throw new Error()"] --> SetThrow["ctx.SetThrow(error)"]
    SetThrow --> Signal["CurrentSignal = ThrowFlowCompletionSignal"]
    Signal --> Check{Next instruction}
    Check --> IsThrow["ctx.IsThrow?"]
    IsThrow -->|Yes| Handle{try/catch?}
    Handle -->|Yes| Catch["Clear signal,\nbind error"]
    Handle -->|No| Propagate["Continue propagation"]
    IsThrow -->|No| Execute["Normal execution"]
Loading

EvaluationContext API

public sealed class EvaluationContext
{
    // Current signal (null = normal flow)
    public ICompletionSignal? CurrentSignal { get; private set; }
    
    // Fast return path (avoids allocation)
    public bool IsReturn { get; private set; }
    private JsValue _returnValue;

    // Signal type checks
    public bool IsThrow => CurrentSignal is ThrowFlowCompletionSignal;
    public bool IsYield => CurrentSignal is YieldCompletionSignal;
    public bool IsBreak => CurrentSignal is BreakCompletionSignal;
    public bool IsContinue => CurrentSignal is ContinueCompletionSignal;
    public bool IsPendingAwait => CurrentSignal is PendingAwaitCompletionSignal;
    
    // Should stop current evaluation?
    public bool ShouldStopEvaluation => IsReturn || CurrentSignal is not null;
    
    // Get the value (for throw/yield/return)
    public JsValue FlowValue
    {
        get
        {
            if (IsReturn) return _returnValue;
            return CurrentSignal switch
            {
                ThrowFlowCompletionSignal ts => ts.JsValue,
                YieldCompletionSignal ys => ys.JsValue,
                _ => JsValue.Undefined
            };
        }
    }
}

Setting Signals

// Return (optimized - no allocation)
public void SetReturn(JsValue value)
{
    IsReturn = true;
    CurrentSignal = null;
    _returnValue = value;
}

// Throw
public void SetThrow(JsValue value)
{
    IsReturn = false;
    CurrentSignal = new ThrowFlowCompletionSignal(value);
}

// Loop control
public void SetBreak(Symbol? label = null)
{
    CurrentSignal = new BreakCompletionSignal(label);
}

public void SetContinue(Symbol? label = null)
{
    CurrentSignal = new ContinueCompletionSignal(label);
}

// Generator yield
public void SetYield(JsValue value, int yieldIndex)
{
    LastYieldIndex = yieldIndex;
    CurrentSignal = new YieldCompletionSignal(value);
}

// Async await suspension
public void SetPendingAwait()
{
    IsReturn = false;
    CurrentSignal = PendingAwaitCompletionSignal.Instance;
}

Clearing Signals

// Clear any signal
public void Clear()
{
    IsReturn = false;
    _returnValue = default;
    CurrentSignal = null;
}

// Clear return only
public void ClearReturn()
{
    IsReturn = false;
    _returnValue = default;
}

// Clear break if label matches (or unlabeled)
public bool TryClearBreak(Symbol? label)
{
    if (CurrentSignal is not BreakCompletionSignal breakSignal)
        return false;
    
    if (breakSignal.Label is not null && 
        (label is null || !ReferenceEquals(breakSignal.Label, label)))
        return false;  // Different label, propagate
    
    CurrentSignal = null;
    return true;
}

// Clear continue if label matches (or unlabeled)
public bool TryClearContinue(Symbol? label)
{
    if (CurrentSignal is not ContinueCompletionSignal continueSignal)
        return false;
    
    if (continueSignal.Label is not null &&
        (label is null || !ReferenceEquals(continueSignal.Label, label)))
        return false;  // Different label, propagate
    
    CurrentSignal = null;
    return true;
}

ThrowSignal (Boundary)

A C# exception used when throws must cross call stack boundaries.

Implementation

public sealed class ThrowSignal : Exception
{
    public JsValue ThrownValue { get; }

    public ThrowSignal(JsValue thrownValue) : base(FormatThrowMessage(thrownValue))
    {
        // DEBUG: Validate proper usage
        Debug.Assert(
            thrownValue.Kind != JsValueKind.Object || thrownValue.ObjectValue is not ThrowSignal,
            "ThrowSignal should not contain another ThrowSignal.");
        Debug.Assert(
            thrownValue.Kind != JsValueKind.Object || thrownValue.ObjectValue is not ICompletionSignal,
            "ThrowSignal should not contain ICompletionSignal.");

        ThrownValue = thrownValue;
    }

    private static string FormatThrowMessage(JsValue thrownValue)
    {
        if (thrownValue.IsNull) return "Unhandled JavaScript throw: null";
        if (thrownValue.IsUndefined) return "Unhandled JavaScript throw: undefined";
        
        if (thrownValue.TryGetString(out var str))
            return $"Unhandled JavaScript throw: \"{str}\"";
        
        if (thrownValue.TryGetObject<JsObject>(out var jsObj))
        {
            if (jsObj.TryGetProperty("message", out var message) && 
                message is { IsNull: false, IsUndefined: false })
            {
                var msgStr = JsOps.ToJsString(message);
                if (jsObj.TryGetProperty("name", out var name) && 
                    name is { IsNull: false, IsUndefined: false })
                    return $"Unhandled JavaScript throw: '{JsOps.ToJsString(name)}': '{msgStr}'";
                return $"Unhandled JavaScript throw: {msgStr}";
            }
        }
        
        return $"Unhandled JavaScript throw: {JsOps.ToJsString(thrownValue)}";
    }
}

When to Use ThrowSignal

flowchart TD
    Error["JS Error Occurs"] --> Where{Where?}
    Where -->|"In evaluator loop"| Signal["ctx.SetThrow()"]
    Where -->|"In host function"| Throw["throw new ThrowSignal()"]
    Where -->|"Escaping function"| Convert["Convert signal to ThrowSignal"]
    
    Signal --> Check["Check ctx.IsThrow"]
    Throw --> Catch["Caught by try/catch"]
    Convert --> Catch
Loading

Conversion Pattern

When a signal must escape a function boundary:

// In function invoker - after evaluation completes
var result = child.EvaluateForJsValue(ref env, ctx);

if (context.IsThrow)
{
    // Convert signal to exception for boundary crossing
    throw new ThrowSignal(context.FlowValue);
}

Catching ThrowSignal

// In host code calling JS
try
{
    var result = engine.Evaluate("throw new Error('oops')");
}
catch (ThrowSignal signal)
{
    // Access the JS error object
    var error = signal.ThrownValue;
    Console.WriteLine($"JS Error: {signal.Message}");
}

try-finally: CompletionState

Save/restore signals across finally blocks:

public readonly struct CompletionState(
    bool isReturn, 
    JsValue returnValue, 
    ICompletionSignal? signal)
{
    public bool IsReturn { get; } = isReturn;
    public JsValue ReturnValue { get; } = returnValue;
    public ICompletionSignal? Signal { get; } = signal;
}

Usage in try-finally

// Before finally block
var savedState = context.SaveCompletionState();
context.Clear();  // Allow finally to execute normally

// Execute finally block
ExecuteFinally();

// After finally (if finally didn't throw)
if (!context.ShouldStopEvaluation)
{
    // Restore original signal (return/throw/break/continue)
    context.RestoreCompletionState(savedState);
}
// If finally threw, its signal takes precedence

Save/Restore API

public CompletionState SaveCompletionState()
{
    return new CompletionState(IsReturn, _returnValue, CurrentSignal);
}

public void RestoreCompletionState(in CompletionState state)
{
    IsReturn = state.IsReturn;
    _returnValue = state.ReturnValue;
    CurrentSignal = state.Signal;
}

Labeled Break/Continue

Labels allow targeting specific loops:

outer: for (let i = 0; i < 10; i++) {
    inner: for (let j = 0; j < 10; j++) {
        if (j === 5) break outer;  // Exit both loops
    }
}

Signal with Label

// break outer;
context.SetBreak(outerLabel);  // Symbol for "outer"

// Loop checks if break is for it
if (context.TryClearBreak(myLabel))
{
    // Break was for this loop, exit
    break;
}
// else: Break is for outer loop, propagate

Best Practices

Do

  1. Use ctx.SetThrow() inside evaluator hot paths
  2. Use throw new ThrowSignal() at function boundaries
  3. Check ctx.IsThrow after any operation that might throw
  4. Save/restore completion state around finally blocks

Don't

  1. Wrap ThrowSignal in JsValue
  2. Wrap ICompletionSignal in JsValue
  3. Forget to check ctx.ShouldStopEvaluation after child evaluation
  4. Clear signals without proper handling

Error Pattern in Host Functions

// CORRECT: throw ThrowSignal directly
private JsValue MyHostFunction(JsValue thisValue, IReadOnlyList<JsValue> args)
{
    if (args.Count == 0)
        throw new ThrowSignal(StandardLibrary.CreateTypeError("Missing argument"));
    
    return DoSomething(args[0]);
}

// WRONG: return ThrowSignal as JsValue
private JsValue MyHostFunction(JsValue thisValue, IReadOnlyList<JsValue> args)
{
    if (args.Count == 0)
        return StandardLibrary.ThrowTypeError("Missing argument");  // BAD!
    
    return DoSomething(args[0]);
}

Performance Comparison

Operation ICompletionSignal ThrowSignal
Set signal ~5ns ~500ns (exception creation)
Check signal ~1ns N/A (already thrown)
Propagate ~2ns per level Stack unwinding
Clear ~2ns N/A

The signal-based approach is ~100x faster for throws that are caught within the same function or nearby scope.


See Also

Clone this wiki locally