-
Notifications
You must be signed in to change notification settings - Fork 1
Error Signaling
Roger Johansson edited this page Jan 14, 2026
·
1 revision
The engine uses two distinct mechanisms for error/control flow propagation, each optimized for different scenarios.
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
| 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 |
| Mechanism | Speed | Use Case |
|---|---|---|
ICompletionSignal |
Very fast (no stack unwinding) | Within evaluator loops |
ThrowSignal |
Slower (exception overhead) | Crossing C# call boundaries |
JavaScript throw can occur:
- Inside tight loops - Must be fast (millions per second)
- Across function boundaries - Must cross C# call stacks
- Into host code - Must be catchable by .NET
A single mechanism can't optimize for all cases.
Typed signals for control flow within the evaluator.
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() { }
}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"]
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
};
}
}
}// 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;
}// 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;
}A C# exception used when throws must cross call stack boundaries.
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)}";
}
}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
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);
}// 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}");
}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;
}// 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 precedencepublic CompletionState SaveCompletionState()
{
return new CompletionState(IsReturn, _returnValue, CurrentSignal);
}
public void RestoreCompletionState(in CompletionState state)
{
IsReturn = state.IsReturn;
_returnValue = state.ReturnValue;
CurrentSignal = state.Signal;
}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
}
}// 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- Use
ctx.SetThrow()inside evaluator hot paths - Use
throw new ThrowSignal()at function boundaries - Check
ctx.IsThrowafter any operation that might throw - Save/restore completion state around finally blocks
- Wrap ThrowSignal in JsValue
- Wrap ICompletionSignal in JsValue
- Forget to check
ctx.ShouldStopEvaluationafter child evaluation - Clear signals without proper handling
// 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]);
}| 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.
- IR Execution - How signals are checked in the dispatch loop
- Generators & Async - Yield and PendingAwait signals
- Performance Patterns - Why signals instead of exceptions