Skip to main content

uni_plugin_wasm_rt/
pool.rs

1//! Per-plugin instance cache with a concurrency cap.
2//!
3//! One [`InstancePool`] per loaded plugin. **It does not reuse live
4//! instances.** Every [`InstancePool::acquire`] constructs a *fresh*
5//! instance via the loader-supplied factory; the factory is expected to
6//! be cheap because the heavy artifacts (a compiled wasmtime `Component`
7//! plus its `InstancePre`, or extism's prepared `Manifest`) are cached
8//! by the loader and the factory only spins up a fresh `Store`+instance.
9//!
10//! Freshness per acquire is a *security* property, not just hygiene:
11//!
12//! - A reused `Store<HostState>` would leak guest linear memory,
13//!   globals, and WASI context across unrelated invocations — a `Pure`
14//!   function could carry state between two unrelated queries (bug #2).
15//! - A trapped store recycled back into a warm pool would re-trap or
16//!   read poisoned memory on its next use (bug #3).
17//!
18//! Re-instantiating per acquire closes both: fresh state every call, and
19//! a trapped instance is simply dropped (its `Drop` decrements the live
20//! counter) and never handed out again.
21//!
22//! What remains of the old pool is the **concurrency cap**:
23//! `PoolConfig::max_instances` bounds how many instances may be live at
24//! once (so a flood of concurrent UDF calls can't exhaust wasmtime
25//! memory), enforced via the same CAS-guarded `live` counter the old
26//! capacity check used. [`PoolMetrics`] keeps a sane meaning —
27//! `misses` counts fresh constructions (every acquire), `hits` is now
28//! always zero (no warm reuse), `exhausted` counts cap rejections,
29//! `live` is the current in-flight count.
30//!
31//! Generic over both:
32//!
33//! - **`T`** — the per-invoke instance type (`extism::Plugin`, a
34//!   wasmtime component instance wrapper, or a dummy in tests).
35//! - **`E`** — the loader-specific error type. The factory returns
36//!   `Result<T, E>`; `acquire` constructs `E` from a
37//!   resource-exhaustion message via [`PoolResourceLimit`].
38
39use std::sync::Arc;
40use std::sync::atomic::{AtomicU64, Ordering};
41
42/// Per-pool configuration.
43#[derive(Clone, Debug)]
44pub struct PoolConfig {
45    /// Maximum concurrent live instances.
46    ///
47    /// Bounds the wasmtime memory footprint. Default `4` matches the
48    /// `Capability::ConcurrentInstances` default in the proposal. Acts
49    /// as a concurrency semaphore: at most this many instances may be
50    /// in flight at once.
51    pub max_instances: usize,
52    /// Retained for API compatibility; no longer pre-warms anything.
53    ///
54    /// Instances are now built fresh per [`InstancePool::acquire`] (so a
55    /// reused store can't leak guest state across calls), so there is no
56    /// warm pool to populate. The field stays so existing
57    /// `PoolConfig { max_instances, warm_count }` construction sites keep
58    /// compiling and downstream config surfaces keep their shape.
59    pub warm_count: usize,
60}
61
62impl Default for PoolConfig {
63    fn default() -> Self {
64        Self {
65            max_instances: 4,
66            warm_count: 1,
67        }
68    }
69}
70
71/// Pool metrics surface — read by `host.metric_counter` host imports.
72#[derive(Debug, Default)]
73pub struct PoolMetrics {
74    /// Warm-reuse hits. Always `0` since instances are never reused.
75    pub hits: AtomicU64,
76    /// Fresh constructions — one per successful acquire.
77    pub misses: AtomicU64,
78    /// Acquires that failed because `max_instances` was reached.
79    pub exhausted: AtomicU64,
80    /// Currently-live (in-flight) instances.
81    pub live: AtomicU64,
82}
83
84/// Loader-error trait used by [`InstancePool::acquire`] to construct
85/// the "pool at capacity" error.
86///
87/// Each loader implements this with one line:
88///
89/// ```ignore
90/// impl uni_plugin_wasm_rt::PoolResourceLimit for ExtismError {
91///     fn resource_limit(msg: String) -> Self { Self::ResourceLimit(msg) }
92/// }
93/// ```
94pub trait PoolResourceLimit {
95    /// Construct a "resource limit exceeded" instance from a diagnostic
96    /// message. Called when the pool's `max_instances` is reached.
97    #[must_use]
98    fn resource_limit(msg: String) -> Self;
99}
100
101/// A per-plugin instance cache with a concurrency cap.
102///
103/// Generic over the per-invoke instance type `T` and the loader's error
104/// type `E`. Production use: `InstancePool<extism::Plugin, ExtismError>`
105/// or `InstancePool<ScalarPluginInstance, WasmError>`.
106///
107/// **Does not reuse instances** — every [`Self::acquire`] builds a fresh
108/// one and every release drops it. See the module docs for why.
109pub struct InstancePool<T, E>
110where
111    T: Send + 'static,
112    E: PoolResourceLimit + Send + Sync + 'static,
113{
114    cfg: PoolConfig,
115    /// Not behind a lock: the closure is `Fn + Send + Sync`, so a `Mutex`
116    /// added no safety and its guard — held across the whole call — serialized
117    /// exactly the instantiation `max_instances` exists to run concurrently.
118    factory: Box<dyn Fn() -> Result<T, E> + Send + Sync>,
119    metrics: Arc<PoolMetrics>,
120}
121
122impl<T, E> std::fmt::Debug for InstancePool<T, E>
123where
124    T: Send + 'static,
125    E: PoolResourceLimit + Send + Sync + 'static,
126{
127    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
128        f.debug_struct("InstancePool")
129            .field("cfg", &self.cfg)
130            .field(
131                "metrics.misses",
132                &self.metrics.misses.load(Ordering::Relaxed),
133            )
134            .field("metrics.live", &self.metrics.live.load(Ordering::Relaxed))
135            .finish_non_exhaustive()
136    }
137}
138
139impl<T, E> InstancePool<T, E>
140where
141    T: Send + 'static,
142    E: PoolResourceLimit + Send + Sync + 'static,
143{
144    /// Construct a pool that builds fresh instances via `factory`.
145    ///
146    /// `cfg.warm_count` is accepted for API compatibility but ignored:
147    /// nothing is pre-warmed, because instances are never reused.
148    ///
149    /// # Errors
150    ///
151    /// This constructor is infallible in practice; the `E` in the return
152    /// type is retained so the signature is stable across the refactor.
153    pub fn new(
154        cfg: PoolConfig,
155        factory: impl Fn() -> Result<T, E> + Send + Sync + 'static,
156    ) -> Result<Self, E> {
157        let factory = Box::new(factory) as Box<dyn Fn() -> Result<T, E> + Send + Sync>;
158        Ok(Self {
159            cfg,
160            factory,
161            metrics: Arc::new(PoolMetrics::default()),
162        })
163    }
164
165    /// Acquire a *fresh* instance, honoring the concurrency cap.
166    ///
167    /// Reserves a live slot (CAS against `max_instances`), then builds a
168    /// brand-new instance via the factory. No warm reuse — the returned
169    /// instance has clean state. Releasing it (via [`PooledInstance`]'s
170    /// drop) frees the slot.
171    ///
172    /// # Errors
173    ///
174    /// - `E::resource_limit(...)` when `max_instances` is reached.
175    /// - Whatever the factory returns on construction failure.
176    pub fn acquire(&self) -> Result<T, E> {
177        // Reserve a live slot atomically. CAS-loop guarantees the
178        // invariant `live <= max` even under concurrent acquirers.
179        let max = self.cfg.max_instances as u64;
180        loop {
181            let live = self.metrics.live.load(Ordering::SeqCst);
182            if live >= max {
183                self.metrics.exhausted.fetch_add(1, Ordering::SeqCst);
184                return Err(E::resource_limit(format!(
185                    "instance pool at capacity ({} live)",
186                    self.cfg.max_instances
187                )));
188            }
189            if self
190                .metrics
191                .live
192                .compare_exchange(live, live + 1, Ordering::SeqCst, Ordering::SeqCst)
193                .is_ok()
194            {
195                break;
196            }
197        }
198        // The slot is reserved; construct a fresh instance. If
199        // construction fails, give the slot back.
200        let inst = (self.factory)().inspect_err(|_| {
201            self.metrics.live.fetch_sub(1, Ordering::SeqCst);
202        })?;
203        self.metrics.misses.fetch_add(1, Ordering::SeqCst);
204        Ok(inst)
205    }
206
207    /// Release an instance, freeing its concurrency slot.
208    ///
209    /// The instance is dropped here (never recycled), so its `Drop` impl
210    /// runs any cleanup. A trapped instance is therefore discarded, not
211    /// handed back out.
212    pub fn release(&self, inst: T) {
213        drop(inst);
214        self.metrics.live.fetch_sub(1, Ordering::SeqCst);
215    }
216
217    /// Snapshot the current metrics.
218    #[must_use]
219    pub fn metrics(&self) -> Arc<PoolMetrics> {
220        Arc::clone(&self.metrics)
221    }
222
223    /// Pool configuration, for diagnostics.
224    #[must_use]
225    pub fn config(&self) -> &PoolConfig {
226        &self.cfg
227    }
228}
229
230/// RAII handle to an instance acquired from an [`InstancePool`].
231///
232/// Holds the fresh instance and frees its concurrency slot on drop
233/// (dropping the instance — never recycling it). Adapters use this to
234/// make "acquire-call-drop" exception-safe: if the plugin call panics or
235/// traps, the slot still frees and the (possibly poisoned) instance is
236/// discarded.
237pub struct PooledInstance<T, E>
238where
239    T: Send + 'static,
240    E: PoolResourceLimit + Send + Sync + 'static,
241{
242    pool: Arc<InstancePool<T, E>>,
243    inst: Option<T>,
244}
245
246impl<T, E> std::fmt::Debug for PooledInstance<T, E>
247where
248    T: Send + 'static,
249    E: PoolResourceLimit + Send + Sync + 'static,
250{
251    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
252        f.debug_struct("PooledInstance")
253            .field("has_inst", &self.inst.is_some())
254            .finish_non_exhaustive()
255    }
256}
257
258impl<T, E> PooledInstance<T, E>
259where
260    T: Send + 'static,
261    E: PoolResourceLimit + Send + Sync + 'static,
262{
263    /// Acquire a fresh `PooledInstance` from the pool.
264    ///
265    /// # Errors
266    ///
267    /// Propagates [`InstancePool::acquire`].
268    pub fn acquire(pool: Arc<InstancePool<T, E>>) -> Result<Self, E> {
269        let inst = pool.acquire()?;
270        Ok(Self {
271            pool,
272            inst: Some(inst),
273        })
274    }
275
276    /// Mutable access to the instance.
277    ///
278    /// # Panics
279    ///
280    /// If called after [`Self::take`].
281    pub fn get_mut(&mut self) -> &mut T {
282        self.inst
283            .as_mut()
284            .expect("PooledInstance accessed after take/drop")
285    }
286
287    /// Consume the wrapper, returning the inner instance without freeing
288    /// its concurrency slot via the pool.
289    ///
290    /// Retained for API compatibility. With per-invoke instances there is
291    /// no "corrupted vs clean" distinction at the pool level (a dropped
292    /// instance is always discarded), but `take` still moves the instance
293    /// out and decrements the live counter so callers that need ownership
294    /// keep working.
295    pub fn take(mut self) -> T {
296        let inst = self.inst.take().expect("PooledInstance already taken");
297        self.pool.metrics.live.fetch_sub(1, Ordering::SeqCst);
298        inst
299    }
300}
301
302impl<T, E> Drop for PooledInstance<T, E>
303where
304    T: Send + 'static,
305    E: PoolResourceLimit + Send + Sync + 'static,
306{
307    fn drop(&mut self) {
308        if let Some(inst) = self.inst.take() {
309            // Always discards the instance and frees the slot — never
310            // recycles, so a trapped store can't be handed out again.
311            self.pool.release(inst);
312        }
313    }
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319
320    #[derive(Debug, thiserror::Error)]
321    enum TestErr {
322        #[error("resource limit: {0}")]
323        ResourceLimit(String),
324    }
325
326    impl PoolResourceLimit for TestErr {
327        fn resource_limit(msg: String) -> Self {
328            Self::ResourceLimit(msg)
329        }
330    }
331
332    #[derive(Debug)]
333    #[allow(dead_code)]
334    struct Dummy(u32);
335
336    type TestPool = InstancePool<Dummy, TestErr>;
337
338    /// The factory is `Fn + Send + Sync`, so wrapping it in a `Mutex` bought
339    /// nothing — but `(self.factory.lock())()` held the guard across the whole
340    /// call, serializing every concurrent construction. `max_instances = 4`
341    /// advertised 4-way concurrency and delivered 1-way.
342    ///
343    /// Two threads acquire at once against a factory that waits on a shared
344    /// `Barrier`. Under the `Mutex` the first holds the lock while waiting for
345    /// the second, which cannot enter — a deadlock, caught here as a timeout.
346    #[test]
347    fn concurrent_acquire_does_not_serialize_construction() {
348        use std::sync::Barrier;
349        use std::sync::mpsc;
350        use std::time::Duration;
351
352        let barrier = Arc::new(Barrier::new(2));
353        let bf = Arc::clone(&barrier);
354        let pool: Arc<TestPool> = Arc::new(
355            InstancePool::new(
356                PoolConfig {
357                    max_instances: 4,
358                    ..PoolConfig::default()
359                },
360                move || {
361                    // Only completes if a second construction can run
362                    // concurrently with this one.
363                    bf.wait();
364                    Ok(Dummy(0))
365                },
366            )
367            .unwrap(),
368        );
369
370        let (tx, rx) = mpsc::channel();
371        for _ in 0..2 {
372            let p = Arc::clone(&pool);
373            let tx = tx.clone();
374            std::thread::spawn(move || {
375                let _ = tx.send(p.acquire().is_ok());
376            });
377        }
378        drop(tx);
379
380        for i in 0..2 {
381            match rx.recv_timeout(Duration::from_secs(10)) {
382                Ok(ok) => assert!(ok, "acquire {i} failed"),
383                Err(_) => panic!(
384                    "acquire {i} did not complete within 10s — construction is \
385                     serialized, so the two factory calls can never rendezvous"
386                ),
387            }
388        }
389    }
390
391    #[test]
392    fn acquire_constructs_fresh_each_time() {
393        let n = Arc::new(AtomicU64::new(0));
394        let nc = Arc::clone(&n);
395        let pool = TestPool::new(
396            PoolConfig {
397                max_instances: 4,
398                warm_count: 1,
399            },
400            move || Ok(Dummy(nc.fetch_add(1, Ordering::SeqCst) as u32)),
401        )
402        .unwrap();
403
404        // Nothing pre-warmed: live starts at zero.
405        assert_eq!(pool.metrics.live.load(Ordering::SeqCst), 0);
406
407        let a = pool.acquire().unwrap();
408        let b = pool.acquire().unwrap();
409        // Distinct fresh instances, both counted as misses (no warm reuse).
410        assert_ne!(a.0, b.0);
411        assert_eq!(pool.metrics.misses.load(Ordering::SeqCst), 2);
412        assert_eq!(pool.metrics.hits.load(Ordering::SeqCst), 0);
413        assert_eq!(pool.metrics.live.load(Ordering::SeqCst), 2);
414    }
415
416    #[test]
417    fn release_frees_the_slot() {
418        let pool = Arc::new(
419            TestPool::new(
420                PoolConfig {
421                    max_instances: 1,
422                    warm_count: 0,
423                },
424                || Ok(Dummy(0)),
425            )
426            .unwrap(),
427        );
428        {
429            let _h = PooledInstance::acquire(Arc::clone(&pool)).unwrap();
430            assert_eq!(pool.metrics.live.load(Ordering::SeqCst), 1);
431            // At capacity while held.
432            assert!(PooledInstance::acquire(Arc::clone(&pool)).is_err());
433        }
434        // Slot freed on drop — acquirable again.
435        assert_eq!(pool.metrics.live.load(Ordering::SeqCst), 0);
436        let _h = PooledInstance::acquire(Arc::clone(&pool)).unwrap();
437    }
438
439    #[test]
440    fn exhaustion_returns_resource_limit() {
441        let pool = TestPool::new(
442            PoolConfig {
443                max_instances: 1,
444                warm_count: 0,
445            },
446            || Ok(Dummy(0)),
447        )
448        .unwrap();
449        let _held = pool.acquire().unwrap();
450        let err = pool.acquire().unwrap_err();
451        assert!(matches!(err, TestErr::ResourceLimit(_)));
452        assert_eq!(pool.metrics.exhausted.load(Ordering::SeqCst), 1);
453    }
454
455    #[test]
456    fn pooled_instance_take_does_not_double_free() {
457        let pool = Arc::new(
458            TestPool::new(
459                PoolConfig {
460                    max_instances: 2,
461                    warm_count: 0,
462                },
463                || Ok(Dummy(7)),
464            )
465            .unwrap(),
466        );
467        let h = PooledInstance::acquire(Arc::clone(&pool)).unwrap();
468        assert_eq!(pool.metrics.live.load(Ordering::SeqCst), 1);
469        let taken = h.take();
470        assert_eq!(taken.0, 7);
471        // `take` decremented live; drop of `taken` does nothing extra.
472        assert_eq!(pool.metrics.live.load(Ordering::SeqCst), 0);
473    }
474
475    #[test]
476    fn config_default_matches_proposal() {
477        let c = PoolConfig::default();
478        assert_eq!(c.max_instances, 4);
479        assert_eq!(c.warm_count, 1);
480    }
481
482    /// The concurrency cap holds even under contention: at most
483    /// `max_instances` acquires succeed concurrently; the rest get
484    /// `resource_limit`. (The CAS-guarded `live` counter is the same one
485    /// the old capacity check used.)
486    #[test]
487    fn concurrent_acquire_never_exceeds_max() {
488        use std::sync::Barrier;
489        use std::thread;
490
491        const MAX: usize = 4;
492        const THREADS: usize = 32;
493
494        let pool = Arc::new(
495            TestPool::new(
496                PoolConfig {
497                    max_instances: MAX,
498                    warm_count: 0,
499                },
500                || Ok(Dummy(0)),
501            )
502            .unwrap(),
503        );
504
505        let barrier = Arc::new(Barrier::new(THREADS));
506        let mut handles = Vec::with_capacity(THREADS);
507        for _ in 0..THREADS {
508            let p = Arc::clone(&pool);
509            let b = Arc::clone(&barrier);
510            handles.push(thread::spawn(move || {
511                b.wait();
512                p.acquire().ok()
513            }));
514        }
515
516        let mut held = Vec::with_capacity(THREADS);
517        for h in handles {
518            if let Some(inst) = h.join().unwrap() {
519                held.push(inst);
520            }
521        }
522
523        assert_eq!(held.len(), MAX, "exactly max_instances must be live");
524        assert_eq!(pool.metrics.live.load(Ordering::SeqCst), MAX as u64);
525        assert_eq!(
526            pool.metrics.exhausted.load(Ordering::SeqCst),
527            (THREADS - MAX) as u64
528        );
529    }
530}