Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions Jint.Benchmark/ConstrainedExecutionBenchmark.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
using BenchmarkDotNet.Attributes;
using Jint.Native;

namespace Jint.Benchmark;

/// <summary>
/// Per-statement constraint check overhead: a registered timeout (the most common embedder
/// safety net) used to force a virtual Check() call before every executed statement and
/// disarm the tight for-body lane. The amortized-constraint partition reduces that to a
/// countdown decrement per statement and keeps the lane armed, so the TimeoutEnabled=true
/// row should track the unconstrained row closely.
/// </summary>
[MemoryDiagnoser]
public class ConstrainedExecutionBenchmark
{
private Engine _engine = null!;
private Prepared<Script> _functionLocalLoop;

[Params(false, true)]
public bool TimeoutEnabled { get; set; }

[GlobalSetup]
public void GlobalSetup()
{
_functionLocalLoop = Engine.PrepareScript("""
function f() {
var s = 0;
for (var i = 0; i < 1000000; i++) {
s += 2;
}
return s;
}
f();
""");

_engine = TimeoutEnabled
? new Engine(options => options.TimeoutInterval(TimeSpan.FromSeconds(30)))
: new Engine();
_engine.Evaluate(_functionLocalLoop);
}

[Benchmark]
public JsValue FunctionLocalLoop() => _engine.Evaluate(_functionLocalLoop);
}
91 changes: 91 additions & 0 deletions Jint.Tests/Runtime/ExecutionConstraintTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,63 @@ public void ShouldCountStatementsPrecisely()
);
}

[Fact]
public void ShouldCountStatementsInsideFunctionLocalLoop()
{
// The function-local expression-body for loop is the shape that arms the interpreter's
// tight for-body lane; MaxStatements counts statements (its call frequency IS its
// semantics), so it must keep the loop on the per-statement path and trip precisely.
var engine = new Engine(cfg => cfg.MaxStatements(1_000));
Assert.Throws<StatementsCountOverflowException>(
() => engine.Evaluate("function f() { var x = 0; for (var i = 0; i < 100000; i++) { x += 1; } return x; } f();")
);
}

[Fact]
public void UserDefinedConstraintIsCheckedOncePerStatement()
{
// Per-statement call frequency is part of the Constraint contract for user-derived
// implementations; amortizing them would be a silent behavior break. Expected counts:
// JintStatement.Execute checks once per non-block statement; the top-level program list
// itself does not check (JintStatementList only checks when it represents a function
// body or block statement node).
var constraint = new CountingConstraint();
var engine = new Engine(cfg => cfg.Constraint(constraint));

engine.Evaluate("var x = 0; x++; x + 5");
Assert.Equal(3, constraint.CheckCount);

// one more top-level statement -> exactly one more check
constraint.ResetCount();
engine.Evaluate("var x = 0; x++; x++; x + 5");
Assert.Equal(4, constraint.CheckCount);
}

[Fact]
public void UserDefinedConstraintIsCheckedPerStatementInsideFunctionLocalLoop()
{
// A user-derived constraint must also keep the tight for-body lane disarmed: every body
// statement of every iteration goes through the per-statement checks.
var constraint = new CountingConstraint();
var engine = new Engine(cfg => cfg.Constraint(constraint));

engine.Evaluate("function f() { var x = 0; for (var i = 0; i < 1000; i++) { x += 1; } return x; } f();");
Assert.True(constraint.CheckCount >= 1000, $"expected at least one check per loop-body statement, got {constraint.CheckCount}");
}

private sealed class CountingConstraint : Constraint
{
public int CheckCount { get; private set; }

public void ResetCount() => CheckCount = 0;

public override void Check() => CheckCount++;

public override void Reset()
{
}
}

[Fact]
public void ShouldThrowMemoryLimitExceeded()
{
Expand All @@ -44,6 +101,40 @@ public void ShouldThrowTimeout()
);
}

