1use crate::gpu_pool::{ActiveGeneration, GpuJob, GpuWorker};
2use crate::model_cache::ModelResidency;
3use crate::queue::{
4 build_sse_complete_event, clean_error_message, save_image_to_dir, save_video_to_dir,
5};
6use crate::state::{GenerationJobResult, SseMessage};
7use mold_core::{
8 Config, ImageData, ModelPaths, OutputFormat, OutputMetadata, SseErrorEvent, SseProgressEvent,
9};
10use mold_inference::device;
11use sha2::{Digest, Sha256};
12use std::sync::atomic::Ordering;
13use std::sync::Arc;
14use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
15
16pub fn spawn_gpu_thread(
19 worker: Arc<GpuWorker>,
20 job_rx: std::sync::mpsc::Receiver<GpuJob>,
21) -> std::thread::JoinHandle<()> {
22 std::thread::Builder::new()
23 .name(format!("gpu-worker-{}", worker.gpu.ordinal))
24 .spawn(move || {
25 mold_inference::device::init_thread_gpu_ordinal(worker.gpu.ordinal);
29 tracing::info!(
30 gpu = worker.gpu.ordinal,
31 name = %worker.gpu.name,
32 "GPU worker thread started"
33 );
34 for job in job_rx.iter() {
35 process_job(&worker, job);
36 }
37 tracing::info!(gpu = worker.gpu.ordinal, "GPU worker thread exiting");
38 })
39 .expect("failed to spawn GPU worker thread")
40}
41
42fn progress_to_sse(event: mold_inference::ProgressEvent) -> SseProgressEvent {
44 event.into()
45}
46
47pub(crate) fn is_cuda_oom(e: &anyhow::Error) -> bool {
54 let full = format!("{e:#}");
55 full.contains("CUDA_ERROR_OUT_OF_MEMORY") || full.contains("out of memory")
56}
57
58pub(crate) fn oom_user_message(model_name: &str) -> String {
62 format!(
63 "GPU ran out of memory loading or running '{model_name}'. \
64 Try: reduce --frames (e.g. 17 or 9), lower --width/--height, \
65 use a quantized variant (e.g. ':q8'), or close other GPU apps."
66 )
67}
68
69fn cuda_oom_user_message(worker: &GpuWorker, model_name: &str) -> (String, bool) {
70 let base = oom_user_message(model_name);
71 let outcome = crate::gpu_pool::record_model_cuda_oom(model_name, worker.gpu.ordinal);
72 if outcome.is_unschedulable() {
73 if let Some(cooldown) = crate::gpu_pool::model_unschedulable_message(model_name) {
74 return (format!("{base} {cooldown}"), false);
75 }
76 }
77 (base, true)
78}
79
80fn process_job(worker: &GpuWorker, job: GpuJob) {
81 let model_name = job.model.clone();
82 let ordinal = worker.gpu.ordinal;
83 let job_id = job.id.clone();
84
85 struct CleanupGuard {
91 queue: crate::state::QueueHandle,
92 registry: crate::job_registry::SharedJobRegistry,
93 id: String,
94 }
95 impl Drop for CleanupGuard {
96 fn drop(&mut self) {
97 self.queue.decrement();
98 self.registry.remove(&self.id);
99 }
100 }
101 let _cleanup = CleanupGuard {
102 queue: job.queue.clone(),
103 registry: job.registry.clone(),
104 id: job_id.clone(),
105 };
106
107 if job.result_tx.is_closed() {
108 tracing::debug!(gpu = ordinal, model = %model_name, "skipping dispatched job — client disconnected");
109 worker.in_flight.fetch_sub(1, Ordering::SeqCst);
110 return;
111 }
112
113 job.registry.mark_running(&job_id, Some(ordinal));
116
117 tracing::info!(gpu = ordinal, model = %model_name, "dispatched job");
118
119 let _load_lock = worker.model_load_lock.lock().unwrap();
121
122 let config_snapshot = job.config.blocking_read().clone();
124 let activation_hint =
125 crate::model_manager::activation_hint_for_request_sync(&config_snapshot, &job.request);
126 if let Err(e) = ensure_model_ready_sync(worker, &model_name, &config_snapshot, activation_hint)
127 {
128 tracing::error!(gpu = ordinal, model = %model_name, "Failed to load model: {e}");
129 let is_oom = is_cuda_oom(&e);
133 let (err_msg, count_worker_failure) = if is_oom {
134 mold_inference::device::try_synchronize_device(ordinal);
135 cuda_oom_user_message(worker, &model_name)
136 } else {
137 (
138 format!("model load error: {}", clean_error_message(&e)),
139 true,
140 )
141 };
142 if let Some(ref tx) = job.progress_tx {
143 let _ = tx.send(SseMessage::Error(SseErrorEvent {
144 message: err_msg.clone(),
145 }));
146 }
147 let _ = job.result_tx.send(Err(err_msg));
148 worker.in_flight.fetch_sub(1, Ordering::SeqCst);
149 if count_worker_failure {
150 record_failure(worker);
151 }
152 return;
153 }
154
155 {
157 let mut gen = worker.active_generation.write().unwrap();
158 *gen = Some(ActiveGeneration {
159 model: model_name.clone(),
160 prompt_sha256: format!("{:x}", Sha256::digest(job.request.prompt.as_bytes())),
161 started_at_unix_ms: SystemTime::now()
162 .duration_since(UNIX_EPOCH)
163 .unwrap_or_default()
164 .as_millis() as u64,
165 started_at: Instant::now(),
166 });
167 }
168
169 if job.result_tx.is_closed() {
170 tracing::debug!(
171 gpu = ordinal,
172 model = %model_name,
173 "skipping generation after model readiness — client disconnected"
174 );
175 worker.in_flight.fetch_sub(1, Ordering::SeqCst);
176 clear_active_generation(worker);
177 return;
178 }
179
180 let taken = {
182 let mut cache = worker.model_cache.lock().unwrap();
183 cache.take(&model_name)
184 };
185
186 let Some(mut cached_engine) = taken else {
187 let err_msg = "engine not found in cache after load".to_string();
188 if let Some(ref tx) = job.progress_tx {
189 let _ = tx.send(SseMessage::Error(SseErrorEvent {
190 message: err_msg.clone(),
191 }));
192 }
193 let _ = job.result_tx.send(Err(err_msg));
194 worker.in_flight.fetch_sub(1, Ordering::SeqCst);
195 clear_active_generation(worker);
196 return;
197 };
198
199 if let Some(ref progress_tx) = job.progress_tx {
201 let tx = progress_tx.clone();
202 cached_engine.engine.set_on_progress(Box::new(move |event| {
203 let _ = tx.send(SseMessage::Progress(progress_to_sse(event)));
204 }));
205 }
206
207 let rss_before = crate::resources::ram_snapshot().used_by_mold;
211
212 let watchdog_stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
217 let watchdog_handle = {
218 let stop = watchdog_stop.clone();
219 let model = model_name.clone();
220 std::thread::Builder::new()
221 .name(format!("rss-watchdog-{ordinal}"))
222 .spawn(move || {
223 let start = Instant::now();
224 while !stop.load(Ordering::SeqCst) {
225 std::thread::sleep(Duration::from_millis(1000));
226 if stop.load(Ordering::SeqCst) {
227 break;
228 }
229 let rss = crate::resources::ram_snapshot().used_by_mold;
230 tracing::info!(
231 gpu = ordinal,
232 model = %model,
233 elapsed_s = start.elapsed().as_secs(),
234 rss_mb = rss / 1_000_000,
235 "rss watchdog"
236 );
237 }
238 })
239 .expect("failed to spawn RSS watchdog")
240 };
241
242 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
244 cached_engine.engine.generate(&job.request)
245 }));
246
247 watchdog_stop.store(true, Ordering::SeqCst);
248 let _ = watchdog_handle.join();
249
250 let trim_enabled = std::env::var("MOLD_MALLOC_TRIM")
256 .map(|v| v != "0")
257 .unwrap_or(true);
258 let rss_pre_trim = if trim_enabled {
259 let v = crate::resources::ram_snapshot().used_by_mold;
260 #[cfg(target_os = "linux")]
261 unsafe {
262 libc::malloc_trim(0);
263 }
264 Some(v)
265 } else {
266 None
267 };
268
269 let rss_after = crate::resources::ram_snapshot().used_by_mold;
270 let rss_delta = rss_after as i64 - rss_before as i64;
271 tracing::info!(
272 gpu = ordinal,
273 model = %model_name,
274 rss_before_mb = rss_before / 1_000_000,
275 rss_after_mb = rss_after / 1_000_000,
276 rss_delta_mb = rss_delta / 1_000_000,
277 rss_pre_trim_mb = rss_pre_trim.map(|v| v / 1_000_000).unwrap_or(0),
278 "generation memory delta"
279 );
280
281 cached_engine.engine.clear_on_progress();
283
284 {
286 let mut cache = worker.model_cache.lock().unwrap();
287 cache.restore(cached_engine);
288 }
289
290 clear_active_generation(worker);
292
293 worker.in_flight.fetch_sub(1, Ordering::SeqCst);
295
296 match result {
297 Ok(Ok(mut response)) => {
298 worker.consecutive_failures.store(0, Ordering::SeqCst);
300 crate::gpu_pool::clear_model_cuda_oom(&model_name);
301
302 response.gpu = Some(ordinal);
304
305 if response.images.is_empty() && response.video.is_none() {
306 let err_msg = "generation error: engine returned no images or video".to_string();
307 if let Some(ref tx) = job.progress_tx {
308 let _ = tx.send(SseMessage::Error(SseErrorEvent {
309 message: err_msg.clone(),
310 }));
311 }
312 let _ = job.result_tx.send(Err(err_msg));
313 return;
314 }
315
316 let img = if !response.images.is_empty() {
318 response.images.remove(0)
319 } else if let Some(ref video) = response.video {
320 ImageData {
321 data: video.thumbnail.clone(),
322 format: OutputFormat::Png,
323 width: video.width,
324 height: video.height,
325 index: 0,
326 }
327 } else {
328 unreachable!("checked above");
329 };
330
331 if let Some(ref dir) = job.output_dir {
339 let metadata = OutputMetadata::from_generate_request(
340 &job.request,
341 response.seed_used,
342 None,
343 mold_core::build_info::version_string(),
344 );
345 let generation_time_ms = response.generation_time_ms as i64;
346 let db = job.metadata_db.as_ref().as_ref();
347 if let Some(ref video) = response.video {
348 save_video_to_dir(
349 dir,
350 &video.data,
351 &video.gif_preview,
352 video.format,
353 &job.model,
354 &metadata,
355 Some(generation_time_ms),
356 db,
357 );
358 } else {
359 save_image_to_dir(
360 dir,
361 &img,
362 &job.model,
363 job.request.batch_size,
364 Some(&metadata),
365 Some(generation_time_ms),
366 db,
367 );
368 }
369 }
370
371 if let Some(ref tx) = job.progress_tx {
377 let event = build_sse_complete_event(&response, &img);
378 let _ = tx.send(SseMessage::Complete(event));
379 }
380
381 let _ = job.result_tx.send(Ok(GenerationJobResult {
383 image: img,
384 response,
385 }));
386 }
387 Ok(Err(e)) => {
388 tracing::warn!(gpu = ordinal, model = %model_name, "Generation failed: {e}");
389 let is_oom = is_cuda_oom(&e);
393 let (err_msg, count_worker_failure) = if is_oom {
394 mold_inference::device::try_synchronize_device(ordinal);
395 cuda_oom_user_message(worker, &model_name)
396 } else {
397 (
398 format!("generation error: {}", clean_error_message(&e)),
399 true,
400 )
401 };
402 if count_worker_failure {
403 record_failure(worker);
404 }
405 if let Some(ref tx) = job.progress_tx {
406 let _ = tx.send(SseMessage::Error(SseErrorEvent {
407 message: err_msg.clone(),
408 }));
409 }
410 let _ = job.result_tx.send(Err(err_msg));
411 }
412 Err(panic_payload) => {
413 tracing::error!(gpu = ordinal, model = %model_name, "Inference panicked");
414 record_failure(worker);
415 let msg = panic_payload
416 .downcast_ref::<String>()
417 .map(|s| s.as_str())
418 .or_else(|| panic_payload.downcast_ref::<&str>().copied())
419 .unwrap_or("unknown panic");
420 let err_msg = format!("inference panicked: {msg}");
421 if let Some(ref tx) = job.progress_tx {
422 let _ = tx.send(SseMessage::Error(SseErrorEvent {
423 message: err_msg.clone(),
424 }));
425 }
426 let _ = job.result_tx.send(Err(err_msg));
427 }
428 }
429}
430
431fn preflight_memory_guard_with_eviction(
445 cache_lock: &std::sync::Mutex<crate::model_cache::ModelCache>,
446 model_name: &str,
447 paths: &ModelPaths,
448 ordinal: usize,
449 hint: Option<crate::model_manager::ActivationHint>,
450) -> Result<(), crate::routes::ApiError> {
451 loop {
452 let active_vram = cache_lock
453 .lock()
454 .unwrap_or_else(|e| e.into_inner())
455 .active_vram_bytes();
456 let err = match crate::model_manager::preflight_memory_guard(
457 model_name,
458 paths,
459 active_vram,
460 ordinal,
461 hint,
462 ) {
463 Ok(()) => return Ok(()),
464 Err(e) => e,
465 };
466
467 let evicted = {
468 let mut cache = cache_lock.lock().unwrap_or_else(|e| e.into_inner());
469 cache.evict_lru_parked_except(Some(model_name))
470 };
471 let Some((evicted_name, engine)) = evicted else {
472 return Err(err);
473 };
474 tracing::info!(
475 gpu = ordinal,
476 target_model = %model_name,
477 evicted_model = %evicted_name,
478 "evicting LRU parked entry to fit incoming load"
479 );
480 drop(engine);
483
484 let safe_to_reclaim = cache_lock
490 .lock()
491 .unwrap_or_else(|e| e.into_inner())
492 .active_model()
493 .is_none();
494 if safe_to_reclaim {
495 device::reclaim_gpu_memory(ordinal);
496 }
497 }
498}
499
500pub fn ensure_model_ready_sync(
508 worker: &GpuWorker,
509 model_name: &str,
510 config: &Config,
511 hint: Option<crate::model_manager::ActivationHint>,
512) -> anyhow::Result<()> {
513 let cache = worker.model_cache.lock().unwrap();
514
515 if let Some(entry) = cache.get(model_name) {
517 if entry.residency == ModelResidency::Gpu {
518 return Ok(());
519 }
520 }
521
522 let has_cached = cache.contains(model_name);
524
525 let cached_paths = if has_cached {
530 cache
531 .get(model_name)
532 .and_then(|e| e.engine.model_paths().cloned())
533 } else {
534 None
535 };
536 drop(cache);
537
538 if has_cached {
539 if let Some(ref paths) = cached_paths {
544 preflight_memory_guard_with_eviction(
545 &worker.model_cache,
546 model_name,
547 paths,
548 worker.gpu.ordinal,
549 hint,
550 )
551 .map_err(|e| anyhow::anyhow!(e.error))?;
552 }
553
554 {
556 let mut cache = worker.model_cache.lock().unwrap();
557 cache.unload_active();
558 }
559 device::reclaim_gpu_memory(worker.gpu.ordinal);
560
561 let mut engine = {
563 let mut cache = worker.model_cache.lock().unwrap();
564 cache
565 .remove(model_name)
566 .ok_or_else(|| anyhow::anyhow!("cache race: model '{model_name}' vanished"))?
567 };
568
569 tracing::info!(
570 gpu = worker.gpu.ordinal,
571 model = %model_name,
572 "reloading cached engine..."
573 );
574 let vram_baseline = device::vram_in_use_bytes(worker.gpu.ordinal);
577 engine.load()?;
578
579 let vram = device::vram_load_delta(worker.gpu.ordinal, vram_baseline);
580 let evicted = {
583 let mut cache = worker.model_cache.lock().unwrap();
584 cache.insert_loaded(model_name.to_string(), engine, vram)
585 };
586 drop(evicted);
587 return Ok(());
588 }
589
590 let paths = ModelPaths::resolve(model_name, config).ok_or_else(|| {
593 if model_name.starts_with("cv:") || model_name.starts_with("hf:") {
601 anyhow::anyhow!(
602 "catalog model '{model_name}' has missing required components. \
603 Re-pull the entry from the catalog so its companions \
604 (CLIP-L / T5 / VAE) are fetched alongside the primary checkpoint."
605 )
606 } else {
607 anyhow::anyhow!("model '{model_name}' is not downloaded. Run: mold pull {model_name}")
608 }
609 })?;
610
611 preflight_memory_guard_with_eviction(
614 &worker.model_cache,
615 model_name,
616 &paths,
617 worker.gpu.ordinal,
618 hint,
619 )
620 .map_err(|e| anyhow::anyhow!(e.error))?;
621
622 {
624 let mut cache = worker.model_cache.lock().unwrap();
625 cache.unload_active();
626 }
627 device::reclaim_gpu_memory(worker.gpu.ordinal);
628
629 let offload = std::env::var("MOLD_OFFLOAD").is_ok_and(|v| v == "1");
630 let mut engine = mold_inference::create_engine_with_pool(
631 model_name.to_string(),
632 paths,
633 config,
634 mold_inference::LoadStrategy::Eager,
635 worker.gpu.ordinal,
636 offload,
637 Some(worker.shared_pool.clone()),
638 )?;
639
640 tracing::info!(
641 gpu = worker.gpu.ordinal,
642 model = %model_name,
643 "loading model..."
644 );
645 let vram_baseline = device::vram_in_use_bytes(worker.gpu.ordinal);
648 engine.load()?;
649
650 let vram = device::vram_load_delta(worker.gpu.ordinal, vram_baseline);
651 let evicted = {
654 let mut cache = worker.model_cache.lock().unwrap();
655 cache.insert_loaded(model_name.to_string(), engine, vram)
656 };
657 drop(evicted);
658
659 Ok(())
660}
661
662pub fn load_blocking(worker: &GpuWorker, model_name: &str, config: &Config) -> anyhow::Result<()> {
669 let _lock = worker.model_load_lock.lock().unwrap();
670 ensure_model_ready_sync(worker, model_name, config, None)
671}
672
673pub fn unload_blocking(worker: &GpuWorker) -> Option<String> {
678 let _lock = worker.model_load_lock.lock().unwrap();
679 let unloaded = {
680 let mut cache = worker.model_cache.lock().unwrap();
681 cache.unload_active()
682 };
683 if unloaded.is_some() {
684 device::reclaim_gpu_memory(worker.gpu.ordinal);
685 }
686 unloaded
687}
688
689fn record_failure(worker: &GpuWorker) {
690 let failures = worker.consecutive_failures.fetch_add(1, Ordering::SeqCst) + 1;
691 if failures >= 3 {
692 let mut degraded = worker.degraded_until.write().unwrap();
693 *degraded = Some(Instant::now() + Duration::from_secs(60));
694 tracing::warn!(
695 gpu = worker.gpu.ordinal,
696 "GPU marked degraded after {failures} consecutive failures (60s cooldown)"
697 );
698 }
699}
700
701fn clear_active_generation(worker: &GpuWorker) {
702 let mut gen = worker.active_generation.write().unwrap();
703 *gen = None;
704}
705
706pub type ChainPrep<T, E> = Result<Result<T, E>, anyhow::Error>;
712
713pub fn run_chain_blocking<T, E>(
733 worker: &GpuWorker,
734 model_name: &str,
735 config: &mold_core::Config,
736 hint: Option<crate::model_manager::ActivationHint>,
737 with_engine: impl FnOnce(&mut dyn mold_inference::InferenceEngine) -> Result<T, E>,
738) -> ChainPrep<T, E> {
739 struct ThreadGpuGuard;
744 impl Drop for ThreadGpuGuard {
745 fn drop(&mut self) {
746 mold_inference::device::clear_thread_gpu_ordinal();
747 }
748 }
749 mold_inference::device::init_thread_gpu_ordinal(worker.gpu.ordinal);
750 let _thread_gpu = ThreadGpuGuard;
751
752 let _load_lock = worker
755 .model_load_lock
756 .lock()
757 .map_err(|e| anyhow::anyhow!("worker.model_load_lock poisoned: {e}"))?;
758
759 ensure_model_ready_sync(worker, model_name, config, hint)?;
762
763 let cached = {
765 let mut cache = worker
766 .model_cache
767 .lock()
768 .map_err(|e| anyhow::anyhow!("worker.model_cache poisoned: {e}"))?;
769 cache.take(model_name).ok_or_else(|| {
770 anyhow::anyhow!("cache race: engine '{model_name}' vanished after ensure_model_ready")
771 })?
772 };
773
774 let mut cached = cached;
786 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
787 with_engine(cached.engine.as_mut())
788 }));
789
790 {
797 let mut cache = worker
798 .model_cache
799 .lock()
800 .unwrap_or_else(|poisoned| poisoned.into_inner());
801 cache.restore(cached);
802 }
803
804 match result {
805 Ok(inner) => Ok(inner),
806 Err(panic_payload) => std::panic::resume_unwind(panic_payload),
807 }
808}
809
810#[cfg(test)]
811mod tests {
812 use super::*;
813 use crate::model_cache::ModelCache;
814 use mold_core::{Config, GenerateRequest, GenerateResponse};
815 use mold_inference::device::DiscoveredGpu;
816 use mold_inference::shared_pool::SharedPool;
817 use mold_inference::InferenceEngine;
818 use std::sync::atomic::{AtomicUsize, Ordering};
819 use std::sync::{Arc, Mutex, RwLock};
820 use std::time::Duration;
821
822 struct FakeSlowEngine {
825 name: String,
826 loaded: bool,
827 load_sleep: Duration,
828 }
829
830 impl FakeSlowEngine {
831 fn boxed(name: &str, load_sleep: Duration) -> Box<dyn InferenceEngine> {
832 Box::new(Self {
833 name: name.to_string(),
834 loaded: false,
835 load_sleep,
836 })
837 }
838 }
839
840 impl InferenceEngine for FakeSlowEngine {
841 fn generate(&mut self, _req: &GenerateRequest) -> anyhow::Result<GenerateResponse> {
842 unreachable!("FakeSlowEngine is not used for generation in tests")
843 }
844 fn model_name(&self) -> &str {
845 &self.name
846 }
847 fn is_loaded(&self) -> bool {
848 self.loaded
849 }
850 fn load(&mut self) -> anyhow::Result<()> {
851 std::thread::sleep(self.load_sleep);
852 self.loaded = true;
853 Ok(())
854 }
855 fn unload(&mut self) {
856 self.loaded = false;
857 }
858 }
859
860 fn single_worker_pool_with_parked(model: &str, load_sleep: Duration) -> Arc<GpuWorker> {
861 let (job_tx, _job_rx) = std::sync::mpsc::sync_channel::<GpuJob>(2);
862 let mut cache = ModelCache::new(3);
863 cache.insert(FakeSlowEngine::boxed(model, load_sleep), 0);
866 Arc::new(GpuWorker {
867 gpu: DiscoveredGpu {
868 ordinal: 0,
869 name: "fake-gpu-0".to_string(),
870 total_vram_bytes: 24_000_000_000,
871 free_vram_bytes: 24_000_000_000,
872 },
873 model_cache: Arc::new(Mutex::new(cache)),
874 active_generation: Arc::new(RwLock::new(None)),
875 model_load_lock: Arc::new(Mutex::new(())),
876 shared_pool: Arc::new(Mutex::new(SharedPool::new())),
877 in_flight: AtomicUsize::new(0),
878 consecutive_failures: AtomicUsize::new(0),
879 degraded_until: RwLock::new(None),
880 job_tx,
881 })
882 }
883
884 #[test]
889 fn run_chain_blocking_serializes_same_worker() {
890 let worker = single_worker_pool_with_parked("fake-model", Duration::from_millis(30));
891 let config = Config::default();
892
893 let active = Arc::new(AtomicUsize::new(0));
894 let max_concurrent = Arc::new(AtomicUsize::new(0));
895
896 let instrumented = |active: Arc<AtomicUsize>, max_concurrent: Arc<AtomicUsize>| {
897 move |_engine: &mut dyn InferenceEngine| -> anyhow::Result<()> {
898 let now = active.fetch_add(1, Ordering::SeqCst) + 1;
899 max_concurrent.fetch_max(now, Ordering::SeqCst);
900 std::thread::sleep(Duration::from_millis(50));
901 active.fetch_sub(1, Ordering::SeqCst);
902 Ok(())
903 }
904 };
905
906 let worker_a = worker.clone();
907 let config_a = config.clone();
908 let a = active.clone();
909 let m = max_concurrent.clone();
910 let t_a = std::thread::spawn(move || {
911 run_chain_blocking(&worker_a, "fake-model", &config_a, None, instrumented(a, m))
912 .expect("prep ok")
913 .expect("closure ok");
914 });
915
916 let worker_b = worker.clone();
917 let config_b = config.clone();
918 let a = active.clone();
919 let m = max_concurrent.clone();
920 let t_b = std::thread::spawn(move || {
921 run_chain_blocking(&worker_b, "fake-model", &config_b, None, instrumented(a, m))
922 .expect("prep ok")
923 .expect("closure ok");
924 });
925
926 t_a.join().unwrap();
927 t_b.join().unwrap();
928
929 assert_eq!(
930 max_concurrent.load(Ordering::SeqCst),
931 1,
932 "two concurrent run_chain_blocking calls must serialize on worker.model_load_lock"
933 );
934 }
935
936 #[test]
943 fn is_cuda_oom_detects_driver_error_string() {
944 let oom_err = anyhow::anyhow!(r#"DriverError(CUDA_ERROR_OUT_OF_MEMORY, "out of memory")"#);
945 assert!(
946 is_cuda_oom(&oom_err),
947 "must detect CUDA_ERROR_OUT_OF_MEMORY in anyhow error chain"
948 );
949 }
950
951 #[test]
953 fn is_cuda_oom_does_not_trigger_on_regular_errors() {
954 let reg_err = anyhow::anyhow!("safetensors file not found");
955 assert!(
956 !is_cuda_oom(®_err),
957 "non-OOM error must not be classified as OOM"
958 );
959 }
960
961 #[test]
965 fn runtime_oom_message_suggests_offload_and_smaller_frames() {
966 let msg = oom_user_message("ltx-video-0.9.8-13b-dev:bf16");
967 assert!(
968 msg.contains("frames") || msg.contains("width") || msg.contains("quantized"),
969 "OOM message must suggest reducing frames, resolution, or using a \
970 quantized variant; got: {msg}",
971 );
972 assert!(
973 !msg.contains("CUDA_ERROR_OUT_OF_MEMORY"),
974 "OOM user message must not expose the raw CUDA driver error string; \
975 got: {msg}",
976 );
977 assert!(
978 msg.contains("ltx-video-0.9.8-13b-dev:bf16"),
979 "OOM message must include the model name so the user knows what failed; \
980 got: {msg}",
981 );
982 }
983
984 #[test]
997 fn failed_load_does_not_leak_into_model_cache() {
998 struct FailingLoadEngine {
1000 name: String,
1001 }
1002 impl InferenceEngine for FailingLoadEngine {
1003 fn generate(&mut self, _: &GenerateRequest) -> anyhow::Result<GenerateResponse> {
1004 unreachable!()
1005 }
1006 fn model_name(&self) -> &str {
1007 &self.name
1008 }
1009 fn is_loaded(&self) -> bool {
1010 false
1011 }
1012 fn load(&mut self) -> anyhow::Result<()> {
1013 anyhow::bail!(r#"DriverError(CUDA_ERROR_OUT_OF_MEMORY, "out of memory")"#)
1014 }
1015 fn unload(&mut self) {}
1016 }
1017
1018 let cache = ModelCache::new(3);
1019 let model_name = "ltx-video-0.9.8-13b-dev:bf16";
1020
1021 let mut engine: Box<dyn InferenceEngine> = Box::new(FailingLoadEngine {
1025 name: model_name.to_string(),
1026 });
1027 let load_result = engine.load();
1028
1029 assert!(
1030 load_result.is_err(),
1031 "engine.load() must fail for this test to be meaningful"
1032 );
1033 assert!(
1034 is_cuda_oom(load_result.as_ref().unwrap_err()),
1035 "load error must be classified as OOM"
1036 );
1037
1038 assert!(
1041 !cache.contains(model_name),
1042 "cache must not contain the model after a failed load — \
1043 `insert_loaded` must only be called on success"
1044 );
1045 assert!(
1046 cache.is_empty(),
1047 "cache must be completely empty after a failed load"
1048 );
1049 }
1050}