Summary
When a closure is captured inside a for-loop's init or condition (any sub-step before the body), and a yield in that same sub-step suspends evaluation, the resume path re-creates the underlying scope. Closures created before the yield therefore pin to the orphaned scope rather than the live LexicalEnvironment that subsequent iterations use. The yield-expression's own resume machinery (interpreter FSuspendedYield, bytecode equivalent) hides this for almost all patterns, but the divergence is observable when anything else holds a reference to the orphaned scope.
Both interpreter and bytecode have this divergence today — they behave identically — so fixing it requires parallel changes in both engines. PR #633 explicitly deferred this; the scope of that PR was the loop-counter-reset symptom from #538.
Why
ES2026 §14.7.4.2 ForBodyEvaluation creates exactly one LexicalEnvironment for the for-header (Goccia's HeaderScope) and one per-iteration env (IterScope) per logical iteration. Since the generator can suspend at any abstract closure within init/test/body/update, every closure captured inside those sub-steps must pin to the same env on resume — there is no re-snapshot in the spec.
Today, both engines:
- Create
HeaderScope afresh on every entry to EvaluateFor, so a yield in init re-creates it on the next g.next().
- Create
IterScope afresh on every gflpIterStart, so a yield in cond re-creates it on the next resume.
Reproducer
Closure captured during init, init yields, then accessed in body:
const obj = {
*gen() {
let snap;
for (let i = (snap = () => i, yield 'init'); i < 1; i++) {
yield snap();
}
},
};
const g = obj.gen();
g.next();
console.log(g.next(0).value); // spec: 0
// interpreter+bytecode: undefined (snap pinned to orphaned HeaderScope)
Run with --compat-traditional-for-loop.
Current behavior
Both interpreter and bytecode return undefined for snap(). Multi-binding init like let i = 'a', j = yield 'mid', k = 'last' does round-trip correctly because the let-binding values are re-evaluated and re-assigned in the new scope — the divergence only shows up when something else holds a reference to the orphaned scope.
Expected behavior
The single LexicalEnvironment persists across yields. The closure sees the value i ended up with after the resume (0 in the reproducer).
Likely fix
Interpreter (source/units/Goccia.Evaluator.pas, EvaluateFor):
- Create
HeaderScope and call EnsureForLoopState(stmt, HeaderScope) before running Init. Introduce a gflpInit phase.
- On resume with
Phase = gflpInit, restore HeaderScope (don't re-create) and continue init — the var-decl re-runs in the saved scope, and the existing EvaluateExpression cache + FSuspendedYield machinery handle replay of completed sub-expressions.
- Move
ForState.IterScope := IterScope and Phase := gflpBody to immediately after creating IterScope (before cond) rather than after cond passes.
Bytecode: mirror the change in the VM frame's loop state — only the when of capture needs to shift. Verify against the for-yield test set in tests/language/for-loop/ and tests/language/for-loop-var/.
Scope notes
Summary
When a closure is captured inside a for-loop's
initorcondition(any sub-step before the body), and ayieldin that same sub-step suspends evaluation, the resume path re-creates the underlying scope. Closures created before the yield therefore pin to the orphaned scope rather than the live LexicalEnvironment that subsequent iterations use. The yield-expression's own resume machinery (interpreterFSuspendedYield, bytecode equivalent) hides this for almost all patterns, but the divergence is observable when anything else holds a reference to the orphaned scope.Both interpreter and bytecode have this divergence today — they behave identically — so fixing it requires parallel changes in both engines. PR #633 explicitly deferred this; the scope of that PR was the loop-counter-reset symptom from #538.
Why
ES2026 §14.7.4.2 ForBodyEvaluation creates exactly one LexicalEnvironment for the for-header (Goccia's
HeaderScope) and one per-iteration env (IterScope) per logical iteration. Since the generator can suspend at any abstract closure within init/test/body/update, every closure captured inside those sub-steps must pin to the same env on resume — there is no re-snapshot in the spec.Today, both engines:
HeaderScopeafresh on every entry toEvaluateFor, so a yield in init re-creates it on the nextg.next().IterScopeafresh on everygflpIterStart, so a yield in cond re-creates it on the next resume.Reproducer
Closure captured during init, init yields, then accessed in body:
Run with
--compat-traditional-for-loop.Current behavior
Both interpreter and bytecode return
undefinedforsnap(). Multi-binding init likelet i = 'a', j = yield 'mid', k = 'last'does round-trip correctly because the let-binding values are re-evaluated and re-assigned in the new scope — the divergence only shows up when something else holds a reference to the orphaned scope.Expected behavior
The single LexicalEnvironment persists across yields. The closure sees the value
iended up with after the resume (0in the reproducer).Likely fix
Interpreter (
source/units/Goccia.Evaluator.pas,EvaluateFor):HeaderScopeand callEnsureForLoopState(stmt, HeaderScope)before runningInit. Introduce agflpInitphase.Phase = gflpInit, restoreHeaderScope(don't re-create) and continue init — the var-decl re-runs in the saved scope, and the existingEvaluateExpressioncache +FSuspendedYieldmachinery handle replay of completed sub-expressions.ForState.IterScope := IterScopeandPhase := gflpBodyto immediately after creatingIterScope(before cond) rather than after cond passes.Bytecode: mirror the change in the VM frame's loop state — only the when of capture needs to shift. Verify against the for-yield test set in
tests/language/for-loop/andtests/language/for-loop-var/.Scope notes
updatepin wrong env). Engine: closures created inside for(;;) update pin wrong environment per ES2026 §14.7.4.4 #539 is about a structural two-scope-per-iteration semantics; this issue is about preserving the single env across a yielded sub-step.