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
2 changes: 2 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,8 @@ jobs:
- name: File size limit
if: ${{ !cancelled() }}
run: ./scripts/check_file_size.sh
- name: Buffer layout ratchet
run: ./scripts/check_buffer_layout.sh --self-test && ./scripts/check_buffer_layout.sh

# #7846: erased TypeScript annotations and initializer-refined local
# types are hints, not runtime proofs. Every codegen read must choose the
Expand Down
15 changes: 15 additions & 0 deletions changelog.d/PENDING-buffer-b1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
Unify native byte access over the current Buffer and TypedArray layouts. `perry-ffi::alloc_buffer` now calls `js_perry_bytes_copy`; its header type is opaque, so an extension cannot derive a payload address from `sizeof(BufferHeader)`. Readers require a `no_gc` token and end their borrow before allocation or JS. The forced extension edits only close those borrow scopes; stream state is unchanged.

`buffer::bytes` provides scoped read/write access, an owner-thread pin, allocation and copying. Pins root the current non-young owner through the existing GC pin mechanism. Nested pins use existing unused header bits, preserve permanent pins, and defer owned native release on detach and NativeArena release on disposal. No cell/header grows; no placement threshold, table, cache, latch, pacing or codegen rule is introduced. Engine-owned foreign memory without a retention protocol is explicitly refused by `pin`: wasm `memory.grow` can otherwise free a pinned raw address outside Perry's collector. Borrowing still covers that placement. This is an unresolved B1 requirement, not a completed foreign-pin guarantee.

C ABI: `js_perry_bytes_{borrow,pin,unpin,new,copy,adopt}` live in `native_payload_abi.rs`. The descriptor prefix and payload digest are copied from draft #12064 (`net-transport-p0`, `2bf026ab46649a07394d419c0689faaa2995b768`). `abi_matches()` checks that unchanged payload digest and a byte-span layout/revision digest. To combine with P0, retain P0's full payload files, append B1's byte section to its runtime ABI file, and add the byte digest check to P0's `abi_matches()`. No net/transport implementation is imported. Current `adopt` consumes a vector by copying and calling its supplied free thunk; B3 may adopt native storage under its own placement rule.

Census closure: the TSV contains 14 production runtime/stdlib `write` rows, although the lane description says 13. Converted `buffer/access.rs:294`, `bun_compat/mod.rs:377`, `child_process/value_util.rs:158`, `dgram/net.rs:154`, `embedded.rs:474`, `fs/mod.rs:931`, `node_v8.rs:230,486`, `tls.rs:688`, `typedarray/mod.rs:705,713`, and stdlib `ethers.rs:326`, `tls.rs:717`, `zlib.rs:1414`. The TypedArray rows were duplicate pointer resolvers; the mutable resolver now shares the canonical read resolver. Existing layouts and element admission paths remain in use.

Validation on current main `ba3d8c0b80` merged at `d633152cc6`: runtime 5,159 passed / 0 failed / 5 ignored (main 5,151 / 0 / 5); codegen 2,047 unit tests plus all integration suites passed on both arms; stdlib 255 passed / 2 failed (main 251 / 2), with the identical existing thread-exit closure and symbol failures; external zlib 17 / 0 on both arms; FFI 74 / 0 (main 75 / 0, the obsolete public BufferHeader layout assertion is retired). Gap A/B and performance measurements are pending. The new unit witnesses require an actual moving collection, native detach lifetime, canonical spans across current placements, NativeArena disposal lifetime, and a native-backed allocation passing through the exact copy ABI used by `alloc_buffer`. Child sabotages remove the owner root, free on detach, restore the cell+8 copy, remove the debug no_gc allocation guard, free NativeArena bytes while pinned, return a view’s owner window, or corrupt each converted zlib/TLS/ethers producer output. The no_gc debug check currently covers byte allocation funnels; a general GC allocation-entry hook remains pending owner approval because allocator files are reserved to the memory lane. The source ratchet refuses new explicit layout sites and keeps the remaining current-layout sites at their baseline (148 after main’s Buffer parameter fix removed one site). No versions are bumped.

The copy API uses the canonical data resolver on its factory-proved fresh cell, avoiding repeated view/detach admission. The existing admitted-inline writer keeps its original cache proof and address calculation within the API. Generic borrowing continues to check detach and view bounds. The source-gate sabotage plants five real layout sites and verifies that the actual gate exits 1 before removing them.

