diff --git a/crates/perry-runtime/src/object/native_call_method.rs b/crates/perry-runtime/src/object/native_call_method.rs index 55a5bc33f..b30806334 100644 --- a/crates/perry-runtime/src/object/native_call_method.rs +++ b/crates/perry-runtime/src/object/native_call_method.rs @@ -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 { + 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.). /// @@ -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 { diff --git a/test-files/test_gap_6328_async_index_call.ts b/test-files/test_gap_6328_async_index_call.ts new file mode 100644 index 000000000..f6dcfb112 --- /dev/null +++ b/test-files/test_gap_6328_async_index_call.ts @@ -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((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((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();