Skip to content

Generator never suspends on a yield in a loop condition/update when the loop is nested in if/try/switch/label #10419

Description

@proggeramlug

Found by the package audit (compiling real npm packages from source instead of Perry's native bindings) on
Perry e6dcb62 (v0.5.1587), Linux x64. #5933 made a yield in a loop's condition or update work when the loop is a
direct statement of the generator body. The same loop nested inside if, try, switch or a labeled statement is
not split into resume states, so the yield never suspends and the generator yields nothing.

Reproduction

main.ts:

function* noIf() {
  for (let t = 2; t >= 0 && ((yield t), t !== 0); ) t = t - 1;
}
function* forInIf(n: number) {
  if (n) for (let t = 2; t >= 0 && ((yield t), t !== 0); ) t = t - 1;
}
function* whileInIf(n: number) {
  let t = 2;
  if (n) while (((yield t), t > 0)) t = t - 1;
}
function* forInTry() {
  try { for (let t = 2; ((yield t), t > 0); ) t = t - 1; } finally {}
}
console.log([...noIf()]);
console.log([...forInIf(1)]);
console.log([...whileInIf(1)]);
console.log([...forInTry()]);
node main.ts
perry compile main.ts -o main && ./main

Expected (Node 26.5.1)

[ 2, 1, 0 ]
[ 2, 1, 0 ]
[ 2, 1, 0 ]
[ 2, 1, 0 ]

Actual (Perry)

Same result with and without PERRY_NO_AUTO_OPTIMIZE=1:

[ 2, 1, 0 ]
[]
[]
[]

Impact

  • lru-cache 11.5.2: Perry resolves import+node to dist/esm/node/index.min.js. The esbuild-minified private
    generators *#A (indexes) and *#z (rindexes) have exactly this shape:
    *#A({allowStale:e=this.allowStale}={}){if(this.#n)for(let t=this.#h;this.#V(t)&&((e||!this.#p(t))&&(yield t),t!==this.#a);)t=this.#u[t]}.
    All iteration APIs are empty: keys, values, entries, rkeys, forEach, for...of, dump. clear()
    also skips disposing the remaining entries. The non-minified dist/esm/node/index.js matches Node.
  • Minifiers routinely fold a loop body's first statement into the loop test and drop braces around if (x) for (...),
    so any minified generator may hit this.
  • Sent values are lost the same way: if (n) { while ((v = yield count) !== "stop" && count < 5) count++; } return count;
    finishes with {"done":true} (value undefined). Node gives {"value":2,"done":true}. With no count bound, the
    same loop never ends in Perry and allocates without limit.

Notes

Results (.js and .ts; [...g(1)] unless noted):

shape Perry
for (...; <yield in test>; ) / while (<yield in test>) / for (...; ...; t = (yield t, t - 1)) directly in the generator body OK
same loop inside a nested for body, or inside a bare block { ... } OK
same loop inside if (n) (braced or not), or the else branch []
if (n) containing do { } while ((yield t), t > 0) []
loop inside try { } finally { } []
loop inside switch case []
labeled loop outer: for (...; (yield t), t > 0; ), with or without an enclosing if []
if (n) for (let t = 0; t < 3; t++) yield t (yield in the body) OK
async function* with if (n) for (...; ((yield t), t > 0); ) [] (for-await)
async function with if (n) while (await p) OK

Cause (verified by reading the code; the fix direction is inferred):

  • The per-iteration rewrite of a yielding loop header exists only as arms of linearize_body:
    • While at crates/perry-transform/src/generator/linearize.rs:686
    • DoWhile at linearize.rs:729
    • For (condition/update) at linearize.rs:769
  • A compound statement is only descended into for linearization when body_contains_yield says it contains a
    suspend point:
    • If at linearize.rs:1351-1356
    • Try at linearize.rs:1126-1132
    • Labeled at linearize.rs:1594
    • Switch at linearize.rs:1660-1665
  • body_contains_yield (crates/perry-transform/src/generator/break_continue.rs:276-371) inspects only loop bodies
    (While :326, DoWhile :332, For :338), never a loop's condition or update expression. So an if whose
    only yield is in a nested loop header is treated as yield-free and emitted inline. The residual Expr::Yield does
    not suspend: the loop runs to completion and yields nothing.
  • Possible fix: make body_contains_yield also check While/DoWhile conditions and For condition/update with
    hoist_yields::expr_contains_yield.

Related: closed #5933 (await/yield in loop condition or update position evaluated once). Its fix covered only the
top-level loop arms.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    package-auditFound by the 2026 package audit: compiling real npm packages from source instead of native bindings

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions