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..fdbedd2abd 100644 --- a/crates/perry/src/commands/compile/link/build_and_run.rs +++ b/crates/perry/src/commands/compile/link/build_and_run.rs @@ -250,6 +250,26 @@ pub(crate) fn build_and_run_link( // 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. 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 { // MSVC link.exe / lld-link equivalents: // /OPT:REF — drop unreferenced functions/data (= --gc-sections) @@ -367,7 +387,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 { @@ -383,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/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..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 { @@ -999,6 +1005,172 @@ 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() { + // `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 { + 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) →