From cdf9a97a89107dd63bc6e07e50ca6f118c9c0fb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 14 Aug 2026 08:20:46 +0200 Subject: [PATCH] fix(gc): index stack maps from loaded provider apps --- .github/workflows/gc-native-roots.yml | 11 + changelog.d/8081-provider-gc-rooting.md | 23 ++ .../perry-runtime/src/gc/roots/stack_maps.rs | 344 ++++++++++++----- .../src/gc/roots/stack_maps_decode_tests.rs | 200 ++++++++++ scripts/gc_provider_dylib_gate.sh | 172 +++++++++ .../issue_8075_provider_gc/app-linker.sh | 27 ++ .../issue_8075_provider_gc/handlers/main.ts | 39 ++ tests/fixtures/issue_8075_provider_gc/host.rs | 354 ++++++++++++++++++ .../issue_8075_provider_gc/perch_entry.ts | 12 + .../issue_8075_provider_gc/stdlib-linker.sh | 66 ++++ .../stdlib-provider/Cargo.toml | 22 ++ .../stdlib-provider/src/lib.rs | 20 + 12 files changed, 1202 insertions(+), 88 deletions(-) create mode 100644 changelog.d/8081-provider-gc-rooting.md create mode 100755 scripts/gc_provider_dylib_gate.sh create mode 100755 tests/fixtures/issue_8075_provider_gc/app-linker.sh create mode 100644 tests/fixtures/issue_8075_provider_gc/handlers/main.ts create mode 100644 tests/fixtures/issue_8075_provider_gc/host.rs create mode 100644 tests/fixtures/issue_8075_provider_gc/perch_entry.ts create mode 100755 tests/fixtures/issue_8075_provider_gc/stdlib-linker.sh create mode 100644 tests/fixtures/issue_8075_provider_gc/stdlib-provider/Cargo.toml create mode 100644 tests/fixtures/issue_8075_provider_gc/stdlib-provider/src/lib.rs diff --git a/.github/workflows/gc-native-roots.yml b/.github/workflows/gc-native-roots.yml index 036373da49..95d38455a5 100644 --- a/.github/workflows/gc-native-roots.yml +++ b/.github/workflows/gc-native-roots.yml @@ -279,6 +279,17 @@ jobs: export RUSTFLAGS="-C force-frame-pointers=yes -C force-unwind-tables=yes" cargo build --profile perry-dev -p perry -p perry-runtime-static -p perry-stdlib-static + # #8075 is a loader shape, not another standalone-executable probe: the + # runtime and stdlib are process-wide providers, generated frames live in + # a later-loaded app-only dylib, and full GC runs from a clean host + # boundary. The gate owns the exact two-module JSON/Buffer fixture and + # requires 32,768 valid responses, >=10 full collections, retained and + # temporary Buffer classifications, concurrent producers serialized on + # one Perry executor, reclaimed temporary bytes, and a flat live slope. + - name: Provider dylib host-boundary full GC + if: ${{ !cancelled() && runner.os != 'Windows' }} + run: scripts/gc_provider_dylib_gate.sh + # The two aarch64 walkers, over a frame this repository wrote, on the host # that has to walk it. # diff --git a/changelog.d/8081-provider-gc-rooting.md b/changelog.d/8081-provider-gc-rooting.md new file mode 100644 index 0000000000..de355bf27f --- /dev/null +++ b/changelog.d/8081-provider-gc-rooting.md @@ -0,0 +1,23 @@ +### Fixed + +- Preserve native GC roots when Perry runs behind separately loaded runtime and + stdlib providers and generated application code lives in an app-only dynamic + library (#8075). The runtime previously indexed only the process executable's + compact stack map, then cached that incomplete view permanently. Provider + hosts could therefore complete a full collection at a clean host boundary + and corrupt values used by the next imported-handler invocation. + +- Rebuild the stack-map index at module initialization and discover compact GC + maps in every loaded Mach-O or ELF image. Generation-ordered publication + prevents an older concurrent loader snapshot from replacing a newer index, + while root scanning never performs loader I/O. Linux fails closed rather + than publishing an incomplete index when a loaded ELF image cannot be read, + and section addresses are checked against the loader's mapped segments + before use. + +- Add a provider-host integration gate for Linux and macOS. It builds separate + runtime and stdlib providers plus an app-only two-module dylib, validates + 32,768 JSON/Buffer responses from serial and concurrently queued callers, + forces at least ten full collections, covers retained and temporary Buffers, + requires reclaimed bytes with a flat latter-half live set, verifies the app + map survives macOS dead stripping, and isolates provider build artifacts. diff --git a/crates/perry-runtime/src/gc/roots/stack_maps.rs b/crates/perry-runtime/src/gc/roots/stack_maps.rs index fc45eb03f9..e142f53708 100644 --- a/crates/perry-runtime/src/gc/roots/stack_maps.rs +++ b/crates/perry-runtime/src/gc/roots/stack_maps.rs @@ -27,7 +27,8 @@ use crate::gc::telemetry::RootSourcesTraceStats; // the Itanium/pthread declarations, which do not exist there. #[cfg(not(target_os = "windows"))] use std::ffi::c_void; -use std::sync::OnceLock; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{OnceLock, RwLock, RwLockReadGuard}; /// Magic and version of the compact map the compiler emits /// (`perry-codegen/src/gc_map.rs`). LLVM's own stack-map section is rewritten @@ -101,7 +102,83 @@ impl StackMapIndex { } } -static STACK_MAPS: OnceLock = OnceLock::new(); +/// All maps visible to this runtime provider. +/// +/// A provider can outlive any one app image, and hosts may `dlopen` another +/// app after the first call to `js_gc_init`. Keep the index replaceable so +/// each module initialization can take a fresh loader snapshot. Root scans +/// only take the read side; rebuilding and ELF/Mach-O parsing therefore stay +/// outside the collector's allocation-free critical section. +#[derive(Debug)] +struct PublishedStackMapIndex { + generation: u64, + index: StackMapIndex, +} + +struct StackMapIndexStore { + next_generation: AtomicU64, + published: OnceLock>, +} + +impl StackMapIndexStore { + const fn new() -> Self { + Self { + next_generation: AtomicU64::new(0), + published: OnceLock::new(), + } + } + + fn rebuild(&self) { + self.rebuild_with(build_stack_map_index); + } + + fn rebuild_with(&self, build: impl FnOnce() -> StackMapIndex) { + // Reserve before taking the loader snapshot. Module initialization + // starts only after that module has been loaded, so generation order + // is also the minimum loader recency each rebuild must preserve. + let generation = self + .next_generation + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |generation| { + generation.checked_add(1) + }) + .unwrap_or_else(|_| panic!("perry: stack-map rebuild generation overflow")) + + 1; + let mut candidate = Some(PublishedStackMapIndex { + generation, + index: build(), + }); + let maps = self.published.get_or_init(|| { + RwLock::new( + candidate + .take() + .expect("perry: initial stack-map candidate missing"), + ) + }); + let Some(candidate) = candidate else { + return; + }; + let mut current = maps + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if candidate.generation > current.generation { + *current = candidate; + } + } + + fn read(&self) -> RwLockReadGuard<'_, PublishedStackMapIndex> { + self.published + .get_or_init(|| { + RwLock::new(PublishedStackMapIndex { + generation: 0, + index: StackMapIndex::default(), + }) + }) + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } +} + +static STACK_MAPS: StackMapIndexStore = StackMapIndexStore::new(); // The two register numbers the compact format's short base tags stand for. // These are aarch64's by definition of the FORMAT, on every architecture — see @@ -244,51 +321,58 @@ pub(in crate::gc) fn record_native_stack_walk_source( } pub(in crate::gc) fn initialize() { - let _ = stack_maps(); + STACK_MAPS.rebuild(); } /// Whether this image carries any native stack-map records — i.e. whether /// precise frame roots depend on mapped PCs at all. Consumed by the /// `PERRY_GC_SAFEPOINT_ONLY` contract assert. pub(in crate::gc) fn native_maps_active() -> bool { - !stack_maps().records.is_empty() + !stack_maps().index.records.is_empty() } -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 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 - // — but their consequences are not: with statepoints as the only root - // mechanism, an empty index means the collector frees live objects and - // corrupts the heap with no diagnostic at all. That is CLAUDE.md's - // 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 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, - ); - } +fn stack_maps() -> RwLockReadGuard<'static, PublishedStackMapIndex> { + STACK_MAPS.read() +} + +fn build_stack_map_index() -> StackMapIndex { + // 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 sections = loaded_stack_map_sections().unwrap_or_else(|error| { + panic!( + "perry: could not inspect every loaded image for native GC roots: {error}; \ + refusing to publish an incomplete stack-map index" + ) + }); + 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 + // — but their consequences are not: with statepoints as the only root + // mechanism, an empty index means the collector frees live objects and + // corrupts the heap with no diagnostic at all. That is CLAUDE.md's + // 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 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) - }) + } + records.sort_unstable_by_key(|record| record.pc); + index_records(records, roots) } fn append_gc_map_section( @@ -666,7 +750,8 @@ impl StackMapIndex { pub(super) fn visit_stack_map_root_slots( visit: &mut impl FnMut(MutableRootSlot), ) -> NativeStackWalkStats { - let index = stack_maps(); + let published = stack_maps(); + let index = &published.index; if index.records.is_empty() { return NativeStackWalkStats::default(); } @@ -899,7 +984,7 @@ 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_sections() -> Vec<&'static [u8]> { +fn loaded_stack_map_sections() -> Result, String> { use mach2::dyld::{_dyld_get_image_header, _dyld_get_image_vmaddr_slide, _dyld_image_count}; const LC_SEGMENT_64: u32 = 0x19; @@ -1007,33 +1092,140 @@ fn loaded_stack_map_sections() -> Vec<&'static [u8]> { } } } - sections + Ok(sections) } -#[cfg(not(target_vendor = "apple"))] -fn loaded_stack_map_sections() -> Vec<&'static [u8]> { - loaded_stack_map_section().into_iter().collect() +#[cfg(not(any(target_vendor = "apple", target_os = "linux")))] +fn loaded_stack_map_sections() -> Result, String> { + Ok(loaded_stack_map_section().into_iter().collect()) } -/// ELF (#7173): the `.perry_gcmap` section of the main executable. +/// ELF (#7173, #8075): the `.perry_gcmap` sections of every loaded image. /// /// Linker-provided `__start_`/`__stop_` symbols would need weak linkage /// (unstable in Rust) or `-rdynamic` (not guaranteed), so instead: read -/// `/proc/self/exe`'s section headers for `.perry_gcmap` (sh_addr, -/// sh_size) and add the main object's load bias from the first -/// `dl_iterate_phdr` callback. Runtime-verified gates for this path are -/// pending a Linux host — tracked in #7173; the parser, index, matching, -/// and verify machinery above are platform-independent already. +/// each `dl_iterate_phdr` image's ELF section headers for `.perry_gcmap` +/// (`sh_addr`, `sh_size`) and add that image's `dlpi_addr` load bias. The +/// executable has an empty `dlpi_name`, for which `/proc/self/exe` is the +/// stable path. Reading only that first image is unsound when the runtime is +/// a provider and generated code lives in an app dylib: its live native roots +/// disappear from the collector exactly when a full collection evacuates. #[cfg(target_os = "linux")] -fn loaded_stack_map_section() -> Option<&'static [u8]> { - let bytes = std::fs::read("/proc/self/exe").ok()?; - let (addr, size) = elf_section_vaddr(&bytes, b".perry_gcmap")?; - let bias = main_object_load_bias()?; - let start = bias.checked_add(addr)?; - if start == 0 || size == 0 { - return None; +fn loaded_stack_map_sections() -> Result, String> { + use std::ffi::CStr; + use std::os::unix::ffi::OsStrExt; + use std::path::Path; + + #[repr(C)] + struct DlPhdrInfo { + dlpi_addr: usize, + dlpi_name: *const std::os::raw::c_char, + dlpi_phdr: *const ElfProgramHeader, + dlpi_phnum: u16, + } + #[repr(C)] + struct ElfProgramHeader { + p_type: u32, + _p_flags: u32, + _p_offset: u64, + p_vaddr: u64, + _p_paddr: u64, + _p_filesz: u64, + p_memsz: u64, + _p_align: u64, + } + struct SectionScan { + sections: Vec<&'static [u8]>, + unreadable_images: Vec, + } + #[allow(clashing_extern_declarations)] + unsafe extern "C" { + fn dl_iterate_phdr( + callback: unsafe extern "C" fn(*mut DlPhdrInfo, usize, *mut c_void) -> i32, + data: *mut c_void, + ) -> i32; + } + unsafe extern "C" fn collect(info: *mut DlPhdrInfo, _size: usize, data: *mut c_void) -> i32 { + let Some(info) = info.as_ref() else { + return 0; + }; + let image_name = if info.dlpi_name.is_null() { + &[][..] + } else { + CStr::from_ptr(info.dlpi_name).to_bytes() + }; + // The kernel-provided vDSO has no backing file. It cannot contain + // Perry-generated code, so it is the sole unreadable-image exception. + if image_name == b"linux-vdso.so.1" || image_name == b"linux-gate.so.1" { + return 0; + } + let path = if image_name.is_empty() { + Path::new("/proc/self/exe") + } else { + Path::new(std::ffi::OsStr::from_bytes(image_name)) + }; + let bytes = match std::fs::read(path) { + Ok(bytes) => bytes, + Err(error) => { + let scan = &mut *data.cast::(); + scan.unreadable_images + .push(format!("{} ({error})", path.display())); + return 0; + } + }; + let Some((addr, size)) = elf_section_vaddr(&bytes, b".perry_gcmap") else { + return 0; + }; + let Some(start) = info.dlpi_addr.checked_add(addr) else { + return 0; + }; + let Some(section_end) = addr.checked_add(size) else { + return 0; + }; + const PT_LOAD: u32 = 1; + let mapped = !info.dlpi_phdr.is_null() + && std::slice::from_raw_parts(info.dlpi_phdr, usize::from(info.dlpi_phnum)) + .iter() + .filter(|header| header.p_type == PT_LOAD) + .any(|header| { + let Ok(segment_start) = usize::try_from(header.p_vaddr) else { + return false; + }; + let Some(segment_end) = usize::try_from(header.p_memsz) + .ok() + .and_then(|size| segment_start.checked_add(size)) + else { + return false; + }; + addr >= segment_start && section_end <= segment_end + }); + // The on-disk path can be replaced after dlopen. Validate its claimed + // address against the loader's actual PT_LOAD ranges before turning + // it into a slice, so a stale or hostile section table cannot make GC + // initialization read outside the mapped image. + if mapped && start != 0 && size != 0 { + let scan = &mut *data.cast::(); + scan.sections + .push(std::slice::from_raw_parts(start as *const u8, size)); + } + 0 + } + + let mut scan = SectionScan { + sections: Vec::new(), + unreadable_images: Vec::new(), + }; + unsafe { + dl_iterate_phdr(collect, (&mut scan as *mut SectionScan).cast::()); + } + if scan.unreadable_images.is_empty() { + Ok(scan.sections) + } else { + Err(format!( + "unreadable loaded ELF image(s): {}", + scan.unreadable_images.join(", ") + )) } - Some(unsafe { std::slice::from_raw_parts(start as *const u8, size) }) } /// Minimal ELF64 section-header walk: returns (sh_addr, sh_size) for the @@ -1056,6 +1248,12 @@ fn elf_section_vaddr(bytes: &[u8], name: &[u8]) -> Option<(usize, usize)> { let candidate = bytes.get(name_pos..name_pos.checked_add(name.len())?)?; let terminator = bytes.get(name_pos + name.len()).copied().unwrap_or(1); if candidate == name && terminator == 0 { + // Only an SHF_ALLOC section has a runtime virtual address. Refuse + // a file-only namesake before constructing a slice from sh_addr. + const SHF_ALLOC: u64 = 0x2; + if read_u64(bytes, hdr.checked_add(0x08)?)? & SHF_ALLOC == 0 { + return None; + } let addr = read_u64(bytes, hdr.checked_add(0x10)?)? as usize; let size = read_u64(bytes, hdr.checked_add(0x20)?)? as usize; return Some((addr, size)); @@ -1064,36 +1262,6 @@ fn elf_section_vaddr(bytes: &[u8], name: &[u8]) -> Option<(usize, usize)> { None } -/// Load bias of the main object: `dlpi_addr` of the first `dl_iterate_phdr` -/// callback (the executable itself on glibc and musl). -#[cfg(target_os = "linux")] -fn main_object_load_bias() -> Option { - #[repr(C)] - struct DlPhdrInfo { - dlpi_addr: usize, - dlpi_name: *const std::os::raw::c_char, - // remaining fields unused - } - #[allow(clashing_extern_declarations)] - unsafe extern "C" { - fn dl_iterate_phdr( - callback: unsafe extern "C" fn(*mut DlPhdrInfo, usize, *mut c_void) -> i32, - data: *mut c_void, - ) -> i32; - } - unsafe extern "C" fn first(info: *mut DlPhdrInfo, _size: usize, data: *mut c_void) -> i32 { - unsafe { - *data.cast::() = (*info).dlpi_addr; - } - 1 // stop after the first (main) object - } - let mut bias = usize::MAX; - unsafe { - dl_iterate_phdr(first, (&mut bias as *mut usize).cast::()); - } - (bias != usize::MAX).then_some(bias) -} - /// Windows/PE: the `.pgcmap` section of the running image. /// /// The name is seven bytes because a PE image section header has an 8-byte name 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 512b6f19f3..9256225427 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 @@ -127,6 +127,206 @@ mod tests { assert_eq!(roots[records[1].roots_start as usize].offset, -16); } + #[test] + fn an_older_initializer_finishing_last_cannot_replace_a_newer_snapshot() { + use std::sync::{mpsc, Arc}; + + fn index_for(function: u64) -> StackMapIndex { + let mut records = Vec::new(); + let mut roots = Vec::new(); + append_gc_map_section(&mut records, &mut roots, &simple(function, 0x20, -8)) + .expect("valid test map"); + records.sort_unstable_by_key(|record| record.pc); + index_records(records, roots) + } + + fn force_reversed_publication(store: Arc, expected_generation: u64) { + let (older_snapshotted, wait_for_older) = mpsc::channel(); + let (release_older, older_may_finish) = mpsc::channel(); + let older_store = Arc::clone(&store); + let older = std::thread::spawn(move || { + older_store.rebuild_with(|| { + let stale = index_for(0x1000); + older_snapshotted.send(()).expect("announce older snapshot"); + older_may_finish.recv().expect("release older snapshot"); + stale + }); + }); + + wait_for_older + .recv() + .expect("older initializer took its snapshot"); + let newer_store = Arc::clone(&store); + let newer = std::thread::spawn(move || { + newer_store.rebuild_with(|| index_for(0x2000)); + }); + newer.join().expect("newer initializer completed"); + release_older.send(()).expect("resume older initializer"); + older.join().expect("older initializer completed last"); + + let published = store.read(); + assert_eq!(published.generation, expected_generation); + assert_eq!(published.index.records.len(), 1); + assert_eq!(published.index.records[0].pc, 0x2020); + } + + // Cover both races from the review: the newer initializer wins the + // OnceLock installation while the older one is stalled, and two + // replacements finish in reverse order after an index already exists. + force_reversed_publication(Arc::new(StackMapIndexStore::new()), 2); + let seeded = Arc::new(StackMapIndexStore::new()); + seeded.rebuild_with(|| index_for(0x0800)); + force_reversed_publication(seeded, 3); + } + + #[test] + fn reading_an_uninitialized_store_does_not_inspect_loaded_images() { + let store = StackMapIndexStore::new(); + let published = store.read(); + + assert_eq!(published.generation, 0); + assert!(published.index.records.is_empty()); + assert_eq!( + store + .next_generation + .load(std::sync::atomic::Ordering::Relaxed), + 0, + "root scanning must not start a loader snapshot" + ); + } + + #[cfg(target_os = "linux")] + #[test] + fn discovers_a_map_from_a_later_loaded_shared_object() { + use std::ffi::CString; + use std::fmt::Write as _; + use std::os::unix::ffi::OsStrExt; + use std::process::Command; + + struct TempDir(std::path::PathBuf); + impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + let map = simple(0x8075_0000, 0x20, -8); + let unique = format!( + "perry-stack-map-dylib-{}-{:?}", + std::process::id(), + std::thread::current().id() + ); + let temp = TempDir(std::env::temp_dir().join(unique)); + std::fs::create_dir(&temp.0).expect("create temporary dylib directory"); + let source = temp.0.join("map.c"); + let library = temp.0.join("libmap.so"); + let mut bytes = String::new(); + for (index, byte) in map.iter().enumerate() { + if index != 0 { + bytes.push(','); + } + write!(bytes, "0x{byte:02x}").expect("format map byte"); + } + std::fs::write( + &source, + format!( + "__attribute__((used, section(\".perry_gcmap\")))\n\ + const unsigned char perry_test_map[] = {{{bytes}}};\n\ + int perry_test_anchor(void) {{ return 8075; }}\n" + ), + ) + .expect("write dylib source"); + let compiler = std::env::var_os("CC").unwrap_or_else(|| "cc".into()); + let output = Command::new(compiler) + .args(["-shared", "-fPIC", "-o"]) + .arg(&library) + .arg(&source) + .output() + .expect("run C compiler"); + assert!( + output.status.success(), + "C compiler failed:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + + let path = CString::new(library.as_os_str().as_bytes()).expect("NUL-free dylib path"); + let handle = unsafe { libc::dlopen(path.as_ptr(), libc::RTLD_NOW | libc::RTLD_LOCAL) }; + assert!(!handle.is_null(), "dlopen failed"); + + // The old Linux loader inspected /proc/self/exe alone, so this exact + // map was invisible even though the generated frame was live in the + // process. Discovering and decoding it pins both the dl_iterate_phdr + // image walk and the per-image load-bias calculation. + let sections = loaded_stack_map_sections().expect("inspect every loaded image"); + assert!( + sections.iter().any(|section| section.starts_with(&map)), + "the later-loaded shared object's GC map was not discovered" + ); + let index = build_stack_map_index(); + assert!( + index.records.iter().any(|record| record.pc == 0x8075_0020), + "the later-loaded shared object's GC map was not indexed" + ); + drop(index); + drop(sections); + unsafe { libc::dlclose(handle) }; + } + + #[cfg(target_os = "linux")] + #[test] + fn rejects_an_unreadable_loaded_shared_object() { + use std::ffi::CString; + use std::os::unix::ffi::OsStrExt; + use std::process::Command; + + struct TempDir(std::path::PathBuf); + impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + let unique = format!( + "perry-unreadable-dylib-{}-{:?}", + std::process::id(), + std::thread::current().id() + ); + let temp = TempDir(std::env::temp_dir().join(unique)); + std::fs::create_dir(&temp.0).expect("create temporary dylib directory"); + let source = temp.0.join("unreadable.c"); + let library = temp.0.join("libunreadable.so"); + std::fs::write( + &source, + "int perry_unreadable_anchor(void) { return 8075; }\n", + ) + .expect("write dylib source"); + let compiler = std::env::var_os("CC").unwrap_or_else(|| "cc".into()); + let output = Command::new(compiler) + .args(["-shared", "-fPIC", "-o"]) + .arg(&library) + .arg(&source) + .output() + .expect("run C compiler"); + assert!( + output.status.success(), + "C compiler failed:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + + let path = CString::new(library.as_os_str().as_bytes()).expect("NUL-free dylib path"); + let handle = unsafe { libc::dlopen(path.as_ptr(), libc::RTLD_NOW | libc::RTLD_LOCAL) }; + assert!(!handle.is_null(), "dlopen failed"); + std::fs::remove_file(&library).expect("unlink loaded dylib"); + + let error = loaded_stack_map_sections().expect_err("unreadable image must fail closed"); + assert!( + error.contains("libunreadable.so"), + "diagnostic did not identify the unreadable image: {error}" + ); + + unsafe { libc::dlclose(handle) }; + } + #[test] fn repeated_live_sets_share_one_copy() { // Three safepoints, the last two repeating the first's live set: the diff --git a/scripts/gc_provider_dylib_gate.sh b/scripts/gc_provider_dylib_gate.sh new file mode 100755 index 0000000000..0fde936be2 --- /dev/null +++ b/scripts/gc_provider_dylib_gate.sh @@ -0,0 +1,172 @@ +#!/usr/bin/env bash +set -euo pipefail + +# #8075: exercise native stack-map roots when Perry's runtime and stdlib are +# process-wide providers and generated code lives only in a later-loaded app +# image. The fixture deliberately mirrors the reporter's host boundary: the +# app is a two-module, app-only dylib; full pressure is requested only after a +# completed invocation; and one dedicated Perry thread serializes both direct +# and concurrently queued callers. + +repo_root=$(cd "$(dirname "$0")/.." && pwd) +fixture="$repo_root/tests/fixtures/issue_8075_provider_gc" +profile=${PERRY_PROVIDER_GC_PROFILE:-perry-dev} +target_dir=${CARGO_TARGET_DIR:-$repo_root/target} +perry=${PERRY_BIN:-$target_dir/$profile/perry} +real_cc=$(command -v cc) +host_os=$(uname -s) +host_arch=$(uname -m) + +case "$host_os/$host_arch" in + Darwin/arm64|Darwin/x86_64) + library_extension=dylib + runtime_filename=libperry_runtime.dylib + stdlib_filename=libperry_stdlib.dylib + if [[ "$host_arch" == arm64 ]]; then + cargo_linker_env=CARGO_TARGET_AARCH64_APPLE_DARWIN_LINKER + else + cargo_linker_env=CARGO_TARGET_X86_64_APPLE_DARWIN_LINKER + fi + ;; + Linux/aarch64|Linux/x86_64) + library_extension=so + runtime_filename=libperry_runtime.so + stdlib_filename=libperry_stdlib.so + if [[ "$host_arch" == aarch64 ]]; then + cargo_linker_env=CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER + else + cargo_linker_env=CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER + fi + ;; + *) + echo "SKIP: #8075 provider GC gate does not support $host_os/$host_arch" + exit 0 + ;; +esac + +if [[ ! -x "$perry" ]]; then + echo "Perry compiler is missing: $perry" >&2 + echo "Build it with: cargo build --profile $profile -p perry" >&2 + exit 1 +fi + +scratch=$(mktemp -d "${TMPDIR:-/tmp}/perry-8075-provider.XXXXXX") +provider_source="$scratch/perry-provider-source" +provider_target_dir="$scratch/provider-target" +worktree_added=false +cleanup() { + if [[ "$worktree_added" == true ]]; then + git -C "$repo_root" worktree remove --force "$provider_source" >/dev/null 2>&1 || true + fi + rm -rf "$scratch" +} +trap cleanup EXIT INT TERM + +# The runtime crate normally emits only an rlib. Build the provider from a +# disposable worktree so changing its crate type cannot race with or dirty the +# checkout running the gate. HEAD is also the compiler/provider identity this +# integration contract requires. +git -C "$repo_root" worktree add --detach "$provider_source" HEAD >/dev/null +worktree_added=true +runtime_manifest="$provider_source/crates/perry-runtime/Cargo.toml" +runtime_manifest_backup="$scratch/perry-runtime.Cargo.toml" +cp "$runtime_manifest" "$runtime_manifest_backup" +perl -0pi -e 's/crate-type = \["rlib"\]/crate-type = ["dylib"]/ or die "runtime crate-type marker missing\n"' "$runtime_manifest" + +runtime_features="full,regex-engine,temporal,url-engine,string-normalize,intl-segmenter,intl-namespace,global-math,global-json,global-reflect,global-atomics,global-url,global-text,global-websocket,global-webcrypto,global-webfetch,proc-ipc,intl-locale,intl-datetime,diagnostics,mod-dgram,mod-http2-constants,mod-node-test,dyn-eval,keepalive-anchors,stdlib" +if [[ "$host_os" == Darwin ]]; then + CARGO_TARGET_DIR="$provider_target_dir" cargo rustc \ + --manifest-path "$provider_source/Cargo.toml" \ + --profile "$profile" -p perry-runtime \ + --no-default-features --features "$runtime_features" -- \ + -C 'link-arg=-Wl,-install_name,@rpath/libperry_runtime.dylib' \ + -C link-arg=-framework -C link-arg=CoreFoundation \ + -C link-arg=-framework -C link-arg=Foundation +else + CARGO_TARGET_DIR="$provider_target_dir" cargo rustc \ + --manifest-path "$provider_source/Cargo.toml" \ + --profile "$profile" -p perry-runtime \ + --no-default-features --features "$runtime_features" -- \ + -C 'link-arg=-Wl,-soname,libperry_runtime.so' +fi + +provider_dir="$scratch/providers" +mkdir -p "$provider_dir" +runtime_library="$provider_dir/$runtime_filename" +cp "$provider_target_dir/$profile/libperry_runtime.$library_extension" "$runtime_library" +cp "$runtime_manifest_backup" "$runtime_manifest" + +stdlib_manifest="$provider_source/tests/fixtures/issue_8075_provider_gc/stdlib-provider/Cargo.toml" +stdlib_linker="$provider_source/tests/fixtures/issue_8075_provider_gc/stdlib-linker.sh" +env \ + CARGO_TARGET_DIR="$provider_target_dir" \ + PERRY_ISSUE_8075_RUNTIME_LIBRARY="$runtime_library" \ + PERRY_ISSUE_8075_REAL_CC="$real_cc" \ + "$cargo_linker_env=$stdlib_linker" \ + cargo build --manifest-path "$stdlib_manifest" --profile provider + +stdlib_library="$provider_dir/$stdlib_filename" +cp "$provider_target_dir/provider/libissue_8075_stdlib.$library_extension" "$stdlib_library" +if [[ "$host_os" == Darwin ]]; then + install_name_tool -id '@rpath/libperry_runtime.dylib' "$runtime_library" + install_name_tool -id '@rpath/libperry_stdlib.dylib' "$stdlib_library" +else + readelf -d "$stdlib_library" | grep -Fq "Shared library: [$runtime_filename]" || { + echo "stdlib provider is not bound to the separate runtime provider" >&2 + exit 1 + } +fi + +app_link_dir="$scratch/app-linker" +mkdir -p "$app_link_dir" +ln -s "$provider_source/tests/fixtures/issue_8075_provider_gc/app-linker.sh" "$app_link_dir/cc" +app="$provider_dir/app.$library_extension" +env \ + PATH="$app_link_dir:$PATH" \ + PERRY_ISSUE_8075_REAL_CC="$real_cc" \ + PERRY_ISSUE_8075_RUNTIME_LIBRARY="$runtime_library" \ + PERRY_ISSUE_8075_STDLIB_LIBRARY="$stdlib_library" \ + PERRY_RS4GC=1 \ + PERRY_RUNTIME_DIR="$target_dir/$profile" \ + "$perry" compile \ + --no-codegen --no-auto-optimize --march generic \ + --output-type dylib -o "$app" "$fixture/perch_entry.ts" + +if [[ "$host_os" == Darwin ]]; then + dependencies=$(otool -L "$app") + grep -Fq '@rpath/libperry_runtime.dylib' <<<"$dependencies" || { + echo "app is not bound to the separate runtime provider" >&2 + exit 1 + } + grep -Fq '@rpath/libperry_stdlib.dylib' <<<"$dependencies" || { + echo "app is not bound to the separate stdlib provider" >&2 + exit 1 + } + load_commands=$(otool -l "$app") + grep -Fq 'sectname __perry_gcmap' <<<"$load_commands" || { + echo "app GC map section was stripped by the macOS linker" >&2 + exit 1 + } + symbols=$(nm -gU "$app" | awk 'NF >= 3 { symbol=$3; sub(/^_/, "", symbol); print symbol }') +else + dependencies=$(readelf -d "$app") + grep -Fq "Shared library: [$runtime_filename]" <<<"$dependencies" + grep -Fq "Shared library: [$stdlib_filename]" <<<"$dependencies" + symbols=$(nm -D --defined-only "$app" | awk 'NF >= 3 { print $3 }') + readelf -SW "$app" | grep -Fq '.perry_gcmap' +fi + +temporary_symbol=$(awk '/^__perry_wrap_perry_fn_.*__perchHttpEntry$/ { print; count++ } END { if (count != 1) exit 1 }' <<<"$symbols") +retained_symbol=$(awk '/^__perry_wrap_perry_fn_.*__perchRetainedEntry$/ { print; count++ } END { if (count != 1) exit 1 }' <<<"$symbols") + +host="$scratch/issue-8075-host" +rustc --edition 2021 -O "$fixture/host.rs" -o "$host" +if [[ "$host_os" == Darwin ]]; then + DYLD_LIBRARY_PATH="$provider_dir" "$host" \ + "$runtime_library" "$stdlib_library" "$app" \ + "$temporary_symbol" "$retained_symbol" +else + LD_LIBRARY_PATH="$provider_dir" "$host" \ + "$runtime_library" "$stdlib_library" "$app" \ + "$temporary_symbol" "$retained_symbol" +fi diff --git a/tests/fixtures/issue_8075_provider_gc/app-linker.sh b/tests/fixtures/issue_8075_provider_gc/app-linker.sh new file mode 100755 index 0000000000..7de784ca5f --- /dev/null +++ b/tests/fixtures/issue_8075_provider_gc/app-linker.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euo pipefail + +real_cc=${PERRY_ISSUE_8075_REAL_CC:-/usr/bin/cc} +runtime_library=${PERRY_ISSUE_8075_RUNTIME_LIBRARY:-} +stdlib_library=${PERRY_ISSUE_8075_STDLIB_LIBRARY:-} +is_shared=false +for argument in "$@"; do + case "$argument" in + -shared|-dynamiclib) is_shared=true ;; + esac +done + +if [[ "$is_shared" == true ]]; then + [[ -f "$runtime_library" ]] || { echo "runtime provider is missing" >&2; exit 1; } + [[ -f "$stdlib_library" ]] || { echo "stdlib provider is missing" >&2; exit 1; } + if [[ $(uname -s) == Darwin ]]; then + exec "$real_cc" "$@" \ + "$runtime_library" "$stdlib_library" \ + -Wl,-rpath,@loader_path -Wl,-dead_strip + fi + exec "$real_cc" "$@" \ + -Wl,--no-as-needed "$runtime_library" "$stdlib_library" -Wl,--as-needed \ + -Wl,--no-undefined -Wl,--gc-sections +fi + +exec "$real_cc" "$@" diff --git a/tests/fixtures/issue_8075_provider_gc/handlers/main.ts b/tests/fixtures/issue_8075_provider_gc/handlers/main.ts new file mode 100644 index 0000000000..8d638d1277 --- /dev/null +++ b/tests/fixtures/issue_8075_provider_gc/handlers/main.ts @@ -0,0 +1,39 @@ +const RETAINED_BODY = Buffer.from(JSON.stringify({ + runtime: "perry", + iterations: 100, + checksum: 3726872593, +})); + +function response(body: Buffer): Buffer { + const output = Buffer.alloc(5 + 2 + 4 + 4 + body.length); + output[0] = 0x50; + output[1] = 0x43; + output[2] = 0x48; + output[3] = 0x32; + output[4] = 2; + let offset = 5; + output.writeUInt16BE(200, offset); + offset += 2; + output.writeUInt32BE(0, offset); + offset += 4; + output.writeUInt32BE(body.length, offset); + offset += 4; + body.copy(output, offset); + return output; +} + +// The issue's temporary-response shape. JSON.stringify's fresh result must +// remain rooted through Buffer.from after a host-boundary full collection. +export function handle(_frame: Buffer): Buffer { + const body = Buffer.from(JSON.stringify({ + runtime: "perry", + iterations: 100, + checksum: 3726872593, + })); + return response(body); +} + +// Classification control: the body is a module root rather than a temporary. +export function handleRetained(_frame: Buffer): Buffer { + return response(RETAINED_BODY); +} diff --git a/tests/fixtures/issue_8075_provider_gc/host.rs b/tests/fixtures/issue_8075_provider_gc/host.rs new file mode 100644 index 0000000000..054693fe07 --- /dev/null +++ b/tests/fixtures/issue_8075_provider_gc/host.rs @@ -0,0 +1,354 @@ +use std::ffi::{c_char, c_int, c_void, CStr, CString}; +use std::sync::mpsc::{self, Receiver, SyncSender}; + +const POINTER_TAG: u64 = 0x7FFD_0000_0000_0000; +const POINTER_MASK: u64 = 0x0000_FFFF_FFFF_FFFF; +const CHECK_INTERVAL: usize = 256; +const GROWTH_FLOOR: u64 = 1024 * 1024; +const SERIAL_CALLS: usize = 16_384; +const CONCURRENT_CALLS: usize = 16_384; +const EXPECTED_BODY: &[u8] = br#"{"runtime":"perry","iterations":100,"checksum":3726872593}"#; + +#[cfg(target_os = "linux")] +const RTLD_GLOBAL: c_int = 0x100; +#[cfg(target_os = "macos")] +const RTLD_GLOBAL: c_int = 0x8; +#[cfg(target_os = "linux")] +const RTLD_LOCAL: c_int = 0; +#[cfg(target_os = "macos")] +const RTLD_LOCAL: c_int = 0x4; +const RTLD_NOW: c_int = 2; + +#[cfg_attr(target_os = "linux", link(name = "dl"))] +unsafe extern "C" { + fn dlopen(path: *const c_char, flags: c_int) -> *mut c_void; + fn dlsym(handle: *mut c_void, symbol: *const c_char) -> *mut c_void; + fn dlerror() -> *const c_char; +} + +type GcInit = unsafe extern "C" fn(); +type ModuleInit = unsafe extern "C" fn(); +type Handler = unsafe extern "C" fn(i64, f64) -> f64; +type BufferAlloc = unsafe extern "C" fn(i32, i32) -> *mut c_void; +type BufferData = unsafe extern "C" fn(f64) -> *mut u8; +type BufferLen = unsafe extern "C" fn(f64) -> usize; +type ArenaStats = unsafe extern "C" fn(*mut u64, *mut u64); +type MemoryPressure = unsafe extern "C" fn(u32) -> u32; +type RuntimeProbe = unsafe extern "C" fn() -> usize; + +#[derive(Clone, Copy)] +struct RuntimeApi { + gc_init: GcInit, + buffer_alloc: BufferAlloc, + buffer_data: BufferData, + buffer_len: BufferLen, + arena_stats: ArenaStats, + memory_pressure: MemoryPressure, +} + +#[derive(Clone, Copy)] +enum BodyKind { + Temporary, + Retained, +} + +enum Command { + Invoke { + kind: BodyKind, + reply: mpsc::Sender>, + }, + Shutdown(mpsc::Sender>), +} + +#[derive(Debug)] +struct RunStats { + calls: usize, + temporary_calls: usize, + retained_calls: usize, + full_collections: usize, + reclaimed_bytes: u64, + post_collection_live: Vec, +} + +fn dynamic_error(context: &str) -> String { + let detail = unsafe { + let error = dlerror(); + if error.is_null() { + "unknown loader error".into() + } else { + CStr::from_ptr(error).to_string_lossy().into_owned() + } + }; + format!("{context}: {detail}") +} + +fn open(path: &str, flags: c_int) -> Result { + let path = CString::new(path).map_err(|_| format!("NUL in library path {path:?}"))?; + let handle = unsafe { dlopen(path.as_ptr(), flags) }; + if handle.is_null() { + Err(dynamic_error("dlopen failed")) + } else { + Ok(handle as usize) + } +} + +unsafe fn symbol(handle: usize, name: &str) -> Result { + let name = CString::new(name).map_err(|_| format!("NUL in symbol {name:?}"))?; + let pointer = dlsym(handle as *mut c_void, name.as_ptr()); + if pointer.is_null() { + return Err(dynamic_error("dlsym failed")); + } + Ok(std::mem::transmute_copy::<*mut c_void, T>(&pointer)) +} + +fn arena_stats(api: RuntimeApi) -> (u64, u64) { + let mut live = 0; + let mut reserved = 0; + unsafe { (api.arena_stats)(&mut live, &mut reserved) }; + (live, reserved) +} + +fn input_buffer(api: RuntimeApi) -> Result { + const INPUT: &[u8] = b"PCH2\x02\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"; + let header = unsafe { (api.buffer_alloc)(INPUT.len() as i32, 0) }; + if header.is_null() { + return Err("input Buffer allocation failed".into()); + } + let value = f64::from_bits(POINTER_TAG | (header as u64 & POINTER_MASK)); + let data = unsafe { (api.buffer_data)(value) }; + if data.is_null() { + return Err("input Buffer has no data".into()); + } + unsafe { std::ptr::copy_nonoverlapping(INPUT.as_ptr(), data, INPUT.len()) }; + Ok(value) +} + +fn validate_frame(api: RuntimeApi, handler: Handler, invocation: usize) -> Result<(), String> { + let argument = input_buffer(api)?; + let result = unsafe { handler(0, argument) }; + let data = unsafe { (api.buffer_data)(result) }; + let length = unsafe { (api.buffer_len)(result) }; + if data.is_null() { + return Err(format!("invocation {invocation} returned a non-Buffer")); + } + let frame = unsafe { std::slice::from_raw_parts(data, length) }; + if frame.len() != 15 + EXPECTED_BODY.len() { + return Err(format!( + "invocation {invocation}: frame length {}, expected {}", + frame.len(), + 15 + EXPECTED_BODY.len() + )); + } + if &frame[..5] != b"PCH2\x02" { + return Err(format!("invocation {invocation}: invalid PCH2 prefix")); + } + if u16::from_be_bytes([frame[5], frame[6]]) != 200 { + return Err(format!("invocation {invocation}: status is not 200")); + } + if u32::from_be_bytes(frame[7..11].try_into().unwrap()) != 0 { + return Err(format!("invocation {invocation}: headers are not empty")); + } + let body_len = u32::from_be_bytes(frame[11..15].try_into().unwrap()) as usize; + if body_len != EXPECTED_BODY.len() || &frame[15..] != EXPECTED_BODY { + return Err(format!( + "invocation {invocation}: corrupt body {:?}", + String::from_utf8_lossy(&frame[15..]) + )); + } + Ok(()) +} + +fn finish(stats: RunStats) -> Result { + if stats.calls < 20_000 { + return Err(format!("only {} invocations ran", stats.calls)); + } + if stats.temporary_calls == 0 || stats.retained_calls == 0 { + return Err("temporary and retained Buffer variants did not both run".into()); + } + if stats.full_collections < 10 { + return Err(format!( + "only {} host-boundary full collections completed", + stats.full_collections + )); + } + if stats.reclaimed_bytes < 10 * GROWTH_FLOOR { + return Err(format!( + "dead temporary buffers reclaimed only {} bytes", + stats.reclaimed_bytes + )); + } + let second_half = &stats.post_collection_live[stats.post_collection_live.len() / 2..]; + let first = second_half.first().copied().unwrap_or(0); + let last = second_half.last().copied().unwrap_or(0); + if last.saturating_sub(first) > GROWTH_FLOOR / 4 { + return Err(format!( + "second-half live arena is not flat: first={first}, last={last}" + )); + } + Ok(stats) +} + +fn executor( + receiver: Receiver, + api: RuntimeApi, + module_init: ModuleInit, + temporary: Handler, + retained: Handler, +) { + unsafe { + (api.gc_init)(); + module_init(); + } + let mut stats = RunStats { + calls: 0, + temporary_calls: 0, + retained_calls: 0, + full_collections: 0, + reclaimed_bytes: 0, + post_collection_live: Vec::new(), + }; + let mut live_baseline = arena_stats(api).0; + + while let Ok(command) = receiver.recv() { + match command { + Command::Invoke { kind, reply } => { + stats.calls += 1; + let handler = match kind { + BodyKind::Temporary => { + stats.temporary_calls += 1; + temporary + } + BodyKind::Retained => { + stats.retained_calls += 1; + retained + } + }; + let result = validate_frame(api, handler, stats.calls); + if result.is_ok() && stats.calls.is_multiple_of(CHECK_INTERVAL) { + let before = arena_stats(api).0; + if before.saturating_sub(live_baseline) >= GROWTH_FLOOR { + let collected = unsafe { (api.memory_pressure)(2) }; + if collected == 2 { + let after = arena_stats(api).0; + stats.full_collections += 1; + stats.reclaimed_bytes += before.saturating_sub(after); + stats.post_collection_live.push(after); + live_baseline = after; + } + } + } + let _ = reply.send(result); + } + Command::Shutdown(reply) => { + let _ = reply.send(finish(stats)); + break; + } + } + } +} + +fn invoke(sender: &SyncSender, kind: BodyKind) -> Result<(), String> { + let (reply, receive) = mpsc::channel(); + sender + .send(Command::Invoke { kind, reply }) + .map_err(|_| "executor stopped".to_string())?; + receive + .recv() + .map_err(|_| "executor dropped invocation reply".to_string())? +} + +fn main() -> Result<(), String> { + let arguments: Vec = std::env::args().collect(); + if arguments.len() != 6 { + return Err("usage: host runtime stdlib app temporary-symbol retained-symbol".into()); + } + let runtime = open(&arguments[1], RTLD_NOW | RTLD_GLOBAL)?; + let stdlib = open(&arguments[2], RTLD_NOW | RTLD_GLOBAL)?; + let app = open(&arguments[3], RTLD_NOW | RTLD_LOCAL)?; + let api = unsafe { + RuntimeApi { + gc_init: symbol(runtime, "js_gc_init")?, + buffer_alloc: symbol(runtime, "js_buffer_alloc")?, + buffer_data: symbol(runtime, "js_native_buffer_data_ptr")?, + buffer_len: symbol(runtime, "js_native_buffer_byte_len")?, + arena_stats: symbol(runtime, "js_arena_stats")?, + memory_pressure: symbol(runtime, "js_gc_memory_pressure")?, + } + }; + let probe: RuntimeProbe = unsafe { symbol(stdlib, "issue_8075_stdlib_runtime_probe")? }; + if unsafe { probe() } != api.gc_init as usize { + return Err("stdlib provider is bound to a different runtime image".into()); + } + let module_init: ModuleInit = unsafe { symbol(app, "perry_module_init")? }; + let temporary: Handler = unsafe { symbol(app, &arguments[4])? }; + let retained: Handler = unsafe { symbol(app, &arguments[5])? }; + + let (sender, receiver) = mpsc::sync_channel(256); + let executor_thread = + std::thread::spawn(move || executor(receiver, api, module_init, temporary, retained)); + + for invocation in 0..SERIAL_CALLS { + let kind = if invocation.is_multiple_of(257) { + BodyKind::Retained + } else { + BodyKind::Temporary + }; + invoke(&sender, kind)?; + } + + let mut producers = Vec::new(); + for producer in 0..4 { + let sender = sender.clone(); + producers.push(std::thread::spawn(move || -> Result<(), String> { + let calls = CONCURRENT_CALLS / 4; + for batch in (0..calls).step_by(32) { + let mut replies = Vec::new(); + for offset in 0..32.min(calls - batch) { + let invocation = producer * calls + batch + offset; + let kind = if invocation.is_multiple_of(257) { + BodyKind::Retained + } else { + BodyKind::Temporary + }; + let (reply, receive) = mpsc::channel(); + sender + .send(Command::Invoke { kind, reply }) + .map_err(|_| "executor stopped during concurrent phase".to_string())?; + replies.push(receive); + } + for reply in replies { + reply + .recv() + .map_err(|_| "executor dropped a concurrent reply".to_string())??; + } + } + Ok(()) + })); + } + for producer in producers { + producer + .join() + .map_err(|_| "concurrent producer panicked".to_string())??; + } + + let (reply, receive) = mpsc::channel(); + sender + .send(Command::Shutdown(reply)) + .map_err(|_| "executor stopped before shutdown".to_string())?; + let stats = receive + .recv() + .map_err(|_| "executor dropped shutdown report".to_string())??; + executor_thread + .join() + .map_err(|_| "executor panicked".to_string())?; + println!( + "issue-8075 provider GC gate passed: calls={} temporary={} retained={} full_collections={} reclaimed_bytes={} second_half_live={:?}", + stats.calls, + stats.temporary_calls, + stats.retained_calls, + stats.full_collections, + stats.reclaimed_bytes, + &stats.post_collection_live[stats.post_collection_live.len() / 2..] + ); + Ok(()) +} diff --git a/tests/fixtures/issue_8075_provider_gc/perch_entry.ts b/tests/fixtures/issue_8075_provider_gc/perch_entry.ts new file mode 100644 index 0000000000..2deb838ae5 --- /dev/null +++ b/tests/fixtures/issue_8075_provider_gc/perch_entry.ts @@ -0,0 +1,12 @@ +import { + handle as perchHttpHandler, + handleRetained as perchRetainedHandler, +} from "./handlers/main"; + +export function perchHttpEntry(frame: Buffer): any { + return perchHttpHandler(frame); +} + +export function perchRetainedEntry(frame: Buffer): any { + return perchRetainedHandler(frame); +} diff --git a/tests/fixtures/issue_8075_provider_gc/stdlib-linker.sh b/tests/fixtures/issue_8075_provider_gc/stdlib-linker.sh new file mode 100755 index 0000000000..96b5812ff8 --- /dev/null +++ b/tests/fixtures/issue_8075_provider_gc/stdlib-linker.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +set -euo pipefail + +runtime_library=${PERRY_ISSUE_8075_RUNTIME_LIBRARY:?set the runtime provider path} +real_cc=${PERRY_ISSUE_8075_REAL_CC:-/usr/bin/cc} +host_os=$(uname -s) +arguments=() +skip_export_list_value=false +original_export_list="" +saw_runtime_rlib=false + +for argument in "$@"; do + if [[ "$skip_export_list_value" == true ]]; then + original_export_list=${argument#-Wl,} + skip_export_list_value=false + continue + fi + case "$argument" in + *libperry_runtime-*.rlib) + saw_runtime_rlib=true + if [[ "$host_os" == Linux ]]; then + arguments+=( + '-Wl,-Bdynamic' '-Wl,--no-as-needed' "$runtime_library" + '-Wl,--as-needed' '-Wl,-Bstatic' "$argument" + ) + else + arguments+=("$runtime_library" "$argument") + fi + ;; + -Wl,-exported_symbols_list) + skip_export_list_value=true + ;; + -Wl,-exported_symbols_list,*) + original_export_list=${argument#-Wl,-exported_symbols_list,} + ;; + *) arguments+=("$argument") ;; + esac +done + +custom_export_list="" +cleanup() { + [[ -z "$custom_export_list" ]] || rm -f "$custom_export_list" +} +trap cleanup EXIT + +if [[ -n "$original_export_list" ]]; then + if [[ "$saw_runtime_rlib" == true ]]; then + custom_export_list=$(mktemp "${TMPDIR:-/tmp}/perry-8075-exports.XXXXXX") + { + sed -n '/issue_8075_stdlib_runtime_probe/p' "$original_export_list" + nm -gU "$runtime_library" | awk 'NF >= 3 { print $3 }' + } | sort -u > "$custom_export_list" + arguments+=('-Wl,-exported_symbols_list' "-Wl,$custom_export_list") + else + arguments+=('-Wl,-exported_symbols_list' "-Wl,$original_export_list") + fi +fi + +if [[ "$saw_runtime_rlib" == true && "$host_os" == Darwin ]]; then + arguments+=('-Wl,-rpath,@loader_path' '-Wl,-flat_namespace' '-Wl,-interposable') +elif [[ "$saw_runtime_rlib" == true ]]; then + # shellcheck disable=SC2016 # $ORIGIN must reach the ELF linker literally. + arguments+=('-Wl,-rpath,$ORIGIN' '-Wl,-soname,libperry_stdlib.so') +fi + +"$real_cc" "${arguments[@]}" diff --git a/tests/fixtures/issue_8075_provider_gc/stdlib-provider/Cargo.toml b/tests/fixtures/issue_8075_provider_gc/stdlib-provider/Cargo.toml new file mode 100644 index 0000000000..78590b9b12 --- /dev/null +++ b/tests/fixtures/issue_8075_provider_gc/stdlib-provider/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "issue-8075-stdlib-provider" +version = "0.0.0" +edition = "2021" +publish = false + +[lib] +name = "issue_8075_stdlib" +crate-type = ["cdylib"] + +[dependencies] +perry-stdlib = { path = "../../../../crates/perry-stdlib", default-features = false, features = ["full"] } + +[profile.provider] +inherits = "release" +opt-level = 2 +lto = false +codegen-units = 16 +panic = "abort" +strip = false + +[workspace] diff --git a/tests/fixtures/issue_8075_provider_gc/stdlib-provider/src/lib.rs b/tests/fixtures/issue_8075_provider_gc/stdlib-provider/src/lib.rs new file mode 100644 index 0000000000..78e456ff6c --- /dev/null +++ b/tests/fixtures/issue_8075_provider_gc/stdlib-provider/src/lib.rs @@ -0,0 +1,20 @@ +//! Minimal test wrapper for Perry's separately loaded stdlib provider. +//! +//! The custom final-link driver binds its runtime calls to the process-wide +//! runtime dylib before leaving the rlib available for Rust generic glue. + +extern crate perry_stdlib; + +unsafe extern "C" { + fn js_gc_init(); +} + +#[used] +static PIN_STDLIB: extern "C" fn() -> i32 = perry_stdlib::common::js_stdlib_process_pending; + +/// Proves that the stdlib resolves stateful runtime calls to the provider the +/// host loaded first, rather than embedding a second GC/runtime image. +#[no_mangle] +pub extern "C" fn issue_8075_stdlib_runtime_probe() -> usize { + js_gc_init as *const () as usize +}