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