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
319 changes: 215 additions & 104 deletions Jint.Tests/Runtime/ExecutionConstraintTests.cs

Large diffs are not rendered by default.

8 changes: 2 additions & 6 deletions Jint/Engine.Constraints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,8 @@ public partial class Engine
/// <item>User-derived constraints may depend on being called once per statement — silently
/// amortizing them would be a breaking behavior change.</item>
/// </list>
/// The statement-count cadence is a wall-clock proxy only while statements stay cheap, so
/// interop call sites that hand control to user CLR code (delegates, reflected members,
/// constructors) re-check via <see cref="CheckAmortizedConstraintsAtHostBoundary"/> — a loop
/// of slow host calls must not stretch the detection window (a call can take arbitrarily
/// long, and only <see cref="Runtime.Interpreter.EvaluationContext.AmortizedConstraintCheckInterval"/>
/// statements' worth of them would otherwise elapse unchecked).
/// Interop call sites additionally re-check on return from user CLR code — see
/// <see cref="CheckAmortizedConstraintsAtHostBoundary"/> for that mechanism's rationale.
/// </summary>
private static ConstraintPartition PartitionConstraints(Constraint[] constraints)
{
Expand Down
44 changes: 33 additions & 11 deletions Jint/Engine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -892,19 +892,41 @@ internal void CheckAmortizedConstraints()
}

/// <summary>
/// Re-checks the amortized constraints at an interpreter/host-code boundary. The per-statement
/// amortization bounds timeout/cancellation detection latency in statement count, which tracks
/// wall-clock time only while statements stay cheap; a single call into user CLR code can take
/// arbitrarily long, so interop call sites check on entry (no new host work starts once the
/// budget has expired) and again after the host code returns (time that passed inside the call
/// is observed immediately), keeping detection latency bounded by one host call instead of
/// <see cref="EvaluationContext.AmortizedConstraintCheckInterval"/> of them. Intentionally not
/// wired into built-in <see cref="ClrFunction"/> dispatch: that would reintroduce the per-call
/// check cost on hot built-ins, and long-running built-ins already bound their own latency via
/// <see cref="ConstraintCheckInterval"/> self-checks.
/// Re-checks the amortized constraints when control returns from user CLR code to the
/// interpreter. The per-statement amortization bounds timeout/cancellation detection latency
/// in statement count, which tracks wall-clock time only while statements stay cheap; a single
/// host call can take arbitrarily long, so interop call sites re-check after the host code
/// returns, keeping detection latency bounded by one host call instead of
/// <see cref="EvaluationContext.AmortizedConstraintCheckInterval"/> statements' worth of them.
/// The boundaries of the mechanism are deliberate:
/// <list type="bullet">
/// <item>Only after the host call returns, never on entry — an entry check would block host
/// cleanup calls (e.g. a release-the-lock delegate inside a JS finally block) once
/// cancellation is pending, skipping cleanup that ran before this mechanism existed.</item>
/// <item>Only while script evaluation is active — constraint state is only meaningful inside
/// an Execute/Invoke window: <see cref="Constraints.TimeConstraint"/>'s timer is re-armed at
/// the end of every run and keeps counting, and a cancellation token may be cancelled during
/// normal teardown, so host-initiated access to wrapped objects from C# after execution must
/// not observe either.</item>
/// <item>Not in debug mode — a debugger paused longer than the timeout would otherwise
/// deterministically fail every interop read in watch/conditional-breakpoint evaluation;
/// debug-mode engines keep the pre-existing amortized-countdown-only behavior.</item>
/// <item>Not wired into built-in <see cref="ClrFunction"/> dispatch (would reintroduce the
/// per-call cost on hot built-ins that the amortization removed; long-running built-ins bound
/// their own latency via <see cref="ConstraintCheckInterval"/> self-checks), nor into
/// operator-overload resolution (whose catch-all would launder constraint exceptions into
/// TargetInvocationException), nor into user-supplied converter/factory callbacks —
/// embedder-authored code hosting long operations can observe the CancellationToken itself.</item>
/// </list>
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
internal void CheckAmortizedConstraintsAtHostBoundary() => CheckAmortizedConstraints();
internal void CheckAmortizedConstraintsAtHostBoundary()
{
if (_activeEvaluationContext is not null && !_isDebugMode)
{
CheckAmortizedConstraints();
}
}

