Skip to main content

foundry_local_sdk/
foundry_local_manager.rs

1//! Top-level entry point for the Foundry Local SDK.
2//!
3//! [`FoundryLocalManager`] initialises the native core library, provides access
4//! to the model [`Catalog`], and can start / stop the local web service. While a
5//! handle is alive it is shared process-wide (see [`FoundryLocalManager::create`]).
6
7use std::sync::atomic::AtomicBool;
8use std::sync::{Arc, Mutex, OnceLock, Weak};
9
10use tokio::sync::Mutex as AsyncMutex;
11
12use crate::catalog::Catalog;
13use crate::configuration::{FoundryLocalConfig, Logger};
14use crate::detail::api::Api;
15use crate::detail::manager::{EpProgressCallback, NativeManager};
16use crate::detail::task::spawn_blocking;
17use crate::error::{FoundryLocalError, Result};
18use crate::types::{EpDownloadResult, EpInfo};
19
20/// Process-wide bookkeeping for the manager singleton.
21///
22/// The native core permits only one live `flManager` and requires it to remain
23/// valid until every `Model`/`Session` derived from it is destroyed. Those
24/// derived handles keep the inner [`NativeManager`] alive via [`Arc`], but *not*
25/// the outer [`FoundryLocalManager`]. Tracking only the outer would let its
26/// [`Weak`] expire while the native manager is still alive through a derived
27/// handle, so the next [`create`](FoundryLocalManager::create) would call
28/// `Manager_Create` again and be rejected.
29///
30/// We therefore track both lifetimes:
31/// * `outer` — the live wrapper, so concurrent callers share the *same* handle
32///   (and thus a single web-service lock); and
33/// * `native` — the inner manager, alive via the outer *or* any derived handle,
34///   so a caller can reuse the existing native manager (rebuilding a fresh
35///   wrapper) instead of attempting a second, rejected `Manager_Create`.
36#[derive(Default)]
37struct SharedInstance {
38    outer: Weak<FoundryLocalManager>,
39    native: Weak<NativeManager>,
40}
41
42/// Process-wide slot holding the shared-instance bookkeeping.
43///
44/// Both handles are [`Weak`], so the global never keeps the manager alive past
45/// its last strong reference: teardown still runs deterministically via [`Drop`]
46/// when the final strong reference (outer or derived) is released — while the
47/// ORT runtime is alive and before the library's C++ static destructors run.
48///
49/// Wrapped in a [`OnceLock`] (rather than a `const` `Mutex::new`) to keep the
50/// crate compatible with its minimum supported Rust version.
51static INSTANCE: OnceLock<Mutex<SharedInstance>> = OnceLock::new();
52
53/// The lazily-initialised slot holding the shared-instance bookkeeping.
54fn instance_slot() -> &'static Mutex<SharedInstance> {
55    INSTANCE.get_or_init(|| Mutex::new(SharedInstance::default()))
56}
57
58/// Primary entry point for interacting with Foundry Local.
59///
60/// Obtain a handle with [`FoundryLocalManager::create`]. While at least one
61/// handle is alive, every caller shares the same instance.
62pub struct FoundryLocalManager {
63    native: Arc<NativeManager>,
64    catalog: Catalog,
65    urls: Mutex<Vec<String>>,
66    /// Serialises web-service start/stop so concurrent callers cannot race on
67    /// the native lifecycle state (which the C++ core mutates without a lock).
68    web_service_lock: AsyncMutex<()>,
69    /// Application logger (stub — not yet wired into the native core).
70    _logger: Option<Box<dyn Logger>>,
71}
72
73type EpDownloadProgressCallback = Box<dyn FnMut(&str, f64) + Send + 'static>;
74
75/// Builder for configuring and running execution provider downloads.
76pub struct EpDownloadBuilder<'a> {
77    manager: &'a FoundryLocalManager,
78    names: Option<Vec<String>>,
79    progress_callback: Option<EpDownloadProgressCallback>,
80    cancel_flag: Option<Arc<AtomicBool>>,
81}
82
83impl<'a> EpDownloadBuilder<'a> {
84    fn new(manager: &'a FoundryLocalManager) -> Self {
85        Self {
86            manager,
87            names: None,
88            progress_callback: None,
89            cancel_flag: None,
90        }
91    }
92
93    /// Download only the named execution providers.
94    pub fn names<I, S>(mut self, names: I) -> Self
95    where
96        I: IntoIterator<Item = S>,
97        S: Into<String>,
98    {
99        self.names = Some(names.into_iter().map(Into::into).collect());
100        self
101    }
102
103    /// Report per-EP download progress as `(ep_name, percent)`.
104    pub fn progress<F>(mut self, callback: F) -> Self
105    where
106        F: FnMut(&str, f64) + Send + 'static,
107    {
108        self.progress_callback = Some(Box::new(callback));
109        self
110    }
111
112    /// Cancel the download when `cancel_flag` is set to `true`.
113    pub fn cancel(mut self, cancel_flag: Arc<AtomicBool>) -> Self {
114        self.cancel_flag = Some(cancel_flag);
115        self
116    }
117
118    /// Run the configured execution provider download.
119    pub async fn run(self) -> Result<EpDownloadResult> {
120        self.manager
121            .download_and_register_eps_impl(self.names, self.progress_callback, self.cancel_flag)
122            .await
123    }
124}
125
126impl FoundryLocalManager {
127    /// Initialise the SDK and return a shared handle to the manager.
128    ///
129    /// While at least one handle is alive — this `Arc`, or any
130    /// [`Model`](crate::Model), client, or session derived from it, each of
131    /// which keeps the native manager alive — every call returns a handle to the
132    /// **same** native manager and the `config` passed to later calls is ignored.
133    /// If the `Arc` returned here is dropped while a derived handle is still
134    /// alive, a subsequent call rebuilds a lightweight wrapper around the
135    /// still-live native manager rather than creating a second one (the core
136    /// permits only one). Once every handle is gone, the next call performs a
137    /// fresh initialisation from the new `config`.
138    ///
139    /// Teardown runs via [`Drop`] when the final handle is released — not via a
140    /// process-exit hook — so the native manager (and its EP unregistration)
141    /// shuts down while the engine / ORT runtime is still alive. This matches
142    /// the C++ SDK's local-`Manager` semantics and avoids the WebGPU
143    /// `ReleaseEpFactory` teardown throw (ORT #29206).
144    pub fn create(config: FoundryLocalConfig) -> Result<Arc<Self>> {
145        // Hold the lock across the whole decision so the reuse check, any native
146        // creation, and the slot update are atomic; concurrent callers then
147        // observe and share the result. A poisoned lock is recoverable: the
148        // guarded weak handles are valid regardless of panics.
149        let mut slot = instance_slot()
150            .lock()
151            .unwrap_or_else(|poisoned| poisoned.into_inner());
152
153        // 1. A live outer wrapper already exists: share it.
154        if let Some(existing) = slot.outer.upgrade() {
155            return Ok(existing);
156        }
157
158        // 2. The outer wrapper is gone, but the native manager is still alive via
159        //    a derived Model/client/session. Rebuild a wrapper around it rather
160        //    than calling `Manager_Create` again (the core permits only one live
161        //    manager and would reject a second creation).
162        if let Some(native) = slot.native.upgrade() {
163            let manager = Self::wrap_existing(native, config)?;
164            slot.outer = Arc::downgrade(&manager);
165            return Ok(manager);
166        }
167
168        // 3. Nothing is alive: perform a full initialisation.
169        let manager = Self::initialise(config)?;
170        slot.native = Arc::downgrade(&manager.native);
171        slot.outer = Arc::downgrade(&manager);
172        Ok(manager)
173    }
174
175    /// Perform a full initialisation: load the library, create the native
176    /// manager, and build the catalog view and outer wrapper.
177    fn initialise(mut config: FoundryLocalConfig) -> Result<Arc<Self>> {
178        let api = Arc::new(Api::load(config.library_path_ref())?);
179        let logger = config.take_logger();
180        let native_config = config.build_native(&api)?;
181
182        let native = Arc::new(NativeManager::create(
183            Arc::clone(&api),
184            native_config.as_ptr(),
185        )?);
186
187        let catalog_ptr = native.catalog_ptr()?;
188        let catalog = Catalog::new(Arc::clone(&api), catalog_ptr, Arc::clone(&native))?;
189
190        Ok(Arc::new(FoundryLocalManager {
191            native,
192            catalog,
193            urls: Mutex::new(Vec::new()),
194            web_service_lock: AsyncMutex::new(()),
195            _logger: logger,
196        }))
197    }
198
199    /// Build a fresh outer wrapper around an already-live native manager.
200    ///
201    /// Reuses the existing native manager (and its loaded library), so no second
202    /// `Manager_Create` is attempted; only the outer-side state (catalog view,
203    /// URL cache, web-service lock, logger) is rebuilt. The rest of `config` is
204    /// ignored, matching the process-wide singleton contract.
205    fn wrap_existing(
206        native: Arc<NativeManager>,
207        mut config: FoundryLocalConfig,
208    ) -> Result<Arc<Self>> {
209        let logger = config.take_logger();
210        let catalog_ptr = native.catalog_ptr()?;
211        let catalog = Catalog::new(native.api(), catalog_ptr, Arc::clone(&native))?;
212
213        // The live native manager may already be running a web service (a prior
214        // outer wrapper started it, then was dropped while a derived handle kept
215        // the native alive). Seed the URL cache from native so `urls()` reflects
216        // the running service instead of misreporting it as stopped. This is a
217        // non-throwing native getter that returns empty when nothing is running.
218        let urls = native.web_service_urls().unwrap_or_default();
219
220        Ok(Arc::new(FoundryLocalManager {
221            native,
222            catalog,
223            urls: Mutex::new(urls),
224            web_service_lock: AsyncMutex::new(()),
225            _logger: logger,
226        }))
227    }
228
229    /// Access the model catalog.
230    pub fn catalog(&self) -> &Catalog {
231        &self.catalog
232    }
233
234    /// Begin a graceful shutdown of the local engine.
235    ///
236    /// Stops the web service, prevents new model loads, stops existing
237    /// sessions, and unloads models. Idempotent and safe to call from any
238    /// thread.
239    ///
240    /// Calling this is optional: the native manager is released automatically
241    /// when the last handle is dropped. Use it when you want to deterministically
242    /// wind the engine down before releasing the handle. After calling
243    /// `shutdown`, the manager should not be used for further inference.
244    pub fn shutdown(&self) -> Result<()> {
245        self.native.shutdown()
246    }
247
248    /// URLs that the local web service is listening on.
249    ///
250    /// Empty until [`Self::start_web_service`] has been called.
251    pub fn urls(&self) -> Result<Vec<String>> {
252        let lock = self.urls.lock().map_err(|_| FoundryLocalError::Internal {
253            reason: "Failed to acquire urls lock".into(),
254        })?;
255        Ok(lock.clone())
256    }
257
258    /// Start the local web service.
259    ///
260    /// The listening URLs are stored internally and can be retrieved via
261    /// [`Self::urls`] after this method returns.
262    pub async fn start_web_service(&self) -> Result<()> {
263        let _guard = self.web_service_lock.lock().await;
264        let native = Arc::clone(&self.native);
265        let urls = spawn_blocking(move || {
266            native.web_service_start()?;
267            native.web_service_urls()
268        })
269        .await?;
270        *self.urls.lock().map_err(|_| FoundryLocalError::Internal {
271            reason: "Failed to acquire urls lock".into(),
272        })? = urls;
273        Ok(())
274    }
275
276    /// Stop the local web service.
277    pub async fn stop_web_service(&self) -> Result<()> {
278        let _guard = self.web_service_lock.lock().await;
279        let native = Arc::clone(&self.native);
280        spawn_blocking(move || native.web_service_stop()).await?;
281        self.urls
282            .lock()
283            .map_err(|_| FoundryLocalError::Internal {
284                reason: "Failed to acquire urls lock".into(),
285            })?
286            .clear();
287        Ok(())
288    }
289
290    /// Discover available execution providers and their registration status.
291    pub fn discover_eps(&self) -> Result<Vec<EpInfo>> {
292        self.native.discover_eps()
293    }
294
295    /// Download and register execution providers.
296    ///
297    /// If `names` is `None` or empty, all available EPs are downloaded.
298    /// Otherwise only the named EPs are downloaded and registered.
299    pub async fn download_and_register_eps(
300        &self,
301        names: Option<&[&str]>,
302    ) -> Result<EpDownloadResult> {
303        let names = names.map(|n| n.iter().map(|s| s.to_string()).collect::<Vec<_>>());
304        self.download_and_register_eps_impl(names, None, None).await
305    }
306
307    /// Download and register execution providers, reporting per-EP progress.
308    ///
309    /// If `names` is `None` or empty, all available EPs are downloaded.
310    /// Otherwise only the named EPs are downloaded and registered.
311    ///
312    /// `progress_callback` receives `(ep_name, percent)` where `percent`
313    /// ranges from 0.0 to 100.0 as each EP downloads.
314    pub async fn download_and_register_eps_with_progress<F>(
315        &self,
316        names: Option<&[&str]>,
317        progress_callback: F,
318    ) -> Result<EpDownloadResult>
319    where
320        F: FnMut(&str, f64) + Send + 'static,
321    {
322        let names = names.map(|n| n.iter().map(|s| s.to_string()).collect::<Vec<_>>());
323        self.download_and_register_eps_impl(names, Some(Box::new(progress_callback)), None)
324            .await
325    }
326
327    /// Configure and run execution provider downloads with a builder.
328    ///
329    /// Use this for call sites that need names, progress, cancellation, or
330    /// future download options.
331    pub fn download_and_register_eps_builder(&self) -> EpDownloadBuilder<'_> {
332        EpDownloadBuilder::new(self)
333    }
334
335    async fn download_and_register_eps_impl(
336        &self,
337        names: Option<Vec<String>>,
338        progress_callback: Option<EpDownloadProgressCallback>,
339        cancel_flag: Option<Arc<AtomicBool>>,
340    ) -> Result<EpDownloadResult> {
341        let native = Arc::clone(&self.native);
342
343        // Snapshot requested EP names (default: all discoverable). A discovery
344        // failure must propagate rather than collapse into an empty set, which
345        // would silently invoke the "download all" path and report success for
346        // zero requested providers.
347        let requested: Vec<String> = match &names {
348            Some(n) if !n.is_empty() => n.clone(),
349            _ => native.discover_eps()?.into_iter().map(|e| e.name).collect(),
350        };
351
352        let (message, after) = spawn_blocking(move || {
353            let name_refs: Option<Vec<&str>> = names
354                .as_ref()
355                .map(|n| n.iter().map(String::as_str).collect());
356            let progress: Option<EpProgressCallback> =
357                progress_callback.map(|cb| cb as EpProgressCallback);
358            let message =
359                native.download_and_register_eps(name_refs.as_deref(), progress, cancel_flag)?;
360            // Re-query registration state to synthesise the per-EP result; an
361            // observation failure here must surface, not be masked as empty.
362            let after = native.discover_eps()?;
363            Ok::<(Option<String>, Vec<EpInfo>), FoundryLocalError>((message, after))
364        })
365        .await?;
366
367        let registered_eps: Vec<String> = requested
368            .iter()
369            .filter(|name| after.iter().any(|e| &e.name == *name && e.is_registered))
370            .cloned()
371            .collect();
372        let failed_eps: Vec<String> = requested
373            .iter()
374            .filter(|name| !registered_eps.contains(*name))
375            .cloned()
376            .collect();
377
378        let success = message.is_none() && failed_eps.is_empty();
379        let status = match &message {
380            None => "All requested execution providers were registered successfully.".to_string(),
381            Some(msg) if msg.is_empty() => {
382                "One or more execution providers failed to register.".to_string()
383            }
384            Some(msg) => msg.clone(),
385        };
386
387        let result = EpDownloadResult {
388            success,
389            status,
390            registered_eps,
391            failed_eps,
392        };
393
394        // Invalidate the catalog cache if any EP was newly registered so the next
395        // access re-fetches models with the updated set of available EPs.
396        if result.success || !result.registered_eps.is_empty() {
397            let _ = self.catalog.update_models().await;
398        }
399
400        Ok(result)
401    }
402}