Skip to main content

lean_ctx/core/
ort_environment.rs

1//! ONNX Runtime global environment: single init per process, runtime dylib loading.
2//!
3//! With the `load-dynamic` Cargo feature (always enabled in lean-ctx's `ort`
4//! dependency), `libonnxruntime` is loaded at runtime via [`ort::init_from`].
5//! This module resolves the library path across platforms, including NixOS.
6//!
7//! # Search order
8//!
9//! 1. `ORT_DYLIB_PATH` env var (resolved relative to the executable directory)
10//! 2. The lean-ctx managed runtime (`lean-ctx embeddings provision`, GH #732)
11//!    — version-matched to this build's `ort` API level by construction
12//! 3. Nix profile paths (Linux):
13//!    - `/run/current-system/sw/lib/` (system profile)
14//!    - `/etc/profiles/per-user/$USER/lib/` (NixOS Home Manager per-user)
15//!    - `~/.nix-profile/lib/` (legacy user profile symlink)
16//! 4. Well-known system directories per platform, including the active
17//!    `HOMEBREW_PREFIX` and the standard Homebrew/Linuxbrew lib dirs
18//! 5. `LD_LIBRARY_PATH` / `DYLD_LIBRARY_PATH`
19//!
20//! If no copy is found, [`ensure_ort_env`] returns an eager error — session
21//! creation hangs rather than failing, so we fail fast.
22
23use std::ffi::{CStr, c_char, c_void};
24use std::path::{Path, PathBuf};
25use std::sync::OnceLock;
26
27use ort::ep::ExecutionProviderDispatch;
28
29/// Ensure the global ONNX Runtime environment is initialized.
30///
31/// On first call: resolves `libonnxruntime` via the search chain defined in
32/// `resolve_ort_dylib`, loads it with [`ort::init_from`], and registers GPU
33/// execution providers.  Subsequent calls are no-ops.
34///
35/// Returns an eager error when the shared library cannot be found (session
36/// creation would otherwise hang).
37pub fn ensure_ort_env(eps: &[ExecutionProviderDispatch]) -> anyhow::Result<()> {
38    static INIT: OnceLock<anyhow::Result<()>> = OnceLock::new();
39    // get_or_init runs the closure at most once; all subsequent calls return
40    // a reference to the stored Result.
41    match INIT.get_or_init(|| {
42        tracing::debug!("Initializing ONNX Runtime environment");
43        init_ort(eps)
44    }) {
45        Ok(()) => Ok(()),
46        // anyhow::Error is !Clone so we reconstitute from Display.
47        Err(e) => Err(anyhow::anyhow!("{e}")),
48    }
49}
50
51// ---------------------------------------------------------------------------
52// Initialisation
53// ---------------------------------------------------------------------------
54
55/// Load `libonnxruntime` at runtime via [`ort::init_from`].
56///
57/// The library path is resolved by [`resolve_ort_dylib`]; errors are
58/// propagated eagerly to avoid hanging on first session creation.
59fn init_ort(eps: &[ExecutionProviderDispatch]) -> anyhow::Result<()> {
60    let path = resolved_ort_dylib_path()?;
61
62    tracing::debug!("Loading libonnxruntime from {}", path.display());
63    validate_ort_dylib_version(&path)?;
64    tracing::debug!("Calling ort::init_from");
65    let init = ort::init_from(&path)
66        .map_err(|e| anyhow::anyhow!("ort::init_from({}) failed: {e}", path.display()))?;
67    tracing::debug!("ort::init_from returned; committing ONNX Runtime environment");
68    init.with_name("lean-ctx")
69        .with_execution_providers(eps)
70        .commit();
71    tracing::debug!("ONNX Runtime environment commit returned");
72
73    tracing::info!("ONNX Runtime initialised ({})", path.display());
74    Ok(())
75}
76
77pub(crate) fn resolved_ort_dylib_path() -> anyhow::Result<PathBuf> {
78    resolve_ort_dylib()
79}
80
81type OrtGetApiBase = unsafe extern "C" fn() -> *const OrtApiBase;
82type GetVersionString = unsafe extern "C" fn() -> *const c_char;
83
84#[repr(C)]
85struct OrtApiBase {
86    get_api: *const c_void,
87    get_version_string: GetVersionString,
88}
89
90fn validate_ort_dylib_version(path: &Path) -> anyhow::Result<()> {
91    // SAFETY: the path was resolved by resolve_ort_dylib; loading a shared
92    // library executes its initializers, which is the accepted risk of any
93    // dlopen-based ORT discovery (same trust boundary as ort::init_from).
94    let lib = unsafe { libloading::Library::new(path) }
95        .map_err(|e| anyhow::anyhow!("failed to load {}: {e}", path.display()))?;
96    // SAFETY: OrtGetApiBase is the stable C entry point every ONNX Runtime
97    // exports; the signature matches the ORT C API declaration.
98    let get_api_base: libloading::Symbol<OrtGetApiBase> = unsafe { lib.get(b"OrtGetApiBase") }
99        .map_err(|_| anyhow::anyhow!("{} does not export OrtGetApiBase", path.display()))?;
100    // SAFETY: the symbol was just resolved from the loaded library and takes
101    // no arguments; it returns a pointer we null-check before use.
102    let base = unsafe { get_api_base() };
103    anyhow::ensure!(
104        !base.is_null(),
105        "OrtGetApiBase returned null for {}",
106        path.display()
107    );
108
109    // SAFETY: base is non-null (checked above) and points to the static
110    // OrtApiBase; GetVersionString takes no arguments.
111    let version = unsafe { ((*base).get_version_string)() };
112    // SAFETY: GetVersionString returns a static NUL-terminated C string owned
113    // by the runtime for the lifetime of the library.
114    let version = unsafe { CStr::from_ptr(version) }.to_string_lossy();
115    let minor = version
116        .split('.')
117        .nth(1)
118        .and_then(|part| part.parse::<u32>().ok())
119        .unwrap_or(0);
120    anyhow::ensure!(
121        minor >= ort::MINOR_VERSION,
122        "{} is ONNX Runtime {version}, but this lean-ctx build requires ONNX Runtime >= 1.{}.x; install a matching onnxruntime package or point ORT_DYLIB_PATH at a newer libonnxruntime",
123        path.display(),
124        ort::MINOR_VERSION,
125    );
126    Ok(())
127}
128
129// ---------------------------------------------------------------------------
130// Library resolution
131// ---------------------------------------------------------------------------
132
133fn dylib_filename() -> &'static str {
134    if cfg!(target_os = "windows") {
135        "onnxruntime.dll"
136    } else if cfg!(target_os = "macos") {
137        "libonnxruntime.dylib"
138    } else {
139        "libonnxruntime.so"
140    }
141}
142
143/// Search for `libonnxruntime` across platform-specific locations.
144///
145/// Returns the first path found, or a descriptive error.
146fn resolve_ort_dylib() -> anyhow::Result<PathBuf> {
147    let name = dylib_filename();
148
149    // 1. ORT_DYLIB_PATH env var (resolved relative to exe dir)
150    if let Ok(p) = std::env::var("ORT_DYLIB_PATH") {
151        let path = PathBuf::from(&p);
152        if path.is_relative() {
153            let rel_to_exe = || -> Option<PathBuf> {
154                let exe = std::env::current_exe().ok()?;
155                let dir = exe.parent()?;
156                let abs = dir.join(&path);
157                abs.is_file().then_some(abs)
158            };
159            if let Some(abs) = rel_to_exe() {
160                return Ok(abs);
161            }
162        }
163        if path.is_file() {
164            return Ok(path);
165        }
166        anyhow::bail!("ORT_DYLIB_PATH={p} set but file does not exist");
167    }
168
169    // 2. Managed runtime (GH #732) — installed by `lean-ctx embeddings
170    //    provision`, SHA-256 pinned to the official release and version-
171    //    matched to this build's ort API level. After ORT_DYLIB_PATH so an
172    //    operator override always wins.
173    if let Some(found) = crate::core::addons::ort_provision::managed_dylib_path() {
174        return Ok(found);
175    }
176
177    // 3. Nix profile paths (Linux) — system & user profiles always point to
178    //    the currently activated version.
179    #[cfg(target_os = "linux")]
180    if let Some(found) = nix_profile_search(name) {
181        return Ok(found);
182    }
183
184    // 4. Well-known system paths (per platform)
185    if let Some(found) = well_known_paths(name) {
186        return Ok(found);
187    }
188
189    // 5. LD_LIBRARY_PATH / DYLD_LIBRARY_PATH
190    if let Some(found) = lib_path_search(name) {
191        return Ok(found);
192    }
193
194    anyhow::bail!(
195        "libonnxruntime not found.\n\
196         Managed:  lean-ctx embeddings provision  (official CPU runtime, sha256-pinned)\n\
197         Or set ORT_DYLIB_PATH=<path> to point to the shared library.\n\
198         Install:  pip install onnxruntime  (Python bundles the .so)\n\
199         NixOS:    nix-shell -p onnxruntime\n\
200         Homebrew: brew install onnxruntime\n\
201         Searched: ORT_DYLIB_PATH, managed runtime dir, Nix store, \
202         well-known system dirs, LD_LIBRARY_PATH/DYLD_LIBRARY_PATH"
203    )
204}
205
206// ---------------------------------------------------------------------------
207// Platform-specific searches
208// ---------------------------------------------------------------------------
209
210/// Check Nix profile symlinks for `libonnxruntime`.
211///
212/// Nix maintains `/run/current-system/sw/lib/` (system profile),
213/// `/etc/profiles/per-user/$USER/lib/` (NixOS Home Manager per-user profile),
214/// and `~/.nix-profile/lib/` (legacy user profile symlink) as symlinks to the
215/// currently activated package versions — these are always authoritative.
216#[cfg(target_os = "linux")]
217fn nix_profile_search(name: &str) -> Option<PathBuf> {
218    let home = dirs::home_dir();
219    let user_profile = home
220        .as_ref()
221        .map(|h| h.join(".nix-profile").join("lib").join(name));
222    let candidates = [
223        Some(Path::new("/run/current-system/sw/lib").join(name)),
224        nix_per_user_lib(Path::new("/etc/profiles/per-user"), name),
225        user_profile,
226    ];
227    candidates.into_iter().flatten().find(|c| c.is_file())
228}
229
230/// Resolve the per-user Nix profile library path from `$USER`.
231///
232/// Returns `None` when `USER` is unset, empty, or contains path-traversal
233/// characters (`/`, `\0`, `..`).  The `base` parameter enables unit-testing
234/// without touching `/etc/profiles/per-user`.
235#[cfg(target_os = "linux")]
236fn nix_per_user_lib(base: &Path, name: &str) -> Option<PathBuf> {
237    let user = std::env::var("USER").ok()?;
238    if user.is_empty() || user.contains('/') || user.contains('\0') || user.contains("..") {
239        return None;
240    }
241    let candidate = base.join(&user).join("lib").join(name);
242    candidate.is_file().then_some(candidate)
243}
244
245/// Check well-known system directories for `libonnxruntime`.
246fn well_known_paths(name: &str) -> Option<PathBuf> {
247    // Platform-specific hints.
248    let dirs: &[&str] = if cfg!(target_os = "linux") {
249        &[
250            "/usr/lib",
251            "/usr/lib64",
252            "/usr/local/lib",
253            // Linuxbrew default prefix (the `onnxruntime` formula symlinks its
254            // dylib here). A custom prefix is covered by HOMEBREW_PREFIX below.
255            "/home/linuxbrew/.linuxbrew/lib",
256        ]
257    } else if cfg!(target_os = "macos") {
258        &["/usr/local/lib", "/opt/homebrew/lib", "/opt/local/lib"]
259    } else if cfg!(target_os = "windows") {
260        // On Windows, check next to the executable and common install paths.
261        &[]
262    } else {
263        &["/usr/lib", "/usr/local/lib"]
264    };
265
266    // Also check next to the executable (common for portable installs, macOS
267    // Frameworks, Windows sibling layout, and Linux $ORIGIN setups).
268    let exe_relative = || -> Option<PathBuf> {
269        let exe = std::env::current_exe().ok()?;
270        let dir = exe.parent()?;
271        let sibling = dir.join(name);
272        if sibling.is_file() {
273            return Some(sibling);
274        }
275        // macOS app bundle: executable in MyApp.app/Contents/MacOS/,
276        // library in MyApp.app/Contents/Frameworks/
277        #[cfg(target_os = "macos")]
278        {
279            let parent = dir.parent()?;
280            let fw = parent.join("Frameworks").join(name);
281            if fw.is_file() {
282                return Some(fw);
283            }
284        }
285        None
286    };
287    if let Some(path) = exe_relative() {
288        return Some(path);
289    }
290
291    // Honor an active Homebrew environment. `brew shellenv` exports
292    // HOMEBREW_PREFIX, so a binary launched from a brew-configured shell can
293    // locate the dylib regardless of platform or custom prefix — Apple Silicon
294    // (/opt/homebrew), Intel (/usr/local) and Linuxbrew
295    // (/home/linuxbrew/.linuxbrew) all symlink `onnxruntime` into <prefix>/lib.
296    if let Ok(prefix) = std::env::var("HOMEBREW_PREFIX") {
297        let candidate = Path::new(&prefix).join("lib").join(name);
298        if candidate.is_file() {
299            return Some(candidate);
300        }
301    }
302
303    for dir in dirs {
304        let candidate = Path::new(dir).join(name);
305        if candidate.is_file() {
306            return Some(candidate);
307        }
308    }
309
310    None
311}
312
313/// Scan `LD_LIBRARY_PATH` (Linux) or `DYLD_LIBRARY_PATH` (macOS) directories.
314fn lib_path_search(name: &str) -> Option<PathBuf> {
315    let var = if cfg!(target_os = "macos") {
316        "DYLD_LIBRARY_PATH"
317    } else {
318        "LD_LIBRARY_PATH"
319    };
320    let path = std::env::var(var).ok()?;
321    for segment in std::env::split_paths(&path) {
322        let candidate = segment.join(name);
323        if candidate.is_file() {
324            return Some(candidate);
325        }
326    }
327    None
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333
334    #[test]
335    fn dylib_filename_known_platform() {
336        let name = dylib_filename();
337        if cfg!(target_os = "linux") {
338            assert_eq!(name, "libonnxruntime.so");
339        } else if cfg!(target_os = "macos") {
340            assert_eq!(name, "libonnxruntime.dylib");
341        } else if cfg!(target_os = "windows") {
342            assert_eq!(name, "onnxruntime.dll");
343        }
344    }
345
346    #[test]
347    fn resolve_dylib_env_var_takes_precedence() {
348        let _env_lock = crate::core::data_dir::test_env_lock();
349        // Set ORT_DYLIB_PATH to a known file (/tmp is guaranteed to exist,
350        // but the file itself won't — this should still error with a clear
351        // message about the file not existing).
352        crate::test_env::set_var("ORT_DYLIB_PATH", "/nonexistent/foo.so");
353        let err = resolve_ort_dylib().unwrap_err();
354        assert!(err.to_string().contains("ORT_DYLIB_PATH"));
355        crate::test_env::remove_var("ORT_DYLIB_PATH");
356    }
357
358    #[test]
359    fn lib_path_search_no_library() {
360        // Should not crash when the env var is unset.
361        assert!(lib_path_search("nonexistent.so.42").is_none());
362    }
363
364    #[test]
365    fn well_known_paths_returns_none_for_nonsense() {
366        assert!(well_known_paths("this-library-surely-does-not-exist.so").is_none());
367    }
368
369    #[test]
370    fn homebrew_prefix_lib_is_searched() {
371        let _env_lock = crate::core::data_dir::test_env_lock();
372        // A dylib under $HOMEBREW_PREFIX/lib is discovered (covers Homebrew on
373        // any platform / custom prefix, incl. Linuxbrew). See issue #544.
374        let tmp = std::env::temp_dir().join(format!("lc-ort-hb-{}", std::process::id()));
375        let libdir = tmp.join("lib");
376        std::fs::create_dir_all(&libdir).unwrap();
377        let name = "libonnxruntime-test-marker.dylib";
378        std::fs::write(libdir.join(name), b"marker").unwrap();
379
380        crate::test_env::set_var("HOMEBREW_PREFIX", tmp.to_str().unwrap());
381        let found = well_known_paths(name);
382        crate::test_env::remove_var("HOMEBREW_PREFIX");
383        std::fs::remove_dir_all(&tmp).ok();
384
385        assert_eq!(found, Some(libdir.join(name)));
386    }
387
388    #[cfg(target_os = "linux")]
389    #[test]
390    fn nix_profile_search_no_panic() {
391        assert!(nix_profile_search("nonexistent.so").is_none());
392    }
393
394    #[cfg(target_os = "linux")]
395    #[test]
396    fn nix_per_user_lib_discovers_file_under_base() {
397        let _env_lock = crate::core::data_dir::test_env_lock();
398        let tmp = std::env::temp_dir().join(format!("lc-nix-pu-{}", std::process::id()));
399        let name = "libonnxruntime-test-marker.so";
400        let user = "testuser";
401        let libdir = tmp.join(user).join("lib");
402        std::fs::create_dir_all(&libdir).unwrap();
403        std::fs::write(libdir.join(name), b"marker").unwrap();
404
405        crate::test_env::set_var("USER", user);
406        let found = nix_per_user_lib(&tmp, name);
407        crate::test_env::remove_var("USER");
408        std::fs::remove_dir_all(&tmp).ok();
409
410        assert_eq!(found, Some(libdir.join(name)));
411    }
412
413    #[cfg(target_os = "linux")]
414    #[test]
415    fn nix_per_user_lib_rejects_traversal_in_user() {
416        let _env_lock = crate::core::data_dir::test_env_lock();
417        let tmp = std::env::temp_dir().join(format!("lc-nix-trv-{}", std::process::id()));
418        std::fs::create_dir_all(&tmp).unwrap();
419
420        // NUL bytes cannot be set via std::env::set_var (OS rejects them),
421        // but the contains('\0') guard is defense-in-depth for direct callers.
422        for bad in ["", "../etc", "foo/bar"] {
423            crate::test_env::set_var("USER", bad);
424            assert!(
425                nix_per_user_lib(&tmp, "lib.so").is_none(),
426                "USER={bad:?} should be rejected"
427            );
428        }
429        crate::test_env::remove_var("USER");
430        std::fs::remove_dir_all(&tmp).ok();
431    }
432}