1use 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
21struct WarmVm {
23 vm: VmManager,
25 created_at: Instant,
27}
28
29type BootVmFuture<'a> = Pin<Box<dyn Future<Output = Result<VmManager>> + Send + 'a>>;
30
31#[derive(Debug, Clone)]
33pub struct PoolStats {
34 pub idle_count: usize,
36 pub total_created: u64,
38 pub total_acquired: u64,
40 pub total_released: u64,
42 pub total_evicted: u64,
44}
45
46pub struct WarmPool {
62 config: PoolConfig,
64 box_config: BoxConfig,
66 idle: Arc<Mutex<Vec<WarmVm>>>,
68 stats: Arc<Mutex<PoolStats>>,
70 event_emitter: EventEmitter,
72 replenish_handle: Option<JoinHandle<()>>,
74 shutdown_tx: watch::Sender<bool>,
76 shutdown_rx: watch::Receiver<bool>,
78 scaler: Option<Arc<Mutex<PoolScaler>>>,
80 metrics: Option<crate::prom::RuntimeMetrics>,
82 template: Arc<Mutex<TemplateState>>,
88}
89
90#[derive(Clone)]
93struct PoolTemplate {
94 mem_file: String,
95 state_file: String,
96 rootfs_cache_key: Option<String>,
97}
98
99const MAX_TEMPLATE_BUILD_FAILURES: u32 = 3;
107
108enum TemplateState {
110 Unbuilt,
112 Ready(PoolTemplate),
114 Failing(u32),
118 Unavailable,
122}
123
124impl WarmPool {
125 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 pool.fill_to_min().await;
182
183 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 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 pub async fn acquire(&self) -> Result<VmManager> {
208 {
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 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 tracing::info!("No idle VM in pool, booting on demand");
243
244 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 pub async fn release(&self, vm: VmManager) -> Result<()> {
265 let mut idle = self.idle.lock().await;
266
267 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 drop(idle); 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 pub async fn stats(&self) -> PoolStats {
321 self.stats.lock().await.clone()
322 }
323
324 pub async fn idle_count(&self) -> usize {
326 self.idle.lock().await.len()
327 }
328
329 pub fn signal_shutdown(&self) {
333 let _ = self.shutdown_tx.send(true);
334 tracing::info!("Warm pool shutdown signaled");
335 }
336
337 pub async fn drain(&mut self) -> Result<()> {
339 let _ = self.shutdown_tx.send(true);
341
342 if let Some(handle) = self.replenish_handle.take() {
344 let _ = handle.await;
345 }
346
347 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 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 async fn remove_idle_vms(&self, box_ids: &[String]) {
399 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 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 {
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 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 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 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 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 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 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 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 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 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 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 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 #[cfg(unix)]
664 async fn trigger_snapshot(sock: &std::path::Path, state_file: &std::path::Path) -> Result<()> {
665 use tokio::io::{AsyncReadExt, AsyncWriteExt};
666 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 #[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 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 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 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 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 if config.idle_ttl_secs > 0 {
783 (config.idle_ttl_secs / 5).max(5)
784 } else {
785 30
786 },
787 );
788
789 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 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 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 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 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 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 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 #[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 #[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 #[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 #[tokio::test]
1115 async fn test_pool_accepts_min_idle_equals_max() {
1116 let config = test_pool_config(3, 3);
1117 let result = WarmPool::start(config, BoxConfig::default(), test_event_emitter()).await;
1120 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 let result = WarmPool::start(config, BoxConfig::default(), test_event_emitter()).await;
1134 match result {
1135 Ok(mut pool) => {
1136 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 assert!(!e.to_string().contains("max_size"));
1146 assert!(!e.to_string().contains("min_idle"));
1147 }
1148 }
1149 }
1150
1151 #[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 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 let mut found_drain_event = false;
1204 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 let acquire_result = pool.acquire().await;
1222 assert!(acquire_result.is_err());
1223 }
1224 }
1225
1226 #[test]
1229 #[allow(clippy::unnecessary_min_or_max)]
1230 fn test_maintenance_check_interval_with_ttl() {
1231 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 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 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 let interval = if 0_u64 > 0 { (0_u64 / 5).max(5) } else { 30 };
1266 assert_eq!(interval, 30);
1267 }
1268
1269 #[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 #[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 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 #[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 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 }
1332 }
1333 }
1334}