[Fact]
public void ShouldThrowTimeoutInsideFunctionLocalTightLoop()
{
// The function-local expression-body for loop is the shape that arms the interpreter's
// tight for-body lane; a timeout is amortized (checked every N statements/iterations),
// and must still fire inside the loop.
var engine = new Engine(cfg => cfg.TimeoutInterval(new TimeSpan(0, 0, 0, 0, 500)));
Assert.Throws<TimeoutException>(
() => engine.Evaluate("function f() { var x = 0; for (var i = 0; i < 1; i += 0) { x += 1; } return x; } f();")
);
}

[Fact]
public void ShouldThrowMemoryLimitExceededInsideFunctionLocalTightLoop()
{
// Memory limit is amortized too and must interrupt an allocating tight-lane loop.
var engine = new Engine(cfg => cfg.LimitMemory(2_000_000));
Assert.Throws<MemoryLimitExceededException>(
() => engine.Evaluate("function f() { var s = ''; for (var i = 0; i < 1; i += 0) { s += 'aaaaaaaaaaaaaaaa'; } return s; } f();")
);
}

[Fact]
public void TimeoutConstraintDoesNotChangeFunctionLocalLoopResults()
{
// With only amortized constraints registered the tight for-body lane stays armed;
// results must be identical to an unconstrained engine's.
const string script = "function f() { var s = 0; for (var i = 0; i < 100000; i++) { s += 2; } return s; } f();";
var unconstrained = new Engine().Evaluate(script).AsNumber();
var constrained = new Engine(cfg => cfg.TimeoutInterval(TimeSpan.FromSeconds(30))).Evaluate(script).AsNumber();
Assert.Equal(unconstrained, constrained);
Assert.Equal(200_000, constrained);
}

[Fact]
public void ShouldThrowExecutionCanceled()
{
Expand Down
40 changes: 40 additions & 0 deletions Jint/Engine.Constraints.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,49 @@
using Jint.Constraints;

namespace Jint;

public partial class Engine
{
public ConstraintOperations Constraints { get; }

[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Auto)]
private readonly record struct ConstraintPartition(Constraint[] Exact, Constraint[] Amortized);

/// <summary>
/// Splits the registered constraints by required check frequency. The built-in time,
/// cancellation and memory-limit constraints only observe external state (a timer, a token,
/// an allocation counter), so checking them every N statements is semantically equivalent to
/// checking per statement — only the detection latency is bounded instead of immediate (the
/// same reasoning bulk built-ins apply via <see cref="ConstraintCheckInterval"/>). Everything
/// else stays exact: <see cref="MaxStatementsConstraint"/> counts statements, so its call
/// frequency IS its semantics, and user-derived constraints may depend on being called once
/// per statement — silently amortizing them would be a breaking behavior change.
/// </summary>
private static ConstraintPartition PartitionConstraints(Constraint[] constraints)
{
if (constraints.Length == 0)
{
return new ConstraintPartition([], []);
}

var exact = new List<Constraint>(constraints.Length);
var amortized = new List<Constraint>(constraints.Length);
foreach (var constraint in constraints)
{
// All three types are sealed, so the checks cannot match a user-derived subclass.
if (constraint is TimeConstraint or CancellationConstraint or MemoryLimitConstraint)
{
amortized.Add(constraint);
}
else
{
exact.Add(constraint);
}
}

return new ConstraintPartition(exact.ToArray(), amortized.ToArray());
}

public class ConstraintOperations
{
private readonly Engine _engine;
Expand Down
46 changes: 46 additions & 0 deletions Jint/Engine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,13 @@ internal void CacheFunctionDefinition(Node key, JintFunctionDefinition definitio
// cached access
internal readonly IObjectConverter[]? _objectConverters;
internal readonly Constraint[] _constraints;

// _constraints partitioned once at construction (the set is fixed for the engine's lifetime):
// exact constraints must run before every statement, amortized ones every N statements.
// See Engine.Constraints.cs for the partitioning rationale.
internal readonly Constraint[] _exactConstraints;
internal readonly Constraint[] _amortizedConstraints;

internal readonly bool _isDebugMode;
internal readonly bool _isStrict;

Expand Down Expand Up @@ -248,6 +255,9 @@ private Engine(Options? options, Action<Engine, Options>? configure)
: null;

_constraints = Options.Constraints.Constraints.ToArray();
var partitionedConstraints = PartitionConstraints(_constraints);
_exactConstraints = partitionedConstraints.Exact;
_amortizedConstraints = partitionedConstraints.Amortized;
_referenceResolver = Options.ReferenceResolver;
_customResolver = !ReferenceEquals(_referenceResolver, DefaultReferenceResolver.Instance);

Expand Down Expand Up @@ -807,6 +817,12 @@ internal void DrainEventLoopUntilSettled(JsPromise promise, TimeSpan timeout)
}
}

/// <summary>
/// Checks every registered constraint plus the debugger step hook. Used by native callback
/// call sites (e.g. sort comparers) where the callee may not run any JS statements itself;
/// the interpreter's per-statement path uses the partitioned
/// <see cref="RunPerStatementChecks"/> / <see cref="CheckAmortizedConstraints"/> pair instead.
/// </summary>
internal void RunBeforeExecuteStatementChecks(StatementOrExpression? statement)
{
// Avoid allocating the enumerator because we run this loop very often.
Expand All @@ -821,6 +837,36 @@ internal void RunBeforeExecuteStatementChecks(StatementOrExpression? statement)
}
}

