From 371317123c1088cb6feec919be2d4420ed010fbb Mon Sep 17 00:00:00 2001 From: Avi Fenesh Date: Fri, 29 May 2026 12:39:36 +0300 Subject: [PATCH 1/2] fix(embed): preflight ONNX Runtime + bundle it in the release tarball The embed binary builds with ort-load-dynamic (no compile-time ORT CDN dependency). At runtime ort loads libonnxruntime lazily; when the dylib is absent, fastembed's init deadlocks in a futex with zero output - the embedder appears to hang forever on any machine without a system ORT. Two fixes: 1. resolve_and_preflight_ort() in model.rs runs BEFORE fastembed touches ort. It dlopens a candidate dylib (ORT_DYLIB_PATH -> next-to-exe -> system search), exports ORT_DYLIB_PATH on success, and bails with actionable install guidance on failure. No more silent hang. Covered by try_dlopen unit tests (rejects non-library / missing file) and an ort_dylib_name platform test. 2. release.yml bundles the matching Microsoft ONNX Runtime 1.24.2 (the ABI ort-sys 2.0.0-rc.12 targets) beside the embed binary in the embed tarball, sha256-pinned per platform. The runtime resolver then finds it as the next-to-exe candidate - zero extra download, zero system install. musl is intentionally unbundled (no MS musl build; a glibc ORT cannot dlopen under musl) and fails fast with guidance instead. Verified locally: missing ORT -> clean error in <1s (was: infinite hang); bundled-lib-beside-binary -> full scan, 3.8MB embeddings; MS 1.24.2 lib is ABI-compatible with the ort-sys rc.12 bindings. --- .github/workflows/release.yml | 70 +++++++++++++- .gitignore | 1 + Cargo.lock | 1 + crates/analyzer-embed/Cargo.toml | 17 +++- crates/analyzer-embed/src/model.rs | 144 +++++++++++++++++++++++++++++ 5 files changed, 226 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9c8d3af..2cef848 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,10 +26,18 @@ jobs: strategy: fail-fast: false matrix: + # ort_asset / ort_sha pin the matching Microsoft ONNX Runtime 1.24.2 + # release (the ABI ort-sys 2.0.0-rc.12 targets). The embed binary is + # built with `ort-load-dynamic`, so libonnxruntime is bundled beside it + # in the embed tarball and loaded at runtime. musl is intentionally + # unbundled: a glibc ORT cannot dlopen under musl, and MS ships no musl + # build - the embed binary fails fast there with install guidance. include: - os: ubuntu-latest target: x86_64-unknown-linux-gnu artifact: agent-analyzer + ort_asset: onnxruntime-linux-x64-1.24.2.tgz + ort_sha: 43725474ba5663642e17684717946693850e2005efbd724ac72da278fead25e6 - os: ubuntu-latest target: x86_64-unknown-linux-musl artifact: agent-analyzer @@ -37,12 +45,18 @@ jobs: target: aarch64-unknown-linux-gnu artifact: agent-analyzer cross: true + ort_asset: onnxruntime-linux-aarch64-1.24.2.tgz + ort_sha: 6715b3d19965a2a6981e78ed4ba24f17a8c30d2d26420dbed10aac7ceca0085e - os: macos-latest target: aarch64-apple-darwin artifact: agent-analyzer + ort_asset: onnxruntime-osx-arm64-1.24.2.tgz + ort_sha: 0af4fa503e8ea285245b47ee42d0a7461b8156a81270857da0c1d4ecf858abde - os: windows-latest target: x86_64-pc-windows-msvc artifact: agent-analyzer.exe + ort_asset: onnxruntime-win-x64-1.24.2.zip + ort_sha: 8e3e9c826375352e29cb2614fe44f3d7a4b0ff7b8028ad7a456af9d949a7e8b0 steps: - uses: actions/checkout@v4 @@ -92,13 +106,62 @@ jobs: echo "name=agent-analyzer-embed" >> "$GITHUB_OUTPUT" fi + # Bundle ONNX Runtime beside the embed binary so the binary's runtime + # loader (resolve_and_preflight_ort) finds it as the "next to the + # executable" candidate - no system install, no network, no hang. + # sha256-pinned; the bundled-name strip (libonnxruntime.so.X -> .so / + # .dylib) matches ort_dylib_name() in model.rs. Skipped for targets with + # no ort_asset (musl), which fail fast at runtime with guidance. + - name: Bundle ONNX Runtime beside embed binary (Unix) + if: runner.os != 'Windows' && matrix.ort_asset != '' + run: | + set -euo pipefail + dest="target/${{ matrix.target }}/release" + url="https://github.com/microsoft/onnxruntime/releases/download/v1.24.2/${{ matrix.ort_asset }}" + curl -fsSL -o ort.tgz "$url" + echo "${{ matrix.ort_sha }} ort.tgz" | sha256sum -c - + mkdir -p ort-extract + tar xzf ort.tgz -C ort-extract + if [ "${{ runner.os }}" = "macOS" ]; then + src=$(find ort-extract -name 'libonnxruntime.*.dylib' | head -1) + cp "$src" "$dest/libonnxruntime.dylib" + ls -la "$dest/libonnxruntime.dylib" + else + src=$(find ort-extract -name 'libonnxruntime.so.*' | head -1) + cp "$src" "$dest/libonnxruntime.so" + ls -la "$dest/libonnxruntime.so" + fi + + - name: Bundle ONNX Runtime beside embed binary (Windows) + if: runner.os == 'Windows' && matrix.ort_asset != '' + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $dest = "target/${{ matrix.target }}/release" + $url = "https://github.com/microsoft/onnxruntime/releases/download/v1.24.2/${{ matrix.ort_asset }}" + Invoke-WebRequest -Uri $url -OutFile ort.zip + $got = (Get-FileHash ort.zip -Algorithm SHA256).Hash.ToLower() + if ($got -ne "${{ matrix.ort_sha }}") { + throw "ORT sha256 mismatch: got $got expected ${{ matrix.ort_sha }}" + } + Expand-Archive -Path ort.zip -DestinationPath ort-extract -Force + $src = Get-ChildItem -Path ort-extract -Recurse -Filter onnxruntime.dll | Select-Object -First 1 + Copy-Item $src.FullName (Join-Path $dest "onnxruntime.dll") + Get-ChildItem (Join-Path $dest "onnxruntime.dll") + - name: Create archives (Unix) if: runner.os != 'Windows' run: | set -euo pipefail cd target/${{ matrix.target }}/release tar czvf ../../../agent-analyzer-${{ matrix.target }}.tar.gz ${{ matrix.artifact }} - tar czvf ../../../agent-analyzer-embed-${{ matrix.target }}.tar.gz ${{ steps.embed_artifact.outputs.name }} + # Bundle the ONNX Runtime dylib into the embed tarball when present + # (every target except musl). The JS resolver extracts the whole + # archive next to the binary, so the lib lands as a sibling. + embed_files="${{ steps.embed_artifact.outputs.name }}" + if [ -f libonnxruntime.so ]; then embed_files="$embed_files libonnxruntime.so"; fi + if [ -f libonnxruntime.dylib ]; then embed_files="$embed_files libonnxruntime.dylib"; fi + tar czvf ../../../agent-analyzer-embed-${{ matrix.target }}.tar.gz $embed_files cd ../../.. shasum -a 256 agent-analyzer-${{ matrix.target }}.tar.gz > agent-analyzer-${{ matrix.target }}.tar.gz.sha256 shasum -a 256 agent-analyzer-embed-${{ matrix.target }}.tar.gz > agent-analyzer-embed-${{ matrix.target }}.tar.gz.sha256 @@ -109,7 +172,10 @@ jobs: run: | cd target/${{ matrix.target }}/release Compress-Archive -Path ${{ matrix.artifact }} -DestinationPath ../../../agent-analyzer-${{ matrix.target }}.zip - Compress-Archive -Path ${{ steps.embed_artifact.outputs.name }} -DestinationPath ../../../agent-analyzer-embed-${{ matrix.target }}.zip + # Bundle onnxruntime.dll into the embed zip when present. + $embedPaths = @("${{ steps.embed_artifact.outputs.name }}") + if (Test-Path "onnxruntime.dll") { $embedPaths += "onnxruntime.dll" } + Compress-Archive -Path $embedPaths -DestinationPath ../../../agent-analyzer-embed-${{ matrix.target }}.zip cd ../../.. (Get-FileHash agent-analyzer-${{ matrix.target }}.zip -Algorithm SHA256).Hash.ToLower() + " agent-analyzer-${{ matrix.target }}.zip" | Out-File -Encoding ASCII agent-analyzer-${{ matrix.target }}.zip.sha256 (Get-FileHash agent-analyzer-embed-${{ matrix.target }}.zip -Algorithm SHA256).Hash.ToLower() + " agent-analyzer-embed-${{ matrix.target }}.zip" | Out-File -Encoding ASCII agent-analyzer-embed-${{ matrix.target }}.zip.sha256 diff --git a/.gitignore b/.gitignore index a396198..a118b68 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ .DS_Store .idea/ .vscode/ +.claude/ diff --git a/Cargo.lock b/Cargo.lock index b05f69a..8abdd2d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -122,6 +122,7 @@ dependencies = [ "fastembed", "half", "ignore", + "libloading", "rayon", "serde", "serde_json", diff --git a/crates/analyzer-embed/Cargo.toml b/crates/analyzer-embed/Cargo.toml index fa72abf..ff1b779 100644 --- a/crates/analyzer-embed/Cargo.toml +++ b/crates/analyzer-embed/Cargo.toml @@ -40,16 +40,23 @@ tree-sitter-java.workspace = true sha2 = "0.10" half = "2" streaming-iterator = "0.1" +# Pre-flight dlopen of ONNX Runtime so a missing/unloadable libonnxruntime +# fails fast with install guidance instead of deadlocking inside fastembed's +# lazy `ort` init (see resolve_and_preflight_ort in model.rs). Same crate ort +# itself loads the dylib with, so no new transitive cost. +libloading = "0.9" # Use `ort-load-dynamic` instead of the default `ort-download-binaries` # so the build does not depend on pyke's CDN at compile time. The # CDN goes down occasionally (sustained 504s during this PR's CI), # which would otherwise fail every PR build until pyke recovers. # -# Runtime trade-off: the embed binary expects ONNX Runtime to be -# loadable at runtime via `ORT_DYLIB_PATH` or a system-default -# location. The JS wrapper (`lib/embed/binary.js` in the repo-intel -# plugin) downloads the lib alongside the binary on first use; users -# running the binary directly need ONNX Runtime installed manually. +# Runtime trade-off: the embed binary needs ONNX Runtime loadable at +# runtime. Resolution order (resolve_and_preflight_ort in model.rs): +# 1. ORT_DYLIB_PATH if set +# 2. libonnxruntime.{so,dylib,dll} next to the embed binary +# (the release tarball bundles it; the JS resolver extracts both) +# 3. system default (LD_LIBRARY_PATH / standard dirs) +# A missing lib is reported with install guidance, never a silent hang. fastembed = { version = "5", default-features = false, features = [ "ort-load-dynamic", "hf-hub-native-tls", diff --git a/crates/analyzer-embed/src/model.rs b/crates/analyzer-embed/src/model.rs index 8fe3c63..a72feeb 100644 --- a/crates/analyzer-embed/src/model.rs +++ b/crates/analyzer-embed/src/model.rs @@ -46,6 +46,14 @@ impl FastEmbedder { /// downloads the model files; subsequent constructions read from the /// fastembed cache. pub fn new(variant: ModelVariant) -> Result { + // Make ONNX Runtime loadable BEFORE fastembed touches `ort`. With the + // `ort-load-dynamic` feature, a missing/unloadable libonnxruntime sends + // fastembed's lazy init into a futex deadlock with no output (observed + // on machines without a system ORT). Resolve a dylib, export + // ORT_DYLIB_PATH for `ort` to consume, and fail fast with guidance if + // none loads. + resolve_and_preflight_ort()?; + let cache_dir = resolve_cache_dir(); if let Err(e) = std::fs::create_dir_all(&cache_dir) { @@ -154,6 +162,108 @@ fn home_dir() -> Option { None } +/// Platform-specific ONNX Runtime shared-library file name. +fn ort_dylib_name() -> &'static str { + if cfg!(target_os = "windows") { + "onnxruntime.dll" + } else if cfg!(target_os = "macos") { + "libonnxruntime.dylib" + } else { + "libonnxruntime.so" + } +} + +/// Ensure ONNX Runtime is loadable before fastembed initializes `ort`. +/// +/// `ort-load-dynamic` defers ORT loading until first use and, on failure to +/// find the dylib, fastembed's init wedges in a futex with no diagnostic. We +/// pre-empt that: pick a dylib, verify it actually `dlopen`s, and publish it +/// via `ORT_DYLIB_PATH` (which `ort` honors). Resolution order: +/// +/// 1. `ORT_DYLIB_PATH` if already set (respect the operator's choice; still +/// preflighted so a bad path is reported, not hung on). +/// 2. `libonnxruntime.{so,dylib,dll}` next to the running executable - the +/// release tarball bundles it beside `agent-analyzer-embed`. +/// 3. System default: let the loader search (LD_LIBRARY_PATH, standard dirs) +/// by dlopen-ing the bare library name. +/// +/// Returns a clear, actionable error if nothing loads - never a silent hang. +fn resolve_and_preflight_ort() -> Result<()> { + // Candidate paths in precedence order. `None` => bare name (system search). + let mut candidates: Vec> = Vec::new(); + + if let Ok(p) = std::env::var("ORT_DYLIB_PATH") { + if !p.is_empty() { + candidates.push(Some(PathBuf::from(p))); + } + } + if let Ok(exe) = std::env::current_exe() { + if let Some(dir) = exe.parent() { + candidates.push(Some(dir.join(ort_dylib_name()))); + } + } + candidates.push(None); // system default search + + let mut tried: Vec = Vec::new(); + for cand in &candidates { + let load_target: PathBuf = match cand { + Some(p) => { + if !p.exists() { + tried.push(format!("{} (not found)", p.display())); + continue; + } + p.clone() + } + None => PathBuf::from(ort_dylib_name()), + }; + + match try_dlopen(&load_target) { + Ok(()) => { + // Only export an explicit path for real files; for the system + // search case leave ORT_DYLIB_PATH unset so `ort` does its own + // default resolution (matching what just succeeded here). + if let Some(p) = cand { + // SAFETY: single-threaded init path (called at the top of + // FastEmbedder::new before any embedding threads spawn). + unsafe { + std::env::set_var("ORT_DYLIB_PATH", p); + } + } + return Ok(()); + } + Err(e) => { + tried.push(format!("{}: {e}", load_target.display())); + } + } + } + + anyhow::bail!( + "could not load ONNX Runtime ({lib}). The embedder needs ONNX Runtime \ + at runtime. Fixes:\n \ + - reinstall the embed binary so the bundled library is restored \ + (it ships in the agent-analyzer-embed release tarball), or\n \ + - install ONNX Runtime and set ORT_DYLIB_PATH to its \ + {lib}.\nTried:\n {tried}", + lib = ort_dylib_name(), + tried = tried.join("\n ") + ); +} + +/// Attempt to `dlopen` a shared library and immediately release it. +/// +/// Returns `Ok(())` if the library loads. Loading ORT runs its initializers; +/// this is the same load `ort` performs internally - we only do it first to +/// surface failures as a clean error rather than a downstream deadlock. The +/// handle is dropped right away; `ort` loads its own copy via `ORT_DYLIB_PATH`. +fn try_dlopen(lib: &Path) -> Result<(), String> { + // SAFETY: dlopen of a shared library by path. Standard dynamic-loading; + // any unsafe initializer the library runs is the same one `ort` would run. + match unsafe { libloading::Library::new(lib) } { + Ok(_handle) => Ok(()), + Err(e) => Err(e.to_string()), + } +} + /// Best-effort detection of a half-built model cache. Fastembed creates /// `models----/refs/main` early in the download; if the /// process is killed before it finalizes, `snapshots/` stays empty and @@ -238,6 +348,40 @@ mod tests { assert_eq!(big, "onnx-community/embeddinggemma-300m-ONNX"); } + #[test] + fn ort_dylib_name_matches_platform() { + let name = ort_dylib_name(); + if cfg!(target_os = "windows") { + assert_eq!(name, "onnxruntime.dll"); + } else if cfg!(target_os = "macos") { + assert_eq!(name, "libonnxruntime.dylib"); + } else { + assert_eq!(name, "libonnxruntime.so"); + } + } + + #[test] + fn try_dlopen_rejects_a_non_library_file() { + // The preflight's load primitive must report failure (so the resolver + // can move to the next candidate / bail with guidance) rather than + // succeed or hang on a file that is not a real shared object. Host- + // independent: a text file never dlopens anywhere. + let tmp = tempdir().unwrap(); + let bogus = tmp.path().join(ort_dylib_name()); + std::fs::write(&bogus, b"not a real shared object").unwrap(); + + let err = try_dlopen(&bogus).expect_err("a text file must not dlopen"); + assert!(!err.is_empty(), "dlopen failure should carry a message"); + } + + #[test] + fn try_dlopen_reports_missing_file() { + // A path that does not exist must error, not panic. + let tmp = tempdir().unwrap(); + let missing = tmp.path().join(ort_dylib_name()); + assert!(try_dlopen(&missing).is_err()); + } + #[test] fn prune_removes_poisoned_layout() { let tmp = tempdir().unwrap(); From d9a901efe733fb1fcf954b481a961b998c5883fd Mon Sep 17 00:00:00 2001 From: Avi Fenesh Date: Fri, 29 May 2026 13:12:12 +0300 Subject: [PATCH 2/2] fix(embed): address gemini review - OnceLock guard + dir-form ORT_DYLIB_PATH Two high-priority review findings on #39: 1. resolve_and_preflight_ort ran on every FastEmbedder::new and called std::env::set_var unguarded - a data race / UB if embedders are built concurrently or repeatedly. Now wrapped in a OnceLock so it runs exactly once per process and the result is cached. 2. ORT_DYLIB_PATH may point at a DIRECTORY (ort supports both dir and file), but dlopen needs the file. Normalize dir -> / before probing, and export the normalized file path. Tests: added try_dlopen_rejects_a_directory + preflight_runs_once_and_caches (48 embed tests pass). Dogfood-verified: ORT_DYLIB_PATH set to a directory now drives a full scan (exit 0, embeddings) - was a guaranteed dlopen failure before the normalization. --- crates/analyzer-embed/src/model.rs | 57 +++++++++++++++++++++++++++--- 1 file changed, 52 insertions(+), 5 deletions(-) diff --git a/crates/analyzer-embed/src/model.rs b/crates/analyzer-embed/src/model.rs index a72feeb..9a0a429 100644 --- a/crates/analyzer-embed/src/model.rs +++ b/crates/analyzer-embed/src/model.rs @@ -188,13 +188,35 @@ fn ort_dylib_name() -> &'static str { /// by dlopen-ing the bare library name. /// /// Returns a clear, actionable error if nothing loads - never a silent hang. +/// +/// Runs at most once per process. `FastEmbedder::new` may be called more than +/// once (multiple variants) and potentially from multiple threads; the actual +/// resolve - which calls `std::env::set_var`, a data race if run concurrently - +/// is guarded by a `OnceLock` so it executes exactly once and the result is +/// cached. fn resolve_and_preflight_ort() -> Result<()> { + static ORT_INIT: std::sync::OnceLock> = std::sync::OnceLock::new(); + ORT_INIT + .get_or_init(|| resolve_and_preflight_ort_inner().map_err(|e| format!("{e:#}"))) + .clone() + .map_err(|e| anyhow::anyhow!(e)) +} + +fn resolve_and_preflight_ort_inner() -> Result<()> { // Candidate paths in precedence order. `None` => bare name (system search). let mut candidates: Vec> = Vec::new(); if let Ok(p) = std::env::var("ORT_DYLIB_PATH") { if !p.is_empty() { - candidates.push(Some(PathBuf::from(p))); + // `ort` accepts ORT_DYLIB_PATH as either the dylib file or a + // directory containing it. dlopen needs the file, so normalize a + // directory to / before probing. + let pb = PathBuf::from(&p); + candidates.push(Some(if pb.is_dir() { + pb.join(ort_dylib_name()) + } else { + pb + })); } } if let Ok(exe) = std::env::current_exe() { @@ -222,11 +244,13 @@ fn resolve_and_preflight_ort() -> Result<()> { // Only export an explicit path for real files; for the system // search case leave ORT_DYLIB_PATH unset so `ort` does its own // default resolution (matching what just succeeded here). - if let Some(p) = cand { - // SAFETY: single-threaded init path (called at the top of - // FastEmbedder::new before any embedding threads spawn). + if cand.is_some() { + // SAFETY: serialized by the OnceLock in the public wrapper - + // this inner fn runs exactly once per process, before any + // embedding threads spawn, so the set_var has no concurrent + // reader/writer. unsafe { - std::env::set_var("ORT_DYLIB_PATH", p); + std::env::set_var("ORT_DYLIB_PATH", &load_target); } } return Ok(()); @@ -382,6 +406,29 @@ mod tests { assert!(try_dlopen(&missing).is_err()); } + #[test] + fn try_dlopen_rejects_a_directory() { + // `ort` accepts ORT_DYLIB_PATH as a directory, but dlopen needs the + // file. The resolver normalizes dir -> dir/ before probing; + // this locks in that dlopen-ing a directory itself fails (so the + // normalization is load-bearing, not cosmetic). + let tmp = tempdir().unwrap(); + assert!( + try_dlopen(tmp.path()).is_err(), + "dlopen of a directory must fail" + ); + } + + #[test] + fn preflight_runs_once_and_caches() { + // The OnceLock wrapper must return a stable result across calls (no + // re-resolve, no repeated set_var). We can't assert success without a + // real ORT on the host, but we can assert idempotence: two calls agree. + let first = resolve_and_preflight_ort().is_ok(); + let second = resolve_and_preflight_ort().is_ok(); + assert_eq!(first, second, "preflight result must be stable/cached"); + } + #[test] fn prune_removes_poisoned_layout() { let tmp = tempdir().unwrap();