Skip to content

fix(runtime): give Buffer instances own properties, method values, and in#6406

Open
proggeramlug wants to merge 2 commits into
mainfrom
fix/buffer-own-props
Open

fix(runtime): give Buffer instances own properties, method values, and in#6406
proggeramlug wants to merge 2 commits into
mainfrom
fix/buffer-own-props

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

The bug

A Buffer is an ordinary Uint8Array in Node: user code can hang own properties on one, read Buffer.prototype methods as values, and shadow them. Perry stored buffers outside the object model — a raw BufferHeader with no property table — so all three were missing:

const b = Buffer.alloc(8);
b.myFlag = 42;
b.myFlag;             // node: 42          perry: undefined   (the write was dropped)
typeof b.readUInt8;   // node: "function"  perry: "undefined" (methods dispatched on CALL only)
"myFlag" in b;        // node: true        perry: false

Why it matters

mysql2 sizes every outgoing packet with exactly that idiom (MockBuffer): it walks the prototype, replaces the matching write methods on a zero-length Buffer with a no-op, serializes once to measure, then allocates for real:

for (const k in Buffer.prototype)
  if (typeof mock[k] === "function") mock[k] = noop;

With the typeof mock[k] === "function" probe reading undefined, nothing was replaced, the measuring pass wrote into the empty Buffer, and the MySQL handshake died with RangeError [ERR_OUT_OF_RANGE].

The fix

An address-keyed own-property table for buffers, wired into the read / write / call / in / computed-index paths and scanned by the GC (both the owner address and the NaN-boxed values, in every phase). register_buffer clears the entry first, so a recycled address cannot inherit a dead buffer's properties. Numeric keys remain byte access and never become own props.

The Buffer.prototype method list is now generated from is_buffer_method_name (115 names; node enumerates 93) instead of an 11-name stub, so the for (const k in Buffer.prototype) walk actually sees them.

Known limitation — #6405

An own property does not shadow a prototype method when the call folds to the inline byte-load intrinsic: when codegen can statically prove the receiver is a buffer and the method name is a literal (b.readUInt8(0), b["readUInt8"](0)). Every dynamic path is correct — which is why the mysql2 idiom works in practice, since the proof fails on minified receivers. Fixing it costs a guard on Perry's hottest byte-access path (a runtime flag + branch, or a whole-program pre-codegen scan), so it deserves a deliberate decision rather than a drive-by. Filed as #6405.

Test

test_gap_buffer_own_props — own data props, method-value reads, in, value-call this binding, dynamic-key shadowing, byte indices, a recycled address, and own props surviving a write through the buffer's own methods. Byte-identical to node.

Found while compiling a real Next.js + MySQL app.

Summary by CodeRabbit

  • New Features

    • Buffer instances now support custom own properties (reads/writes/existence) and can override buffer methods.
  • Bug Fixes

    • Tightened Uint8Array/Buffer indexing so non-numeric keys follow standard property semantics instead of byte-path behavior.
    • Prevented buffer own-properties from being inherited across recycled buffer addresses.
    • Improved in and field/property dispatch for registered Buffers to correctly reflect own properties and bounds.
  • Tests

    • Added assertions covering own properties vs prototype methods and verifying numeric byte-index behavior remains unchanged.

…d `in`

A Buffer is an ordinary `Uint8Array` in Node: user code can hang own properties
on one, read `Buffer.prototype` methods as VALUES, and shadow them. Perry stored
buffers outside the object model — a raw `BufferHeader` with no property table —
so all three were missing:

    const b = Buffer.alloc(8);
    b.myFlag = 42;
    b.myFlag;             // node: 42          perry: undefined  (write dropped)
    typeof b.readUInt8;   // node: "function"  perry: "undefined" (dispatched on CALL only)
    "myFlag" in b;        // node: true        perry: false

mysql2 sizes every outgoing packet with exactly that idiom (`MockBuffer`): it
walks the 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 reading `undefined`, nothing was replaced,
the measuring pass wrote into the empty Buffer, and the MySQL handshake died with
`RangeError [ERR_OUT_OF_RANGE]`.

This adds an address-keyed own-property table for buffers, wired into the
read/write/call/`in`/computed-index paths and scanned by the GC (both the owner
address and the NaN-boxed values, in every phase). `register_buffer` clears the
entry first, so a recycled address cannot inherit a dead buffer's properties.
Numeric keys remain byte access and never become own props. The full
`Buffer.prototype` method list is now generated from `is_buffer_method_name`
(115 names; node enumerates 93) instead of an 11-name stub, so the `for (const k
in Buffer.prototype)` walk sees them.