internal JsValue GetValue(object value)
{
Expand Down
13 changes: 6 additions & 7 deletions Jint/Runtime/Interop/DelegateWrapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,6 @@ public DelegateWrapper(

protected internal override JsValue Call(JsValue thisObject, JsCallArguments arguments)
{
Engine.CheckAmortizedConstraintsAtHostBoundary();

var parameterInfos = _d.Method.GetParameters();

#if NETFRAMEWORK
Expand Down Expand Up @@ -136,12 +134,13 @@ protected internal override JsValue Call(JsValue thisObject, JsCallArguments arg
try
{
var result = _d.DynamicInvoke(parameters);
// an awaitable result must reach promise conversion before a constraint can throw,
// so that the in-flight Task gets a continuation attached and is never left unobserved
var returnValue = IsAwaitable(result)
? ConvertAwaitableToPromise(Engine, result!)
: FromObject(Engine, result);
Engine.CheckAmortizedConstraintsAtHostBoundary();
if (!IsAwaitable(result))
{
return FromObject(Engine, result);
}
return ConvertAwaitableToPromise(Engine, result!);
return returnValue;
}
catch (TargetInvocationException exception)
{
Expand Down
5 changes: 1 addition & 4 deletions Jint/Runtime/Interop/GetterFunction.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,6 @@ public GetterFunction(Engine engine, Func<JsValue, JsValue> getter)

protected internal override JsValue Call(JsValue thisObject, JsCallArguments arguments)
{
_engine.CheckAmortizedConstraintsAtHostBoundary();
var result = _getter(thisObject);
_engine.CheckAmortizedConstraintsAtHostBoundary();
return result;
return _getter(thisObject);
}
}
3 changes: 0 additions & 3 deletions Jint/Runtime/Interop/MethodDescriptor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -280,8 +280,6 @@ private static string GetTypeName(Type type)

public JsValue Call(Engine engine, object? instance, JsCallArguments arguments)
{
engine.CheckAmortizedConstraintsAtHostBoundary();

object?[] parameters = arguments.Length == 0 ? [] : new object?[arguments.Length];
var methodParameters = Parameters;
var valueCoercionType = engine.Options.Interop.ValueCoercion;
Expand Down Expand Up @@ -320,7 +318,6 @@ public JsValue Call(Engine engine, object? instance, JsCallArguments arguments)
}

var retVal = Invoke(instance, parameters);
engine.CheckAmortizedConstraintsAtHostBoundary();
return JsValue.FromObject(engine, retVal);
}
catch (TargetInvocationException exception)
Expand Down
2 changes: 0 additions & 2 deletions Jint/Runtime/Interop/MethodInfoFunction.cs
Original file line number Diff line number Diff line change
Expand Up @@ -155,8 +155,6 @@ private static MethodBase ResolveMethod(MethodBase method, ParameterInfo[] metho

protected internal override JsValue Call(JsValue thisObject, JsCallArguments jsArguments)
{
_engine.CheckAmortizedConstraintsAtHostBoundary();

var converter = Engine.TypeConverter;
var thisObj = thisObject.ToObject() ?? _target;
var state = new MethodResolverState(_engine, thisObject, jsArguments);
Expand Down
59 changes: 38 additions & 21 deletions Jint/Runtime/Interop/ObjectWrapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,9 @@ public override bool Set(JsValue property, JsValue value, JsValue receiver)
return false;
}

return _typeDescriptor.TrySetDictionaryValue(Target, clrKey!, clrValue);
var written = _typeDescriptor.TrySetDictionaryValue(Target, clrKey!, clrValue);
_engine.CheckAmortizedConstraintsAtHostBoundary();
return written;
}

return SetSlow(property, value);
Expand Down Expand Up @@ -434,17 +436,21 @@ public override JsValue Get(JsValue property, JsValue receiver)
}
else
{
if (_typeDescriptor.IsStringKeyedGenericDictionary
&& _typeDescriptor.TryGetDictionaryValue(Target, property.ToString(), out var value))
if (_typeDescriptor.IsStringKeyedGenericDictionary)
{
// Check stored properties first - frozen/sealed objects have descriptors in _properties
// that must be respected to return the same (frozen) instance
if (TryGetProperty(property, out var stored))
var found = _typeDescriptor.TryGetDictionaryValue(Target, property.ToString(), out var value);
_engine.CheckAmortizedConstraintsAtHostBoundary();
if (found)
{
return UnwrapJsValue(stored, receiver);
}
// Check stored properties first - frozen/sealed objects have descriptors in _properties
// that must be respected to return the same (frozen) instance
if (TryGetProperty(property, out var stored))
{
return UnwrapJsValue(stored, receiver);
}

return FromObject(_engine, value);
return FromObject(_engine, value);
}
}
}
}
Expand All @@ -456,9 +462,9 @@ public override JsValue Get(JsValue property, JsValue receiver)
{
// Prototype chain is intentionally skipped on miss: non-string non-symbol keys can't
// resolve to Object.prototype members (which are all string/symbol-keyed).
return _typeDescriptor.TryGetDictionaryValue(Target, clrKey!, out var raw)
? FromObject(_engine, raw)
: Undefined;
var found = _typeDescriptor.TryGetDictionaryValue(Target, clrKey!, out var raw);
_engine.CheckAmortizedConstraintsAtHostBoundary();
return found ? FromObject(_engine, raw) : Undefined;
}

