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
5 changes: 5 additions & 0 deletions crates/perry-api-manifest/src/entries.rs
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,10 @@ pub const NATIVE_MODULES: &[&str] = &[
"perry/i18n",
"worker_threads",
"perry/thread",
// `perry/gc` — explicit GC control (collect / minor / idleHint).
// Served entirely by perry-runtime; a no-op-style Perry-native
// surface like `perry/thread` (doesn't resolve under Node/Bun).
"perry/gc",
"perry/updater",
"perry/container",
"perry/container-compose",
Expand Down Expand Up @@ -217,6 +221,7 @@ pub const RUNTIME_ONLY_MODULES: &[&str] = &[
"perry/widget",
"perry/i18n",
"perry/thread",
"perry/gc",
"perry/media",
"perry/audio",
"perry/tui",
Expand Down
8 changes: 8 additions & 0 deletions crates/perry-api-manifest/src/entries/part_1.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1388,6 +1388,14 @@ pub(crate) const API_MANIFEST_PART_1: &[ApiEntry] = &[
&[p_any("p0")],
TypeSpec::Promise,
),
// `perry/gc` — explicit GC control. `collect()` runs a full collection
// (same as the global `gc()`), `minor()` runs a nursery-only collection
// and returns the freed byte count, `idleHint()` runs a threshold-due
// collection at a caller-declared idle point (frame boundary) and
// returns whether one ran.
method_sig("perry/gc", "collect", false, None, &[], TypeSpec::Void),
method_sig("perry/gc", "minor", false, None, &[], TypeSpec::Number),
method_sig("perry/gc", "idleHint", false, None, &[], TypeSpec::Bool),
method_sig(
"lodash",
"chunk",
Expand Down
22 changes: 15 additions & 7 deletions crates/perry-codegen/src/lower_call/builtin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,19 +137,27 @@ pub(super) fn lower_builtin_new(
Ok(Some(nanbox_pointer_inline(blk, &handle)))
}
"Uint8Array" if args.len() >= 2 => {
// Pass the raw NaN-boxed offset/length (undefined when absent),
// same as the non-Uint8Array view kinds below: the runtime runs
// ToIndex — which can execute user `valueOf` code — and applies
// the spec's post-coercion detached/bounds checks. The old
// `fptosi` cast silently turned object arguments into garbage
// without ever running their coercion.
let source = lower_expr(ctx, &args[0])?;
let offset = lower_expr(ctx, &args[1])?;
let offset_i32 = ctx.block().fptosi(DOUBLE, &offset, I32);
let length_i32 = if args.len() >= 3 {
let length = lower_expr(ctx, &args[2])?;
ctx.block().fptosi(DOUBLE, &length, I32)
let offset_box = lower_expr(ctx, &args[1])?;
let length_box = if args.len() >= 3 {
lower_expr(ctx, &args[2])?
} else {
"-1".to_string()
double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED))
};
let handle = ctx.block().call(
I64,
"js_uint8array_view",
&[(DOUBLE, &source), (I32, &offset_i32), (I32, &length_i32)],
&[
(DOUBLE, &source),
(DOUBLE, &offset_box),
(DOUBLE, &length_box),
],
);
Ok(Some(nanbox_pointer_inline(ctx.block(), &handle)))
}
Expand Down
32 changes: 32 additions & 0 deletions crates/perry-codegen/src/lower_call/native_table/thread_lodash.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,38 @@ pub(super) const THREAD_LODASH_ROWS: &[NativeModSig] = &[
args: &[NA_F64],
ret: NR_F64,
},
// ========== perry/gc (explicit GC control: collect, minor, idleHint) ==========
// Zero-arg runtime calls. The runtime side returns already-NaN-boxed
// JSValues (undefined / a byte count / a boolean), so NR_F64 passes them
// through unchanged. Under Node/Bun these imports don't resolve — the
// module is a Perry-native pacing surface, same story as perry/thread.
NativeModSig {
module: "perry/gc",
has_receiver: false,
method: "collect",
class_filter: None,
runtime: "js_gc_module_collect",
args: &[],
ret: NR_F64,
},
NativeModSig {
module: "perry/gc",
has_receiver: false,
method: "minor",
class_filter: None,
runtime: "js_gc_module_minor",
args: &[],
ret: NR_F64,
},
NativeModSig {
module: "perry/gc",
has_receiver: false,
method: "idleHint",
class_filter: None,
runtime: "js_gc_module_idle_hint",
args: &[],
ret: NR_F64,
},
// ========== lodash (named-import form: import { chunk } from 'lodash') ==========
// Default-import form (import _ from 'lodash'; _.chunk(...)) needs has_receiver:true
// but would pass the module object as first arg, breaking the C signature.
Expand Down
4 changes: 3 additions & 1 deletion crates/perry-codegen/src/runtime_decls/strings_part2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,9 @@ pub(crate) fn declare_phase_b_strings_part2(module: &mut LlModule) {
// `new Uint8Array(x)` runtime dispatch — handles the non-literal case
// where `x` could be a number (length) or an array (source data).
module.declare_function("js_uint8array_new", I64, &[DOUBLE]);
module.declare_function("js_uint8array_view", I64, &[DOUBLE, I32, I32]);
// Raw NaN-boxed byteOffset/length (undefined when absent) — the runtime
// runs ToIndex and the spec's post-coercion detached/bounds checks.
module.declare_function("js_uint8array_view", I64, &[DOUBLE, DOUBLE, DOUBLE]);
// Generic typed array runtime (Int8/16/32, Uint16/32, Float32/64, Uint8Clamped).
// Uint8Array piggybacks on the BufferHeader path.
module.declare_function("js_typed_array_new_empty", I64, &[I32, I32]);
Expand Down
163 changes: 163 additions & 0 deletions crates/perry-runtime/src/buffer/detach.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
//! ArrayBuffer detach state and `ArrayBuffer.prototype.transfer` /
//! `transferToFixedLength` / `detached` (ES2024).
//!
//! Buffer bytes live INLINE after the `BufferHeader` in a GC old-arena
//! allocation, so a detached buffer's storage cannot be individually freed
//! while the JS object is alive. Detach therefore (1) zeroes the header —
//! the pre-existing structuredClone-transfer convention, which makes
//! `byteLength` read 0 — (2) zeroes every registered view's length so views
//! over the detached buffer report length 0 like Node, and (3) hands the
//! page-aligned interior of the payload back to the OS with `madvise`, so a
//! large detached buffer stops costing RSS immediately even while the
//! ArrayBuffer object itself is still reachable. The GcHeader `size` field
//! is left untouched: the arena sweep still steps over the full allocation,
//! and the decommitted pages stay mapped (later reads are legal and return
//! zeros), so the only observable effect is RSS dropping.

use super::*;
use crate::fast_hash::{new_ptr_hash_set, PtrHashSet};
use std::cell::RefCell;

thread_local! {
/// Buffers detached via `transfer`/`transferToFixedLength`/structuredClone
/// transfer. A detached buffer also has `length == capacity == 0`, but that
/// alone cannot be the probe: `new ArrayBuffer(0)` is empty yet NOT
/// detached.
static DETACHED_BUFFER_REGISTRY: RefCell<PtrHashSet<usize>> =
RefCell::new(new_ptr_hash_set());
}

/// `ArrayBuffer.prototype.detached` — true after a successful transfer.
pub fn is_detached_buffer(addr: usize) -> bool {
DETACHED_BUFFER_REGISTRY.with(|r| r.borrow().contains(&addr))
}

/// Drop the detached mark when the buffer dies — a recycled address would
/// otherwise inherit detached-ness (the #6080 ABA class).
pub(crate) fn remove_detached_entry_for_dead_buffer(addr: usize) {
DETACHED_BUFFER_REGISTRY.with(|r| {
r.borrow_mut().remove(&addr);
});
}

/// DetachArrayBuffer(buffer): idempotent.
pub fn detach_array_buffer(addr: usize) {
if is_detached_buffer(addr) {
return;
}
let buf = addr as *mut BufferHeader;
let capacity = unsafe { (*buf).capacity };
unsafe {
(*buf).length = 0;
(*buf).capacity = 0;
}
DETACHED_BUFFER_REGISTRY.with(|r| {
r.borrow_mut().insert(addr);
});
// Buffer-shaped views (`new Uint8Array(ab)`, DataView slices): zero their
// own header lengths so `.length`/`.byteLength` report 0 and every indexed
// access is out-of-bounds, matching Node's view-over-detached semantics.
// `ArrayBuffer.prototype.slice` results also land in the view table (the
// Buffer.slice aliasing mechanism registers them), but they are
// independent COPIES per spec and must survive the source's detach —
// they're the only view-table entries marked as ArrayBuffers, so skip
// those.
super::view::for_each_view(addr, |view_ptr, _info| {
if is_array_buffer(view_ptr) {
return;
}
unsafe {
(*(view_ptr as *mut BufferHeader)).length = 0;
}
});
// Typed-array views (`new Float32Array(ab, ...)`) record their backing in
// a separate side table; zero those lengths too.
crate::typedarray_view::zero_views_of_detached_backing(addr);
decommit_payload_pages(buffer_data_mut(buf), capacity as usize);
}

/// Release the page-aligned interior of a detached payload back to the OS.
/// Rounds INWARD (start up, end down), so only pages lying entirely inside
/// `[data, data + capacity)` are touched — the BufferHeader, the GcHeader in
/// front of it, and any neighbor allocations on the boundary pages are never
/// affected. Failure is harmless (the advice is best-effort), so the return
/// value is ignored.
#[cfg(unix)]
fn decommit_payload_pages(data: *mut u8, capacity: usize) {
let page = unsafe { libc::sysconf(libc::_SC_PAGESIZE) };
if page <= 0 {
return;
}
let page = page as usize;
let start = (data as usize).wrapping_add(page - 1) & !(page - 1);
let end = (data as usize + capacity) & !(page - 1);
if end <= start {
return;
}
unsafe {
// macOS: MADV_FREE_REUSABLE drops the pages from the process
// footprint immediately (plain MADV_FREE only reclaims under
// memory pressure, so RSS wouldn't visibly shrink). It can fail
// on some region types — fall back to MADV_FREE then.
#[cfg(target_os = "macos")]
{
let len = end - start;
if libc::madvise(start as *mut libc::c_void, len, libc::MADV_FREE_REUSABLE) != 0 {
libc::madvise(start as *mut libc::c_void, len, libc::MADV_FREE);
}
}
// Linux (and other unix): MADV_DONTNEED drops the pages (and RSS)
// immediately; later reads legally return zeros.
#[cfg(not(target_os = "macos"))]
{
libc::madvise(start as *mut libc::c_void, end - start, libc::MADV_DONTNEED);
}
}
}

#[cfg(not(unix))]
fn decommit_payload_pages(_data: *mut u8, _capacity: usize) {}

fn throw_type_error(message: &str) -> ! {
let msg = crate::string::js_string_from_bytes(message.as_ptr(), message.len() as u32);
let err = crate::error::js_error_new_with_name_message(b"TypeError", msg);
crate::exception::js_throw(crate::value::js_nanbox_pointer(err as i64))
}

/// `ArrayBuffer.prototype.transfer(newLength?)` and `transferToFixedLength`.
/// Perry has no resizable ArrayBuffers, so both produce a fixed-length result
/// and are identical: allocate a zero-filled buffer of `newLength` (default:
/// the current byteLength), copy `min(oldLength, newLength)` bytes, detach the
/// source, and return the new buffer.
pub(crate) fn array_buffer_transfer(addr: usize, args: &[f64]) -> f64 {
// ES2024 ArrayBufferCopyAndDetach ordering: ToIndex(newLength) runs FIRST
// — it can execute user code (`valueOf`) that detaches this very buffer —
// and IsDetachedBuffer is checked after, so a mid-coercion detach is
// caught before any stale header read.
let requested_len = match args.first().copied() {
Some(v) if !crate::value::JSValue::from_bits(v.to_bits()).is_undefined() => {
Some(super::from::array_buffer_to_index(v))
}
_ => None,
};
if is_detached_buffer(addr) {
throw_type_error("Cannot perform ArrayBuffer.prototype.transfer on a detached ArrayBuffer");
}
let src = addr as *mut BufferHeader;
let old_len = unsafe { (*src).length } as i32;
let new_len = requested_len.unwrap_or(old_len);
let dst = super::from::zeroed_array_buffer_storage(new_len);
mark_as_array_buffer(dst as usize);
let copy_len = old_len.min(new_len);
if copy_len > 0 {
unsafe {
std::ptr::copy_nonoverlapping(
buffer_data(src),
buffer_data_mut(dst),
copy_len as usize,
);
}
}
detach_array_buffer(addr);
f64::from_bits(crate::value::JSValue::pointer(dst as *mut u8).bits())
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Loading
Loading