The current-main merge includes #12094 and #12095 without altering their codegen or gzip fixes. Both performance arms use separate targets, the same prebuilt package/feature set and forced http/net/ws/zlib archives. The drivers retain symbols, disable the object cache, and compare semantic output with Node 26.5.1; only elapsed-time fields are normalized for the typed kernels and the supplied Effect workload. Measurements alternate arm order for five trials, pair every main run with an identical-binary control, and record full collections in separate diagnostic runs. RSS attribution samples anonymous, file-backed and huge-page RSS. THP-off uses both `MIMALLOC_ALLOW_THP=0` and process `PR_SET_THP_DISABLE`, verified by a child reporting `PR_GET_THP_DISABLE=1`; its artifacts are separate from the normal cohort.

A final placement review found that process-global SharedArrayBuffer headers must remain read-only across agents. Pins now use their existing permanent process owner without touching GC/header pin counts. Thread-local SAB copies still use ordinary GC custody. A concurrent two-agent witness checks the global header before, during and after pins; its `shared_pin_write` child sabotage restores the mutable-header path and must turn that witness red. Validation after this fix is in progress.
14 changes: 6 additions & 8 deletions crates/perry-ext-ethers/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,8 +146,9 @@ pub unsafe extern "C" fn js_keccak256_native(buf_ptr: i64) -> *mut StringHeader
let s = "0x0000000000000000000000000000000000000000000000000000000000000000";
return alloc_string(s).as_raw();
}
let bytes = perry_ffi::read_buffer_bytes(buf).unwrap_or(&[]);
let hash = keccak256(bytes);
let hash = perry_ffi::bytes::no_gc(|scope| {
keccak256(perry_ffi::read_buffer_bytes(buf, scope).unwrap_or(&[]))
});

let hex_chars = b"0123456789abcdef";
let mut out = Vec::with_capacity(66);
Expand All @@ -174,12 +175,9 @@ pub unsafe extern "C" fn js_keccak256_native(buf_ptr: i64) -> *mut StringHeader
#[no_mangle]
pub unsafe extern "C" fn js_keccak256_native_bytes(buf_ptr: i64) -> *mut BufferHeader {
let buf = (buf_ptr as u64 & 0x0000_FFFF_FFFF_FFFF) as *const BufferHeader;
let bytes: &[u8] = if buf.is_null() {
&[]
} else {
perry_ffi::read_buffer_bytes(buf).unwrap_or(&[])
};
let hash = keccak256(bytes);
let hash = perry_ffi::bytes::no_gc(|scope| {
keccak256(perry_ffi::read_buffer_bytes(buf, scope).unwrap_or(&[]))
});
alloc_buffer(&hash)
}

