Skip to content

fix: fail fast on incompatible ONNX Runtime - #731

Merged
yvgude merged 2 commits into
yvgude:mainfrom
malochapo:fix/embeddings-ort-runtime-preflight
Jul 6, 2026
Merged

fix: fail fast on incompatible ONNX Runtime#731
yvgude merged 2 commits into
yvgude:mainfrom
malochapo:fix/embeddings-ort-runtime-preflight

Conversation

@malochapo

Copy link
Copy Markdown
Contributor

Summary

Fixes #730.

This PR hardens the embeddings initialization path when ort is used with load-dynamic.

It adds an eager ONNX Runtime version preflight before calling ort::init_from, preserves embedding engine load errors so the CLI can show useful diagnostics, and ensures deterministic embeddings still register a CPU execution provider.

Changes:

  • Add libloading to the embeddings and neural feature dependency sets so the runtime .so can be inspected before handing it to ort.
  • Preflight libonnxruntime in core::ort_environment:
    • load the selected shared library;
    • resolve OrtGetApiBase;
    • read GetVersionString;
    • compare the runtime minor version with ort::MINOR_VERSION;
    • return a clear error when the runtime is too old.
  • Add debug logs around ort::init_from and ONNX Runtime environment commit.
  • Keep CPU execution provider registration in deterministic embedding mode instead of passing an empty provider list.
  • Add shared_engine_result() so callers that can surface diagnostics do not lose the original engine-load error.
  • Update semantic index build to report the actual embedding engine load failure instead of a generic “check logs” message.

Why

lean-ctx currently relies on a dynamically loaded ONNX Runtime shared library. If the discovered library is missing or too old, the user needs an immediate, actionable error. In practice, an ONNX Runtime 1.22.0 library with a build compiled for api-24 caused ort::init_from to hang before commit(), which made semantic indexing appear stuck.

Deterministic mode also passed zero execution providers. Once the API mismatch was removed in a probe build, session creation could crash inside ONNX Runtime because the execution-provider vector was empty. CPU should always be present as the fallback provider.

Test plan

  • cd rust && cargo check -q --lib --features embeddings
  • cd rust && cargo build -q --bin lean-ctx --features 'tree-sitter embeddings http-server team-server gateway-server shape-xlat secure-update jemalloc qdrant pgvector'
  • Validate incompatible ONNX Runtime 1.22.0 fails fast with a clear version error instead of hanging.
  • Validate compatible ONNX Runtime 1.27.0 succeeds end-to-end and persists semantic embeddings.
  • cd rust && cargo test
  • cd rust && cargo clippy --all-targets --all-features -- -D warnings
  • cd rust && cargo fmt --check

Incompatible runtime validation with ONNX Runtime 1.22.0:

ORT_DIR=/home/user/.cache/uv/archive-v0/.../onnxruntime/capi
timeout 60s env \
  ORT_DYLIB_PATH="$ORT_DIR/libonnxruntime.so.1.22.0" \
  LD_LIBRARY_PATH="$ORT_DIR:${LD_LIBRARY_PATH:-}" \
  LEAN_CTX_EMBEDDING_DETERMINISTIC=1 \
  LEAN_CTX_EMBEDDING_BATCH_SIZE=1 \
  RUST_LOG=lean_ctx::core::ort_environment=debug,lean_ctx::core::embeddings=debug,lean_ctx::core::embedding_index=debug \
  ./target/debug/lean-ctx index build-semantic /home/user/travail/oss/lean-ctx

Result: no hang; reports that ONNX Runtime 1.22.0 is too old for a build requiring >= 1.24.x.

Compatible runtime validation with ONNX Runtime 1.27.0:

rm -rf /tmp/leanctx-ort127
mkdir -p /tmp/leanctx-ort127
uv pip install --target /tmp/leanctx-ort127 'onnxruntime==1.27.0'

ORT_DIR=/tmp/leanctx-ort127/onnxruntime/capi
timeout 120s env \
  ORT_DYLIB_PATH="$ORT_DIR/libonnxruntime.so.1.27.0" \
  LD_LIBRARY_PATH="$ORT_DIR:${LD_LIBRARY_PATH:-}" \
  LEAN_CTX_EMBEDDING_DETERMINISTIC=1 \
  LEAN_CTX_EMBEDDING_BATCH_SIZE=1 \
  RUST_LOG=lean_ctx::core::ort_environment=debug,lean_ctx::core::embeddings=debug,lean_ctx::core::embedding_index=info \
  ./target/debug/lean-ctx index build-semantic /home/user/travail/oss/lean-ctx

Result:

Embedding engine loaded: model=all-MiniLM-L6-v2, 384d, max_seq_len=256, topology=transformer
[embedding_index] successfully persisted 10 file embeddings (10 chunks)
semantic index ready
EXIT=0

Notes for reviewers

  • Risk areas / edge cases: dynamic loading of libonnxruntime, runtime version parsing, deterministic embedding mode, and CPU execution-provider fallback.
  • Backwards compatibility: compatible ONNX Runtime libraries continue to work; incompatible libraries now fail fast with a diagnostic instead of hanging.
  • Docs updated: no product docs in this PR. Follow-up issues should cover auto-provisioning a matching ONNX Runtime library and the supported GPU/NPU provider matrix.
  • The ONNX Runtime shared library is native code loaded via dlopen; the Python wheel is only a convenient distribution source for libonnxruntime.so, not a Python runtime dependency.
  • This PR intentionally does not auto-download ONNX Runtime. That should be handled in a follow-up design change so release artifacts and cache behavior are explicit.
  • This PR intentionally does not enable GPU providers by default. Existing GPU/NPU support remains behind compile features.

