Skip to content

Engine: closures captured inside yielded for-loop init or condition pin to orphaned scope after resume #637

@frostney

Description

@frostney

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):

  1. Create HeaderScope and call EnsureForLoopState(stmt, HeaderScope) before running Init. Introduce a gflpInit phase.
  2. 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.
  3. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    engineTGocciaEngine: language semantics, ECMAScript built-ins, parser, interpreter, bytecode VMspec complianceMismatch against official JavaScript/TypeScript specification

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions