Skip to main content

a3s_box_runtime/pool/
warm_pool.rs

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