Contributor License Agreement

First-time contributors: a bot will ask you to sign our one-time CLA. Reply to the PR with: I have read the CLA Document and I hereby sign the CLA.

@yvgude

yvgude commented Jul 6, 2026

Copy link
Copy Markdown
Owner

Thanks — fail-fast preflight + the CPU-EP fix are exactly the right immediate scope, and the debug-tracing breadcrumbs around init_from/commit will help diagnose future runtime mismatches. Two CI gates are red; both are mechanical:

1. Clippy (also fails the Embed SDK job, which compiles the lib with -Dwarnings): this repo enforces clippy::undocumented_unsafe_blocks, so each of the five unsafe blocks in validate_ort_dylib_version needs a // SAFETY: comment on the preceding line.

2. Format: cargo fmt wants the long ensure!/builder-chain lines wrapped (two spots in ort_environment.rs).

Both resolved in one go by something like:

fn validate_ort_dylib_version(path: &Path) -> anyhow::Result<()> {
    // SAFETY: the path was resolved by resolve_ort_dylib; loading a shared
    // library executes its initializers, which is the accepted risk of any
    // dlopen-based ORT discovery (same trust boundary as ort::init_from).
    let lib = unsafe { libloading::Library::new(path) }
        .map_err(|e| anyhow::anyhow!("failed to load {}: {e}", path.display()))?;
    // SAFETY: OrtGetApiBase is the stable C entry point every ONNX Runtime
    // exports; the signature matches the ORT C API declaration.
    let get_api_base: libloading::Symbol<OrtGetApiBase> = unsafe { lib.get(b"OrtGetApiBase") }
        .map_err(|_| anyhow::anyhow!("{} does not export OrtGetApiBase", path.display()))?;
    // SAFETY: the symbol was just resolved from the loaded library and takes
    // no arguments; it returns a pointer we null-check before use.
    let base = unsafe { get_api_base() };
    anyhow::ensure!(
        !base.is_null(),
        "OrtGetApiBase returned null for {}",
        path.display()
    );

    // SAFETY: base is non-null (checked above) and points to the static
    // OrtApiBase; GetVersionString takes no arguments.
    let version = unsafe { ((*base).get_version_string)() };
    // SAFETY: GetVersionString returns a static NUL-terminated C string owned
    // by the runtime for the lifetime of the library.
    let version = unsafe { CStr::from_ptr(version) }.to_string_lossy();
    let minor = version
        .split('.')
        .nth(1)
        .and_then(|part| part.parse::<u32>().ok())
        .unwrap_or(0);
    ...

(then cd rust && cargo fmt && cargo clippy --all-targets -- -D warnings locally to confirm.)

One substantive review note: the EP change makes deterministic mode register all compiled-in providers, not just CPU — on ort-cuda/ort-coreml/… builds that silently re-introduces the nondeterministic GPU paths that deterministic_inference() exists to exclude. Since gpu_execution_providers() always appends the CPU EP last, the minimal fix that both repairs the empty-provider crash and preserves the determinism guarantee is:

let eps = if deterministic {
    vec![ort::ep::CPU::default().build()]
} else {
    crate::core::ort_execution_providers::gpu_execution_providers()
};

Default/official builds behave identically either way (no GPU features compiled in), so this only matters for feature builds — but it keeps the deterministic contract honest. With the SAFETY comments, fmt, and that one-liner this is good to merge from my side.

- add SAFETY comments to unsafe blocks in validate_ort_dylib_version
  (clippy::undocumented_unsafe_blocks)
- run cargo fmt on ort_environment.rs
- keep deterministic embedding mode CPU-only instead of registering all
  compiled-in execution providers, preserving the determinism guarantee

@yvgude yvgude left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Reviewed the preflight path in detail — this is exactly the right shape. ✅

  • validate_ort_dylib_version checks OrtGetApiBase→GetVersionString before ort::init_from, so an ABI-incompatible runtime turns from a hang/crash into a clear, actionable error (the message names both versions and ORT_DYLIB_PATH). The minor >= ort::MINOR_VERSION comparison matches ORT's compatibility rule (MINOR_VERSION is ORT_API_VERSION, runtimes are backwards-compatible).
  • The double dlopen (libloading probe, then ort::init_from) is fine — the loader refcounts, and the probe shares the exact trust boundary the subsequent init has anyway. SAFETY comments are accurate.
  • shared_engine_result() preserving the load error (instead of a bare Option) finally makes ctx doctor-style diagnostics possible from build_or_update; the Option wrapper keeps every existing caller untouched.
  • Explicit CPU EP for deterministic inference is a good hardening move (identical behavior, clearer intent).

CI is fully green across all three OS + the no-ORT build. Merging — thanks for the thorough fix, this closes a nasty failure mode for everyone on distro-packaged onnxruntime.

@yvgude
yvgude merged commit 7809654 into yvgude:main Jul 6, 2026
26 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators Jul 6, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: embeddings hang or crash with incompatible dynamic ONNX Runtime

2 participants