// slow path requires us to create a property descriptor that might get cached or not
Expand Down Expand Up @@ -622,15 +628,19 @@ private PropertyDescriptor GetOwnProperty(JsValue property, bool mustBeReadable,
if (!property.IsString() && _typeDescriptor.IsNonStringKeyedGenericDictionary)
{
// non-string-keyed CLR generic dictionary — resolve via underlying CLR key, not string
if (TryConvertJsValueToDictionaryKey(property, _typeDescriptor.GenericDictionaryKeyType!, out var clrKey)
&& _typeDescriptor.TryGetDictionaryValue(Target, clrKey!, out var raw))
if (TryConvertJsValueToDictionaryKey(property, _typeDescriptor.GenericDictionaryKeyType!, out var clrKey))
{
var flags = PropertyFlag.Enumerable;
if (_engine.Options.Interop.AllowWrite)
var found = _typeDescriptor.TryGetDictionaryValue(Target, clrKey!, out var raw);
_engine.CheckAmortizedConstraintsAtHostBoundary();
if (found)
{
flags |= PropertyFlag.Configurable;
var flags = PropertyFlag.Enumerable;
if (_engine.Options.Interop.AllowWrite)
{
flags |= PropertyFlag.Configurable;
}
return new PropertyDescriptor(FromObject(_engine, raw), flags);
}
return new PropertyDescriptor(FromObject(_engine, raw), flags);
}
return PropertyDescriptor.Undefined;
}
Expand All @@ -643,7 +653,9 @@ private PropertyDescriptor GetOwnProperty(JsValue property, bool mustBeReadable,
var isDictionary = _typeDescriptor.IsStringKeyedGenericDictionary;
if (isDictionary)
{
if (_typeDescriptor.TryGetDictionaryValue(Target, member, out var value))
var found = _typeDescriptor.TryGetDictionaryValue(Target, member, out var value);
_engine.CheckAmortizedConstraintsAtHostBoundary();
if (found)
{
var flags = PropertyFlag.Enumerable;
if (_engine.Options.Interop.AllowWrite)
Expand All @@ -667,6 +679,7 @@ private PropertyDescriptor GetOwnProperty(JsValue property, bool mustBeReadable,
}

var result = Engine.Options.Interop.MemberAccessor(Engine, Target, member);
Engine.CheckAmortizedConstraintsAtHostBoundary();
if (result is not null)
{
return new PropertyDescriptor(result, PropertyFlag.OnlyEnumerable);
Expand Down Expand Up @@ -831,7 +844,9 @@ public DictionaryIterator(Engine engine, ObjectWrapper target) : base(engine)

public override bool TryIteratorStep(out ObjectInstance nextItem)
{
if (_enumerator.MoveNext())
var hasNext = _enumerator.MoveNext();
_engine.CheckAmortizedConstraintsAtHostBoundary();
if (hasNext)
{
var key = _enumerator.Current;
var value = _target.Get(key);
Expand Down Expand Up @@ -862,7 +877,9 @@ public override void Close(CompletionType completion)

public override bool TryIteratorStep(out ObjectInstance nextItem)
{
if (_enumerator.MoveNext())
var hasNext = _enumerator.MoveNext();
_engine.CheckAmortizedConstraintsAtHostBoundary();
if (hasNext)
{
var value = _enumerator.Current;
nextItem = IteratorResult.CreateValueIteratorPosition(_engine, FromObject(_engine, value));
Expand Down
4 changes: 0 additions & 4 deletions Jint/Runtime/Interop/Reflection/ReflectionAccessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,6 @@ protected ReflectionAccessor(
return constantValue;
}

engine.CheckAmortizedConstraintsAtHostBoundary();

// first check indexer so we don't confuse inherited properties etc
var value = TryReadFromIndexer(target, memberName);

Expand Down Expand Up @@ -90,8 +88,6 @@ protected ReflectionAccessor(

public void SetValue(Engine engine, object target, string memberName, JsValue value)
{
engine.CheckAmortizedConstraintsAtHostBoundary();

object? converted;
if (_memberType == typeof(JsValue))
{
Expand Down
2 changes: 0 additions & 2 deletions Jint/Runtime/Interop/SetterFunction.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,7 @@ public SetterFunction(Engine engine, Action<JsValue, JsValue> setter)

protected internal override JsValue Call(JsValue thisObject, JsCallArguments arguments)
{
_engine.CheckAmortizedConstraintsAtHostBoundary();
_setter(thisObject, arguments[0]);
_engine.CheckAmortizedConstraintsAtHostBoundary();

return Null;
}
Expand Down
4 changes: 4 additions & 0 deletions Jint/Runtime/Interop/TypeReference.cs
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,10 @@ static ObjectInstance ObjectCreator(Engine engine, Realm realm, ObjectCreateStat
Throw.InteropResolutionError(realm, message, referenceType, memberName: null, arguments, constructors);
}

// all three creation branches above (user factory, Activator, reflected constructor)
// ran user CLR code
engine.CheckAmortizedConstraintsAtHostBoundary();

result.SetPrototypeOf(state.TypeReference);

return result;
Expand Down
7 changes: 2 additions & 5 deletions Jint/Runtime/Interpreter/EvaluationContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,8 @@ internal sealed class EvaluationContext
/// 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. The statement-count bound tracks
/// wall-clock time only while statements stay cheap; statements that transfer control to
/// user CLR code re-check at the transition instead (see
/// <see cref="Engine.CheckAmortizedConstraintsAtHostBoundary"/>), since a single host call
/// can consume unbounded wall-clock time.
/// collapses to a countdown decrement and branch. Statements that call user CLR code
/// re-check on return instead — see <see cref="Engine.CheckAmortizedConstraintsAtHostBoundary"/>.
/// </summary>
internal const int AmortizedConstraintCheckInterval = 64;

Expand Down