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 && crate::vm::native_snapshot_fork_supported() {
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            } else if snapshot_fork {
515                tracing::debug!(
516                    "snapshot-fork is unavailable on this build; cold-booting without snapshot side effects"
517                );
518            }
519            let mut vm = VmManager::new(box_config.clone(), event_emitter.clone());
520            vm.boot().await?;
521            vm.wait_for_exec_available(std::time::Duration::from_secs(120))
522                .await?;
523            Ok(vm)
524        })
525    }
526
527    /// Get the snapshot-fork template, building it once lazily. Concurrent callers
528    /// wait on the lock and reuse the first result — a built template OR a cached
529    /// `Unavailable` verdict, so a failed build (native VM snapshot unsupported on
530    /// this build) is attempted at most once rather than re-tried (and re-timed-out)
531    /// on every pool fill. Returns `Err` when unavailable so `boot_or_restore` cold
532    /// boots instead.
533    async fn ensure_template(
534        box_config: &BoxConfig,
535        event_emitter: &EventEmitter,
536        template: &Arc<Mutex<TemplateState>>,
537    ) -> Result<PoolTemplate> {
538        if !crate::vm::native_snapshot_fork_supported() {
539            return Err(BoxError::PoolError(
540                "snapshot-fork requires the Linux x86_64 KVM build".to_string(),
541            ));
542        }
543        let mut guard = template.lock().await;
544        let prior_failures = match &*guard {
545            TemplateState::Ready(t) => return Ok(t.clone()),
546            TemplateState::Unavailable => {
547                return Err(BoxError::PoolError(
548                    "snapshot-fork template unavailable (native VM snapshot unsupported)"
549                        .to_string(),
550                ));
551            }
552            // Unbuilt or a still-retryable prior failure: (re)attempt the build.
553            TemplateState::Failing(n) => *n,
554            TemplateState::Unbuilt => 0,
555        };
556
557        match Self::build_template(box_config, event_emitter).await {
558            Ok(tpl) => {
559                *guard = TemplateState::Ready(tpl.clone());
560                event_emitter.emit(BoxEvent::with_string(
561                    "pool.template.built",
562                    format!(
563                        "Snapshot-fork template built for image {}",
564                        box_config.image
565                    ),
566                ));
567                Ok(tpl)
568            }
569            Err(error) => {
570                // Bounded retry: a transient failure presents identically to
571                // "snapshot unsupported", so only give up permanently after a few
572                // consecutive failures rather than downgrading the pool to
573                // cold-boot forever on a one-off hiccup.
574                let failures = prior_failures + 1;
575                if failures >= MAX_TEMPLATE_BUILD_FAILURES {
576                    tracing::warn!(
577                        %error, failures,
578                        "snapshot-fork template build failed repeatedly; marking \
579                         unavailable — the warm pool will cold-boot"
580                    );
581                    *guard = TemplateState::Unavailable;
582                } else {
583                    tracing::warn!(
584                        %error, failures,
585                        "snapshot-fork template build failed; will retry on a later fill"
586                    );
587                    *guard = TemplateState::Failing(failures);
588                }
589                Err(error)
590            }
591        }
592    }
593
594    /// Cold-boot one source VM with file-backed RAM + a trigger socket, snapshot it,
595    /// and tear it down — leaving the RAM image + state file as the template.
596    async fn build_template(
597        box_config: &BoxConfig,
598        event_emitter: &EventEmitter,
599    ) -> Result<PoolTemplate> {
600        let dir = a3s_box_core::dirs_home().join("pool").join(format!(
601            "tpl-{:016x}",
602            crate::vm::fnv1a_hash(&box_config.image)
603        ));
604        std::fs::create_dir_all(&dir).map_err(BoxError::IoError)?;
605
606        // Cross-process lock on the per-image template dir. The dir is keyed only
607        // by the image hash, so two processes building the same image's template
608        // would write the same template.ram/template.state concurrently and
609        // corrupt them. Held (via a Send File handle) across the boot+snapshot
610        // awaits below; acquired off-runtime so a contended flock doesn't block a
611        // worker thread.
612        let lock_target = dir.clone();
613        let _lock =
614            tokio::task::spawn_blocking(move || crate::file_lock::FileLock::acquire(&lock_target))
615                .await
616                .map_err(|e| BoxError::PoolError(format!("Template lock task failed: {e}")))?
617                .map_err(|e| BoxError::PoolError(format!("Failed to lock template dir: {e}")))?;
618
619        let mem_file = dir.join("template.ram");
620        let sock = dir.join("template.sock");
621        let state_file = dir.join("template.state");
622        let _ = std::fs::remove_file(&sock);
623
624        // Cold-boot the source as a snapshot TEMPLATE (file-backed RAM + trigger sock).
625        let mut cfg = box_config.clone();
626        cfg.snapshot_mem_file = Some(mem_file.to_string_lossy().into_owned());
627        cfg.snapshot_sock = Some(sock.to_string_lossy().into_owned());
628        cfg.restore_from = None;
629        let mut src = VmManager::new(cfg, event_emitter.clone());
630        src.boot().await?;
631        let rootfs_cache_key = match src.current_rootfs_cache_key() {
632            Ok(key) => key,
633            Err(error) => {
634                let _ = src.destroy_with_timeout(2000).await;
635                return Err(error);
636            }
637        };
638
639        // Trigger the snapshot over libkrun's socket, then tear down the source (it is
640        // left paused by the snapshot; the RAM + state files are the template).
641        //
642        // Destroy the source UNCONDITIONALLY: `trigger_snapshot` fails on any
643        // libkrun without snapshot support (the common case), and `?`-ing out
644        // here would leak the fully-booted source VM (shim process, overlay
645        // mount, box dir, sockets) — neither VmManager nor ShimHandler reaps on
646        // drop. Capture the result, tear down, then propagate.
647        let snapshot = Self::trigger_snapshot(&sock, &state_file).await;
648        let _ = src.destroy_with_timeout(2000).await;
649        snapshot?;
650
651        Ok(PoolTemplate {
652            mem_file: mem_file.to_string_lossy().into_owned(),
653            state_file: state_file.to_string_lossy().into_owned(),
654            rootfs_cache_key,
655        })
656    }
657
658    /// Send a `snapshot <state>` request to libkrun's per-template trigger socket and
659    /// wait for the `ok` reply (the socket appears once the template's vCPUs run).
660    ///
661    /// Snapshot-fork is a Linux/KVM (Unix) feature; on non-Unix hosts the trigger
662    /// socket does not exist, so this is unavailable (see the `not(unix)` stub).
663    #[cfg(unix)]
664    async fn trigger_snapshot(sock: &std::path::Path, state_file: &std::path::Path) -> Result<()> {
665        use tokio::io::{AsyncReadExt, AsyncWriteExt};
666        // The socket is bound by libkrun after the guest starts; poll briefly.
667        let mut stream = None;
668        for _ in 0..200 {
669            match tokio::net::UnixStream::connect(sock).await {
670                Ok(s) => {
671                    stream = Some(s);
672                    break;
673                }
674                Err(_) => tokio::time::sleep(std::time::Duration::from_millis(25)).await,
675            }
676        }
677        let mut stream = stream.ok_or_else(|| {
678            BoxError::PoolError(format!("snapshot socket {} never appeared", sock.display()))
679        })?;
680        let cmd = format!("snapshot {}\n", state_file.display());
681        stream
682            .write_all(cmd.as_bytes())
683            .await
684            .map_err(BoxError::IoError)?;
685        let mut buf = [0u8; 64];
686        let n = stream.read(&mut buf).await.map_err(BoxError::IoError)?;
687        let reply = String::from_utf8_lossy(&buf[..n]);
688        if reply.trim() == "ok" {
689            Ok(())
690        } else {
691            Err(BoxError::PoolError(format!(
692                "snapshot trigger failed: {}",
693                reply.trim()
694            )))
695        }
696    }
697
698    /// Non-Unix stub: snapshot-fork relies on libkrun's Unix trigger socket and KVM
699    /// state save/restore, neither of which exist on Windows. `--snapshot-fork` is
700    /// Linux/KVM-only, so this path is never reached there in practice.
701    #[cfg(not(unix))]
702    async fn trigger_snapshot(
703        _sock: &std::path::Path,
704        _state_file: &std::path::Path,
705    ) -> Result<()> {
706        Err(BoxError::PoolError(
707            "snapshot-fork is only supported on Linux/KVM hosts".to_string(),
708        ))
709    }
710
711    /// Fill the pool to the minimum idle count.
712    async fn fill_to_min(&self) {
713        let current = self.idle.lock().await.len();
714        let needed = self.config.min_idle.saturating_sub(current);
715
716        if needed == 0 {
717            return;
718        }
719
720        tracing::debug!(
721            current,
722            needed,
723            min_idle = self.config.min_idle,
724            "Replenishing warm pool"
725        );
726
727        // Track VMs added in this fill attempt so we can clean up on failure.
728        let mut added_ids: Vec<String> = Vec::new();
729
730        for _ in 0..needed {
731            match self.boot_new_vm().await {
732                Ok(vm) => {
733                    let box_id = vm.box_id().to_string();
734                    let mut idle = self.idle.lock().await;
735                    idle.push(WarmVm {
736                        vm,
737                        created_at: Instant::now(),
738                    });
739                    let mut stats = self.stats.lock().await;
740                    stats.idle_count = idle.len();
741                    added_ids.push(box_id.clone());
742
743                    tracing::debug!(box_id = %box_id, "Added VM to warm pool");
744                }
745                Err(e) => {
746                    tracing::warn!(error = %e, "Failed to boot VM for warm pool");
747                    // Clean up any VMs that were successfully added before this failure.
748                    if !added_ids.is_empty() {
749                        tracing::info!(
750                            count = added_ids.len(),
751                            "Cleaning up VMs added before fill_to_min failed"
752                        );
753                        self.remove_idle_vms(&added_ids).await;
754                    }
755                    break;
756                }
757            }
758        }
759
760        self.event_emitter.emit(BoxEvent::empty("pool.replenish"));
761    }
762
763    /// Spawn the background maintenance loop.
764    ///
765    /// Periodically checks for:
766    /// 1. Autoscaler evaluation → adjust min_idle dynamically
767    /// 2. Pool below min_idle → replenish
768    /// 3. Idle VMs past TTL → evict
769    fn spawn_maintenance_loop(&self) -> JoinHandle<()> {
770        let idle = Arc::clone(&self.idle);
771        let stats = Arc::clone(&self.stats);
772        let config = self.config.clone();
773        let box_config = self.box_config.clone();
774        let event_emitter = self.event_emitter.clone();
775        let mut shutdown_rx = self.shutdown_rx.clone();
776        let scaler = self.scaler.clone();
777        let template = Arc::clone(&self.template);
778
779        tokio::spawn(async move {
780            let check_interval = std::time::Duration::from_secs(
781                // Check every 1/5 of TTL, minimum 5 seconds
782                if config.idle_ttl_secs > 0 {
783                    (config.idle_ttl_secs / 5).max(5)
784                } else {
785                    30
786                },
787            );
788
789            // Dynamic min_idle starts from config, adjusted by scaler
790            let mut effective_min_idle = config.min_idle;
791
792            loop {
793                tokio::select! {
794                    result = shutdown_rx.changed() => {
795                        if result.is_ok() && *shutdown_rx.borrow() {
796                            tracing::debug!("Pool maintenance loop shutting down");
797                            break;
798                        }
799                    }
800                    _ = tokio::time::sleep(check_interval) => {
801                        // Evict expired VMs
802                        if config.idle_ttl_secs > 0 {
803                            Self::evict_expired_static(
804                                &idle,
805                                &stats,
806                                &event_emitter,
807                                config.idle_ttl_secs,
808                            ).await;
809                        }
810
811                        // Evaluate autoscaler
812                        if let Some(ref scaler) = scaler {
813                            let mut s = scaler.lock().await;
814                            let decision = s.evaluate();
815                            let new_min = s.current_min_idle();
816                            if new_min != effective_min_idle {
817                                tracing::info!(
818                                    old_min_idle = effective_min_idle,
819                                    new_min_idle = new_min,
820                                    ?decision,
821                                    "Autoscaler adjusted min_idle"
822                                );
823                                event_emitter.emit(BoxEvent::with_string(
824                                    "pool.autoscale",
825                                    format!(
826                                        "min_idle adjusted {} → {} ({:?})",
827                                        effective_min_idle, new_min, decision
828                                    ),
829                                ));
830                                effective_min_idle = new_min;
831                            }
832                        }
833
834                        // Replenish if below effective min_idle
835                        let current = idle.lock().await.len();
836                        if current < effective_min_idle {
837                            let needed = effective_min_idle - current;
838                            tracing::debug!(current, needed, min_idle = effective_min_idle, "Replenishing warm pool");
839
840                            // Fill the `needed` slots CONCURRENTLY rather than one
841                            // boot at a time — a snapshot-fork restore (or even a cold
842                            // boot) overlaps its readiness wait, so a batch fills in
843                            // roughly one boot's time instead of N×. For snapshot-fork
844                            // the first task builds the template under ensure_template's
845                            // lock; the rest wait then restore in parallel.
846                            let mut set = tokio::task::JoinSet::new();
847                            for _ in 0..needed {
848                                let sf = config.snapshot_fork;
849                                let bc = box_config.clone();
850                                let ee = event_emitter.clone();
851                                let tpl = Arc::clone(&template);
852                                set.spawn(async move {
853                                    WarmPool::boot_or_restore(sf, &bc, &ee, &tpl).await
854                                });
855                            }
856                            while let Some(joined) = set.join_next().await {
857                                match joined {
858                                    Ok(Ok(mut vm)) => {
859                                        let box_id = vm.box_id().to_string();
860                                        // If shutdown landed while this batch was
861                                        // booting, drain_idle has already cleared
862                                        // `idle` and will not run again, so a VM
863                                        // pushed now leaks (no Drop reaper). Destroy
864                                        // it instead. Acquire the idle lock FIRST and
865                                        // re-check shutdown UNDER it: drain_idle drains
866                                        // while holding this same lock (always after
867                                        // signal_shutdown), so the check-and-push is
868                                        // atomic against it — closing the TOCTOU window
869                                        // that an unlocked `borrow()` check left open.
870                                        let mut pool = idle.lock().await;
871                                        if *shutdown_rx.borrow() {
872                                            drop(pool);
873                                            tracing::debug!(
874                                                box_id = %box_id,
875                                                "Pool shutting down mid-replenish; destroying freshly-booted VM"
876                                            );
877                                            let _ = vm.destroy_with_timeout(2000).await;
878                                            continue;
879                                        }
880                                        pool.push(WarmVm {
881                                            vm,
882                                            created_at: Instant::now(),
883                                        });
884                                        let mut s = stats.lock().await;
885                                        s.total_created += 1;
886                                        s.idle_count = pool.len();
887                                        drop(s);
888                                        drop(pool);
889
890                                        event_emitter.emit(BoxEvent::with_string(
891                                            "pool.vm.created",
892                                            format!("Replenished VM {}", box_id),
893                                        ));
894                                    }
895                                    Ok(Err(e)) => {
896                                        tracing::warn!(error = %e, "Failed to replenish warm pool");
897                                    }
898                                    Err(e) => {
899                                        tracing::warn!(error = %e, "Replenish task join error");
900                                    }
901                                }
902                            }
903
904                            event_emitter.emit(BoxEvent::empty("pool.replenish"));
905                        }
906                    }
907                }
908            }
909        })
910    }
911
912    /// Static version of evict_expired for use in the spawned task.
913    async fn evict_expired_static(
914        idle: &Arc<Mutex<Vec<WarmVm>>>,
915        stats: &Arc<Mutex<PoolStats>>,
916        event_emitter: &EventEmitter,
917        idle_ttl_secs: u64,
918    ) {
919        let ttl = std::time::Duration::from_secs(idle_ttl_secs);
920
921        let mut pool = idle.lock().await;
922        let mut kept = Vec::new();
923        let mut expired = Vec::new();
924
925        for warm_vm in pool.drain(..) {
926            if warm_vm.created_at.elapsed() > ttl {
927                expired.push(warm_vm);
928            } else {
929                kept.push(warm_vm);
930            }
931        }
932        *pool = kept;
933        let after_count = pool.len();
934        drop(pool);
935
936        let evicted_count = expired.len();
937        for warm_vm in expired {
938            let mut vm = warm_vm.vm;
939            let _ = vm.destroy().await;
940        }
941
942        if evicted_count > 0 {
943            let mut s = stats.lock().await;
944            s.total_evicted += evicted_count as u64;
945            s.idle_count = after_count;
946
947            event_emitter.emit(BoxEvent::with_string(
948                "pool.vm.evicted",
949                format!("Evicted {} expired VMs", evicted_count),
950            ));
951        }
952    }
953}
954
955#[cfg(test)]
956mod tests {
957    use super::*;
958    use a3s_box_core::config::PoolConfig;
959
960    fn test_pool_config(min_idle: usize, max_size: usize) -> PoolConfig {
961        PoolConfig {
962            enabled: true,
963            min_idle,
964            max_size,
965            idle_ttl_secs: 300,
966            ..Default::default()
967        }
968    }
969
970    fn test_event_emitter() -> EventEmitter {
971        EventEmitter::new(100)
972    }
973
974    #[test]
975    fn boot_or_restore_future_stays_heap_indirected() {
976        let config = BoxConfig::default();
977        let emitter = test_event_emitter();
978        let template = Arc::new(Mutex::new(TemplateState::Unbuilt));
979        let future = WarmPool::boot_or_restore(false, &config, &emitter, &template);
980
981        assert!(
982            std::mem::size_of_val(&future) <= 2 * std::mem::size_of::<usize>(),
983            "boot_or_restore future must remain pointer-sized so pool misses fit on Tokio worker stacks; got {} bytes",
984            std::mem::size_of_val(&future)
985        );
986    }
987
988    #[cfg(not(all(target_os = "linux", target_arch = "x86_64")))]
989    #[tokio::test]
990    async fn unsupported_snapshot_fork_is_rejected_before_template_construction() {
991        let template = Arc::new(Mutex::new(TemplateState::Unbuilt));
992        let result =
993            WarmPool::ensure_template(&BoxConfig::default(), &test_event_emitter(), &template)
994                .await;
995        let error = match result {
996            Ok(_) => panic!("unsupported host unexpectedly built a snapshot template"),
997            Err(error) => error.to_string(),
998        };
999
1000        assert!(error.contains("Linux x86_64 KVM"), "{error}");
1001        assert!(matches!(&*template.lock().await, TemplateState::Unbuilt));
1002    }
1003
1004    // --- PoolConfig validation tests ---
1005
1006    #[tokio::test]
1007    async fn test_pool_rejects_zero_max_size() {
1008        let config = test_pool_config(0, 0);
1009        let result = WarmPool::start(config, BoxConfig::default(), test_event_emitter()).await;
1010        match result {
1011            Err(e) => assert!(e.to_string().contains("max_size must be greater than 0")),
1012            Ok(_) => panic!("Expected error for zero max_size"),
1013        }
1014    }
1015
1016    #[tokio::test]
1017    async fn test_pool_rejects_min_idle_exceeds_max() {
1018        let config = test_pool_config(10, 5);
1019        let result = WarmPool::start(config, BoxConfig::default(), test_event_emitter()).await;
1020        match result {
1021            Err(e) => assert!(e.to_string().contains("cannot exceed max_size")),
1022            Ok(_) => panic!("Expected error for min_idle > max_size"),
1023        }
1024    }
1025
1026    // --- PoolStats tests ---
1027
1028    #[test]
1029    fn test_pool_stats_default() {
1030        let stats = PoolStats {
1031            idle_count: 0,
1032            total_created: 0,
1033            total_acquired: 0,
1034            total_released: 0,
1035            total_evicted: 0,
1036        };
1037        assert_eq!(stats.idle_count, 0);
1038        assert_eq!(stats.total_created, 0);
1039    }
1040
1041    #[test]
1042    fn test_pool_stats_clone() {
1043        let stats = PoolStats {
1044            idle_count: 3,
1045            total_created: 10,
1046            total_acquired: 7,
1047            total_released: 5,
1048            total_evicted: 2,
1049        };
1050        let cloned = stats.clone();
1051        assert_eq!(cloned.idle_count, 3);
1052        assert_eq!(cloned.total_created, 10);
1053        assert_eq!(cloned.total_acquired, 7);
1054        assert_eq!(cloned.total_released, 5);
1055        assert_eq!(cloned.total_evicted, 2);
1056    }
1057
1058    #[test]
1059    fn test_pool_stats_debug() {
1060        let stats = PoolStats {
1061            idle_count: 1,
1062            total_created: 2,
1063            total_acquired: 3,
1064            total_released: 4,
1065            total_evicted: 5,
1066        };
1067        let debug = format!("{:?}", stats);
1068        assert!(debug.contains("idle_count"));
1069        assert!(debug.contains("total_created"));
1070    }
1071
1072    // --- PoolConfig serialization tests ---
1073
1074    #[test]
1075    fn test_pool_config_roundtrip() {
1076        let config = PoolConfig {
1077            enabled: true,
1078            min_idle: 3,
1079            max_size: 10,
1080            idle_ttl_secs: 600,
1081            ..Default::default()
1082        };
1083
1084        let json = serde_json::to_string(&config).unwrap();
1085        let parsed: PoolConfig = serde_json::from_str(&json).unwrap();
1086
1087        assert!(parsed.enabled);
1088        assert_eq!(parsed.min_idle, 3);
1089        assert_eq!(parsed.max_size, 10);
1090        assert_eq!(parsed.idle_ttl_secs, 600);
1091    }
1092
1093    #[test]
1094    fn test_pool_config_default_values() {
1095        let config = PoolConfig::default();
1096        assert!(!config.enabled);
1097        assert_eq!(config.min_idle, 1);
1098        assert_eq!(config.max_size, 5);
1099        assert_eq!(config.idle_ttl_secs, 300);
1100    }
1101
1102    #[test]
1103    fn test_pool_config_deserialization_with_defaults() {
1104        let json = r#"{"enabled": true}"#;
1105        let config: PoolConfig = serde_json::from_str(json).unwrap();
1106        assert!(config.enabled);
1107        assert_eq!(config.min_idle, 1);
1108        assert_eq!(config.max_size, 5);
1109        assert_eq!(config.idle_ttl_secs, 300);
1110    }
1111
1112    // --- PoolConfig validation edge cases ---
1113
1114    #[tokio::test]
1115    async fn test_pool_accepts_min_idle_equals_max() {
1116        let config = test_pool_config(3, 3);
1117        // This should be accepted (min_idle == max_size is valid)
1118        // It will fail at boot (no shim), but config validation should pass
1119        let result = WarmPool::start(config, BoxConfig::default(), test_event_emitter()).await;
1120        // The error should be about VM boot, not config validation
1121        match result {
1122            Err(e) => assert!(!e.to_string().contains("cannot exceed max_size")),
1123            Ok(mut pool) => {
1124                let _ = pool.drain().await;
1125            }
1126        }
1127    }
1128
1129    #[tokio::test]
1130    async fn test_pool_accepts_min_idle_zero() {
1131        let config = test_pool_config(0, 5);
1132        // min_idle=0 means no pre-warming, should be valid
1133        let result = WarmPool::start(config, BoxConfig::default(), test_event_emitter()).await;
1134        match result {
1135            Ok(mut pool) => {
1136                // Pool should start with 0 idle VMs
1137                assert_eq!(pool.idle_count().await, 0);
1138                let stats = pool.stats().await;
1139                assert_eq!(stats.idle_count, 0);
1140                assert_eq!(stats.total_created, 0);
1141                let _ = pool.drain().await;
1142            }
1143            Err(e) => {
1144                // If it fails, it should NOT be a config validation error
1145                assert!(!e.to_string().contains("max_size"));
1146                assert!(!e.to_string().contains("min_idle"));
1147            }
1148        }
1149    }
1150
1151    // --- WarmPool internal state tests (using min_idle=0 to avoid boot) ---
1152
1153    #[tokio::test]
1154    async fn test_pool_stats_initial() {
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            let stats = pool.stats().await;
1159            assert_eq!(stats.idle_count, 0);
1160            assert_eq!(stats.total_created, 0);
1161            assert_eq!(stats.total_acquired, 0);
1162            assert_eq!(stats.total_released, 0);
1163            assert_eq!(stats.total_evicted, 0);
1164            let _ = pool.drain().await;
1165        }
1166    }
1167
1168    #[tokio::test]
1169    async fn test_pool_idle_count_initial() {
1170        let config = test_pool_config(0, 5);
1171        let result = WarmPool::start(config, BoxConfig::default(), test_event_emitter()).await;
1172        if let Ok(mut pool) = result {
1173            assert_eq!(pool.idle_count().await, 0);
1174            let _ = pool.drain().await;
1175        }
1176    }
1177
1178    #[tokio::test]
1179    async fn test_pool_drain_empty_pool() {
1180        let config = test_pool_config(0, 5);
1181        let result = WarmPool::start(config, BoxConfig::default(), test_event_emitter()).await;
1182        if let Ok(mut pool) = result {
1183            // Draining an empty pool should succeed without error
1184            let drain_result = pool.drain().await;
1185            assert!(drain_result.is_ok());
1186
1187            let stats = pool.stats().await;
1188            assert_eq!(stats.idle_count, 0);
1189        }
1190    }
1191
1192    #[tokio::test]
1193    async fn test_pool_drain_emits_event() {
1194        let emitter = test_event_emitter();
1195        let mut receiver = emitter.subscribe();
1196        let config = test_pool_config(0, 5);
1197
1198        let result = WarmPool::start(config, BoxConfig::default(), emitter).await;
1199        if let Ok(mut pool) = result {
1200            pool.drain().await.unwrap();
1201
1202            // Check that pool.drained event was emitted
1203            let mut found_drain_event = false;
1204            // Drain all events from the receiver
1205            while let Ok(event) = receiver.try_recv() {
1206                if event.key == "pool.drained" {
1207                    found_drain_event = true;
1208                }
1209            }
1210            assert!(found_drain_event, "Expected pool.drained event");
1211        }
1212    }
1213
1214    #[tokio::test]
1215    async fn test_pool_acquire_from_empty_pool_fails_without_shim() {
1216        let config = test_pool_config(0, 5);
1217        let result = WarmPool::start(config, BoxConfig::default(), test_event_emitter()).await;
1218        if let Ok(pool) = result {
1219            // Acquire from empty pool should try to boot a VM, which will fail
1220            // because there's no shim binary available in test environment
1221            let acquire_result = pool.acquire().await;
1222            assert!(acquire_result.is_err());
1223        }
1224    }
1225
1226    // --- Maintenance loop check interval calculation ---
1227
1228    #[test]
1229    #[allow(clippy::unnecessary_min_or_max)]
1230    fn test_maintenance_check_interval_with_ttl() {
1231        // TTL = 300s → check every 60s (300/5)
1232        let interval = if 300_u64 > 0 {
1233            (300_u64 / 5).max(5)
1234        } else {
1235            30
1236        };
1237        assert_eq!(interval, 60);
1238    }
1239
1240    #[test]
1241    #[allow(clippy::unnecessary_min_or_max)]
1242    fn test_maintenance_check_interval_short_ttl() {
1243        // TTL = 10s → check every 5s (min 5)
1244        let interval = if 10_u64 > 0 { (10_u64 / 5).max(5) } else { 30 };
1245        assert_eq!(interval, 5);
1246    }
1247
1248    #[test]
1249    #[allow(clippy::unnecessary_min_or_max)]
1250    fn test_maintenance_check_interval_very_short_ttl() {
1251        // TTL = 1s → check every 5s (min 5)
1252        let interval = if 1_u64 > 0 { (1_u64 / 5).max(5) } else { 30 };
1253        assert_eq!(interval, 5);
1254    }
1255
1256    #[test]
1257    #[allow(
1258        clippy::absurd_extreme_comparisons,
1259        clippy::erasing_op,
1260        clippy::unnecessary_min_or_max,
1261        unused_comparisons
1262    )]
1263    fn test_maintenance_check_interval_no_ttl() {
1264        // TTL = 0 → check every 30s
1265        let interval = if 0_u64 > 0 { (0_u64 / 5).max(5) } else { 30 };
1266        assert_eq!(interval, 30);
1267    }
1268
1269    // --- WarmVm struct tests ---
1270
1271    #[test]
1272    fn test_warm_vm_created_at_is_recent() {
1273        let before = Instant::now();
1274        let created_at = Instant::now();
1275        let after = Instant::now();
1276
1277        assert!(created_at >= before);
1278        assert!(created_at <= after);
1279    }
1280
1281    // --- PoolStats field coverage ---
1282
1283    #[test]
1284    fn test_pool_stats_all_fields() {
1285        let stats = PoolStats {
1286            idle_count: 10,
1287            total_created: 100,
1288            total_acquired: 80,
1289            total_released: 70,
1290            total_evicted: 15,
1291        };
1292
1293        assert_eq!(stats.idle_count, 10);
1294        assert_eq!(stats.total_created, 100);
1295        assert_eq!(stats.total_acquired, 80);
1296        assert_eq!(stats.total_released, 70);
1297        assert_eq!(stats.total_evicted, 15);
1298
1299        // Verify debug output contains all fields
1300        let debug = format!("{:?}", stats);
1301        assert!(debug.contains("10"));
1302        assert!(debug.contains("100"));
1303        assert!(debug.contains("80"));
1304        assert!(debug.contains("70"));
1305        assert!(debug.contains("15"));
1306    }
1307
1308    // Note: Full integration tests for acquire/release/drain with actual VMs
1309    // require a working VM runtime (shim binary + libkrun). These are tested
1310    // in integration tests with the full box environment. The unit tests here
1311    // validate configuration, statistics, error handling, and pool lifecycle
1312    // with min_idle=0 (no VM boot required).
1313
1314    #[tokio::test]
1315    async fn test_pool_set_metrics_attaches() {
1316        let config = test_pool_config(0, 5);
1317        let result = WarmPool::start(config, BoxConfig::default(), test_event_emitter()).await;
1318        match result {
1319            Ok(mut pool) => {
1320                let metrics = crate::prom::RuntimeMetrics::new();
1321                pool.set_metrics(metrics.clone());
1322                assert!(pool.metrics.is_some());
1323                // Metrics start at zero
1324                assert_eq!(metrics.warm_pool_hits.get(), 0);
1325                assert_eq!(metrics.warm_pool_misses.get(), 0);
1326                assert_eq!(metrics.warm_pool_size.get(), 0);
1327                let _ = pool.drain().await;
1328            }
1329            Err(_) => {
1330                // Boot failure is acceptable in unit test environment
1331            }
1332        }
1333    }
1334}