From 15122b8204b8d266326c395491e80c63c7a5712c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 25 Jun 2026 18:57:28 +0200 Subject: [PATCH 1/2] fix(compile): generalize #5000 stdlib-stub localization to macOS/Linux + PERRY_LINK_MAP The standalone perry-runtime archive (built without the stdlib feature) defines no-op stdlib_stubs (js_fetch_with_options/js_headers_new/js_request_new/js_ws_*/ js_readline_*) which, linked alongside perry-stdlib's real impls, can shadow them (first-definition-wins) -> fetch() no-ops. #5000 fixed this on Windows only; generalize to macOS/Linux via llvm-objcopy --localize-symbol (nightly tool for Mach-O), same STDLIB_STUB_SYMBOLS + perry-stdlib cross-check, gated on uses_fetch/needs_stdlib, non-fatal. Adds opt-in PERRY_LINK_MAP= linker map. --- crates/perry/src/commands/compile.rs | 4 +- .../commands/compile/link/build_and_run.rs | 21 ++- crates/perry/src/commands/compile/link/mod.rs | 6 +- .../perry/src/commands/compile/strip_dedup.rs | 160 ++++++++++++++++++ 4 files changed, 185 insertions(+), 6 deletions(-) diff --git a/crates/perry/src/commands/compile.rs b/crates/perry/src/commands/compile.rs index 0144d58c3d..52071916e9 100644 --- a/crates/perry/src/commands/compile.rs +++ b/crates/perry/src/commands/compile.rs @@ -98,8 +98,8 @@ use resolve::{ }; use strip_dedup::{ dedup_native_lib_for_tier3, dedup_runtime_for_tier3, dedup_stdlib_for_tier3, - localize_stdlib_stub_symbols_for_windows, strip_duplicate_objects_from_lib, - strip_duplicate_objects_from_well_known_lib, + localize_stdlib_stub_symbols, localize_stdlib_stub_symbols_for_windows, + strip_duplicate_objects_from_lib, strip_duplicate_objects_from_well_known_lib, }; use targets::{ apple_sdk_version, find_visionos_swift_runtime, find_watchos_swift_runtime, diff --git a/crates/perry/src/commands/compile/link/build_and_run.rs b/crates/perry/src/commands/compile/link/build_and_run.rs index cf7bdb3cde..6fdc1ededb 100644 --- a/crates/perry/src/commands/compile/link/build_and_run.rs +++ b/crates/perry/src/commands/compile/link/build_and_run.rs @@ -249,6 +249,11 @@ pub(crate) fn build_and_run_link( } else { // Native macOS/iOS via clang driver cmd.arg("-Wl,-dead_strip"); + // PERRY_LINK_MAP= — emit a linker map (which archive each + // symbol resolves from) for diagnosing dup-symbol / shadowing bugs. + if let Some(map) = std::env::var_os("PERRY_LINK_MAP") { + cmd.arg(format!("-Wl,-map,{}", map.to_string_lossy())); + } } } else { // MSVC link.exe / lld-link equivalents: @@ -367,7 +372,21 @@ pub(crate) fn build_and_run_link( // Also link runtime for symbols DCE'd from stdlib's bundled // perry-runtime; on tier-3 it's first stripped of stdlib's objects. if !is_android && !is_windows { - cmd.arg(dedup_runtime_for_tier3(target, runtime_lib, stdlib)); + // #5000 (macOS/Linux): the standalone runtime archive is built + // WITHOUT the `stdlib` feature, so it also defines the no-op + // stdlib_stubs (js_fetch_with_options, js_headers_new, + // js_request_new, js_ws_*, js_readline_*). With ELF/Mach-O + // first-definition-wins those stubs can satisfy the user's + // fetch ref before perry-stdlib's real impls, so `fetch()` + // no-ops and a program awaiting it hangs. When this build + // actually uses stdlib (fetch / ws / readline), localize those + // stub symbols in a copy of the runtime so perry-stdlib wins. + let runtime_for_link = if ctx.uses_fetch || ctx.needs_stdlib { + localize_stdlib_stub_symbols(runtime_lib, stdlib) + } else { + runtime_lib.to_path_buf() + }; + cmd.arg(dedup_runtime_for_tier3(target, &runtime_for_link, stdlib)); } } else { if ctx.needs_stdlib { diff --git a/crates/perry/src/commands/compile/link/mod.rs b/crates/perry/src/commands/compile/link/mod.rs index 1433b620d2..8b61fd6a0e 100644 --- a/crates/perry/src/commands/compile/link/mod.rs +++ b/crates/perry/src/commands/compile/link/mod.rs @@ -36,9 +36,9 @@ use super::{ find_geisterhand_stdlib, find_geisterhand_ui, find_lld_link, find_llvm_tool, find_msvc_lib_paths, find_msvc_link_exe, find_perry_windows_sdk, find_stdlib_library, find_ui_library, find_visionos_swift_runtime, find_watchos_swift_runtime, - localize_stdlib_stub_symbols_for_windows, rust_target_triple, strip_duplicate_objects_from_lib, - strip_duplicate_objects_from_well_known_lib, windows_pe_subsystem_flag, - windows_subsystem_needs_ui, CompilationContext, + localize_stdlib_stub_symbols, localize_stdlib_stub_symbols_for_windows, rust_target_triple, + strip_duplicate_objects_from_lib, strip_duplicate_objects_from_well_known_lib, + windows_pe_subsystem_flag, windows_subsystem_needs_ui, CompilationContext, }; mod build_and_run; diff --git a/crates/perry/src/commands/compile/strip_dedup.rs b/crates/perry/src/commands/compile/strip_dedup.rs index 1b5474ef67..b31723b624 100644 --- a/crates/perry/src/commands/compile/strip_dedup.rs +++ b/crates/perry/src/commands/compile/strip_dedup.rs @@ -999,6 +999,166 @@ fn try_localize_stdlib_stub_symbols(runtime_lib: &Path, stdlib_lib: &Path) -> Re Ok(trimmed_lib) } +/// macOS/Linux (#5000) equivalent of [`localize_stdlib_stub_symbols_for_windows`]. +/// +/// The prebuilt standalone `libperry_runtime.a` is built WITHOUT the `stdlib` +/// Cargo feature, so it defines the no-op `stdlib_stubs` symbols. On the +/// macOS/Linux link line it is linked alongside the auto-optimized perry-stdlib +/// (which carries the REAL `js_fetch_with_options` / `js_headers_new` / `js_ws_*` +/// / `js_readline_*`), and with archive first-definition-wins the runtime stub +/// can satisfy the user's fetch reference first — so `fetch()` silently no-ops +/// (`[perry] warning: js_headers_new is a no-op stub`) and a program awaiting the +/// fetch hangs. Unlike COFF, ELF/Mach-O accept `--localize-symbol`, so localize +/// (global→local) exactly those stub symbols the runtime defines AND perry-stdlib +/// also provides; the now-local stub no longer satisfies the external reference, +/// the linker resolves it from perry-stdlib, and `-dead_strip`/`--gc-sections` +/// drops the unreferenced stub body. The stdlib cross-check guarantees a +/// runtime-only symbol is never localized. Best-effort: returns `runtime_lib` +/// unchanged on any failure, preserving pre-fix behavior. +pub(super) fn localize_stdlib_stub_symbols(runtime_lib: &Path, stdlib_lib: &Path) -> PathBuf { + match try_localize_stdlib_stub_symbols_unix(runtime_lib, stdlib_lib) { + Ok(p) => p, + Err(e) => { + eprintln!("[strip-dedup] runtime stdlib-stub localize skipped (non-fatal): {e}"); + runtime_lib.to_path_buf() + } + } +} + +fn try_localize_stdlib_stub_symbols_unix(runtime_lib: &Path, stdlib_lib: &Path) -> Result { + let lib_name = runtime_lib + .file_name() + .and_then(|f| f.to_str()) + .unwrap_or("libperry_runtime.a"); + + // Mach-O requires a matched-LLVM `llvm-objcopy` for `--localize-symbol` + // (a mismatched one rejects it on Mach-O / `llvm-nm` mis-reads nightly + // bitcode), so prefer the nightly toolchain tool, mirroring + // `strip_duplicate_objects_from_well_known_lib`. + let llvm_ar = find_llvm_tool("llvm-ar") + .or_else(|| find_path_tool("ar")) + .ok_or_else(|| anyhow::anyhow!("llvm-ar not found"))?; + let objcopy = find_nightly_llvm_tool("llvm-objcopy") + .or_else(|| find_llvm_tool("llvm-objcopy")) + .or_else(|| find_path_tool("objcopy")) + .ok_or_else(|| anyhow::anyhow!("llvm-objcopy not found"))?; + let nm = find_nightly_llvm_tool("llvm-nm") + .or_else(|| find_llvm_tool("llvm-nm")) + .or_else(|| find_path_tool("nm")) + .ok_or_else(|| anyhow::anyhow!("llvm-nm not found"))?; + + let abs_runtime = std::fs::canonicalize(runtime_lib)?; + let abs_stdlib = std::fs::canonicalize(stdlib_lib)?; + + let stub_set: std::collections::HashSet<&str> = STDLIB_STUB_SYMBOLS.iter().copied().collect(); + + let runtime_member_syms = collect_archive_symbols_by_member(&nm, &abs_runtime) + .ok_or_else(|| anyhow::anyhow!("failed to inspect {lib_name} symbols"))?; + let mut candidates: std::collections::BTreeMap> = + std::collections::BTreeMap::new(); + for (member, syms) in &runtime_member_syms { + let hits: Vec = syms + .iter() + .filter(|s| stub_set.contains(s.as_str())) + .cloned() + .collect(); + if !hits.is_empty() { + candidates.insert(member.clone(), hits); + } + } + if candidates.is_empty() { + // Runtime built without the stubs (e.g. `stdlib` feature on). + return Ok(runtime_lib.to_path_buf()); + } + + // Cross-check against perry-stdlib: only localize a stub the real stdlib also + // provides, so we never turn a runtime-only symbol into an undefined ref. + let stdlib_syms = collect_archive_symbols_flat(&nm, &abs_stdlib); + if stdlib_syms.is_empty() { + return Err(anyhow::anyhow!( + "llvm-nm reported no symbols for {}", + abs_stdlib.display() + )); + } + let mut to_localize: std::collections::BTreeMap> = + std::collections::BTreeMap::new(); + for (member, hits) in candidates { + let mut kept: Vec = hits + .into_iter() + .filter(|s| stdlib_syms.contains(s)) + .collect(); + if !kept.is_empty() { + kept.sort(); + kept.dedup(); + to_localize.insert(member, kept); + } + } + if to_localize.is_empty() { + return Ok(runtime_lib.to_path_buf()); + } + + let tmp_base = std::env::temp_dir().join(format!("perry_strip_{}", std::process::id())); + std::fs::create_dir_all(&tmp_base).ok(); + let extract_dir = tmp_base.join(format!("_{lib_name}_stub_localize_extract")); + let _ = std::fs::remove_dir_all(&extract_dir); + std::fs::create_dir_all(&extract_dir)?; + let trimmed_lib = tmp_base.join(format!("_{lib_name}_stub_localized.a")); + let _ = std::fs::remove_file(&trimmed_lib); + std::fs::copy(&abs_runtime, &trimmed_lib)?; + + let mut localized = 0usize; + for (member, symbols) in &to_localize { + let extract_out = Command::new(&llvm_ar) + .arg("x") + .arg(&abs_runtime) + .arg(member) + .current_dir(&extract_dir) + .output()?; + if !extract_out.status.success() { + let stderr = String::from_utf8_lossy(&extract_out.stderr); + return Err(anyhow::anyhow!("failed to extract {member}: {stderr}")); + } + let member_path = extract_dir.join(member); + if !member_path.exists() { + continue; + } + let mut objcopy_cmd = Command::new(&objcopy); + for symbol in symbols { + objcopy_cmd.arg("--localize-symbol").arg(symbol); + } + let out = objcopy_cmd.arg(&member_path).output()?; + if !out.status.success() { + let stderr = String::from_utf8_lossy(&out.stderr); + return Err(anyhow::anyhow!( + "failed to localize stub symbols in {member}: {stderr}" + )); + } + let replace_out = Command::new(&llvm_ar) + .arg("r") + .arg(&trimmed_lib) + .arg(&member_path) + .output()?; + if !replace_out.status.success() { + let stderr = String::from_utf8_lossy(&replace_out.stderr); + return Err(anyhow::anyhow!("failed to splice {member}: {stderr}")); + } + localized += symbols.len(); + } + + let index_out = Command::new(&llvm_ar).arg("s").arg(&trimmed_lib).output()?; + if !index_out.status.success() { + let stderr = String::from_utf8_lossy(&index_out.stderr); + return Err(anyhow::anyhow!("failed to reindex {lib_name}: {stderr}")); + } + + eprintln!( + "[strip-dedup] {lib_name}: localized {localized} stdlib-stub symbol(s) \ + so perry-stdlib wins the link (#5000, macOS/Linux)" + ); + let _ = std::fs::remove_dir_all(&extract_dir); + Ok(trimmed_lib) +} + /// Tier-3 (tvOS/watchOS, no prebuilt std): perry-stdlib is built with /// `-Zbuild-std` and bundles its own copy of std's allocator/panic runtime /// shims, which duplicate the ones in runtime_lib (the canonical provider) → From 2b852dbd81bc16b14ef84e0c6ebc26c4cad7950d Mon Sep 17 00:00:00 2001 From: Ralph Date: Thu, 25 Jun 2026 11:38:13 -0700 Subject: [PATCH 2/2] fix(compile): address CodeRabbit review on #5685 - PERRY_LINK_MAP: honor it on every non-Windows linker, not just native macOS. Emit GNU ld `-Map` on ELF (Linux/Android/HarmonyOS) and ld64 `-map` on the Apple cross/native/watch paths. - Linux GTK4 UI: localize runtime stub symbols in the runtime-only path when a bare UI build (ctx.needs_stdlib == false) will later force-link perry-stdlib, so the real js_stdlib_process_pending wins over the no-op runtime stub instead of being shadowed by first-definition-wins. - stdlib-stub localize: treat a missing extracted member after a "successful" `llvm-ar x` as a failure (fall back to the untouched runtime) instead of silently returning a half-localized archive. Fixed in both the Unix and the original Windows path. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../commands/compile/link/build_and_run.rs | 47 ++++++++++++++++--- .../perry/src/commands/compile/strip_dedup.rs | 16 ++++++- 2 files changed, 55 insertions(+), 8 deletions(-) diff --git a/crates/perry/src/commands/compile/link/build_and_run.rs b/crates/perry/src/commands/compile/link/build_and_run.rs index 6fdc1ededb..fdbedd2abd 100644 --- a/crates/perry/src/commands/compile/link/build_and_run.rs +++ b/crates/perry/src/commands/compile/link/build_and_run.rs @@ -249,10 +249,25 @@ pub(crate) fn build_and_run_link( } else { // Native macOS/iOS via clang driver cmd.arg("-Wl,-dead_strip"); - // PERRY_LINK_MAP= — emit a linker map (which archive each - // symbol resolves from) for diagnosing dup-symbol / shadowing bugs. - if let Some(map) = std::env::var_os("PERRY_LINK_MAP") { - cmd.arg(format!("-Wl,-map,{}", map.to_string_lossy())); + } + // PERRY_LINK_MAP= — emit a linker map (which archive each symbol + // resolves from) for diagnosing dup-symbol / shadowing bugs. Honor it on + // every non-Windows linker, not just native macOS. GNU ld (ELF) spells + // it `-Map=`; ld64 (Apple) spells it `-map `. The + // cross-Apple linkers are driven directly (no `-Wl,` prefix). + if let Some(map) = std::env::var_os("PERRY_LINK_MAP") { + let map = map.to_string_lossy(); + if is_android || is_linux || is_harmonyos { + cmd.arg(format!("-Wl,-Map,{map}")); + } else if is_cross_ios || is_cross_visionos || is_cross_macos || is_cross_tvos { + cmd.arg("-map").arg(map.as_ref()); + } else if is_watchos || is_visionos { + cmd.arg("-Xlinker") + .arg("-map") + .arg("-Xlinker") + .arg(map.as_ref()); + } else { + cmd.arg(format!("-Wl,-map,{map}")); } } } else { @@ -402,8 +417,28 @@ pub(crate) fn build_and_run_link( cmd.arg(runtime_lib); } } else { - // Runtime-only linking — no stdlib needed - cmd.arg(runtime_lib); + // Runtime-only linking — no stdlib needed. + // + // #5000 (Linux GTK4 UI): a bare UI program has + // `ctx.needs_stdlib == false`, so perry-stdlib isn't linked above — + // but the GTK4 UI branch below force-links it with + // `--whole-archive --allow-multiple-definition` to satisfy glib + // trampolines that call js_stdlib_process_pending / + // js_promise_run_microtasks. With first-definition-wins, the + // unlocalized runtime stubs linked here would shadow stdlib's real + // impls, leaving those pumps as no-ops. Localize the runtime's stub + // symbols first so stdlib wins, mirroring the needs_stdlib path. + let force_stdlib_for_linux_ui = + is_linux && ctx.needs_ui && find_ui_library(target).is_some(); + let runtime_for_link = if force_stdlib_for_linux_ui { + match stdlib_lib.clone().or_else(|| find_stdlib_library(target)) { + Some(stdlib) => localize_stdlib_stub_symbols(runtime_lib, &stdlib), + None => runtime_lib.to_path_buf(), + } + } else { + runtime_lib.to_path_buf() + }; + cmd.arg(&runtime_for_link); } } else if ctx.needs_stdlib { // Android + UI: runtime is provided by UI lib, but stdlib must still be linked diff --git a/crates/perry/src/commands/compile/strip_dedup.rs b/crates/perry/src/commands/compile/strip_dedup.rs index b31723b624..fcf82fb8fa 100644 --- a/crates/perry/src/commands/compile/strip_dedup.rs +++ b/crates/perry/src/commands/compile/strip_dedup.rs @@ -958,7 +958,13 @@ fn try_localize_stdlib_stub_symbols(runtime_lib: &Path, stdlib_lib: &Path) -> Re } let member_path = extract_dir.join(member); if !member_path.exists() { - continue; + // `llvm-ar x` returned success but produced no file (e.g. a member + // name that doesn't round-trip as a path). Don't silently skip: that + // would return a "localized" archive with this member's stubs still + // global. Fail so the caller falls back to the untouched runtime. + return Err(anyhow::anyhow!( + "failed to extract {member}: member file was not created" + )); } let mut objcopy_cmd = Command::new(&objcopy); for symbol in symbols { @@ -1120,7 +1126,13 @@ fn try_localize_stdlib_stub_symbols_unix(runtime_lib: &Path, stdlib_lib: &Path) } let member_path = extract_dir.join(member); if !member_path.exists() { - continue; + // `llvm-ar x` returned success but produced no file (e.g. a member + // name that doesn't round-trip as a path). Don't silently skip: that + // would return a "localized" archive with this member's stubs still + // global. Fail so the caller falls back to the untouched runtime. + return Err(anyhow::anyhow!( + "failed to extract {member}: member file was not created" + )); } let mut objcopy_cmd = Command::new(&objcopy); for symbol in symbols {