Skip to main content

rightkit_ort/
lib.rs

1//! Product-neutral ONNX Runtime dynamic-library resolution, environment and
2//! session setup for Right Suite apps.
3//!
4//! Merged from ScrapeRight `ort_common` (installed-bundle layout, system-DLL
5//! hazard) and HeardRight `heardright-onnx-asr` (environment, execution
6//! providers, session builder). Product environment variables and data roots
7//! stay in app adapters.
8
9/// The pinned `ort` crate, re-exported so apps that run their own inference reach
10/// `rightkit_ort::ort::{value::Tensor, session::Session, ...}` through this crate.
11/// They never declare a direct `ort` dependency, which keeps the release ownership
12/// scanner from seeing one and keeps the whole suite on a single `ort` pin.
13///
14/// ```no_run
15/// use rightkit_ort::ort::{session::Session, value::Tensor};
16///
17/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
18/// // Configure the runtime and environment first (see `configure_runtime`).
19/// let mut session = Session::builder()?.commit_from_file("model.onnx")?;
20/// let x = Tensor::from_array(([1usize, 4], vec![0.0f32; 4]))?;
21/// let _outputs = session.run(rightkit_ort::ort::inputs!["x" => x])?;
22/// # Ok(())
23/// # }
24/// ```
25#[cfg(feature = "session")]
26pub use ort;
27
28/// `half` (f16/bf16) as used by ort's `half` feature: `rightkit_ort::half::f16`.
29#[cfg(feature = "half")]
30pub use half;
31
32#[cfg(feature = "session")]
33pub mod environment;
34#[cfg(feature = "session")]
35pub mod probe;
36#[cfg(feature = "session")]
37pub mod session;
38
39#[cfg(feature = "session")]
40pub use environment::{
41    cpu_thread_budget, init_environment, init_environment_with_options, shared_pool_active,
42    EnvironmentInitOptions, EnvironmentInitReport, EnvironmentOptions, EnvironmentReport,
43    EnvironmentStatus, GlobalPool,
44};
45#[cfg(feature = "session")]
46pub use probe::{probe_providers, ProviderDiagnostic, ProviderKind};
47#[cfg(feature = "session")]
48pub use session::{BuiltSession, ExecutionProvider, SessionError, SessionOptions};
49
50use std::fmt;
51use std::path::{Path, PathBuf};
52use std::sync::Mutex;
53
54static CONFIGURED_RUNTIME: Mutex<Option<PathBuf>> = Mutex::new(None);
55
56/// Clear inherited `ORT_DYLIB_PATH` in release builds; no-op in debug builds.
57/// This keeps shell-exported development paths from steering shipped apps,
58/// consistent with the policy that inherited `ORT_DYLIB_PATH` fails closed.
59///
60/// Call at process startup, before any thread spawns, runtime binding or ORT
61/// use. Removing a process environment variable requires exclusive access to
62/// the environment; this function does not unload an already-loaded runtime.
63pub fn clear_inherited_runtime_for_release() {
64    clear_inherited_runtime_with(|| std::env::remove_var("ORT_DYLIB_PATH"));
65}
66
67// Injected removal keeps release/debug tests free of process env mutation.
68fn clear_inherited_runtime_with(remove_path: impl FnOnce()) {
69    if cfg!(not(debug_assertions)) {
70        remove_path();
71    }
72}
73
74/// Read a caller-named development override as an explicit runtime candidate.
75/// Unset or empty values return `None`; release builds always return `None`
76/// without reading the variable. Path validation remains in runtime resolution.
77pub fn developer_override_candidate(env_name: &str) -> Option<RuntimeCandidate> {
78    if cfg!(debug_assertions) {
79        nonempty_env_path(env_name)
80            .map(|path| RuntimeCandidate::new(path, CandidateSource::Explicit))
81    } else {
82        None
83    }
84}
85
86/// Return the last path successfully bound by this crate for this process,
87/// falling back to a non-empty `ORT_DYLIB_PATH` when no binding is recorded.
88/// Read-only: no resolution, file check, environment mutation or ORT loading.
89/// Missing-mode bindings are included; this is not proof of a loaded library.
90pub fn configured_runtime() -> Option<PathBuf> {
91    let recorded = CONFIGURED_RUNTIME
92        .lock()
93        .unwrap_or_else(|poisoned| poisoned.into_inner())
94        .clone();
95    recorded.or_else(|| nonempty_env_path("ORT_DYLIB_PATH"))
96}
97
98#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub enum CandidateSource {
100    Explicit,
101    Bundled,
102    AppData,
103    /// Caller-opted-in `<workspace>/tools/bin/<runtime filename>`.
104    LegacyDefault,
105}
106
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct RuntimeCandidate {
109    pub path: PathBuf,
110    pub source: CandidateSource,
111}
112
113impl RuntimeCandidate {
114    pub fn new(path: impl Into<PathBuf>, source: CandidateSource) -> Self {
115        Self {
116            path: path.into(),
117            source,
118        }
119    }
120}
121
122#[derive(Debug, Clone, PartialEq, Eq)]
123pub struct CandidateDiagnostic {
124    pub path: PathBuf,
125    pub source: CandidateSource,
126    pub absolute: bool,
127    pub expected_filename: bool,
128    pub exists: bool,
129    pub is_file: bool,
130}
131
132#[derive(Debug, Clone, PartialEq, Eq)]
133pub struct RuntimeSelection {
134    pub path: PathBuf,
135    pub source: CandidateSource,
136    pub diagnostics: Vec<CandidateDiagnostic>,
137}
138
139/// Binding policy; defaults retain strict file validation & conflict refusal.
140#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
141pub struct RuntimeBindingOptions {
142    /// Overwrite ORT_DYLIB_PATH, including an invalid existing value. This does
143    /// not replace a library already loaded by ort. Use before any ORT use.
144    pub force_replace: bool,
145    /// If no regular file resolves, bind the first missing candidate. Existing
146    /// directories, invalid filenames & unsafe paths remain errors.
147    pub allow_missing: bool,
148}
149
150#[derive(Debug, Clone, Copy, PartialEq, Eq)]
151pub enum RuntimeBindingStatus {
152    Ready,
153    /// Path recorded & bound, but absent. Callers own degraded-mode messaging.
154    Missing,
155}
156
157#[derive(Debug, Clone, PartialEq, Eq)]
158pub struct RuntimeBinding {
159    pub selection: RuntimeSelection,
160    pub status: RuntimeBindingStatus,
161}
162
163#[derive(Debug, Clone, PartialEq, Eq)]
164pub enum RuntimeError {
165    NoCandidates,
166    UnsafePath {
167        path: PathBuf,
168    },
169    UnexpectedFilename {
170        path: PathBuf,
171        expected: &'static str,
172    },
173    NotFound {
174        diagnostics: Vec<CandidateDiagnostic>,
175    },
176    Canonicalize {
177        path: PathBuf,
178        message: String,
179    },
180    AlreadyConfigured {
181        configured: PathBuf,
182        selected: PathBuf,
183    },
184    /// The Windows-ML copy in `System32` hangs at session init; apps must
185    /// supply their bundled runtime instead.
186    SystemRuntimeRejected {
187        path: PathBuf,
188    },
189}
190
191impl fmt::Display for RuntimeError {
192    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
193        match self {
194            Self::NoCandidates => write!(f, "no ONNX Runtime candidates were supplied"),
195            Self::UnsafePath { path } => {
196                write!(f, "runtime path must be absolute: {}", path.display())
197            }
198            Self::UnexpectedFilename { path, expected } => {
199                write!(f, "runtime path {} must end in {expected}", path.display())
200            }
201            Self::NotFound { .. } => {
202                write!(f, "no supplied ONNX Runtime candidate is a regular file")
203            }
204            Self::Canonicalize { path, message } => {
205                write!(f, "cannot canonicalize {}: {message}", path.display())
206            }
207            Self::SystemRuntimeRejected { path } => write!(
208                f,
209                "runtime {} is the OS-provided copy; bundle and supply an app runtime",
210                path.display()
211            ),
212            Self::AlreadyConfigured {
213                configured,
214                selected,
215            } => write!(
216                f,
217                "ORT_DYLIB_PATH is already {}, refusing replacement with {}",
218                configured.display(),
219                selected.display()
220            ),
221        }
222    }
223}
224
225impl std::error::Error for RuntimeError {}
226
227pub fn runtime_filename() -> &'static str {
228    #[cfg(target_os = "windows")]
229    {
230        "onnxruntime.dll"
231    }
232    #[cfg(target_os = "macos")]
233    {
234        "libonnxruntime.dylib"
235    }
236    #[cfg(all(not(target_os = "windows"), not(target_os = "macos")))]
237    {
238        "libonnxruntime.so"
239    }
240}
241
242pub fn inspect_candidate(candidate: &RuntimeCandidate) -> CandidateDiagnostic {
243    CandidateDiagnostic {
244        path: candidate.path.clone(),
245        source: candidate.source,
246        absolute: candidate.path.is_absolute(),
247        expected_filename: candidate.path.file_name().and_then(|name| name.to_str())
248            == Some(runtime_filename()),
249        exists: candidate.path.exists(),
250        is_file: candidate.path.is_file(),
251    }
252}
253
254pub fn resolve_runtime(
255    candidates: impl IntoIterator<Item = RuntimeCandidate>,
256) -> Result<RuntimeSelection, RuntimeError> {
257    let candidates: Vec<_> = candidates.into_iter().collect();
258    if candidates.is_empty() {
259        return Err(RuntimeError::NoCandidates);
260    }
261    let mut diagnostics = Vec::with_capacity(candidates.len());
262    for candidate in candidates {
263        let diagnostic = inspect_candidate(&candidate);
264        if !diagnostic.absolute {
265            return Err(RuntimeError::UnsafePath {
266                path: candidate.path,
267            });
268        }
269        if !diagnostic.expected_filename {
270            return Err(RuntimeError::UnexpectedFilename {
271                path: candidate.path,
272                expected: runtime_filename(),
273            });
274        }
275        if is_system_runtime(&candidate.path) {
276            return Err(RuntimeError::SystemRuntimeRejected {
277                path: candidate.path,
278            });
279        }
280        diagnostics.push(diagnostic.clone());
281        if diagnostic.is_file {
282            let path =
283                candidate
284                    .path
285                    .canonicalize()
286                    .map_err(|error| RuntimeError::Canonicalize {
287                        path: candidate.path,
288                        message: error.to_string(),
289                    })?;
290            return Ok(RuntimeSelection {
291                path,
292                source: candidate.source,
293                diagnostics,
294            });
295        }
296    }
297    Err(RuntimeError::NotFound { diagnostics })
298}
299
300/// True when the path sits under a Windows `System32`/`SysWOW64` directory.
301/// The OS copy of `onnxruntime.dll` (Windows ML) hangs at session init, so it
302/// is never an acceptable candidate (ScrapeRight `ort_common` finding).
303pub fn is_system_runtime(path: &Path) -> bool {
304    path.components().any(|component| {
305        component
306            .as_os_str()
307            .to_str()
308            .map(|name| {
309                name.eq_ignore_ascii_case("system32") || name.eq_ignore_ascii_case("syswow64")
310            })
311            .unwrap_or(false)
312    })
313}
314
315/// Resources directory of an installed application bundle, derived from its
316/// executable only (no checkout or working-directory fallback):
317/// macOS `Foo.app/Contents/MacOS/foo` -> `Contents/Resources`; Windows the
318/// executable directory; elsewhere `<exe dir>/resources`.
319pub fn installed_resource_dir(executable: &Path) -> Option<PathBuf> {
320    if !executable.is_absolute() {
321        return None;
322    }
323    let dir = executable.parent()?.to_path_buf();
324    let is = |p: &Path, name: &str| {
325        p.file_name()
326            .and_then(|n| n.to_str())
327            .map(|n| n.eq_ignore_ascii_case(name))
328            .unwrap_or(false)
329    };
330    #[cfg(target_os = "macos")]
331    {
332        if is(&dir, "MacOS") {
333            if let Some(contents) = dir.parent() {
334                if is(contents, "Contents") {
335                    return Some(contents.join("Resources"));
336                }
337            }
338        }
339        if is(&dir, "Resources") {
340            return Some(dir);
341        }
342        Some(dir.join("Resources"))
343    }
344    #[cfg(target_os = "windows")]
345    {
346        let _ = is;
347        Some(dir)
348    }
349    #[cfg(not(any(target_os = "macos", target_os = "windows")))]
350    {
351        if is(&dir, "resources") {
352            Some(dir)
353        } else {
354            Some(dir.join("resources"))
355        }
356    }
357}
358
359/// Bundled-runtime candidate for an installed app: `<resources>/<subdir>/<runtime file>`.
360/// Apps pass their own `subdir` (ScrapeRight/HeardRight/CodeRight all use `runtime`).
361pub fn installed_runtime_candidate(executable: &Path, subdir: &str) -> Option<RuntimeCandidate> {
362    let dir = installed_resource_dir(executable)?;
363    Some(RuntimeCandidate::new(
364        dir.join(subdir).join(runtime_filename()),
365        CandidateSource::Bundled,
366    ))
367}
368
369/// Explicit legacy candidate; never searched unless the caller supplies it.
370/// Relative roots are rejected rather than resolved against process cwd.
371pub fn legacy_runtime_candidate(workspace_root: &Path) -> Result<RuntimeCandidate, RuntimeError> {
372    if !workspace_root.is_absolute() {
373        return Err(RuntimeError::UnsafePath {
374            path: workspace_root.to_path_buf(),
375        });
376    }
377    Ok(RuntimeCandidate::new(
378        workspace_root
379            .join("tools")
380            .join("bin")
381            .join(runtime_filename()),
382        CandidateSource::LegacyDefault,
383    ))
384}
385
386/// Configure ORT only after resolving a caller-supplied, absolute regular file.
387/// Existing configuration is preserved and must canonicalize to the same file.
388pub fn configure_runtime(
389    candidates: impl IntoIterator<Item = RuntimeCandidate>,
390) -> Result<RuntimeSelection, RuntimeError> {
391    configure_runtime_with_options(candidates, &RuntimeBindingOptions::default())
392        .map(|binding| binding.selection)
393}
394
395/// Bind explicit candidates under caller-selected policy. Available regular
396/// files win in caller order; missing mode falls back to the first absent path
397/// only when no file resolves. Missing paths retain their supplied absolute
398/// spelling because they cannot be canonicalized.
399///
400/// Call before starting worker threads or using any ORT API: this mutates a
401/// process environment variable, not ort's already-loaded library handle.
402pub fn configure_runtime_with_options(
403    candidates: impl IntoIterator<Item = RuntimeCandidate>,
404    options: &RuntimeBindingOptions,
405) -> Result<RuntimeBinding, RuntimeError> {
406    let binding = configure_runtime_with_env(
407        candidates,
408        options,
409        nonempty_env_path("ORT_DYLIB_PATH"),
410        |path| std::env::set_var("ORT_DYLIB_PATH", path),
411    )?;
412    *CONFIGURED_RUNTIME
413        .lock()
414        .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(binding.selection.path.clone());
415    Ok(binding)
416}
417
418// Injected environment writer avoids process-global mutation in unit tests.
419fn configure_runtime_with_env(
420    candidates: impl IntoIterator<Item = RuntimeCandidate>,
421    options: &RuntimeBindingOptions,
422    configured: Option<PathBuf>,
423    set_path: impl FnOnce(&Path),
424) -> Result<RuntimeBinding, RuntimeError> {
425    let binding = match resolve_runtime(candidates) {
426        Ok(selection) => RuntimeBinding {
427            selection,
428            status: RuntimeBindingStatus::Ready,
429        },
430        Err(RuntimeError::NotFound { diagnostics }) if options.allow_missing => {
431            let Some(missing) = diagnostics.iter().find(|candidate| !candidate.exists) else {
432                return Err(RuntimeError::NotFound { diagnostics });
433            };
434            RuntimeBinding {
435                selection: RuntimeSelection {
436                    path: missing.path.clone(),
437                    source: missing.source,
438                    diagnostics,
439                },
440                status: RuntimeBindingStatus::Missing,
441            }
442        }
443        Err(error) => return Err(error),
444    };
445    if let Some(configured) = configured.filter(|_| !options.force_replace) {
446        if options.allow_missing && configured == binding.selection.path {
447            return Ok(binding);
448        }
449        let configured = canonical_if_file(&configured)?;
450        if configured != binding.selection.path {
451            return Err(RuntimeError::AlreadyConfigured {
452                configured,
453                selected: binding.selection.path,
454            });
455        }
456        return Ok(binding);
457    }
458    set_path(&binding.selection.path);
459    Ok(binding)
460}
461
462fn nonempty_env_path(name: &str) -> Option<PathBuf> {
463    std::env::var_os(name)
464        .filter(|value| !value.is_empty())
465        .map(PathBuf::from)
466}
467
468fn canonical_if_file(path: &Path) -> Result<PathBuf, RuntimeError> {
469    if !path.is_absolute() {
470        return Err(RuntimeError::UnsafePath {
471            path: path.to_path_buf(),
472        });
473    }
474    path.canonicalize()
475        .map_err(|error| RuntimeError::Canonicalize {
476            path: path.to_path_buf(),
477            message: error.to_string(),
478        })
479}
480
481#[cfg(test)]
482mod binding_tests {
483    use super::*;
484    use std::sync::atomic::{AtomicUsize, Ordering};
485
486    #[cfg(debug_assertions)]
487    #[test]
488    fn clearing_inherited_runtime_is_noop_in_debug() {
489        clear_inherited_runtime_with(|| panic!("debug must preserve inherited runtime"));
490    }
491
492    #[cfg(not(debug_assertions))]
493    #[test]
494    fn clearing_inherited_runtime_removes_it_in_release() {
495        let mut inherited = Some(PathBuf::from("shell-exported-dev-runtime"));
496        clear_inherited_runtime_with(|| inherited = None);
497        assert_eq!(inherited, None);
498    }
499
500    struct Fixture(PathBuf);
501
502    impl Fixture {
503        fn new() -> Self {
504            static NEXT: AtomicUsize = AtomicUsize::new(0);
505            let root = std::env::temp_dir().join(format!(
506                "rightkit-ort-binding-{}-{}",
507                std::process::id(),
508                NEXT.fetch_add(1, Ordering::Relaxed)
509            ));
510            std::fs::create_dir_all(&root).unwrap();
511            Self(root.canonicalize().unwrap())
512        }
513
514        fn candidate(&self, directory: &str, exists: bool) -> RuntimeCandidate {
515            let path = self.0.join(directory).join(runtime_filename());
516            if exists {
517                std::fs::create_dir_all(path.parent().unwrap()).unwrap();
518                std::fs::write(&path, b"not a real ORT binary").unwrap();
519            }
520            RuntimeCandidate::new(path, CandidateSource::Explicit)
521        }
522    }
523
524    impl Drop for Fixture {
525        fn drop(&mut self) {
526            let _ = std::fs::remove_dir_all(&self.0);
527        }
528    }
529
530    #[test]
531    fn binding_defaults_preserve_conflicts_and_require_files() {
532        let options = RuntimeBindingOptions::default();
533        assert!(!options.force_replace);
534        assert!(!options.allow_missing);
535        let fixture = Fixture::new();
536        let first = fixture.candidate("first", true);
537        let second = fixture.candidate("second", true);
538        let error = configure_runtime_with_env([second], &options, Some(first.path), |_| {
539            panic!("must not overwrite")
540        })
541        .unwrap_err();
542        assert!(matches!(error, RuntimeError::AlreadyConfigured { .. }));
543        let error = configure_runtime_with_env(
544            [fixture.candidate("missing", false)],
545            &options,
546            None,
547            |_| panic!("must not bind missing path by default"),
548        )
549        .unwrap_err();
550        assert!(matches!(error, RuntimeError::NotFound { .. }));
551    }
552
553    #[test]
554    fn forced_replacement_overwrites_existing_or_invalid_configuration() {
555        let fixture = Fixture::new();
556        let first = fixture.candidate("first", true);
557        let selected = fixture.candidate("second", true);
558        for configured in [first.path, PathBuf::from("invalid-relative-path")] {
559            let mut written = None;
560            let report = configure_runtime_with_env(
561                [selected.clone()],
562                &RuntimeBindingOptions {
563                    force_replace: true,
564                    ..Default::default()
565                },
566                Some(configured),
567                |path| written = Some(path.to_path_buf()),
568            )
569            .unwrap();
570            let expected = selected.path.canonicalize().unwrap();
571            assert_eq!(written, Some(expected.clone()));
572            assert_eq!(report.selection.path, expected);
573            assert_eq!(report.status, RuntimeBindingStatus::Ready);
574        }
575    }
576
577    #[test]
578    fn missing_mode_binds_records_and_reuses_first_absent_candidate() {
579        let fixture = Fixture::new();
580        let first = fixture.candidate("missing-first", false);
581        let second = fixture.candidate("missing-second", false);
582        let options = RuntimeBindingOptions {
583            allow_missing: true,
584            ..Default::default()
585        };
586        let mut written = None;
587        let report = configure_runtime_with_env([first.clone(), second], &options, None, |path| {
588            written = Some(path.to_path_buf())
589        })
590        .unwrap();
591        assert_eq!(report.status, RuntimeBindingStatus::Missing);
592        assert_eq!(report.selection.path, first.path);
593        assert_eq!(written, Some(first.path.clone()));
594        assert_eq!(report.selection.diagnostics.len(), 2);
595        assert!(report
596            .selection
597            .diagnostics
598            .iter()
599            .all(|d| !d.exists && !d.is_file));
600        let repeated =
601            configure_runtime_with_env([first.clone()], &options, Some(first.path), |_| {
602                panic!("matching binding must be preserved")
603            })
604            .unwrap();
605        assert_eq!(repeated.status, RuntimeBindingStatus::Missing);
606    }
607
608    #[test]
609    fn missing_mode_prefers_available_files_and_never_binds_directories() {
610        let fixture = Fixture::new();
611        let available = fixture.candidate("available", true);
612        let options = RuntimeBindingOptions {
613            allow_missing: true,
614            ..Default::default()
615        };
616        let report = configure_runtime_with_env(
617            [fixture.candidate("absent", false), available.clone()],
618            &options,
619            None,
620            |_| {},
621        )
622        .unwrap();
623        assert_eq!(report.status, RuntimeBindingStatus::Ready);
624        assert_eq!(
625            report.selection.path,
626            available.path.canonicalize().unwrap()
627        );
628        let directory = fixture.candidate("directory", false);
629        std::fs::create_dir_all(&directory.path).unwrap();
630        let error = configure_runtime_with_env([directory], &options, None, |_| {
631            panic!("directory must not be bound")
632        })
633        .unwrap_err();
634        assert!(matches!(error, RuntimeError::NotFound { .. }));
635    }
636
637    #[test]
638    fn forced_missing_binding_overwrites_existing_configuration() {
639        let fixture = Fixture::new();
640        let previous = fixture.candidate("previous", true);
641        let missing = fixture.candidate("missing", false);
642        let mut written = None;
643        let report = configure_runtime_with_env(
644            [missing.clone()],
645            &RuntimeBindingOptions {
646                force_replace: true,
647                allow_missing: true,
648            },
649            Some(previous.path),
650            |path| written = Some(path.to_path_buf()),
651        )
652        .unwrap();
653        assert_eq!(report.status, RuntimeBindingStatus::Missing);
654        assert_eq!(written, Some(missing.path));
655    }
656
657    #[test]
658    fn permissive_options_still_reject_unsafe_paths_and_system_runtime() {
659        let fixture = Fixture::new();
660        let options = RuntimeBindingOptions {
661            force_replace: true,
662            allow_missing: true,
663        };
664        for (candidate, expected) in [
665            (
666                RuntimeCandidate::new(runtime_filename(), CandidateSource::Explicit),
667                "relative",
668            ),
669            (
670                RuntimeCandidate::new(fixture.0.join("wrong.bin"), CandidateSource::Explicit),
671                "filename",
672            ),
673            (fixture.candidate("System32", false), "system"),
674        ] {
675            let error = configure_runtime_with_env([candidate], &options, None, |_| {
676                panic!("unsafe candidate must not be bound")
677            })
678            .unwrap_err();
679            assert!(match expected {
680                "relative" => matches!(error, RuntimeError::UnsafePath { .. }),
681                "filename" => matches!(error, RuntimeError::UnexpectedFilename { .. }),
682                _ => matches!(error, RuntimeError::SystemRuntimeRejected { .. }),
683            });
684        }
685    }
686}