Expand Down
12 changes: 7 additions & 5 deletions crates/perry-ext-http/src/agent/tls_compat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -247,11 +247,13 @@ unsafe fn raw_pfx_identity(options: f64) -> Option<String> {
let raw = value.as_pointer::<u8>() as i64;
if unsafe { is_buffer(raw) } != 0 {
2_u8.hash(hasher);
if let Some(bytes) =
perry_ffi::read_buffer_bytes(raw as *const perry_ffi::BufferHeader)
{
bytes.hash(hasher);
}
perry_ffi::bytes::no_gc(|scope| {
if let Some(bytes) =
perry_ffi::read_buffer_bytes(raw as *const perry_ffi::BufferHeader, scope)
{
bytes.hash(hasher);
}
});
return;
}
}
Expand Down
6 changes: 4 additions & 2 deletions crates/perry-ext-http/src/client_outgoing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,10 @@ pub(crate) unsafe fn chunk_to_bytes(value: f64) -> Option<Vec<u8>> {
if bits >> 48 == 0x7FFD {
let raw = (bits & PTR_MASK) as i64;
if js_buffer_is_buffer(raw) != 0 {
return perry_ffi::read_buffer_bytes(raw as *const perry_ffi::BufferHeader)
.map(|b| b.to_vec());
return perry_ffi::bytes::no_gc(|scope| {
perry_ffi::read_buffer_bytes(raw as *const perry_ffi::BufferHeader, scope)
.map(|b| b.to_vec())
});
}
}
extract_string_value(value).map(String::into_bytes)
Expand Down
98 changes: 51 additions & 47 deletions crates/perry-ext-http/src/server/http2_settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -154,60 +154,62 @@ pub extern "C" fn js_node_http2_get_packed_settings(settings_bits: i64) -> *mut
#[no_mangle]
pub extern "C" fn js_node_http2_get_unpacked_settings(buf_bits: i64) -> *mut StringHeader {
let value = JsValue::from_bits(buf_bits as u64);
let bytes = match value_byte_slice(value) {
Some(b) => b,
None => throw_not_buffer(value),
let len = perry_ffi::bytes::no_gc(|scope| value_byte_slice(value, scope).map(<[u8]>::len));
let Some(len) = len else {
throw_not_buffer(value)
};
if bytes.len() % 6 != 0 {
if len % 6 != 0 {
throw_with_code(
"Packed settings length must be a multiple of six",
"ERR_HTTP2_INVALID_PACKED_SETTINGS_LENGTH",
ErrorKind::RangeError,
);
}

let mut parts: Vec<String> = Vec::new();
let mut custom_settings = BTreeMap::new();
let mut custom_position = None;
let mut i = 0;
while i + 6 <= bytes.len() {
let id = u16::from_be_bytes([bytes[i], bytes[i + 1]]);
let val = u32::from_be_bytes([bytes[i + 2], bytes[i + 3], bytes[i + 4], bytes[i + 5]]);
match id {
ID_HEADER_TABLE_SIZE => parts.push(format!("\"headerTableSize\":{val}")),
ID_ENABLE_PUSH => parts.push(format!("\"enablePush\":{}", val != 0)),
ID_MAX_CONCURRENT_STREAMS => parts.push(format!("\"maxConcurrentStreams\":{val}")),
ID_INITIAL_WINDOW_SIZE => parts.push(format!("\"initialWindowSize\":{val}")),
ID_MAX_FRAME_SIZE => parts.push(format!("\"maxFrameSize\":{val}")),
ID_MAX_HEADER_LIST_SIZE => {
// Node populates both aliases from identifier 6.
parts.push(format!("\"maxHeaderSize\":{val}"));
parts.push(format!("\"maxHeaderListSize\":{val}"));
}
ID_ENABLE_CONNECT_PROTOCOL => {
parts.push(format!("\"enableConnectProtocol\":{}", val != 0))
}
_ => {
// Node exposes unknown IDs, with the container inserted at
// the first unknown record and duplicate IDs last-wins.
if custom_position.is_none() {
custom_position = Some(parts.len());
parts.push(String::new());
let json = perry_ffi::bytes::no_gc(|scope| {
let bytes = value_byte_slice(value, scope).expect("validated byte value");
let mut parts: Vec<String> = Vec::new();
let mut custom_settings = BTreeMap::new();
let mut custom_position = None;
let mut i = 0;
while i + 6 <= bytes.len() {
let id = u16::from_be_bytes([bytes[i], bytes[i + 1]]);
let val = u32::from_be_bytes([bytes[i + 2], bytes[i + 3], bytes[i + 4], bytes[i + 5]]);
match id {
ID_HEADER_TABLE_SIZE => parts.push(format!("\"headerTableSize\":{val}")),
ID_ENABLE_PUSH => parts.push(format!("\"enablePush\":{}", val != 0)),
ID_MAX_CONCURRENT_STREAMS => parts.push(format!("\"maxConcurrentStreams\":{val}")),
ID_INITIAL_WINDOW_SIZE => parts.push(format!("\"initialWindowSize\":{val}")),
ID_MAX_FRAME_SIZE => parts.push(format!("\"maxFrameSize\":{val}")),
ID_MAX_HEADER_LIST_SIZE => {
// Node populates both aliases from identifier 6.
parts.push(format!("\"maxHeaderSize\":{val}"));
parts.push(format!("\"maxHeaderListSize\":{val}"));
}
ID_ENABLE_CONNECT_PROTOCOL => {
parts.push(format!("\"enableConnectProtocol\":{}", val != 0))
}
_ => {
// Node exposes unknown IDs, with the container inserted at
// the first unknown record and duplicate IDs last-wins.
if custom_position.is_none() {
custom_position = Some(parts.len());
parts.push(String::new());
}
custom_settings.insert(id, val);
}
custom_settings.insert(id, val);
}
i += 6;
}
i += 6;
}
if let Some(position) = custom_position {
// All u16 IDs are JS array-index keys, so enumerate them numerically.
let custom_parts: Vec<String> = custom_settings
.iter()
.map(|(id, val)| format!("\"{id}\":{val}"))
.collect();
parts[position] = format!("\"customSettings\":{{{}}}", custom_parts.join(","));
}
let json = format!("{{{}}}", parts.join(","));
if let Some(position) = custom_position {
// All u16 IDs are JS array-index keys, so enumerate them numerically.
let custom_parts: Vec<String> = custom_settings
.iter()
.map(|(id, val)| format!("\"{id}\":{val}"))
.collect();
parts[position] = format!("\"customSettings\":{{{}}}", custom_parts.join(","));
}
format!("{{{}}}", parts.join(","))
});
alloc_string(&json).as_raw()
}

Expand Down Expand Up @@ -410,9 +412,11 @@ mod tests {
fn pack_object(settings: JsValue) -> Result<Vec<u8>, f64> {
perry_runtime::exception::catch_js_throw(|| {
let buffer = js_node_http2_get_packed_settings(settings.bits() as i64);
value_byte_slice(JsValue::from_object_ptr(buffer))
.expect("packed settings must be a Buffer")
.to_vec()
perry_ffi::bytes::no_gc(|scope| {
value_byte_slice(JsValue::from_object_ptr(buffer), scope)
.expect("packed settings must be a Buffer")
.to_vec()
})
})
}

Expand Down
21 changes: 11 additions & 10 deletions crates/perry-ext-http/src/server/https_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -227,22 +227,23 @@ pub struct HttpsServer {
/// provider is shared by future per-connection configs, so rotation takes
/// effect without replacing the accept loop or touching another server.
pub(crate) fn set_ticket_keys(server_handle: i64, value: f64) {
let Some(bytes) = perry_ffi::value_byte_slice(JsValue::from_bits(value.to_bits())) else {
perry_ffi::throw_with_code(
let keys = perry_ffi::bytes::no_gc(|scope| {
perry_ffi::value_byte_slice(JsValue::from_bits(value.to_bits()), scope)
.map(|bytes| <[u8; 48]>::try_from(bytes))
});
let keys = match keys {
None => perry_ffi::throw_with_code(
"The session ticket keys argument must be a Buffer or TypedArray",
"ERR_INVALID_ARG_TYPE",
perry_ffi::ErrorKind::TypeError,
);
};
if bytes.len() != 48 {
perry_ffi::throw_with_code(
),
Some(Err(_)) => perry_ffi::throw_with_code(
"Session ticket keys must be a 48-byte buffer",
"ERR_INVALID_ARG_VALUE",
perry_ffi::ErrorKind::TypeError,
);
}
let mut keys = [0_u8; 48];
keys.copy_from_slice(bytes);
),
Some(Ok(keys)) => keys,
};
let (ticket_key, port) = get_handle::<HttpsServer>(server_handle)
.map(|server| (server.ticket_key.clone(), server.base.bound_port))
.unwrap_or_else(|| {
Expand Down
12 changes: 8 additions & 4 deletions crates/perry-ext-http/src/server/server/upgrade_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,14 @@ extern "C" fn observe(
set_closure_capture_f64(closure as *mut _, 0, count + 1.0);
set_closure_capture_f64(closure as *mut _, 1, req);
set_closure_capture_f64(closure as *mut _, 2, socket);
let bytes = perry_ffi::read_buffer_bytes(
(head.to_bits() & PTR_MASK) as *const perry_ffi::BufferHeader,
)
.unwrap();
let bytes = perry_ffi::bytes::no_gc(|scope| {
perry_ffi::read_buffer_bytes(
(head.to_bits() & PTR_MASK) as *const perry_ffi::BufferHeader,
scope,
)
.unwrap()
.to_vec()
});
assert_eq!(bytes, &[0xff, 0, 0x80]);
}
0.0
Expand Down
4 changes: 3 additions & 1 deletion crates/perry-ext-http/src/server/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,9 @@ pub fn jsvalue_to_body_bytes(value: f64) -> Option<Vec<u8>> {
if is_buffer {
let buf = raw as *const BufferHeader;
if !buf.is_null() {
return perry_ffi::read_buffer_bytes(buf).map(<[u8]>::to_vec);
return perry_ffi::bytes::no_gc(|scope| {
perry_ffi::read_buffer_bytes(buf, scope).map(<[u8]>::to_vec)
});
}
}
// Non-buffer pointer — try the string-shaped header (shared
Expand Down
8 changes: 6 additions & 2 deletions crates/perry-ext-http/src/server/upgrade.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,11 +91,15 @@ fn _force_link() -> u64 {
mod tests {
use super::{upgrade_head_arg, POINTER_TAG, PTR_MASK};

fn head_bytes(data: &[u8]) -> &'static [u8] {
fn head_bytes(data: &[u8]) -> Vec<u8> {
let arg = upgrade_head_arg(data);
assert_eq!(arg.to_bits() & !PTR_MASK, POINTER_TAG);
let ptr = (arg.to_bits() & PTR_MASK) as *const perry_ffi::BufferHeader;
perry_ffi::read_buffer_bytes(ptr).expect("upgrade head buffer")
perry_ffi::bytes::no_gc(|scope| {
perry_ffi::read_buffer_bytes(ptr, scope)
.expect("upgrade head buffer")
.to_vec()
})
}

#[test]
Expand Down
5 changes: 4 additions & 1 deletion crates/perry-ext-http/src/tls_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -699,7 +699,10 @@ unsafe fn live_pfx_entries(options: f64) -> Option<Vec<(Vec<u8>, String)>> {
if is_buffer(raw) == 0 {
return None;
}
perry_ffi::read_buffer_bytes(raw as *const perry_ffi::BufferHeader).map(<[u8]>::to_vec)
perry_ffi::bytes::no_gc(|scope| {
perry_ffi::read_buffer_bytes(raw as *const perry_ffi::BufferHeader, scope)
.map(<[u8]>::to_vec)
})
}

unsafe fn entry(
Expand Down
6 changes: 5 additions & 1 deletion crates/perry-ext-net/src/jsvalue.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,11 @@ pub(crate) unsafe fn jsvalue_to_socket_bytes(value: f64) -> Option<Vec<u8>> {
if js_buffer_is_buffer(raw) != 0 {
let buf = raw as *const BufferHeader;
if !buf.is_null() {
return Some(perry_ffi::read_buffer_bytes(buf).unwrap_or(&[]).to_vec());
return Some(perry_ffi::bytes::no_gc(|scope| {
perry_ffi::read_buffer_bytes(buf, scope)
.unwrap_or(&[])
.to_vec()
}));
}
}
// Non-buffer pointer: do NOT reinterpret it as a `StringHeader`. A real
Expand Down
16 changes: 10 additions & 6 deletions crates/perry-ext-sharp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,10 @@ unsafe fn decode_image_from_value(input_bits: i64) -> Option<DynamicImage> {
return None;
}
if js_buffer_is_buffer(ptr) != 0 {
let bytes = read_buffer_bytes(ptr as *const BufferHeader)?;
image::load_from_memory(bytes).ok()
perry_ffi::bytes::no_gc(|scope| {
let bytes = read_buffer_bytes(ptr as *const BufferHeader, scope)?;
image::load_from_memory(bytes).ok()
})
} else if JsValue::from_bits(input_bits as u64).is_pointer() {
None // object/array — not a valid input
} else {
Expand Down Expand Up @@ -311,10 +313,12 @@ pub unsafe extern "C" fn js_sharp_from_input(input_bits: i64) -> Handle {
return -1;
}
if js_buffer_is_buffer(ptr) != 0 {
return match read_buffer_bytes(ptr as *const BufferHeader) {
Some(bytes) => decode_image_bytes(bytes),
None => -1,
};
return perry_ffi::bytes::no_gc(|scope| {
match read_buffer_bytes(ptr as *const BufferHeader, scope) {
Some(bytes) => decode_image_bytes(bytes),
None => -1,
}
});
}
let input = JsValue::from_bits(input_bits as u64);
if input.is_pointer() {
Expand Down
Loading
Loading