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:
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.
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
yieldin a loop's condition or update work when the loop is adirect statement of the generator body. The same loop nested inside
if,try,switchor a labeled statement isnot split into resume states, so the
yieldnever suspends and the generator yields nothing.Reproduction
main.ts:node main.ts perry compile main.ts -o main && ./mainExpected (Node 26.5.1)
Actual (Perry)
Same result with and without
PERRY_NO_AUTO_OPTIMIZE=1:Impact
import+nodetodist/esm/node/index.min.js. The esbuild-minified privategenerators
*#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.jsmatches Node.if (x) for (...),so any minified generator may hit this.
if (n) { while ((v = yield count) !== "stop" && count < 5) count++; } return count;finishes with
{"done":true}(valueundefined). Node gives{"value":2,"done":true}. With nocountbound, thesame loop never ends in Perry and allocates without limit.
Notes
Results (
.jsand.ts;[...g(1)]unless noted):for (...; <yield in test>; )/while (<yield in test>)/for (...; ...; t = (yield t, t - 1))directly in the generator bodyforbody, or inside a bare block{ ... }if (n)(braced or not), or theelsebranch[]if (n)containingdo { } while ((yield t), t > 0)[]try { } finally { }[]switchcase[]outer: for (...; (yield t), t > 0; ), with or without an enclosingif[]if (n) for (let t = 0; t < 3; t++) yield t(yield in the body)async function*withif (n) for (...; ((yield t), t > 0); )[](for-await)async functionwithif (n) while (await p)Cause (verified by reading the code; the fix direction is inferred):
linearize_body:Whileatcrates/perry-transform/src/generator/linearize.rs:686DoWhileatlinearize.rs:729For(condition/update) atlinearize.rs:769body_contains_yieldsays it contains asuspend point:
Ifatlinearize.rs:1351-1356Tryatlinearize.rs:1126-1132Labeledatlinearize.rs:1594Switchatlinearize.rs:1660-1665body_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 anifwhoseonly
yieldis in a nested loop header is treated as yield-free and emitted inline. The residualExpr::Yielddoesnot suspend: the loop runs to completion and yields nothing.
body_contains_yieldalso checkWhile/DoWhileconditions andForcondition/update withhoist_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.