Repro
class Listener {
async start() { return 42; }
}
class Flow {
authCodeListener: any = null;
port: any = null;
async go() {
// minified shape: assignment comma-chained with an await that reads the
// just-assigned field
this.authCodeListener = new Listener, this.port = await this.authCodeListener.start();
return this.port;
}
}
const f = new Flow();
f.go().then((v) => console.log("GOT", v)).catch((e) => console.log("ERR", String(e)));
- node:
GOT 42
- perry-compiled:
ERR TypeError: Cannot read properties of null (reading 'start')
Mechanism
The plain-async pre-pass (async_to_generator.rs) hoists every nested Expr::Await into a let __await_N = await <operand>; above the containing statement. For a comma sequence, that moves the awaited operand's evaluation above the sequence's earlier operands:
// source order: // after hoisting:
this.l = new Listener, let __await_N = await this.l.start(); // reads this.l = null!
this.p = await this.l.start() this.l = new Listener, this.p = __await_N;
Conditional branches (#342) and logical RHS (#5434) already have dedicated lifts for exactly this class of unsound hoist; Expr::Sequence has none. Minifiers/bundlers emit the a = new X, b = await a.m() shape constantly (any const x = new X(); const y = await x.m(); pair gets comma-folded), so real-world async code that works under node fails under perry with a null/undefined member read, or silently computes with stale values when the receiver was previously assigned.
Fix direction
Mirror the #342/#5434 lifts: when a sequence contains an await, lift the non-final operands into statements before the containing statement (each recursively hoisted, preserving evaluation order), leaving the final operand as the expression value. The hoisted __await_N let then lands after the lifted operands. Same shape applies to hoist_awaits_avoiding_top_level (statement-position sequences) and hoist_awaits_in_expr_full (nested sequences). The generator yield hoist (generator/hoist_yields.rs) has the same latent gap for yield-in-sequence and can adopt the identical lift as a follow-up.
Repro
GOT 42ERR TypeError: Cannot read properties of null (reading 'start')Mechanism
The plain-async pre-pass (
async_to_generator.rs) hoists every nestedExpr::Awaitinto alet __await_N = await <operand>;above the containing statement. For a comma sequence, that moves the awaited operand's evaluation above the sequence's earlier operands:Conditional branches (#342) and logical RHS (#5434) already have dedicated lifts for exactly this class of unsound hoist;
Expr::Sequencehas none. Minifiers/bundlers emit thea = new X, b = await a.m()shape constantly (anyconst x = new X(); const y = await x.m();pair gets comma-folded), so real-world async code that works under node fails under perry with anull/undefinedmember read, or silently computes with stale values when the receiver was previously assigned.Fix direction
Mirror the #342/#5434 lifts: when a sequence contains an await, lift the non-final operands into statements before the containing statement (each recursively hoisted, preserving evaluation order), leaving the final operand as the expression value. The hoisted
__await_Nlet then lands after the lifted operands. Same shape applies tohoist_awaits_avoiding_top_level(statement-position sequences) andhoist_awaits_in_expr_full(nested sequences). The generatoryieldhoist (generator/hoist_yields.rs) has the same latent gap foryield-in-sequence and can adopt the identical lift as a follow-up.