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
251 changes: 78 additions & 173 deletions crates/perry-runtime/src/buffer/header.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,6 @@ use super::*;
/// Type ID constant for Buffer/Uint8Array - matches class_id 0xFFFF0004
pub const BUFFER_TYPE_ID: u32 = 0xFFFF0004;

/// #5067 — throw a catchable `RangeError: Array buffer allocation failed`
/// (Node/V8's message) when a buffer backing block cannot be allocated,
/// rather than aborting the process.
#[cold]
fn throw_buffer_alloc_failed() -> ! {
let msg = b"Array buffer allocation failed";
let s = crate::string::js_string_from_bytes(msg.as_ptr(), msg.len() as u32);
let err = crate::error::js_rangeerror_new(s);
crate::exception::js_throw(crate::value::js_nanbox_pointer(err as i64))
}

/// Buffer header - similar to StringHeader but specifically for binary data
/// NOTE: Layout must match ArrayHeader (length at offset 0, capacity at offset 4)
/// because the codegen treats Uint8Array like arrays with hardcoded offsets.
Expand All @@ -25,12 +14,6 @@ pub struct BufferHeader {
pub capacity: u32,
}

/// Calculate the layout for a buffer with given capacity
fn buffer_layout(capacity: usize) -> Layout {
let total_size = std::mem::size_of::<BufferHeader>() + capacity;
Layout::from_size_align(total_size, 8).unwrap()
}

