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::sync::Arc;
7use std::time::Instant;
8
9use a3s_box_core::config::{BoxConfig, PoolConfig};
10use a3s_box_core::error::{BoxError, Result};
11use a3s_box_core::event::{BoxEvent, EventEmitter};
12use tokio::sync::watch;
13use tokio::sync::Mutex;
14use tokio::task::JoinHandle;
15
16use crate::pool::scaler::PoolScaler;
17use crate::vm::VmManager;
18
19/// A pre-warmed VM waiting in the pool.
20struct WarmVm {
21    /// The ready VM manager instance.
22    vm: VmManager,
23    /// When this VM was added to the pool.
24    created_at: Instant,
25}
26
27/// Statistics about the warm pool.
28#[derive(Debug, Clone)]
29pub struct PoolStats {
30    /// Number of idle VMs ready for acquisition.
31    pub idle_count: usize,
32    /// Total number of VMs created by this pool (including acquired ones).
33    pub total_created: u64,
34    /// Total number of VMs acquired from the pool.
35    pub total_acquired: u64,
36    /// Total number of VMs released back to the pool.
37    pub total_released: u64,
38    /// Total number of VMs evicted due to idle TTL.
39    pub total_evicted: u64,
40}
41
42/// A pre-warmed pool of ready-to-use MicroVMs.
43///
44/// The pool maintains `min_idle` VMs in `Ready` state. When a VM is
45/// acquired, the pool spawns a replacement in the background. Idle VMs
46/// that exceed `idle_ttl_secs` are automatically evicted.
47///
48/// # Usage
49///
50/// ```ignore
51/// let pool = WarmPool::start(pool_config, box_config, emitter).await?;
52/// let vm = pool.acquire().await?;  // Instant if pool has capacity
53/// // ... use vm ...
54/// pool.release(vm).await?;         // Return to pool or destroy
55/// pool.drain().await?;             // Graceful shutdown
56/// ```
57pub struct WarmPool {
58    /// Pool configuration.
59    config: PoolConfig,
60    /// Base BoxConfig template for creating new VMs.
61    box_config: BoxConfig,
62    /// Idle VMs ready for acquisition.
63    idle: Arc<Mutex<Vec<WarmVm>>>,
64    /// Pool statistics.
65    stats: Arc<Mutex<PoolStats>>,
66    /// Event emitter for pool lifecycle events.
67    event_emitter: EventEmitter,
68    /// Background replenishment task handle.
69    replenish_handle: Option<JoinHandle<()>>,
70    /// Shutdown signal sender.
71    shutdown_tx: watch::Sender<bool>,
72    /// Shutdown signal receiver (cloned for background task).
73    shutdown_rx: watch::Receiver<bool>,
74    /// Autoscaler for dynamic min_idle adjustment (None if scaling disabled).
75    scaler: Option<Arc<Mutex<PoolScaler>>>,
76    /// Prometheus metrics (optional).
77    metrics: Option<crate::prom::RuntimeMetrics>,
78    /// Snapshot-fork template state (built lazily on first fill when
79    /// `config.snapshot_fork`): the file-backed RAM image + state file every other
80    /// pool VM restores from. Caches an `Unavailable` verdict so a build failure
81    /// (native VM snapshot unsupported on this build) is not re-attempted on every
82    /// fill — the pool cold-boots instead.
83    template: Arc<Mutex<TemplateState>>,
84}
85
86/// A built snapshot-fork template: the shared RAM image + state file that pool VMs
87/// restore from (MAP_PRIVATE CoW of the RAM file).
88#[derive(Clone)]
89struct PoolTemplate {
90    mem_file: String,
91    state_file: String,
92}
93
94/// How many consecutive template-build failures are tolerated before the
95/// verdict becomes permanently `Unavailable`. A transient failure (host
96/// resource pressure, a source VM slow to bind its snapshot socket) presents
97/// identically to "snapshot unsupported by this libkrun build" ("snapshot
98/// socket never appeared"), so a bounded retry avoids permanently downgrading
99/// the whole pool to cold-boot on a one-off hiccup, while still giving up on a
100/// genuinely-unsupported host after a few attempts.
101const MAX_TEMPLATE_BUILD_FAILURES: u32 = 3;
102
103/// Cached state of the snapshot-fork template.
104enum TemplateState {
105    /// Not built yet — the first snapshot-fork fill attempts the build.
106    Unbuilt,
107    /// Built and ready; pool VMs restore from it.
108    Ready(PoolTemplate),
109    /// The last build failed but is still retryable; carries the consecutive
110    /// failure count. A later fill retries until it reaches
111    /// `MAX_TEMPLATE_BUILD_FAILURES`, then it becomes `Unavailable`.
112    Failing(u32),
113    /// The build failed permanently (native VM snapshot unavailable on this
114    /// build/platform, or too many consecutive failures). Cached so it is not
115    /// retried — `boot_or_restore` cold-boots instead.
116    Unavailable,
117}
118
119impl WarmPool {
120    /// Create and start the warm pool.
121    ///
122    /// Spawns `min_idle` VMs in the background and starts the
123    /// replenishment/eviction loop.
124    pub async fn start(
125        config: PoolConfig,
126        box_config: BoxConfig,
127        event_emitter: EventEmitter,
128    ) -> Result<Self> {
129        if config.max_size == 0 {
130            return Err(BoxError::PoolError(
131                "Pool max_size must be greater than 0".to_string(),
132            ));
133        }
134        if config.min_idle > config.max_size {
135            return Err(BoxError::PoolError(format!(
136                "Pool min_idle ({}) cannot exceed max_size ({})",
137                config.min_idle, config.max_size
138            )));
139        }
140
141        let idle = Arc::new(Mutex::new(Vec::with_capacity(config.max_size)));
142        let stats = Arc::new(Mutex::new(PoolStats {
143            idle_count: 0,
144            total_created: 0,
145            total_acquired: 0,
146            total_released: 0,
147            total_evicted: 0,
148        }));
149        let (shutdown_tx, shutdown_rx) = watch::channel(false);
150
151        let scaler = if config.scaling.enabled {
152            Some(Arc::new(Mutex::new(PoolScaler::new(
153                config.scaling.clone(),
154                config.min_idle,
155                config.max_size,
156            ))))
157        } else {
158            None
159        };
160
161        let mut pool = Self {
162            config,
163            box_config,
164            idle,
165            stats,
166            event_emitter,
167            replenish_handle: None,
168            shutdown_tx,
169            shutdown_rx,
170            scaler,
171            metrics: None,
172            template: Arc::new(Mutex::new(TemplateState::Unbuilt)),
173        };
174
175        // Initial fill
176        pool.fill_to_min().await;
177
178        // Start background maintenance loop
179        let handle = pool.spawn_maintenance_loop();
180        pool.replenish_handle = Some(handle);
181
182        tracing::info!(
183            min_idle = pool.config.min_idle,
184            max_size = pool.config.max_size,
185            idle_ttl_secs = pool.config.idle_ttl_secs,
186            "Warm pool started"
187        );
188
189        Ok(pool)
190    }
191
192    /// Attach Prometheus metrics to this pool.
193    pub fn set_metrics(&mut self, metrics: crate::prom::RuntimeMetrics) {
194        metrics.warm_pool_capacity.set(self.config.max_size as i64);
195        self.metrics = Some(metrics);
196    }
197
198    /// Acquire a ready VM from the pool.
199    ///
200    /// If an idle VM is available, returns it immediately.
201    /// Otherwise, boots a new VM on demand (slower path).
202    pub async fn acquire(&self) -> Result<VmManager> {
203        // Try to pop an idle VM
204        {
205            let mut idle = self.idle.lock().await;
206            if let Some(warm_vm) = idle.pop() {
207                let mut stats = self.stats.lock().await;
208                stats.total_acquired += 1;
209                stats.idle_count = idle.len();
210
211                // Record hit for autoscaler
212                if let Some(ref scaler) = self.scaler {
213                    scaler.lock().await.record_acquire(true);
214                }
215
216                if let Some(ref m) = self.metrics {
217                    m.warm_pool_hits.inc();
218                    m.warm_pool_size.set(idle.len() as i64);
219                }
220
221                self.event_emitter.emit(BoxEvent::with_string(
222                    "pool.vm.acquired",
223                    format!("Acquired VM {} from pool", warm_vm.vm.box_id()),
224                ));
225
226                tracing::debug!(
227                    box_id = %warm_vm.vm.box_id(),
228                    idle_remaining = idle.len(),
229                    "Acquired VM from warm pool"
230                );
231
232                return Ok(warm_vm.vm);
233            }
234        }
235
236        // No idle VM available — boot one on demand (miss)
237        tracing::info!("No idle VM in pool, booting on demand");
238
239        // Record miss for autoscaler
240        if let Some(ref scaler) = self.scaler {
241            scaler.lock().await.record_acquire(false);
242        }
243
244        if let Some(ref m) = self.metrics {
245            m.warm_pool_misses.inc();
246        }
247
248        let vm = self.boot_new_vm().await?;
249
250        let mut stats = self.stats.lock().await;
251        stats.total_acquired += 1;
252
253        Ok(vm)
254    }
255
256    /// Release a VM back to the pool.
257    ///
258    /// If the pool is at capacity, the VM is destroyed instead.
259    pub async fn release(&self, vm: VmManager) -> Result<()> {
260        let mut idle = self.idle.lock().await;
261
262        // Don't return a VM to a pool that is shutting down: drain_idle has (or
263        // soon will have) cleared `idle` and won't run again, so a push here leaks
264        // the VM (no Drop reaper). Checked under the idle lock so it is atomic with
265        // a concurrent drain_idle. Destroy the VM instead.
266        if *self.shutdown_rx.borrow() {
267            drop(idle);
268            let mut vm = vm;
269            vm.destroy().await?;
270            return Ok(());
271        }
272
273        if idle.len() >= self.config.max_size {
274            // Pool is full — destroy the VM
275            drop(idle); // Release lock before async destroy
276            let mut vm = vm;
277            vm.destroy().await?;
278
279            tracing::debug!(
280                box_id = %vm.box_id(),
281                "Pool full, destroyed released VM"
282            );
283            return Ok(());
284        }
285
286        let box_id = vm.box_id().to_string();
287        idle.push(WarmVm {
288            vm,
289            created_at: Instant::now(),
290        });
291
292        let mut stats = self.stats.lock().await;
293        stats.total_released += 1;
294        stats.idle_count = idle.len();
295
296        if let Some(ref m) = self.metrics {
297            m.warm_pool_size.set(idle.len() as i64);
298        }
299
300        self.event_emitter.emit(BoxEvent::with_string(
301            "pool.vm.released",
302            format!("Released VM {} back to pool", box_id),
303        ));
304
305        tracing::debug!(
306            box_id = %box_id,
307            idle_count = idle.len(),
308            "Released VM back to warm pool"
309        );
310
311        Ok(())
312    }
313
314    /// Get current pool statistics.
315    pub async fn stats(&self) -> PoolStats {
316        self.stats.lock().await.clone()
317    }
318
319    /// Get the number of idle VMs currently in the pool.
320    pub async fn idle_count(&self) -> usize {
321        self.idle.lock().await.len()
322    }
323
324    /// Signal the pool to shutdown. This signals the background task to stop
325    /// replenishing and sets the shutdown flag. VMs will continue to exist
326    /// until the pool is drained or dropped.
327    pub fn signal_shutdown(&self) {
328        let _ = self.shutdown_tx.send(true);
329        tracing::info!("Warm pool shutdown signaled");
330    }
331
332    /// Gracefully drain all VMs and stop the pool.
333    pub async fn drain(&mut self) -> Result<()> {
334        // Signal shutdown to background task
335        let _ = self.shutdown_tx.send(true);
336
337        // Wait for background task to finish
338        if let Some(handle) = self.replenish_handle.take() {
339            let _ = handle.await;
340        }
341
342        // Destroy all idle VMs
343        let mut idle = self.idle.lock().await;
344        let count = idle.len();
345
346        for warm_vm in idle.drain(..) {
347            let mut vm = warm_vm.vm;
348            if let Err(e) = vm.destroy().await {
349                tracing::warn!(
350                    box_id = %vm.box_id(),
351                    error = %e,
352                    "Failed to destroy pooled VM during drain"
353                );
354            }
355        }
356
357        let mut stats = self.stats.lock().await;
358        stats.idle_count = 0;
359
360        self.event_emitter.emit(BoxEvent::empty("pool.drained"));
361
362        tracing::info!(destroyed = count, "Warm pool drained");
363
364        Ok(())
365    }
366
367    /// Destroy all idle VMs without consuming the pool (`&self`), so it can be
368    /// shut down from behind an `Arc` (e.g. a daemon serving concurrent requests).
369    /// Pair with [`Self::signal_shutdown`] first to stop the background replenisher;
370    /// its task then exits on its own (it watches the shutdown channel).
371    pub async fn drain_idle(&self) -> Result<()> {
372        let mut idle = self.idle.lock().await;
373        let count = idle.len();
374        for warm_vm in idle.drain(..) {
375            let mut vm = warm_vm.vm;
376            if let Err(e) = vm.destroy().await {
377                tracing::warn!(
378                    box_id = %vm.box_id(),
379                    error = %e,
380                    "Failed to destroy pooled VM during drain_idle"
381                );
382            }
383        }
384        self.stats.lock().await.idle_count = 0;
385        tracing::info!(destroyed = count, "Warm pool idle VMs drained");
386        Ok(())
387    }
388
389    /// Remove and destroy specific idle VMs by their box IDs.
390    ///
391    /// Used when `fill_to_min` partially fails and needs to rollback
392    /// successfully added VMs.
393    async fn remove_idle_vms(&self, box_ids: &[String]) {
394        // First pass: collect indices of VMs to remove
395        let indices_to_remove: Vec<usize> = {
396            let idle = self.idle.lock().await;
397            idle.iter()
398                .enumerate()
399                .filter(|(_, wm)| box_ids.iter().any(|id| id == wm.vm.box_id()))
400                .map(|(i, _)| i)
401                .collect()
402        };
403
404        if indices_to_remove.is_empty() {
405            return;
406        }
407
408        // Second pass: remove and collect VMs to destroy
409        // We do this in reverse order to avoid index shifting issues
410        let mut to_destroy: Vec<WarmVm> = Vec::new();
411        {
412            let mut idle = self.idle.lock().await;
413            for idx in indices_to_remove.into_iter().rev() {
414                if idx < idle.len() {
415                    let warm_vm = idle.remove(idx);
416                    to_destroy.push(warm_vm);
417                }
418            }
419        }
420
421        // Update stats before destroying (approximate, since VMs still exist in to_destroy)
422        {
423            let idle_count = self.idle.lock().await.len();
424            if let Ok(mut stats) = self.stats.try_lock() {
425                stats.idle_count = idle_count;
426            }
427        }
428
429        // Destroy collected VMs (outside of pool lock)
430        for warm_vm in to_destroy {
431            let box_id = warm_vm.vm.box_id().to_string();
432            let mut vm = warm_vm.vm;
433            if let Err(e) = vm.destroy().await {
434                tracing::warn!(
435                    box_id = %box_id,
436                    error = %e,
437                    "Failed to destroy VM during fill_to_min rollback"
438                );
439            } else {
440                tracing::debug!(box_id = %box_id, "Destroyed VM during fill_to_min rollback");
441            }
442        }
443    }
444
445    /// Boot a new VM using the pool's template config.
446    async fn boot_new_vm(&self) -> Result<VmManager> {
447        let vm = Self::boot_or_restore(
448            self.config.snapshot_fork,
449            &self.box_config,
450            &self.event_emitter,
451            &self.template,
452        )
453        .await?;
454
455        let mut stats = self.stats.lock().await;
456        stats.total_created += 1;
457
458        self.event_emitter.emit(BoxEvent::with_string(
459            "pool.vm.created",
460            format!("Booted new VM {}", vm.box_id()),
461        ));
462
463        Ok(vm)
464    }
465
466    /// Fill one slot: restore from the snapshot-fork template when enabled, else cold
467    /// boot. Static so both `boot_new_vm` and the background replenish task use it.
468    async fn boot_or_restore(
469        snapshot_fork: bool,
470        box_config: &BoxConfig,
471        event_emitter: &EventEmitter,
472        template: &Arc<Mutex<TemplateState>>,
473    ) -> Result<VmManager> {
474        if snapshot_fork {
475            // Try the snapshot-fork template. If it can't be built (native VM
476            // snapshot unavailable — the verdict is cached so this is attempted at
477            // most once), fall back to a normal cold boot so the warm pool still
478            // fills rather than failing outright.
479            match Self::ensure_template(box_config, event_emitter, template).await {
480                Ok(tpl) => {
481                    let mut cfg = box_config.clone();
482                    cfg.snapshot_mem_file = Some(tpl.mem_file.clone());
483                    cfg.restore_from = Some(tpl.state_file.clone());
484                    cfg.snapshot_sock = None;
485                    let mut vm = VmManager::new(cfg, event_emitter.clone());
486                    vm.boot().await?;
487                    vm.wait_for_exec_available(std::time::Duration::from_secs(120))
488                        .await?;
489                    return Ok(vm);
490                }
491                Err(error) => {
492                    tracing::debug!(%error, "snapshot-fork unavailable; cold-booting this pool VM");
493                }
494            }
495        }
496        let mut vm = VmManager::new(box_config.clone(), event_emitter.clone());
497        vm.boot().await?;
498        vm.wait_for_exec_available(std::time::Duration::from_secs(120))
499            .await?;
500        Ok(vm)
501    }
502
503    /// Get the snapshot-fork template, building it once lazily. Concurrent callers
504    /// wait on the lock and reuse the first result — a built template OR a cached
505    /// `Unavailable` verdict, so a failed build (native VM snapshot unsupported on
506    /// this build) is attempted at most once rather than re-tried (and re-timed-out)
507    /// on every pool fill. Returns `Err` when unavailable so `boot_or_restore` cold
508    /// boots instead.
509    async fn ensure_template(
510        box_config: &BoxConfig,
511        event_emitter: &EventEmitter,
512        template: &Arc<Mutex<TemplateState>>,
513    ) -> Result<PoolTemplate> {
514        let mut guard = template.lock().await;
515        let prior_failures = match &*guard {
516            TemplateState::Ready(t) => return Ok(t.clone()),
517            TemplateState::Unavailable => {
518                return Err(BoxError::PoolError(
519                    "snapshot-fork template unavailable (native VM snapshot unsupported)"
520                        .to_string(),
521                ));
522            }
523            // Unbuilt or a still-retryable prior failure: (re)attempt the build.
524            TemplateState::Failing(n) => *n,
525            TemplateState::Unbuilt => 0,
526        };
527
528        match Self::build_template(box_config, event_emitter).await {
529            Ok(tpl) => {
530                *guard = TemplateState::Ready(tpl.clone());
531                event_emitter.emit(BoxEvent::with_string(
532                    "pool.template.built",
533                    format!(
534                        "Snapshot-fork template built for image {}",
535                        box_config.image
536                    ),
537                ));
538                Ok(tpl)
539            }
540            Err(error) => {
541                // Bounded retry: a transient failure presents identically to
542                // "snapshot unsupported", so only give up permanently after a few
543                // consecutive failures rather than downgrading the pool to
544                // cold-boot forever on a one-off hiccup.
545                let failures = prior_failures + 1;
546                if failures >= MAX_TEMPLATE_BUILD_FAILURES {
547                    tracing::warn!(
548                        %error, failures,
549                        "snapshot-fork template build failed repeatedly; marking \
550                         unavailable — the warm pool will cold-boot"
551                    );
552                    *guard = TemplateState::Unavailable;
553                } else {
554                    tracing::warn!(
555                        %error, failures,
556                        "snapshot-fork template build failed; will retry on a later fill"
557                    );
558                    *guard = TemplateState::Failing(failures);
559                }
560                Err(error)
561            }
562        }
563    }
564
565    /// Cold-boot one source VM with file-backed RAM + a trigger socket, snapshot it,
566    /// and tear it down — leaving the RAM image + state file as the template.
567    async fn build_template(
568        box_config: &BoxConfig,
569        event_emitter: &EventEmitter,
570    ) -> Result<PoolTemplate> {
571        let dir = a3s_box_core::dirs_home().join("pool").join(format!(
572            "tpl-{:016x}",
573            crate::vm::fnv1a_hash(&box_config.image)
574        ));
575        std::fs::create_dir_all(&dir).map_err(BoxError::IoError)?;
576
577        // Cross-process lock on the per-image template dir. The dir is keyed only
578        // by the image hash, so two processes building the same image's template
579        // would write the same template.ram/template.state concurrently and
580        // corrupt them. Held (via a Send File handle) across the boot+snapshot
581        // awaits below; acquired off-runtime so a contended flock doesn't block a
582        // worker thread.
583        let lock_target = dir.clone();
584        let _lock =
585            tokio::task::spawn_blocking(move || crate::file_lock::FileLock::acquire(&lock_target))
586                .await
587                .map_err(|e| BoxError::PoolError(format!("Template lock task failed: {e}")))?
588                .map_err(|e| BoxError::PoolError(format!("Failed to lock template dir: {e}")))?;
589
590        let mem_file = dir.join("template.ram");
591        let sock = dir.join("template.sock");
592        let state_file = dir.join("template.state");
593        let _ = std::fs::remove_file(&sock);
594
595        // Cold-boot the source as a snapshot TEMPLATE (file-backed RAM + trigger sock).
596        let mut cfg = box_config.clone();
597        cfg.snapshot_mem_file = Some(mem_file.to_string_lossy().into_owned());
598        cfg.snapshot_sock = Some(sock.to_string_lossy().into_owned());
599        cfg.restore_from = None;
600        let mut src = VmManager::new(cfg, event_emitter.clone());
601        src.boot().await?;
602
603        // Trigger the snapshot over libkrun's socket, then tear down the source (it is
604        // left paused by the snapshot; the RAM + state files are the template).
605        //
606        // Destroy the source UNCONDITIONALLY: `trigger_snapshot` fails on any
607        // libkrun without snapshot support (the common case), and `?`-ing out
608        // here would leak the fully-booted source VM (shim process, overlay
609        // mount, box dir, sockets) — neither VmManager nor ShimHandler reaps on
610        // drop. Capture the result, tear down, then propagate.
611        let snapshot = Self::trigger_snapshot(&sock, &state_file).await;
612        let _ = src.destroy_with_timeout(2000).await;
613        snapshot?;
614
615        Ok(PoolTemplate {
616            mem_file: mem_file.to_string_lossy().into_owned(),
617            state_file: state_file.to_string_lossy().into_owned(),
618        })
619    }
620
621    /// Send a `snapshot <state>` request to libkrun's per-template trigger socket and
622    /// wait for the `ok` reply (the socket appears once the template's vCPUs run).
623    ///
624    /// Snapshot-fork is a Linux/KVM (Unix) feature; on non-Unix hosts the trigger
625    /// socket does not exist, so this is unavailable (see the `not(unix)` stub).
626    #[cfg(unix)]
627    async fn trigger_snapshot(sock: &std::path::Path, state_file: &std::path::Path) -> Result<()> {
628        use tokio::io::{AsyncReadExt, AsyncWriteExt};
629        // The socket is bound by libkrun after the guest starts; poll briefly.
630        let mut stream = None;
631        for _ in 0..200 {
632            match tokio::net::UnixStream::connect(sock).await {
633                Ok(s) => {
634                    stream = Some(s);
635                    break;
636                }
637                Err(_) => tokio::time::sleep(std::time::Duration::from_millis(25)).await,
638            }
639        }
640        let mut stream = stream.ok_or_else(|| {
641            BoxError::PoolError(format!("snapshot socket {} never appeared", sock.display()))
642        })?;
643        let cmd = format!("snapshot {}\n", state_file.display());
644        stream
645            .write_all(cmd.as_bytes())
646            .await
647            .map_err(BoxError::IoError)?;
648        let mut buf = [0u8; 64];
649        let n = stream.read(&mut buf).await.map_err(BoxError::IoError)?;
650        let reply = String::from_utf8_lossy(&buf[..n]);
651        if reply.trim() == "ok" {
652            Ok(())
653        } else {
654            Err(BoxError::PoolError(format!(
655                "snapshot trigger failed: {}",
656                reply.trim()
657            )))
658        }
659    }
660
661    /// Non-Unix stub: snapshot-fork relies on libkrun's Unix trigger socket and KVM
662    /// state save/restore, neither of which exist on Windows. `--snapshot-fork` is
663    /// Linux/KVM-only, so this path is never reached there in practice.
664    #[cfg(not(unix))]
665    async fn trigger_snapshot(
666        _sock: &std::path::Path,
667        _state_file: &std::path::Path,
668    ) -> Result<()> {
669        Err(BoxError::PoolError(
670            "snapshot-fork is only supported on Linux/KVM hosts".to_string(),
671        ))
672    }
673
674    /// Fill the pool to the minimum idle count.
675    async fn fill_to_min(&self) {
676        let current = self.idle.lock().await.len();
677        let needed = self.config.min_idle.saturating_sub(current);
678
679        if needed == 0 {
680            return;
681        }
682
683        tracing::debug!(
684            current,
685            needed,
686            min_idle = self.config.min_idle,
687            "Replenishing warm pool"
688        );
689
690        // Track VMs added in this fill attempt so we can clean up on failure.
691        let mut added_ids: Vec<String> = Vec::new();
692
693        for _ in 0..needed {
694            match self.boot_new_vm().await {
695                Ok(vm) => {
696                    let box_id = vm.box_id().to_string();
697                    let mut idle = self.idle.lock().await;
698                    idle.push(WarmVm {
699                        vm,
700                        created_at: Instant::now(),
701                    });
702                    let mut stats = self.stats.lock().await;
703                    stats.idle_count = idle.len();
704                    added_ids.push(box_id.clone());
705
706                    tracing::debug!(box_id = %box_id, "Added VM to warm pool");
707                }
708                Err(e) => {
709                    tracing::warn!(error = %e, "Failed to boot VM for warm pool");
710                    // Clean up any VMs that were successfully added before this failure.
711                    if !added_ids.is_empty() {
712                        tracing::info!(
713                            count = added_ids.len(),
714                            "Cleaning up VMs added before fill_to_min failed"
715                        );
716                        self.remove_idle_vms(&added_ids).await;
717                    }
718                    break;
719                }
720            }
721        }
722
723        self.event_emitter.emit(BoxEvent::empty("pool.replenish"));
724    }
725
726    /// Spawn the background maintenance loop.
727    ///
728    /// Periodically checks for:
729    /// 1. Autoscaler evaluation → adjust min_idle dynamically
730    /// 2. Pool below min_idle → replenish
731    /// 3. Idle VMs past TTL → evict
732    fn spawn_maintenance_loop(&self) -> JoinHandle<()> {
733        let idle = Arc::clone(&self.idle);
734        let stats = Arc::clone(&self.stats);
735        let config = self.config.clone();
736        let box_config = self.box_config.clone();
737        let event_emitter = self.event_emitter.clone();
738        let mut shutdown_rx = self.shutdown_rx.clone();
739        let scaler = self.scaler.clone();
740        let template = Arc::clone(&self.template);
741
742        tokio::spawn(async move {
743            let check_interval = std::time::Duration::from_secs(
744                // Check every 1/5 of TTL, minimum 5 seconds
745                if config.idle_ttl_secs > 0 {
746                    (config.idle_ttl_secs / 5).max(5)
747                } else {
748                    30
749                },
750            );
751
752            // Dynamic min_idle starts from config, adjusted by scaler
753            let mut effective_min_idle = config.min_idle;
754
755            loop {
756                tokio::select! {
757                    result = shutdown_rx.changed() => {
758                        if result.is_ok() && *shutdown_rx.borrow() {
759                            tracing::debug!("Pool maintenance loop shutting down");
760                            break;
761                        }
762                    }
763                    _ = tokio::time::sleep(check_interval) => {
764                        // Evict expired VMs
765                        if config.idle_ttl_secs > 0 {
766                            Self::evict_expired_static(
767                                &idle,
768                                &stats,
769                                &event_emitter,
770                                config.idle_ttl_secs,
771                            ).await;
772                        }
773
774                        // Evaluate autoscaler
775                        if let Some(ref scaler) = scaler {
776                            let mut s = scaler.lock().await;
777                            let decision = s.evaluate();
778                            let new_min = s.current_min_idle();
779                            if new_min != effective_min_idle {
780                                tracing::info!(
781                                    old_min_idle = effective_min_idle,
782                                    new_min_idle = new_min,
783                                    ?decision,
784                                    "Autoscaler adjusted min_idle"
785                                );
786                                event_emitter.emit(BoxEvent::with_string(
787                                    "pool.autoscale",
788                                    format!(
789                                        "min_idle adjusted {} → {} ({:?})",
790                                        effective_min_idle, new_min, decision
791                                    ),
792                                ));
793                                effective_min_idle = new_min;
794                            }
795                        }
796
797                        // Replenish if below effective min_idle
798                        let current = idle.lock().await.len();
799                        if current < effective_min_idle {
800                            let needed = effective_min_idle - current;
801                            tracing::debug!(current, needed, min_idle = effective_min_idle, "Replenishing warm pool");
802
803                            // Fill the `needed` slots CONCURRENTLY rather than one
804                            // boot at a time — a snapshot-fork restore (or even a cold
805                            // boot) overlaps its readiness wait, so a batch fills in
806                            // roughly one boot's time instead of N×. For snapshot-fork
807                            // the first task builds the template under ensure_template's
808                            // lock; the rest wait then restore in parallel.
809                            let mut set = tokio::task::JoinSet::new();
810                            for _ in 0..needed {
811                                let sf = config.snapshot_fork;
812                                let bc = box_config.clone();
813                                let ee = event_emitter.clone();
814                                let tpl = Arc::clone(&template);
815                                set.spawn(async move {
816                                    WarmPool::boot_or_restore(sf, &bc, &ee, &tpl).await
817                                });
818                            }
819                            while let Some(joined) = set.join_next().await {
820                                match joined {
821                                    Ok(Ok(mut vm)) => {
822                                        let box_id = vm.box_id().to_string();
823                                        // If shutdown landed while this batch was
824                                        // booting, drain_idle has already cleared
825                                        // `idle` and will not run again, so a VM
826                                        // pushed now leaks (no Drop reaper). Destroy
827                                        // it instead. Acquire the idle lock FIRST and
828                                        // re-check shutdown UNDER it: drain_idle drains
829                                        // while holding this same lock (always after
830                                        // signal_shutdown), so the check-and-push is
831                                        // atomic against it — closing the TOCTOU window
832                                        // that an unlocked `borrow()` check left open.
833                                        let mut pool = idle.lock().await;
834                                        if *shutdown_rx.borrow() {
835                                            drop(pool);
836                                            tracing::debug!(
837                                                box_id = %box_id,
838                                                "Pool shutting down mid-replenish; destroying freshly-booted VM"
839                                            );
840                                            let _ = vm.destroy_with_timeout(2000).await;
841                                            continue;
842                                        }
843                                        pool.push(WarmVm {
844                                            vm,
845                                            created_at: Instant::now(),
846                                        });
847                                        let mut s = stats.lock().await;
848                                        s.total_created += 1;
849                                        s.idle_count = pool.len();
850                                        drop(s);
851                                        drop(pool);
852
853                                        event_emitter.emit(BoxEvent::with_string(
854                                            "pool.vm.created",
855                                            format!("Replenished VM {}", box_id),
856                                        ));
857                                    }
858                                    Ok(Err(e)) => {
859                                        tracing::warn!(error = %e, "Failed to replenish warm pool");
860                                    }
861                                    Err(e) => {
862                                        tracing::warn!(error = %e, "Replenish task join error");
863                                    }
864                                }
865                            }
866
867                            event_emitter.emit(BoxEvent::empty("pool.replenish"));
868                        }
869                    }
870                }
871            }
872        })
873    }
874
875    /// Static version of evict_expired for use in the spawned task.
876    async fn evict_expired_static(
877        idle: &Arc<Mutex<Vec<WarmVm>>>,
878        stats: &Arc<Mutex<PoolStats>>,
879        event_emitter: &EventEmitter,
880        idle_ttl_secs: u64,
881    ) {
882        let ttl = std::time::Duration::from_secs(idle_ttl_secs);
883
884        let mut pool = idle.lock().await;
885        let mut kept = Vec::new();
886        let mut expired = Vec::new();
887
888        for warm_vm in pool.drain(..) {
889            if warm_vm.created_at.elapsed() > ttl {
890                expired.push(warm_vm);
891            } else {
892                kept.push(warm_vm);
893            }
894        }
895        *pool = kept;
896        let after_count = pool.len();
897        drop(pool);
898
899        let evicted_count = expired.len();
900        for warm_vm in expired {
901            let mut vm = warm_vm.vm;
902            let _ = vm.destroy().await;
903        }
904
905        if evicted_count > 0 {
906            let mut s = stats.lock().await;
907            s.total_evicted += evicted_count as u64;
908            s.idle_count = after_count;
909
910            event_emitter.emit(BoxEvent::with_string(
911                "pool.vm.evicted",
912                format!("Evicted {} expired VMs", evicted_count),
913            ));
914        }
915    }
916}
917
918#[cfg(test)]
919mod tests {
920    use super::*;
921    use a3s_box_core::config::PoolConfig;
922
923    fn test_pool_config(min_idle: usize, max_size: usize) -> PoolConfig {
924        PoolConfig {
925            enabled: true,
926            min_idle,
927            max_size,
928            idle_ttl_secs: 300,
929            ..Default::default()
930        }
931    }
932
933    fn test_event_emitter() -> EventEmitter {
934        EventEmitter::new(100)
935    }
936
937    // --- PoolConfig validation tests ---
938
939    #[tokio::test]
940    async fn test_pool_rejects_zero_max_size() {
941        let config = test_pool_config(0, 0);
942        let result = WarmPool::start(config, BoxConfig::default(), test_event_emitter()).await;
943        match result {
944            Err(e) => assert!(e.to_string().contains("max_size must be greater than 0")),
945            Ok(_) => panic!("Expected error for zero max_size"),
946        }
947    }
948
949    #[tokio::test]
950    async fn test_pool_rejects_min_idle_exceeds_max() {
951        let config = test_pool_config(10, 5);
952        let result = WarmPool::start(config, BoxConfig::default(), test_event_emitter()).await;
953        match result {
954            Err(e) => assert!(e.to_string().contains("cannot exceed max_size")),
955            Ok(_) => panic!("Expected error for min_idle > max_size"),
956        }
957    }
958
959    // --- PoolStats tests ---
960
961    #[test]
962    fn test_pool_stats_default() {
963        let stats = PoolStats {
964            idle_count: 0,
965            total_created: 0,
966            total_acquired: 0,
967            total_released: 0,
968            total_evicted: 0,
969        };
970        assert_eq!(stats.idle_count, 0);
971        assert_eq!(stats.total_created, 0);
972    }
973
974    #[test]
975    fn test_pool_stats_clone() {
976        let stats = PoolStats {
977            idle_count: 3,
978            total_created: 10,
979            total_acquired: 7,
980            total_released: 5,
981            total_evicted: 2,
982        };
983        let cloned = stats.clone();
984        assert_eq!(cloned.idle_count, 3);
985        assert_eq!(cloned.total_created, 10);
986        assert_eq!(cloned.total_acquired, 7);
987        assert_eq!(cloned.total_released, 5);
988        assert_eq!(cloned.total_evicted, 2);
989    }
990
991    #[test]
992    fn test_pool_stats_debug() {
993        let stats = PoolStats {
994            idle_count: 1,
995            total_created: 2,
996            total_acquired: 3,
997            total_released: 4,
998            total_evicted: 5,
999        };
1000        let debug = format!("{:?}", stats);
1001        assert!(debug.contains("idle_count"));
1002        assert!(debug.contains("total_created"));
1003    }
1004
1005    // --- PoolConfig serialization tests ---
1006
1007    #[test]
1008    fn test_pool_config_roundtrip() {
1009        let config = PoolConfig {
1010            enabled: true,
1011            min_idle: 3,
1012            max_size: 10,
1013            idle_ttl_secs: 600,
1014            ..Default::default()
1015        };
1016
1017        let json = serde_json::to_string(&config).unwrap();
1018        let parsed: PoolConfig = serde_json::from_str(&json).unwrap();
1019
1020        assert!(parsed.enabled);
1021        assert_eq!(parsed.min_idle, 3);
1022        assert_eq!(parsed.max_size, 10);
1023        assert_eq!(parsed.idle_ttl_secs, 600);
1024    }
1025
1026    #[test]
1027    fn test_pool_config_default_values() {
1028        let config = PoolConfig::default();
1029        assert!(!config.enabled);
1030        assert_eq!(config.min_idle, 1);
1031        assert_eq!(config.max_size, 5);
1032        assert_eq!(config.idle_ttl_secs, 300);
1033    }
1034
1035    #[test]
1036    fn test_pool_config_deserialization_with_defaults() {
1037        let json = r#"{"enabled": true}"#;
1038        let config: PoolConfig = serde_json::from_str(json).unwrap();
1039        assert!(config.enabled);
1040        assert_eq!(config.min_idle, 1);
1041        assert_eq!(config.max_size, 5);
1042        assert_eq!(config.idle_ttl_secs, 300);
1043    }
1044
1045    // --- PoolConfig validation edge cases ---
1046
1047    #[tokio::test]
1048    async fn test_pool_accepts_min_idle_equals_max() {
1049        let config = test_pool_config(3, 3);
1050        // This should be accepted (min_idle == max_size is valid)
1051        // It will fail at boot (no shim), but config validation should pass
1052        let result = WarmPool::start(config, BoxConfig::default(), test_event_emitter()).await;
1053        // The error should be about VM boot, not config validation
1054        match result {
1055            Err(e) => assert!(!e.to_string().contains("cannot exceed max_size")),
1056            Ok(mut pool) => {
1057                let _ = pool.drain().await;
1058            }
1059        }
1060    }
1061
1062    #[tokio::test]
1063    async fn test_pool_accepts_min_idle_zero() {
1064        let config = test_pool_config(0, 5);
1065        // min_idle=0 means no pre-warming, should be valid
1066        let result = WarmPool::start(config, BoxConfig::default(), test_event_emitter()).await;
1067        match result {
1068            Ok(mut pool) => {
1069                // Pool should start with 0 idle VMs
1070                assert_eq!(pool.idle_count().await, 0);
1071                let stats = pool.stats().await;
1072                assert_eq!(stats.idle_count, 0);
1073                assert_eq!(stats.total_created, 0);
1074                let _ = pool.drain().await;
1075            }
1076            Err(e) => {
1077                // If it fails, it should NOT be a config validation error
1078                assert!(!e.to_string().contains("max_size"));
1079                assert!(!e.to_string().contains("min_idle"));
1080            }
1081        }
1082    }
1083
1084    // --- WarmPool internal state tests (using min_idle=0 to avoid boot) ---
1085
1086    #[tokio::test]
1087    async fn test_pool_stats_initial() {
1088        let config = test_pool_config(0, 5);
1089        let result = WarmPool::start(config, BoxConfig::default(), test_event_emitter()).await;
1090        if let Ok(mut pool) = result {
1091            let stats = pool.stats().await;
1092            assert_eq!(stats.idle_count, 0);
1093            assert_eq!(stats.total_created, 0);
1094            assert_eq!(stats.total_acquired, 0);
1095            assert_eq!(stats.total_released, 0);
1096            assert_eq!(stats.total_evicted, 0);
1097            let _ = pool.drain().await;
1098        }
1099    }
1100
1101    #[tokio::test]
1102    async fn test_pool_idle_count_initial() {
1103        let config = test_pool_config(0, 5);
1104        let result = WarmPool::start(config, BoxConfig::default(), test_event_emitter()).await;
1105        if let Ok(mut pool) = result {
1106            assert_eq!(pool.idle_count().await, 0);
1107            let _ = pool.drain().await;
1108        }
1109    }
1110
1111    #[tokio::test]
1112    async fn test_pool_drain_empty_pool() {
1113        let config = test_pool_config(0, 5);
1114        let result = WarmPool::start(config, BoxConfig::default(), test_event_emitter()).await;
1115        if let Ok(mut pool) = result {
1116            // Draining an empty pool should succeed without error
1117            let drain_result = pool.drain().await;
1118            assert!(drain_result.is_ok());
1119
1120            let stats = pool.stats().await;
1121            assert_eq!(stats.idle_count, 0);
1122        }
1123    }
1124
1125    #[tokio::test]
1126    async fn test_pool_drain_emits_event() {
1127        let emitter = test_event_emitter();
1128        let mut receiver = emitter.subscribe();
1129        let config = test_pool_config(0, 5);
1130
1131        let result = WarmPool::start(config, BoxConfig::default(), emitter).await;
1132        if let Ok(mut pool) = result {
1133            pool.drain().await.unwrap();
1134
1135            // Check that pool.drained event was emitted
1136            let mut found_drain_event = false;
1137            // Drain all events from the receiver
1138            while let Ok(event) = receiver.try_recv() {
1139                if event.key == "pool.drained" {
1140                    found_drain_event = true;
1141                }
1142            }
1143            assert!(found_drain_event, "Expected pool.drained event");
1144        }
1145    }
1146
1147    #[tokio::test]
1148    async fn test_pool_acquire_from_empty_pool_fails_without_shim() {
1149        let config = test_pool_config(0, 5);
1150        let result = WarmPool::start(config, BoxConfig::default(), test_event_emitter()).await;
1151        if let Ok(pool) = result {
1152            // Acquire from empty pool should try to boot a VM, which will fail
1153            // because there's no shim binary available in test environment
1154            let acquire_result = pool.acquire().await;
1155            assert!(acquire_result.is_err());
1156        }
1157    }
1158
1159    // --- Maintenance loop check interval calculation ---
1160
1161    #[test]
1162    #[allow(clippy::unnecessary_min_or_max)]
1163    fn test_maintenance_check_interval_with_ttl() {
1164        // TTL = 300s → check every 60s (300/5)
1165        let interval = if 300_u64 > 0 {
1166            (300_u64 / 5).max(5)
1167        } else {
1168            30
1169        };
1170        assert_eq!(interval, 60);
1171    }
1172
1173    #[test]
1174    #[allow(clippy::unnecessary_min_or_max)]
1175    fn test_maintenance_check_interval_short_ttl() {
1176        // TTL = 10s → check every 5s (min 5)
1177        let interval = if 10_u64 > 0 { (10_u64 / 5).max(5) } else { 30 };
1178        assert_eq!(interval, 5);
1179    }
1180
1181    #[test]
1182    #[allow(clippy::unnecessary_min_or_max)]
1183    fn test_maintenance_check_interval_very_short_ttl() {
1184        // TTL = 1s → check every 5s (min 5)
1185        let interval = if 1_u64 > 0 { (1_u64 / 5).max(5) } else { 30 };
1186        assert_eq!(interval, 5);
1187    }
1188
1189    #[test]
1190    #[allow(
1191        clippy::absurd_extreme_comparisons,
1192        clippy::erasing_op,
1193        clippy::unnecessary_min_or_max,
1194        unused_comparisons
1195    )]
1196    fn test_maintenance_check_interval_no_ttl() {
1197        // TTL = 0 → check every 30s
1198        let interval = if 0_u64 > 0 { (0_u64 / 5).max(5) } else { 30 };
1199        assert_eq!(interval, 30);
1200    }
1201
1202    // --- WarmVm struct tests ---
1203
1204    #[test]
1205    fn test_warm_vm_created_at_is_recent() {
1206        let before = Instant::now();
1207        let created_at = Instant::now();
1208        let after = Instant::now();
1209
1210        assert!(created_at >= before);
1211        assert!(created_at <= after);
1212    }
1213
1214    // --- PoolStats field coverage ---
1215
1216    #[test]
1217    fn test_pool_stats_all_fields() {
1218        let stats = PoolStats {
1219            idle_count: 10,
1220            total_created: 100,
1221            total_acquired: 80,
1222            total_released: 70,
1223            total_evicted: 15,
1224        };
1225
1226        assert_eq!(stats.idle_count, 10);
1227        assert_eq!(stats.total_created, 100);
1228        assert_eq!(stats.total_acquired, 80);
1229        assert_eq!(stats.total_released, 70);
1230        assert_eq!(stats.total_evicted, 15);
1231
1232        // Verify debug output contains all fields
1233        let debug = format!("{:?}", stats);
1234        assert!(debug.contains("10"));
1235        assert!(debug.contains("100"));
1236        assert!(debug.contains("80"));
1237        assert!(debug.contains("70"));
1238        assert!(debug.contains("15"));
1239    }
1240
1241    // Note: Full integration tests for acquire/release/drain with actual VMs
1242    // require a working VM runtime (shim binary + libkrun). These are tested
1243    // in integration tests with the full box environment. The unit tests here
1244    // validate configuration, statistics, error handling, and pool lifecycle
1245    // with min_idle=0 (no VM boot required).
1246
1247    #[tokio::test]
1248    async fn test_pool_set_metrics_attaches() {
1249        let config = test_pool_config(0, 5);
1250        let result = WarmPool::start(config, BoxConfig::default(), test_event_emitter()).await;
1251        match result {
1252            Ok(mut pool) => {
1253                let metrics = crate::prom::RuntimeMetrics::new();
1254                pool.set_metrics(metrics.clone());
1255                assert!(pool.metrics.is_some());
1256                // Metrics start at zero
1257                assert_eq!(metrics.warm_pool_hits.get(), 0);
1258                assert_eq!(metrics.warm_pool_misses.get(), 0);
1259                assert_eq!(metrics.warm_pool_size.get(), 0);
1260                let _ = pool.drain().await;
1261            }
1262            Err(_) => {
1263                // Boot failure is acceptable in unit test environment
1264            }
1265        }
1266    }
1267}