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