Skip to content

codegen: async-generator yield* loop loses a captured local callback #10048

Description

@proggeramlug

Standalone reproduction

async function* delegate(value: number) { yield value; }
async function* outer() {
  let base = 40;
  for (let index = 0; index < 2; index++) {
    yield* delegate(index);
    let getter = () => base + index;
    let wrapper = () => getter();
    yield wrapper();
  }
}
async function main() {
  const values: number[] = [];
  for await (const value of outer()) values.push(value);
  if (values.join(',') !== '0,40,1,41') throw new Error(values.join(','));
  console.log('PASS: delegated loop captures local callable');
}
main().catch(error => { console.error(error); process.exit(1); });

Node 26.5.1 passes. Perry exits 1 with TypeError: value is not a function.
No application, network, child process, credentials, or npm dependency is needed.

Verified on macOS arm64 with a coherently built/frozen compiler and all nine
runtime/provider archives at f3e8d5d1a66753cb00d15da552ad73abb8740520 (Perry
0.5.1532), an explicit diagnostic integration of #10042/#10046 onto fetched main
603b074ac. Native compile uses --platform bun --enable-wasm-runtime --no-auto-optimize, LLVM Oz, and compact shadow-GC settings. This is an exact
tested integration identity, not a claim that those PRs have merged.

Control cases with a local callback after a simple yield, await, or yield*
outside a loop pass. The delegated loop fails. A larger source exposed the same
shape, and LLDB showed the nested closure's getter capture already contains
TAG_UNDEFINED at function entry, before its attempted invocation.

Located mechanism

Retained LLVM has two emitted copies of the loop continuation. The getter's
box-pointer alloca is entry-initialized to undefined. The first copy calls
js_box_alloc_bits and stores the new cell into that slot. The second copy
creates the getter closure and writes/captures through the same slot, but emits
no corresponding box allocation. On that resumed path the slot is still
undefined. This is deterministic initialization/control-flow failure, not
evidence of a moved pointer.

Follow-up native verification narrows this to the general existing-local reuse
guard in codegen/stmt/let_stmt.rs (the #1803 hoisted-var redeclaration path),
before the boxed Let allocation arm. The reproduced getter is an ordinary
boxed Let, not a PreallocateBoxes directive. A PreallocateBoxes-only candidate
in draft #10049 passed its IR tests but still failed both native GC modes; it
was not accepted as a fix. Preserve real hoisted-var/parameter binding identity
while ensuring a reused boxed declaration has a cell before its initializer
or a nested capturing callback executes. Per-iteration and TDZ behavior need
native regression coverage too.

This appears distinct from #10023's nested destructuring/liveness case: this
reproducer has no destructuring, and the declaration is present in the resumed
path while its storage initialization is missing.

Acceptance should include Node/native O0/Os/Oz parity with default/compact GC,
retained per-iteration closures, empty/nonempty delegates, ordinary yield/await
controls, and preallocated/TDZ/hoisted-binding regressions. Keep the fix and
tests independent of any application extraction or bundle.

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions