Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 68 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,23 +26,37 @@ 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
- os: ubuntu-latest
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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@
.DS_Store
.idea/
.vscode/
.claude/
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 12 additions & 5 deletions crates/analyzer-embed/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
191 changes: 191 additions & 0 deletions crates/analyzer-embed/src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,14 @@ impl FastEmbedder {
/// downloads the model files; subsequent constructions read from the
/// fastembed cache.
pub fn new(variant: ModelVariant) -> Result<Self> {
// 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) {
Expand Down Expand Up @@ -154,6 +162,132 @@ fn home_dir() -> Option<PathBuf> {
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.
///
/// 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<()> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

resolve_and_preflight_ort is called every time FastEmbedder::new is invoked. If a caller creates multiple embedders (e.g., for different model variants) or initializes them concurrently from multiple threads, this will run repeatedly. This leads to:

  1. Multiple concurrent/parallel calls to std::env::set_var, which is a data race and undefined behavior in Rust (especially in multi-threaded contexts).
  2. Unnecessary overhead of re-running the preflight checks and loading/unloading the library.

We should wrap the preflight check in a std::sync::OnceLock to ensure it runs exactly once per process lifetime.

static ORT_INIT: std::sync::OnceLock<Result<(), String>> = std::sync::OnceLock::new();

fn resolve_and_preflight_ort() -> Result<()> {
    ORT_INIT.get_or_init(|| {
        resolve_and_preflight_ort_inner().map_err(|e| e.to_string())
    })
    .clone()
    .map_err(|e| anyhow::anyhow!(e))
}

fn resolve_and_preflight_ort_inner() -> Result<()> {

static ORT_INIT: std::sync::OnceLock<Result<(), String>> = 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<Option<PathBuf>> = Vec::new();

if let Ok(p) = std::env::var("ORT_DYLIB_PATH") {
if !p.is_empty() {
// `ort` accepts ORT_DYLIB_PATH as either the dylib file or a
// directory containing it. dlopen needs the file, so normalize a
// directory to <dir>/<libname> before probing.
let pb = PathBuf::from(&p);
candidates.push(Some(if pb.is_dir() {
pb.join(ort_dylib_name())
} else {
pb
}));
}
}
Comment on lines +209 to +221

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If ORT_DYLIB_PATH is set to a directory (which is a common use case, e.g., pointing to a directory containing the shared library rather than the file itself), p.exists() will be true, but trying to dlopen the directory directly will fail. The ort crate natively supports ORT_DYLIB_PATH pointing to either a directory or a file.

To handle this robustly, we should check if the path is a directory and append the platform-specific library name if so.

if let Ok(p) = std::env::var(

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<String> = 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 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", &load_target);
}
}
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--<org>--<name>/refs/main` early in the download; if the
/// process is killed before it finalizes, `snapshots/` stays empty and
Expand Down Expand Up @@ -238,6 +372,63 @@ 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 try_dlopen_rejects_a_directory() {
// `ort` accepts ORT_DYLIB_PATH as a directory, but dlopen needs the
// file. The resolver normalizes dir -> dir/<libname> 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();
Expand Down
Loading