From 25365f21504e8b8c7c01dfe61e7c62b1bf717fc3 Mon Sep 17 00:00:00 2001 From: Faktorial Agent Date: Tue, 2 Jun 2026 03:01:58 +0200 Subject: [PATCH] Admit captured arrow reads to production bytecode --- docs/unified-bytecode-expansion-contract.md | 12 ++++---- .../TypedAstEvaluator.SyncFunctionInvoker.cs | 26 ++++++++++++++-- .../UnifiedBytecodeCompiler.cs | 14 ++++++++- .../UnifiedBytecodeProductionEligibility.cs | 30 ++++++++++++++++--- .../ExpressionProgramCoverageMapTests.cs | 2 +- ...ifiedBytecodeProductionEligibilityTests.cs | 24 +++++++++++++++ ...nifiedBytecodeProductionInvocationTests.cs | 12 ++++---- 7 files changed, 100 insertions(+), 20 deletions(-) diff --git a/docs/unified-bytecode-expansion-contract.md b/docs/unified-bytecode-expansion-contract.md index 081b5b9f1..ab4b3f2a2 100644 --- a/docs/unified-bytecode-expansion-contract.md +++ b/docs/unified-bytecode-expansion-contract.md @@ -330,7 +330,7 @@ must still obey the no-mixed-execution rule. | `ClassConstructorActivation` | Activation descriptor gate for class constructor activation outside the admitted simple base constructor route, explicit derived-constructor `super(...)` route with post-super `this` body reads/writes, and default-derived constructor `super(...args)` forwarding route | Constructor route | Constructor boundary lane | `rtk dotnet test tests/Asynkron.JsEngine.Tests --filter "FullyQualifiedName~UnifiedBytecodeProductionConstructCallTests&FullyQualifiedName~Constructor"` | | `CallDependency` | Direct eval outside the one-argument non-spread eval-identifier boundary, out-of-boundary call-target preparation, and complex call arguments excluding admitted simple/binary template-literal substitutions, simple/binary computed object keys, and zero-argument activation-resolved identifier-call computed object keys | Existing sync IR call route | Wider call invocation lane | `rtk dotnet test tests/Asynkron.JsEngine.Tests --filter "FullyQualifiedName~UnifiedBytecodeProductionEligibilityTests"` | | `DynamicLookupDependency` | Unresolved identifier loads/stores/update outside the admitted ordinary, with-backed, and direct-eval-backed dynamic-name paths; plans that need direct eval plus post-eval dynamic identifier reads now route when captured activation, with-chain, and arguments-object dependencies are absent | Existing sync IR / environment lookup route | Dynamic-name lane | `rtk dotnet test tests/Asynkron.JsEngine.Tests --filter "FullyQualifiedName~UnifiedBytecodeProductionEligibilityTests&FullyQualifiedName~Evaluate_DirectEvalDeclaredVarRead_AcceptsOrdinaryDynamicNameProgram"` | -| `PropertyReadBoundaryOutOfScope` | Named/computed property reads outside the admitted activation-resolved boundaries | Existing sync IR property route | Property read widening lane | `rtk dotnet test tests/Asynkron.JsEngine.Tests --filter "FullyQualifiedName~UnifiedBytecodeProductionEligibilityTests&FullyQualifiedName~Evaluate_ComputedPropertyReadOutsideFirstBoundary_DeclinesWithBoundaryCode"` | +| `PropertyReadBoundaryOutOfScope` | Named/computed property reads outside the admitted activation-resolved and read-only dynamic-identifier-base boundaries | Existing sync IR property route | Property read widening lane | `rtk dotnet test tests/Asynkron.JsEngine.Tests --filter "FullyQualifiedName~UnifiedBytecodeProductionEligibilityTests&FullyQualifiedName~Evaluate_ComputedPropertyReadOutsideFirstBoundary_DeclinesWithBoundaryCode"` | | `PropertyWriteDependency` | Property writes and compound/logical property writes outside the admitted direct property-write shapes, supported computed expression-key mutation shapes, simple nested named receiver assignment shape, nested named compound-write shape, and nested named logical-write shape | Existing sync IR property-write route | Property write widening lane | `rtk dotnet test tests/Asynkron.JsEngine.Tests --filter "FullyQualifiedName~UnifiedBytecodeProductionEligibilityTests&FullyQualifiedName~Evaluate_LogicalAndAssignment_UnsupportedShapes_DeclineWithExplicitCodes"` | | `PropertyUpdateDependency` | Property and identifier update expressions outside the admitted direct update, computed expression-key update, and simple nested named receiver update boundary | Existing sync IR update route | Property update lane | `rtk dotnet test tests/Asynkron.JsEngine.Tests --filter "FullyQualifiedName~UnifiedBytecodeProductionEligibilityTests&FullyQualifiedName~Evaluate_NestedNamedPropertyUpdate_AcceptsOwnedPropertyOpcodes"` | | `DeleteDependency` | `delete` expressions outside the admitted ordinary named/computed property delete lane, ordinary dynamic-key computed delete lane, and with-backed dynamic-name delete lane | Existing sync IR delete route | Delete semantics lane | `rtk dotnet test tests/Asynkron.JsEngine.Tests --filter "FullyQualifiedName~UnifiedBytecodeProductionEligibilityTests&FullyQualifiedName~Evaluate_NestedComputedPropertyDeleteDynamicKey_AcceptsOrdinaryDynamicNameOpcode"` | @@ -354,7 +354,7 @@ not always have a `UnifiedBytecodeProductionDeclineCode`. | Pre-gate key | Owning source / current example | Current fallback route | Planned batch / lane | Proof command | |---|---|---|---|---| | `pre-gate:IsClassConstructor` | Class constructor invokers outside the admitted simple base constructor route and explicit derived-constructor `super(...)` route with post-super `this` body reads/writes | Constructor route | Constructor boundary lane | `rtk dotnet test tests/Asynkron.JsEngine.Tests --filter "FullyQualifiedName~UnifiedBytecodeProductionInvocationTests"` | -| `pre-gate:IsArrowFunction` | Arrow functions outside the admitted simple-return route whose lowered body proves no super, closure-variable, or dynamic-identifier dependency. Captured lexical `this` / `new.target` values are VM-owned; lexical-this environments remain a separate pre-gate. | Existing arrow invocation route | Arrow route lane | `rtk dotnet test tests/Asynkron.JsEngine.Tests --filter "FullyQualifiedName~UnifiedBytecodeProductionInvocationTests"` | +| `pre-gate:IsArrowFunction` | Arrow functions outside the admitted simple-return route whose lowered body proves no super, call-target, assignment, update, delete, nested function/class literal, or other unowned dependency. Captured lexical `this` / `new.target`, statically resolved flat-slot reads, read-only ordinary environment identifier reads, and named property reads from those dynamic identifier bases are VM-owned; lexical-this environments remain a separate pre-gate. | Existing arrow invocation route | Arrow route lane | `rtk dotnet test tests/Asynkron.JsEngine.Tests --filter "FullyQualifiedName~UnifiedBytecodeProductionInvocationTests"` | | `pre-gate:IsAsyncLike` | Async and formerly-async ordinary invokers | Async route | Resumable async/generator lane | `rtk dotnet test tests/Asynkron.JsEngine.Tests --filter "FullyQualifiedName~UnifiedBytecodeProductionEligibilityTests&FullyQualifiedName~Evaluate_AsyncLikeActivation_DeclinesBeforePlanInspection"` | | `pre-gate:IsGenerator` | Generator functions before ordinary sync production routing | Generator route | Resumable async/generator lane | `rtk dotnet test tests/Asynkron.JsEngine.Tests --filter "FullyQualifiedName~UnifiedBytecodeProductionEligibilityTests&FullyQualifiedName~Evaluate_GeneratorActivation_DeclinesBeforePlanInspection"` | | `pre-gate:hasParameterExpressions` | Default/rest/destructured parameter expressions | Existing parameter environment route | Parameter semantics lane | `rtk dotnet test tests/Asynkron.JsEngine.Tests --filter "FullyQualifiedName~UnifiedBytecodeProductionInvocationTests"` | @@ -461,9 +461,11 @@ the final post-compile production subset check before VM entry. - Sloppy-mode `this` coercion is handled before VM entry: `boundThis` is computed via `CoerceThisValueForNonStrict` in `TryInvokeProductionUnifiedBytecode`, so the VM's `LoadThis` opcode always receives the correctly coerced value. -- Simple-return arrow functions whose lowered body has no lexical `this`, - `new.target`, super, closure-variable, dynamic-identifier, or nested - function/class literal dependency may route. Dependency-bearing arrows still +- Simple-return arrow functions whose lowered body has no super, call-target, + assignment, update, delete, nested function/class literal, or other unowned + dependency may route. Captured lexical `this` / `new.target`, flat-slot reads, + read-only ordinary environment identifier reads, and named property reads from + those dynamic identifier bases are VM-owned. Dependency-bearing arrows still decline via the `IsArrowFunction`, captured-activation, or `_lexicalThisEnvironment is not null` pre-gates. - There is no retained `this` decline in the production activation descriptor; diff --git a/src/Asynkron.JsEngine/Ast/TypedAstEvaluator.SyncFunctionInvoker.cs b/src/Asynkron.JsEngine/Ast/TypedAstEvaluator.SyncFunctionInvoker.cs index 09217c27e..48e269bfa 100644 --- a/src/Asynkron.JsEngine/Ast/TypedAstEvaluator.SyncFunctionInvoker.cs +++ b/src/Asynkron.JsEngine/Ast/TypedAstEvaluator.SyncFunctionInvoker.cs @@ -3398,7 +3398,12 @@ private bool CanUseProductionUnifiedBytecodeFastPath(ExecutionPlan plan, JsValue return false; } - return CanUseProductionUnifiedBytecodePlanShape(plan, canUseDynamicNamePath) || + return CanUseProductionUnifiedBytecodePlanShape( + plan, + canUseDynamicNamePath || + canUseOrdinaryDynamicNamePath || + canUseArrowFunctionPath || + canUseImplicitArgumentsObjectReadPath) || (canUseDerivedClassConstructorPath || canUseBaseClassConstructorPath) && plan.ActivationSlots is not null; } @@ -3442,7 +3447,9 @@ private UnifiedBytecodeProductionActivationDescriptor CreateProductionUnifiedByt IsClassConstructor && !canUseDerivedClassConstructorPath && !canUseBaseClassConstructorPath, HasDynamicLookupDependency: hasUnprovenDynamicActivation, AllowsOrdinaryDynamicIdentifierEnvironmentOperations: - canUseOrdinaryDynamicNamePath || canUseImplicitArgumentsObjectReadPath); + canUseOrdinaryDynamicNamePath || + canUseImplicitArgumentsObjectReadPath || + canUseArrowFunctionPath); } [MethodImpl(JsEngineConstants.Inlining)] @@ -3719,7 +3726,8 @@ private static bool CanUseProductionUnifiedBytecodeArrowActivationDependencyPath } if (TryGetIdentifierDependency(operation, identifierConstants, out var identifier) && - !ResolvesToOwnActivationSlot(identifier, activationSlots)) + !ResolvesToOwnActivationOrFlatSlot(identifier, activationSlots) && + !CanUseArrowDynamicIdentifierRead(operation, identifier)) { return false; } @@ -3765,6 +3773,18 @@ private static bool ResolvesToOwnActivationSlot( activationSlots.SlotMap.ContainsKey(identifier.Name); } + private static bool ResolvesToOwnActivationOrFlatSlot( + IdentifierOperand identifier, + ActivationSlotShape activationSlots) => + ResolvesToOwnActivationSlot(identifier, activationSlots) || + identifier.FlatSlotId >= 0; + + private static bool CanUseArrowDynamicIdentifierRead( + PackedExpressionOp operation, + IdentifierOperand identifier) => + identifier.FlatSlotId < 0 && + operation.Kind is ExpressionOpKind.LoadIdentifier or ExpressionOpKind.TypeOfIdentifier; + private bool CanUseProductionUnifiedBytecodeBaseClassConstructorActivation( ExecutionPlan plan, JsValue newTarget) diff --git a/src/Asynkron.JsEngine/Execution/UnifiedBytecode/UnifiedBytecodeCompiler.cs b/src/Asynkron.JsEngine/Execution/UnifiedBytecode/UnifiedBytecodeCompiler.cs index cc6a8a7f5..364e76c05 100644 --- a/src/Asynkron.JsEngine/Execution/UnifiedBytecode/UnifiedBytecodeCompiler.cs +++ b/src/Asynkron.JsEngine/Execution/UnifiedBytecode/UnifiedBytecodeCompiler.cs @@ -3783,7 +3783,9 @@ private static bool TryAppendExpressionProgramOps( expressionProgram, activationSlots, unified, + literalConstants, stringConstants, + allowsDynamicIdentifiers, out reason)) { return true; @@ -8485,7 +8487,9 @@ private static bool TryAppendFirstBoundaryNamedPropertyReadChain( ExpressionProgram expressionProgram, ActivationSlotShape activationSlots, ImmutableArray.Builder unified, + ImmutableArray.Builder literalConstants, ImmutableArray.Builder stringConstants, + bool allowsDynamicIdentifiers, out string reason) { if (expressionProgram.OperationCount < 2) @@ -8511,7 +8515,15 @@ private static bool TryAppendFirstBoundaryNamedPropertyReadChain( } } - if (!TryAppendActivationValueLoad(baseLoad, expressionProgram, activationSlots, unified, out reason)) + if (!TryAppendSimpleOperandLoadWithDynamic( + baseLoad, + expressionProgram, + activationSlots, + allowsDynamicIdentifiers, + unified, + literalConstants, + stringConstants, + out reason)) { return false; } diff --git a/src/Asynkron.JsEngine/Execution/UnifiedBytecode/UnifiedBytecodeProductionEligibility.cs b/src/Asynkron.JsEngine/Execution/UnifiedBytecode/UnifiedBytecodeProductionEligibility.cs index 2dc73a715..d83c961de 100644 --- a/src/Asynkron.JsEngine/Execution/UnifiedBytecode/UnifiedBytecodeProductionEligibility.cs +++ b/src/Asynkron.JsEngine/Execution/UnifiedBytecode/UnifiedBytecodeProductionEligibility.cs @@ -1113,7 +1113,11 @@ private static bool TryFindExpressionDecline( break; } - if (TryIsFirstBoundaryNamedPropertyReadCandidate(program, identifierConstants, activationSlots)) + if (TryIsFirstBoundaryNamedPropertyReadCandidate( + program, + identifierConstants, + activationSlots, + allowsDynamicIdentifiers)) { break; } @@ -1247,7 +1251,7 @@ private static bool TryFindExpressionDecline( declineCode = UnifiedBytecodeProductionDeclineCode.PropertyReadBoundaryOutOfScope; declineReason = - "Named property reads are outside the first production property-read boundary unless they are direct activation-resolved base reads or exact two-hop named chains."; + "Named property reads are outside the first production property-read boundary unless they are activation-resolved or admitted dynamic-identifier base reads."; return true; case ExpressionOpKind.GetComputedProperty: @@ -1902,14 +1906,17 @@ private static bool TryGetActivationOrImplicitArgumentsObjectReadValue( private static bool TryIsFirstBoundaryNamedPropertyReadCandidate( ExpressionProgram program, ReadOnlySpan identifierConstants, - ActivationSlotShape activationSlots) + ActivationSlotShape activationSlots, + bool allowsDynamicIdentifiers) { if (program.OperationCount < 2) { return false; } - if (!TryGetActivationResolvedValue(program.GetOperation(0), identifierConstants, activationSlots)) + if (!TryGetActivationResolvedValue(program.GetOperation(0), identifierConstants, activationSlots) && + !(allowsDynamicIdentifiers && + TryGetPlainDynamicIdentifierReadValue(program.GetOperation(0), identifierConstants, activationSlots))) { return false; } @@ -1928,6 +1935,21 @@ private static bool TryIsFirstBoundaryNamedPropertyReadCandidate( return true; } + private static bool TryGetPlainDynamicIdentifierReadValue( + PackedExpressionOp operation, + ReadOnlySpan identifierConstants, + ActivationSlotShape activationSlots) + { + if (operation.Kind != ExpressionOpKind.LoadIdentifier || operation.IsArguments) + { + return false; + } + + var identifier = operation.GetIdentifier(identifierConstants); + return identifier.FlatSlotId < 0 && + !TryResolveActivationSlot(identifier, activationSlots); + } + private static bool TryIsFirstBoundaryObjectLiteralNamedPropertyReadCandidate( ExpressionProgram program, ReadOnlySpan identifierConstants, diff --git a/tests/Asynkron.JsEngine.Tests/ExpressionProgramCoverageMapTests.cs b/tests/Asynkron.JsEngine.Tests/ExpressionProgramCoverageMapTests.cs index 94d185000..1157460ea 100644 --- a/tests/Asynkron.JsEngine.Tests/ExpressionProgramCoverageMapTests.cs +++ b/tests/Asynkron.JsEngine.Tests/ExpressionProgramCoverageMapTests.cs @@ -77,7 +77,7 @@ private sealed record ProductionUnifiedBytecodeProofPackShape( "Evaluate_LinearSlotLiteralReturnPlan_Accepts", "Execute_LinearSlotLiteralReturnPlan_ReturnsSlotValueInProductionVm", "LinearSlotReturnFunction_UsesUnifiedBytecodeProductionFastPath", - "ArrowFunction_CapturedThis_DeclinesUnifiedBytecodeProductionFastPath", + "BinaryReturnFunction_KeepsExistingSpecializedFastPath", "SourceGate_ProductionUnifiedBytecodeAcceptedPath_DoesNotDelegateToAstOrExecutionPlanRunner") ]; diff --git a/tests/Asynkron.JsEngine.Tests/UnifiedBytecodeProductionEligibilityTests.cs b/tests/Asynkron.JsEngine.Tests/UnifiedBytecodeProductionEligibilityTests.cs index af35f530d..89934604d 100644 --- a/tests/Asynkron.JsEngine.Tests/UnifiedBytecodeProductionEligibilityTests.cs +++ b/tests/Asynkron.JsEngine.Tests/UnifiedBytecodeProductionEligibilityTests.cs @@ -1151,6 +1151,30 @@ function read(box) { Assert.Equal("value", Assert.Single(result.Program.StringConstants)); } + [Fact] + public void Evaluate_NamedPropertyReadWithDynamicIdentifierBase_AcceptsWhenDynamicReadsAreAdmitted() + { + var plan = GetFunctionPlan(""" + function read() { + return outer.value; + } + """, + "read"); + + var result = UnifiedBytecodeProductionEligibility.Evaluate( + plan, + new UnifiedBytecodeProductionActivationDescriptor( + AllowsOrdinaryDynamicIdentifierEnvironmentOperations: true)); + + Assert.True(result.IsEligible, result.Reason); + Assert.Equal(UnifiedBytecodeProductionDeclineCode.None, result.Code); + Assert.Contains(result.Program.Instructions, instruction => + instruction.OpCode == UnifiedBytecodeOpCode.LoadDynamicIdentifier); + Assert.Contains(result.Program.Instructions, instruction => + instruction.OpCode == UnifiedBytecodeOpCode.GetNamedProperty); + Assert.Equal(new[] { "outer", "value" }, result.Program.StringConstants); + } + [Fact] public void Evaluate_TwoHopNamedPropertyReadCandidate_AcceptsOwnedPropertyOpcodes() { diff --git a/tests/Asynkron.JsEngine.Tests/UnifiedBytecodeProductionInvocationTests.cs b/tests/Asynkron.JsEngine.Tests/UnifiedBytecodeProductionInvocationTests.cs index a436e9f16..684d26d47 100644 --- a/tests/Asynkron.JsEngine.Tests/UnifiedBytecodeProductionInvocationTests.cs +++ b/tests/Asynkron.JsEngine.Tests/UnifiedBytecodeProductionInvocationTests.cs @@ -6657,22 +6657,22 @@ record => record.Message.Contains( } [Fact(Timeout = 5000)] - public async Task ArrowFunction_CapturedThis_DeclinesUnifiedBytecodeProductionFastPath() + public async Task ArrowFunction_CapturedOuterEnvironmentRead_UsesUnifiedBytecodeProductionFastPath() { await using var engine = CreateEngine(); var result = await engine.Evaluate(""" function makeGetter(obj) { - var get = () => obj.value; - return get(); + return () => obj.value; } - makeGetter({ value: 42 }); + var get = makeGetter({ value: 42 }); + get(); """); Assert.Equal(42d, result); - Assert.DoesNotContain(CurrentLogger!.Collector.Snapshot(), + Assert.Contains(CurrentLogger!.Collector.Snapshot(), record => record.Message.Contains( - "unified-bytecode-production-fast-path func=get", + "unified-bytecode-production-fast-path func=", StringComparison.Ordinal)); }