Skip to main content

alef_component_runtime/
manager.rs

1use crate::loader::{decode_hash, validate_manifest};
2use crate::manifest::COMPONENT_MANIFEST_SCHEMA;
3use crate::{
4    ArtifactCache, CachedArtifact, ComponentDeliveryMode, ComponentError, ComponentLock, ComponentLockEntry,
5    ComponentRequirements, LoadedComponent, TrustPolicy,
6};
7use alef_component_abi::AlefHostApiV1;
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10use std::sync::{Arc, Mutex, PoisonError};
11
12/// Where a configured component currently stands, independent of its on-disk location (see
13/// [`ComponentManager::cache_path`] for that). Generated bindings expose this as both a
14/// stable string tag and a numeric code (`alef_component_status`/`alef_component_status_code`
15/// in `alef::backends::native_components`) so callers in any host language can branch on it
16/// without parsing free-form text.
17#[derive(Clone, Debug, PartialEq, Eq)]
18pub enum ComponentStatus {
19    /// Loaded and its contracts activated in this process.
20    Ready,
21    /// Downloaded and verified on disk, but not yet loaded in this process.
22    Cached,
23    /// Not yet present in the on-disk cache.
24    NotDownloaded,
25    /// Linked into the binding ahead of time for this target; never downloaded or loaded
26    /// through this manager.
27    Bundled,
28    /// This host/target does not support downloading native components at all.
29    Unsupported { reason: String },
30}
31
32pub struct ComponentManager {
33    cache: ArtifactCache,
34    target: String,
35    entries: HashMap<String, ComponentLockEntry>,
36    loaded: Mutex<HashMap<String, Arc<LoadedComponent>>>,
37    /// One lock per (component, contract) key, used to coalesce concurrent `ensure_contract`
38    /// calls for the same key onto a single download-and-load instead of racing duplicates;
39    /// a call for a different key gets its own lock and proceeds independently. See
40    /// [`Self::contract_lock`].
41    key_locks: Mutex<HashMap<String, Arc<Mutex<()>>>>,
42    /// Artifacts this process has already installed and verified, keyed by the lock entry's
43    /// own content-addressed `sha256` digest. A component that provides more than one
44    /// contract shares one artifact across several `ensure_contract` calls; without this, each
45    /// call would re-read and re-hash the same (potentially large) dylib. On-disk tampering
46    /// with an artifact after this process has verified it once is out of scope: entries here
47    /// are trusted for the rest of the process, not re-checked against the disk.
48    verified_artifacts: Mutex<HashMap<String, CachedArtifact>>,
49    /// Whether the lock this manager was built from had zero artifacts *before* target
50    /// filtering -- the shape `alef generate` bootstraps when a crate declares components but
51    /// `alef component lock` has never run (see
52    /// `alef::bin_cli::component_commands::bootstrap_missing_component_locks`). Distinguished
53    /// from "no artifact for this particular target", which is a normal, populated lock that
54    /// simply was not built for the requested target.
55    lock_is_empty: bool,
56    host: AlefHostApiV1,
57}
58
59// SAFETY: `LoadedComponent` is thread-safe metadata and the host callback
60// context is required by the ABI to remain valid for calls from component
61// threads. Mutation is protected by the manager mutex. ~keep
62unsafe impl Send for ComponentManager {}
63unsafe impl Sync for ComponentManager {}
64
65impl ComponentManager {
66    pub fn from_lock(
67        lock: ComponentLock,
68        cache_root: impl Into<PathBuf>,
69        target: impl Into<String>,
70        host: AlefHostApiV1,
71    ) -> Result<Self, ComponentError> {
72        if lock.schema_version != COMPONENT_MANIFEST_SCHEMA {
73            return Err(ComponentError::UnsupportedLockSchema(lock.schema_version));
74        }
75        let lock_is_empty = lock.artifacts.is_empty();
76        let target = target.into();
77        let mut entries = HashMap::new();
78        for artifact in lock
79            .artifacts
80            .into_iter()
81            .filter(|artifact| artifact.identity.target == target)
82        {
83            let id = artifact.identity.component.clone();
84            if entries.insert(id.clone(), artifact).is_some() {
85                return Err(ComponentError::DuplicateLockEntry {
86                    component_id: id,
87                    target,
88                });
89            }
90        }
91        Ok(Self {
92            cache: ArtifactCache::new(cache_root, TrustPolicy::EmbeddedKeys(lock.public_keys)),
93            target,
94            entries,
95            loaded: Mutex::new(HashMap::new()),
96            key_locks: Mutex::new(HashMap::new()),
97            verified_artifacts: Mutex::new(HashMap::new()),
98            lock_is_empty,
99            host,
100        })
101    }
102
103    /// Disable network downloads: [`Self::ensure_contract`] and [`Self::prefetch`] fail a
104    /// missing artifact with [`ComponentError::Offline`] instead of attempting a request. An
105    /// artifact already cached on disk is unaffected. Generated bindings call this when the
106    /// crate-specific `{CRATE}_COMPONENT_OFFLINE` environment variable is set.
107    #[must_use]
108    pub fn offline(mut self, offline: bool) -> Self {
109        self.cache = self.cache.offline(offline);
110        self
111    }
112
113    /// Load one contract a component provides.
114    ///
115    /// `entry_symbol` is that contract's NUL-terminated producer entry-point
116    /// symbol (`alef::codegen::component::entry_point_symbol`); the caller
117    /// supplies it because computing it requires the same identifier-casing
118    /// logic the producer used, which this crate does not depend on.
119    ///
120    /// Concurrent calls for the *same* `(component_id, contract_name)` coalesce onto one
121    /// download-and-load: a second caller waits behind the first instead of racing a
122    /// duplicate download or `dlopen`. A call for a different key is unaffected and proceeds
123    /// immediately -- the mutex guarding the loaded-component map is only ever held for a
124    /// map lookup or insert, never across I/O.
125    pub fn ensure_contract(
126        &self,
127        component_id: &str,
128        contract_name: &str,
129        entry_symbol: &[u8],
130    ) -> Result<Arc<LoadedComponent>, ComponentError> {
131        let cache_key = loaded_cache_key(component_id, contract_name);
132        if let Some(component) = self.loaded_component(&cache_key) {
133            return Ok(component);
134        }
135
136        let key_lock = self.contract_lock(cache_key.clone());
137        let _guard = key_lock.lock().unwrap_or_else(PoisonError::into_inner);
138        // Re-check now that this call holds the per-key lock: a concurrent caller for the
139        // same key may have finished the work while this call was waiting for it.
140        if let Some(component) = self.loaded_component(&cache_key) {
141            return Ok(component);
142        }
143
144        let entry = self.entry(component_id)?;
145        let requirements = contract_requirements(entry, contract_name)?;
146        let cached = self.cached_artifact(entry)?;
147        validate_manifest(&cached.manifest, &requirements)?;
148        let component = Arc::new(LoadedComponent::load(
149            cached.library,
150            entry_symbol,
151            &requirements,
152            self.host,
153        )?);
154        self.insert_loaded(cache_key, Arc::clone(&component));
155        Ok(component)
156    }
157
158    pub fn prefetch(&self, component_ids: &[&str]) -> Result<Vec<CachedArtifact>, ComponentError> {
159        component_ids
160            .iter()
161            .map(|component_id| self.cached_artifact(self.entry(component_id)?))
162            .collect()
163    }
164
165    fn loaded_component(&self, cache_key: &str) -> Option<Arc<LoadedComponent>> {
166        self.loaded
167            .lock()
168            .unwrap_or_else(PoisonError::into_inner)
169            .get(cache_key)
170            .cloned()
171    }
172
173    fn insert_loaded(&self, cache_key: String, component: Arc<LoadedComponent>) {
174        self.loaded
175            .lock()
176            .unwrap_or_else(PoisonError::into_inner)
177            .insert(cache_key, component);
178    }
179
180    /// The per-`cache_key` lock used to coalesce concurrent [`Self::ensure_contract`] calls.
181    /// The outer `key_locks` mutex is only ever held for the lookup-or-insert below, never
182    /// across the download/load work the returned lock guards.
183    fn contract_lock(&self, cache_key: String) -> Arc<Mutex<()>> {
184        Arc::clone(
185            self.key_locks
186                .lock()
187                .unwrap_or_else(PoisonError::into_inner)
188                .entry(cache_key)
189                .or_insert_with(|| Arc::new(Mutex::new(()))),
190        )
191    }
192
193    /// Install `entry`'s artifact, reusing an in-process-verified copy when this process has
194    /// already installed it (see [`Self::verified_artifacts`]).
195    fn cached_artifact(&self, entry: &ComponentLockEntry) -> Result<CachedArtifact, ComponentError> {
196        if let Some(cached) = self
197            .verified_artifacts
198            .lock()
199            .unwrap_or_else(PoisonError::into_inner)
200            .get(&entry.sha256)
201            .cloned()
202        {
203            return Ok(cached);
204        }
205        let cached = self.cache.install(entry)?;
206        self.verified_artifacts
207            .lock()
208            .unwrap_or_else(PoisonError::into_inner)
209            .insert(entry.sha256.clone(), cached.clone());
210        Ok(cached)
211    }
212
213    /// Report where `component_id` currently stands. Unlike [`Self::ensure_contract`],
214    /// [`Self::prefetch`], and [`Self::cache_path`], a `Bundled` entry is a legitimate answer
215    /// here rather than a failure: it is this manager's way of saying "linked in ahead of
216    /// time, never downloaded or loaded through me", not an error.
217    pub fn status(&self, component_id: &str) -> Result<ComponentStatus, ComponentError> {
218        let entry = self.lookup(component_id)?;
219        if entry.mode == ComponentDeliveryMode::Bundled {
220            return Ok(ComponentStatus::Bundled);
221        }
222        let loaded_prefix = format!("{component_id}::");
223        if self
224            .loaded
225            .lock()
226            .unwrap_or_else(PoisonError::into_inner)
227            .keys()
228            .any(|key| key.starts_with(&loaded_prefix))
229        {
230            return Ok(ComponentStatus::Ready);
231        }
232        let root = self.cache.object_path(entry)?;
233        if root.join("component.json").is_file() {
234            Ok(ComponentStatus::Cached)
235        } else {
236            Ok(ComponentStatus::NotDownloaded)
237        }
238    }
239
240    pub fn cache_path(&self, component_id: &str) -> Result<PathBuf, ComponentError> {
241        self.cache.object_path(self.entry(component_id)?)
242    }
243
244    #[must_use]
245    pub fn cache_root(&self) -> &Path {
246        self.cache.root()
247    }
248
249    #[must_use]
250    pub fn target(&self) -> &str {
251        &self.target
252    }
253
254    /// Look up a component's lock entry for this manager's target, with no restriction on
255    /// its delivery mode. [`Self::status`] uses this directly since a `Bundled` entry is a
256    /// legitimate status to report, not a failure.
257    ///
258    /// A miss against a lock that was empty before target filtering reports
259    /// [`ComponentError::ComponentsNotLocked`] instead of the generic
260    /// [`ComponentError::ArtifactNotFound`]: `alef component lock` has never populated it, so
261    /// naming the fix is more useful than reporting a missing target.
262    fn lookup(&self, component_id: &str) -> Result<&ComponentLockEntry, ComponentError> {
263        self.entries.get(component_id).ok_or_else(|| {
264            if self.lock_is_empty {
265                ComponentError::ComponentsNotLocked {
266                    component_id: component_id.to_owned(),
267                }
268            } else {
269                ComponentError::ArtifactNotFound {
270                    component_id: component_id.to_owned(),
271                    target: self.target.clone(),
272                }
273            }
274        })
275    }
276
277    /// Like [`Self::lookup`], but additionally rejects a `Bundled` entry -- one this manager
278    /// cannot download -- before any cache I/O runs. Every caller that actually needs to
279    /// touch the cache (`ensure_contract`, `prefetch`, `cache_path`) reaches it only through
280    /// this method.
281    fn entry(&self, component_id: &str) -> Result<&ComponentLockEntry, ComponentError> {
282        let entry = self.lookup(component_id)?;
283        if entry.mode == ComponentDeliveryMode::Bundled {
284            return Err(ComponentError::BundledComponentNotLoadable {
285                component: component_id.to_owned(),
286            });
287        }
288        Ok(entry)
289    }
290}
291
292fn loaded_cache_key(component_id: &str, contract_name: &str) -> String {
293    format!("{component_id}::{contract_name}")
294}
295
296fn contract_requirements(
297    entry: &ComponentLockEntry,
298    contract_name: &str,
299) -> Result<ComponentRequirements, ComponentError> {
300    let provided = entry
301        .provides
302        .iter()
303        .find(|provided| provided.contract == contract_name)
304        .ok_or_else(|| ComponentError::ContractNotProvided {
305            component_id: entry.identity.component.clone(),
306            contract: contract_name.to_owned(),
307        })?;
308    Ok(ComponentRequirements {
309        component_id: entry.identity.component.clone(),
310        contract_name: contract_name.to_owned(),
311        contract_hash: decode_hash(&provided.contract_hash)?,
312        feature_set_hash: Some(decode_hash(&entry.identity.feature_hash)?),
313    })
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319    use crate::ComponentIdentity;
320    use std::collections::BTreeMap;
321    use std::ffi::c_void;
322
323    fn host() -> AlefHostApiV1 {
324        AlefHostApiV1 {
325            struct_size: 0,
326            abi_major: 0,
327            abi_minor: 0,
328            context: core::ptr::null_mut::<c_void>(),
329            log: None,
330        }
331    }
332
333    fn lock(target: &str) -> ComponentLock {
334        lock_with_mode(target, ComponentDeliveryMode::Download)
335    }
336
337    fn lock_with_mode(target: &str, mode: ComponentDeliveryMode) -> ComponentLock {
338        ComponentLock {
339            schema_version: COMPONENT_MANIFEST_SCHEMA,
340            public_keys: BTreeMap::new(),
341            artifacts: vec![ComponentLockEntry {
342                identity: ComponentIdentity {
343                    crate_name: "demo-core".into(),
344                    component: "demo".into(),
345                    version: "1.0.0".into(),
346                    target: target.into(),
347                    feature_hash: hex::encode([2; 32]),
348                    contract_hash: hex::encode([1; 32]),
349                },
350                provides: vec![crate::ComponentProvidedContract {
351                    contract: "engine".into(),
352                    interface_version: 1,
353                    contract_hash: hex::encode([1; 32]),
354                    implementation: "demo::Demo".into(),
355                }],
356                mode,
357                url: "file:///does/not/exist".into(),
358                sha256: hex::encode([3; 32]),
359                size: 0,
360                manifest_sha256: hex::encode([4; 32]),
361                key_id: String::new(),
362            }],
363        }
364    }
365
366    #[test]
367    fn bootstrap_empty_lock_reports_components_not_locked_instead_of_artifact_not_found() {
368        let dir = tempfile::tempdir().unwrap();
369        let empty = ComponentLock {
370            schema_version: COMPONENT_MANIFEST_SCHEMA,
371            public_keys: BTreeMap::new(),
372            artifacts: Vec::new(),
373        };
374        let manager = ComponentManager::from_lock(empty, dir.path(), "target-a", host()).unwrap();
375
376        assert!(matches!(
377            manager.status("demo"),
378            Err(ComponentError::ComponentsNotLocked { .. })
379        ));
380        assert!(matches!(
381            manager.ensure_contract("demo", "engine", b"irrelevant\0"),
382            Err(ComponentError::ComponentsNotLocked { .. })
383        ));
384        assert!(matches!(
385            manager.prefetch(&["demo"]),
386            Err(ComponentError::ComponentsNotLocked { .. })
387        ));
388        assert!(matches!(
389            manager.cache_path("demo"),
390            Err(ComponentError::ComponentsNotLocked { .. })
391        ));
392    }
393
394    #[test]
395    fn manager_filters_lock_to_exact_target() {
396        let dir = tempfile::tempdir().unwrap();
397        let manager = ComponentManager::from_lock(lock("target-a"), dir.path(), "target-b", host()).unwrap();
398        assert!(matches!(
399            manager.status("demo"),
400            Err(ComponentError::ArtifactNotFound { .. })
401        ));
402    }
403
404    #[test]
405    fn manager_reports_content_address_before_download() {
406        let dir = tempfile::tempdir().unwrap();
407        let manager = ComponentManager::from_lock(lock("target-a"), dir.path(), "target-a", host()).unwrap();
408        assert_eq!(manager.status("demo").unwrap(), ComponentStatus::NotDownloaded);
409        assert!(
410            manager
411                .cache_path("demo")
412                .unwrap()
413                .ends_with(format!("sha256/{}", hex::encode([3; 32])))
414        );
415    }
416
417    #[test]
418    fn duplicate_target_entry_is_rejected() {
419        let dir = tempfile::tempdir().unwrap();
420        let mut lock = lock("target-a");
421        lock.artifacts.push(lock.artifacts[0].clone());
422        assert!(matches!(
423            ComponentManager::from_lock(lock, dir.path(), "target-a", host()),
424            Err(ComponentError::DuplicateLockEntry { .. })
425        ));
426    }
427
428    #[test]
429    fn ensure_contract_rejects_a_contract_the_component_does_not_provide() {
430        let dir = tempfile::tempdir().unwrap();
431        let manager = ComponentManager::from_lock(lock("target-a"), dir.path(), "target-a", host()).unwrap();
432        assert!(matches!(
433            manager.ensure_contract("demo", "missing-contract", b"irrelevant\0"),
434            Err(ComponentError::ContractNotProvided { .. })
435        ));
436    }
437
438    #[test]
439    fn ensure_contract_reaches_the_download_step_for_a_provided_contract() {
440        let dir = tempfile::tempdir().unwrap();
441        let mut two_contracts = lock("target-a");
442        two_contracts.artifacts[0]
443            .provides
444            .push(crate::ComponentProvidedContract {
445                contract: "second".into(),
446                interface_version: 1,
447                contract_hash: hex::encode([9; 32]),
448                implementation: "demo::Second".into(),
449            });
450        let manager = ComponentManager::from_lock(two_contracts, dir.path(), "target-a", host()).unwrap();
451        // Fails to find the library at the fake `file://` URL -- proving contract resolution
452        // for "second" succeeded and the manager reached the download step, not that the
453        // download itself succeeded.
454        assert!(matches!(
455            manager.ensure_contract("demo", "second", b"alef_component_entry_v1_second\0"),
456            Err(ComponentError::Io(_))
457        ));
458    }
459
460    #[test]
461    fn bundled_component_is_rejected_before_touching_the_cache() {
462        let dir = tempfile::tempdir().unwrap();
463        let manager = ComponentManager::from_lock(
464            lock_with_mode("target-a", ComponentDeliveryMode::Bundled),
465            dir.path(),
466            "target-a",
467            host(),
468        )
469        .unwrap();
470
471        assert!(matches!(
472            manager.ensure_contract("demo", "engine", b"alef_component_entry_v1_engine\0"),
473            Err(ComponentError::BundledComponentNotLoadable { .. })
474        ));
475        // Unlike `ensure_contract`/`prefetch`/`cache_path`, `status` reports `Bundled` as a
476        // legitimate answer rather than failing: this manager genuinely cannot download or
477        // load the component, and that is exactly what the status says.
478        assert_eq!(manager.status("demo").unwrap(), ComponentStatus::Bundled);
479        assert!(matches!(
480            manager.prefetch(&["demo"]),
481            Err(ComponentError::BundledComponentNotLoadable { .. })
482        ));
483        assert!(matches!(
484            manager.cache_path("demo"),
485            Err(ComponentError::BundledComponentNotLoadable { .. })
486        ));
487        assert!(
488            std::fs::read_dir(dir.path()).unwrap().next().is_none(),
489            "a bundled component must never touch the cache directory"
490        );
491    }
492
493    #[test]
494    fn prefetch_reuses_an_already_verified_artifact_without_reinstalling() {
495        let dir = tempfile::tempdir().unwrap();
496        let manager = ComponentManager::from_lock(lock("target-a"), dir.path(), "target-a", host()).unwrap();
497        let entry = manager.entries.get("demo").unwrap().clone();
498
499        let fake = CachedArtifact {
500            root: dir.path().join("fake-root"),
501            library: dir.path().join("fake-root/libdemo.so"),
502            manifest: crate::ComponentManifest {
503                schema_version: COMPONENT_MANIFEST_SCHEMA,
504                abi_version: 1,
505                identity: entry.identity.clone(),
506                provides: entry.provides.clone(),
507                features: Vec::new(),
508                default_features: false,
509                library: crate::ComponentLibrary {
510                    file: "libdemo.so".into(),
511                    sha256: hex::encode([0; 32]),
512                    size: 0,
513                },
514            },
515        };
516        manager
517            .verified_artifacts
518            .lock()
519            .unwrap()
520            .insert(entry.sha256.clone(), fake.clone());
521
522        // The lock entry's URL points nowhere real (`file:///does/not/exist`, see `lock()`),
523        // so this call would fail if it attempted to actually install/verify the artifact;
524        // succeeding proves it reused the in-process cache instead.
525        let prefetched = manager.prefetch(&["demo"]).unwrap();
526        assert_eq!(prefetched, vec![fake]);
527    }
528
529    #[test]
530    fn contract_lock_coalesces_the_same_key_and_separates_different_keys() {
531        let dir = tempfile::tempdir().unwrap();
532        let manager = ComponentManager::from_lock(lock("target-a"), dir.path(), "target-a", host()).unwrap();
533
534        let first = manager.contract_lock("demo::engine".to_string());
535        let second = manager.contract_lock("demo::engine".to_string());
536        assert!(
537            Arc::ptr_eq(&first, &second),
538            "the same (component, contract) key must coalesce onto one lock"
539        );
540
541        let unrelated = manager.contract_lock("other::engine".to_string());
542        assert!(
543            !Arc::ptr_eq(&first, &unrelated),
544            "an unrelated key must get its own lock so it is never blocked by a different key"
545        );
546    }
547
548    #[test]
549    fn concurrent_calls_for_the_same_key_serialize_on_the_coalescing_lock() {
550        use std::sync::Barrier;
551        use std::thread;
552
553        let dir = tempfile::tempdir().unwrap();
554        let manager = Arc::new(ComponentManager::from_lock(lock("target-a"), dir.path(), "target-a", host()).unwrap());
555        let order = Arc::new(Mutex::new(Vec::new()));
556        let ready = Arc::new(Barrier::new(2));
557
558        // Hold the coalescing lock as a stand-in for an in-progress download, then prove a
559        // concurrent request for the *same* key really does wait for it: `order` only ever
560        // records the second entry-point-symbol lookup after the first thread has both run
561        // and released the lock, never interleaved with it.
562        let held = manager.contract_lock("demo::engine".to_string());
563        let guard = held.lock().unwrap();
564
565        let waiter = {
566            let manager = Arc::clone(&manager);
567            let order = Arc::clone(&order);
568            let ready = Arc::clone(&ready);
569            thread::spawn(move || {
570                ready.wait();
571                let _ = manager.ensure_contract("demo", "engine", b"alef_component_entry_v1_engine\0");
572                order.lock().unwrap().push("waiter");
573            })
574        };
575
576        ready.wait();
577        thread::sleep(std::time::Duration::from_millis(50));
578        assert!(
579            order.lock().unwrap().is_empty(),
580            "a concurrent call for the same key must not proceed while the lock is held"
581        );
582        order.lock().unwrap().push("holder");
583        drop(guard);
584
585        waiter.join().unwrap();
586        assert_eq!(*order.lock().unwrap(), vec!["holder", "waiter"]);
587    }
588}