From 56c297884f09415f816bf07289c1b93bac7b3430 Mon Sep 17 00:00:00 2001 From: Ralph Date: Sat, 18 Jul 2026 13:02:31 -0700 Subject: [PATCH 1/2] =?UTF-8?q?fix(compile):=20#5779=20=E2=80=94=20discove?= =?UTF-8?q?r=20a=20bitcode-compatible=20LLVM=20tool=20so=20the=20runtime-d?= =?UTF-8?q?edup=20strip=20runs=20on=20macOS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Perry process that is both a `node:http` server and a `fetch` client can hang forever on macOS: the in-process loopback fetch never settles. The root cause is not a reqwest lost-wakeup (the original hypothesis, from before the single-thread runtime rewrite) but the link-time runtime-dedup strip silently no-opping. `perry-ext-*` wrapper crates are staticlibs that bundle their own copy of perry-runtime and are linked before perry-stdlib, so `strip_bundled_runtime_from_well_known_lib` drops the wrapper's bundled copy to keep stdlib's as the single provider. It inspects the archives with `llvm-nm`, but they carry LLVM bitcode from the active Rust toolchain's LLVM (LLVM 22 for rust 1.97.1); macOS's system `nm` is Apple LLVM 21 and can't read it ("Unknown attribute kind"), exits non-zero, and the strip bails ("failed to inspect defined symbols"). The surviving duplicate leaves two disjoint copies of perry-runtime's event-loop globals — the wait-driver / stdlib pump is registered in one copy (stdlib's, via LTO-internal refs) and read from the other (the wrapper's, first-definition on the generated main loop), so the fetch's runtime is never driven and its promise never settles. Which global lands in the wrong copy shifts with link layout, so it presents as a flaky, load-sensitive hang. A compatible tool exists (Homebrew LLVM, or the toolchain's `llvm-tools` component) but is missed: Homebrew's LLVM is keg-only (not on PATH) and the component may be absent, so `find_llvm_tool` falls back to the too-old Apple `nm`. Fix: `find_llvm_tool` now also searches the standard LLVM install directories that are commonly off PATH — Homebrew's keg (`/opt/homebrew/opt/llvm`, `/usr/local/opt/llvm`, versioned `llvm@NN` kegs) and Debian/Ubuntu's `/usr/lib/llvm-NN` — newest first. With a bitcode-compatible `llvm-nm`/`llvm-ar` found, the existing dedup drops the duplicate runtime and the deadlock is gone. The toolchain's own `llvm-tools` (checked earlier, version-exact) is still preferred when present. Also emit an actionable warning when no compatible tool is found — previously a silent skip whose consequence was a hard-to-diagnose deadlock. Verified: the issue's exact 200-fetch program hung within ~20 iterations before and now runs to completion, 8/8 under CPU load. New unit tests cover the version ordering (`versioned_llvm_bin_dirs`). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/commands/compile/library_search.rs | 123 ++++++++++++++++++ .../commands/compile/link/build_and_run.rs | 11 ++ 2 files changed, 134 insertions(+) diff --git a/crates/perry/src/commands/compile/library_search.rs b/crates/perry/src/commands/compile/library_search.rs index b891622b48..b146d81a8d 100644 --- a/crates/perry/src/commands/compile/library_search.rs +++ b/crates/perry/src/commands/compile/library_search.rs @@ -229,9 +229,84 @@ pub(super) fn find_llvm_tool(tool_name: &str) -> Option { } } + // 4. Well-known LLVM install directories (#5779). The prebuilt runtime/stdlib + // and well-known-wrapper archives carry LLVM *bitcode* emitted by the + // active Rust toolchain's LLVM (thin-LTO). Inspecting/rewriting them for + // the runtime-dedup strip (see `strip_bundled_runtime_from_well_known_lib`) + // needs an `llvm-nm`/`llvm-ar`/`llvm-objcopy` whose LLVM is >= the + // toolchain's — an older one fails (`nm: ... Unknown attribute kind`) and + // the strip silently no-ops, leaving two disjoint copies of perry-runtime's + // event-loop globals (the #5779 in-process server+fetch deadlock). The + // system `nm` on macOS (Apple LLVM) is routinely too old, and Homebrew's + // LLVM is keg-only (not on PATH), so it is missed by the checks above. + // Search the standard keg/versioned locations, preferring the newest, so a + // matching tool is found without the user having to add the toolchain's + // `llvm-tools` component or put Homebrew LLVM on PATH. + if let Some(p) = find_tool_in_well_known_llvm_dirs(tool_name) { + return Some(p); + } + None } +/// Fallback LLVM-tool locator used by [`find_llvm_tool`]: scan the standard +/// LLVM install directories that are commonly NOT on `PATH` — Homebrew's +/// keg-only LLVM on macOS (`/opt/homebrew/opt/llvm`, `/usr/local/opt/llvm`, +/// including versioned `llvm@NN` kegs) and Debian/Ubuntu's `/usr/lib/llvm-NN`. +/// Versioned directories are tried newest-first so the returned tool is as new +/// as possible (bitcode inspection needs LLVM >= the Rust toolchain's). +fn find_tool_in_well_known_llvm_dirs(tool_name: &str) -> Option { + let exe = format!("{tool_name}{}", std::env::consts::EXE_SUFFIX); + + // Unversioned "current" kegs/prefixes first. + let mut dirs: Vec = vec![ + PathBuf::from("/opt/homebrew/opt/llvm/bin"), + PathBuf::from("/usr/local/opt/llvm/bin"), + ]; + + // Versioned kegs/installs, newest first. `(parent, prefix)`: e.g. + // `/opt/homebrew/opt/llvm@18` and `/usr/lib/llvm-18`. + for (parent, prefix) in [ + ("/opt/homebrew/opt", "llvm@"), + ("/usr/local/opt", "llvm@"), + ("/usr/lib", "llvm-"), + ] { + let names: Vec = std::fs::read_dir(parent) + .map(|entries| { + entries + .flatten() + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect() + }) + .unwrap_or_default(); + dirs.extend(versioned_llvm_bin_dirs(parent, prefix, &names)); + } + + dirs.into_iter() + .map(|d| d.join(&exe)) + .find(|candidate| candidate.is_file()) +} + +/// Pure helper for [`find_tool_in_well_known_llvm_dirs`]: given the entry +/// `names` found directly under `parent`, return the `//bin` +/// paths for entries shaped `[...]` (e.g. `llvm@18`, `llvm-17`), +/// ordered newest major version first. Non-matching or non-numeric entries are +/// ignored. Kept separate (no filesystem access) so the version ordering is +/// unit-testable. +fn versioned_llvm_bin_dirs(parent: &str, prefix: &str, names: &[String]) -> Vec { + let mut versioned: Vec<(u32, PathBuf)> = names + .iter() + .filter_map(|name| { + let ver = name.strip_prefix(prefix)?; + let major: String = ver.chars().take_while(|c| c.is_ascii_digit()).collect(); + let n = major.parse::().ok()?; + Some((n, Path::new(parent).join(name).join("bin"))) + }) + .collect(); + versioned.sort_by(|a, b| b.0.cmp(&a.0)); + versioned.into_iter().map(|(_, p)| p).collect() +} + /// Find MSVC link.exe by searching Visual Studio installation directories. /// On Windows, the PATH may contain a GNU `link` utility (e.g. from Git Bash/MSYS2) /// which is not the MSVC linker. This function searches for the real MSVC link.exe. @@ -1698,6 +1773,54 @@ mod native_lib_artifact_tests { } } +#[cfg(test)] +mod llvm_tool_discovery_tests { + use super::versioned_llvm_bin_dirs; + use std::path::PathBuf; + + // #5779: the well-known-LLVM-dir fallback must return versioned kegs + // newest-first, so bitcode inspection uses an LLVM at least as new as the + // Rust toolchain's. Ordering is the only non-trivial part, and it is pure, + // so exercise it directly without touching the filesystem. + #[test] + fn versioned_llvm_dirs_are_newest_first() { + let names = vec![ + "llvm@15".to_string(), + "llvm@18".to_string(), + "llvm@9".to_string(), + "llvm".to_string(), // unversioned — ignored here + "node@20".to_string(), // wrong prefix — ignored + ]; + let dirs = versioned_llvm_bin_dirs("/opt/homebrew/opt", "llvm@", &names); + assert_eq!( + dirs, + vec![ + PathBuf::from("/opt/homebrew/opt/llvm@18/bin"), + PathBuf::from("/opt/homebrew/opt/llvm@15/bin"), + PathBuf::from("/opt/homebrew/opt/llvm@9/bin"), + ] + ); + } + + #[test] + fn versioned_llvm_dirs_handles_debian_prefix_and_ignores_nonmatching() { + let names = vec![ + "llvm-17".to_string(), + "llvm-16.0".to_string(), + "gcc-12".to_string(), + "llvm-".to_string(), // no numeric version — ignored + ]; + let dirs = versioned_llvm_bin_dirs("/usr/lib", "llvm-", &names); + assert_eq!( + dirs, + vec![ + PathBuf::from("/usr/lib/llvm-17/bin"), + PathBuf::from("/usr/lib/llvm-16.0/bin"), + ] + ); + } +} + #[cfg(all(test, target_os = "windows"))] mod windows_toolchain_tests { use super::msvc_vswhere_installation_path_args; 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 056c9d128d..e591877906 100644 --- a/crates/perry/src/commands/compile/link/build_and_run.rs +++ b/crates/perry/src/commands/compile/link/build_and_run.rs @@ -344,6 +344,17 @@ pub(crate) fn build_and_run_link( "[strip-dedup] bundled-runtime drop skipped for {} (non-fatal): {e}", wk.display() ); + // #5779: skipping this leaves two copies of perry-runtime's + // mutable globals; an in-process HTTP server + fetch program + // can then deadlock. It fails when no LLVM tool new enough to + // read the archives' bitcode is found — install the toolchain's + // llvm-tools (`rustup component add llvm-tools`) or a current LLVM. + eprintln!( + "[strip-dedup] warning: could not de-duplicate the bundled \ + perry-runtime — an in-process server+fetch program may \ + deadlock (#5779). Install `rustup component add llvm-tools` \ + or a current LLVM (e.g. `brew install llvm`)." + ); wk.clone() }), None => wk.clone(), From a7dcdcae14adb347c7aa84cf97d94b2c50298a0c Mon Sep 17 00:00:00 2001 From: Ralph Date: Sat, 18 Jul 2026 13:08:23 -0700 Subject: [PATCH 2/2] =?UTF-8?q?refactor:=20address=20CodeRabbit=20?= =?UTF-8?q?=E2=80=94=20slice=20version=20string=20instead=20of=20allocatin?= =?UTF-8?q?g;=20sort=5Fby=5Fkey(Reverse)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/perry/src/commands/compile/library_search.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/perry/src/commands/compile/library_search.rs b/crates/perry/src/commands/compile/library_search.rs index b146d81a8d..e22d92d46b 100644 --- a/crates/perry/src/commands/compile/library_search.rs +++ b/crates/perry/src/commands/compile/library_search.rs @@ -298,12 +298,12 @@ fn versioned_llvm_bin_dirs(parent: &str, prefix: &str, names: &[String]) -> Vec< .iter() .filter_map(|name| { let ver = name.strip_prefix(prefix)?; - let major: String = ver.chars().take_while(|c| c.is_ascii_digit()).collect(); - let n = major.parse::().ok()?; + let end = ver.find(|c: char| !c.is_ascii_digit()).unwrap_or(ver.len()); + let n = ver[..end].parse::().ok()?; Some((n, Path::new(parent).join(name).join("bin"))) }) .collect(); - versioned.sort_by(|a, b| b.0.cmp(&a.0)); + versioned.sort_by_key(|k| std::cmp::Reverse(k.0)); versioned.into_iter().map(|(_, p)| p).collect() }