/// <summary>
/// The exactly-once-per-statement slice of the before-statement checks: constraints whose
/// call frequency is observable (statement counting, user-derived) and the debugger step hook.
/// </summary>
internal void RunPerStatementChecks(StatementOrExpression? statement)
{
// Avoid allocating the enumerator because we run this loop very often.
foreach (var constraint in _exactConstraints)
{
constraint.Check();
}

if (_isDebugMode && statement != null && statement.Type != NodeType.BlockStatement)
{
Debugger.OnStep(statement);
}
}

/// <summary>
/// Checks the constraints that only observe external state; callers are responsible for the
/// amortization cadence (see <see cref="EvaluationContext.RunAmortizedConstraintChecks"/>).
/// </summary>
internal void CheckAmortizedConstraints()
{
foreach (var constraint in _amortizedConstraints)
{
constraint.Check();
}
}

internal JsValue GetValue(object value)
{
return GetValue(value, false);
Expand Down
51 changes: 43 additions & 8 deletions Jint/Runtime/Interpreter/EvaluationContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,31 +8,47 @@ namespace Jint.Runtime.Interpreter;
/// </summary>
internal sealed class EvaluationContext
{
private readonly bool _shouldRunBeforeExecuteStatementChecks;
/// <summary>
/// How many statements may execute between checks of the amortized constraints (see
/// Engine.Constraints.cs for the partition rationale). Small enough that timeout /
/// cancellation / memory-limit detection latency stays far below anything observable at
/// the granularity those constraints operate on, large enough that the per-statement cost
/// collapses to a countdown decrement and branch.
/// </summary>
internal const int AmortizedConstraintCheckInterval = 64;

private readonly bool _shouldRunPerStatementChecks;
private readonly bool _hasAmortizedConstraints;
private int _amortizedConstraintCountdown;

public EvaluationContext(Engine engine)
{
Engine = engine;
OperatorOverloadingAllowed = engine.Options.Interop.AllowOperatorOverloading;
_shouldRunBeforeExecuteStatementChecks = engine._constraints.Length > 0 || engine._isDebugMode;
_shouldRunPerStatementChecks = engine._exactConstraints.Length > 0 || engine._isDebugMode;
_hasAmortizedConstraints = engine._amortizedConstraints.Length > 0;
_amortizedConstraintCountdown = AmortizedConstraintCheckInterval;
}

// for fast evaluation checks only
public EvaluationContext()
{
Engine = null!;
OperatorOverloadingAllowed = false;
_shouldRunBeforeExecuteStatementChecks = false;
_shouldRunPerStatementChecks = false;
_hasAmortizedConstraints = false;
}

public readonly Engine Engine;
public bool DebugMode => Engine._isDebugMode;

/// <summary>
/// Frozen per context (constraints or debug mode at creation); statement fast paths that
/// skip <see cref="RunBeforeExecuteStatementChecks"/> entirely must be gated on this.
/// Frozen per context (exact constraints or debug mode at creation); statement fast paths
/// that skip <see cref="RunBeforeExecuteStatementChecks"/> must be gated on this AND keep
/// amortized constraints live by driving <see cref="RunAmortizedConstraintChecks"/> at a
/// bounded cadence (e.g. once per loop iteration).
/// </summary>
internal bool ShouldRunBeforeExecuteStatementChecks => _shouldRunBeforeExecuteStatementChecks;
internal bool ShouldRunPerStatementChecks => _shouldRunPerStatementChecks;

/// <summary>
/// Returns true if the generator is suspended (yielded) or a return was requested.
Expand Down Expand Up @@ -78,9 +94,28 @@ public Node LastSyntaxElement

public void RunBeforeExecuteStatementChecks(StatementOrExpression statement)
{
if (_shouldRunBeforeExecuteStatementChecks)
if (_shouldRunPerStatementChecks)
{
Engine.RunPerStatementChecks(statement);
}

RunAmortizedConstraintChecks();
}

/// <summary>
/// The amortized slice of the before-statement checks: with only observation-only constraints
/// registered (e.g. a timeout — the common embedder configuration) this is the whole
/// per-statement cost, a countdown decrement and branch. The countdown is per-context state,
/// so detection latency stays bounded at <see cref="AmortizedConstraintCheckInterval"/>
/// statements regardless of which call sites drive it.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
internal void RunAmortizedConstraintChecks()
{
if (_hasAmortizedConstraints && --_amortizedConstraintCountdown == 0)
{
Engine.RunBeforeExecuteStatementChecks(statement);
_amortizedConstraintCountdown = AmortizedConstraintCheckInterval;
Engine.CheckAmortizedConstraints();
}
}

Expand Down
14 changes: 11 additions & 3 deletions Jint/Runtime/Interpreter/Statements/JintForStatement.cs
Original file line number Diff line number Diff line change
Expand Up @@ -511,14 +511,17 @@ private Completion ForBodyEvaluation(EvaluationContext context, JsValue initialV
var v = initialValue;

// Tight loop: with an expression-statements-only body, a non-suspendable frame, no
// constraint/debug checks, dead completion values and no fresh per-iteration environment,
// nothing per iteration remains observable but test, body expressions and update.
// per-statement (exact) constraint/debug checks, dead completion values and no fresh
// per-iteration environment, nothing per iteration remains observable but test, body
// expressions and update. Amortized constraints (timeout, cancellation, memory limit)
// do not disarm the lane: TightForBody drives the shared amortized countdown once per
// iteration, keeping their detection latency bounded.
// The DebugMode probe is live (same coarseness as the debugHandler hoisting below).
if (_tightBodyEligible
&& !skipTestOnce
&& !resumeUpdateOnce
&& !context.CompletionValuesObservable
&& !context.ShouldRunBeforeExecuteStatementChecks
&& !context.ShouldRunPerStatementChecks
&& !context.DebugMode
&& (!_shouldCreatePerIterationEnvironment || _canReuseIterationEnvironment)
&& (!_tightBodyHasLexicalDeclarations || flattenActive)
Expand Down Expand Up @@ -656,6 +659,9 @@ private Completion ForBodyEvaluation(EvaluationContext context, JsValue initialV
/// loop environment carries the body's slots: their TDZ is re-established per iteration
/// before the body statements run, mirroring the generic flattened arm — skipped when the
/// block scan proved every slot initializes before any use (leftovers are unobservable).
/// Amortized constraints stay live through the context's shared countdown, driven once per
/// iteration (a tripped constraint throws and propagates like any other exception); exact
/// constraints and debug mode never reach this lane by the caller's gate.
/// </summary>
[System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)]
private Completion TightForBody(EvaluationContext context, bool flattenActive)
Expand All @@ -669,6 +675,8 @@ private Completion TightForBody(EvaluationContext context, bool flattenActive)

while (test.GetBooleanValue(context))
{
context.RunAmortizedConstraintChecks();

if (resetBodySlotsPerIteration)
{
var env = (DeclarativeEnvironment) engine.ExecutionContext.LexicalEnvironment;
Expand Down
Loading
Loading