diff --git a/Jint/Runtime/Descriptors/Specialized/LazyPropertyDescriptor.cs b/Jint/Runtime/Descriptors/Specialized/LazyPropertyDescriptor.cs index fac78d633..aa1a167f9 100644 --- a/Jint/Runtime/Descriptors/Specialized/LazyPropertyDescriptor.cs +++ b/Jint/Runtime/Descriptors/Specialized/LazyPropertyDescriptor.cs @@ -19,7 +19,19 @@ internal LazyPropertyDescriptor(T state, Func resolver, PropertyFlag protected internal override JsValue? CustomValue { - get => _value ??= _resolver(_state); + get + { + var value = _value; + if (value is null) + { + _value = value = _resolver(_state); + // Once materialized this is semantically a plain data descriptor; clearing the + // flag lets value reads/writes skip the CustomValue indirection and admits the + // descriptor to the global-binding and member-write inline caches. + _flags &= ~PropertyFlag.CustomJsValue; + } + return value; + } set => _value = value; } } diff --git a/Jint/Runtime/Interpreter/Expressions/JintAssignmentExpression.cs b/Jint/Runtime/Interpreter/Expressions/JintAssignmentExpression.cs index dbbd3911e..622fdb74c 100644 --- a/Jint/Runtime/Interpreter/Expressions/JintAssignmentExpression.cs +++ b/Jint/Runtime/Interpreter/Expressions/JintAssignmentExpression.cs @@ -962,8 +962,10 @@ private JsValue SetValue(EvaluationContext context) // identifiers to a single field test; the rest stays out-of-line. if (left._cachedGlobalEnv is not null) { + // Writable is re-checked through the cached reference: defineProperty flips the + // flag in place without bumping the versions the validator checks. var cachedGlobalDescriptor = left.TryGetValidatedGlobalDescriptor(engine, env); - if (cachedGlobalDescriptor is not null) + if (cachedGlobalDescriptor is not null && cachedGlobalDescriptor.Writable) { return AssignToCachedGlobalBinding(context, left, right, cachedGlobalDescriptor, hasEvalOrArguments, nameAnonymousFunction, strict); } @@ -1011,8 +1013,9 @@ private JsValue SetValue(EvaluationContext context) // Populate the global-binding cache from the write side too, so write-first // patterns benefit from the next access on. Must run after the set: the set - // may have created the property (bumping the shape version). - if (ReferenceEquals(environmentRecord, env) && environmentRecord is GlobalEnvironment globalEnv) + // may have created the property (bumping the shape version). No hop + // restriction — the validator re-walks with shadow probes on every use. + if (environmentRecord is GlobalEnvironment globalEnv) { left.TryRememberGlobalBinding(globalEnv); } diff --git a/Jint/Runtime/Interpreter/Expressions/JintIdentifierExpression.cs b/Jint/Runtime/Interpreter/Expressions/JintIdentifierExpression.cs index d03a9a719..7cefbd3b4 100644 --- a/Jint/Runtime/Interpreter/Expressions/JintIdentifierExpression.cs +++ b/Jint/Runtime/Interpreter/Expressions/JintIdentifierExpression.cs @@ -23,10 +23,11 @@ internal sealed class JintIdentifierExpression : JintExpression private DeclarativeEnvironment? _cachedSlotEnv; private int _cachedSlotIndex = -1; - // Version-based inline cache for global bindings: when top-level code (current lexical - // env IS the global env) resolves to a plain writable MutableBinding data property on the - // real GlobalObject, remember the descriptor. Valid while the global object's own-property - // shape and the set of global lexical declarations are unchanged — value writes mutate the + // Version-based inline cache for global bindings: when resolution (from any scope depth) + // lands on a plain writable data property of the real GlobalObject, remember the + // descriptor. Valid while the global object's own-property shape and the set of global + // lexical declarations are unchanged AND a bounded walk from the current env reaches the + // global env without an intermediate that could own the name — value writes mutate the // descriptor in place and bump neither version. Mirrors JintMemberExpression's read cache. internal GlobalEnvironment? _cachedGlobalEnv; private Runtime.Descriptors.PropertyDescriptor? _cachedGlobalDescriptor; @@ -232,8 +233,10 @@ public override JsValue GetValue(EvaluationContext context) _cachedSlotIndex = slotIndex; } } - else if (ReferenceEquals(identifierEnvironment, env) && identifierEnvironment is GlobalEnvironment globalEnv) + else if (identifierEnvironment is GlobalEnvironment globalEnv) { + // No hop restriction: the validator re-walks with shadow probes on every read, + // so nested-scope reads of globals (loop bodies, closures) can use the cache. TryRememberGlobalBinding(globalEnv); } @@ -252,31 +255,84 @@ public override JsValue GetValue(EvaluationContext context) } /// - /// Validates the global-binding cache for the current environment: the current lexical env - /// must be the cached GlobalEnvironment itself (top-level code) and neither the global - /// object's own-property shape nor the global lexical declaration set may have changed. - /// Returns the cached plain writable data descriptor, or null on miss. + /// Validates the global-binding cache for the current environment: neither the global + /// object's own-property shape nor the global lexical declaration set may have changed, + /// and a bounded walk from the current lexical env must reach the cached GlobalEnvironment + /// without passing an environment that could own the name (mirrors the slot-cache walk: + /// with-objects and declarative envs holding the binding — e.g. sloppy direct eval injected + /// a var — bail to full resolution; the per-read probes are what make nested-scope use + /// safe). Returns the cached plain writable data descriptor, or null on miss. /// [MethodImpl(MethodImplOptions.NoInlining)] internal Runtime.Descriptors.PropertyDescriptor? TryGetValidatedGlobalDescriptor(Engine engine, Environment env) { var cachedGlobalEnv = _cachedGlobalEnv; - if (cachedGlobalEnv is not null - && ReferenceEquals(env, cachedGlobalEnv) - && ReferenceEquals(cachedGlobalEnv._engine, engine) - && cachedGlobalEnv._global._propertiesVersion == _cachedGlobalShapeVersion - && cachedGlobalEnv._lexicalMutations == _cachedGlobalLexicalVersion) + if (cachedGlobalEnv is null) { - return _cachedGlobalDescriptor; + return null; + } + + // Hop 0 (top-level code) keeps the single identity compare up front and this method + // compact — it is by far the most common case (var-heavy scripts validate here many + // times per loop iteration) and must not pay for the nested-scope walk's code size. + if (ReferenceEquals(env, cachedGlobalEnv)) + { + return ReferenceEquals(cachedGlobalEnv._engine, engine) + && cachedGlobalEnv._global._propertiesVersion == _cachedGlobalShapeVersion + && cachedGlobalEnv._lexicalMutations == _cachedGlobalLexicalVersion + ? _cachedGlobalDescriptor + : null; + } + + return TryGetValidatedGlobalDescriptorNested(engine, env, cachedGlobalEnv); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private Runtime.Descriptors.PropertyDescriptor? TryGetValidatedGlobalDescriptorNested(Engine engine, Environment env, GlobalEnvironment cachedGlobalEnv) + { + if (!ReferenceEquals(cachedGlobalEnv._engine, engine) + || cachedGlobalEnv._global._propertiesVersion != _cachedGlobalShapeVersion + || cachedGlobalEnv._lexicalMutations != _cachedGlobalLexicalVersion) + { + return null; + } + + var key = _identifier.Key; + var search = env; + for (var hops = 0; hops < MaxSlotCacheChainDepth; hops++) + { + if (search is ObjectEnvironment) + { + return null; + } + + if (search is DeclarativeEnvironment intermediate && intermediate.HasBinding(key)) + { + return null; + } + + search = search._outerEnv; + if (search is null) + { + return null; + } + + if (ReferenceEquals(search, cachedGlobalEnv)) + { + return _cachedGlobalDescriptor; + } } return null; } /// - /// Caches the resolved global binding when it is a plain writable MutableBinding data - /// property on the real GlobalObject and no lexical declaration shadows it. Both reads - /// and writes may then operate on the descriptor directly while the versions hold. + /// Caches the resolved global binding when it is a plain writable data property on the + /// real GlobalObject (no CustomValue indirection — materialized lazy built-ins qualify, + /// live accessor-like descriptors never do) and no lexical declaration shadows it. Reads + /// may then use the descriptor directly while the versions hold; writers must re-check + /// through the cached + /// reference because defineProperty flips flags in place without bumping the versions. /// internal void TryRememberGlobalBinding(GlobalEnvironment globalEnv) { @@ -287,10 +343,10 @@ internal void TryRememberGlobalBinding(GlobalEnvironment globalEnv) return; } - if (globalObject._properties!.TryGetValue(identifier.Key, out var descriptor) + if (globalObject._properties?.TryGetValue(identifier.Key, out var descriptor) == true && descriptor.IsDataDescriptor() && descriptor.Writable - && (descriptor._flags & Runtime.Descriptors.PropertyFlag.MutableBinding) != Runtime.Descriptors.PropertyFlag.None) + && (descriptor._flags & Runtime.Descriptors.PropertyFlag.CustomJsValue) == Runtime.Descriptors.PropertyFlag.None) { _cachedGlobalEnv = globalEnv; _cachedGlobalDescriptor = descriptor; diff --git a/Jint/Runtime/Interpreter/Expressions/JintUpdateExpression.cs b/Jint/Runtime/Interpreter/Expressions/JintUpdateExpression.cs index 42df0de08..ab1eb92a6 100644 --- a/Jint/Runtime/Interpreter/Expressions/JintUpdateExpression.cs +++ b/Jint/Runtime/Interpreter/Expressions/JintUpdateExpression.cs @@ -172,7 +172,9 @@ private JsValue UpdateNonIdentifier(EvaluationContext context) if (_leftIdentifier!._cachedGlobalEnv is not null && !context.OperatorOverloadingAllowed) { var descriptor = _leftIdentifier.TryGetValidatedGlobalDescriptor(engine, engine.ExecutionContext.LexicalEnvironment); - if (descriptor is not null && descriptor._value is { } current) + // Writable is re-checked through the cached reference: defineProperty flips the + // flag in place without bumping the versions the validator checks. + if (descriptor is not null && descriptor.Writable && descriptor._value is { } current) { if (_evalOrArguments && StrictModeScope.IsStrictModeCode) {