From 44c66f2130511997fbf097ec7fcb8ddd110c0039 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 14 Jul 2026 08:15:26 +0200 Subject: [PATCH] fix(runtime): a bare heap pointer receiver was misdispatched as a number MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A method call on a valid object threw `TypeError: is not a function` when the receiver arrived as a BARE heap pointer — a real object address that was never NaN-boxed. Its top 16 bits are zero, so it decodes as a denormal double; the dispatcher classified the receiver as a primitive `"number"` and threw, on an object that was sitting right there in memory. Observed receiver: `bits=0x0000028591712e78` (~2.7 TB — a live heap address, well above the heap floor), reported as `as_f64 = 1.37e-312`. Perry already recovers bare pointers on other dispatch paths; this one threw before it ever got the chance. The primitive-kind classification now first checks whether a "number" receiver is in fact an object pointer: zero high bits, above the handle band, and a genuinely GC-tracked allocation. All three checks are dereference-free (magnitude + page-map/registry lookups), so a forged value is rejected without a load. On a match it reboxes as a POINTER_TAG value and dispatches on the object it actually is; otherwise the primitive path is unchanged. Found in a large esbuild-bundled CLI app whose terminal UI was completely unresponsive to the keyboard: every keystroke reached the input handler, which threw this TypeError, and the framework swallowed it in a try/catch — so the app painted correctly and silently ignored all input. With the recovery the keyboard works (menus open, arrows navigate, selection commits) and a `RangeError` that rode along with it disappears too. perry-runtime: 1309 passed / 0 failed. Note: this is a guard at the dispatch boundary, matching perry's existing bare-pointer recovery elsewhere. Whatever hands out an unboxed pointer upstream is a separate defect worth its own hunt; this stops it from turning a live object into a "number". --- .../src/object/native_call_method.rs | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/crates/perry-runtime/src/object/native_call_method.rs b/crates/perry-runtime/src/object/native_call_method.rs index 84246233ee..eb24f29741 100644 --- a/crates/perry-runtime/src/object/native_call_method.rs +++ b/crates/perry-runtime/src/object/native_call_method.rs @@ -1609,6 +1609,30 @@ pub unsafe extern "C" fn js_native_call_method( } } } + // A BARE heap pointer — a real object whose value was never NaN-boxed, so its + // top 16 bits are zero and it decodes as a denormal double. Classifying it as a + // "number" here throws ` is not a function` on a perfectly good object. + // Validate deref-free (above the handle band + a genuinely tracked allocation), + // rebox as a POINTER_TAG value and dispatch on the object it actually is. + // Perry already recovers bare pointers on other dispatch paths; this one threw + // before it ever got the chance. + if jsval.is_number() && (jsval.bits() >> 48) == 0 { + let raw = jsval.bits() as usize; + if crate::value::addr_class::is_above_handle_band(raw) + && crate::value::addr_class::is_valid_obj_ptr(raw as *const u8) + { + let reboxed = crate::value::JSValue::pointer(raw as *const u8); + if reboxed.bits() != jsval.bits() { + return js_native_call_method( + f64::from_bits(reboxed.bits()), + method_name_ptr, + method_name_len, + args_ptr, + args_len, + ); + } + } + } let primitive_kind: Option<&'static str> = if jsval.is_any_string() { Some("string") } else if jsval.is_int32() || jsval.is_number() {