Skip to main content

uni_plugin_wasm/
multi_version.rs

1//! M10 per-major `Linker` cache for multi-version ABI coexistence.
2//!
3//! A plugin's manifest carries a semver range describing which host
4//! ABI majors it tolerates ([`uni_plugin::AbiRange`]). The host can
5//! support several majors concurrently — `^1` plugins use the v1
6//! linker, `^2` plugins use the v2 linker — so an ABI bump does not
7//! force every plugin to be rebuilt in lockstep with the host.
8//!
9//! [`MultiVersionLinker`] is the dispatch point. It owns a wasmtime
10//! `Engine` and, for each `(major, caps_signature)` pair, lazily
11//! constructs and caches an `Arc<Linker<HostState>>`.
12//!
13//! # Why cache?
14//!
15//! `Linker::new` plus per-host-fn `func_wrap` registrations are cheap
16//! (microseconds), but constructing a fresh linker on every plugin
17//! `load()` adds avoidable allocation churn — the same `(major, caps)`
18//! combination hits on every hot-reload of any plugin in that
19//! configuration. The cache reuses the Arc-shared linker across all
20//! compatible plugins.
21
22use std::collections::HashMap;
23use std::sync::Arc;
24
25use parking_lot::RwLock;
26use uni_plugin::AbiRange;
27use wasmtime::Engine;
28use wasmtime::component::Linker;
29
30use crate::error::WasmError;
31use crate::host_state::HostState;
32use crate::linker::{build_scalar_linker_v1, build_scalar_linker_v2};
33
34/// Major versions the host can link against.
35///
36/// Probed in order by [`MultiVersionLinker::linker_for`] — the first
37/// major whose plugin's [`AbiRange`] matches wins. v2 is a placeholder
38/// today (see `build_scalar_linker_v2`) but the dispatch path is
39/// already exercised so a real v2 cutover is purely additive.
40pub const SUPPORTED_MAJORS: &[u64] = &[1, 2];
41
42/// Cache key: `(host_major, caps_signature)`. The caps signature is a
43/// deterministic concatenation of the sorted capability strings.
44type CacheKey = (u64, String);
45
46/// Resolve a plugin's declared [`AbiRange`] to the host major it links against.
47///
48/// Probes [`SUPPORTED_MAJORS`] in order; the first major whose `abi.matches`
49/// is `true` wins. Shared by [`MultiVersionLinker::linker_for`] and the
50/// loader's per-pool linker selection so both apply the same dispatch.
51///
52/// # Errors
53///
54/// Returns [`WasmError::AbiUnsupported`] when no supported major matches.
55pub(crate) fn major_for_abi(abi: &AbiRange) -> Result<u64, WasmError> {
56    SUPPORTED_MAJORS
57        .iter()
58        .copied()
59        .find(|m| abi.matches(*m))
60        .ok_or_else(|| WasmError::AbiUnsupported {
61            requested: abi.as_str().to_owned(),
62            supported: SUPPORTED_MAJORS.to_vec(),
63        })
64}
65
66/// Per-major `Linker` cache.
67///
68/// Construct once at host startup (e.g., alongside the `Engine`),
69/// then call [`Self::linker_for`] on every plugin load.
70pub struct MultiVersionLinker {
71    engine: Engine,
72    cache: RwLock<HashMap<CacheKey, Arc<Linker<HostState>>>>,
73}
74
75impl std::fmt::Debug for MultiVersionLinker {
76    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77        f.debug_struct("MultiVersionLinker")
78            .field("cached_entries", &self.cache.read().len())
79            .finish_non_exhaustive()
80    }
81}
82
83impl MultiVersionLinker {
84    /// Construct a new cache over `engine`.
85    #[must_use]
86    pub fn new(engine: Engine) -> Self {
87        Self {
88            engine,
89            cache: RwLock::new(HashMap::new()),
90        }
91    }
92
93    /// Resolve and return the `Linker` matching the plugin's declared
94    /// ABI range and effective capability set.
95    ///
96    /// Probes [`SUPPORTED_MAJORS`] in order; the first major whose
97    /// `abi.matches(major)` is `true` is selected. The corresponding
98    /// `build_scalar_linker_vN` is invoked on cache miss; subsequent
99    /// calls with the same `(major, caps)` return the cached Arc.
100    ///
101    /// # Errors
102    ///
103    /// Returns [`WasmError::AbiUnsupported`] when no supported major
104    /// matches the plugin's `abi` range.
105    pub fn linker_for(
106        &self,
107        abi: &AbiRange,
108        effective_caps: &uni_plugin::CapabilitySet,
109    ) -> Result<Arc<Linker<HostState>>, WasmError> {
110        let major = major_for_abi(abi)?;
111        let key: CacheKey = (major, caps_signature(effective_caps));
112        if let Some(cached) = self.cache.read().get(&key) {
113            return Ok(Arc::clone(cached));
114        }
115        // Miss — build under the write lock. Use the entry pattern so
116        // a concurrent racer's insert is observed.
117        let mut cache = self.cache.write();
118        if let Some(cached) = cache.get(&key) {
119            return Ok(Arc::clone(cached));
120        }
121        let built = match major {
122            1 => build_scalar_linker_v1(&self.engine, effective_caps)?,
123            2 => build_scalar_linker_v2(&self.engine, effective_caps)?,
124            _ => {
125                return Err(WasmError::AbiUnsupported {
126                    requested: abi.as_str().to_owned(),
127                    supported: SUPPORTED_MAJORS.to_vec(),
128                });
129            }
130        };
131        let arc = Arc::new(built);
132        cache.insert(key, Arc::clone(&arc));
133        Ok(arc)
134    }
135
136    /// Reset the cache. Intended for tests; production callers don't
137    /// need to clear because cached linkers are immutable after build.
138    pub fn clear_cache(&self) {
139        self.cache.write().clear();
140    }
141}
142
143/// Build a deterministic signature for an effective capability set (linker
144/// cache key).
145///
146/// `CapabilitySet` is backed by a `BTreeSet`, so its serialization is sorted
147/// and stable — including the attenuation patterns, so two grants that differ
148/// only in (say) their network allow-list key distinct linkers.
149fn caps_signature(caps: &uni_plugin::CapabilitySet) -> String {
150    serde_json::to_string(caps).unwrap_or_default()
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156
157    fn engine() -> Engine {
158        let mut cfg = wasmtime::Config::new();
159        cfg.wasm_component_model(true);
160        Engine::new(&cfg).expect("engine")
161    }
162
163    #[test]
164    fn linker_for_v1_matches_caret_one() {
165        let mv = MultiVersionLinker::new(engine());
166        let abi = AbiRange::parse("^1").unwrap();
167        let l = mv
168            .linker_for(&abi, &uni_plugin::CapabilitySet::new())
169            .expect("v1 selected");
170        assert!(Arc::strong_count(&l) >= 2, "cache holds an Arc clone");
171    }
172
173    #[test]
174    fn linker_for_v2_matches_caret_two() {
175        let mv = MultiVersionLinker::new(engine());
176        let abi = AbiRange::parse("^2").unwrap();
177        let _ = mv
178            .linker_for(&abi, &uni_plugin::CapabilitySet::new())
179            .expect("v2 selected");
180    }
181
182    #[test]
183    fn linker_for_rejects_unsupported_major() {
184        let mv = MultiVersionLinker::new(engine());
185        let abi = AbiRange::parse("^99").unwrap();
186        let err = match mv.linker_for(&abi, &uni_plugin::CapabilitySet::new()) {
187            Ok(_) => panic!("expected AbiUnsupported"),
188            Err(e) => e,
189        };
190        match err {
191            WasmError::AbiUnsupported {
192                requested,
193                supported,
194            } => {
195                assert_eq!(requested, "^99");
196                assert_eq!(supported, vec![1, 2]);
197            }
198            other => panic!("expected AbiUnsupported, got {other:?}"),
199        }
200    }
201
202    #[test]
203    fn cache_returns_same_arc_on_repeat_lookup() {
204        let mv = MultiVersionLinker::new(engine());
205        let abi = AbiRange::parse("^1").unwrap();
206        let a = mv
207            .linker_for(&abi, &uni_plugin::CapabilitySet::new())
208            .unwrap();
209        let b = mv
210            .linker_for(&abi, &uni_plugin::CapabilitySet::new())
211            .unwrap();
212        assert!(Arc::ptr_eq(&a, &b), "expected cache hit to return same Arc");
213    }
214
215    #[test]
216    fn caps_signature_is_order_invariant() {
217        use uni_plugin::{Capability, CapabilitySet};
218        // CapabilitySet is a BTreeSet, so insertion order can't change the
219        // signature; distinct attenuation must, though.
220        let a = CapabilitySet::from_iter_of([Capability::ScalarFn, Capability::Procedure]);
221        let b = CapabilitySet::from_iter_of([Capability::Procedure, Capability::ScalarFn]);
222        assert_eq!(caps_signature(&a), caps_signature(&b));
223        let net1 = CapabilitySet::from_iter_of([Capability::Network {
224            allow: vec!["https://a/**".into()],
225        }]);
226        let net2 = CapabilitySet::from_iter_of([Capability::Network {
227            allow: vec!["https://b/**".into()],
228        }]);
229        assert_ne!(
230            caps_signature(&net1),
231            caps_signature(&net2),
232            "different allow-lists must key distinct linkers"
233        );
234    }
235}