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
8 changes: 8 additions & 0 deletions changelog.d/8045-next-response-cross-module.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
### Preserve streamed responses across module boundaries

`Response` subclasses such as Next.js's `NextResponse` now keep their native
response identity, live header and cookie mutations, and `ReadableStream`
bodies when returned synchronously or through promises from another module.
Shared-runtime dylib builds also register stream roots with the runtime
provider and index GC maps from loaded app images, so moving collections keep
queued stream state alive through a full drain.
10 changes: 8 additions & 2 deletions crates/perry-codegen/src/lower_call/options/fetch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -362,7 +362,10 @@ pub(in crate::lower_call) fn lower_fetch_native_method(

// ── Request property getters ──
if module == "Request" {
let h_handle = lower_expr(ctx, recv)?;
let h_value = lower_expr(ctx, recv)?;
let h_handle = ctx
.block()
.call(DOUBLE, "js_fetch_unwrap_handle", &[(DOUBLE, &h_value)]);
match method {
"url" => {
let str_ptr = ctx
Expand Down Expand Up @@ -530,7 +533,10 @@ pub(in crate::lower_call) fn lower_fetch_native_method(
// DOUBLE without any fptosi/bitcast conversion. May also be a chained
// result from `.headers` / `.clone()` — those cases are recognised at
// the Call callsite in lower_call.
let recv_handle = lower_expr(ctx, recv)?;
let recv_value = lower_expr(ctx, recv)?;
let recv_handle =
ctx.block()
.call(DOUBLE, "js_fetch_unwrap_handle", &[(DOUBLE, &recv_value)]);
match method {
"text" => {
let blk = ctx.block();
Expand Down
3 changes: 3 additions & 0 deletions crates/perry-codegen/src/runtime_decls/strings_part2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -931,6 +931,9 @@ pub(crate) fn declare_phase_b_strings_part2(module: &mut LlModule) {
// ──────────────────────────────────────────────────────────────────
// new Response(body_ptr, status, status_text_ptr, headers_handle) -> f64
module.declare_function("js_response_new", DOUBLE, &[I64, DOUBLE, I64, DOUBLE]);
// Normalize a Request/Response subclass object (for example NextResponse)
// to its native Fetch registry handle; bare handles pass through.
module.declare_function("js_fetch_unwrap_handle", DOUBLE, &[DOUBLE]);
// js_response_body_init_ptr(body_value_f64) -> string_ptr (i64): drains a
// ReadableStream body to bytes, else falls back to string coercion.
module.declare_function("js_response_body_init_ptr", I64, &[DOUBLE]);
Expand Down
122 changes: 78 additions & 44 deletions crates/perry-runtime/src/gc/roots/stack_maps.rs
Original file line number Diff line number Diff line change
Expand Up @@ -258,9 +258,10 @@ fn stack_maps() -> &'static StackMapIndex {
STACK_MAPS.get_or_init(|| {
// No section at all is the ordinary shadow-stack build: there are no
// native frame roots to find, and an empty index is the right answer.
let Some(section) = loaded_stack_map_section() else {
let sections = loaded_stack_map_sections();
if sections.is_empty() {
return StackMapIndex::default();
};
}
// A section that exists but does not decode is a different thing
// entirely, and it must never degrade to "no roots". The two failure
// shapes are indistinguishable downstream — both yield an empty index
Expand All @@ -270,22 +271,41 @@ fn stack_maps() -> &'static StackMapIndex {
// fourth gate-failure mode (the gate runs, its subject never did), so
// fail loudly instead. In practice this can only mean a binary whose
// compiler and runtime disagree about the map format.
let Some((mut records, roots)) = parse_gc_map(section) else {
panic!(
"perry: the GC map section (__perry_gcmap / .perry_gcmap, {} bytes) is \
present but could not be decoded — expected format {:?} v{}. This binary's \
compiler and runtime disagree about the map layout; continuing would run \
the collector with no roots and corrupt the heap silently.",
section.len(),
std::str::from_utf8(GC_MAP_MAGIC).unwrap_or("PGCM"),
GC_MAP_VERSION,
);
};
let mut records = Vec::new();
let mut roots = Vec::new();
for section in sections {
if append_gc_map_section(&mut records, &mut roots, section).is_none() {
panic!(
"perry: a GC map section (__perry_gcmap / .perry_gcmap, {} bytes) is \
present but could not be decoded — expected format {:?} v{}. This binary's \
compiler and runtime disagree about the map layout; continuing would run \
the collector with missing roots and corrupt the heap silently.",
section.len(),
std::str::from_utf8(GC_MAP_MAGIC).unwrap_or("PGCM"),
GC_MAP_VERSION,
);
}
}
records.sort_unstable_by_key(|record| record.pc);
index_records(records, roots)
})
}

fn append_gc_map_section(
records: &mut Vec<StackMapRecord>,
roots: &mut Vec<StackMapLocation>,
section: &[u8],
) -> Option<()> {
let (mut section_records, section_roots) = parse_gc_map(section)?;
let root_base = u32::try_from(roots.len()).ok()?;
for record in &mut section_records {
record.roots_start = record.roots_start.checked_add(root_base)?;
}
records.append(&mut section_records);
roots.extend(section_roots);
Some(())
}

fn index_records(records: Vec<StackMapRecord>, roots: Vec<StackMapLocation>) -> StackMapIndex {
// SP-relative locations are admitted here and resolved per FRAME in the
// walker, which decodes the owning function's `add x29, sp, #imm`
Expand Down Expand Up @@ -879,8 +899,8 @@ fn read_u64(bytes: &[u8], offset: usize) -> Option<u64> {
/// function addresses as `u64` and this code does `usize` arithmetic on them.
/// The compiler refuses that target for the same reason.
#[cfg(target_vendor = "apple")]
fn loaded_stack_map_section() -> Option<&'static [u8]> {
use mach2::dyld::{_dyld_get_image_header, _dyld_get_image_vmaddr_slide};
fn loaded_stack_map_sections() -> Vec<&'static [u8]> {
use mach2::dyld::{_dyld_get_image_header, _dyld_get_image_vmaddr_slide, _dyld_image_count};

const LC_SEGMENT_64: u32 = 0x19;

Expand Down Expand Up @@ -942,43 +962,57 @@ fn loaded_stack_map_section() -> Option<&'static [u8]> {
&& actual.get(expected.len()).copied().unwrap_or(0) == 0
}

let mut sections = Vec::new();
unsafe {
let raw_header = _dyld_get_image_header(0);
if raw_header.is_null() {
return None;
}
let header = &*(raw_header.cast::<MachHeader64>());
let slide = _dyld_get_image_vmaddr_slide(0);
let mut command_ptr = raw_header
.cast::<u8>()
.add(std::mem::size_of::<MachHeader64>());
for _ in 0..header.command_count {
let load = std::ptr::read_unaligned(command_ptr.cast::<LoadCommand>());
if load.size < std::mem::size_of::<LoadCommand>() as u32 {
return None;
for image_index in 0.._dyld_image_count() {
let raw_header = _dyld_get_image_header(image_index);
if raw_header.is_null() {
continue;
}
if load.command == LC_SEGMENT_64 {
let segment = std::ptr::read_unaligned(command_ptr.cast::<SegmentCommand64>());
let mut section_ptr = command_ptr.add(std::mem::size_of::<SegmentCommand64>());
for _ in 0..segment.section_count {
let section = std::ptr::read_unaligned(section_ptr.cast::<Section64>());
if fixed_name_matches(&section.segment_name, b"__PERRY_GCMAP")
&& fixed_name_matches(&section.section_name, b"__perry_gcmap")
{
let address = (section.address as isize).checked_add(slide)? as usize;
let size = usize::try_from(section.size).ok()?;
if address == 0 || size == 0 {
return None;
let header = &*(raw_header.cast::<MachHeader64>());
let slide = _dyld_get_image_vmaddr_slide(image_index);
let mut command_ptr = raw_header
.cast::<u8>()
.add(std::mem::size_of::<MachHeader64>());
for _ in 0..header.command_count {
let load = std::ptr::read_unaligned(command_ptr.cast::<LoadCommand>());
if load.size < std::mem::size_of::<LoadCommand>() as u32 {
break;
}
if load.command == LC_SEGMENT_64 {
let segment = std::ptr::read_unaligned(command_ptr.cast::<SegmentCommand64>());
let mut section_ptr = command_ptr.add(std::mem::size_of::<SegmentCommand64>());
for _ in 0..segment.section_count {
let section = std::ptr::read_unaligned(section_ptr.cast::<Section64>());
if fixed_name_matches(&section.segment_name, b"__PERRY_GCMAP")
&& fixed_name_matches(&section.section_name, b"__perry_gcmap")
{
if let (Some(address), Ok(size)) = (
(section.address as isize).checked_add(slide),
usize::try_from(section.size),
) {
if address > 0 && size != 0 {
sections.push(std::slice::from_raw_parts(
address as usize as *const u8,
size,
));
}
}
break;
}
return Some(std::slice::from_raw_parts(address as *const u8, size));
section_ptr = section_ptr.add(std::mem::size_of::<Section64>());
}
section_ptr = section_ptr.add(std::mem::size_of::<Section64>());
}
command_ptr = command_ptr.add(load.size as usize);
}
command_ptr = command_ptr.add(load.size as usize);
}
}
None
sections
}

#[cfg(not(target_vendor = "apple"))]
fn loaded_stack_map_sections() -> Vec<&'static [u8]> {
loaded_stack_map_section().into_iter().collect()
}

/// ELF (#7173): the `.perry_gcmap` section of the main executable.
Expand Down
17 changes: 17 additions & 0 deletions crates/perry-runtime/src/gc/roots/stack_maps_decode_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,23 @@ mod tests {
assert_eq!(records[1].pc, 0x2020);
}

#[test]
fn merges_gc_maps_from_separate_loaded_images() {
let first = simple(0x1000, 0x10, -8);
let second = simple(0x2000, 0x20, -16);
let mut records = Vec::new();
let mut roots = Vec::new();
append_gc_map_section(&mut records, &mut roots, &first).expect("first image map");
append_gc_map_section(&mut records, &mut roots, &second).expect("second image map");

assert_eq!(records.len(), 2);
assert_eq!(roots.len(), 2);
assert_eq!(records[0].roots_start, 0);
assert_eq!(records[1].roots_start, 1);
assert_eq!(roots[records[0].roots_start as usize].offset, -8);
assert_eq!(roots[records[1].roots_start as usize].offset, -16);
}

#[test]
fn repeated_live_sets_share_one_copy() {
// Three safepoints, the last two repeating the first's live set: the
Expand Down
21 changes: 21 additions & 0 deletions crates/perry-runtime/src/object/field_get_set.rs
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,27 @@ pub(crate) unsafe fn fetch_subclass_handle_id(obj: usize) -> Option<i64> {
}
}

/// Normalize a Fetch `Request`/`Response` value for a direct stdlib FFI call.
///
/// Bare Fetch values are already registry handles and pass through unchanged.
/// A userland subclass such as Next.js's `NextResponse` is a GC object whose
/// native handle lives in [`FETCH_SUBCLASS_HANDLE_FIELD`]; typed lowering used
/// to pass that object address to `js_fetch_response_*`, where it could never
/// resolve in the stdlib registry. Recover and re-box the backing handle so
/// typed and dynamic property access share the same record.
#[no_mangle]
pub extern "C" fn js_fetch_unwrap_handle(value: f64) -> f64 {
let js_value = crate::value::JSValue::from_bits(value.to_bits());
if !js_value.is_pointer() {
return value;
}
let raw = crate::value::js_nanbox_get_pointer(value) as usize;
match unsafe { fetch_subclass_handle_id(raw) } {
Some(id) => crate::value::js_nanbox_pointer(id),
None => value,
}
}

/// Hidden own-field name under which a `class X extends Temporal.<Type>`
/// instance stashes the NaN-boxed pointer to its underlying Temporal cell.
/// Written by `js_fetch_or_value_super` (the runtime-value super dispatcher,
Expand Down
9 changes: 9 additions & 0 deletions crates/perry-runtime/src/symbol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,15 @@ pub fn well_known_symbol(short_name: &str) -> *mut SymbolHeader {
sym_ptr
}

/// Provider-safe C ABI for the Headers iterable probe. Separately packaged
/// stdlib images must not call the Rust-mangled `well_known_symbol` directly,
/// because their fallback runtime glue owns a different symbol cache.
#[no_mangle]
pub extern "C" fn js_symbol_well_known_iterator() -> f64 {
let symbol = well_known_symbol("iterator");
f64::from_bits(POINTER_TAG | (symbol as u64 & POINTER_MASK))
}

/// O(1) check whether a raw pointer is a well-known symbol (Symbol.toPrimitive etc.).
/// Used by `js_symbol_key_for` so the spec-mandated `undefined` return for
/// well-known symbols is preserved.
Expand Down
35 changes: 5 additions & 30 deletions crates/perry-stdlib/src/fetch/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -480,37 +480,12 @@ pub fn dispatch_request_method(req_id: usize, method: &str, _args: &[f64]) -> Op
/// Returns `None` if the id isn't a known Response or the property is unknown.
#[doc(hidden)]
pub fn dispatch_response_property(resp_id: usize, prop: &str) -> Option<f64> {
// `response.headers` — lazily allocate a Headers registry entry
// backed by the response's stored headers and cache the id on the
// FetchResponse so repeat reads return the same handle (preserves
// `res.headers === res.headers`). Hono's `#newResponse` mutates the
// returned Headers object via `.set(k, v)`, but our snapshot is a
// copy of the response's HeadersStore — mutations land on the
// Headers handle's HeadersStore, not back on the FetchResponse.
// For the read-only case (the issue #486 acceptance) this is
// sufficient; spec-perfect "live header view" would need the
// FetchResponse's storage to be the same Vec as the Headers
// entries, which is a wider refactor.
// `response.headers` — use the same backing handle as the typed accessor.
// This preserves both object identity and mutations (notably
// `NextResponse.cookies`' Set-Cookie writes) across module boundaries.
if prop == "headers" {
let cached = {
let guard = FETCH_RESPONSES.lock().unwrap();
guard.get(&resp_id)?.cached_headers_id
};
let id = match cached {
Some(id) => id,
None => {
let store = {
let guard = FETCH_RESPONSES.lock().unwrap();
guard.get(&resp_id)?.headers.clone()
};
let new_id = alloc_headers(store);
if let Some(resp) = FETCH_RESPONSES.lock().unwrap().get_mut(&resp_id) {
resp.cached_headers_id = Some(new_id);
}
new_id
}
};
return Some(handle_to_f64(id));
FETCH_RESPONSES.lock().unwrap().get(&resp_id)?;
return Some(response_headers_handle(resp_id));
}
// `response.body` — `ReadableStream | null` per the Web Fetch spec.
// Returns a NaN-boxed (POINTER_TAG) single-chunk ReadableStream handle
Expand Down
13 changes: 7 additions & 6 deletions crates/perry-stdlib/src/fetch/headers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@

use super::*;

extern "C" {
fn js_symbol_well_known_iterator() -> f64;
fn js_object_get_symbol_property(obj_f64: f64, sym_f64: f64) -> f64;
}

/// new Headers() — returns NaN-boxed POINTER_TAG handle as f64.
/// See `handle_to_f64` / `handle_id` for the encoding contract.
#[no_mangle]
Expand Down Expand Up @@ -68,12 +73,8 @@ fn gc_type_for_raw_ptr(raw: i64) -> Option<u8> {
}

fn has_sync_iterator(value: f64) -> bool {
let iter_wk = perry_runtime::symbol::well_known_symbol("iterator");
if iter_wk.is_null() {
return false;
}
let sym = f64::from_bits(JSValue::pointer(iter_wk as *const u8).bits());
let iter_fn = unsafe { perry_runtime::symbol::js_object_get_symbol_property(value, sym) };
let sym = unsafe { js_symbol_well_known_iterator() };
let iter_fn = unsafe { js_object_get_symbol_property(value, sym) };
if iter_fn.to_bits() == TAG_UNDEFINED {
return false;
}
Expand Down
34 changes: 29 additions & 5 deletions crates/perry-stdlib/src/fetch/headers_method_value.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,28 @@

use super::*;

extern "C" {
#[link_name = "js_closure_alloc"]
fn provider_js_closure_alloc(
function: *const u8,
capture_count: u32,
) -> *mut perry_runtime::closure::ClosureHeader;
#[link_name = "js_closure_set_capture_f64"]
fn provider_js_closure_set_capture_f64(
closure: *mut perry_runtime::closure::ClosureHeader,
index: u32,
value: f64,
);
#[link_name = "js_closure_set_capture_ptr"]
fn provider_js_closure_set_capture_ptr(
closure: *mut perry_runtime::closure::ClosureHeader,
index: u32,
value: i64,
);
#[link_name = "js_nanbox_pointer"]
fn provider_js_nanbox_pointer(pointer: i64) -> f64;
}

lazy_static::lazy_static! {
static ref HEADERS_METHOD_VALUE_CACHE: Mutex<HashMap<(usize, &'static str), u64>> =
Mutex::new(HashMap::new());
Expand All @@ -29,11 +51,13 @@ pub(crate) fn headers_bound_method_value(headers_id: usize, method_name: &'stati
}

let closure =
perry_runtime::closure::js_closure_alloc(perry_runtime::closure::BOUND_METHOD_FUNC_PTR, 3);
perry_runtime::closure::js_closure_set_capture_f64(closure, 0, handle_to_f64(headers_id));
perry_runtime::closure::js_closure_set_capture_ptr(closure, 1, method_name.as_ptr() as i64);
perry_runtime::closure::js_closure_set_capture_ptr(closure, 2, method_name.len() as i64);
let value = perry_runtime::value::js_nanbox_pointer(closure as i64);
unsafe { provider_js_closure_alloc(perry_runtime::closure::BOUND_METHOD_FUNC_PTR, 3) };
unsafe {
provider_js_closure_set_capture_f64(closure, 0, handle_to_f64(headers_id));
provider_js_closure_set_capture_ptr(closure, 1, method_name.as_ptr() as i64);
provider_js_closure_set_capture_ptr(closure, 2, method_name.len() as i64);
}
let value = unsafe { provider_js_nanbox_pointer(closure as i64) };
unsafe { js_write_barrier_root_nanbox(value.to_bits()) };
HEADERS_METHOD_VALUE_CACHE
.lock()
Expand Down
Loading
Loading