diff --git a/Jint.Tests/Runtime/LoopBodyFlatteningTests.cs b/Jint.Tests/Runtime/LoopBodyFlatteningTests.cs new file mode 100644 index 000000000..325134434 --- /dev/null +++ b/Jint.Tests/Runtime/LoopBodyFlatteningTests.cs @@ -0,0 +1,163 @@ +namespace Jint.Tests.Runtime; + +/// +/// Pins the loop-body lexical flattening (JintForStatement): an eligible body block's let/const +/// bindings fold into the pooled loop environment, and the semantics must be indistinguishable +/// from the per-iteration block environment. +/// +public class LoopBodyFlatteningTests +{ + [Fact] + public void StopwatchModernShapeComputesCorrectly() + { + var engine = new Engine(); + // the stopwatch-modern inner-loop shape: let header, const body bindings, if/else chain + var result = engine.Evaluate(""" + (function () { + let hits = 0; + for (let x = 0; x < 8; x++) { + const z = x ^ 1; + const doubled = z * 2; + if (z % 2 == 0) { hits += doubled; } + else if (z % 3 == 0) { hits += 100; } + } + return hits; + })() + """).AsNumber(); + + // z values: 1,0,3,2,5,4,7,6 → even z (0,2,4,6) add 2z = 0+4+8+12 = 24; z=3 adds 100 + Assert.Equal(124, result); + } + + [Fact] + public void ConstIsFreshPerIterationAndTdzReestablished() + { + var engine = new Engine(); + var result = engine.Evaluate(""" + (function () { + var seen = []; + for (let i = 0; i < 3; i++) { + try { touch; } catch (e) { seen.push('tdz' + i); } + const touch = i * 10; + seen.push(touch); + } + return seen.join(','); + })() + """).AsString(); + + Assert.Equal("tdz0,0,tdz1,10,tdz2,20", result); + } + + [Fact] + public void ContinueReestablishesTdzForSkippedDeclarations() + { + var engine = new Engine(); + // continue jumps before `late` is initialized; the next iteration must still see TDZ, + // not the previous iteration's value + var result = engine.Evaluate(""" + (function () { + var seen = []; + for (let i = 0; i < 4; i++) { + const early = i; + if (i % 2 == 0) { continue; } + try { seen.push(late); } catch (e) { seen.push('tdz'); } + const late = 'v' + i; + seen.push(late, early); + } + return seen.join(','); + })() + """).AsString(); + + Assert.Equal("tdz,v1,1,tdz,v3,3", result); + } + + [Fact] + public void ShadowingHeaderNameKeepsBlockScoping() + { + var engine = new Engine(); + // body const shadows the header let: names overlap, flattening must decline and the + // block env must keep proper shadowing semantics + var result = engine.Evaluate(""" + (function () { + var seen = []; + for (let v = 0; v < 3; v++) { + seen.push(v); + { + const v = 'inner'; + seen.push(v); + } + } + var direct = []; + for (let w = 0; w < 2; w++) { + const w2 = w + 10; + direct.push(w2); + } + return seen.join(',') + '|' + direct.join(','); + })() + """).AsString(); + + Assert.Equal("0,inner,1,inner,2,inner|10,11", result); + } + + [Fact] + public void CapturingBodyKeepsPerIterationSemantics() + { + var engine = new Engine(); + // closures capture the body const: escape analysis must exclude flattening (and env + // reuse), so each captured binding is distinct + var result = engine.Evaluate(""" + (function () { + const fns = []; + for (let i = 0; i < 3; i++) { + const snapshot = i * 2; + fns.push(() => snapshot); + } + return fns.map(f => f()).join(','); + })() + """).AsString(); + + Assert.Equal("0,2,4", result); + } + + [Fact] + public void ThrowMidBodyLeavesConsistentState() + { + var engine = new Engine(); + var result = engine.Evaluate(""" + (function () { + var caught = 0, total = 0; + for (let i = 0; i < 5; i++) { + try { + const val = i; + if (i === 2) { throw new Error('x'); } + total += val; + } catch (e) { + caught++; + } + } + return total + ':' + caught; + })() + """).AsString(); + + Assert.Equal("8:1", result); // 0+1+3+4, one catch + } + + [Fact] + public void UsingDeclarationsKeepBlockDisposeSemantics() + { + var engine = new Engine(); + // using declarations need block-exit dispose per iteration: flattening must decline + var result = engine.Evaluate(""" + (function () { + const order = []; + for (let i = 0; i < 2; i++) { + using res = { [Symbol.dispose]() { order.push('d' + i); } }; + order.push('b' + i); + } + return order.join(','); + })() + """).AsString(); + + Assert.Equal("b0,d0,b1,d1", result); + } +} diff --git a/Jint/Runtime/Interpreter/Statements/JintBlockStatement.cs b/Jint/Runtime/Interpreter/Statements/JintBlockStatement.cs index adb4b7ba6..ae938f6b8 100644 --- a/Jint/Runtime/Interpreter/Statements/JintBlockStatement.cs +++ b/Jint/Runtime/Interpreter/Statements/JintBlockStatement.cs @@ -19,6 +19,7 @@ internal sealed class JintBlockStatement : JintStatement // exposed so enclosing loop fast paths can reuse the exact handler instances of this block internal JintStatement? SingleStatement => _singleStatement; internal JintStatementList? StatementList => _statementList; + internal BlockState State => _blockState; // Reuse cache for this block's fixed-slot environment. Held on the handler instance — which is built // per statement list, i.e. per engine — rather than on the shared BlockState: BlockState lives on the @@ -323,6 +324,15 @@ private void SetupDisposeSuspension(Engine engine, AsyncFunctionInstance asyncFn PromiseOperations.PerformPromiseThen(engine, promise, onFulfilled, onRejected, null!); } + /// + /// Executes the block's contents against the CURRENT lexical environment, without creating, + /// attaching or parking the block environment. For enclosing-loop flattening only: the caller + /// owns an environment that already carries this block's slot layout (re-TDZ'd per iteration) + /// and has verified there are no dispose resources to run. + /// + internal Completion ExecuteFlattenedContents(EvaluationContext context) + => _statementList is not null ? _statementList.Execute(context) : ExecuteSingle(context); + private Completion ExecuteSingle(EvaluationContext context) { Completion blockValue; diff --git a/Jint/Runtime/Interpreter/Statements/JintForStatement.cs b/Jint/Runtime/Interpreter/Statements/JintForStatement.cs index b7d3fefcf..03591f12d 100644 --- a/Jint/Runtime/Interpreter/Statements/JintForStatement.cs +++ b/Jint/Runtime/Interpreter/Statements/JintForStatement.cs @@ -30,6 +30,16 @@ internal sealed class JintForStatement : JintStatement private readonly JintExpressionStatement? _tightSingleStatement; private readonly JintStatementList? _tightBodyList; + // Body-lexical flattening: when the body block's let/const bindings are slot-eligible + // (no function/class declarations, no escaping closures — BlockState.SlotNames implies both), + // contain no using declarations and don't shadow the loop header's names, they fold into the + // pooled loop environment. The body then runs against that environment directly, eliding the + // block-env attach/swap/park ceremony per iteration; the body's slot range is re-TDZ'd before + // each iteration instead. + private readonly bool _bodyFlattened; + private readonly int _flattenedHeaderSlotCount; + private readonly JintBlockStatement? _flattenedBodyBlock; + private readonly bool _shouldCreatePerIterationEnvironment; private readonly bool _canReuseIterationEnvironment; @@ -100,6 +110,30 @@ public JintForStatement(ForStatement statement) : base(statement) _incrementCanDiscard = _increment.HasDiscardFastPath; } + if (_canPoolLoopEnv && _body.BlockStatement is { } flattenCandidate) + { + var bodyState = flattenCandidate.State; + if (bodyState.SlotNames is { } bodyNames + && _boundNames!.Count + bodyNames.Length <= 16 + && !HasUsingDeclarations(bodyState) + && !NamesOverlap(_loopSlotNames!, bodyNames)) + { + var headerCount = _loopSlotNames!.Length; + var combinedNames = new Key[headerCount + bodyNames.Length]; + var combinedTemplates = new Binding[combinedNames.Length]; + System.Array.Copy(_loopSlotNames, combinedNames, headerCount); + System.Array.Copy(_loopSlotTemplates!, combinedTemplates, headerCount); + System.Array.Copy(bodyNames, 0, combinedNames, headerCount, bodyNames.Length); + System.Array.Copy(bodyState.SlotTemplates!, 0, combinedTemplates, headerCount, bodyNames.Length); + + _loopSlotNames = combinedNames; + _loopSlotTemplates = combinedTemplates; + _flattenedHeaderSlotCount = headerCount; + _flattenedBodyBlock = flattenCandidate; + _bodyFlattened = true; + } + } + if (_test is not null && IsTightBodyShape(statement.Body)) { _tightBodyEligible = true; @@ -255,7 +289,9 @@ protected override Completion ExecuteInternal(EvaluationContext context) } } - completion = ForBodyEvaluation(context, suspendData?.AccumulatedValue ?? JsValue.Undefined, skipTestOnce: resumingInBody, resumeUpdateOnce: resumingInUpdate); + // body flattening engages only when the pooled combined-slot environment is live + var flattenActive = _bodyFlattened && loopEnv is not null && _canPoolLoopEnv && suspendable is null; + completion = ForBodyEvaluation(context, suspendData?.AccumulatedValue ?? JsValue.Undefined, skipTestOnce: resumingInBody, resumeUpdateOnce: resumingInUpdate, flattenActive); return completion; } finally @@ -319,6 +355,48 @@ private static void ResetSlots(Binding[] slots, Binding[] templates) } } + /// + /// Re-establishes the TDZ of the flattened body's slot range before an iteration; the header + /// range is left untouched (a reused iteration environment keeps its header bindings). + /// + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] + private static void ResetBodySlotRange(Binding[] slots, Binding[] templates, int headerCount) + { + for (var i = headerCount; i < templates.Length; i++) + { + slots[i] = templates[i]; + } + } + + private static bool HasUsingDeclarations(JintBlockStatement.BlockState state) + { + foreach (var declaration in state.Declarations) + { + if (declaration.Declaration is VariableDeclaration { Kind: VariableDeclarationKind.Using or VariableDeclarationKind.AwaitUsing }) + { + return true; + } + } + + return false; + } + + private static bool NamesOverlap(Key[] headerNames, Key[] bodyNames) + { + foreach (var bodyName in bodyNames) + { + foreach (var headerName in headerNames) + { + if (bodyName == headerName) + { + return true; + } + } + } + + return false; + } + /// /// Checks if the given node is inside this for statement's body, test, or update (but NOT init). /// Used to determine if we're resuming from a yield/await inside the loop. @@ -362,7 +440,7 @@ private bool IsNodeInsideForStatementExcludingInit(Node node) /// /// https://tc39.es/ecma262/#sec-forbodyevaluation /// - private Completion ForBodyEvaluation(EvaluationContext context, JsValue initialValue, bool skipTestOnce, bool resumeUpdateOnce) + private Completion ForBodyEvaluation(EvaluationContext context, JsValue initialValue, bool skipTestOnce, bool resumeUpdateOnce, bool flattenActive = false) { var v = initialValue; @@ -416,7 +494,20 @@ private Completion ForBodyEvaluation(EvaluationContext context, JsValue initialV var suspendable = context.Engine.ExecutionContext.Suspendable; if (!resumeUpdateOnce) { - var result = _body.Execute(context); + Completion result; + if (flattenActive && !context.DebugMode) + { + // the pooled loop environment carries the body's slots: re-establish their TDZ + // and run the block contents in place. Under a debugger the normal block path + // runs instead — its fresh env shadows the (uninitialized) flattened slots. + var env = (DeclarativeEnvironment) context.Engine.ExecutionContext.LexicalEnvironment; + ResetBodySlotRange(env._slots!, _loopSlotTemplates!, _flattenedHeaderSlotCount); + result = _flattenedBodyBlock!.ExecuteFlattenedContents(context); + } + else + { + result = _body.Execute(context); + } if (!result.Value.IsEmpty) { v = result.Value;