Skip to main content

a3s_box_runtime/pool/
warm_pool.rs

1//! WarmPool — Pre-warmed pool of ready-to-use MicroVMs.
2//!
3//! Maintains a set of pre-booted VMs in `Ready` state so that
4//! `acquire()` can return a VM instantly without waiting for boot.
5
6use std::future::Future;
7use std::pin::Pin;
8use std::sync::Arc;
9use std::time::{Duration, Instant};
10
11use a3s_box_core::config::{BoxConfig, PoolConfig};
12use a3s_box_core::error::{BoxError, Result};
13use a3s_box_core::event::{BoxEvent, EventEmitter};
14use tokio::sync::{watch, Mutex, OwnedSemaphorePermit, Semaphore};
15use tokio::task::JoinHandle;
16
17use crate::pool::scaler::PoolScaler;
18use crate::vm::VmManager;
19
20/// A pre-warmed VM waiting in the pool.
21struct WarmVm {
22    /// The ready VM manager instance.
23    vm: VmManager,
24    /// When this VM was added to the pool.
25    created_at: Instant,
26}
27
28type BootVmFuture<'a> = Pin<Box<dyn Future<Output = Result<VmManager>> + Send + 'a>>;
29
30/// Keeps the in-flight warm-pool boot gauge balanced even when a boot task is
31/// cancelled or panics while its JoinSet is being drained.
32struct BootMetricGuard {
33    metrics: Option<crate::prom::RuntimeMetrics>,
34}
35
36impl BootMetricGuard {
37    fn new(metrics: Option<crate::prom::RuntimeMetrics>) -> Self {
38        if let Some(metrics) = &metrics {
39            metrics.warm_pool_boots_inflight.inc();
40        }
41        Self { metrics }
42    }
43}
44
45impl Drop for BootMetricGuard {
46    fn drop(&mut self) {
47        if let Some(metrics) = &self.metrics {
48            metrics.warm_pool_boots_inflight.dec();
49        }
50    }
51}
52
53/// Acquire the per-pool and optional daemon-wide permits in one consistent
54/// order. Keeping the order identical for eager and on-demand boots avoids a
55/// cross-pool semaphore cycle while a daemon is filling multiple images.
56async fn acquire_boot_permits(
57    boot_limiter: Arc<Semaphore>,
58    global_boot_limiter: Option<Arc<Semaphore>>,
59) -> Result<(OwnedSemaphorePermit, Option<OwnedSemaphorePermit>)> {
60    let pool_permit = boot_limiter
61        .acquire_owned()
62        .await
63        .map_err(|_| BoxError::PoolError("Warm-pool boot limiter closed".to_string()))?;
64    let global_permit = match global_boot_limiter {
65        Some(limiter) => Some(limiter.acquire_owned().await.map_err(|_| {
66            BoxError::PoolError("Warm-pool global boot limiter closed".to_string())
67        })?),
68        None => None,
69    };
70    Ok((pool_permit, global_permit))
71}
72
73/// Statistics about the warm pool.
74#[derive(Debug, Clone)]
75pub struct PoolStats {
76    /// Number of idle VMs ready for acquisition.
77    pub idle_count: usize,
78    /// Total number of VMs created by this pool (including acquired ones).
79    pub total_created: u64,
80    /// Total number of VMs acquired from the pool.
81    pub total_acquired: u64,
82    /// Total number of VMs released back to the pool.
83    pub total_released: u64,
84    /// Total number of VMs evicted due to idle TTL.
85    pub total_evicted: u64,
86}
87
88/// A pre-warmed pool of ready-to-use MicroVMs.
89///
90/// The pool maintains `min_idle` VMs in `Ready` state. When a VM is
91/// acquired, the pool spawns a replacement in the background. Idle VMs
92/// that exceed `idle_ttl_secs` are automatically evicted.
93///
94/// # Usage
95///
96/// ```ignore
97/// let pool = WarmPool::start(pool_config, box_config, emitter).await?;
98/// let vm = pool.acquire().await?;  // Instant if pool has capacity
99/// // ... use vm ...
100/// pool.release(vm).await?;         // Return to pool or destroy
101/// pool.drain().await?;             // Graceful shutdown
102/// ```
103pub struct WarmPool {
104    /// Pool configuration.
105    config: PoolConfig,
106    /// Base BoxConfig template for creating new VMs.
107    box_config: BoxConfig,
108    /// Idle VMs ready for acquisition.
109    idle: Arc<Mutex<Vec<WarmVm>>>,
110    /// Pool statistics.
111    stats: Arc<Mutex<PoolStats>>,
112    /// Event emitter for pool lifecycle events.
113    event_emitter: EventEmitter,
114    /// Background replenishment task handle.
115    replenish_handle: Option<JoinHandle<()>>,
116    /// Shutdown signal sender.
117    shutdown_tx: watch::Sender<bool>,
118    /// Shutdown signal receiver (cloned for background task).
119    shutdown_rx: watch::Receiver<bool>,
120    /// Autoscaler for dynamic min_idle adjustment (None if scaling disabled).
121    scaler: Option<Arc<Mutex<PoolScaler>>>,
122    /// Prometheus metrics (optional).
123    metrics: Option<crate::prom::RuntimeMetrics>,
124    /// Per-pool boot limiter shared by eager fill and on-demand misses.
125    boot_limiter: Arc<Semaphore>,
126    /// Optional daemon-wide boot limiter shared by all image pools.
127    global_boot_limiter: Option<Arc<Semaphore>>,
128    /// Snapshot-fork template state (built lazily on first fill when
129    /// `config.snapshot_fork`): the file-backed RAM image + state file every other
130    /// pool VM restores from. Caches an `Unavailable` verdict so a build failure
131    /// (native VM snapshot unsupported on this build) is not re-attempted on every
132    /// fill — the pool cold-boots instead.
133    template: Arc<Mutex<TemplateState>>,
134}
135
136/// A built snapshot-fork template: the shared RAM image + state file that pool VMs
137/// restore from (MAP_PRIVATE CoW of the RAM file).
138#[derive(Clone)]
139struct PoolTemplate {
140    mem_file: String,
141    state_file: String,
142    rootfs_cache_key: Option<String>,
143}
144
145/// How many consecutive template-build failures are tolerated before the
146/// verdict becomes permanently `Unavailable`. A transient failure (host
147/// resource pressure, a source VM slow to bind its snapshot socket) presents
148/// identically to "snapshot unsupported by this libkrun build" ("snapshot
149/// socket never appeared"), so a bounded retry avoids permanently downgrading
150/// the whole pool to cold-boot on a one-off hiccup, while still giving up on a
151/// genuinely-unsupported host after a few attempts.
152const MAX_TEMPLATE_BUILD_FAILURES: u32 = 3;
153
154/// Cached state of the snapshot-fork template.
155enum TemplateState {
156    /// Not built yet — the first snapshot-fork fill attempts the build.
157    Unbuilt,
158    /// Built and ready; pool VMs restore from it.
159    Ready(PoolTemplate),
160    /// The last build failed but is still retryable; carries the consecutive
161    /// failure count. A later fill retries until it reaches
162    /// `MAX_TEMPLATE_BUILD_FAILURES`, then it becomes `Unavailable`.
163    Failing(u32),
164    /// The build failed permanently (native VM snapshot unavailable on this
165    /// build/platform, or too many consecutive failures). Cached so it is not
166    /// retried — `boot_or_restore` cold-boots instead.
167    Unavailable,
168}
169
170#[derive(Clone, Copy)]
171enum InitialFill {
172    /// Boot the configured `min_idle` count before returning from `start`.
173    Eager,
174    /// Boot one ready VM before returning and let maintenance fill the rest.
175    FirstReady,
176}
177
178impl WarmPool {
179    /// Create and start the warm pool.
180    ///
181    /// Spawns `min_idle` VMs in the background and starts the
182    /// replenishment/eviction loop.
183    pub async fn start(
184        config: PoolConfig,
185        box_config: BoxConfig,
186        event_emitter: EventEmitter,
187    ) -> Result<Self> {
188        Self::start_with_metrics(config, box_config, event_emitter, None).await
189    }
190
191    /// Create and start the warm pool with an optional shared metrics sink.
192    ///
193    /// The sink is installed before the initial fill so the first pre-warmed
194    /// VMs contribute boot, cache, and pool metrics. [`Self::start`] remains
195    /// the compatibility entry point for callers that do not need metrics.
196    pub async fn start_with_metrics(
197        config: PoolConfig,
198        box_config: BoxConfig,
199        event_emitter: EventEmitter,
200        metrics: Option<crate::prom::RuntimeMetrics>,
201    ) -> Result<Self> {
202        Self::start_with_metrics_and_boot_limiter(config, box_config, event_emitter, metrics, None)
203            .await
204    }
205
206    /// Create and start the warm pool with optional metrics and a shared
207    /// daemon-wide boot limiter.
208    ///
209    /// `max_concurrent_boots` still limits each pool independently. When a
210    /// shared limiter is supplied, it additionally caps aggregate boots across
211    /// all pools that use it (for example, a multi-image pool daemon).
212    pub async fn start_with_metrics_and_boot_limiter(
213        config: PoolConfig,
214        box_config: BoxConfig,
215        event_emitter: EventEmitter,
216        metrics: Option<crate::prom::RuntimeMetrics>,
217        global_boot_limiter: Option<Arc<Semaphore>>,
218    ) -> Result<Self> {
219        Self::start_with_metrics_and_boot_limiter_with_fill(
220            config,
221            box_config,
222            event_emitter,
223            metrics,
224            global_boot_limiter,
225            InitialFill::Eager,
226        )
227        .await
228    }
229
230    /// Create a warm pool with one ready VM on the critical path.
231    ///
232    /// This is intended for lazy, multi-image daemons: the first request for a
233    /// new pool can run as soon as one VM is ready while the maintenance loop
234    /// fills the remaining `min_idle` capacity in the background. The regular
235    /// [`Self::start_with_metrics_and_boot_limiter`] API keeps its eager-fill
236    /// behavior for explicit pool startup.
237    pub async fn start_with_metrics_and_boot_limiter_first_ready(
238        config: PoolConfig,
239        box_config: BoxConfig,
240        event_emitter: EventEmitter,
241        metrics: Option<crate::prom::RuntimeMetrics>,
242        global_boot_limiter: Option<Arc<Semaphore>>,
243    ) -> Result<Self> {
244        Self::start_with_metrics_and_boot_limiter_with_fill(
245            config,
246            box_config,
247            event_emitter,
248            metrics,
249            global_boot_limiter,
250            InitialFill::FirstReady,
251        )
252        .await
253    }
254
255    async fn start_with_metrics_and_boot_limiter_with_fill(
256        config: PoolConfig,
257        box_config: BoxConfig,
258        event_emitter: EventEmitter,
259        metrics: Option<crate::prom::RuntimeMetrics>,
260        global_boot_limiter: Option<Arc<Semaphore>>,
261        initial_fill: InitialFill,
262    ) -> Result<Self> {
263        if config.max_size == 0 {
264            return Err(BoxError::PoolError(
265                "Pool max_size must be greater than 0".to_string(),
266            ));
267        }
268        if config.min_idle > config.max_size {
269            return Err(BoxError::PoolError(format!(
270                "Pool min_idle ({}) cannot exceed max_size ({})",
271                config.min_idle, config.max_size
272            )));
273        }
274        if config.max_concurrent_boots == 0 {
275            return Err(BoxError::PoolError(
276                "Pool max_concurrent_boots must be greater than 0".to_string(),
277            ));
278        }
279
280        let idle = Arc::new(Mutex::new(Vec::with_capacity(config.max_size)));
281        let stats = Arc::new(Mutex::new(PoolStats {
282            idle_count: 0,
283            total_created: 0,
284            total_acquired: 0,
285            total_released: 0,
286            total_evicted: 0,
287        }));
288        let (shutdown_tx, shutdown_rx) = watch::channel(false);
289
290        let scaler = if config.scaling.enabled {
291            Some(Arc::new(Mutex::new(PoolScaler::new(
292                config.scaling.clone(),
293                config.min_idle,
294                config.max_size,
295            ))))
296        } else {
297            None
298        };
299
300        let boot_limiter = Arc::new(Semaphore::new(config.max_concurrent_boots));
301        let mut pool = Self {
302            config,
303            box_config,
304            idle,
305            stats,
306            event_emitter,
307            replenish_handle: None,
308            shutdown_tx,
309            shutdown_rx,
310            scaler,
311            metrics,
312            boot_limiter,
313            global_boot_limiter,
314            template: Arc::new(Mutex::new(TemplateState::Unbuilt)),
315        };
316
317        if let Some(metrics) = &pool.metrics {
318            metrics.warm_pool_capacity.set(pool.config.max_size as i64);
319        }
320
321        // Initial fill. Lazy pools only wait for the first ready VM; the
322        // maintenance loop immediately schedules the remaining capacity.
323        let initial_target = match initial_fill {
324            InitialFill::Eager => pool.config.min_idle,
325            InitialFill::FirstReady => pool.config.min_idle.min(1),
326        };
327        let initial_fill_started = Instant::now();
328        pool.fill_to_target(initial_target).await;
329        if let Some(metrics) = &pool.metrics {
330            metrics
331                .warm_pool_initial_fill_duration
332                .observe(initial_fill_started.elapsed().as_secs_f64());
333        }
334
335        // Start background maintenance loop
336        let handle = pool.spawn_maintenance_loop();
337        pool.replenish_handle = Some(handle);
338
339        tracing::info!(
340            min_idle = pool.config.min_idle,
341            max_size = pool.config.max_size,
342            idle_ttl_secs = pool.config.idle_ttl_secs,
343            "Warm pool started"
344        );
345
346        Ok(pool)
347    }
348
349    /// Attach Prometheus metrics to this pool.
350    pub fn set_metrics(&mut self, metrics: crate::prom::RuntimeMetrics) {
351        metrics.warm_pool_capacity.set(self.config.max_size as i64);
352        metrics.warm_pool_size.set(
353            self.idle
354                .try_lock()
355                .map(|idle| idle.len() as i64)
356                .unwrap_or_default(),
357        );
358        self.metrics = Some(metrics);
359    }
360
361    fn sync_idle_metric(metrics: Option<&crate::prom::RuntimeMetrics>, idle_count: usize) {
362        if let Some(metrics) = metrics {
363            metrics.warm_pool_size.set(idle_count as i64);
364        }
365    }
366
367    /// Acquire a ready VM from the pool.
368    ///
369    /// If an idle VM is available, returns it immediately.
370    /// Otherwise, boots a new VM on demand (slower path).
371    pub async fn acquire(&self) -> Result<VmManager> {
372        // Try to pop an idle VM
373        {
374            let mut idle = self.idle.lock().await;
375            if let Some(warm_vm) = idle.pop() {
376                let mut stats = self.stats.lock().await;
377                stats.total_acquired += 1;
378                stats.idle_count = idle.len();
379
380                // Record hit for autoscaler
381                if let Some(ref scaler) = self.scaler {
382                    scaler.lock().await.record_acquire(true);
383                }
384
385                if let Some(ref m) = self.metrics {
386                    m.warm_pool_hits.inc();
387                    m.warm_pool_size.set(idle.len() as i64);
388                }
389
390                self.event_emitter.emit(BoxEvent::with_string(
391                    "pool.vm.acquired",
392                    format!("Acquired VM {} from pool", warm_vm.vm.box_id()),
393                ));
394
395                tracing::debug!(
396                    box_id = %warm_vm.vm.box_id(),
397                    idle_remaining = idle.len(),
398                    "Acquired VM from warm pool"
399                );
400
401                return Ok(warm_vm.vm);
402            }
403        }
404
405        // No idle VM available — boot one on demand (miss)
406        tracing::info!("No idle VM in pool, booting on demand");
407
408        // Record miss for autoscaler
409        if let Some(ref scaler) = self.scaler {
410            scaler.lock().await.record_acquire(false);
411        }
412
413        if let Some(ref m) = self.metrics {
414            m.warm_pool_misses.inc();
415        }
416
417        let vm = self.boot_new_vm().await?;
418
419        let mut stats = self.stats.lock().await;
420        stats.total_acquired += 1;
421
422        Ok(vm)
423    }
424
425    /// Release a VM back to the pool.
426    ///
427    /// If the pool is at capacity, the VM is destroyed instead.
428    pub async fn release(&self, vm: VmManager) -> Result<()> {
429        let mut idle = self.idle.lock().await;
430
431        // Don't return a VM to a pool that is shutting down: drain_idle has (or
432        // soon will have) cleared `idle` and won't run again, so a push here leaks
433        // the VM (no Drop reaper). Checked under the idle lock so it is atomic with
434        // a concurrent drain_idle. Destroy the VM instead.
435        if *self.shutdown_rx.borrow() {
436            drop(idle);
437            let mut vm = vm;
438            vm.destroy().await?;
439            return Ok(());
440        }
441
442        if idle.len() >= self.config.max_size {
443            // Pool is full — destroy the VM
444            drop(idle); // Release lock before async destroy
445            let mut vm = vm;
446            vm.destroy().await?;
447
448            tracing::debug!(
449                box_id = %vm.box_id(),
450                "Pool full, destroyed released VM"
451            );
452            return Ok(());
453        }
454
455        let box_id = vm.box_id().to_string();
456        idle.push(WarmVm {
457            vm,
458            created_at: Instant::now(),
459        });
460
461        let mut stats = self.stats.lock().await;
462        stats.total_released += 1;
463        stats.idle_count = idle.len();
464
465        if let Some(ref m) = self.metrics {
466            m.warm_pool_size.set(idle.len() as i64);
467        }
468
469        self.event_emitter.emit(BoxEvent::with_string(
470            "pool.vm.released",
471            format!("Released VM {} back to pool", box_id),
472        ));
473
474        tracing::debug!(
475            box_id = %box_id,
476            idle_count = idle.len(),
477            "Released VM back to warm pool"
478        );
479
480        Ok(())
481    }
482
483    /// Get current pool statistics.
484    pub async fn stats(&self) -> PoolStats {
485        self.stats.lock().await.clone()
486    }
487
488    /// Get the number of idle VMs currently in the pool.
489    pub async fn idle_count(&self) -> usize {
490        self.idle.lock().await.len()
491    }
492
493    /// Signal the pool to shutdown. This signals the background task to stop
494    /// replenishing and sets the shutdown flag. VMs will continue to exist
495    /// until the pool is drained or dropped.
496    pub fn signal_shutdown(&self) {
497        let _ = self.shutdown_tx.send(true);
498        tracing::info!("Warm pool shutdown signaled");
499    }
500
501    /// Gracefully drain all VMs and stop the pool.
502    pub async fn drain(&mut self) -> Result<()> {
503        // Signal shutdown to background task
504        let _ = self.shutdown_tx.send(true);
505
506        // Wait for background task to finish
507        if let Some(handle) = self.replenish_handle.take() {
508            let _ = handle.await;
509        }
510
511        // Detach idle VMs before destroying them. VM teardown is asynchronous
512        // and must not hold the pool lock, otherwise acquire/release and the
513        // maintenance loop can be blocked for the entire drain duration.
514        let idle_vms = {
515            let mut idle = self.idle.lock().await;
516            let idle_vms = idle.drain(..).collect::<Vec<_>>();
517            Self::sync_idle_metric(self.metrics.as_ref(), idle.len());
518            idle_vms
519        };
520        let count = idle_vms.len();
521
522        for warm_vm in idle_vms {
523            let mut vm = warm_vm.vm;
524            if let Err(e) = vm.destroy().await {
525                tracing::warn!(
526                    box_id = %vm.box_id(),
527                    error = %e,
528                    "Failed to destroy pooled VM during drain"
529                );
530            }
531        }
532
533        let mut stats = self.stats.lock().await;
534        stats.idle_count = 0;
535
536        self.event_emitter.emit(BoxEvent::empty("pool.drained"));
537
538        tracing::info!(destroyed = count, "Warm pool drained");
539
540        Ok(())
541    }
542
543    /// Destroy all idle VMs without consuming the pool (`&self`), so it can be
544    /// shut down from behind an `Arc` (e.g. a daemon serving concurrent requests).
545    /// Pair with [`Self::signal_shutdown`] first to stop the background replenisher;
546    /// its task then exits on its own (it watches the shutdown channel).
547    pub async fn drain_idle(&self) -> Result<()> {
548        // Detach idle VMs before destroying them. Keeping `idle` locked while
549        // awaiting VM teardown blocks concurrent acquire/release operations and
550        // widens the shutdown race window for a replenishment batch.
551        let idle_vms = {
552            let mut idle = self.idle.lock().await;
553            let idle_vms = idle.drain(..).collect::<Vec<_>>();
554            Self::sync_idle_metric(self.metrics.as_ref(), idle.len());
555            idle_vms
556        };
557        let count = idle_vms.len();
558        for warm_vm in idle_vms {
559            let mut vm = warm_vm.vm;
560            if let Err(e) = vm.destroy().await {
561                tracing::warn!(
562                    box_id = %vm.box_id(),
563                    error = %e,
564                    "Failed to destroy pooled VM during drain_idle"
565                );
566            }
567        }
568        self.stats.lock().await.idle_count = 0;
569        tracing::info!(destroyed = count, "Warm pool idle VMs drained");
570        Ok(())
571    }
572
573    /// Remove and destroy specific idle VMs by their box IDs.
574    ///
575    /// Used when a fill partially fails and needs to roll back
576    /// successfully added VMs.
577    async fn remove_idle_vms(&self, box_ids: &[String]) {
578        // First pass: collect indices of VMs to remove
579        let indices_to_remove: Vec<usize> = {
580            let idle = self.idle.lock().await;
581            idle.iter()
582                .enumerate()
583                .filter(|(_, wm)| box_ids.iter().any(|id| id == wm.vm.box_id()))
584                .map(|(i, _)| i)
585                .collect()
586        };
587
588        if indices_to_remove.is_empty() {
589            return;
590        }
591
592        // Second pass: remove and collect VMs to destroy
593        // We do this in reverse order to avoid index shifting issues
594        let mut to_destroy: Vec<WarmVm> = Vec::new();
595        {
596            let mut idle = self.idle.lock().await;
597            for idx in indices_to_remove.into_iter().rev() {
598                if idx < idle.len() {
599                    let warm_vm = idle.remove(idx);
600                    to_destroy.push(warm_vm);
601                }
602            }
603        }
604
605        // Update stats before destroying (approximate, since VMs still exist in to_destroy)
606        {
607            let idle_count = self.idle.lock().await.len();
608            if let Ok(mut stats) = self.stats.try_lock() {
609                stats.idle_count = idle_count;
610            }
611            Self::sync_idle_metric(self.metrics.as_ref(), idle_count);
612        }
613
614        // Destroy collected VMs (outside of pool lock)
615        for warm_vm in to_destroy {
616            let box_id = warm_vm.vm.box_id().to_string();
617            let mut vm = warm_vm.vm;
618            if let Err(e) = vm.destroy().await {
619                tracing::warn!(
620                    box_id = %box_id,
621                    error = %e,
622                    "Failed to destroy VM during pool fill rollback"
623                );
624            } else {
625                tracing::debug!(box_id = %box_id, "Destroyed VM during pool fill rollback");
626            }
627        }
628    }
629
630    /// Boot a new VM using the pool's template config.
631    async fn boot_new_vm(&self) -> Result<VmManager> {
632        let _boot_permits =
633            acquire_boot_permits(self.boot_limiter.clone(), self.global_boot_limiter.clone())
634                .await?;
635        let _boot_guard = BootMetricGuard::new(self.metrics.clone());
636        let result = Self::boot_or_restore(
637            self.config.snapshot_fork,
638            &self.box_config,
639            &self.event_emitter,
640            &self.template,
641        )
642        .await;
643        if result.is_err() {
644            if let Some(metrics) = &self.metrics {
645                metrics.warm_pool_boot_failures_total.inc();
646            }
647        }
648        let vm = result?;
649
650        let mut stats = self.stats.lock().await;
651        stats.total_created += 1;
652
653        self.event_emitter.emit(BoxEvent::with_string(
654            "pool.vm.created",
655            format!("Booted new VM {}", vm.box_id()),
656        ));
657
658        Ok(vm)
659    }
660
661    /// Fill one slot: restore from the snapshot-fork template when enabled, else cold
662    /// boot. Static so both `boot_new_vm` and the background replenish task use it.
663    fn boot_or_restore<'a>(
664        snapshot_fork: bool,
665        box_config: &'a BoxConfig,
666        event_emitter: &'a EventEmitter,
667        template: &'a Arc<Mutex<TemplateState>>,
668    ) -> BootVmFuture<'a> {
669        Box::pin(async move {
670            if snapshot_fork && crate::vm::native_snapshot_fork_supported() {
671                // Try the snapshot-fork template. If it can't be built (native VM
672                // snapshot unavailable — the verdict is cached so this is attempted at
673                // most once), fall back to a normal cold boot so the warm pool still
674                // fills rather than failing outright.
675                match Self::ensure_template(box_config, event_emitter, template).await {
676                    Ok(tpl) => {
677                        let mut cfg = box_config.clone();
678                        cfg.snapshot_mem_file = Some(tpl.mem_file.clone());
679                        cfg.restore_from = Some(tpl.state_file.clone());
680                        cfg.snapshot_sock = None;
681                        let mut vm = VmManager::new(cfg, event_emitter.clone());
682                        vm.restore_rootfs_cache_key = tpl.rootfs_cache_key.clone();
683                        let restored = async {
684                            vm.boot().await?;
685                            vm.wait_for_exec_available(std::time::Duration::from_secs(120))
686                                .await
687                        }
688                        .await;
689                        match restored {
690                            Ok(()) => return Ok(vm),
691                            Err(error) => {
692                                let _ = vm.destroy_with_timeout(2000).await;
693                                tracing::warn!(
694                                    %error,
695                                    "snapshot-fork restore failed; cold-booting this pool VM"
696                                );
697                            }
698                        }
699                    }
700                    Err(error) => {
701                        tracing::debug!(%error, "snapshot-fork unavailable; cold-booting this pool VM");
702                    }
703                }
704            } else if snapshot_fork {
705                tracing::debug!(
706                    "snapshot-fork is unavailable on this build; cold-booting without snapshot side effects"
707                );
708            }
709            let mut vm = VmManager::new(box_config.clone(), event_emitter.clone());
710            vm.boot().await?;
711            vm.wait_for_exec_available(std::time::Duration::from_secs(120))
712                .await?;
713            Ok(vm)
714        })
715    }
716
717    /// Boot a bounded batch of VMs and return results in completion order.
718    ///
719    /// VM boot is expensive in both host CPU and memory. A JoinSet without a
720    /// concurrency limit turns a large min_idle or autoscaler step into a
721    /// resource burst, so only `max_concurrent_boots` tasks are in flight at
722    /// once. Each task owns cloned inputs, allowing the set to remain
723    /// `'static` while the caller retains its pool state.
724    async fn boot_batch(
725        snapshot_fork: bool,
726        box_config: &BoxConfig,
727        event_emitter: &EventEmitter,
728        template: &Arc<Mutex<TemplateState>>,
729        needed: usize,
730        max_concurrent_boots: usize,
731        metrics: Option<crate::prom::RuntimeMetrics>,
732        boot_limiter: Arc<Semaphore>,
733        global_boot_limiter: Option<Arc<Semaphore>>,
734    ) -> Vec<Result<VmManager>> {
735        if needed == 0 {
736            return Vec::new();
737        }
738
739        let limit = bounded_boot_limit(needed, max_concurrent_boots);
740        let mut set = tokio::task::JoinSet::new();
741        let mut launched = 0usize;
742        let mut results = Vec::with_capacity(needed);
743
744        while launched < needed || !set.is_empty() {
745            while launched < needed && set.len() < limit {
746                let config = box_config.clone();
747                let emitter = event_emitter.clone();
748                let shared_template = Arc::clone(template);
749                let boot_metrics = metrics.clone();
750                let pool_boot_limiter = boot_limiter.clone();
751                let daemon_boot_limiter = global_boot_limiter.clone();
752                set.spawn(async move {
753                    let _boot_permits =
754                        acquire_boot_permits(pool_boot_limiter, daemon_boot_limiter).await?;
755                    let _boot_guard = BootMetricGuard::new(boot_metrics);
756                    WarmPool::boot_or_restore(snapshot_fork, &config, &emitter, &shared_template)
757                        .await
758                });
759                launched += 1;
760            }
761
762            if let Some(result) = set.join_next().await {
763                let result = match result {
764                    Ok(result) => result,
765                    Err(error) => Err(BoxError::PoolError(format!(
766                        "Warm-pool boot task failed: {error}"
767                    ))),
768                };
769                if result.is_err() {
770                    if let Some(metrics) = &metrics {
771                        metrics.warm_pool_boot_failures_total.inc();
772                    }
773                }
774                results.push(result);
775            }
776        }
777
778        results
779    }
780
781    /// Get the snapshot-fork template, building it once lazily. Concurrent callers
782    /// wait on the lock and reuse the first result — a built template OR a cached
783    /// `Unavailable` verdict, so a failed build (native VM snapshot unsupported on
784    /// this build) is attempted at most once rather than re-tried (and re-timed-out)
785    /// on every pool fill. Returns `Err` when unavailable so `boot_or_restore` cold
786    /// boots instead.
787    async fn ensure_template(
788        box_config: &BoxConfig,
789        event_emitter: &EventEmitter,
790        template: &Arc<Mutex<TemplateState>>,
791    ) -> Result<PoolTemplate> {
792        if !crate::vm::native_snapshot_fork_supported() {
793            return Err(BoxError::PoolError(
794                "snapshot-fork requires the Linux x86_64 KVM build".to_string(),
795            ));
796        }
797        let mut guard = template.lock().await;
798        let prior_failures = match &*guard {
799            TemplateState::Ready(t) => return Ok(t.clone()),
800            TemplateState::Unavailable => {
801                return Err(BoxError::PoolError(
802                    "snapshot-fork template unavailable (native VM snapshot unsupported)"
803                        .to_string(),
804                ));
805            }
806            // Unbuilt or a still-retryable prior failure: (re)attempt the build.
807            TemplateState::Failing(n) => *n,
808            TemplateState::Unbuilt => 0,
809        };
810
811        match Self::build_template(box_config, event_emitter).await {
812            Ok(tpl) => {
813                *guard = TemplateState::Ready(tpl.clone());
814                event_emitter.emit(BoxEvent::with_string(
815                    "pool.template.built",
816                    format!(
817                        "Snapshot-fork template built for image {}",
818                        box_config.image
819                    ),
820                ));
821                Ok(tpl)
822            }
823            Err(error) => {
824                // Bounded retry: a transient failure presents identically to
825                // "snapshot unsupported", so only give up permanently after a few
826                // consecutive failures rather than downgrading the pool to
827                // cold-boot forever on a one-off hiccup.
828                let failures = prior_failures + 1;
829                if failures >= MAX_TEMPLATE_BUILD_FAILURES {
830                    tracing::warn!(
831                        %error, failures,
832                        "snapshot-fork template build failed repeatedly; marking \
833                         unavailable — the warm pool will cold-boot"
834                    );
835                    *guard = TemplateState::Unavailable;
836                } else {
837                    tracing::warn!(
838                        %error, failures,
839                        "snapshot-fork template build failed; will retry on a later fill"
840                    );
841                    *guard = TemplateState::Failing(failures);
842                }
843                Err(error)
844            }
845        }
846    }
847
848    /// Cold-boot one source VM with file-backed RAM + a trigger socket, snapshot it,
849    /// and tear it down — leaving the RAM image + state file as the template.
850    async fn build_template(
851        box_config: &BoxConfig,
852        event_emitter: &EventEmitter,
853    ) -> Result<PoolTemplate> {
854        let dir = a3s_box_core::dirs_home().join("pool").join(format!(
855            "tpl-{:016x}",
856            crate::vm::fnv1a_hash(&box_config.image)
857        ));
858        std::fs::create_dir_all(&dir).map_err(BoxError::IoError)?;
859
860        // Cross-process lock on the per-image template dir. The dir is keyed only
861        // by the image hash, so two processes building the same image's template
862        // would write the same template.ram/template.state concurrently and
863        // corrupt them. Held (via a Send File handle) across the boot+snapshot
864        // awaits below; acquired off-runtime so a contended flock doesn't block a
865        // worker thread.
866        let lock_target = dir.clone();
867        let _lock =
868            tokio::task::spawn_blocking(move || crate::file_lock::FileLock::acquire(&lock_target))
869                .await
870                .map_err(|e| BoxError::PoolError(format!("Template lock task failed: {e}")))?
871                .map_err(|e| BoxError::PoolError(format!("Failed to lock template dir: {e}")))?;
872
873        let mem_file = dir.join("template.ram");
874        let sock = dir.join("template.sock");
875        let state_file = dir.join("template.state");
876        let _ = std::fs::remove_file(&sock);
877
878        // Cold-boot the source as a snapshot TEMPLATE (file-backed RAM + trigger sock).
879        let mut cfg = box_config.clone();
880        cfg.snapshot_mem_file = Some(mem_file.to_string_lossy().into_owned());
881        cfg.snapshot_sock = Some(sock.to_string_lossy().into_owned());
882        cfg.restore_from = None;
883        let mut src = VmManager::new(cfg, event_emitter.clone());
884        src.boot().await?;
885        let rootfs_cache_key = match src.current_rootfs_cache_key() {
886            Ok(key) => key,
887            Err(error) => {
888                let _ = src.destroy_with_timeout(2000).await;
889                return Err(error);
890            }
891        };
892
893        // Trigger the snapshot over libkrun's socket, then tear down the source (it is
894        // left paused by the snapshot; the RAM + state files are the template).
895        //
896        // Destroy the source UNCONDITIONALLY: `trigger_snapshot` fails on any
897        // libkrun without snapshot support (the common case), and `?`-ing out
898        // here would leak the fully-booted source VM (shim process, overlay
899        // mount, box dir, sockets) — neither VmManager nor ShimHandler reaps on
900        // drop. Capture the result, tear down, then propagate.
901        let snapshot = Self::trigger_snapshot(&sock, &state_file).await;
902        let _ = src.destroy_with_timeout(2000).await;
903        snapshot?;
904
905        Ok(PoolTemplate {
906            mem_file: mem_file.to_string_lossy().into_owned(),
907            state_file: state_file.to_string_lossy().into_owned(),
908            rootfs_cache_key,
909        })
910    }
911
912    /// Send a `snapshot <state>` request to libkrun's per-template trigger socket and
913    /// wait for the `ok` reply (the socket appears once the template's vCPUs run).
914    ///
915    /// Snapshot-fork is a Linux/KVM (Unix) feature; on non-Unix hosts the trigger
916    /// socket does not exist, so this is unavailable (see the `not(unix)` stub).
917    #[cfg(unix)]
918    async fn trigger_snapshot(sock: &std::path::Path, state_file: &std::path::Path) -> Result<()> {
919        use tokio::io::{AsyncReadExt, AsyncWriteExt};
920        // The socket is bound by libkrun after the guest starts; poll briefly.
921        let mut stream = None;
922        for _ in 0..200 {
923            match tokio::net::UnixStream::connect(sock).await {
924                Ok(s) => {
925                    stream = Some(s);
926                    break;
927                }
928                Err(_) => tokio::time::sleep(std::time::Duration::from_millis(25)).await,
929            }
930        }
931        let mut stream = stream.ok_or_else(|| {
932            BoxError::PoolError(format!("snapshot socket {} never appeared", sock.display()))
933        })?;
934        let cmd = format!("snapshot {}\n", state_file.display());
935        stream
936            .write_all(cmd.as_bytes())
937            .await
938            .map_err(BoxError::IoError)?;
939        let mut buf = [0u8; 64];
940        let n = stream.read(&mut buf).await.map_err(BoxError::IoError)?;
941        let reply = String::from_utf8_lossy(&buf[..n]);
942        if reply.trim() == "ok" {
943            Ok(())
944        } else {
945            Err(BoxError::PoolError(format!(
946                "snapshot trigger failed: {}",
947                reply.trim()
948            )))
949        }
950    }
951
952    /// Non-Unix stub: snapshot-fork relies on libkrun's Unix trigger socket and KVM
953    /// state save/restore, neither of which exist on Windows. `--snapshot-fork` is
954    /// Linux/KVM-only, so this path is never reached there in practice.
955    #[cfg(not(unix))]
956    async fn trigger_snapshot(
957        _sock: &std::path::Path,
958        _state_file: &std::path::Path,
959    ) -> Result<()> {
960        Err(BoxError::PoolError(
961            "snapshot-fork is only supported on Linux/KVM hosts".to_string(),
962        ))
963    }
964
965    /// Fill the pool to a specific idle target.
966    async fn fill_to_target(&self, target: usize) {
967        let current = self.idle.lock().await.len();
968        let needed = target.saturating_sub(current);
969
970        if needed == 0 {
971            return;
972        }
973
974        tracing::debug!(current, needed, target, "Replenishing warm pool");
975
976        // Track VMs added in this fill attempt so we can clean up on failure.
977        let mut added_ids: Vec<String> = Vec::new();
978        let mut failed = false;
979        let results = Self::boot_batch(
980            self.config.snapshot_fork,
981            &self.box_config,
982            &self.event_emitter,
983            &self.template,
984            needed,
985            self.config.max_concurrent_boots,
986            self.metrics.clone(),
987            self.boot_limiter.clone(),
988            self.global_boot_limiter.clone(),
989        )
990        .await;
991
992        for result in results {
993            match result {
994                Ok(vm) => {
995                    let box_id = vm.box_id().to_string();
996                    let mut idle = self.idle.lock().await;
997                    idle.push(WarmVm {
998                        vm,
999                        created_at: Instant::now(),
1000                    });
1001                    Self::sync_idle_metric(self.metrics.as_ref(), idle.len());
1002                    let mut stats = self.stats.lock().await;
1003                    stats.total_created += 1;
1004                    stats.idle_count = idle.len();
1005                    added_ids.push(box_id.clone());
1006
1007                    self.event_emitter.emit(BoxEvent::with_string(
1008                        "pool.vm.created",
1009                        format!("Booted new VM {box_id}"),
1010                    ));
1011                    tracing::debug!(box_id = %box_id, "Added VM to warm pool");
1012                }
1013                Err(error) => {
1014                    failed = true;
1015                    tracing::warn!(error = %error, "Failed to boot VM for warm pool");
1016                }
1017            }
1018        }
1019
1020        if failed && !added_ids.is_empty() {
1021            tracing::info!(
1022                count = added_ids.len(),
1023                "Cleaning up VMs added before pool fill failed"
1024            );
1025            self.remove_idle_vms(&added_ids).await;
1026        }
1027
1028        self.event_emitter.emit(BoxEvent::empty("pool.replenish"));
1029    }
1030
1031    /// Spawn the background maintenance loop.
1032    ///
1033    /// Periodically checks for:
1034    /// 1. Autoscaler evaluation → adjust min_idle dynamically
1035    /// 2. Pool below min_idle → replenish
1036    /// 3. Idle VMs past TTL → evict
1037    fn spawn_maintenance_loop(&self) -> JoinHandle<()> {
1038        let idle = Arc::clone(&self.idle);
1039        let stats = Arc::clone(&self.stats);
1040        let config = self.config.clone();
1041        let box_config = self.box_config.clone();
1042        let event_emitter = self.event_emitter.clone();
1043        let mut shutdown_rx = self.shutdown_rx.clone();
1044        let scaler = self.scaler.clone();
1045        let template = Arc::clone(&self.template);
1046        let metrics = self.metrics.clone();
1047        let boot_limiter = self.boot_limiter.clone();
1048        let global_boot_limiter = self.global_boot_limiter.clone();
1049
1050        tokio::spawn(async move {
1051            let check_interval = std::time::Duration::from_secs(
1052                // Check every 1/5 of TTL, minimum 5 seconds
1053                if config.idle_ttl_secs > 0 {
1054                    (config.idle_ttl_secs / 5).max(5)
1055                } else {
1056                    30
1057                },
1058            );
1059            let mut maintenance = tokio::time::interval(check_interval);
1060            // The first tick is immediate, which lets a first-ready lazy pool
1061            // continue filling without waiting for the full maintenance period.
1062            maintenance.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
1063
1064            // Dynamic min_idle starts from config, adjusted by scaler
1065            let mut effective_min_idle = config.min_idle;
1066            // A provider failure should not cause the maintenance loop to
1067            // repeatedly launch expensive doomed boots. Back off retries while
1068            // keeping the normal maintenance/eviction cadence unchanged.
1069            let mut replenish_failures = 0u32;
1070            let mut next_replenish_at = Instant::now();
1071
1072            loop {
1073                tokio::select! {
1074                    result = shutdown_rx.changed() => {
1075                        if result.is_ok() && *shutdown_rx.borrow() {
1076                            tracing::debug!("Pool maintenance loop shutting down");
1077                            break;
1078                        }
1079                    }
1080                    _ = maintenance.tick() => {
1081                        // Evict expired VMs
1082                        if config.idle_ttl_secs > 0 {
1083                            Self::evict_expired_static(
1084                                &idle,
1085                                &stats,
1086                                &event_emitter,
1087                                metrics.as_ref(),
1088                                config.idle_ttl_secs,
1089                            ).await;
1090                        }
1091
1092                        // Evaluate autoscaler
1093                        if let Some(ref scaler) = scaler {
1094                            let mut s = scaler.lock().await;
1095                            let decision = s.evaluate();
1096                            let new_min = s.current_min_idle();
1097                            if new_min != effective_min_idle {
1098                                tracing::info!(
1099                                    old_min_idle = effective_min_idle,
1100                                    new_min_idle = new_min,
1101                                    ?decision,
1102                                    "Autoscaler adjusted min_idle"
1103                                );
1104                                event_emitter.emit(BoxEvent::with_string(
1105                                    "pool.autoscale",
1106                                    format!(
1107                                        "min_idle adjusted {} → {} ({:?})",
1108                                        effective_min_idle, new_min, decision
1109                                    ),
1110                                ));
1111                                effective_min_idle = new_min;
1112                            }
1113                        }
1114
1115                        // Replenish if below effective min_idle
1116                        let current = idle.lock().await.len();
1117                        if current < effective_min_idle && Instant::now() >= next_replenish_at {
1118                            let needed = effective_min_idle - current;
1119                            tracing::debug!(current, needed, min_idle = effective_min_idle, "Replenishing warm pool");
1120
1121                            // Overlap readiness waits while keeping the number of
1122                            // expensive VM boots bounded by configuration.
1123                            let results = Self::boot_batch(
1124                                config.snapshot_fork,
1125                                &box_config,
1126                                &event_emitter,
1127                                &template,
1128                                needed,
1129                                config.max_concurrent_boots,
1130                                metrics.clone(),
1131                                boot_limiter.clone(),
1132                                global_boot_limiter.clone(),
1133                            )
1134                            .await;
1135                            let mut batch_failed = false;
1136                            for result in results {
1137                                match result {
1138                                    Ok(mut vm) => {
1139                                        let box_id = vm.box_id().to_string();
1140                                        // If shutdown landed while this batch was
1141                                        // booting, drain_idle has already cleared
1142                                        // `idle` and will not run again, so a VM
1143                                        // pushed now leaks (no Drop reaper). Destroy
1144                                        // it instead. Acquire the idle lock FIRST and
1145                                        // re-check shutdown UNDER it: drain_idle drains
1146                                        // while holding this same lock (always after
1147                                        // signal_shutdown), so the check-and-push is
1148                                        // atomic against it — closing the TOCTOU window
1149                                        // that an unlocked `borrow()` check left open.
1150                                        let mut pool = idle.lock().await;
1151                                        if *shutdown_rx.borrow() {
1152                                            drop(pool);
1153                                            tracing::debug!(
1154                                                box_id = %box_id,
1155                                                "Pool shutting down mid-replenish; destroying freshly-booted VM"
1156                                            );
1157                                            let _ = vm.destroy_with_timeout(2000).await;
1158                                            continue;
1159                                        }
1160                                        pool.push(WarmVm {
1161                                            vm,
1162                                            created_at: Instant::now(),
1163                                        });
1164                                        Self::sync_idle_metric(metrics.as_ref(), pool.len());
1165                                        let mut s = stats.lock().await;
1166                                        s.total_created += 1;
1167                                        s.idle_count = pool.len();
1168                                        drop(s);
1169                                        drop(pool);
1170
1171                                        event_emitter.emit(BoxEvent::with_string(
1172                                            "pool.vm.created",
1173                                            format!("Replenished VM {}", box_id),
1174                                        ));
1175                                    }
1176                                    Err(error) => {
1177                                        batch_failed = true;
1178                                        tracing::warn!(error = %error, "Failed to replenish warm pool");
1179                                    }
1180                                }
1181                            }
1182
1183                            if batch_failed {
1184                                replenish_failures = replenish_failures.saturating_add(1);
1185                                let delay = replenish_backoff_delay(
1186                                    replenish_failures,
1187                                    check_interval,
1188                                );
1189                                next_replenish_at = Instant::now() + delay;
1190                                tracing::warn!(
1191                                    failures = replenish_failures,
1192                                    retry_in_secs = delay.as_secs(),
1193                                    "Backing off warm-pool replenishment after boot failure"
1194                                );
1195                            } else {
1196                                replenish_failures = 0;
1197                                next_replenish_at = Instant::now();
1198                            }
1199
1200                            event_emitter.emit(BoxEvent::empty("pool.replenish"));
1201                        }
1202                    }
1203                }
1204            }
1205        })
1206    }
1207
1208    /// Static version of evict_expired for use in the spawned task.
1209    async fn evict_expired_static(
1210        idle: &Arc<Mutex<Vec<WarmVm>>>,
1211        stats: &Arc<Mutex<PoolStats>>,
1212        event_emitter: &EventEmitter,
1213        metrics: Option<&crate::prom::RuntimeMetrics>,
1214        idle_ttl_secs: u64,
1215    ) {
1216        let ttl = std::time::Duration::from_secs(idle_ttl_secs);
1217
1218        let mut pool = idle.lock().await;
1219        let mut kept = Vec::new();
1220        let mut expired = Vec::new();
1221
1222        for warm_vm in pool.drain(..) {
1223            if warm_vm.created_at.elapsed() > ttl {
1224                expired.push(warm_vm);
1225            } else {
1226                kept.push(warm_vm);
1227            }
1228        }
1229        *pool = kept;
1230        let after_count = pool.len();
1231        drop(pool);
1232
1233        let evicted_count = expired.len();
1234        Self::sync_idle_metric(metrics, after_count);
1235        for warm_vm in expired {
1236            let mut vm = warm_vm.vm;
1237            let _ = vm.destroy().await;
1238        }
1239
1240        if evicted_count > 0 {
1241            let mut s = stats.lock().await;
1242            s.total_evicted += evicted_count as u64;
1243            s.idle_count = after_count;
1244
1245            event_emitter.emit(BoxEvent::with_string(
1246                "pool.vm.evicted",
1247                format!("Evicted {} expired VMs", evicted_count),
1248            ));
1249        }
1250    }
1251}
1252
1253/// Return the number of boot tasks that may be in flight for one batch.
1254///
1255/// Configuration validation rejects zero for normal pool construction. The
1256/// defensive `max(1)` keeps this scheduler total for internal callers and
1257/// prevents a zero limit from deadlocking the JoinSet loop.
1258fn bounded_boot_limit(needed: usize, max_concurrent_boots: usize) -> usize {
1259    needed.min(max_concurrent_boots.max(1))
1260}
1261
1262/// Calculate the retry delay after a failed background replenishment batch.
1263///
1264/// The regular maintenance tick remains responsible for eviction and scaling,
1265/// while only replenishment is delayed. Capping the delay keeps a transient
1266/// provider outage recoverable without allowing a permanently unavailable
1267/// backend to churn the host indefinitely.
1268fn replenish_backoff_delay(failures: u32, check_interval: Duration) -> Duration {
1269    let exponent = failures.saturating_sub(1).min(8);
1270    let multiplier = 1u64 << exponent;
1271    let delay_secs = check_interval
1272        .as_secs()
1273        .max(1)
1274        .saturating_mul(multiplier)
1275        .min(300);
1276    Duration::from_secs(delay_secs)
1277}
1278
1279#[cfg(test)]
1280mod tests {
1281    use super::*;
1282    use a3s_box_core::config::PoolConfig;
1283
1284    fn test_pool_config(min_idle: usize, max_size: usize) -> PoolConfig {
1285        PoolConfig {
1286            enabled: true,
1287            min_idle,
1288            max_size,
1289            idle_ttl_secs: 300,
1290            ..Default::default()
1291        }
1292    }
1293
1294    fn test_event_emitter() -> EventEmitter {
1295        EventEmitter::new(100)
1296    }
1297
1298    #[test]
1299    fn boot_or_restore_future_stays_heap_indirected() {
1300        let config = BoxConfig::default();
1301        let emitter = test_event_emitter();
1302        let template = Arc::new(Mutex::new(TemplateState::Unbuilt));
1303        let future = WarmPool::boot_or_restore(false, &config, &emitter, &template);
1304
1305        assert!(
1306            std::mem::size_of_val(&future) <= 2 * std::mem::size_of::<usize>(),
1307            "boot_or_restore future must remain pointer-sized so pool misses fit on Tokio worker stacks; got {} bytes",
1308            std::mem::size_of_val(&future)
1309        );
1310    }
1311
1312    #[cfg(not(all(target_os = "linux", target_arch = "x86_64")))]
1313    #[tokio::test]
1314    async fn unsupported_snapshot_fork_is_rejected_before_template_construction() {
1315        let template = Arc::new(Mutex::new(TemplateState::Unbuilt));
1316        let result =
1317            WarmPool::ensure_template(&BoxConfig::default(), &test_event_emitter(), &template)
1318                .await;
1319        let error = match result {
1320            Ok(_) => panic!("unsupported host unexpectedly built a snapshot template"),
1321            Err(error) => error.to_string(),
1322        };
1323
1324        assert!(error.contains("Linux x86_64 KVM"), "{error}");
1325        assert!(matches!(&*template.lock().await, TemplateState::Unbuilt));
1326    }
1327
1328    // --- PoolConfig validation tests ---
1329
1330    #[tokio::test]
1331    async fn test_pool_rejects_zero_max_size() {
1332        let config = test_pool_config(0, 0);
1333        let result = WarmPool::start(config, BoxConfig::default(), test_event_emitter()).await;
1334        match result {
1335            Err(e) => assert!(e.to_string().contains("max_size must be greater than 0")),
1336            Ok(_) => panic!("Expected error for zero max_size"),
1337        }
1338    }
1339
1340    #[tokio::test]
1341    async fn test_pool_rejects_min_idle_exceeds_max() {
1342        let config = test_pool_config(10, 5);
1343        let result = WarmPool::start(config, BoxConfig::default(), test_event_emitter()).await;
1344        match result {
1345            Err(e) => assert!(e.to_string().contains("cannot exceed max_size")),
1346            Ok(_) => panic!("Expected error for min_idle > max_size"),
1347        }
1348    }
1349
1350    #[tokio::test]
1351    async fn test_pool_rejects_zero_max_concurrent_boots() {
1352        let mut config = test_pool_config(0, 1);
1353        config.max_concurrent_boots = 0;
1354        let result = WarmPool::start(config, BoxConfig::default(), test_event_emitter()).await;
1355        match result {
1356            Err(error) => assert!(error.to_string().contains("max_concurrent_boots")),
1357            Ok(_) => panic!("Expected error for zero max_concurrent_boots"),
1358        }
1359    }
1360
1361    #[tokio::test]
1362    async fn acquire_boot_permits_releases_both_scopes() {
1363        let pool_limiter = Arc::new(Semaphore::new(1));
1364        let global_limiter = Arc::new(Semaphore::new(1));
1365        let (pool_permit, global_permit) =
1366            acquire_boot_permits(pool_limiter.clone(), Some(global_limiter.clone()))
1367                .await
1368                .expect("both boot limiters should grant a permit");
1369        assert_eq!(pool_limiter.available_permits(), 0);
1370        assert_eq!(global_limiter.available_permits(), 0);
1371        drop((pool_permit, global_permit));
1372        assert_eq!(pool_limiter.available_permits(), 1);
1373        assert_eq!(global_limiter.available_permits(), 1);
1374    }
1375
1376    #[test]
1377    fn boot_batch_limit_is_bounded_and_never_deadlocks() {
1378        assert_eq!(bounded_boot_limit(0, 2), 0);
1379        assert_eq!(bounded_boot_limit(8, 2), 2);
1380        assert_eq!(bounded_boot_limit(2, 8), 2);
1381        assert_eq!(bounded_boot_limit(8, 0), 1);
1382    }
1383
1384    #[test]
1385    fn replenish_backoff_is_exponential_and_capped() {
1386        let base = Duration::from_secs(5);
1387        assert_eq!(replenish_backoff_delay(0, base), Duration::from_secs(5));
1388        assert_eq!(replenish_backoff_delay(1, base), Duration::from_secs(5));
1389        assert_eq!(replenish_backoff_delay(2, base), Duration::from_secs(10));
1390        assert_eq!(replenish_backoff_delay(7, base), Duration::from_secs(300));
1391        assert_eq!(replenish_backoff_delay(20, base), Duration::from_secs(300));
1392    }
1393
1394    #[test]
1395    fn boot_metric_guard_balances_inflight_gauge() {
1396        let metrics = crate::prom::RuntimeMetrics::new();
1397        {
1398            let _guard = BootMetricGuard::new(Some(metrics.clone()));
1399            assert_eq!(metrics.warm_pool_boots_inflight.get(), 1);
1400        }
1401        assert_eq!(metrics.warm_pool_boots_inflight.get(), 0);
1402    }
1403
1404    // --- PoolStats tests ---
1405
1406    #[test]
1407    fn test_pool_stats_default() {
1408        let stats = PoolStats {
1409            idle_count: 0,
1410            total_created: 0,
1411            total_acquired: 0,
1412            total_released: 0,
1413            total_evicted: 0,
1414        };
1415        assert_eq!(stats.idle_count, 0);
1416        assert_eq!(stats.total_created, 0);
1417    }
1418
1419    #[test]
1420    fn test_pool_stats_clone() {
1421        let stats = PoolStats {
1422            idle_count: 3,
1423            total_created: 10,
1424            total_acquired: 7,
1425            total_released: 5,
1426            total_evicted: 2,
1427        };
1428        let cloned = stats.clone();
1429        assert_eq!(cloned.idle_count, 3);
1430        assert_eq!(cloned.total_created, 10);
1431        assert_eq!(cloned.total_acquired, 7);
1432        assert_eq!(cloned.total_released, 5);
1433        assert_eq!(cloned.total_evicted, 2);
1434    }
1435
1436    #[test]
1437    fn test_pool_stats_debug() {
1438        let stats = PoolStats {
1439            idle_count: 1,
1440            total_created: 2,
1441            total_acquired: 3,
1442            total_released: 4,
1443            total_evicted: 5,
1444        };
1445        let debug = format!("{:?}", stats);
1446        assert!(debug.contains("idle_count"));
1447        assert!(debug.contains("total_created"));
1448    }
1449
1450    // --- PoolConfig serialization tests ---
1451
1452    #[test]
1453    fn test_pool_config_roundtrip() {
1454        let config = PoolConfig {
1455            enabled: true,
1456            min_idle: 3,
1457            max_size: 10,
1458            idle_ttl_secs: 600,
1459            ..Default::default()
1460        };
1461
1462        let json = serde_json::to_string(&config).unwrap();
1463        let parsed: PoolConfig = serde_json::from_str(&json).unwrap();
1464
1465        assert!(parsed.enabled);
1466        assert_eq!(parsed.min_idle, 3);
1467        assert_eq!(parsed.max_size, 10);
1468        assert_eq!(parsed.idle_ttl_secs, 600);
1469    }
1470
1471    #[test]
1472    fn test_pool_config_default_values() {
1473        let config = PoolConfig::default();
1474        assert!(!config.enabled);
1475        assert_eq!(config.min_idle, 1);
1476        assert_eq!(config.max_size, 5);
1477        assert_eq!(config.idle_ttl_secs, 300);
1478    }
1479
1480    #[test]
1481    fn test_pool_config_deserialization_with_defaults() {
1482        let json = r#"{"enabled": true}"#;
1483        let config: PoolConfig = serde_json::from_str(json).unwrap();
1484        assert!(config.enabled);
1485        assert_eq!(config.min_idle, 1);
1486        assert_eq!(config.max_size, 5);
1487        assert_eq!(config.idle_ttl_secs, 300);
1488    }
1489
1490    // --- PoolConfig validation edge cases ---
1491
1492    #[tokio::test]
1493    async fn test_pool_accepts_min_idle_equals_max() {
1494        let config = test_pool_config(3, 3);
1495        // This should be accepted (min_idle == max_size is valid)
1496        // It will fail at boot (no shim), but config validation should pass
1497        let result = WarmPool::start(config, BoxConfig::default(), test_event_emitter()).await;
1498        // The error should be about VM boot, not config validation
1499        match result {
1500            Err(e) => assert!(!e.to_string().contains("cannot exceed max_size")),
1501            Ok(mut pool) => {
1502                let _ = pool.drain().await;
1503            }
1504        }
1505    }
1506
1507    #[tokio::test]
1508    async fn test_pool_accepts_min_idle_zero() {
1509        let config = test_pool_config(0, 5);
1510        // min_idle=0 means no pre-warming, should be valid
1511        let result = WarmPool::start(config, BoxConfig::default(), test_event_emitter()).await;
1512        match result {
1513            Ok(mut pool) => {
1514                // Pool should start with 0 idle VMs
1515                assert_eq!(pool.idle_count().await, 0);
1516                let stats = pool.stats().await;
1517                assert_eq!(stats.idle_count, 0);
1518                assert_eq!(stats.total_created, 0);
1519                let _ = pool.drain().await;
1520            }
1521            Err(e) => {
1522                // If it fails, it should NOT be a config validation error
1523                assert!(!e.to_string().contains("max_size"));
1524                assert!(!e.to_string().contains("min_idle"));
1525            }
1526        }
1527    }
1528
1529    // --- WarmPool internal state tests (using min_idle=0 to avoid boot) ---
1530
1531    #[tokio::test]
1532    async fn test_pool_stats_initial() {
1533        let config = test_pool_config(0, 5);
1534        let result = WarmPool::start(config, BoxConfig::default(), test_event_emitter()).await;
1535        if let Ok(mut pool) = result {
1536            let stats = pool.stats().await;
1537            assert_eq!(stats.idle_count, 0);
1538            assert_eq!(stats.total_created, 0);
1539            assert_eq!(stats.total_acquired, 0);
1540            assert_eq!(stats.total_released, 0);
1541            assert_eq!(stats.total_evicted, 0);
1542            let _ = pool.drain().await;
1543        }
1544    }
1545
1546    #[tokio::test]
1547    async fn test_pool_idle_count_initial() {
1548        let config = test_pool_config(0, 5);
1549        let result = WarmPool::start(config, BoxConfig::default(), test_event_emitter()).await;
1550        if let Ok(mut pool) = result {
1551            assert_eq!(pool.idle_count().await, 0);
1552            let _ = pool.drain().await;
1553        }
1554    }
1555
1556    #[tokio::test]
1557    async fn test_pool_drain_empty_pool() {
1558        let config = test_pool_config(0, 5);
1559        let result = WarmPool::start(config, BoxConfig::default(), test_event_emitter()).await;
1560        if let Ok(mut pool) = result {
1561            // Draining an empty pool should succeed without error
1562            let drain_result = pool.drain().await;
1563            assert!(drain_result.is_ok());
1564
1565            let stats = pool.stats().await;
1566            assert_eq!(stats.idle_count, 0);
1567        }
1568    }
1569
1570    #[tokio::test]
1571    async fn test_pool_drain_emits_event() {
1572        let emitter = test_event_emitter();
1573        let mut receiver = emitter.subscribe();
1574        let config = test_pool_config(0, 5);
1575
1576        let result = WarmPool::start(config, BoxConfig::default(), emitter).await;
1577        if let Ok(mut pool) = result {
1578            pool.drain().await.unwrap();
1579
1580            // Check that pool.drained event was emitted
1581            let mut found_drain_event = false;
1582            // Drain all events from the receiver
1583            while let Ok(event) = receiver.try_recv() {
1584                if event.key == "pool.drained" {
1585                    found_drain_event = true;
1586                }
1587            }
1588            assert!(found_drain_event, "Expected pool.drained event");
1589        }
1590    }
1591
1592    #[tokio::test]
1593    async fn test_pool_acquire_from_empty_pool_fails_without_shim() {
1594        let config = test_pool_config(0, 5);
1595        let result = WarmPool::start(config, BoxConfig::default(), test_event_emitter()).await;
1596        if let Ok(pool) = result {
1597            // Acquire from empty pool should try to boot a VM, which will fail
1598            // because there's no shim binary available in test environment
1599            let acquire_result = pool.acquire().await;
1600            assert!(acquire_result.is_err());
1601        }
1602    }
1603
1604    // --- Maintenance loop check interval calculation ---
1605
1606    #[test]
1607    #[allow(clippy::unnecessary_min_or_max)]
1608    fn test_maintenance_check_interval_with_ttl() {
1609        // TTL = 300s → check every 60s (300/5)
1610        let interval = if 300_u64 > 0 {
1611            (300_u64 / 5).max(5)
1612        } else {
1613            30
1614        };
1615        assert_eq!(interval, 60);
1616    }
1617
1618    #[test]
1619    #[allow(clippy::unnecessary_min_or_max)]
1620    fn test_maintenance_check_interval_short_ttl() {
1621        // TTL = 10s → check every 5s (min 5)
1622        let interval = if 10_u64 > 0 { (10_u64 / 5).max(5) } else { 30 };
1623        assert_eq!(interval, 5);
1624    }
1625
1626    #[test]
1627    #[allow(clippy::unnecessary_min_or_max)]
1628    fn test_maintenance_check_interval_very_short_ttl() {
1629        // TTL = 1s → check every 5s (min 5)
1630        let interval = if 1_u64 > 0 { (1_u64 / 5).max(5) } else { 30 };
1631        assert_eq!(interval, 5);
1632    }
1633
1634    #[test]
1635    #[allow(
1636        clippy::absurd_extreme_comparisons,
1637        clippy::erasing_op,
1638        clippy::unnecessary_min_or_max,
1639        unused_comparisons
1640    )]
1641    fn test_maintenance_check_interval_no_ttl() {
1642        // TTL = 0 → check every 30s
1643        let interval = if 0_u64 > 0 { (0_u64 / 5).max(5) } else { 30 };
1644        assert_eq!(interval, 30);
1645    }
1646
1647    // --- WarmVm struct tests ---
1648
1649    #[test]
1650    fn test_warm_vm_created_at_is_recent() {
1651        let before = Instant::now();
1652        let created_at = Instant::now();
1653        let after = Instant::now();
1654
1655        assert!(created_at >= before);
1656        assert!(created_at <= after);
1657    }
1658
1659    // --- PoolStats field coverage ---
1660
1661    #[test]
1662    fn test_pool_stats_all_fields() {
1663        let stats = PoolStats {
1664            idle_count: 10,
1665            total_created: 100,
1666            total_acquired: 80,
1667            total_released: 70,
1668            total_evicted: 15,
1669        };
1670
1671        assert_eq!(stats.idle_count, 10);
1672        assert_eq!(stats.total_created, 100);
1673        assert_eq!(stats.total_acquired, 80);
1674        assert_eq!(stats.total_released, 70);
1675        assert_eq!(stats.total_evicted, 15);
1676
1677        // Verify debug output contains all fields
1678        let debug = format!("{:?}", stats);
1679        assert!(debug.contains("10"));
1680        assert!(debug.contains("100"));
1681        assert!(debug.contains("80"));
1682        assert!(debug.contains("70"));
1683        assert!(debug.contains("15"));
1684    }
1685
1686    // Note: Full integration tests for acquire/release/drain with actual VMs
1687    // require a working VM runtime (shim binary + libkrun). These are tested
1688    // in integration tests with the full box environment. The unit tests here
1689    // validate configuration, statistics, error handling, and pool lifecycle
1690    // with min_idle=0 (no VM boot required).
1691
1692    #[tokio::test]
1693    async fn test_pool_set_metrics_attaches() {
1694        let config = test_pool_config(0, 5);
1695        let result = WarmPool::start(config, BoxConfig::default(), test_event_emitter()).await;
1696        match result {
1697            Ok(mut pool) => {
1698                let metrics = crate::prom::RuntimeMetrics::new();
1699                pool.set_metrics(metrics.clone());
1700                assert!(pool.metrics.is_some());
1701                // Metrics start at zero
1702                assert_eq!(metrics.warm_pool_hits.get(), 0);
1703                assert_eq!(metrics.warm_pool_misses.get(), 0);
1704                assert_eq!(metrics.warm_pool_size.get(), 0);
1705                let _ = pool.drain().await;
1706            }
1707            Err(_) => {
1708                // Boot failure is acceptable in unit test environment
1709            }
1710        }
1711    }
1712
1713    #[tokio::test]
1714    async fn test_pool_start_with_metrics_installs_sink_before_fill() {
1715        let config = test_pool_config(0, 5);
1716        let metrics = crate::prom::RuntimeMetrics::new();
1717        let result = WarmPool::start_with_metrics(
1718            config,
1719            BoxConfig::default(),
1720            test_event_emitter(),
1721            Some(metrics.clone()),
1722        )
1723        .await;
1724
1725        match result {
1726            Ok(mut pool) => {
1727                assert!(pool.metrics.is_some());
1728                assert_eq!(metrics.warm_pool_capacity.get(), 5);
1729                assert_eq!(
1730                    metrics.warm_pool_initial_fill_duration.get_sample_count(),
1731                    1
1732                );
1733                let _ = pool.drain().await;
1734            }
1735            Err(_) => {
1736                // Boot failure is acceptable in unit test environments without
1737                // a usable VM provider; min_idle=0 normally avoids this path.
1738            }
1739        }
1740    }
1741}