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
49 changes: 44 additions & 5 deletions crates/perry-codegen/src/expr/arrays_finds.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,29 @@ pub(crate) fn lower_uint8array_get_i32(
return Ok(value);
}

// An UNPROVEN key may be a string at runtime: a Buffer is an ordinary
// object in Node, so `buf[k]` with a non-numeric `k` reads a property (an
// own expando, else the prototype method), not a byte. Coercing it to i32
// read byte 0 and yielded `undefined`, which broke the ubiquitous
// feature-probe `typeof obj[k] === "function"` — mysql2's `MockBuffer`
// relies on it to neutralize a zero-length Buffer's write methods while
// sizing each outgoing packet, so the MySQL handshake died with RangeError
// [ERR_OUT_OF_RANGE]. Route to the polymorphic helper (it dispatches
// numeric keys to the byte read and string keys to the property path); the
// proven-numeric fast paths above are untouched.
if !is_numeric_expr(ctx, index) {
let a = lower_expr(ctx, array)?;
let key = lower_expr(ctx, index)?;
let blk = ctx.block();
let handle = unbox_to_i64(blk, &a);
let result = blk.call(
DOUBLE,
"js_object_get_index_polymorphic",
&[(I64, &handle), (DOUBLE, &key)],
);
return Ok(LoweredValue::js_value(result));
}

