Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions crates/perry-runtime/src/object/native_call_method.rs
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,23 @@ pub unsafe extern "C" fn js_native_call_method_apply_by_id(
)
}

/// The numeric property key of an `obj[key](...)` call, as the raw `f64` index
/// `js_object_get_index_polymorphic` consumes, or `None` when `key` is not a
/// number. Both representations a numeric key can arrive in are accepted: a
/// plain IEEE double (what a boxed `Any` capture reads back as — the #6328
/// async-loop shape) and a NaN-boxed INT32 (the i32 loop-counter lowering).
#[inline]
fn numeric_index_key(key: JSValue) -> Option<f64> {
if key.is_int32() {
return Some(key.as_int32() as f64);
}
// `is_number` accepts every non-Perry-tagged bit pattern, NaN included; a
// NaN key is not an index and would only make the polymorphic read return
// `undefined`, so screen it out here rather than paying for the probe.
let raw = f64::from_bits(key.bits());
(key.is_number() && !raw.is_nan()).then_some(raw)
}

/// Dispatch `obj[key](args)` where `key` is a *runtime value* whose static type
/// is not provably a string (`cur._op`, `arr[i]`, a `let`-rebound key, etc.).
///
Expand Down Expand Up @@ -339,6 +356,39 @@ pub unsafe extern "C" fn js_native_call_method_value(
}
}

// #6328: NUMERIC key — `fns[i](x)`, `resolvers[i](i)`. `js_to_property_key`
// canonicalizes the index to the string `"i"`, and the string branch below
// hands that to `js_native_call_method`, which dispatches by *method name*:
// own-field scan + prototype/class-id chain. An Array's ELEMENT storage is
// none of those, so the lookup misses, the tower returns `undefined`, and
// the call SILENTLY EVAPORATES — no throw, no diagnostic, exit code 0.
//
// Codegen only routes an `arr[i](...)` call here when it cannot prove `i`
// numeric (`try_lower_index_get_call` bails to the array element-call
// lowering when `is_numeric_expr` holds). Inside an async function it never
// can: the async-to-generator transform turns every body local into a
// boxed mutable capture typed `Any`, so `i` reads back as an untyped value
// and the call lands here. That is why `await Promise.all(ps)` evaporated —
// the `for (…) resolvers[i](i)` loop resolved nothing (#6328).
//
// Per spec `obj[k](...)` is Get(obj, k) then Call — the property READ wins.
// Resolve the element/own value first and invoke it when the key names
// something; only fall through to the name tower when it names nothing, so
// a numerically-named vtable method (`class C { 3() {} }`) keeps working.
if !is_symbol_key {
if let Some(index) = numeric_index_key(key_jsval) {
let field =
crate::object::js_object_get_index_polymorphic(object.to_bits() as i64, index);
let fv = JSValue::from_bits(field.to_bits());
if !fv.is_undefined() && !fv.is_null() {
let prev_this = IMPLICIT_THIS.with(|c| c.replace(object.to_bits()));
let result = crate::closure::js_native_call_value(field, args_ptr, args_len);
IMPLICIT_THIS.with(|c| c.set(prev_this));
return result;
}
}
}

let property_key = if is_symbol_key {
key
} else {
Expand Down
102 changes: 102 additions & 0 deletions test-files/test_gap_6328_async_index_call.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// #6328: `await Promise.all([...])` silently evaporated (exit 0, no output) when
// the input promises were settled through executor-captured resolvers invoked
// from a loop.
//
// Root cause was not in the promise machinery at all: `arr[i](x)` — a call whose
// callee is a computed member with a *numeric* key — is routed to
// `js_native_call_method_value` whenever codegen cannot statically prove the key
// numeric. Inside an `async` function it never can (the async-to-generator
// transform turns body locals into boxed `Any` captures), and that runtime helper
// stringified the index and dispatched by METHOD NAME — which can never see an
// Array's element storage. The lookup missed, `undefined` came back, and the call
// was a silent no-op. The loop resolved nothing, so `await all` never resumed.
//
// The `for` loops below matter: at a low trip count the loop is unrolled to
// constant indices, which codegen *can* prove numeric — the bug only shows up
// once the index stays a runtime value.

async function combinator() {
const resolvers: ((v: number) => void)[] = [];
const ps = Array.from(
{ length: 100 },
() => new Promise<number>((res) => { resolvers.push(res); }),
);
const all = Promise.all(ps);
for (let i = 0; i < 100; i++) resolvers[i](i);
const r = await all;
console.log("all", r.length, r[0], r[99]);

const settled = await Promise.allSettled(ps);
console.log("allSettled", settled.length, settled[7].status);
console.log("race", await Promise.race(ps));
console.log("any", await Promise.any(ps));
}

// The underlying defect, with no promise anywhere: calling closures out of an
// array by runtime index, inside an async function.
async function indexCall() {
const fns: ((v: number) => string)[] = [];
for (let i = 0; i < 9; i++) fns.push((v) => "fn:" + v);
const out: string[] = [];
for (let i = 0; i < 9; i++) out.push(fns[i](i));
await 0;
console.log("indexCall", out.join(","));
}

// `this` must still be bound by the dynamic-key dispatch (#321): a numerically
// keyed own method on a plain object reads `this` through the same helper.
async function thisBinding() {
const obj: any = { tag: "obj", 3: function () { return this.tag; } };
let k = 3;
console.log("thisBinding", obj[k]());
await 0;
}

// A numerically named class method still resolves through the vtable — the
// element-read fast path must fall through when the key names no own value.
class Numbered {
3() {
return "vtable-3";
}
}

async function vtable() {
const c: any = new Numbered();
let k = 3;
console.log("vtable", c[k]());
await 0;
}

// A rejecting member must still reject, and `await` must still see it.
async function rejects() {
const resolvers: ((v: number) => void)[] = [];
const rejecters: ((e: unknown) => void)[] = [];
const ps = Array.from(
{ length: 10 },
() =>
new Promise<number>((res, rej) => {
resolvers.push(res);
rejecters.push(rej);
}),
);
const all = Promise.all(ps);
for (let i = 0; i < 9; i++) resolvers[i](i);
rejecters[9](new Error("boom"));
try {
await all;
console.log("rejects UNREACHABLE");
} catch (e) {
console.log("rejects", (e as Error).message);
}
}

async function main() {
await combinator();
await indexCall();
await thisBinding();
await vtable();
await rejects();
console.log("done");
}

main();
Loading