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}