From 49f08f06af08ef7de5359210a601af3885805272 Mon Sep 17 00:00:00 2001 From: Marko Lahma Date: Fri, 10 Jul 2026 20:29:15 +0300 Subject: [PATCH 1/2] Add call-site constructor cache with zero-arg leaf fast path for new Date() Every new Date() re-tested IsConstructor and entered the generic Engine.Construct with its call-stack push/pop and recursion-depth compare - 171k times per stopwatch benchmark op - although the resolved constructor is version-validated by the global identifier cache and its verdicts are immutable per instance. JintNewExpression now remembers the last constructor object it constructed through. An identity hit skips the IsConstructor test; when the cached constructor is additionally a zero-argument leaf built-in (new virtual Function.IsZeroArgLeafConstructor, overridden by DateConstructor when the engine runs the exact stock DefaultTimeSystem), the construct bypasses Engine.Construct entirely - such a constructor runs no user-observable code and cannot raise a JavaScript error, so the call-stack frame is dead. Custom or derived time systems keep the frame (a throwing GetUtcNow would otherwise lose its new Date() stack entry), as do debug-mode engines and suspendable frames. A cache miss (callee reassigned, another engine sharing the prepared node) falls through to the generic path and re-caches - never a permanent decline. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BY8HoKam5H8vTPn1bMY2Hv --- Jint.Tests/Runtime/NewExpressionCacheTests.cs | 137 ++++++++++++++++++ Jint/Native/Date/DateConstructor.cs | 8 + Jint/Native/Function/Function.cs | 8 + .../Expressions/JintNewExpression.cs | 35 ++++- 4 files changed, 187 insertions(+), 1 deletion(-) create mode 100644 Jint.Tests/Runtime/NewExpressionCacheTests.cs diff --git a/Jint.Tests/Runtime/NewExpressionCacheTests.cs b/Jint.Tests/Runtime/NewExpressionCacheTests.cs new file mode 100644 index 000000000..df3037436 --- /dev/null +++ b/Jint.Tests/Runtime/NewExpressionCacheTests.cs @@ -0,0 +1,137 @@ +using Jint.Runtime; + +namespace Jint.Tests.Runtime; + +/// +/// Pins the semantics of the JintNewExpression call-site constructor cache: identity-validated, +/// falls through on any miss, and the zero-arg leaf fast path (default-time-system Date) stays +/// unobservable. +/// +public class NewExpressionCacheTests +{ + [Fact] + public void RepeatedNewDateUsesRealmPrototype() + { + var engine = new Engine(); + var result = engine.Evaluate(""" + (function () { + var ok = 0; + var last = null; + for (var i = 0; i < 1000; i++) { + var d = new Date(); + if (Object.getPrototypeOf(d) === Date.prototype && d !== last) ok++; + last = d; + } + return ok; + })() + """).AsNumber(); + + Assert.Equal(1000, result); + } + + [Fact] + public void ReassignedDateConstructorIsPickedUpAtWarmCallSite() + { + var engine = new Engine(); + // warm the call site with the built-in Date, then swap the global binding: the cache + // must miss on identity and construct through the new function + var result = engine.Evaluate(""" + (function () { + var seen = ''; + function make() { return new Date(); } + for (var i = 0; i < 100; i++) make(); + seen += (make() instanceof Date); + Date = function () { this.marker = 42; }; + var d = make(); + seen += ':' + d.marker; + return seen; + })() + """).AsString(); + + Assert.Equal("true:42", result); + } + + [Fact] + public void SubclassedDateGetsSubclassPrototype() + { + var engine = new Engine(); + var result = engine.Evaluate(""" + (function () { + for (var i = 0; i < 100; i++) new Date(); + class D extends Date { } + var d = new D(); + return (Object.getPrototypeOf(d) === D.prototype) + ':' + (d instanceof Date); + })() + """).AsString(); + + Assert.Equal("true:true", result); + } + + [Fact] + public void SharedPreparedScriptConstructsAgainstOwnRealm() + { + // one prepared script executed by two engines: the call-site cache must re-resolve per + // engine (identity miss) so each engine gets dates from its own realm intrinsics + var prepared = Engine.PrepareScript(""" + var d = new Date(); + Object.getPrototypeOf(d) === Date.prototype; + """); + + var engine1 = new Engine(); + var engine2 = new Engine(); + for (var i = 0; i < 5; i++) + { + Assert.True(engine1.Evaluate(prepared).AsBoolean()); + Assert.True(engine2.Evaluate(prepared).AsBoolean()); + } + } + + [Fact] + public void CustomTimeSystemStillServesNewDate() + { + // a custom ITimeSystem disables the leaf fast path (exact-type gate); constructs run the + // generic path and observe the custom clock + var engine = new Engine(options => options.TimeSystem = new FixedTimeSystem()); + var result = engine.Evaluate(""" + (function () { + var ok = 0; + for (var i = 0; i < 100; i++) { + if (new Date().getTime() === 123456789) ok++; + } + return ok; + })() + """).AsNumber(); + + Assert.Equal(100, result); + } + + [Fact] + public void ThrowingCustomTimeSystemSurfacesExceptionPerConstruct() + { + var engine = new Engine(options => options.TimeSystem = new ThrowingTimeSystem()); + var ex = Assert.Throws(() => engine.Evaluate("new Date()")); + Assert.Equal("clock is broken", ex.Message); + } + + private sealed class FixedTimeSystem : ITimeSystem + { + public DateTimeOffset GetUtcNow() => DateTimeOffset.FromUnixTimeMilliseconds(123456789); + public TimeZoneInfo DefaultTimeZone => TimeZoneInfo.Utc; + public bool TryParse(string date, out long epochMilliseconds) + { + epochMilliseconds = 0; + return false; + } + } + + private sealed class ThrowingTimeSystem : ITimeSystem + { + public DateTimeOffset GetUtcNow() => throw new InvalidOperationException("clock is broken"); + public TimeZoneInfo DefaultTimeZone => TimeZoneInfo.Utc; + public bool TryParse(string date, out long epochMilliseconds) + { + epochMilliseconds = 0; + return false; + } + } +} diff --git a/Jint/Native/Date/DateConstructor.cs b/Jint/Native/Date/DateConstructor.cs index c5fd8f47e..b4525c424 100644 --- a/Jint/Native/Date/DateConstructor.cs +++ b/Jint/Native/Date/DateConstructor.cs @@ -17,6 +17,7 @@ internal sealed partial class DateConstructor : Constructor private static readonly long EpochTicks = Epoch.Ticks; private static readonly JsString _functionName = new JsString("Date"); private readonly ITimeSystem _timeSystem; + private readonly bool _isZeroArgLeafConstructor; internal DateConstructor( Engine engine, @@ -30,8 +31,15 @@ internal DateConstructor( _length = new PropertyDescriptor(7, PropertyFlag.Configurable); _prototypeDescriptor = new PropertyDescriptor(PrototypeObject, PropertyFlag.AllForbidden); _timeSystem = engine.Options.TimeSystem; + + // Only the stock time system is proven side-effect- and throw-free; a derived or custom + // ITimeSystem could throw from GetUtcNow and would then miss its `new Date()` stack frame + // on the leaf construct path, so the exact type is required. + _isZeroArgLeafConstructor = _timeSystem.GetType() == typeof(DefaultTimeSystem); } + internal override bool IsZeroArgLeafConstructor => _isZeroArgLeafConstructor; + internal DatePrototype PrototypeObject { get; } protected override void Initialize() => CreateProperties_Generated(); diff --git a/Jint/Native/Function/Function.cs b/Jint/Native/Function/Function.cs index 406be04b8..666378b85 100644 --- a/Jint/Native/Function/Function.cs +++ b/Jint/Native/Function/Function.cs @@ -86,6 +86,14 @@ internal Function( internal override bool IsConstructor => this is IConstructor; + /// + /// True for built-in constructors whose zero-argument [[Construct]] with newTarget == this + /// runs no user-observable code and cannot raise a JavaScript error, allowing call sites to + /// skip the call-stack frame and constructor-resolution ceremony. Must stay false whenever a + /// user callback (e.g. a custom time system) could observe the call or throw through it. + /// + internal virtual bool IsZeroArgLeafConstructor => false; + public override IEnumerable> GetOwnProperties() { if (_prototypeDescriptor != null) diff --git a/Jint/Runtime/Interpreter/Expressions/JintNewExpression.cs b/Jint/Runtime/Interpreter/Expressions/JintNewExpression.cs index 1dfae2d16..4a6a4f81e 100644 --- a/Jint/Runtime/Interpreter/Expressions/JintNewExpression.cs +++ b/Jint/Runtime/Interpreter/Expressions/JintNewExpression.cs @@ -1,14 +1,29 @@ +using Jint.Native; +using Jint.Native.Function; + namespace Jint.Runtime.Interpreter.Expressions; internal sealed class JintNewExpression : JintExpression { private readonly ExpressionCache _arguments = new(); private readonly JintExpression _calleeExpression; + private readonly bool _zeroArgs; + + // Monomorphic call-site cache: the constructor object seen by the last evaluation. + // Identity pins the realm and the immutable per-instance IsConstructor / + // IsZeroArgLeafConstructor verdicts, so a hit skips the constructor type-test — and for + // zero-argument leaf built-ins (default-time-system Date) the whole Engine.Construct + // call-stack ceremony, which such constructors can neither observe nor throw through. + // A miss (callee reassigned, different engine sharing this prepared node) falls through + // to the generic path and re-caches — never a permanent decline. + private Function? _cachedConstructor; + private bool _cachedLeafZeroArg; public JintNewExpression(NewExpression expression) : base(expression) { _arguments.Initialize(expression.Arguments.AsSpan()); _calleeExpression = Build(expression.Callee); + _zeroArgs = expression.Arguments.Count == 0; } protected override object EvaluateInternal(EvaluationContext context) @@ -18,6 +33,18 @@ protected override object EvaluateInternal(EvaluationContext context) // todo: optimize by defining a common abstract class or interface var jsValue = _calleeExpression.GetValue(context); + var isCachedConstructor = ReferenceEquals(jsValue, _cachedConstructor); + if (isCachedConstructor + && _cachedLeafZeroArg + && !engine._isDebugMode + && engine.ExecutionContext.Suspendable is null) + { + // Error objects cannot be produced, but a custom .NET-level failure would still + // resolve its script location from the last syntax element. + context.LastSyntaxElement = _expression; + return ((IConstructor) jsValue).Construct(Arguments.Empty, jsValue); + } + if (context.IsSuspended()) { return jsValue; @@ -36,7 +63,7 @@ protected override object EvaluateInternal(EvaluationContext context) return jsValue; } - if (!jsValue.IsConstructor) + if (!isCachedConstructor && !jsValue.IsConstructor) { Throw.TypeError(engine.Realm, $"{_calleeExpression.SourceText} is not a constructor"); } @@ -49,6 +76,12 @@ protected override object EvaluateInternal(EvaluationContext context) engine._jsValueArrayPool.ReturnArray(arguments); } + if (!isCachedConstructor && jsValue is Function function) + { + _cachedConstructor = function; + _cachedLeafZeroArg = _zeroArgs && function.IsZeroArgLeafConstructor && function is IConstructor; + } + return instance; } } From 04f3a07d35914d9d2a2c0962fb2432ce2c2e4fbc Mon Sep 17 00:00:00 2001 From: Marko Lahma Date: Fri, 10 Jul 2026 20:55:44 +0300 Subject: [PATCH 2/2] Fix ITimeSystem test doubles: implement GetUtcOffset (net472 has no DIM support) --- Jint.Tests/Runtime/NewExpressionCacheTests.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Jint.Tests/Runtime/NewExpressionCacheTests.cs b/Jint.Tests/Runtime/NewExpressionCacheTests.cs index df3037436..2968cf386 100644 --- a/Jint.Tests/Runtime/NewExpressionCacheTests.cs +++ b/Jint.Tests/Runtime/NewExpressionCacheTests.cs @@ -117,6 +117,7 @@ private sealed class FixedTimeSystem : ITimeSystem { public DateTimeOffset GetUtcNow() => DateTimeOffset.FromUnixTimeMilliseconds(123456789); public TimeZoneInfo DefaultTimeZone => TimeZoneInfo.Utc; + public TimeSpan GetUtcOffset(long epochMilliseconds) => TimeSpan.Zero; public bool TryParse(string date, out long epochMilliseconds) { epochMilliseconds = 0; @@ -128,6 +129,7 @@ private sealed class ThrowingTimeSystem : ITimeSystem { public DateTimeOffset GetUtcNow() => throw new InvalidOperationException("clock is broken"); public TimeZoneInfo DefaultTimeZone => TimeZoneInfo.Utc; + public TimeSpan GetUtcOffset(long epochMilliseconds) => TimeSpan.Zero; public bool TryParse(string date, out long epochMilliseconds) { epochMilliseconds = 0;