From c95739daba252e5d918370a93e0ba4047e17b31f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 25 Sep 2026 00:35:31 +0000 Subject: [PATCH 1/2] fix(runtime): a primitive is never instanceof a native class (#11261) The native brand probes behind instanceof EventEmitter (and net.Socket, AsyncLocalStorage, http.Agent, ...) resolve their operand through small_native_handle_id, which also accepts a plain positive integral number as a handle id. A number equal to a live emitter's handle id was therefore an EventEmitter. Apply OrdinaryHasInstance step 3 in both instanceof entry points, after a user Symbol.hasInstance has had its turn. --- crates/perry-runtime/src/object/instanceof.rs | 127 ++++++++++++++++++ .../src/object/instanceof/dynamic_dispatch.rs | 14 ++ .../src/object/instanceof/static_dispatch.rs | 6 + ...t_gap_11261_primitive_instanceof_native.ts | 123 +++++++++++++++++ 4 files changed, 270 insertions(+) create mode 100644 test-files/test_gap_11261_primitive_instanceof_native.ts diff --git a/crates/perry-runtime/src/object/instanceof.rs b/crates/perry-runtime/src/object/instanceof.rs index 3d2aa004a4..293b98782c 100644 --- a/crates/perry-runtime/src/object/instanceof.rs +++ b/crates/perry-runtime/src/object/instanceof.rs @@ -73,6 +73,43 @@ fn small_native_handle_id(value: f64) -> Option { None } +/// OrdinaryHasInstance step 3 (ECMA-262 7.3.21) for the `instanceof` entry +/// points: "If Type(O) is not Object, return false." Checked AFTER a +/// user-defined `@@hasInstance` has had its turn (that hook may legitimately +/// answer `true` for a primitive) and BEFORE any native brand probe. +/// +/// #11261: the native probes resolve their operand through +/// `small_native_handle_id`, which also accepts a plain positive integral +/// number as a registry handle id. So `3 instanceof EventEmitter` answered +/// `true` whenever a live emitter happened to hold handle id 3 — the +/// perry-stdlib-bundled emitter mints ids from 1 (the shared FFI pool), so +/// the first few emitters in a program made small numbers "emitters". +/// +/// Decided from the tag alone, without dereferencing: `undefined`, `null`, +/// booleans, strings (heap and inline), bigints, INT32 numbers that are not +/// registered class references (a class ref is a callable constructor), and +/// IEEE doubles. The raw-bitcast band (top 16 bits zero, non-zero bits) is +/// deliberately NOT classified here: legacy raw heap pointers and raw handle +/// ids share it with subnormal numbers, and the downstream probes already +/// decode it. Symbols are POINTER-tagged heap cells; classifying them needs a +/// registry lookup that would tax every object operand, and no native probe +/// matches a symbol cell, so they are left to the existing paths. +#[inline] +fn instanceof_lhs_is_primitive(value: f64) -> bool { + let bits = value.to_bits(); + if (bits >> 48) == 0 && bits != 0 { + return false; + } + let jv = crate::JSValue::from_bits(bits); + jv.is_undefined() + || jv.is_null() + || jv.is_bool() + || jv.is_any_string() + || jv.is_bigint() + || jv.is_number() + || (jv.is_int32() && class_ref_id(value).is_none()) +} + /// Candidate heap address of an `instanceof` operand; 0 for every primitive. /// #10479: this used to treat every tag band `>= 0x7FF8` as a pointer, so a /// 1-5 byte inline string (or an INT32 class ref) reached @@ -906,3 +943,93 @@ mod generic_origin_chain_tests { assert!(class_chain_reaches(0x0757_50FE, 0x0757_50FE)); } } + +#[cfg(test)] +mod primitive_lhs_native_brand_tests_11261 { + use super::*; + + /// A handle id no other runtime unit test mints. The probe below answers + /// `true` for exactly this id, so leaving it registered after the test + /// cannot change another test's verdict. + const PROBE_HANDLE: i64 = 0x3_1261; + + unsafe extern "C" fn probe_answers_for_one_handle(handle: i64) -> bool { + handle == PROBE_HANDLE + } + + fn truthy(v: f64) -> bool { + v.to_bits() == crate::value::TAG_TRUE + } + + #[test] + fn lhs_primitive_classification_is_tag_only() { + let primitives = [ + f64::from_bits(crate::value::TAG_UNDEFINED), + f64::from_bits(crate::value::TAG_NULL), + f64::from_bits(crate::value::TAG_TRUE), + f64::from_bits(crate::value::TAG_FALSE), + f64::from_bits(crate::value::STRING_TAG | 0x1000), + f64::from_bits(crate::value::SHORT_STRING_TAG | 0x0300_0069_7275), + f64::from_bits(crate::value::BIGINT_TAG | 0x1000), + // An INT32 payload that is not a registered class id. + f64::from_bits(crate::value::INT32_TAG | 0x7654_3210), + 0.0, + -0.0, + 3.0, + PROBE_HANDLE as f64, + 1.5, + -7.0, + f64::NAN, + f64::INFINITY, + ]; + for v in primitives { + assert!( + instanceof_lhs_is_primitive(v), + "{:#018x} must classify as a primitive instanceof operand", + v.to_bits() + ); + } + let not_primitive = [ + // A POINTER-tagged registry handle and a heap address. + f64::from_bits(crate::value::POINTER_TAG | PROBE_HANDLE as u64), + f64::from_bits(crate::value::POINTER_TAG | 0x7F12_3456_7890), + // The raw-bitcast band is left to the downstream decoders. + f64::from_bits(PROBE_HANDLE as u64), + ]; + for v in not_primitive { + assert!( + !instanceof_lhs_is_primitive(v), + "{:#018x} must not classify as a primitive", + v.to_bits() + ); + } + } + + /// #11261: a plain number equal to a live emitter's handle id answered + /// `true` for `n instanceof EventEmitter`. The probe is live for the + /// POINTER-tagged handle (witness), and must not be consulted for the + /// number with the same value. + #[test] + fn number_equal_to_a_live_emitter_handle_is_not_an_emitter() { + let previous = crate::object::event_emitter_handle_probe(); + unsafe { + crate::object::js_register_event_emitter_handle_probe(probe_answers_for_one_handle) + }; + + let handle = f64::from_bits(crate::value::POINTER_TAG | PROBE_HANDLE as u64); + let witness = truthy(js_instanceof(handle, CLASS_ID_EVENT_EMITTER)); + let number = truthy(js_instanceof(PROBE_HANDLE as f64, CLASS_ID_EVENT_EMITTER)); + + if let Some(prev) = previous { + unsafe { crate::object::js_register_event_emitter_handle_probe(prev) }; + } + assert!( + witness, + "the probe must be live: the handle itself is an emitter" + ); + assert!( + !number, + "a number is never instanceof EventEmitter (#11261)" + ); + } +} diff --git a/crates/perry-runtime/src/object/instanceof/dynamic_dispatch.rs b/crates/perry-runtime/src/object/instanceof/dynamic_dispatch.rs index ef93b4d17a..064fb7bb47 100644 --- a/crates/perry-runtime/src/object/instanceof/dynamic_dispatch.rs +++ b/crates/perry-runtime/src/object/instanceof/dynamic_dispatch.rs @@ -75,6 +75,20 @@ pub extern "C" fn js_instanceof_dynamic(value: f64, type_ref: f64) -> f64 { } } } + // OrdinaryHasInstance step 3 (#11261): a primitive is never an instance. + // Only once the RHS is known callable — a non-callable RHS must still + // reach the `TypeError` below (InstanceofOperator step 4 precedes + // OrdinaryHasInstance). A class reference or class object RHS still + // forwards to `js_instanceof`, which applies the same rule only after the + // class's own static `@@hasInstance` hook (lifted per class id) has had + // its turn. + if instanceof_lhs_is_primitive(value) + && value_is_callable(type_ref) + && class_ref_id(type_ref).is_none() + && !is_class_object_value(type_ref) + { + return f64::from_bits(TAG_FALSE); + } // Native http(s).Agent handles have no heap prototype chain. After any own // override above has had first refusal, retain their native brand check. if let Some((module, method)) = unsafe { bound_native_callable_module_and_method(type_ref) } { diff --git a/crates/perry-runtime/src/object/instanceof/static_dispatch.rs b/crates/perry-runtime/src/object/instanceof/static_dispatch.rs index a2190aa444..80eb661d28 100644 --- a/crates/perry-runtime/src/object/instanceof/static_dispatch.rs +++ b/crates/perry-runtime/src/object/instanceof/static_dispatch.rs @@ -79,6 +79,12 @@ pub extern "C" fn js_instanceof(value: f64, class_id: u32) -> f64 { } } + // OrdinaryHasInstance step 3: once no user `@@hasInstance` answered, a + // primitive is never an instance of anything (#11261). Without this a + // plain number reached the native brand probes below as a handle id. + if instanceof_lhs_is_primitive(value) { + return false_val; + } // Subclass-of-built-in: see `subclass_of_builtin_reaches`. if subclass_of_builtin_reaches(value, class_id) { return true_val; diff --git a/test-files/test_gap_11261_primitive_instanceof_native.ts b/test-files/test_gap_11261_primitive_instanceof_native.ts new file mode 100644 index 0000000000..a049d4554d --- /dev/null +++ b/test-files/test_gap_11261_primitive_instanceof_native.ts @@ -0,0 +1,123 @@ +// #11261: `3 instanceof EventEmitter` printed `true`. The native brand probes +// behind `instanceof EventEmitter` (and net.Socket, AsyncLocalStorage, …) +// resolved their operand as a registry handle id, and that resolution also +// accepted a plain positive integral NUMBER. So any number equal to a live +// emitter's handle id was an "emitter". Which ids are live depends on how +// `node:events` is linked: the perry-stdlib-bundled emitter mints ids from 1, +// the perry-ext-events wrapper from 0x38000 — so this sweeps the whole handle +// band [1, 0x40000) instead of guessing one id. +// +// OrdinaryHasInstance step 3: a primitive is never `instanceof` anything +// (unless a user `Symbol.hasInstance` says otherwise — covered at the end). +import { EventEmitter } from "node:events"; + +const rt = (v: T): T => JSON.parse(JSON.stringify(v)); + +// Several live emitters, so small ids AND the wrapper's band are occupied. +const emitters: EventEmitter[] = []; +for (let i = 0; i < 8; i++) emitters.push(new EventEmitter()); + +class Plain {} +class Child extends Plain {} +class MyEmitter extends EventEmitter {} +const mine = new MyEmitter(); + +// Static-RHS sweep over the handle band. +let eeHits = 0; +let firstHit = -1; +for (let n = 1; n < 0x40000; n++) { + const v: any = rt(n); + if (v instanceof EventEmitter) { + eeHits++; + if (firstHit < 0) firstHit = n; + } +} +console.log("numbers instanceof EventEmitter:", eeHits, "first:", firstHit); + +// Dynamic-RHS sweep (constructor held in a variable). +const EEdyn: any = [EventEmitter][rt(0)]; +let dynHits = 0; +for (let n = 1; n < 0x40000; n++) { + if ((rt(n) as any) instanceof EEdyn) dynHits++; +} +console.log("numbers instanceof (dynamic) EventEmitter:", dynHits); + +const primitives: [string, any][] = [ + ["number", rt(3)], + ["zero", rt(0)], + ["negative", rt(-1)], + ["float", rt(1.5)], + ["NaN", NaN], + ["string", rt("x")], + ["long string", rt("y".repeat(40))], + ["empty string", rt("")], + ["true", rt(true)], + ["false", rt(false)], + ["null", rt(null)], + ["undefined", undefined], + ["bigint", BigInt(rt(3))], + ["symbol", Symbol("s")], +]; + +const ctors: [string, any][] = [ + ["EventEmitter", EventEmitter], + ["Buffer", Buffer], + ["Uint8Array", Uint8Array], + ["Map", Map], + ["Error", Error], + ["Object", Object], + ["Plain", Plain], + ["Child", Child], + ["MyEmitter", MyEmitter], +]; + +for (const [pname, p] of primitives) { + const statics = [ + p instanceof EventEmitter, + p instanceof Buffer, + p instanceof Uint8Array, + p instanceof Map, + p instanceof Error, + p instanceof Object, + p instanceof Plain, + p instanceof Child, + p instanceof MyEmitter, + ]; + const dynamics = ctors.map(([, C]) => p instanceof C); + const reflective = ctors.map(([, C]) => + (Function.prototype as any)[Symbol.hasInstance].call(C, p), + ); + console.log( + `${pname}:`, + statics.some(Boolean) || dynamics.some(Boolean) || reflective.some(Boolean) + ? `MATCHED static=${statics} dynamic=${dynamics} reflective=${reflective}` + : "false everywhere", + ); +} + +// Controls: real instances still match. +console.log("emitter:", emitters[0] instanceof EventEmitter, emitters[7] instanceof EEdyn); +console.log("subclass:", mine instanceof EventEmitter, mine instanceof MyEmitter); +console.log("buffer:", Buffer.from("ab") instanceof Buffer, Buffer.from("ab") instanceof Uint8Array); +console.log("u8:", new Uint8Array(2) instanceof Uint8Array); +console.log("map:", new Map() instanceof Map, "error:", new Error("e") instanceof Error); +console.log("user:", new Child() instanceof Plain, new Child() instanceof Child); +console.log("object:", {} instanceof Object, [] instanceof Object); +console.log("class instanceof Function:", Plain instanceof Function); + +// A user `Symbol.hasInstance` still decides for primitives. +class Even { + static [Symbol.hasInstance](v: unknown): boolean { + return typeof v === "number" && v % 2 === 0; + } +} +const EvenDyn: any = [Even][rt(0)]; +console.log("hasInstance:", (rt(4) as any) instanceof Even, (rt(3) as any) instanceof Even); +console.log("hasInstance dynamic:", (rt(4) as any) instanceof EvenDyn, (rt(3) as any) instanceof EvenDyn); + +// Emitters still work after all of the checks. +let fired = 0; +const e0 = new EventEmitter(); +e0.on("x", (n: number) => (fired += n)); +e0.emit("x", 5); +console.log("fired:", fired); From 9ff2532ea534d8302c40b50928c8229bced1d3c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 25 Sep 2026 01:55:42 +0000 Subject: [PATCH 2/2] changelog: fragment for #11271 --- changelog.d/11271-primitive-instanceof-native.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/11271-primitive-instanceof-native.md diff --git a/changelog.d/11271-primitive-instanceof-native.md b/changelog.d/11271-primitive-instanceof-native.md new file mode 100644 index 0000000000..2c5d127d71 --- /dev/null +++ b/changelog.d/11271-primitive-instanceof-native.md @@ -0,0 +1 @@ +- **`instanceof`: a primitive is never an instance of a native class (#11261).** The native brand probes behind `instanceof EventEmitter`, `net.Socket`, `AsyncLocalStorage`, `http.Agent` and similar classes resolve their operand as a registry handle id, and that resolution also accepted a plain positive integral number. As a result, any number equal to a live handle id was an "instance": for example, `3 instanceof EventEmitter` when the emitter is bundled in perry-stdlib (ids from 1), or `229376 instanceof EventEmitter` with `perry-ext-events` (ids from `0x38000`). `js_instanceof` and `js_instanceof_dynamic` now apply OrdinaryHasInstance step 3. They run it after a user `Symbol.hasInstance` has had its turn, and the dynamic form runs it only once the RHS is known to be callable, so a non-callable RHS still throws `TypeError`. `test_gap_10556_instanceof_native_emitter` now passes when `node:events` is not routed to the ext wrapper. The new `test_gap_11261_primitive_instanceof_native` sweeps the whole handle band and checks every primitive kind against native, built-in and user constructors.