#[inline]
fn buffer_payload_size(capacity: usize) -> usize {
std::mem::size_of::<BufferHeader>() + capacity
Expand Down Expand Up @@ -183,131 +166,22 @@ pub fn register_buffer(ptr: *const BufferHeader) {
BUFFER_REGISTRY.with(|r| r.borrow_mut().insert(ptr as usize));
}

// ----- Small-buffer slab allocator ----------------------------------------
//
// GC interaction:
// Buffers carry no GcHeader and are not tracked in MALLOC_STATE (the existing
// malloc path also never calls `dealloc` on individual buffers — they live for
// the lifetime of the thread). Slab blocks are malloc'd once and retained for
// the same duration. No GC behaviour changes.
//
// Registry:
// Large buffers (capacity >= SMALL_BUF_THRESHOLD) still go through
// `register_buffer` and appear in BUFFER_REGISTRY (HashSet).
// Small buffers skip the HashSet insert; `is_registered_buffer` instead
// performs a range-check against the (tiny) list of slab blocks — O(n_slabs),
// typically ≤ 5 entries for a 100k-iteration allocation loop.
// No false positives: slab blocks exclusively contain BufferHeader allocations
// and all callers of `is_registered_buffer` pass the header pointer (the
// NaN-boxed POINTER_TAG value always points to the header start, never to
// interior data bytes).

/// Capacities strictly below this threshold use the slab fast path.
/// Historical tier boundary, retained for callers that size test fixtures
/// around it. Since the 2026-07-09 audit fix every buffer allocates through
/// the GC old arena (see `buffer_alloc`) — there is no slab tier anymore.
pub const SMALL_BUF_THRESHOLD: u32 = 256;

/// One slab block covers this many bytes of BufferHeader+data storage.
/// 256 KB → ≥ 1 000 allocations of the max small size (255 bytes), or up to
/// 32 768 allocations of the minimum (0 bytes / header only).
const SLAB_CAPACITY: usize = 256 * 1024;

/// Per-thread bump-pointer slab for small buffers.
/// Raw pointers stored as `usize` to keep the type `Send + Sync`.
struct SmallBufSlab {
/// Byte offset of the next free slot within the current slab block.
current: usize,
/// One-past-the-end offset (absolute address as usize) of the current block.
end: usize,
/// (start, end) address pair for every slab block allocated so far.
/// Used by `is_registered_buffer` to confirm an address is a small buffer.
ranges: Vec<(usize, usize)>,
}

thread_local! {
static SMALL_BUF_SLAB: RefCell<SmallBufSlab> = const { RefCell::new(SmallBufSlab {
current: 0,
end: 0,
ranges: Vec::new(),
}) };
}

fn buffer_alloc_small(capacity: u32) -> *mut BufferHeader {
let needed = std::mem::size_of::<BufferHeader>() + capacity as usize;
// Round up to 8-byte boundary so every header is naturally aligned.
let aligned = (needed + 7) & !7;
// #5226: reserve an extra `GC_HEADER_SIZE` per buffer for a zeroed sentinel
// that precedes the returned pointer (see the `write_bytes` below). Buffers
// are off-GC-heap with no real `GcHeader`, but the runtime is littered with
// `*(ptr - GC_HEADER_SIZE)` obj_type probes (Promise / Date / Array / class
// dispatch). Without the sentinel the back-read crosses outside the slab —
// into the unmapped page before a freshly mapped block for the first buffer
// — and segfaults. A `0` sentinel matches no `GC_TYPE_*`, so every probe
// cleanly classifies the buffer as "not my type".
let slot = crate::gc::GC_HEADER_SIZE + aligned;

SMALL_BUF_SLAB.with(|slab_ref| {
let mut slab = slab_ref.borrow_mut();

if slab.current + slot > slab.end {
// Current block exhausted (or first call): allocate a fresh slab.
let layout = Layout::from_size_align(SLAB_CAPACITY, 8).unwrap();
let block = unsafe { alloc(layout) };
if block.is_null() {
panic!(
"buffer: failed to allocate small-buffer slab ({} bytes)",
SLAB_CAPACITY
);
}
let block_start = block as usize;
let block_end = block_start + SLAB_CAPACITY;
slab.ranges.push((block_start, block_end));
slab.current = block_start;
slab.end = block_end;
}

// Zero the 8-byte sentinel, then hand out the pointer just past it. The
// sentinel always lands inside the slab block, so `ptr - GC_HEADER_SIZE`
// is a mapped read; `is_registered_buffer`'s slab-range check still
// covers `ptr` (it stays within `[block_start, block_end)`).
let ptr = unsafe {
std::ptr::write_bytes(slab.current as *mut u8, 0, crate::gc::GC_HEADER_SIZE);
(slab.current + crate::gc::GC_HEADER_SIZE) as *mut BufferHeader
};
slab.current += slot;

unsafe {
(*ptr).length = 0;
(*ptr).capacity = capacity;
}

ptr
})
}

/// True when `addr` lies inside a small-buffer slab block. Slab allocations
/// carry NO GcHeader, so reading `addr - GC_HEADER_SIZE` there yields the
/// previous allocation's trailing data bytes — a content-dependent fake
/// header. `addr_class::try_read_gc_header` consults this before any deref so
/// brand probes (Temporal/Date/Map/Set) can't misroute a small Buffer whose
/// payload happens to spell a matching `obj_type`.
pub(crate) fn is_small_buf_slab_addr(addr: usize) -> bool {
SMALL_BUF_SLAB.with(|slab_ref| {
slab_ref
.borrow()
.ranges
.iter()
.any(|&(start, end)| addr >= start && addr < end)
})
/// The small-buffer slab allocator is gone (2026-07-09 audit): slab
/// allocations carried no GcHeader, were never freed, and were invisible to
/// every GC trigger. Every buffer now has a real header in the old arena.
/// `addr_class::try_read_gc_header` still consults this probe; no slab
/// ranges can exist, so it is constant `false`.
pub(crate) fn is_small_buf_slab_addr(_addr: usize) -> bool {
false
}

/// Check if a pointer is a registered buffer (for instanceof Uint8Array)
pub fn is_registered_buffer(addr: usize) -> bool {
// Fast path: address falls within a small-buffer slab block. All bytes in
// a slab block belong exclusively to BufferHeader allocations, so any match
// is definitively a buffer pointer.
if is_small_buf_slab_addr(addr) {
return true;
}
// Slow path: large buffers tracked in the HashSet registry.
if BUFFER_REGISTRY.with(|r| r.borrow().contains(&addr)) {
return true;
}
Expand Down Expand Up @@ -544,48 +418,79 @@ pub fn buffer_byte_offset(buf: usize) -> u32 {
super::view::byte_offset_of(buf)
}

/// Allocate a buffer with the given capacity
/// Allocate a buffer with the given capacity.
///
/// 2026-07-09 audit: EVERY buffer is now a GC-heap (old-arena) object with a
/// real GcHeader. The former three-tier scheme left <256 B slab buffers and
/// 256 B–16 KB raw-`alloc`'d buffers permanently invisible to the collector
/// — never freed, never counted by any GC trigger — so servers churning
/// small binary data (HTTP chunks, digests, protocol frames) grew RSS
/// monotonically with no GC recourse. The old arena is the right space:
/// buffers are non-movable (raw data pointers are handed to FFI/tokio), and
/// dead buffer runs are reclaimed by full-cycle whole-block resets plus the
/// post-trace registry pruning below. Their bytes now also count toward
/// `arena_total_bytes`, so allocation pressure finally triggers collections.
pub fn buffer_alloc(capacity: u32) -> *mut BufferHeader {
// Fast path: small buffers come from a per-thread bump slab (no malloc,
// no HashSet insert). Large buffers fall through to the existing malloc path.
if capacity < SMALL_BUF_THRESHOLD {
return buffer_alloc_small(capacity);
}
if crate::gc::is_large_object_total_size(buffer_gc_total_size(capacity as usize)) {
let ptr = crate::arena::arena_alloc_gc_old(
buffer_payload_size(capacity as usize),
8,
crate::gc::GC_TYPE_BUFFER,
) as *mut BufferHeader;
unsafe {
let header =
(ptr as *mut u8).sub(crate::gc::GC_HEADER_SIZE) as *mut crate::gc::GcHeader;
(*header).gc_flags |= crate::gc::GC_FLAG_TENURED;
(*ptr).length = 0;
(*ptr).capacity = capacity;
}
register_buffer(ptr);
return ptr;
}
// #5226: mid-size buffers are raw-`alloc`'d off the GC heap. Reserve a
// zeroed `GC_HEADER_SIZE` sentinel before the returned pointer so the
// runtime's `*(ptr - GC_HEADER_SIZE)` obj_type probes read a mapped `0`
// (matching no `GC_TYPE_*`) instead of faulting at a region boundary.
let inner = buffer_layout(capacity as usize);
let layout = Layout::from_size_align(crate::gc::GC_HEADER_SIZE + inner.size(), 8).unwrap();
let ptr = crate::arena::arena_alloc_gc_old(
buffer_payload_size(capacity as usize),
8,
crate::gc::GC_TYPE_BUFFER,
) as *mut BufferHeader;
unsafe {
let raw = alloc(layout);
if raw.is_null() {
// #5067 — surface a catchable `RangeError` instead of aborting.
throw_buffer_alloc_failed();
}
std::ptr::write_bytes(raw, 0, crate::gc::GC_HEADER_SIZE);
let ptr = raw.add(crate::gc::GC_HEADER_SIZE) as *mut BufferHeader;
let header = (ptr as *mut u8).sub(crate::gc::GC_HEADER_SIZE) as *mut crate::gc::GcHeader;
(*header).gc_flags |= crate::gc::GC_FLAG_TENURED;
(*ptr).length = 0;
(*ptr).capacity = capacity;
register_buffer(ptr);
ptr
}
register_buffer(ptr);
ptr
}

/// Post-trace registry pruning (mirrors the #6010 Map/Set pattern): collect
/// registered buffers whose header is genuinely dead so the sweep subphase
/// can drop their side-table state. All buffers are TENURED old-arena
/// residents, and minor traces never mark the old generation — deadness is
/// only trustworthy after a FULL trace.
pub(crate) fn collect_dead_registered_buffers_post_trace(full_trace: bool) -> Vec<usize> {
if !full_trace {
return Vec::new();
}
BUFFER_REGISTRY.with(|r| {
r.borrow()
.iter()
.copied()
.filter(|&addr| unsafe { registered_buffer_is_dead_post_trace(addr) })
.collect()
})
}

unsafe fn registered_buffer_is_dead_post_trace(addr: usize) -> bool {
let Some(header) = crate::value::addr_class::try_read_gc_header(addr) else {
return false;
};
if header.obj_type != crate::gc::GC_TYPE_BUFFER {
return false;
}
header.gc_flags
& (crate::gc::GC_FLAG_MARKED | crate::gc::GC_FLAG_PINNED | crate::gc::GC_FLAG_FORWARDED)
== 0
}

/// Drop every registry/side-table entry keyed by a dead buffer's address.
/// Without this, the recycled address inherits buffer identity
/// (`is_registered_buffer`/`is_array_buffer` misclassify the next tenant —
/// the #6080 ABA class) and the entries leak forever.
pub(crate) fn finalize_collected_dead_buffer(addr: usize) {
BUFFER_REGISTRY.with(|r| {
r.borrow_mut().remove(&addr);
});
ARRAY_BUFFER_REGISTRY.with(|r| {
r.borrow_mut().remove(&addr);
});
BUFFER_AB_ALIAS.with(|r| {
r.borrow_mut().remove(&addr);
});
super::view::remove_entries_for_dead_buffer(addr);
}

/// Get the data pointer for a buffer
Expand Down
37 changes: 25 additions & 12 deletions crates/perry-runtime/src/buffer/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//! Buffer module - provides binary data handling similar to Node.js Buffer

use std::alloc::{alloc, Layout};
use std::alloc::Layout;
use std::ptr;

use crate::array::ArrayHeader;
Expand All @@ -24,7 +24,7 @@ mod query;
mod transcode;
mod u8_codec;
pub mod validate;
mod view;
pub(crate) mod view;

// ---- Re-exports: types & constants ----
pub use header::{BufferHeader, BUFFER_TYPE_ID, SMALL_BUF_THRESHOLD};
Expand All @@ -39,6 +39,9 @@ pub use header::{
mark_as_crypto_key, mark_as_data_view, mark_as_secret_key, mark_as_shared_array_buffer,
mark_as_uint8array, register_buffer, resolve_buffer_ab_alias, set_buffer_ab_alias,
};
pub(crate) use header::{
collect_dead_registered_buffers_post_trace, finalize_collected_dead_buffer,
};

// ---- Re-exports: Buffer.from / alloc / concat (FFI) ----
pub use from::{
Expand Down Expand Up @@ -171,23 +174,33 @@ mod tests {
}
}

// #5226: every off-heap buffer (incl. `new Uint8Array(n)`, which lowers to
// a slab Buffer) must reserve a zeroed 8-byte sentinel before its pointer,
// so the runtime's many `*(ptr - GC_HEADER_SIZE)` type probes read a mapped
// `0` (matching no GC_TYPE) instead of crossing into the unmapped page
// before a freshly mapped slab/block and segfaulting. The sentinel must be
// `0`, never a real type tag.
// #5226 successor (2026-07-09 audit): every buffer — including the
// formerly slab-allocated small tier — now carries a REAL GcHeader with
// `GC_TYPE_BUFFER`, so the runtime's `*(ptr - GC_HEADER_SIZE)` type
// probes read a genuine header (matching no other GC_TYPE) instead of
// the old zeroed off-heap sentinel. The classification property the
// sentinel protected must keep holding.
#[test]
fn small_buffer_reserves_zeroed_header_sentinel() {
for cap in [0u32, 1, 3, 16, 255] {
let buf = buffer_alloc(cap);
assert!(is_registered_buffer(buf as usize), "cap={cap}");
unsafe {
let sentinel = *(buf as *const u8).sub(crate::gc::GC_HEADER_SIZE);
assert_eq!(sentinel, 0, "cap={cap}: header sentinel must be zero");
let header =
(buf as *const u8).sub(crate::gc::GC_HEADER_SIZE) as *const crate::gc::GcHeader;
assert_eq!(
(*header).obj_type,
crate::gc::GC_TYPE_BUFFER,
"cap={cap}: buffers must carry a real GC_TYPE_BUFFER header"
);
assert_ne!(
(*header).gc_flags & crate::gc::GC_FLAG_TENURED,
0,
"cap={cap}: buffers are born tenured in the old arena"
);
}
// The header-probing classifiers must read the sentinel and answer
// "not my type" without faulting.
// The header-probing classifiers must answer "not my type"
// without faulting.
let v = crate::value::js_nanbox_pointer(buf as i64);
assert_eq!(crate::promise::js_value_is_promise(v), 0, "cap={cap}");
assert!(!crate::date::is_date_cell_addr(buf as usize), "cap={cap}");
Expand Down
23 changes: 23 additions & 0 deletions crates/perry-runtime/src/buffer/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,29 @@ pub(crate) fn for_each_view<F: FnMut(usize, ViewInfo)>(backing_ptr: usize, mut f
});
}

/// Drop every view-registry entry keyed by (or backed by) a dead buffer's
/// address (2026-07-09 audit, registry death pruning). A recycled address
/// would otherwise inherit stale view/backing metadata and misroute reads
/// and mirrored writes for the next tenant.
pub(crate) fn remove_entries_for_dead_buffer(addr: usize) {
VIEW_REGISTRY.with(|r| {
let mut r = r.borrow_mut();
r.remove(&addr);
r.retain(|_, info| info.backing != addr);
});
BACKING_TO_VIEWS.with(|m| {
let mut m = m.borrow_mut();
m.remove(&addr);
for views in m.values_mut() {
for view in views.iter_mut() {
if *view == addr {
*view = 0;
}
}
}
});
}

/// Register a freshly-allocated `view_ptr` as a view over `backing_ptr`
/// at byte range `[offset, offset+length)`. Resolves slices-of-slices
/// to the ultimate backing so reads/writes never walk a chain.
Expand Down
Loading