Known limitation, filed as #6405: an own property does NOT shadow a prototype
method when the call folds to the inline byte-load intrinsic — i.e. when codegen
can statically prove the receiver is a buffer AND the method name is a literal
(`b.readUInt8(0)`, `b["readUInt8"](0)`). Every dynamic path is correct, which is
why the mysql2 idiom works in practice (the proof fails on minified receivers).
Fixing it costs a guard on the hottest byte-access path, so it wants a deliberate
decision rather than a drive-by.

Found while compiling a real Next.js + MySQL app. Covered by
`test_gap_buffer_own_props` — own data props, method-value reads, `in`,
value-call `this` binding, dynamic-key shadowing, byte indices, a recycled
address, and own props surviving a write. Byte-identical to node.
@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Buffer and Uint8Array lowering now distinguishes numeric and string keys. Runtime support stores Buffer own properties, integrates them with GC, resolves them during reads, writes, membership checks, and method dispatch, and adds coverage for shadowing and address reuse.

Changes

Buffer own-property semantics

Layer / File(s) Summary
Key-aware Buffer lowering
crates/perry-hir/src/lower/expr_assign.rs, crates/perry-hir/src/lower/expr_member/member_tail.rs, crates/perry-codegen/src/expr/arrays_finds.rs
String keys bypass specialized byte indexing and use polymorphic property access; numeric keys retain typed-array paths.
Own-property storage and GC integration
crates/perry-runtime/src/buffer/own_props.rs, crates/perry-runtime/src/buffer/{mod.rs,header.rs}, crates/perry-runtime/src/gc/mod.rs
Buffer properties are stored by address, traced and relocated during GC, cleared on buffer registration, and re-exported through the buffer module.
Buffer property dispatch and validation
crates/perry-runtime/src/object/{polymorphic_index.rs,buffer_dispatch.rs,field_set_by_name.rs}, crates/perry-runtime/src/object/field_get_set/*, test-files/test_gap_buffer_own_props.ts
Buffer reads, writes, in checks, method shadowing, and own-property persistence use the new storage and are covered by tests.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

Possibly related PRs

  • PerryTS/perry#5544 — Overlaps in typed-array index lowering and polymorphic get/set routing.
  • PerryTS/perry#6092 — Both modify Uint8Array/Buffer index-access lowering and fallback behavior.
  • PerryTS/perry#6163 — Relates to registered-Buffer key existence handling in js_object_has_property.

Suggested reviewers: andrewtdiz

Sequence Diagram(s)

sequenceDiagram
  participant BufferAccess
  participant PolymorphicIndex
  participant BufferOwnProps
  participant BufferDispatch
  BufferAccess->>PolymorphicIndex: read or write computed string key
  PolymorphicIndex->>BufferOwnProps: get or set Buffer own property
  PolymorphicIndex->>BufferDispatch: resolve Buffer method when no own property exists
  BufferDispatch-->>BufferAccess: bound method or dispatched result
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed It clearly summarizes the main runtime change: Buffer own properties, method values, and in support.
Description check ✅ Passed It covers the bug, rationale, fix, limitation, and tests, even though it doesn't follow the template headings exactly.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/buffer-own-props

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 6

🤖 Prompt for all review comments with 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.

Inline comments:
In `@crates/perry-hir/src/lower/expr_assign.rs`:
- Around line 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.

In `@crates/perry-runtime/src/buffer/own_props.rs`:
- Around line 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.

In `@crates/perry-runtime/src/object/buffer_dispatch.rs`:
- Around line 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.

In `@crates/perry-runtime/src/object/field_get_set/has_property.rs`:
- Around line 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.

In `@crates/perry-runtime/src/object/field_set_by_name.rs`:
- Around line 293-306: The Buffer branch must never fall through to the
ObjectHeader path. In the buffer handling logic, recognize only canonical
numeric index strings (not values such as "00") for index writes; store every
other name with buffer_set_own_prop, then return unconditionally, including
invalid UTF-8 cases as appropriate for the existing behavior.

In `@crates/perry-runtime/src/object/polymorphic_index.rs`:
- Around line 115-121: Ensure Buffer method names passed to js_class_method_bind
remain valid after binding: in
crates/perry-runtime/src/object/polymorphic_index.rs:115-121, copy name into
stable closure-owned storage before binding; apply the same fix to the
GC-managed key bytes in
crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs:276-282.
Preserve the existing Buffer method dispatch behavior while ensuring both bound
pointers outlive their closures.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 9b876601-2aa9-458f-b6d5-d69048ebb6ea

📥 Commits

Reviewing files that changed from the base of the PR and between baefb2a and 3c9153e.

📒 Files selected for processing (13)
  • crates/perry-codegen/src/expr/arrays_finds.rs
  • crates/perry-hir/src/lower/expr_assign.rs
  • crates/perry-hir/src/lower/expr_member/member_tail.rs
  • crates/perry-runtime/src/buffer/header.rs
  • crates/perry-runtime/src/buffer/mod.rs
  • crates/perry-runtime/src/buffer/own_props.rs
  • crates/perry-runtime/src/gc/mod.rs
  • crates/perry-runtime/src/object/buffer_dispatch.rs
  • crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs
  • crates/perry-runtime/src/object/field_get_set/has_property.rs
  • crates/perry-runtime/src/object/field_set_by_name.rs
  • crates/perry-runtime/src/object/polymorphic_index.rs
  • test-files/test_gap_buffer_own_props.ts

Comment on lines +1074 to +1091
// 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")
{

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.

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

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

Comment on lines +368 to +379
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;
}
}
}

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.

Comment on lines +231 to +249
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;
}
}

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.

Comment on lines +293 to +306
if !key.is_null() && crate::buffer::is_registered_buffer(obj as usize) {
unsafe {
let key_ptr = (key as *const u8).add(std::mem::size_of::<crate::StringHeader>());
let key_len = (*key).byte_len as usize;
if let Ok(name) = std::str::from_utf8(std::slice::from_raw_parts(key_ptr, key_len)) {
// Numeric keys are element writes (`buf[0] = 1`) — leave those
// to the index path; only NAMED props become expandos.
if name.parse::<u32>().is_err() {
crate::buffer::buffer_set_own_prop(obj as usize, name, value);
return;
}
}
}
}

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

Return from the Buffer branch and distinguish canonical indices.

parse::<u32>() misclassifies ordinary keys such as "00" as numeric. They then fall through toward the ObjectHeader path even though the receiver is a raw BufferHeader, risking an invalid GC-header read. Handle canonical numeric-index strings explicitly; store all other names as own properties, then always return.

🤖 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_set_by_name.rs` around lines 293 - 306,
The Buffer branch must never fall through to the ObjectHeader path. In the
buffer handling logic, recognize only canonical numeric index strings (not
values such as "00") for index writes; store every other name with
buffer_set_own_prop, then return unconditionally, including invalid UTF-8 cases
as appropriate for the existing behavior.

Comment on lines +115 to +121
if crate::object::buffer_dispatch::is_buffer_method_name(&name) {
let bytes = name.as_bytes();
return crate::object::js_class_method_bind(
crate::value::js_nanbox_pointer(raw as i64),
bytes.as_ptr(),
bytes.len(),
);

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

Ensure bound Buffer method names outlive their closures.

Both paths pass transient name storage to js_class_method_bind, which retains the pointer.

  • crates/perry-runtime/src/object/polymorphic_index.rs#L115-L121: copy name into stable closure-owned storage before binding.
  • crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs#L276-L282: copy the GC-managed key bytes before binding.
📍 Affects 2 files
  • crates/perry-runtime/src/object/polymorphic_index.rs#L115-L121 (this comment)
  • crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs#L276-L282
🤖 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/polymorphic_index.rs` around lines 115 - 121,
Ensure Buffer method names passed to js_class_method_bind remain valid after
binding: in crates/perry-runtime/src/object/polymorphic_index.rs:115-121, copy
name into stable closure-owned storage before binding; apply the same fix to the
GC-managed key bytes in
crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs:276-282.
Preserve the existing Buffer method dispatch behavior while ensuring both bound
pointers outlive their closures.

…_name_tail

get_field_by_name_tail.rs is a single very large function and was 11 lines under
the 2000-line cap; the Buffer own-property/method read pushed it to 2016. Move
that block to a `buffer_own_prop` sibling module. Pure relocation.
@proggeramlug

Copy link
Copy Markdown
Contributor Author

CodeRabbit's key-guard finding is real — confirmed against node and fixed in e466bfae5, along with the file-size cap that was failing lint.

The dynamic-key hole. The guard tested !key_is_string"not provably a string" — and key_is_string only recognizes a string literal or a local declared Type::String. A dynamic key (Type::Any, a call result, an element read) is neither, so it still folded to the byte path:

const buf = Buffer.alloc(4);
const keys: any[] = ["dyn"];
const k: any = keys[0];        // a string at runtime
(buf as any)[k] = "D";
console.log((buf as any)[k]);  // node: "D"   this PR (before): undefined

The store went to a byte index and the read came back out-of-range. That's the same defect this PR fixes for static keys, left open for dynamic ones — and dynamic is the common shape (a key out of Object.keys, a value off an untyped array), which is close to the mysql2 MockBuffer idiom the PR is about.

Fix: invert to key_is_numeric in both mirrored sites (expr_assign.rs, member_tail.rs) — fold only when the key is provably a number, and let everything else fall through to the generic property path, which discriminates on the runtime value.

The thing worth checking with that inversion is that dynamic numeric indices still reach the bytes, since they now route through the generic path. They do. Verified byte-identical to node:

result
any-typed key holding "dyn" own property ✅ (was undefined)
any-typed key holding 2 writes byte 2 ✅
literal / typed-local / loop-counter indices bytes ✅
length, subarray().toString()
typeof buf["writeInt8"] === "function"

Also test_gap_uint8array_source_dispatch still passes. Extended test_gap_buffer_own_props.ts to cover both dynamic-key directions.

Separatelyget_field_by_name_tail.rs is a single very large function that sat 11 lines under the 2000-line cap; this PR's block pushed it to 2016 and failed lint. Split the Buffer own-prop read into a buffer_own_prop sibling module (pure relocation).

@proggeramlug proggeramlug force-pushed the fix/buffer-own-props branch from e466bfa to 151cc2d Compare July 14, 2026 18:38
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Correction — I was wrong, and I've reverted it. The key_is_numeric inversion I pushed in e466bfae5 caused a real perf regression, and the compiler-output-regression gate was right to catch it. Force-pushed back to 151cc2d8f (the file-size split only).

What I got wrong

CodeRabbit's finding is real — an any-typed key holding a string does silently take the byte path and lose the store:

const k: any = keys[0];   // "dyn" at runtime
(buf as any)[k] = "D";
(buf as any)[k]           // node: "D"   perry: undefined

But my fix assumed a "provably numeric" key could be recognised statically. It can't. Printing the HIR for an ordinary buffer kernel shows why:

Let { id: 2, name: "i", ty: Any, mutable: true, init: Some(Integer(0)) }
   ^^^ a `for (let i = 0; ...)` counter is typed Any, not Number

So a loop counter and a string-holding any key are the same type to this guard. Requiring Type::Number therefore drops the fast path for every buf[i] in a loop — exactly what the native-region-proof fixtures (h1_buffer_alias_negative, image_convolution, …) are built from. Measured on a plain for (let i…) buf[i] = … kernel:

guard Uint8Array folds
!key_is_string (this PR / main) 1
key_is_numeric (my change) 0

Trading a buffer-kernel perf regression for an edge-case correctness fix is a bad deal, especially since the dynamic-key bug is pre-existing on main — this PR doesn't introduce it, it fixes the static-key half. So the PR is a strict improvement as it stands.

What actually needs to happen (not in this PR)

The guard can't separate these two cases while loop counters are Any. Two real fixes:

  1. Infer Number for numeric induction variablesfor (let i = 0; …; i++) with no non-numeric assignment in the body. Then a sound key_is_numeric guard keeps the fast path and fixes the bug. (Worth doing carefully: widening type visibility can wake other latent fast paths — that's how perf(codegen): keep the numeric-array specialization when the array is captured (#6369) #6377 revived runtime: await Promise.all([...]) never resumes when inputs settle via executor-captured resolvers — program silently exits 0 #6328.)
  2. Discriminate at runtime — always fold, and have the byte-path helper route a non-index key to the property path.

Filing this separately with the repro and the HIR evidence rather than jamming it in here.

The file-size split (get_field_by_name_tail.rsbuffer_own_prop sibling module) stays — that's what was failing lint, and it's a pure relocation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant