Skip to content

fix(runtime): numeric-key computed calls must read the element, not dispatch by name (#6328)#6344

Open
proggeramlug wants to merge 1 commit into
mainfrom
fix/6328-await-promise-all-evaporates
Open

fix(runtime): numeric-key computed calls must read the element, not dispatch by name (#6328)#6344
proggeramlug wants to merge 1 commit into
mainfrom
fix/6328-await-promise-all-evaporates

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Fixes #6328.

TL;DR

The promise machinery was never at fault, and Promise.all is not needed to reproduce. The real defect is that arr[i](x) — a call whose callee is a computed member with a numeric key — was a silent no-op inside an async function. The reported await Promise.all([...]) hang is a downstream symptom: the for (…) resolvers[i](i) loop resolved nothing.

Root cause

try_lower_index_get_call (perry-codegen/src/lower_call/early_branches.rs) bails out to the array element-call lowering only when it can prove the index numeric:

if crate::type_analysis::is_numeric_expr(ctx, index) && !object_is_class_ref {
    return Ok(None);   // → real element-call lowering
}

Otherwise the call goes to js_native_call_method_value, the dynamic-key this-binding path added for #321 (effect's this[(cur)._op](cur)).

Inside an async function that proof is never available: the async-to-generator transform turns every body local into a boxed mutable capture typed Any, so the loop counter reads back untyped. Confirmed in the HIR — the step closure carries captures: [0, 1, 3, …], mutable_captures: [0, 1, 3, …], and the call lowers as Call { callee: IndexGet { object: LocalGet(0), index: LocalGet(3) } }.

js_native_call_method_value then ran the key through js_to_property_key, which canonicalizes the index to the string "3", and handed that to js_native_call_method — the dispatch tower, which resolves by method name (own-field scan + prototype/class-id chain). An Array's element storage is none of those. The lookup missed, undefined came back, and the call evaporated. No throw, no diagnostic.

So the loop resolved nothing, await all waited on a promise nobody would ever settle, the event loop drained, and the process exited 0 having printed nothing.

This also explains every discriminator in the issue:

observation why
.then() form works main has no await → not transformed → locals stay typed → codegen proves the index numeric → correct lowering
setTimeout form works same — the resolve loop moves out of the async body
N=8 works, N=9 fails the loop unroller: at a low trip count the indices const-fold to literals, which codegen can prove numeric

The 8-vs-9 threshold was the tell — it is an unroll boundary, not an array-capacity or promise-count boundary.

Fix

One runtime change, in js_native_call_method_value. Per spec obj[k](...) is Get(obj, k) then Callthe property read wins. For a numeric key, resolve the element/own value through js_object_get_index_polymorphic first and invoke it when the key names something; fall through to the name tower when it names nothing, so a numerically-named vtable method (class C { 3() {} }) keeps its dispatch. this stays bound on both paths.

Deliberately not fixed in codegen: the type information genuinely is not recoverable there (both the array and the index are untyped boxed captures), so the runtime is the only place this can be made correct. The sync fast path is untouched.

Shape matrix vs node 26 (all byte-identical, exit codes match)

shape before after
N=1 / N=2 / N=8 pass pass
N=9 / N=100 (Promise.all) silent, exit 0 pass
Promise.allSettled / race / any, N=100 silent, exit 0 pass
.then() form, N=100 pass pass
resolvers fired from setTimeout pass pass
await already-resolved plain promise pass pass
promise settling synchronously inside its executor pass pass
rejecting memberawait throws, catch sees it pass pass
unhandled combinator rejection → exit 1 + report (#6077) pass pass
arr[i](x) with no promise anywhere (the real bug) silent no-op pass
this-binding via numeric key on a plain object (#321) pass pass
class C { 3() {} } vtable dispatch pass pass

Validation

  • Cross-controlled archive A/B (runtime-only change, so the libperry_runtime.a staleness trap is real — perry-runtime is rlib-only; the archive comes from perry-runtime-static): base compiler + my archive → fixed; my compiler + base archive → bug returns. The archive is decisively the causal factor.
  • Gap suite A/B, 297 tests, against a pristine origin/main build (735e894): zero regressions. All 296 shared tests produce identical output. The single sha delta, test_gap_module_const_local_shadow, is already triaged in known_failures.json and reads uninitialised memory — its garbage float drifts on every run of the same binary, on base and on this branch alike.
  • cargo test -p perry-runtime: 1285/1285 green (--test-threads=1). The parallel suite flakes on test_array_exotic_descriptors_and_global_prototype_identity, stream_constructors_expose_static_method_values and two gc::telemetry_verifier tests — pre-existing shared-realm races that reproduce at the same rate on pristine main.
  • cargo fmt --all -- --check clean; scripts/check_file_size.sh clean.

Regression fixture

test-files/test_gap_6328_async_index_call.ts — asserts exit code as well as stdout, since the failure mode is a silent exit 0. Covers the combinator shapes, the underlying arr[i](x) defect with no promise involved, this-binding, vtable fall-through, and the rejecting-member path.

Found while here — separate bug, NOT fixed in this PR

Per-iteration let/const binding collapses for closures created in a loop body inside an async function: every closure sees the last iteration's value.

async function main() {
  const fns: (() => void)[] = [];
  for (let i = 0; i < 9; i++) { const j = i; fns.push(() => console.log(j)); }
  fns.forEach(f => f());   // node: 0..8   perry: 8,8,8,8,8,8,8,8,8
  await 0;
}

Same root family (the async transform boxes body locals into one shared cell) but a distinct fix in the transform, not the runtime. It is pre-existing and independent — it reproduces on unpatched main through a call shape that already worked (const f = fns[i]; f(i)), so this PR neither causes nor masks it. Filing separately.

Overlap with #6327

None. #6327 (fix/6084-promise-all-and-freeze-fastpath) rewrites the promise side tables (PROMISE_SETTLE_LISTENERS, PROMISE_OVERFLOW_REACTIONS, PROMISE_ALL_STATES) with an O(1) index — a perf change in promise/{combinators,reactions,scanners}.rs + a new keyed_table.rs. This PR touches only object/native_call_method.rs. No file overlap, no semantic overlap, and #6327 would not have fixed this (the bug is upstream of the promise tables — the promises were never settled at all). Based on origin/main; merges cleanly in either order.

Summary by CodeRabbit

  • Bug Fixes

    • Fixed calls to functions and methods accessed through runtime numeric keys.
    • Preserved correct this binding and class-method dispatch for numeric-key access.
    • Improved reliability for asynchronous operations involving indexed callbacks and promises.
    • Ensured rejecting promises continue to propagate errors correctly.
  • Tests

    • Added coverage for indexed function calls, method binding, class dispatch, and promise combinators.

…ispatch by name (#6328)

`await Promise.all([...])` silently evaporated — no output, exit code 0 — when
the input promises were settled through executor-captured resolvers invoked from
a loop:

    const ps = Array.from({length: 100}, () => new Promise(r => resolvers.push(r)));
    const all = Promise.all(ps);
    for (let i = 0; i < 100; i++) resolvers[i](i);
    const r = await all;              // never resumes

The promise machinery was not at fault. `Promise.all` is not even required to
reproduce: the defect is that `arr[i](x)` — a call whose callee is a computed
member with a *numeric* key — was a silent no-op inside an `async` function.

Codegen's `try_lower_index_get_call` bails out to the array element-call lowering
only when `is_numeric_expr` proves the index numeric; otherwise the call goes to
`js_native_call_method_value` (the #321 dynamic-key `this`-binding path). Inside
an async function it can never prove it: the async-to-generator transform turns
every body local into a boxed mutable capture typed `Any`, so the loop counter
reads back untyped. `js_native_call_method_value` then ran the key through
`js_to_property_key`, which canonicalizes the index to the string "3", and handed
that to the dispatch tower — which resolves by METHOD NAME (own-field scan +
prototype/class-id chain). An Array's ELEMENT storage is none of those, so the
lookup missed, `undefined` came back, and the call vanished. The loop resolved
nothing; `await all` waited on a promise nobody would ever settle; the event loop
drained and the process exited 0.

The 8-vs-9 threshold in the report is the loop unroller: at a low trip count the
indices const-fold to literals, which codegen *can* prove numeric.

Fix: per spec `obj[k](...)` is Get(obj, k) then Call — the property read wins.
For a numeric key, resolve the element/own value through
`js_object_get_index_polymorphic` first and invoke it when the key names
something. Fall through to the name tower when it names nothing, so a
numerically-named vtable method (`class C { 3() {} }`) keeps its dispatch, and
`this` stays bound either way.

Fixture asserts exit code as well as stdout — the failure mode is a silent exit 0.
@coderabbitai

coderabbitai Bot commented Jul 13, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: ce7f35b5-b405-4701-a31b-70966f6c02a7

📥 Commits

Reviewing files that changed from the base of the PR and between be5ed2b and 97f6e5c.

📒 Files selected for processing (2)
  • crates/perry-runtime/src/object/native_call_method.rs
  • test-files/test_gap_6328_async_index_call.ts

📝 Walkthrough

Walkthrough

Adds numeric-key resolution to native method calls and a regression test covering async promise combinators, indexed function calls, this binding, class dispatch, and rejection handling.

Changes

Numeric method calls

Layer / File(s) Summary
Numeric key resolution and invocation
crates/perry-runtime/src/object/native_call_method.rs
Canonicalizes valid numeric keys, probes indexed fields, preserves fallback behavior, and invokes resolved callables with the receiver bound as this.
Async indexed-call regression coverage
test-files/test_gap_6328_async_index_call.ts
Covers indexed promise resolvers, function calls, plain-object receiver binding, numeric class methods, rejection handling, and sequential async execution.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant js_native_call_method_value
  participant js_object_get_index_polymorphic
  participant js_native_call_value
  js_native_call_method_value->>js_object_get_index_polymorphic: read numeric indexed field
  js_object_get_index_polymorphic-->>js_native_call_method_value: return indexed value
  js_native_call_method_value->>js_native_call_value: invoke callable with implicit this
Loading

Possibly related PRs

  • PerryTS/perry#5544: Both modify numeric indexed property lookup paths involving js_object_get_index_polymorphic.
  • PerryTS/perry#6262: Both adjust runtime behavior for numeric/indexed property access.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the runtime fix for numeric-key computed calls.
Description check ✅ Passed The description is detailed and covers the bug, fix, issue reference, and validation, though it doesn’t follow the template headings exactly.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/6328-await-promise-all-evaporates

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

runtime: await Promise.all([...]) never resumes when inputs settle via executor-captured resolvers — program silently exits 0

1 participant