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