let idx_i32 = lower_index_i32(ctx, index)?;
let a = lower_expr(ctx, array)?;
let blk = ctx.block();
Expand Down Expand Up @@ -825,16 +848,32 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
));
}
if !numeric_index_has_integer_array_index_proof(ctx, index) {
// A non-numeric key stores an OWN property (Node's Buffer is an
// ordinary object, and an own key shadows the same-named
// prototype method — mysql2's `MockBuffer` overwrites the write
// methods of a zero-length Buffer to size a packet). The
// typed-array helper coerces the key to a number and dropped the
// store; the polymorphic setter dispatches numeric keys to the
// byte write and string keys to the own-prop table.
let key_maybe_string = !is_numeric_expr(ctx, index);
let a = lower_expr(ctx, array)?;
let key = lower_expr(ctx, index)?;
let val = lower_expr(ctx, value)?;
let blk = ctx.block();
let handle = unbox_to_i64(blk, &a);
let result = blk.call(
DOUBLE,
"js_typed_array_index_set_dynamic",
&[(I64, &handle), (DOUBLE, &key), (DOUBLE, &val)],
);
let result = if key_maybe_string {
blk.call_void(
"js_object_set_index_polymorphic",
&[(I64, &handle), (DOUBLE, &key), (DOUBLE, &val)],
);
val.clone()
} else {
blk.call(
DOUBLE,
"js_typed_array_index_set_dynamic",
&[(I64, &handle), (DOUBLE, &key), (DOUBLE, &val)],
)
};
if ctx.discard_expr_value {
return Ok(double_literal(0.0));
}
Expand Down
19 changes: 18 additions & 1 deletion crates/perry-hir/src/lower/expr_assign.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1071,7 +1071,24 @@ fn lower_assignment_target(
// typed `Buffer` must route through the byte-write path.
if let Expr::LocalGet(id) = &*object {
if let Some((_, _, ty)) = ctx.locals.iter().find(|(_, lid, _)| lid == id) {
if matches!(ty, Type::Named(n) if n == "Uint8Array" || n == "Buffer") {
// Numeric keys only — a STRING key stores an own
// property on the Buffer (Node's Buffer is an
// ordinary Uint8Array object), and an own key
// shadows the same-named prototype method. Folding
// it to the byte-write path silently dropped the
// store (see the mirrored comment in IndexGet).
let key_is_string = matches!(index.as_ref(), Expr::String(_))
|| matches!(
index.as_ref(),
Expr::LocalGet(kid) if ctx
.locals
.iter()
.find(|(_, lid, _)| lid == kid)
.is_some_and(|(_, _, kty)| matches!(kty, Type::String))
);
if !key_is_string
&& matches!(ty, Type::Named(n) if n == "Uint8Array" || n == "Buffer")
{
Comment on lines +1074 to +1091

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Dynamic or any-typed string keys fall through to the byte-access path.

Both lowering paths currently default to the specialized Uint8ArraySet / Uint8ArrayGet byte paths when the key is not provably a string (!key_is_string). However, if the computed key is dynamic (e.g. Type::Any or the result of a function call) and evaluates to a string at runtime, it will incorrectly hit the numeric byte-access path, bypassing the own-property logic entirely.

Consider inverting this heuristic to only take the fast byte-access path when the key is provably numeric (e.g., key_is_numeric), allowing dynamic keys to safely fall through to the generic polymorphic path which checks the type at runtime.

  • crates/perry-hir/src/lower/expr_assign.rs#L1074-L1091: Adjust the condition to use key_is_numeric before emitting Expr::Uint8ArraySet.
  • crates/perry-hir/src/lower/expr_member/member_tail.rs#L752-L763: Adjust the condition similarly before emitting Expr::Uint8ArrayGet.
📍 Affects 2 files
  • crates/perry-hir/src/lower/expr_assign.rs#L1074-L1091 (this comment)
  • crates/perry-hir/src/lower/expr_member/member_tail.rs#L752-L763
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-hir/src/lower/expr_assign.rs` around lines 1074 - 1091, Use a
provably numeric-key check instead of the current !key_is_string heuristic in
the Uint8ArraySet path at crates/perry-hir/src/lower/expr_assign.rs:1074-1091,
defining or reusing key_is_numeric so dynamic and any-typed keys fall through to
generic property handling; apply the same condition before Uint8ArrayGet in
crates/perry-hir/src/lower/expr_member/member_tail.rs:752-763.

return Ok(wrap_assign_object_prelude(
prelude.take(),
Expr::Uint8ArraySet {
Expand Down
24 changes: 23 additions & 1 deletion crates/perry-hir/src/lower/expr_member/member_tail.rs
Original file line number Diff line number Diff line change
Expand Up @@ -738,7 +738,29 @@ pub(crate) fn lower_member_tail(
// would return NaN-boxed pointer bits as a denormal f64.
if let Expr::LocalGet(id) = &*object {
if let Some((_, _, ty)) = ctx.locals.iter().find(|(_, lid, _)| lid == id) {
if matches!(ty, Type::Named(n) if n == "Uint8Array" || n == "Buffer") {
// …but ONLY for a numeric key. A Buffer is an ordinary
// object in Node (a Uint8Array), so a STRING key reads a
// property, not a byte: `buf["writeInt8"]` is the method,
// `buf[k] = v` (k non-numeric) an expando. Folding those to
// the byte path returned `undefined` (an out-of-range byte
// read), which broke the ubiquitous feature-probe idiom
// `typeof obj[k] === "function"` — mysql2's `MockBuffer`
// uses it to neutralize the write methods of a zero-length
// Buffer while sizing a packet, so every outgoing MySQL
// packet was measured against a live (empty) buffer and the
// handshake died with RangeError [ERR_OUT_OF_RANGE].
let key_is_string = matches!(index.as_ref(), Expr::String(_))
|| matches!(
index.as_ref(),
Expr::LocalGet(kid) if ctx
.locals
.iter()
.find(|(_, lid, _)| lid == kid)
.is_some_and(|(_, _, kty)| matches!(kty, Type::String))
);
if !key_is_string
&& matches!(ty, Type::Named(n) if n == "Uint8Array" || n == "Buffer")
{
return Ok(Expr::Uint8ArrayGet {
array: object,
index,
Expand Down
8 changes: 8 additions & 0 deletions crates/perry-runtime/src/buffer/header.rs
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,14 @@ pub(crate) fn test_shared_array_buffer_registry_len() -> usize {

/// Register a buffer pointer in the thread-local registry
pub fn register_buffer(ptr: *const BufferHeader) {
// A FRESH buffer must not inherit the own properties of a dead one that
// happened to sit at the same address (the own-prop table is address-keyed
// and buffer storage is recycled). mysql2 measures a packet against a
// zero-length Buffer whose write methods it overrode with no-ops, then
// allocates the real packet buffer — which lands on the freed mock's
// address, and without this the no-ops would carry over and the real packet
// would serialize as all zeros (the MySQL server then times out reading it).
super::own_props::clear_buffer_own_props(ptr as usize);
BUFFER_REGISTRY.with(|r| r.borrow_mut().insert(ptr as usize));
}

Expand Down
5 changes: 5 additions & 0 deletions crates/perry-runtime/src/buffer/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ mod header;
mod iter;
mod mutate;
mod numeric;
mod own_props;
mod query;
mod transcode;
mod u8_codec;
Expand Down Expand Up @@ -53,6 +54,10 @@ pub(crate) use header::{test_data_view_registry_len, test_shared_array_buffer_re
// part of the public surface.
pub use detach::is_detached_buffer;
pub(crate) use detach::{array_buffer_transfer, detach_array_buffer};
pub use own_props::{
buffer_get_own_prop, buffer_has_own_prop, buffer_set_own_prop, clear_buffer_own_props,
scan_buffer_own_props_roots_mut,
};

// ---- Re-exports: Buffer.from / alloc / concat (FFI) ----
pub use from::{
Expand Down
107 changes: 107 additions & 0 deletions crates/perry-runtime/src/buffer/own_props.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
//! Own (dynamic) properties assigned onto a `Buffer` / typed-array value.
//!
//! Perry allocates buffers as raw `BufferHeader`s outside the object model, so
//! a plain `buf.foo = v` had nowhere to go: the set was dropped and the read
//! returned `undefined`. Node's Buffer is a `Uint8Array` — an ordinary object —
//! so user code freely stores properties on one, and it also *shadows* the
//! prototype's methods when the key collides.
//!
//! mysql2 sizes every outgoing packet with exactly that idiom
//! (`packets/packet.js` → `MockBuffer`):
//!
//! ```js
//! const noop = function () {};
//! const mock = Buffer.alloc(0);
//! for (const k in Packet.prototype)
//! if (typeof mock[k] === "function") mock[k] = noop; // neutralize writes
//! // …serialize once against `mock` to MEASURE, then for real against
//! // Buffer.alloc(mock.offset)
//! ```
//!
//! Without own-prop storage the no-ops never landed, the measuring pass wrote
//! into the zero-length Buffer, and the MySQL handshake died with
//! `RangeError [ERR_OUT_OF_RANGE]`.
//!
//! Mirrors `closure::dynamic_props` (same locked side table + GC root scanner
//! contract): values are traced in EVERY phase so a stored closure/array stays
//! reachable, and the owner key is rewritten on evacuation.

use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};

type BufferProps = HashMap<usize, HashMap<String, u64>>;

fn buffer_props() -> &'static Mutex<BufferProps> {
static PROPS: OnceLock<Mutex<BufferProps>> = OnceLock::new();
PROPS.get_or_init(|| Mutex::new(HashMap::new()))
}
Comment on lines +34 to +37

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

Global Mutex shares isolate-specific GC state across threads.

BufferProps is defined as a global static OnceLock<Mutex<...>>. Perry runs JS inside thread-local arenas (isolates), which means this side table will wrongly share JS values (the NaN-boxed f64s) across all threads. When one thread runs GC and calls scan_buffer_own_props_roots_mut, it will extract all owners from the global map and trace them using its thread-local visitor, leading to severe memory corruption or crashes.

Switch to a thread_local! RefCell to ensure own-properties remain safely isolated per thread. This also eliminates the locking overhead and greatly simplifies the GC scanner's loop. As per path instructions, thread-local arenas make cross-thread JS values invalid.

🛠️ Proposed `thread_local!` refactor
-use std::sync::{Mutex, OnceLock};
+use std::cell::RefCell;
 
 type BufferProps = HashMap<usize, HashMap<String, u64>>;
 
-fn buffer_props() -> &'static Mutex<BufferProps> {
-    static PROPS: OnceLock<Mutex<BufferProps>> = OnceLock::new();
-    PROPS.get_or_init(|| Mutex::new(HashMap::new()))
-}
+thread_local! {
+    static PROPS: RefCell<BufferProps> = RefCell::new(HashMap::new());
+}
 
 /// Store `buf.<prop> = value`. Only reached for a registered buffer address.
 pub fn buffer_set_own_prop(addr: usize, prop: &str, value: f64) {
     if addr == 0 {
         return;
     }
-    if let Ok(mut props) = buffer_props().lock() {
-        props
-            .entry(addr)
-            .or_default()
-            .insert(prop.to_string(), value.to_bits());
-    }
+    PROPS.with(|p| {
+        p.borrow_mut()
+            .entry(addr)
+            .or_default()
+            .insert(prop.to_string(), value.to_bits());
+    });
 }
 
 /// Read an own dynamic prop, or `None` when the buffer has no such key.
 pub fn buffer_get_own_prop(addr: usize, prop: &str) -> Option<f64> {
     if addr == 0 {
         return None;
     }
-    buffer_props()
-        .lock()
-        .ok()
-        .and_then(|props| props.get(&addr).and_then(|m| m.get(prop)).copied())
+    PROPS.with(|p| p.borrow().get(&addr).and_then(|m| m.get(prop)).copied())
         .map(f64::from_bits)
 }
 
 /// Whether the buffer carries any own dynamic prop under `prop`.
 pub fn buffer_has_own_prop(addr: usize, prop: &str) -> bool {
     buffer_get_own_prop(addr, prop).is_some()
 }
 
 /// GC: trace stored values in every phase (a stored closure is reachable ONLY
 /// through this table) and rewrite the owner address when the buffer moves.
 pub fn scan_buffer_own_props_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_>) {
-    let owners = buffer_props()
-        .lock()
-        .ok()
-        .map(|props| props.keys().copied().collect::<Vec<_>>())
-        .unwrap_or_default();
-    for owner in owners {
-        let Some(mut entries) = buffer_props()
-            .lock()
-            .ok()
-            .and_then(|mut props| props.remove(&owner))
-        else {
-            continue;
-        };
-        let mut new_owner = owner;
-        visitor.visit_metadata_usize_slot(&mut new_owner);
-        for bits in entries.values_mut() {
-            let mut v = f64::from_bits(*bits);
-            visitor.visit_nanbox_f64_slot(&mut v);
-            *bits = v.to_bits();
-        }
-        if let Ok(mut props) = buffer_props().lock() {
-            props.insert(new_owner, entries);
-        }
-    }
+    PROPS.with(|p| {
+        let mut props = p.borrow_mut();
+        let mut new_props = HashMap::with_capacity(props.len());
+        for (owner, mut entries) in props.drain() {
+            let mut new_owner = owner;
+            visitor.visit_metadata_usize_slot(&mut new_owner);
+            if new_owner == 0 {
+                continue; // Prune dead addresses
+            }
+            for bits in entries.values_mut() {
+                let mut v = f64::from_bits(*bits);
+                visitor.visit_nanbox_f64_slot(&mut v);
+                *bits = v.to_bits();
+            }
+            new_props.insert(new_owner, entries);
+        }
+        *props = new_props;
+    });
 }
 
 /// Drop every own prop recorded for `addr`. Called when a buffer is registered
 /// (freed storage is recycled, and the table is address-keyed).
 pub fn clear_buffer_own_props(addr: usize) {
     if addr == 0 {
         return;
     }
-    if let Ok(mut props) = buffer_props().lock() {
-        props.remove(&addr);
-    }
+    PROPS.with(|p| {
+        p.borrow_mut().remove(&addr);
+    });
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fn buffer_props() -> &'static Mutex<BufferProps> {
static PROPS: OnceLock<Mutex<BufferProps>> = OnceLock::new();
PROPS.get_or_init(|| Mutex::new(HashMap::new()))
}
use std::cell::RefCell;
type BufferProps = HashMap<usize, HashMap<String, u64>>;
thread_local! {
static PROPS: RefCell<BufferProps> = RefCell::new(HashMap::new());
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/buffer/own_props.rs` around lines 34 - 37, Replace
the global OnceLock<Mutex<BufferProps>> returned by buffer_props with
thread-local storage using thread_local! and RefCell, so each isolate thread
owns a separate own-properties map. Update callers, especially
scan_buffer_own_props_roots_mut, to access the RefCell through with/borrow_mut
and iterate directly without mutex locking, preserving existing property
behavior.

Source: Path instructions


/// Store `buf.<prop> = value`. Only reached for a registered buffer address.
pub fn buffer_set_own_prop(addr: usize, prop: &str, value: f64) {
if addr == 0 {
return;
}
if let Ok(mut props) = buffer_props().lock() {
props
.entry(addr)
.or_default()
.insert(prop.to_string(), value.to_bits());
}
}

/// Read an own dynamic prop, or `None` when the buffer has no such key.
pub fn buffer_get_own_prop(addr: usize, prop: &str) -> Option<f64> {
if addr == 0 {
return None;
}
buffer_props()
.lock()
.ok()
.and_then(|props| props.get(&addr).and_then(|m| m.get(prop)).copied())
.map(f64::from_bits)
}

/// Whether the buffer carries any own dynamic prop under `prop`.
pub fn buffer_has_own_prop(addr: usize, prop: &str) -> bool {
buffer_get_own_prop(addr, prop).is_some()
}

/// GC: trace stored values in every phase (a stored closure is reachable ONLY
/// through this table) and rewrite the owner address when the buffer moves.
pub fn scan_buffer_own_props_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_>) {
let owners = buffer_props()
.lock()
.ok()
.map(|props| props.keys().copied().collect::<Vec<_>>())
.unwrap_or_default();
for owner in owners {
let Some(mut entries) = buffer_props()
.lock()
.ok()
.and_then(|mut props| props.remove(&owner))
else {
continue;
};
let mut new_owner = owner;
visitor.visit_metadata_usize_slot(&mut new_owner);
for bits in entries.values_mut() {
let mut v = f64::from_bits(*bits);
visitor.visit_nanbox_f64_slot(&mut v);
*bits = v.to_bits();
}
if let Ok(mut props) = buffer_props().lock() {
props.insert(new_owner, entries);
}
}
}

/// Drop every own prop recorded for `addr`. Called when a buffer is registered
/// (freed storage is recycled, and the table is address-keyed).
pub fn clear_buffer_own_props(addr: usize) {
if addr == 0 {
return;
}
if let Ok(mut props) = buffer_props().lock() {
props.remove(&addr);
}
}
1 change: 1 addition & 0 deletions crates/perry-runtime/src/gc/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -475,6 +475,7 @@ pub fn gc_init() {
// captured young values or future cache hits miss on stale addresses.
gc_register_mutable_root_scanner(crate::closure::scan_singleton_closure_roots_mut);
gc_register_mutable_root_scanner(crate::closure::scan_closure_dynamic_props_roots_mut);
gc_register_mutable_root_scanner(crate::buffer::scan_buffer_own_props_roots_mut);
// Generic per-handle expando properties (`blob.colors = [...]` and other
// arbitrary own props on native HANDLE values). Keys are stable small handle
// ids; only the stored VALUES are JS references that must be traced.
Expand Down
17 changes: 17 additions & 0 deletions crates/perry-runtime/src/object/buffer_dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,23 @@ pub unsafe fn dispatch_buffer_method(
} else {
&[]
};
// An OWN property shadows the prototype method of the same name (Node's
// Buffer is an ordinary Uint8Array). mysql2's `MockBuffer` overwrites the
// write methods of a zero-length Buffer with a no-op to MEASURE a packet
// before allocating it; dispatching the native method regardless would
// write into the empty buffer and throw RangeError [ERR_OUT_OF_RANGE].
if let Some(own) = crate::buffer::buffer_get_own_prop(addr, method_name) {
let jv = JSValue::from_bits(own.to_bits());
if jv.is_pointer() {
let ptr = jv.as_pointer::<u8>() as usize;
if crate::closure::is_closure_ptr(ptr) {
let prev_this = crate::object::js_implicit_this_set(buf_f64);
let r = crate::closure::js_native_call_value(own, args_ptr, args_len);
crate::object::js_implicit_this_set(prev_this);
return r;
}
}
}
Comment on lines +368 to +379

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Do not fall through when a non-callable own property shadows the method.

An own value such as buf.writeUInt8 = undefined still shadows Buffer.prototype.writeUInt8; calling it must throw a non-callable TypeError. This currently invokes the native method instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/object/buffer_dispatch.rs` around lines 368 - 379,
Update the own-property handling in the buffer method dispatch to treat any
present property as authoritative, not only closure pointers. When
buffer_get_own_prop finds a non-callable own value, throw the standard
non-callable TypeError instead of falling through to the native method; preserve
the existing js_native_call_value path for callable own properties.

let arg_i32 = |i: usize| -> i32 {
if i < args.len() {
args[i] as i32
Expand Down
1 change: 1 addition & 0 deletions crates/perry-runtime/src/object/field_get_set.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ pub(crate) fn is_fetch_subclass_body_method(name: &[u8]) -> bool {

// ── Topical sub-modules (issue #1103: keep every file < 2000 lines) ──
mod accessors;
mod buffer_own_prop;
mod crypto_key;
mod enumeration;
mod field_ops;
Expand Down
42 changes: 42 additions & 0 deletions crates/perry-runtime/src/object/field_get_set/buffer_own_prop.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
//! Buffer own-property / method-value reads for the object-deref tail.
//!
//! Split out of `get_field_by_name_tail.rs` to keep that file under the
//! 2000-line budget (it is a single very large function).

use super::*;

/// Node's Buffer IS an object (a Uint8Array), so user code can store properties
/// on one — and an own key SHADOWS the prototype method of the same name. Perry
/// keeps buffers outside the object model, so both halves were missing:
/// `buf.foo = v` was dropped and `typeof buf.writeInt8` read `undefined`
/// (methods dispatched on CALL only).
///
/// mysql2 sizes every outgoing packet with exactly that idiom (`MockBuffer`):
/// it walks `Packet.prototype`, replaces the matching write methods on a
/// ZERO-LENGTH Buffer with a no-op, serializes once to MEASURE, then allocates
/// for real. With the `typeof mock[k] === "function"` probe false, nothing was
/// replaced, the measuring pass wrote into the empty Buffer, and the MySQL
/// handshake died with RangeError [ERR_OUT_OF_RANGE].
///
/// Returns `None` when the key names neither an own property nor a Buffer
/// method, so the caller falls through to the rest of the property walk.
pub(super) fn buffer_own_prop_or_method(
obj: *const ObjectHeader,
key_bytes: &[u8],
key_ptr: *const u8,
key_len: usize,
) -> Option<JSValue> {
let name = std::str::from_utf8(key_bytes).ok()?;
if let Some(v) = crate::buffer::buffer_get_own_prop(obj as usize, name) {
return Some(JSValue::from_bits(v.to_bits()));
}
if crate::object::buffer_dispatch::is_buffer_method_name(name) {
let bound = crate::object::js_class_method_bind(
crate::value::js_nanbox_pointer(obj as i64),
key_ptr,
key_len,
);
return Some(JSValue::from_bits(bound.to_bits()));
}
None
}
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,13 @@ pub(crate) fn get_field_by_name_object_tail(
let b = obj as *const crate::buffer::BufferHeader;
return JSValue::number(crate::buffer::js_buffer_length(b) as f64);
}
// An own property on the Buffer shadows the same-named prototype
// method; both reads live in `buffer_own_prop`.
if let Some(v) = super::buffer_own_prop::buffer_own_prop_or_method(
obj, key_bytes, key_ptr, key_len,
) {
return v;
}
// ArrayBuffer.prototype `resizable` / `maxByteLength` getters.
// Perry has no resizable ArrayBuffers, so `resizable` is always
// false and `maxByteLength` equals `byteLength`. These live only
Expand Down
24 changes: 24 additions & 0 deletions crates/perry-runtime/src/object/field_get_set/has_property.rs
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,30 @@ pub extern "C" fn js_object_has_property(obj: f64, key: f64) -> f64 {
// already does further down this function.
if obj_val.is_pointer() {
let addr = (obj_val.bits() & crate::value::POINTER_MASK) as usize;
// A Buffer is an ordinary Uint8Array in Node, so `"k" in buf` must see both
// the own properties user code hangs on it and the inherited
// `Buffer.prototype` methods. Perry keeps buffers outside the object model
// (a raw `BufferHeader`), so the generic pointer path below reads the header
// as an `ObjectHeader` and reported `false` for both.
if crate::buffer::is_registered_buffer(addr) {
if let Some(name) = unsafe { crate::object::metadata_key_to_string(key) } {
if crate::buffer::buffer_get_own_prop(addr, &name).is_some()
|| crate::object::buffer_dispatch::is_buffer_method_name(&name)
{
return nanbox_true;
}
// A numeric key is an index into the bytes: present iff in range.
if let Ok(idx) = name.parse::<usize>() {
let len = unsafe { (*(addr as *const crate::buffer::BufferHeader)).length };
return if idx < len as usize {
nanbox_true
} else {
nanbox_false
};
}
return nanbox_false;
}
}
Comment on lines +231 to +249

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Do not bypass the complete Buffer HasProperty path.

This early branch returns false before the existing Buffer logic can recognize own view slots and inherited typed-array members. For example, "length" in buf and "map" in buf now become false. Merge own-property and Buffer-method checks into the later branch instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/object/field_get_set/has_property.rs` around lines
231 - 249, Remove the early registered-buffer return block from the HasProperty
path and merge its own-property, numeric-index, and Buffer-method checks into
the existing complete Buffer handling branch. Ensure that symbols such as
“length”, “map”, own view slots, inherited typed-array members, and in-range
byte indices continue through the established Buffer logic instead of being
returned false prematurely.

if addr >= crate::value::addr_class::COMMON_HANDLE_BAND_END
&& crate::value::addr_class::is_handle_band(addr)
{
Expand Down
Loading
Loading