diff --git a/changelog.d/8045-next-response-cross-module.md b/changelog.d/8045-next-response-cross-module.md new file mode 100644 index 0000000000..74026bb46d --- /dev/null +++ b/changelog.d/8045-next-response-cross-module.md @@ -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. diff --git a/crates/perry-codegen/src/lower_call/options/fetch.rs b/crates/perry-codegen/src/lower_call/options/fetch.rs index 9c2eaa2f8a..72eaee489f 100644 --- a/crates/perry-codegen/src/lower_call/options/fetch.rs +++ b/crates/perry-codegen/src/lower_call/options/fetch.rs @@ -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 @@ -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(); diff --git a/crates/perry-codegen/src/runtime_decls/strings_part2.rs b/crates/perry-codegen/src/runtime_decls/strings_part2.rs index ff2fd7c670..679d7e2373 100644 --- a/crates/perry-codegen/src/runtime_decls/strings_part2.rs +++ b/crates/perry-codegen/src/runtime_decls/strings_part2.rs @@ -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]); diff --git a/crates/perry-runtime/src/gc/roots/stack_maps.rs b/crates/perry-runtime/src/gc/roots/stack_maps.rs index 9aac9d75db..fc45eb03f9 100644 --- a/crates/perry-runtime/src/gc/roots/stack_maps.rs +++ b/crates/perry-runtime/src/gc/roots/stack_maps.rs @@ -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 @@ -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, + roots: &mut Vec, + 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, roots: Vec) -> StackMapIndex { // SP-relative locations are admitted here and resolved per FRAME in the // walker, which decodes the owning function's `add x29, sp, #imm` @@ -879,8 +899,8 @@ fn read_u64(bytes: &[u8], offset: usize) -> Option { /// 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; @@ -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::()); - let slide = _dyld_get_image_vmaddr_slide(0); - let mut command_ptr = raw_header - .cast::() - .add(std::mem::size_of::()); - for _ in 0..header.command_count { - let load = std::ptr::read_unaligned(command_ptr.cast::()); - if load.size < std::mem::size_of::() 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::()); - let mut section_ptr = command_ptr.add(std::mem::size_of::()); - for _ in 0..segment.section_count { - let section = std::ptr::read_unaligned(section_ptr.cast::()); - if fixed_name_matches(§ion.segment_name, b"__PERRY_GCMAP") - && fixed_name_matches(§ion.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::()); + let slide = _dyld_get_image_vmaddr_slide(image_index); + let mut command_ptr = raw_header + .cast::() + .add(std::mem::size_of::()); + for _ in 0..header.command_count { + let load = std::ptr::read_unaligned(command_ptr.cast::()); + if load.size < std::mem::size_of::() as u32 { + break; + } + if load.command == LC_SEGMENT_64 { + let segment = std::ptr::read_unaligned(command_ptr.cast::()); + let mut section_ptr = command_ptr.add(std::mem::size_of::()); + for _ in 0..segment.section_count { + let section = std::ptr::read_unaligned(section_ptr.cast::()); + if fixed_name_matches(§ion.segment_name, b"__PERRY_GCMAP") + && fixed_name_matches(§ion.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::()); } - section_ptr = section_ptr.add(std::mem::size_of::()); } + 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. diff --git a/crates/perry-runtime/src/gc/roots/stack_maps_decode_tests.rs b/crates/perry-runtime/src/gc/roots/stack_maps_decode_tests.rs index 9c071565a3..512b6f19f3 100644 --- a/crates/perry-runtime/src/gc/roots/stack_maps_decode_tests.rs +++ b/crates/perry-runtime/src/gc/roots/stack_maps_decode_tests.rs @@ -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 diff --git a/crates/perry-runtime/src/object/field_get_set.rs b/crates/perry-runtime/src/object/field_get_set.rs index 986d591c4a..bee6ff4f5b 100644 --- a/crates/perry-runtime/src/object/field_get_set.rs +++ b/crates/perry-runtime/src/object/field_get_set.rs @@ -147,6 +147,27 @@ pub(crate) unsafe fn fetch_subclass_handle_id(obj: usize) -> Option { } } +/// 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.` /// instance stashes the NaN-boxed pointer to its underlying Temporal cell. /// Written by `js_fetch_or_value_super` (the runtime-value super dispatcher, diff --git a/crates/perry-runtime/src/symbol.rs b/crates/perry-runtime/src/symbol.rs index 15a27b2207..b16060a522 100644 --- a/crates/perry-runtime/src/symbol.rs +++ b/crates/perry-runtime/src/symbol.rs @@ -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. diff --git a/crates/perry-stdlib/src/fetch/dispatch.rs b/crates/perry-stdlib/src/fetch/dispatch.rs index 2f5530eaff..86e5b7aeb3 100644 --- a/crates/perry-stdlib/src/fetch/dispatch.rs +++ b/crates/perry-stdlib/src/fetch/dispatch.rs @@ -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 { - // `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 diff --git a/crates/perry-stdlib/src/fetch/headers.rs b/crates/perry-stdlib/src/fetch/headers.rs index 1cff0d5567..a3120f1600 100644 --- a/crates/perry-stdlib/src/fetch/headers.rs +++ b/crates/perry-stdlib/src/fetch/headers.rs @@ -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] @@ -68,12 +73,8 @@ fn gc_type_for_raw_ptr(raw: i64) -> Option { } 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; } diff --git a/crates/perry-stdlib/src/fetch/headers_method_value.rs b/crates/perry-stdlib/src/fetch/headers_method_value.rs index f180cfcf8b..ed95f0d709 100644 --- a/crates/perry-stdlib/src/fetch/headers_method_value.rs +++ b/crates/perry-stdlib/src/fetch/headers_method_value.rs @@ -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> = Mutex::new(HashMap::new()); @@ -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() diff --git a/crates/perry-stdlib/src/fetch/mod.rs b/crates/perry-stdlib/src/fetch/mod.rs index 5560d2cd9f..a882ebef41 100644 --- a/crates/perry-stdlib/src/fetch/mod.rs +++ b/crates/perry-stdlib/src/fetch/mod.rs @@ -196,49 +196,7 @@ fn alloc_fetch_handle_id() -> usize { mod headers_json_test; #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn fetch_handle_ids_use_high_small_handle_range() { - use perry_runtime::value::addr_class; - assert!(FETCH_HANDLE_ID_START >= addr_class::COMMON_HANDLE_BAND_END); - assert!(FETCH_HANDLE_ID_END <= addr_class::HANDLE_BAND_MAX); - - let native_id = crate::common::register_handle("native-request-marker".to_string()); - let id = alloc_fetch_handle_id(); - assert!((native_id as usize) < FETCH_HANDLE_ID_START); - assert!((FETCH_HANDLE_ID_START..FETCH_HANDLE_ID_END).contains(&id)); - assert_ne!(native_id as usize, id); - crate::common::drop_handle(native_id); - } - - /// `string_from_header` must treat a handle-band value (a Fetch / native - /// registry id, not a `StringHeader` pointer) as "not a string" and return - /// `None` WITHOUT dereferencing it. Regression for the doctor / mcp-list - /// startup SIGSEGV: `fetch()` called with a non-string first argument (a - /// `Request`/`Headers` object) passed the bare handle id into the - /// `url_ptr` `*StringHeader` slot, and reading `(*ptr).byte_len` at `id+4` - /// dereferenced an unmapped low address. - #[test] - fn string_from_header_rejects_handle_band_ids() { - use perry_runtime::value::addr_class; - for &id in &[ - 1usize, // common native handle - addr_class::FETCH_HANDLE_BAND_START, // 0x40000 - addr_class::FETCH_HANDLE_BAND_START + 2, // a fetch handle id - addr_class::HANDLE_BAND_MAX - 1, // 0xFFFFF - ] { - assert!(addr_class::is_handle_band(id)); - // Must return None without dereferencing the bogus pointer. - let r = unsafe { string_from_header(id as *const StringHeader) }; - assert!( - r.is_none(), - "handle-band id {id:#x} must be rejected, got {r:?}" - ); - } - } -} +mod tests; struct StreamState { status: u8, // 0=connecting, 1=streaming, 2=done, 3=error @@ -278,6 +236,36 @@ struct FetchResponse { body_stream_id: Option, } +/// Return the one `Headers` registry handle that backs `response.headers`. +/// +/// Both typed property lowering (`js_response_get_headers`) and untyped handle +/// dispatch must come through this helper. Allocating a fresh snapshot in the +/// typed path made repeated reads disagree and, more importantly, discarded +/// mutations made through an earlier view. `NextResponse.cookies` mutates the +/// response through that Headers view, so losing the handle also lost its +/// `Set-Cookie` header when the response crossed a module boundary. +fn response_headers_handle(resp_id: usize) -> f64 { + let mut responses = FETCH_RESPONSES.lock().unwrap(); + let Some(response) = responses.get_mut(&resp_id) else { + return f64::from_bits(TAG_UNDEFINED); + }; + if let Some(id) = response.cached_headers_id { + return handle_to_f64(id); + } + let id = alloc_headers(response.headers.clone()); + response.cached_headers_id = Some(id); + handle_to_f64(id) +} + +/// Snapshot the observable Headers backing, including mutations made through +/// `response.headers`, for operations such as `Response.clone()`. +fn response_headers_snapshot(response: &FetchResponse) -> HeadersStore { + response + .cached_headers_id + .and_then(|id| HEADERS_REGISTRY.lock().unwrap().get(&id).cloned()) + .unwrap_or_else(|| response.headers.clone()) +} + thread_local! { static PENDING_FETCH_BODY_STREAM_ID: Cell = const { Cell::new(0) }; } @@ -1360,7 +1348,7 @@ pub unsafe extern "C" fn js_response_blob(handle: f64) -> *mut perry_runtime::Pr let guard = FETCH_RESPONSES.lock().unwrap(); guard .get(&id) - .and_then(|resp| resp.headers.get("content-type")) + .and_then(|resp| response_headers_snapshot(resp).get("content-type")) .unwrap_or_default() }; let body = match consume_response_body(handle) { diff --git a/crates/perry-stdlib/src/fetch/response_ctor.rs b/crates/perry-stdlib/src/fetch/response_ctor.rs index 964ebc67b8..67453a62a7 100644 --- a/crates/perry-stdlib/src/fetch/response_ctor.rs +++ b/crates/perry-stdlib/src/fetch/response_ctor.rs @@ -87,11 +87,20 @@ pub unsafe extern "C" fn js_response_new( } else { HeadersStore::default() }; + // A Response owns a private Headers list. The constructor input may be an + // existing Headers object, so retaining its registry id would make + // mutations alias in both directions instead of copying the initializer. + let response_headers_id = (headers_id != 0).then(|| alloc_headers(headers.clone())); let id = alloc_response(status_u16, status_text, headers, body, body_present); - if let Some(stream_id) = body_stream_id { + if response_headers_id.is_some() || body_stream_id.is_some() { if let Some(resp) = FETCH_RESPONSES.lock().unwrap().get_mut(&id) { - resp.body_stream_id = Some(stream_id); - resp.cached_body_stream_id = Some(stream_id); + if let Some(headers_id) = response_headers_id { + resp.cached_headers_id = Some(headers_id); + } + if let Some(stream_id) = body_stream_id { + resp.body_stream_id = Some(stream_id); + resp.cached_body_stream_id = Some(stream_id); + } } } handle_to_f64(id) @@ -102,14 +111,7 @@ pub unsafe extern "C" fn js_response_new( #[no_mangle] pub extern "C" fn js_response_get_headers(handle: f64) -> f64 { let id = handle_id(handle); - let store = { - let guard = FETCH_RESPONSES.lock().unwrap(); - match guard.get(&id) { - Some(resp) => resp.headers.clone(), - None => return f64::from_bits(TAG_UNDEFINED), - } - }; - handle_to_f64(alloc_headers(store)) + response_headers_handle(id) } /// response.clone() — duplicates the response (deep copy of body + headers) @@ -134,7 +136,7 @@ pub extern "C" fn js_response_clone(handle: f64) -> f64 { FetchResponse { status: resp.status, status_text: resp.status_text.clone(), - headers: resp.headers.clone(), + headers: response_headers_snapshot(resp), body: resp.body.clone(), body_present: resp.body_present, body_used: false, diff --git a/crates/perry-stdlib/src/fetch/tests.rs b/crates/perry-stdlib/src/fetch/tests.rs new file mode 100644 index 0000000000..ac3b69bb08 --- /dev/null +++ b/crates/perry-stdlib/src/fetch/tests.rs @@ -0,0 +1,106 @@ +use super::*; + +#[test] +fn fetch_handle_ids_use_high_small_handle_range() { + use perry_runtime::value::addr_class; + assert!(FETCH_HANDLE_ID_START >= addr_class::COMMON_HANDLE_BAND_END); + assert!(FETCH_HANDLE_ID_END <= addr_class::HANDLE_BAND_MAX); + + let native_id = crate::common::register_handle("native-request-marker".to_string()); + let id = alloc_fetch_handle_id(); + assert!((native_id as usize) < FETCH_HANDLE_ID_START); + assert!((FETCH_HANDLE_ID_START..FETCH_HANDLE_ID_END).contains(&id)); + assert_ne!(native_id as usize, id); + crate::common::drop_handle(native_id); +} + +/// `string_from_header` must treat a handle-band value (a Fetch / native +/// registry id, not a `StringHeader` pointer) as "not a string" and return +/// `None` WITHOUT dereferencing it. Regression for the doctor / mcp-list +/// startup SIGSEGV: `fetch()` called with a non-string first argument (a +/// `Request`/`Headers` object) passed the bare handle id into the +/// `url_ptr` `*StringHeader` slot, and reading `(*ptr).byte_len` at `id+4` +/// dereferenced an unmapped low address. +#[test] +fn string_from_header_rejects_handle_band_ids() { + use perry_runtime::value::addr_class; + for &id in &[ + 1usize, // common native handle + addr_class::FETCH_HANDLE_BAND_START, // 0x40000 + addr_class::FETCH_HANDLE_BAND_START + 2, // a fetch handle id + addr_class::HANDLE_BAND_MAX - 1, // 0xFFFFF + ] { + assert!(addr_class::is_handle_band(id)); + // Must return None without dereferencing the bogus pointer. + let r = unsafe { string_from_header(id as *const StringHeader) }; + assert!( + r.is_none(), + "handle-band id {id:#x} must be rejected, got {r:?}" + ); + } +} + +#[test] +fn response_constructor_copies_headers_initializer() { + let mut source = HeadersStore::default(); + source.set("x", "a"); + let source_id = alloc_headers(source); + + let response = unsafe { + js_response_new( + std::ptr::null(), + 200.0, + std::ptr::null(), + handle_to_f64(source_id), + ) + }; + let response_id = handle_id(response); + let response_headers_id = handle_id(js_response_get_headers(response)); + assert_ne!(response_headers_id, source_id); + + HEADERS_REGISTRY + .lock() + .unwrap() + .get_mut(&source_id) + .unwrap() + .set("x", "b"); + assert_eq!( + HEADERS_REGISTRY + .lock() + .unwrap() + .get(&response_headers_id) + .and_then(|headers| headers.get("x")), + Some("a".to_string()) + ); + + HEADERS_REGISTRY + .lock() + .unwrap() + .get_mut(&response_headers_id) + .unwrap() + .set("x", "c"); + assert_eq!( + HEADERS_REGISTRY + .lock() + .unwrap() + .get(&source_id) + .and_then(|headers| headers.get("x")), + Some("b".to_string()) + ); + assert_eq!( + FETCH_RESPONSES + .lock() + .unwrap() + .get(&response_id) + .map(response_headers_snapshot) + .and_then(|headers| headers.get("x")), + Some("c".to_string()) + ); + + FETCH_RESPONSES.lock().unwrap().remove(&response_id); + HEADERS_REGISTRY.lock().unwrap().remove(&source_id); + HEADERS_REGISTRY + .lock() + .unwrap() + .remove(&response_headers_id); +} diff --git a/crates/perry-stdlib/src/streams.rs b/crates/perry-stdlib/src/streams.rs index 2099457f0e..0e8d3d590b 100644 --- a/crates/perry-stdlib/src/streams.rs +++ b/crates/perry-stdlib/src/streams.rs @@ -25,17 +25,263 @@ //! into `desiredSize`) live in `streams/byob.rs` and the queue helpers on //! `ReadableStreamData` (#4915). -use perry_runtime::{ - js_array_alloc, js_array_push, js_closure_call0, js_closure_call1, js_closure_call2, - js_nanbox_get_pointer, js_object_alloc, js_object_get_field_by_name, js_object_set_field, - js_object_set_field_by_name, js_object_set_keys, js_promise_mark_internally_handled, - js_promise_new, js_promise_reject, js_promise_resolve, js_string_from_bytes, ClosureHeader, - JSValue, ObjectHeader, Promise, -}; +use perry_runtime::{ArrayHeader, ClosureHeader, JSValue, ObjectHeader, Promise, StringHeader}; use std::collections::{HashMap, VecDeque}; use std::os::raw::c_int; use std::sync::Mutex; +// Calls that allocate or mutate runtime-owned values must cross the stable C +// ABI. A shared stdlib still contains fallback Rust runtime glue for generic +// monomorphizations; direct Rust calls would allocate into that image's arena +// instead of the process-wide runtime provider. +extern "C" { + #[link_name = "js_array_alloc"] + fn provider_js_array_alloc(capacity: u32) -> *mut ArrayHeader; + #[link_name = "js_array_push"] + fn provider_js_array_push(array: *mut ArrayHeader, value: JSValue) -> *mut ArrayHeader; + #[link_name = "js_array_get"] + fn provider_js_array_get(array: *const ArrayHeader, index: u32) -> JSValue; + #[link_name = "js_array_is_array"] + fn provider_js_array_is_array(value: f64) -> f64; + #[link_name = "js_array_length"] + fn provider_js_array_length(array: *const ArrayHeader) -> u32; + #[link_name = "js_array_push_f64"] + fn provider_js_array_push_f64(array: *mut ArrayHeader, value: f64) -> *mut ArrayHeader; + #[link_name = "js_iterator_to_array"] + fn provider_js_iterator_to_array(iterator: f64) -> *mut ArrayHeader; + #[link_name = "js_assimilate_thenable"] + fn provider_js_assimilate_thenable(value: f64) -> f64; + #[link_name = "js_closure_alloc"] + fn provider_js_closure_alloc(function: *const u8, capture_count: u32) -> *mut ClosureHeader; + #[link_name = "js_closure_call0"] + fn provider_js_closure_call0(closure: *const ClosureHeader) -> f64; + #[link_name = "js_closure_call1"] + fn provider_js_closure_call1(closure: *const ClosureHeader, arg0: f64) -> f64; + #[link_name = "js_closure_call2"] + fn provider_js_closure_call2(closure: *const ClosureHeader, arg0: f64, arg1: f64) -> f64; + #[link_name = "js_closure_get_capture_ptr"] + fn provider_js_closure_get_capture_ptr(closure: *const ClosureHeader, index: u32) -> i64; + #[link_name = "js_closure_set_capture_ptr"] + fn provider_js_closure_set_capture_ptr(closure: *mut ClosureHeader, index: u32, value: i64); + #[link_name = "js_nanbox_get_pointer"] + fn provider_js_nanbox_get_pointer(value: f64) -> i64; + #[link_name = "js_object_alloc"] + fn provider_js_object_alloc(class_id: u32, field_count: u32) -> *mut ObjectHeader; + #[link_name = "js_object_get_field_by_name"] + fn provider_js_object_get_field_by_name( + object: *const ObjectHeader, + key: *const StringHeader, + ) -> JSValue; + #[link_name = "js_object_set_field"] + fn provider_js_object_set_field(object: *mut ObjectHeader, index: u32, value: JSValue); + #[link_name = "js_object_set_field_by_name"] + fn provider_js_object_set_field_by_name( + object: *mut ObjectHeader, + key: *const StringHeader, + value: f64, + ); + #[link_name = "js_object_set_keys"] + fn provider_js_object_set_keys(object: *mut ObjectHeader, keys: *mut ArrayHeader); + #[link_name = "js_promise_mark_internally_handled"] + fn provider_js_promise_mark_internally_handled(promise: *mut Promise); + #[link_name = "js_native_call_value"] + fn provider_js_native_call_value( + function: f64, + arguments: *const f64, + argument_count: usize, + ) -> f64; + #[link_name = "js_implicit_this_set"] + fn provider_js_implicit_this_set(value: f64) -> f64; + #[link_name = "js_promise_new"] + fn provider_js_promise_new() -> *mut Promise; + #[link_name = "js_promise_all"] + fn provider_js_promise_all(promises: *const ArrayHeader) -> *mut Promise; + #[link_name = "js_promise_reason"] + fn provider_js_promise_reason(promise: *mut Promise) -> f64; + #[link_name = "js_promise_reject"] + fn provider_js_promise_reject(promise: *mut Promise, reason: f64); + #[link_name = "js_promise_resolve"] + fn provider_js_promise_resolve(promise: *mut Promise, value: f64); + #[link_name = "js_promise_resolve_with_promise"] + fn provider_js_promise_resolve_with_promise(outer: *mut Promise, inner: *mut Promise); + #[link_name = "js_promise_run_microtasks"] + fn provider_js_promise_run_microtasks() -> i32; + #[link_name = "js_promise_state"] + fn provider_js_promise_state(promise: *mut Promise) -> i32; + #[link_name = "js_promise_then"] + fn provider_js_promise_then( + promise: *mut Promise, + on_fulfilled: *const ClosureHeader, + on_rejected: *const ClosureHeader, + ) -> *mut Promise; + #[link_name = "js_promise_value"] + fn provider_js_promise_value(promise: *mut Promise) -> f64; + #[link_name = "js_register_closure_arity"] + fn provider_js_register_closure_arity(function: *const u8, arity: u32); + #[link_name = "js_string_from_bytes"] + fn provider_js_string_from_bytes(data: *const u8, len: u32) -> *mut StringHeader; + #[link_name = "js_value_is_promise"] + fn provider_js_value_is_promise(value: f64) -> i32; +} + +macro_rules! provider_call { + ($name:ident ( $($argument:expr),* $(,)? )) => { + unsafe { $name($($argument),*) } + }; +} + +fn js_array_alloc(capacity: u32) -> *mut ArrayHeader { + provider_call!(provider_js_array_alloc(capacity)) +} + +fn js_array_push(array: *mut ArrayHeader, value: JSValue) -> *mut ArrayHeader { + provider_call!(provider_js_array_push(array, value)) +} + +fn js_array_get(array: *const ArrayHeader, index: u32) -> JSValue { + provider_call!(provider_js_array_get(array, index)) +} + +fn js_array_is_array(value: f64) -> f64 { + provider_call!(provider_js_array_is_array(value)) +} + +fn js_array_length(array: *const ArrayHeader) -> u32 { + provider_call!(provider_js_array_length(array)) +} + +fn js_array_push_f64(array: *mut ArrayHeader, value: f64) -> *mut ArrayHeader { + provider_call!(provider_js_array_push_f64(array, value)) +} + +fn js_iterator_to_array(iterator: f64) -> *mut ArrayHeader { + provider_call!(provider_js_iterator_to_array(iterator)) +} + +fn js_assimilate_thenable(value: f64) -> f64 { + provider_call!(provider_js_assimilate_thenable(value)) +} + +fn js_closure_alloc(function: *const u8, capture_count: u32) -> *mut ClosureHeader { + provider_call!(provider_js_closure_alloc(function, capture_count)) +} + +fn js_closure_call0(closure: *const ClosureHeader) -> f64 { + provider_call!(provider_js_closure_call0(closure)) +} + +fn js_closure_call1(closure: *const ClosureHeader, arg0: f64) -> f64 { + provider_call!(provider_js_closure_call1(closure, arg0)) +} + +fn js_closure_call2(closure: *const ClosureHeader, arg0: f64, arg1: f64) -> f64 { + provider_call!(provider_js_closure_call2(closure, arg0, arg1)) +} + +fn js_closure_get_capture_ptr(closure: *const ClosureHeader, index: u32) -> i64 { + provider_call!(provider_js_closure_get_capture_ptr(closure, index)) +} + +fn js_closure_set_capture_ptr(closure: *mut ClosureHeader, index: u32, value: i64) { + provider_call!(provider_js_closure_set_capture_ptr(closure, index, value)) +} + +fn js_nanbox_get_pointer(value: f64) -> i64 { + provider_call!(provider_js_nanbox_get_pointer(value)) +} + +fn js_object_alloc(class_id: u32, field_count: u32) -> *mut ObjectHeader { + provider_call!(provider_js_object_alloc(class_id, field_count)) +} + +fn js_object_get_field_by_name(object: *const ObjectHeader, key: *const StringHeader) -> JSValue { + provider_call!(provider_js_object_get_field_by_name(object, key)) +} + +fn js_object_set_field(object: *mut ObjectHeader, index: u32, value: JSValue) { + provider_call!(provider_js_object_set_field(object, index, value)) +} + +fn js_object_set_field_by_name(object: *mut ObjectHeader, key: *const StringHeader, value: f64) { + provider_call!(provider_js_object_set_field_by_name(object, key, value)) +} + +fn js_object_set_keys(object: *mut ObjectHeader, keys: *mut ArrayHeader) { + provider_call!(provider_js_object_set_keys(object, keys)) +} + +fn js_promise_mark_internally_handled(promise: *mut Promise) { + provider_call!(provider_js_promise_mark_internally_handled(promise)) +} + +fn js_native_call_value(function: f64, arguments: *const f64, argument_count: usize) -> f64 { + provider_call!(provider_js_native_call_value( + function, + arguments, + argument_count + )) +} + +fn js_implicit_this_set(value: f64) -> f64 { + provider_call!(provider_js_implicit_this_set(value)) +} + +fn js_promise_new() -> *mut Promise { + provider_call!(provider_js_promise_new()) +} + +fn js_promise_all(promises: *const ArrayHeader) -> *mut Promise { + provider_call!(provider_js_promise_all(promises)) +} + +fn js_promise_reason(promise: *mut Promise) -> f64 { + provider_call!(provider_js_promise_reason(promise)) +} + +fn js_promise_reject(promise: *mut Promise, reason: f64) { + provider_call!(provider_js_promise_reject(promise, reason)) +} + +fn js_promise_resolve(promise: *mut Promise, value: f64) { + provider_call!(provider_js_promise_resolve(promise, value)) +} + +fn js_promise_resolve_with_promise(outer: *mut Promise, inner: *mut Promise) { + provider_call!(provider_js_promise_resolve_with_promise(outer, inner)) +} + +fn js_promise_run_microtasks() -> i32 { + provider_call!(provider_js_promise_run_microtasks()) +} + +fn js_promise_state(promise: *mut Promise) -> i32 { + provider_call!(provider_js_promise_state(promise)) +} + +fn js_promise_then( + promise: *mut Promise, + on_fulfilled: *const ClosureHeader, + on_rejected: *const ClosureHeader, +) -> *mut Promise { + provider_call!(provider_js_promise_then(promise, on_fulfilled, on_rejected)) +} + +fn js_promise_value(promise: *mut Promise) -> f64 { + provider_call!(provider_js_promise_value(promise)) +} + +fn js_register_closure_arity(function: *const u8, arity: u32) { + provider_call!(provider_js_register_closure_arity(function, arity)) +} + +fn js_string_from_bytes(data: *const u8, len: u32) -> *mut StringHeader { + provider_call!(provider_js_string_from_bytes(data, len)) +} + +fn js_value_is_promise(value: f64) -> i32 { + provider_call!(provider_js_value_is_promise(value)) +} + /// Allocate a promise the stream machinery owns and observes internally — the /// reader/writer `closed`, writer `ready`, and `[[closeRequest]]` promises. /// Node marks these `markPromiseAsHandled` so that an abort / error / cancel @@ -62,8 +308,8 @@ unsafe fn try_call_stream_action(callback: i64, reason: f64) -> Result } unsafe fn stream_action_promise(result: f64) -> Option<*mut Promise> { - let adopted = perry_runtime::promise::js_assimilate_thenable(result); - if perry_runtime::promise::js_value_is_promise(adopted) == 0 { + let adopted = js_assimilate_thenable(result); + if js_value_is_promise(adopted) == 0 { return None; } let promise = js_nanbox_get_pointer(adopted) as *mut Promise; @@ -73,20 +319,21 @@ unsafe fn stream_action_promise(result: f64) -> Option<*mut Promise> { unsafe fn settle_stream_action_promise(promise: *mut Promise, actions: &[*mut Promise]) { match actions { [] => js_promise_resolve(promise, f64::from_bits(TAG_UNDEFINED)), - [action] => perry_runtime::promise::js_promise_resolve_with_promise(promise, *action), + [action] => js_promise_resolve_with_promise(promise, *action), _ => { let values = js_array_alloc(actions.len() as u32); for action in actions { js_array_push(values, JSValue::pointer(*action as *const u8)); } - let all = perry_runtime::promise::js_promise_all(values); - perry_runtime::promise::js_promise_resolve_with_promise(promise, all); + let all = js_promise_all(values); + js_promise_resolve_with_promise(promise, all); } } } mod byob; mod expando; +mod gc; mod idalloc; mod pipe; mod strategy; @@ -105,6 +352,9 @@ pub use self::byob::{ js_readable_stream_controller_byob_request, js_readable_stream_get_byob_reader, js_reader_read_with_view, }; +use self::gc::ensure_gc_registered; +#[cfg(test)] +use self::gc::scan_stream_roots; pub(crate) use self::strategy::parse_strategy_value; pub use self::strategy::{ js_byte_length_queuing_strategy_new, js_count_queuing_strategy_new, @@ -323,133 +573,6 @@ lazy_static::lazy_static! { // through `idalloc` so the finite band survives long-running servers. use self::idalloc::next_stream_id; -static GC_REGISTERED: std::sync::Once = std::sync::Once::new(); - -/// Register the streams GC root scanner once. Closures held by user- -/// supplied `start` / `pull` / `cancel` / `write` / `close` / `abort` / -/// `transform` / `flush` callbacks live in the registry maps below; the -/// runtime GC mark phase wouldn't see them otherwise and a sweep -/// between registration and dispatch would free the closure body. Same -/// shape as `ws.rs::ensure_gc_scanner_registered`. -fn ensure_gc_registered() { - GC_REGISTERED.call_once(|| { - perry_runtime::gc::gc_register_mutable_root_scanner_named( - "stdlib:streams", - scan_stream_roots_mut, - ); - perry_runtime::node_submodules::js_register_stream_consumer_callbacks( - js_readable_stream_get_reader, - js_reader_read, - ); - unsafe { - perry_runtime::object::js_register_stream_expando_set(expando::stream_expando_set_hook); - } - }); -} - -#[allow(dead_code)] -fn scan_stream_roots(mark: &mut dyn FnMut(f64)) { - let mut visitor = perry_runtime::gc::RuntimeRootVisitor::for_copy(mark); - scan_stream_roots_mut(&mut visitor); -} - -fn visit_stream_value_slot( - visitor: &mut perry_runtime::gc::RuntimeRootVisitor<'_>, - slot: &mut u64, -) { - let top = *slot >> 48; - if top == 0x7FFD || top == 0x7FFF { - visitor.visit_nanbox_u64_slot(slot); - } -} - -fn scan_stream_roots_mut(visitor: &mut perry_runtime::gc::RuntimeRootVisitor<'_>) { - expando::scan_expando_roots(visitor); - if let Ok(mut map) = READABLE_STREAMS.lock() { - for s in map.values_mut() { - visitor.visit_i64_slot(&mut s.start_cb); - visitor.visit_i64_slot(&mut s.pull_cb); - visitor.visit_i64_slot(&mut s.cancel_cb); - visitor.visit_i64_slot(&mut s.strategy_size_cb); - for c in s.chunks.iter_mut() { - visit_stream_value_slot(visitor, c); - } - for p in s.pending_reads.iter_mut() { - visitor.visit_raw_mut_ptr_slot(p); - } - if s.state == ReadableState::Errored { - visit_stream_value_slot(visitor, &mut s.error_value); - } - if let Some(error) = &mut s.pending_error_after_chunks { - visit_stream_value_slot(visitor, error); - } - } - } - byob::scan_byob_roots(visitor); - if let Ok(mut map) = WRITABLE_STREAMS.lock() { - for s in map.values_mut() { - visitor.visit_i64_slot(&mut s.write_cb); - visitor.visit_i64_slot(&mut s.close_cb); - visitor.visit_i64_slot(&mut s.abort_cb); - visitor.visit_i64_slot(&mut s.strategy_size_cb); - for (chunk, p, _size) in s.write_queue.iter_mut() { - visit_stream_value_slot(visitor, chunk); - visitor.visit_raw_mut_ptr_slot(p); - } - visitor.visit_raw_mut_ptr_slot(&mut s.ready_promise); - visitor.visit_raw_mut_ptr_slot(&mut s.closed_promise); - visitor.visit_raw_mut_ptr_slot(&mut s.close_request_promise); - if s.state == WritableState::Errored { - visit_stream_value_slot(visitor, &mut s.error_value); - } - } - } - if let Ok(mut map) = TRANSFORM_STREAMS.lock() { - for t in map.values_mut() { - visitor.visit_i64_slot(&mut t.transform_cb); - visitor.visit_i64_slot(&mut t.flush_cb); - } - } - // Parked/deferred transform promises are held only as raw addresses in - // these maps (an unawaited write/close has no other root). - if let Ok(mut map) = transform::TRANSFORM_WRITE_RELEASES.lock() { - for promises in map.values_mut() { - for slot in promises.iter_mut() { - let mut p = *slot as *mut Promise; - visitor.visit_raw_mut_ptr_slot(&mut p); - *slot = p as usize; - } - } - } - if let Ok(mut map) = transform::TRANSFORM_PENDING_CLOSE.lock() { - for slot in map.values_mut() { - let mut p = *slot as *mut Promise; - visitor.visit_raw_mut_ptr_slot(&mut p); - *slot = p as usize; - } - } - if let Ok(mut map) = transform::TRANSFORM_BACKPRESSURED_JOBS.lock() { - for jobs in map.values_mut() { - for slot in jobs.iter_mut() { - let mut job = *slot as *mut ClosureHeader; - visitor.visit_raw_mut_ptr_slot(&mut job); - *slot = job as usize; - } - } - } - if let Ok(mut map) = READERS.lock() { - for r in map.values_mut() { - visitor.visit_raw_mut_ptr_slot(&mut r.closed_promise); - } - } - if let Ok(mut map) = WRITERS.lock() { - for w in map.values_mut() { - visitor.visit_raw_mut_ptr_slot(&mut w.closed_promise); - visitor.visit_raw_mut_ptr_slot(&mut w.ready_promise); - } - } -} - // ───────────────────────────────────────────────────────────────────── // Helpers // ───────────────────────────────────────────────────────────────────── @@ -730,11 +853,10 @@ unsafe fn invoke_start(stream_id: usize) { extern "C" fn readable_pull_microtask(closure: *const ClosureHeader) -> f64 { unsafe { - let stream_bits = perry_runtime::closure::js_closure_get_capture_ptr(closure, 0) as u64; + let stream_bits = js_closure_get_capture_ptr(closure, 0) as u64; let stream_id = f64::from_bits(stream_bits) as usize; - let cb = perry_runtime::closure::js_closure_get_capture_ptr(closure, 1); - let pull_returns_byte_chunk = - perry_runtime::closure::js_closure_get_capture_ptr(closure, 2) != 0; + let cb = js_closure_get_capture_ptr(closure, 1); + let pull_returns_byte_chunk = js_closure_get_capture_ptr(closure, 2) != 0; let should_pull = { let mut g = READABLE_STREAMS.lock().unwrap(); match g.get_mut(&stream_id) { @@ -765,7 +887,7 @@ extern "C" fn readable_pull_microtask(closure: *const ClosureHeader) -> f64 { }); match pull_outcome { Ok(result) => { - if perry_runtime::promise::js_value_is_promise(result) != 0 { + if js_value_is_promise(result) != 0 { let promise = perry_runtime::value::js_nanbox_get_pointer(result) as *mut Promise; if !promise.is_null() { @@ -777,9 +899,7 @@ extern "C" fn readable_pull_microtask(closure: *const ClosureHeader) -> f64 { readable_pull_rejected as *const u8, stream_id, ); - let _ = perry_runtime::promise::js_promise_then( - promise, fulfilled, rejected, - ); + let _ = js_promise_then(promise, fulfilled, rejected); return f64::from_bits(TAG_UNDEFINED); } } @@ -800,15 +920,15 @@ extern "C" fn readable_pull_microtask(closure: *const ClosureHeader) -> f64 { } fn readable_pull_settled_closure(func: *const u8, stream_id: usize) -> *mut ClosureHeader { - perry_runtime::closure::js_register_closure_arity(func, 1); - let closure = perry_runtime::closure::js_closure_alloc(func, 1); - perry_runtime::closure::js_closure_set_capture_ptr(closure, 0, stream_id as i64); + js_register_closure_arity(func, 1); + let closure = js_closure_alloc(func, 1); + js_closure_set_capture_ptr(closure, 0, stream_id as i64); closure } extern "C" fn readable_pull_fulfilled(closure: *const ClosureHeader, _value: f64) -> f64 { unsafe { - let stream_id = perry_runtime::closure::js_closure_get_capture_ptr(closure, 0) as usize; + let stream_id = js_closure_get_capture_ptr(closure, 0) as usize; if let Some(s) = READABLE_STREAMS.lock().unwrap().get_mut(&stream_id) { s.pulling = false; } @@ -819,7 +939,7 @@ extern "C" fn readable_pull_fulfilled(closure: *const ClosureHeader, _value: f64 extern "C" fn readable_pull_rejected(closure: *const ClosureHeader, reason: f64) -> f64 { unsafe { - let stream_id = perry_runtime::closure::js_closure_get_capture_ptr(closure, 0) as usize; + let stream_id = js_closure_get_capture_ptr(closure, 0) as usize; let should_error = { let mut streams = READABLE_STREAMS.lock().unwrap(); match streams.get_mut(&stream_id) { @@ -893,15 +1013,11 @@ unsafe fn maybe_pull_inner(stream_id: usize, force: bool) { return; } let pull_fn = readable_pull_microtask as *const u8; - perry_runtime::closure::js_register_closure_arity(pull_fn, 0); - let pull = perry_runtime::closure::js_closure_alloc(pull_fn, 3); - perry_runtime::closure::js_closure_set_capture_ptr(pull, 0, controller.to_bits() as i64); - perry_runtime::closure::js_closure_set_capture_ptr(pull, 1, cb); - perry_runtime::closure::js_closure_set_capture_ptr( - pull, - 2, - if pull_returns_byte_chunk { 1 } else { 0 }, - ); + js_register_closure_arity(pull_fn, 0); + let pull = js_closure_alloc(pull_fn, 3); + js_closure_set_capture_ptr(pull, 0, controller.to_bits() as i64); + js_closure_set_capture_ptr(pull, 1, cb); + js_closure_set_capture_ptr(pull, 2, if pull_returns_byte_chunk { 1 } else { 0 }); perry_runtime::builtins::js_queue_microtask(pull as i64); } @@ -1327,10 +1443,8 @@ fn ptr_addr_from_nanbox(value: f64) -> Option { } unsafe fn chunks_from_array_ptr(arr_ptr: *const perry_runtime::ArrayHeader) -> Vec { - let len = perry_runtime::array::js_array_length(arr_ptr); - (0..len) - .map(|i| perry_runtime::array::js_array_get(arr_ptr, i).bits()) - .collect() + let len = js_array_length(arr_ptr); + (0..len).map(|i| js_array_get(arr_ptr, i).bits()).collect() } unsafe fn chunks_from_sync_iterable(value: f64) -> Option> { @@ -1338,7 +1452,7 @@ unsafe fn chunks_from_sync_iterable(value: f64) -> Option> { if iter.to_bits() == value.to_bits() { return None; } - let arr = perry_runtime::array::js_iterator_to_array(iter); + let arr = js_iterator_to_array(iter); Some(chunks_from_array_ptr(arr)) } @@ -1377,9 +1491,9 @@ unsafe fn call_symbol_async_iterator(value: f64) -> Option { if !is_callable_value(method) { return None; } - let prev_this = perry_runtime::object::js_implicit_this_set(value); - let iterator = perry_runtime::closure::js_native_call_value(method, std::ptr::null(), 0); - perry_runtime::object::js_implicit_this_set(prev_this); + let prev_this = js_implicit_this_set(value); + let iterator = js_native_call_value(method, std::ptr::null(), 0); + js_implicit_this_set(prev_this); if iterator.to_bits() == TAG_UNDEFINED { None } else { @@ -1399,7 +1513,7 @@ unsafe fn has_iterator_next(value: f64) -> bool { } unsafe fn await_maybe_promise(value: f64) -> SettledValue { - if perry_runtime::promise::js_value_is_promise(value) == 0 { + if js_value_is_promise(value) == 0 { return SettledValue::Fulfilled(value); } let promise = js_nanbox_get_pointer(value) as *mut Promise; @@ -1408,17 +1522,17 @@ unsafe fn await_maybe_promise(value: f64) -> SettledValue { } for _ in 0..100_000 { - if perry_runtime::promise::js_promise_state(promise) != 0 { + if js_promise_state(promise) != 0 { break; } - if perry_runtime::promise::js_promise_run_microtasks() == 0 { + if js_promise_run_microtasks() == 0 { break; } } - match perry_runtime::promise::js_promise_state(promise) { - 1 => SettledValue::Fulfilled(perry_runtime::promise::js_promise_value(promise)), - 2 => SettledValue::Rejected(perry_runtime::promise::js_promise_reason(promise).to_bits()), + match js_promise_state(promise) { + 1 => SettledValue::Fulfilled(js_promise_value(promise)), + 2 => SettledValue::Rejected(js_promise_reason(promise).to_bits()), _ => SettledValue::Pending, } } @@ -1433,9 +1547,9 @@ unsafe fn call_iterator_next(iterator: f64) -> Option { let next_val = js_object_get_field_by_name(iter_obj, next_key); let next = f64::from_bits(next_val.bits()); if is_callable_value(next) { - let prev_this = perry_runtime::object::js_implicit_this_set(iterator); - let result = perry_runtime::closure::js_native_call_value(next, std::ptr::null(), 0); - perry_runtime::object::js_implicit_this_set(prev_this); + let prev_this = js_implicit_this_set(iterator); + let result = js_native_call_value(next, std::ptr::null(), 0); + js_implicit_this_set(prev_this); Some(result) } else { Some(perry_runtime::object::js_native_call_method( @@ -1525,7 +1639,7 @@ pub unsafe extern "C" fn js_readable_stream_from_iterable(value: f64) -> f64 { let source = if let Some(source) = chunks_from_async_iterable(value) { source - } else if perry_runtime::array::js_array_is_array(value).to_bits() == TAG_TRUE { + } else if js_array_is_array(value).to_bits() == TAG_TRUE { let arr_ptr = ptr_addr.unwrap_or(0) as *const perry_runtime::ArrayHeader; ReadableFromSource::closed(chunks_from_array_ptr(arr_ptr)) } else if let Some(addr) = ptr_addr { @@ -1587,8 +1701,8 @@ pub unsafe extern "C" fn js_readable_stream_from_iterable(value: f64) -> f64 { /// rendered HTML node directly (degraded but usable). #[no_mangle] pub unsafe extern "C" fn js_jsx_render_stream_from_value(html_value: f64) -> f64 { - let mut arr = perry_runtime::array::js_array_alloc(1); - arr = perry_runtime::array::js_array_push_f64(arr, html_value); + let mut arr = js_array_alloc(1); + arr = js_array_push_f64(arr, html_value); let arr_f64 = f64::from_bits(perry_runtime::JSValue::pointer(arr as *const u8).bits()); js_readable_stream_from_iterable(arr_f64) } @@ -1825,7 +1939,7 @@ extern "C" fn readable_from_chunk_fulfilled(closure: *const ClosureHeader, value if closure.is_null() { return f64::from_bits(TAG_UNDEFINED); } - let promise = perry_runtime::closure::js_closure_get_capture_ptr(closure, 0) as *mut Promise; + let promise = js_closure_get_capture_ptr(closure, 0) as *mut Promise; unsafe { let result = build_iter_result(value.to_bits(), false); js_promise_resolve(promise, f64::from_bits(result)); @@ -1837,7 +1951,7 @@ extern "C" fn readable_from_chunk_rejected(closure: *const ClosureHeader, reason if closure.is_null() { return f64::from_bits(TAG_UNDEFINED); } - let promise = perry_runtime::closure::js_closure_get_capture_ptr(closure, 0) as *mut Promise; + let promise = js_closure_get_capture_ptr(closure, 0) as *mut Promise; js_promise_reject(promise, reason); f64::from_bits(TAG_UNDEFINED) } @@ -1851,7 +1965,7 @@ unsafe fn resolve_reader_read_value(promise: *mut Promise, value_bits: u64) { } } let value = f64::from_bits(value_bits); - if perry_runtime::promise::js_value_is_promise(value) == 0 { + if js_value_is_promise(value) == 0 { let result = build_iter_result(value_bits, false); js_promise_resolve(promise, f64::from_bits(result)); return; @@ -1864,27 +1978,21 @@ unsafe fn resolve_reader_read_value(promise: *mut Promise, value_bits: u64) { return; } - match perry_runtime::promise::js_promise_state(inner) { + match js_promise_state(inner) { 1 => { - let value = perry_runtime::promise::js_promise_value(inner); + let value = js_promise_value(inner); let result = build_iter_result(value.to_bits(), false); js_promise_resolve(promise, f64::from_bits(result)); } 2 => { - js_promise_reject(promise, perry_runtime::promise::js_promise_reason(inner)); + js_promise_reject(promise, js_promise_reason(inner)); } _ => { - let fulfill = perry_runtime::closure::js_closure_alloc( - readable_from_chunk_fulfilled as *const u8, - 1, - ); - let reject = perry_runtime::closure::js_closure_alloc( - readable_from_chunk_rejected as *const u8, - 1, - ); - perry_runtime::closure::js_closure_set_capture_ptr(fulfill, 0, promise as i64); - perry_runtime::closure::js_closure_set_capture_ptr(reject, 0, promise as i64); - let _ = perry_runtime::promise::js_promise_then(inner, fulfill, reject); + let fulfill = js_closure_alloc(readable_from_chunk_fulfilled as *const u8, 1); + let reject = js_closure_alloc(readable_from_chunk_rejected as *const u8, 1); + js_closure_set_capture_ptr(fulfill, 0, promise as i64); + js_closure_set_capture_ptr(reject, 0, promise as i64); + let _ = js_promise_then(inner, fulfill, reject); } } } @@ -2002,9 +2110,9 @@ fn closure_capture_value( value: f64, ) -> *mut ClosureHeader { let fn_ptr = func as *const u8; - perry_runtime::closure::js_register_closure_arity(fn_ptr, 0); - let closure = perry_runtime::closure::js_closure_alloc(fn_ptr, 1); - perry_runtime::closure::js_closure_set_capture_ptr(closure, 0, value.to_bits() as i64); + js_register_closure_arity(fn_ptr, 0); + let closure = js_closure_alloc(fn_ptr, 1); + js_closure_set_capture_ptr(closure, 0, value.to_bits() as i64); closure } @@ -2012,7 +2120,7 @@ fn closure_capture_value_get(closure: *const ClosureHeader) -> f64 { if closure.is_null() { return f64::from_bits(TAG_UNDEFINED); } - let bits = perry_runtime::closure::js_closure_get_capture_ptr(closure, 0) as u64; + let bits = js_closure_get_capture_ptr(closure, 0) as u64; f64::from_bits(bits) } diff --git a/crates/perry-stdlib/src/streams/byob.rs b/crates/perry-stdlib/src/streams/byob.rs index e4f840f1da..23b779bcda 100644 --- a/crates/perry-stdlib/src/streams/byob.rs +++ b/crates/perry-stdlib/src/streams/byob.rs @@ -20,6 +20,7 @@ // pull-sources driving `byobRequest.respond(n)` — observe Node-shaped // results either way. +use super::gc::visit_stream_value_slot; use super::*; use std::collections::HashMap; @@ -54,7 +55,7 @@ pub(super) fn has_pending(stream_id: usize) -> bool { .unwrap_or(false) } -pub(super) fn scan_byob_roots(visitor: &mut perry_runtime::gc::RuntimeRootVisitor<'_>) { +pub(super) fn scan_byob_roots(visitor: &mut V) { if let Ok(mut map) = BYOB_PENDING.lock() { for queue in map.values_mut() { for pending in queue.iter_mut() { diff --git a/crates/perry-stdlib/src/streams/expando.rs b/crates/perry-stdlib/src/streams/expando.rs index 15575a9ee1..973d9df62e 100644 --- a/crates/perry-stdlib/src/streams/expando.rs +++ b/crates/perry-stdlib/src/streams/expando.rs @@ -3,8 +3,8 @@ //! accept arbitrary properties, so a live stream-band handle must too. Split //! out of `streams.rs` to keep that file under the file-size gate. +use super::gc::{visit_stream_value_slot, StreamRootVisitor}; use super::subclass::js_stream_handle_kind; -use super::visit_stream_value_slot; use std::collections::HashMap; use std::sync::Mutex; @@ -69,7 +69,7 @@ pub(crate) fn stream_expando_clear(id: usize) { } /// #5437: GC-trace expando values. Called from `scan_stream_roots_mut`. -pub(crate) fn scan_expando_roots(visitor: &mut perry_runtime::gc::RuntimeRootVisitor<'_>) { +pub(crate) fn scan_expando_roots(visitor: &mut V) { if let Ok(mut map) = STREAM_EXPANDO.lock() { for entries in map.values_mut() { for (_, bits) in entries.iter_mut() { diff --git a/crates/perry-stdlib/src/streams/gc.rs b/crates/perry-stdlib/src/streams/gc.rs new file mode 100644 index 0000000000..b8fc62377c --- /dev/null +++ b/crates/perry-stdlib/src/streams/gc.rs @@ -0,0 +1,215 @@ +//! Provider-safe GC registration for Web Streams registry roots. + +use super::*; +use std::ffi::c_void; + +const FFI_SLOT_I64: u32 = 1; +const FFI_SLOT_RAW_MUT_PTR: u32 = 3; +const FFI_SLOT_NANBOX_U64: u32 = 5; + +type FfiMutableRootVisitor = extern "C" fn(kind: u32, slot: *mut c_void, ctx: *mut c_void) -> bool; +type FfiNamedMutableRootScanner = + extern "C" fn(scanner_id: usize, visit: FfiMutableRootVisitor, ctx: *mut c_void); +type StreamGetReaderFn = unsafe extern "C" fn(f64) -> f64; +type StreamReaderReadFn = unsafe extern "C" fn(f64) -> *mut Promise; +type StreamExpandoSetFn = + unsafe extern "C" fn(id: usize, key_ptr: *const u8, key_len: usize, value: f64) -> i32; + +extern "C" { + fn perry_ffi_gc_register_mutable_root_scanner_named( + source_ptr: *const u8, + source_len: usize, + scanner_id: usize, + scanner: FfiNamedMutableRootScanner, + ); + #[link_name = "js_register_stream_consumer_callbacks"] + fn provider_js_register_stream_consumer_callbacks( + get_reader: StreamGetReaderFn, + reader_read: StreamReaderReadFn, + ); + #[link_name = "js_register_stream_expando_set"] + fn provider_js_register_stream_expando_set(hook: StreamExpandoSetFn); +} + +pub(super) trait StreamRootVisitor { + fn visit_i64_slot(&mut self, slot: &mut i64); + fn visit_raw_mut_ptr_slot(&mut self, slot: &mut *mut T); + fn visit_nanbox_u64_slot(&mut self, slot: &mut u64); +} + +impl StreamRootVisitor for perry_runtime::gc::RuntimeRootVisitor<'_> { + fn visit_i64_slot(&mut self, slot: &mut i64) { + perry_runtime::gc::RuntimeRootVisitor::visit_i64_slot(self, slot); + } + + fn visit_raw_mut_ptr_slot(&mut self, slot: &mut *mut T) { + perry_runtime::gc::RuntimeRootVisitor::visit_raw_mut_ptr_slot(self, slot); + } + + fn visit_nanbox_u64_slot(&mut self, slot: &mut u64) { + perry_runtime::gc::RuntimeRootVisitor::visit_nanbox_u64_slot(self, slot); + } +} + +struct FfiStreamRootVisitor { + visit: FfiMutableRootVisitor, + ctx: *mut c_void, +} + +impl StreamRootVisitor for FfiStreamRootVisitor { + fn visit_i64_slot(&mut self, slot: &mut i64) { + (self.visit)(FFI_SLOT_I64, slot as *mut i64 as *mut c_void, self.ctx); + } + + fn visit_raw_mut_ptr_slot(&mut self, slot: &mut *mut T) { + (self.visit)( + FFI_SLOT_RAW_MUT_PTR, + slot as *mut *mut T as *mut c_void, + self.ctx, + ); + } + + fn visit_nanbox_u64_slot(&mut self, slot: &mut u64) { + (self.visit)( + FFI_SLOT_NANBOX_U64, + slot as *mut u64 as *mut c_void, + self.ctx, + ); + } +} + +static GC_REGISTERED: std::sync::Once = std::sync::Once::new(); + +/// Register through the stable C ABI so a separately packaged stdlib installs +/// its scanner in the process-wide runtime provider, not in fallback Rust glue +/// that may also be present in the stdlib image. +pub(super) fn ensure_gc_registered() { + GC_REGISTERED.call_once(|| { + const SOURCE: &[u8] = b"stdlib:streams"; + unsafe { + perry_ffi_gc_register_mutable_root_scanner_named( + SOURCE.as_ptr(), + SOURCE.len(), + 0, + scan_stream_roots_ffi, + ); + } + unsafe { + provider_js_register_stream_consumer_callbacks( + js_readable_stream_get_reader, + js_reader_read, + ); + provider_js_register_stream_expando_set(expando::stream_expando_set_hook); + } + }); +} + +extern "C" fn scan_stream_roots_ffi( + _scanner_id: usize, + visit: FfiMutableRootVisitor, + ctx: *mut c_void, +) { + scan_stream_roots_with(&mut FfiStreamRootVisitor { visit, ctx }); +} + +#[cfg(test)] +pub(super) fn scan_stream_roots(mark: &mut dyn FnMut(f64)) { + let mut visitor = perry_runtime::gc::RuntimeRootVisitor::for_copy(mark); + scan_stream_roots_with(&mut visitor); +} + +pub(super) fn visit_stream_value_slot(visitor: &mut V, slot: &mut u64) { + let top = *slot >> 48; + if matches!(top, 0x7FFA | 0x7FFD | 0x7FFF) { + visitor.visit_nanbox_u64_slot(slot); + } +} + +pub(super) fn scan_stream_roots_with(visitor: &mut V) { + expando::scan_expando_roots(visitor); + if let Ok(mut map) = READABLE_STREAMS.lock() { + for stream in map.values_mut() { + visitor.visit_i64_slot(&mut stream.start_cb); + visitor.visit_i64_slot(&mut stream.pull_cb); + visitor.visit_i64_slot(&mut stream.cancel_cb); + visitor.visit_i64_slot(&mut stream.strategy_size_cb); + for chunk in stream.chunks.iter_mut() { + visit_stream_value_slot(visitor, chunk); + } + for promise in stream.pending_reads.iter_mut() { + visitor.visit_raw_mut_ptr_slot(promise); + } + if stream.state == ReadableState::Errored { + visit_stream_value_slot(visitor, &mut stream.error_value); + } + if let Some(error) = &mut stream.pending_error_after_chunks { + visit_stream_value_slot(visitor, error); + } + } + } + byob::scan_byob_roots(visitor); + if let Ok(mut map) = WRITABLE_STREAMS.lock() { + for stream in map.values_mut() { + visitor.visit_i64_slot(&mut stream.write_cb); + visitor.visit_i64_slot(&mut stream.close_cb); + visitor.visit_i64_slot(&mut stream.abort_cb); + visitor.visit_i64_slot(&mut stream.strategy_size_cb); + for (chunk, promise, _size) in stream.write_queue.iter_mut() { + visit_stream_value_slot(visitor, chunk); + visitor.visit_raw_mut_ptr_slot(promise); + } + visitor.visit_raw_mut_ptr_slot(&mut stream.ready_promise); + visitor.visit_raw_mut_ptr_slot(&mut stream.closed_promise); + visitor.visit_raw_mut_ptr_slot(&mut stream.close_request_promise); + if stream.state == WritableState::Errored { + visit_stream_value_slot(visitor, &mut stream.error_value); + } + } + } + if let Ok(mut map) = TRANSFORM_STREAMS.lock() { + for transform in map.values_mut() { + visitor.visit_i64_slot(&mut transform.transform_cb); + visitor.visit_i64_slot(&mut transform.flush_cb); + } + } + scan_transform_deferred_roots(visitor); + if let Ok(mut map) = READERS.lock() { + for reader in map.values_mut() { + visitor.visit_raw_mut_ptr_slot(&mut reader.closed_promise); + } + } + if let Ok(mut map) = WRITERS.lock() { + for writer in map.values_mut() { + visitor.visit_raw_mut_ptr_slot(&mut writer.closed_promise); + visitor.visit_raw_mut_ptr_slot(&mut writer.ready_promise); + } + } +} + +pub(super) fn scan_transform_deferred_roots(visitor: &mut V) { + if let Ok(mut map) = transform::TRANSFORM_WRITE_RELEASES.lock() { + for promises in map.values_mut() { + for slot in promises.iter_mut() { + let mut promise = *slot as *mut Promise; + visitor.visit_raw_mut_ptr_slot(&mut promise); + *slot = promise as usize; + } + } + } + if let Ok(mut map) = transform::TRANSFORM_PENDING_CLOSE.lock() { + for slot in map.values_mut() { + let mut promise = *slot as *mut Promise; + visitor.visit_raw_mut_ptr_slot(&mut promise); + *slot = promise as usize; + } + } + if let Ok(mut map) = transform::TRANSFORM_BACKPRESSURED_JOBS.lock() { + for jobs in map.values_mut() { + for slot in jobs.iter_mut() { + let mut job = *slot as *mut ClosureHeader; + visitor.visit_raw_mut_ptr_slot(&mut job); + *slot = job as usize; + } + } + } +} diff --git a/crates/perry-stdlib/src/streams/tests.rs b/crates/perry-stdlib/src/streams/tests.rs index 0c56b119f2..d14e9850c8 100644 --- a/crates/perry-stdlib/src/streams/tests.rs +++ b/crates/perry-stdlib/src/streams/tests.rs @@ -161,9 +161,9 @@ fn root_scanner_emits_callbacks_chunks_and_promises() { 1, ReadableStreamData { state: ReadableState::Errored, - chunks: VecDeque::from([0x7FFD_0000_0000_1234]), - chunk_sizes: VecDeque::from([1.0]), - queue_total_size: 1.0, + chunks: VecDeque::from([0x7FFD_0000_0000_1234, 0x7FFA_0000_0000_2345]), + chunk_sizes: VecDeque::from([1.0, 1.0]), + queue_total_size: 2.0, pending_reads: VecDeque::from([0x2345_6780 as *mut Promise]), start_cb: 0x3456_7890, pull_cb: 0, @@ -186,12 +186,95 @@ fn root_scanner_emits_callbacks_chunks_and_promises() { scan_stream_roots(&mut |value| emitted.push(value.to_bits())); assert!(emitted.contains(&0x7FFD_0000_0000_1234)); + assert!(emitted.contains(&0x7FFA_0000_0000_2345)); assert!(emitted.contains(&(0x7FFD_0000_0000_0000 | 0x2345_6780))); assert!(emitted.contains(&(0x7FFD_0000_0000_0000 | 0x3456_7890))); assert!(emitted.contains(&0x7FFF_0000_0000_4567)); READABLE_STREAMS.lock().unwrap().clear(); } +#[test] +fn transform_root_scanner_writes_relocated_pointers_back() { + struct RelocatingVisitor; + + impl super::gc::StreamRootVisitor for RelocatingVisitor { + fn visit_i64_slot(&mut self, slot: &mut i64) { + *slot += 0x1000; + } + + fn visit_raw_mut_ptr_slot(&mut self, slot: &mut *mut T) { + *slot = ((*slot as usize) + 0x1000) as *mut T; + } + + fn visit_nanbox_u64_slot(&mut self, slot: &mut u64) { + *slot += 0x1000; + } + } + + let _serial = serial_guard(); + transform::TRANSFORM_WRITE_RELEASES + .lock() + .unwrap() + .insert(0xA001, vec![0x2100]); + transform::TRANSFORM_PENDING_CLOSE + .lock() + .unwrap() + .insert(0xA002, 0x3100); + transform::TRANSFORM_BACKPRESSURED_JOBS + .lock() + .unwrap() + .insert(0xA003, vec![0x4100]); + + super::gc::scan_transform_deferred_roots(&mut RelocatingVisitor); + + assert_eq!( + transform::TRANSFORM_WRITE_RELEASES.lock().unwrap()[&0xA001], + vec![0x3100] + ); + assert_eq!( + transform::TRANSFORM_PENDING_CLOSE.lock().unwrap()[&0xA002], + 0x4100 + ); + assert_eq!( + transform::TRANSFORM_BACKPRESSURED_JOBS.lock().unwrap()[&0xA003], + vec![0x5100] + ); + + transform::TRANSFORM_WRITE_RELEASES + .lock() + .unwrap() + .remove(&0xA001); + transform::TRANSFORM_PENDING_CLOSE + .lock() + .unwrap() + .remove(&0xA002); + transform::TRANSFORM_BACKPRESSURED_JOBS + .lock() + .unwrap() + .remove(&0xA003); +} + +#[test] +fn stream_runtime_owned_calls_cannot_bypass_provider_abi() { + let streams_source = include_str!("../streams.rs"); + for forbidden in [ + "perry_runtime::array::js_", + "perry_runtime::closure::js_", + "perry_runtime::object::js_implicit_this_set", + "perry_runtime::promise::js_", + ] { + assert!( + !streams_source.contains(forbidden), + "streams.rs bypasses the provider ABI through {forbidden}" + ); + } + + let gc_source = include_str!("gc.rs"); + assert!(!gc_source + .contains("perry_runtime::node_submodules::js_register_stream_consumer_callbacks")); + assert!(!gc_source.contains("perry_runtime::object::js_register_stream_expando_set")); +} + #[test] fn web_compression_formats_round_trip() { let input = b"hello stream/web compression"; diff --git a/test-files/_helpers/issue_8038_response_producer.ts b/test-files/_helpers/issue_8038_response_producer.ts new file mode 100644 index 0000000000..79cfa97f46 --- /dev/null +++ b/test-files/_helpers/issue_8038_response_producer.ts @@ -0,0 +1,79 @@ +function streamedResponse(label: string): Response { + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(`{"label":"${label}",`)); + queueMicrotask(() => { + controller.enqueue(encoder.encode('"complete":true}')); + controller.close(); + }); + }, + }); + + const response = new Response(stream, { + status: 207, + statusText: "Multi-Status", + headers: { + "content-type": "application/json; charset=utf-8", + "x-perry-repro": label, + }, + }); + response.headers.append( + "set-cookie", + `perry_ctx=${label}; Path=/; HttpOnly; SameSite=Strict`, + ); + return response; +} + +export function syncResponse(): Response { + return streamedResponse("sync"); +} + +export async function asyncResponse(): Promise { + await Promise.resolve(); + return streamedResponse("async"); +} + +export class NextLikeResponse extends Response { + readonly nextMarker = "next-like"; + + constructor(body?: BodyInit | null, init?: ResponseInit) { + super(body, init); + } +} + +export async function asyncNextResponse(): Promise { + await Promise.resolve(); + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('{"label":"next",')); + queueMicrotask(() => { + controller.enqueue(encoder.encode('"complete":true}')); + controller.close(); + }); + }, + }); + const response = new NextLikeResponse(stream, { + status: 207, + statusText: "Multi-Status", + headers: { + "content-type": "application/json; charset=utf-8", + "x-perry-repro": "next", + }, + }); + response.headers.append( + "set-cookie", + "perry_ctx=next; Path=/; HttpOnly; SameSite=Strict", + ); + return response; +} + +export function erroredResponse(): Response { + const stream = new ReadableStream({ + start(controller) { + queueMicrotask(() => controller.error(new Error("stream-boom"))); + }, + }); + return new Response(stream); +} diff --git a/test-files/test_issue_8038_cross_module_response_stream.ts b/test-files/test_issue_8038_cross_module_response_stream.ts new file mode 100644 index 0000000000..174239719c --- /dev/null +++ b/test-files/test_issue_8038_cross_module_response_stream.ts @@ -0,0 +1,95 @@ +// parity-env: PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1 PERRY_GC_SCHEDULE_SEED=8038 PERRY_GC_SCHEDULE_RATE=1 + +import { + asyncNextResponse, + asyncResponse, + erroredResponse, + NextLikeResponse, + syncResponse, +} from "./_helpers/issue_8038_response_producer.ts"; + +declare function gc(): void; + +function forceGc(): void { + const churn: Array<{ index: number; payload: string }> = []; + for (let index = 0; index < 2_000; index += 1) { + churn.push({ index, payload: `response-root-${index}` }); + } + if (typeof gc === "function") gc(); +} + +async function validate(label: string, response: Response): Promise { + const firstHeaders = response.headers; + const firstBody = response.body; + // Keep the response, Headers view, ReadableStream, queued chunk/controller, + // and later the reader live across both scheduled and explicit collections. + forceGc(); + console.log(`${label}:brand=${response instanceof Response}`); + console.log(`${label}:status=${response.status} ${response.statusText}`); + console.log(`${label}:headers-stable=${firstHeaders === response.headers}`); + console.log(`${label}:body-stable=${firstBody === response.body}`); + console.log(`${label}:content-type=${firstHeaders.get("content-type")}`); + console.log(`${label}:x-perry-repro=${firstHeaders.get("x-perry-repro")}`); + console.log(`${label}:cookie=${firstHeaders.get("set-cookie")}`); + + if (firstBody === null) { + throw new Error(`${label}: missing body`); + } + const reader = firstBody.getReader(); + forceGc(); + const decoder = new TextDecoder(); + let body = ""; + let chunks = 0; + let eof = false; + while (!eof) { + const result = await reader.read(); + forceGc(); + eof = result.done; + if (!result.done) { + chunks += 1; + body += decoder.decode(result.value); + } + } + console.log(`${label}:chunks=${chunks} eof=${eof} body=${body}`); +} + +async function main(): Promise { + const initializerHeaders = new Headers({ x: "a" }); + const copiedHeadersResponse = new Response(null, { + headers: initializerHeaders, + }); + console.log( + `headers-copy:identity=${copiedHeadersResponse.headers === initializerHeaders}`, + ); + initializerHeaders.set("x", "b"); + console.log(`headers-copy:response=${copiedHeadersResponse.headers.get("x")}`); + copiedHeadersResponse.headers.set("x", "c"); + console.log(`headers-copy:initializer=${initializerHeaders.get("x")}`); + + await validate("sync", syncResponse()); + await validate("async", await asyncResponse()); + const next = await asyncNextResponse(); + console.log(`next:subclass-brand=${next instanceof NextLikeResponse}`); + console.log(`next:marker=${next.nextMarker}`); + await validate("next", next); + + const response = erroredResponse(); + if (response.body === null) throw new Error("error: missing body"); + const reader = response.body.getReader(); + try { + await reader.read(); + console.log("error:missing"); + } catch (error) { + console.log(`error:surfaced=${String(error)}`); + } +} + +export function runIssue8038(): Promise { + return main(); +} + +// App-only dylib hosts call the exported wrapper and root its returned Promise. +// Ordinary executable and Node parity runs retain the normal top-level entry. +if (process.env.PERRY_ISSUE_8038_LIBRARY_HOST !== "1") { + runIssue8038(); +} diff --git a/test-parity/expected/test_issue_8038_cross_module_response_stream.txt b/test-parity/expected/test_issue_8038_cross_module_response_stream.txt new file mode 100644 index 0000000000..e869be6b47 --- /dev/null +++ b/test-parity/expected/test_issue_8038_cross_module_response_stream.txt @@ -0,0 +1,30 @@ +headers-copy:identity=false +headers-copy:response=a +headers-copy:initializer=b +sync:brand=true +sync:status=207 Multi-Status +sync:headers-stable=true +sync:body-stable=true +sync:content-type=application/json; charset=utf-8 +sync:x-perry-repro=sync +sync:cookie=perry_ctx=sync; Path=/; HttpOnly; SameSite=Strict +sync:chunks=2 eof=true body={"label":"sync","complete":true} +async:brand=true +async:status=207 Multi-Status +async:headers-stable=true +async:body-stable=true +async:content-type=application/json; charset=utf-8 +async:x-perry-repro=async +async:cookie=perry_ctx=async; Path=/; HttpOnly; SameSite=Strict +async:chunks=2 eof=true body={"label":"async","complete":true} +next:subclass-brand=true +next:marker=next-like +next:brand=true +next:status=207 Multi-Status +next:headers-stable=true +next:body-stable=true +next:content-type=application/json; charset=utf-8 +next:x-perry-repro=next +next:cookie=perry_ctx=next; Path=/; HttpOnly; SameSite=Strict +next:chunks=2 eof=true body={"label":"next","complete":true} +error:surfaced=Error: stream-boom