1use crate::gpu_pool::{ActiveGeneration, GpuJob, GpuWorker};
2use crate::model_cache::ModelResidency;
3use crate::queue::{
4 apply_upscale_response_to_image_generation, build_sse_completion_message, clean_error_message,
5 save_generated_image_outputs, save_video_to_dir, settle_post_generation_upscale,
6};
7use crate::state::{GenerationJobResult, SseMessage};
8use mold_core::{
9 Config, ImageData, ModelPaths, OutputFormat, OutputMetadata, SseErrorEvent, SseProgressEvent,
10};
11use mold_inference::device;
12use sha2::{Digest, Sha256};
13use std::sync::atomic::Ordering;
14use std::sync::Arc;
15use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
16
17pub fn spawn_gpu_thread(
20 worker: Arc<GpuWorker>,
21 job_rx: std::sync::mpsc::Receiver<GpuJob>,
22) -> std::thread::JoinHandle<()> {
23 std::thread::Builder::new()
24 .name(format!("gpu-worker-{}", worker.gpu.ordinal))
25 .spawn(move || {
26 mold_inference::device::init_thread_gpu_ordinal(worker.gpu.ordinal);
30 tracing::info!(
31 gpu = worker.gpu.ordinal,
32 name = %worker.gpu.name,
33 "GPU worker thread started"
34 );
35 for job in job_rx.iter() {
36 process_job(&worker, job);
37 }
38 tracing::info!(gpu = worker.gpu.ordinal, "GPU worker thread exiting");
39 })
40 .expect("failed to spawn GPU worker thread")
41}
42
43fn progress_to_sse(event: mold_inference::ProgressEvent) -> SseProgressEvent {
45 event.into()
46}
47
48pub(crate) fn is_cuda_oom(e: &anyhow::Error) -> bool {
55 let full = format!("{e:#}");
56 full.contains("CUDA_ERROR_OUT_OF_MEMORY") || full.contains("out of memory")
57}
58
59pub(crate) fn is_fatal_cuda_error(e: &anyhow::Error) -> bool {
66 has_fatal_cuda_error(&format!("{e:#}"))
67}
68
69fn has_fatal_cuda_error(message: &str) -> bool {
70 [
71 "CUDA_ERROR_ILLEGAL_ADDRESS",
72 "CUDA_ERROR_ECC_UNCORRECTABLE",
73 "CUDA_ERROR_LAUNCH_FAILED",
74 "CUDA_ERROR_ASSERT",
75 "CUDA_ERROR_MISALIGNED_ADDRESS",
76 "CUDA_ERROR_HARDWARE_STACK_ERROR",
77 "CUDA_ERROR_ILLEGAL_INSTRUCTION",
78 "CUDA_ERROR_INVALID_ADDRESS_SPACE",
79 "CUDA_ERROR_INVALID_PC",
80 "CUDA_ERROR_LAUNCH_TIMEOUT",
81 ]
82 .iter()
83 .any(|needle| message.contains(needle))
84}
85
86fn fatal_cuda_user_message(model_name: &str) -> String {
87 format!(
88 "fatal CUDA error while running '{model_name}'; this GPU worker was quarantined because its CUDA context is no longer safe to reuse. Restart the mold server to recover the GPU."
89 )
90}
91
92fn quarantine_poisoned_worker(worker: &GpuWorker) {
93 worker.poisoned.store(true, Ordering::SeqCst);
94 worker.consecutive_failures.store(3, Ordering::SeqCst);
95 *worker.degraded_until.write().unwrap() = None;
96 worker.fatal_cuda_error.store(true, Ordering::SeqCst);
97 worker.fatal_cuda_shutdown.notify_one();
98 tracing::error!(
99 gpu = worker.gpu.ordinal,
100 "GPU worker quarantined after fatal CUDA context error; shutting down for process restart"
101 );
102}
103
104pub(crate) fn quarantine_if_fatal_cuda_error(worker: &GpuWorker, error: &anyhow::Error) -> bool {
105 let fatal = is_fatal_cuda_error(error);
106 if fatal {
107 quarantine_poisoned_worker(worker);
108 }
109 fatal
110}
111
112pub(crate) fn ensure_worker_not_poisoned(
113 worker: &GpuWorker,
114 model_name: &str,
115) -> anyhow::Result<()> {
116 if worker.poisoned.load(Ordering::SeqCst) {
117 anyhow::bail!(fatal_cuda_user_message(model_name));
118 }
119 Ok(())
120}
121
122pub(crate) fn oom_user_message(model_name: &str) -> String {
126 oom_user_message_for_request(model_name, None, None)
127}
128
129pub(crate) fn oom_user_message_for_request(
130 model_name: &str,
131 family_slug: Option<&str>,
132 req: Option<&mold_core::GenerateRequest>,
133) -> String {
134 let requested_size = req
135 .map(|r| format!(" Requested size: {}x{}.", r.width, r.height))
136 .unwrap_or_default();
137 let batch_hint = match req.map(|r| r.batch_size).unwrap_or(1) {
138 0 | 1 => "keep --batch 1".to_string(),
139 n => format!("reduce --batch {n} to --batch 1"),
140 };
141
142 if family_slug.is_some_and(is_video_family) || req.and_then(|r| r.frames).is_some() {
143 let frames_hint = req
144 .and_then(|r| r.frames)
145 .map(|frames| format!("reduce --frames below {frames} (e.g. 17 or 9)"))
146 .unwrap_or_else(|| "reduce --frames (e.g. 17 or 9)".to_string());
147 return format!(
148 "GPU ran out of memory loading or running '{model_name}'.{requested_size} \
149 Try: {frames_hint}, lower --width/--height, use a quantized variant \
150 if available, or close other GPU apps."
151 );
152 }
153
154 let family_note = match family_slug {
155 Some("sd15") => {
156 if req.is_some_and(|r| r.width == 1024 && r.height == 1024) {
157 " SD1.5 defaults to 512x512; 1024x1024 is 4x the pixels and can OOM \
158 even when the checkpoint file is only a few GB."
159 } else {
160 " SD1.5 defaults to 512x512; larger sizes multiply activation and \
161 VAE workspace beyond the checkpoint file size."
162 }
163 }
164 Some("sdxl") => {
165 " SDXL's usual 1024x1024 size still needs activation and VAE workspace \
166 beyond the checkpoint file size."
167 }
168 Some("sd3") => " SD3 needs activation and VAE workspace beyond the checkpoint file size.",
169 Some("flux")
170 | Some("flux2")
171 | Some("qwen-image")
172 | Some("qwen-image-edit")
173 | Some("z-image")
174 | Some("wuerstchen") => {
175 " The checkpoint size is only the weights; peak VRAM also includes \
176 activations, VAE decode workspace, CUDA workspaces, and resident cache."
177 }
178 _ => {
179 " The model file size is only the weights; peak VRAM also includes \
180 activations, decoder workspace, CUDA workspaces, and resident cache."
181 }
182 };
183 let resolution_hint = match family_slug {
184 Some("sd15") => "lower --width/--height (try 768x768 or 512x512)",
185 _ => "lower --width/--height",
186 };
187
188 format!(
189 "GPU ran out of memory loading or running '{model_name}'.{requested_size}{family_note} \
190 Try: {resolution_hint}, {batch_hint}, use a smaller/quantized variant if \
191 this model provides one, run mold unload, or close other GPU apps."
192 )
193}
194
195fn is_video_family(family_slug: &str) -> bool {
196 matches!(family_slug, "ltx-video" | "ltx2" | "ltx-2" | "ltx-2.3")
197}
198
199fn upscale_generated_image_on_worker(
200 worker: &GpuWorker,
201 job: &GpuJob,
202 upscale_model: &str,
203 img: ImageData,
204 response: &mut mold_core::GenerateResponse,
205) -> Result<ImageData, String> {
206 let model_name = mold_core::manifest::resolve_model_name(upscale_model);
207 let weights_path = {
208 let config = job.config.blocking_read();
209 config
210 .models
211 .get(&model_name)
212 .and_then(|c| c.transformer.as_ref())
213 .map(std::path::PathBuf::from)
214 }
215 .ok_or_else(|| format!("upscaler model '{model_name}' is not downloaded"))?;
216
217 if let Some(ref tx) = job.progress_tx {
218 let _ = tx.send(SseMessage::Progress(SseProgressEvent::StageStart {
219 name: format!("Loading upscaler {model_name}"),
220 }));
221 }
222 let mut engine = mold_inference::create_upscale_engine(
223 model_name.clone(),
224 weights_path,
225 mold_inference::LoadStrategy::Eager,
226 worker.gpu.ordinal,
227 )
228 .map_err(|e| format!("failed to load upscaler: {e}"))?;
229 if let Some(ref tx) = job.progress_tx {
230 let tx = tx.clone();
231 engine.set_on_progress(Box::new(move |event| {
232 let _ = tx.send(SseMessage::Progress(progress_to_sse(event)));
233 }));
234 }
235 let req = mold_core::UpscaleRequest {
236 model: model_name,
237 image: img.data.clone(),
238 output_format: img.format,
239 tile_size: None,
240 metadata: Some(OutputMetadata::from_generate_request(
241 &job.request,
242 response.seed_used,
243 None,
244 mold_core::build_info::version_string(),
245 )),
246 };
247 let upscaled = engine
248 .upscale(&req)
249 .map_err(|e| format!("upscale failed: {e}"))?;
250 engine.clear_on_progress();
251 apply_upscale_response_to_image_generation(&job.request, response, img, upscaled)
252 .map_err(|e| format!("upscale failed: {e}"))
253}
254
255fn cuda_oom_user_message(
256 worker: &GpuWorker,
257 model_name: &str,
258 family_slug: Option<&str>,
259 req: Option<&mold_core::GenerateRequest>,
260) -> (String, bool) {
261 let base = if family_slug.is_none() && req.is_none() {
262 oom_user_message(model_name)
263 } else {
264 oom_user_message_for_request(model_name, family_slug, req)
265 };
266 let outcome = crate::gpu_pool::record_model_cuda_oom(model_name, worker.gpu.ordinal);
267 if outcome.is_unschedulable() {
268 if let Some(cooldown) = crate::gpu_pool::model_unschedulable_message(model_name) {
269 return (format!("{base} {cooldown}"), false);
270 }
271 }
272 (base, true)
273}
274
275fn process_job(worker: &GpuWorker, job: GpuJob) {
276 let model_name = job.model.clone();
277 let ordinal = worker.gpu.ordinal;
278 let job_id = job.id.clone();
279
280 struct CleanupGuard {
286 queue: crate::state::QueueHandle,
287 registry: crate::job_registry::SharedJobRegistry,
288 id: String,
289 }
290 impl Drop for CleanupGuard {
291 fn drop(&mut self) {
292 self.queue.decrement();
293 self.registry.remove(&self.id);
294 }
295 }
296 let _cleanup = CleanupGuard {
297 queue: job.queue.clone(),
298 registry: job.registry.clone(),
299 id: job_id.clone(),
300 };
301
302 if worker.poisoned.load(Ordering::SeqCst) {
306 let err_msg = fatal_cuda_user_message(&model_name);
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 worker.in_flight.fetch_sub(1, Ordering::SeqCst);
314 return;
315 }
316
317 if job.result_tx.is_closed() {
318 tracing::debug!(gpu = ordinal, model = %model_name, "skipping dispatched job — client disconnected");
319 worker.in_flight.fetch_sub(1, Ordering::SeqCst);
320 return;
321 }
322
323 job.registry.mark_running(&job_id, Some(ordinal));
326
327 tracing::info!(gpu = ordinal, model = %model_name, "dispatched job");
328
329 let _load_lock = worker.model_load_lock.lock().unwrap();
331
332 if let Err(error) = ensure_worker_not_poisoned(worker, &model_name) {
335 let err_msg = error.to_string();
336 if let Some(ref tx) = job.progress_tx {
337 let _ = tx.send(SseMessage::Error(SseErrorEvent {
338 message: err_msg.clone(),
339 }));
340 }
341 let _ = job.result_tx.send(Err(err_msg));
342 worker.in_flight.fetch_sub(1, Ordering::SeqCst);
343 return;
344 }
345
346 let config_snapshot = job.config.blocking_read().clone();
348 let family_slug = crate::model_manager::family_for_model_sync(&model_name, &config_snapshot);
349 let activation_hint =
350 crate::model_manager::activation_hint_for_request_sync(&config_snapshot, &job.request);
351 let request_has_lora = crate::model_manager::request_has_effective_lora(&job.request);
352 if let Err(e) = ensure_model_ready_sync(
353 worker,
354 &model_name,
355 &config_snapshot,
356 activation_hint,
357 request_has_lora,
358 ) {
359 tracing::error!(gpu = ordinal, model = %model_name, "Failed to load model: {e}");
360 let is_fatal_cuda = is_fatal_cuda_error(&e);
364 let is_oom = is_cuda_oom(&e);
365 let (err_msg, count_worker_failure) = if is_fatal_cuda {
366 quarantine_poisoned_worker(worker);
367 (fatal_cuda_user_message(&model_name), false)
368 } else if is_oom {
369 mold_inference::device::try_synchronize_device(ordinal);
370 cuda_oom_user_message(
371 worker,
372 &model_name,
373 family_slug.as_deref(),
374 Some(&job.request),
375 )
376 } else {
377 (
378 format!("model load error: {}", clean_error_message(&e)),
379 true,
380 )
381 };
382 if let Some(ref tx) = job.progress_tx {
383 let _ = tx.send(SseMessage::Error(SseErrorEvent {
384 message: err_msg.clone(),
385 }));
386 }
387 let _ = job.result_tx.send(Err(err_msg));
388 worker.in_flight.fetch_sub(1, Ordering::SeqCst);
389 if count_worker_failure {
390 record_failure(worker);
391 }
392 return;
393 }
394
395 {
397 let mut gen = worker.active_generation.write().unwrap();
398 *gen = Some(ActiveGeneration {
399 model: model_name.clone(),
400 prompt_sha256: format!("{:x}", Sha256::digest(job.request.prompt.as_bytes())),
401 started_at_unix_ms: SystemTime::now()
402 .duration_since(UNIX_EPOCH)
403 .unwrap_or_default()
404 .as_millis() as u64,
405 started_at: Instant::now(),
406 });
407 }
408
409 if job.result_tx.is_closed() {
410 tracing::debug!(
411 gpu = ordinal,
412 model = %model_name,
413 "skipping generation after model readiness — client disconnected"
414 );
415 worker.in_flight.fetch_sub(1, Ordering::SeqCst);
416 clear_active_generation(worker);
417 return;
418 }
419
420 let taken = {
422 let mut cache = worker.model_cache.lock().unwrap();
423 cache.take(&model_name)
424 };
425
426 let Some(mut cached_engine) = taken else {
427 let err_msg = "engine not found in cache after load".to_string();
428 if let Some(ref tx) = job.progress_tx {
429 let _ = tx.send(SseMessage::Error(SseErrorEvent {
430 message: err_msg.clone(),
431 }));
432 }
433 let _ = job.result_tx.send(Err(err_msg));
434 worker.in_flight.fetch_sub(1, Ordering::SeqCst);
435 clear_active_generation(worker);
436 return;
437 };
438
439 if let Some(ref progress_tx) = job.progress_tx {
441 let tx = progress_tx.clone();
442 cached_engine.engine.set_on_progress(Box::new(move |event| {
443 let _ = tx.send(SseMessage::Progress(progress_to_sse(event)));
444 }));
445 }
446
447 let rss_before = crate::resources::ram_snapshot().used_by_mold;
451
452 let watchdog_stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
457 let watchdog_handle = {
458 let stop = watchdog_stop.clone();
459 let model = model_name.clone();
460 std::thread::Builder::new()
461 .name(format!("rss-watchdog-{ordinal}"))
462 .spawn(move || {
463 let start = Instant::now();
464 while !stop.load(Ordering::SeqCst) {
465 std::thread::sleep(Duration::from_millis(1000));
466 if stop.load(Ordering::SeqCst) {
467 break;
468 }
469 let rss = crate::resources::ram_snapshot().used_by_mold;
470 tracing::info!(
471 gpu = ordinal,
472 model = %model,
473 elapsed_s = start.elapsed().as_secs(),
474 rss_mb = rss / 1_000_000,
475 "rss watchdog"
476 );
477 }
478 })
479 .expect("failed to spawn RSS watchdog")
480 };
481
482 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
484 cached_engine.engine.generate(&job.request)
485 }));
486
487 watchdog_stop.store(true, Ordering::SeqCst);
488 let _ = watchdog_handle.join();
489
490 let trim_enabled = std::env::var("MOLD_MALLOC_TRIM")
496 .map(|v| v != "0")
497 .unwrap_or(true);
498 let rss_pre_trim = if trim_enabled {
499 let v = crate::resources::ram_snapshot().used_by_mold;
500 #[cfg(target_os = "linux")]
501 unsafe {
502 libc::malloc_trim(0);
503 }
504 Some(v)
505 } else {
506 None
507 };
508
509 let rss_after = crate::resources::ram_snapshot().used_by_mold;
510 let rss_delta = rss_after as i64 - rss_before as i64;
511 tracing::info!(
512 gpu = ordinal,
513 model = %model_name,
514 rss_before_mb = rss_before / 1_000_000,
515 rss_after_mb = rss_after / 1_000_000,
516 rss_delta_mb = rss_delta / 1_000_000,
517 rss_pre_trim_mb = rss_pre_trim.map(|v| v / 1_000_000).unwrap_or(0),
518 "generation memory delta"
519 );
520
521 cached_engine.engine.clear_on_progress();
523
524 let fatal_cuda = matches!(&result, Ok(Err(e)) if is_fatal_cuda_error(e));
531 if fatal_cuda {
532 quarantine_poisoned_worker(worker);
535 drop(cached_engine);
536 let remaining = {
537 let mut cache = worker.model_cache.lock().unwrap();
538 cache.clear()
539 };
540 drop(remaining);
541 } else {
542 let mut cache = worker.model_cache.lock().unwrap();
543 cache.restore(cached_engine);
544 }
545
546 clear_active_generation(worker);
548
549 worker.in_flight.fetch_sub(1, Ordering::SeqCst);
551
552 match result {
553 Ok(Ok(mut response)) => {
554 worker.consecutive_failures.store(0, Ordering::SeqCst);
556 crate::gpu_pool::clear_model_cuda_oom(&model_name);
557
558 response.gpu = Some(ordinal);
560
561 if response.images.is_empty() && response.video.is_none() {
562 let err_msg = "generation error: engine returned no images or video".to_string();
563 if let Some(ref tx) = job.progress_tx {
564 let _ = tx.send(SseMessage::Error(SseErrorEvent {
565 message: err_msg.clone(),
566 }));
567 }
568 let _ = job.result_tx.send(Err(err_msg));
569 return;
570 }
571
572 let mut img = if !response.images.is_empty() {
574 response.images.remove(0)
575 } else if let Some(ref video) = response.video {
576 ImageData {
577 data: video.thumbnail.clone(),
578 format: OutputFormat::Png,
579 width: video.width,
580 height: video.height,
581 index: 0,
582 }
583 } else {
584 unreachable!("checked above");
585 };
586 let mut original_img = None;
587
588 if response.video.is_none() {
589 if let Some(upscale_model) = job
590 .request
591 .upscale_model
592 .as_deref()
593 .map(str::trim)
594 .filter(|m| !m.is_empty())
595 {
596 let upscale_result = upscale_generated_image_on_worker(
597 worker,
598 &job,
599 upscale_model,
600 img.clone(),
601 &mut response,
602 );
603 if upscale_result
604 .as_ref()
605 .is_err_and(|error| has_fatal_cuda_error(error))
606 {
607 quarantine_poisoned_worker(worker);
608 }
609 let (output, preserved_original, upscale_error) =
610 settle_post_generation_upscale(img, upscale_result);
611 img = output;
612 original_img = preserved_original;
613 if let Some(error) = upscale_error {
614 tracing::warn!(
615 gpu = ordinal,
616 %error,
617 "post-generation upscale failed; keeping original image"
618 );
619 }
620 }
621 }
622
623 let metadata = OutputMetadata::from_generate_request(
631 &job.request,
632 response.seed_used,
633 None,
634 mold_core::build_info::version_string(),
635 );
636 let mut saved_names = crate::queue::SavedOutputNames::default();
637 if let Some(ref dir) = job.output_dir {
638 let generation_time_ms = response.generation_time_ms as i64;
639 let db = job.metadata_db.as_ref().as_ref();
640 let events = Some(job.events.as_ref());
641 if let Some(ref video) = response.video {
642 saved_names.output = save_video_to_dir(
643 dir,
644 &video.data,
645 &video.gif_preview,
646 video.format,
647 &job.model,
648 &metadata,
649 Some(generation_time_ms),
650 db,
651 events,
652 );
653 } else {
654 saved_names = save_generated_image_outputs(
655 dir,
656 original_img.as_ref(),
657 &img,
658 &job.model,
659 job.request.batch_size,
660 &metadata,
661 Some(generation_time_ms),
662 db,
663 events,
664 );
665 }
666 }
667
668 if let Some(ref tx) = job.progress_tx {
674 let message = build_sse_completion_message(
675 &response,
676 &img,
677 original_img.as_ref(),
678 Some(&metadata),
679 &saved_names,
680 job.completion_payload,
681 );
682 let _ = tx.send(message);
683 }
684
685 let _ = job.result_tx.send(Ok(GenerationJobResult {
687 image: img,
688 response,
689 }));
690 }
691 Ok(Err(e)) => {
692 tracing::warn!(gpu = ordinal, model = %model_name, "Generation failed: {e}");
693 let is_oom = is_cuda_oom(&e);
697 let (err_msg, count_worker_failure) = if fatal_cuda {
698 (fatal_cuda_user_message(&model_name), false)
699 } else if is_oom {
700 mold_inference::device::try_synchronize_device(ordinal);
701 cuda_oom_user_message(
702 worker,
703 &model_name,
704 family_slug.as_deref(),
705 Some(&job.request),
706 )
707 } else {
708 (
709 format!("generation error: {}", clean_error_message(&e)),
710 true,
711 )
712 };
713 if count_worker_failure {
714 record_failure(worker);
715 }
716 if let Some(ref tx) = job.progress_tx {
717 let _ = tx.send(SseMessage::Error(SseErrorEvent {
718 message: err_msg.clone(),
719 }));
720 }
721 let _ = job.result_tx.send(Err(err_msg));
722 }
723 Err(panic_payload) => {
724 tracing::error!(gpu = ordinal, model = %model_name, "Inference panicked");
725 record_failure(worker);
726 let msg = panic_payload
727 .downcast_ref::<String>()
728 .map(|s| s.as_str())
729 .or_else(|| panic_payload.downcast_ref::<&str>().copied())
730 .unwrap_or("unknown panic");
731 let err_msg = format!("inference panicked: {msg}");
732 if let Some(ref tx) = job.progress_tx {
733 let _ = tx.send(SseMessage::Error(SseErrorEvent {
734 message: err_msg.clone(),
735 }));
736 }
737 let _ = job.result_tx.send(Err(err_msg));
738 }
739 }
740}
741
742fn preflight_memory_guard_with_eviction(
756 cache_lock: &std::sync::Mutex<crate::model_cache::ModelCache>,
757 model_name: &str,
758 paths: &ModelPaths,
759 ordinal: usize,
760 hint: Option<crate::model_manager::ActivationHint>,
761) -> Result<(), crate::routes::ApiError> {
762 loop {
763 let active_vram = cache_lock
764 .lock()
765 .unwrap_or_else(|e| e.into_inner())
766 .active_vram_bytes();
767 let err = match crate::model_manager::preflight_memory_guard(
768 model_name,
769 paths,
770 active_vram,
771 ordinal,
772 hint,
773 ) {
774 Ok(()) => return Ok(()),
775 Err(e) => e,
776 };
777
778 let evicted = {
779 let mut cache = cache_lock.lock().unwrap_or_else(|e| e.into_inner());
780 cache.evict_lru_parked_except(Some(model_name))
781 };
782 let Some((evicted_name, engine)) = evicted else {
783 return Err(err);
784 };
785 tracing::info!(
786 gpu = ordinal,
787 target_model = %model_name,
788 evicted_model = %evicted_name,
789 "evicting LRU parked entry to fit incoming load"
790 );
791 drop(engine);
794
795 let safe_to_reclaim = cache_lock
801 .lock()
802 .unwrap_or_else(|e| e.into_inner())
803 .active_model()
804 .is_none();
805 if safe_to_reclaim {
806 device::reclaim_gpu_memory(ordinal);
807 }
808 }
809}
810
811fn select_load_strategy_for_worker(
812 worker: &GpuWorker,
813 model_name: &str,
814 paths: &ModelPaths,
815 hint: Option<crate::model_manager::ActivationHint>,
816) -> mold_inference::LoadStrategy {
817 let active_vram = worker
818 .model_cache
819 .lock()
820 .unwrap_or_else(|e| e.into_inner())
821 .active_vram_bytes();
822 let available =
823 crate::model_manager::effective_load_available_bytes(active_vram, worker.gpu.ordinal);
824 let strategy = crate::model_manager::select_server_load_strategy_for_device(
825 paths,
826 available,
827 Some(worker.gpu.total_vram_bytes),
828 hint,
829 );
830 if strategy == mold_inference::LoadStrategy::Sequential {
831 tracing::info!(
832 gpu = worker.gpu.ordinal,
833 model = %model_name,
834 "server load strategy degraded to sequential to fit memory budget"
835 );
836 }
837 strategy
838}
839
840pub fn ensure_model_ready_sync(
848 worker: &GpuWorker,
849 model_name: &str,
850 config: &Config,
851 hint: Option<crate::model_manager::ActivationHint>,
852 request_has_lora: bool,
853) -> anyhow::Result<()> {
854 let cache = worker.model_cache.lock().unwrap();
855
856 if let Some(entry) = cache.get(model_name) {
858 if entry.residency == ModelResidency::Gpu {
859 let must_recreate = entry.engine.model_paths().is_some_and(|paths| {
860 crate::model_manager::request_requires_fresh_engine_for_offload_policy(
861 paths,
862 hint,
863 request_has_lora,
864 )
865 });
866 if !must_recreate {
867 return Ok(());
868 }
869 }
870 }
871
872 let has_cached = cache.contains(model_name);
874
875 let cached_paths = if has_cached {
880 cache
881 .get(model_name)
882 .and_then(|e| e.engine.model_paths().cloned())
883 } else {
884 None
885 };
886 drop(cache);
887
888 if has_cached {
889 let load_strategy = cached_paths
890 .as_ref()
891 .map(|paths| select_load_strategy_for_worker(worker, model_name, paths, hint))
892 .unwrap_or(mold_inference::LoadStrategy::Eager);
893
894 if let Some(ref paths) = cached_paths {
899 preflight_memory_guard_with_eviction(
900 &worker.model_cache,
901 model_name,
902 paths,
903 worker.gpu.ordinal,
904 hint,
905 )
906 .map_err(|e| anyhow::anyhow!(e.error))?;
907 }
908
909 {
911 let mut cache = worker.model_cache.lock().unwrap();
912 cache.unload_active();
913 }
914 device::reclaim_gpu_memory(worker.gpu.ordinal);
915
916 if load_strategy == mold_inference::LoadStrategy::Sequential {
917 let paths = cached_paths.ok_or_else(|| {
918 anyhow::anyhow!("cached engine for '{model_name}' does not expose model paths")
919 })?;
920 let old_engine = {
921 let mut cache = worker.model_cache.lock().unwrap();
922 cache
923 .remove(model_name)
924 .ok_or_else(|| anyhow::anyhow!("cache race: model '{model_name}' vanished"))?
925 };
926
927 let offload = crate::model_manager::server_offload_enabled_for_paths(
928 &paths,
929 hint,
930 request_has_lora,
931 );
932 let resolved_catalog_config =
933 crate::model_manager::resolve_installed_catalog_paths_for_worker(
934 model_name, config,
935 )
936 .map_err(|e| anyhow::anyhow!(e.error))?
937 .map(|(_, config)| config);
938 let engine_config = resolved_catalog_config.as_ref().unwrap_or(config);
939 let mut engine = match mold_inference::create_engine_with_pool(
940 model_name.to_string(),
941 paths,
942 engine_config,
943 load_strategy,
944 worker.gpu.ordinal,
945 offload,
946 Some(worker.shared_pool.clone()),
947 ) {
948 Ok(engine) => engine,
949 Err(err) => {
950 let evicted = {
951 let mut cache = worker.model_cache.lock().unwrap();
952 cache.insert(old_engine, 0)
953 };
954 drop(evicted);
955 return Err(err);
956 }
957 };
958
959 tracing::info!(
960 gpu = worker.gpu.ordinal,
961 model = %model_name,
962 "recreating cached engine in sequential mode..."
963 );
964 let vram_baseline = device::vram_in_use_bytes(worker.gpu.ordinal);
965 if let Err(err) = engine.load() {
966 let evicted = {
967 let mut cache = worker.model_cache.lock().unwrap();
968 cache.insert(old_engine, 0)
969 };
970 drop(evicted);
971 return Err(err);
972 }
973 let vram = device::vram_load_delta(worker.gpu.ordinal, vram_baseline);
974 drop(old_engine);
975 let evicted = {
976 let mut cache = worker.model_cache.lock().unwrap();
977 cache.insert_loaded(model_name.to_string(), engine, vram)
978 };
979 drop(evicted);
980 return Ok(());
981 }
982
983 let mut engine = {
985 let mut cache = worker.model_cache.lock().unwrap();
986 cache
987 .remove(model_name)
988 .ok_or_else(|| anyhow::anyhow!("cache race: model '{model_name}' vanished"))?
989 };
990
991 tracing::info!(
992 gpu = worker.gpu.ordinal,
993 model = %model_name,
994 "reloading cached engine..."
995 );
996 let vram_baseline = device::vram_in_use_bytes(worker.gpu.ordinal);
999 engine.load()?;
1000
1001 let vram = device::vram_load_delta(worker.gpu.ordinal, vram_baseline);
1002 let evicted = {
1005 let mut cache = worker.model_cache.lock().unwrap();
1006 cache.insert_loaded(model_name.to_string(), engine, vram)
1007 };
1008 drop(evicted);
1009 return Ok(());
1010 }
1011
1012 let mut resolved_catalog_config = None;
1015 let paths = if let Some(paths) = ModelPaths::resolve(model_name, config) {
1016 paths
1017 } else if let Some((paths, config)) =
1018 crate::model_manager::resolve_installed_catalog_paths_for_worker(model_name, config)
1019 .map_err(|e| anyhow::anyhow!(e.error))?
1020 {
1021 resolved_catalog_config = Some(config);
1022 paths
1023 } else {
1024 return Err(
1025 if model_name.starts_with("cv:") || model_name.starts_with("hf:") {
1026 anyhow::anyhow!(
1034 "catalog model '{model_name}' has missing required components. \
1035 Re-pull the entry from the catalog so its companions \
1036 (CLIP-L / T5 / VAE) are fetched alongside the primary checkpoint."
1037 )
1038 } else {
1039 anyhow::anyhow!(
1040 "model '{model_name}' is not downloaded. Run: mold pull {model_name}"
1041 )
1042 },
1043 );
1044 };
1045
1046 preflight_memory_guard_with_eviction(
1049 &worker.model_cache,
1050 model_name,
1051 &paths,
1052 worker.gpu.ordinal,
1053 hint,
1054 )
1055 .map_err(|e| anyhow::anyhow!(e.error))?;
1056
1057 let load_strategy = select_load_strategy_for_worker(worker, model_name, &paths, hint);
1058
1059 {
1061 let mut cache = worker.model_cache.lock().unwrap();
1062 cache.unload_active();
1063 }
1064 device::reclaim_gpu_memory(worker.gpu.ordinal);
1065
1066 let offload =
1067 crate::model_manager::server_offload_enabled_for_paths(&paths, hint, request_has_lora);
1068 let engine_config = resolved_catalog_config.as_ref().unwrap_or(config);
1069 let mut engine = mold_inference::create_engine_with_pool(
1070 model_name.to_string(),
1071 paths,
1072 engine_config,
1073 load_strategy,
1074 worker.gpu.ordinal,
1075 offload,
1076 Some(worker.shared_pool.clone()),
1077 )?;
1078
1079 tracing::info!(
1080 gpu = worker.gpu.ordinal,
1081 model = %model_name,
1082 "loading model..."
1083 );
1084 let vram_baseline = device::vram_in_use_bytes(worker.gpu.ordinal);
1087 engine.load()?;
1088
1089 let vram = device::vram_load_delta(worker.gpu.ordinal, vram_baseline);
1090 let evicted = {
1093 let mut cache = worker.model_cache.lock().unwrap();
1094 cache.insert_loaded(model_name.to_string(), engine, vram)
1095 };
1096 drop(evicted);
1097
1098 Ok(())
1099}
1100
1101pub fn load_blocking(worker: &GpuWorker, model_name: &str, config: &Config) -> anyhow::Result<()> {
1108 if worker.poisoned.load(Ordering::SeqCst) {
1109 anyhow::bail!(fatal_cuda_user_message(model_name));
1110 }
1111 let _lock = worker.model_load_lock.lock().unwrap();
1112 if worker.poisoned.load(Ordering::SeqCst) {
1113 anyhow::bail!(fatal_cuda_user_message(model_name));
1114 }
1115 let result = ensure_model_ready_sync(worker, model_name, config, None, false);
1116 if result.as_ref().is_err_and(is_fatal_cuda_error) {
1117 quarantine_poisoned_worker(worker);
1118 }
1119 result
1120}
1121
1122pub fn unload_blocking(worker: &GpuWorker) -> Option<String> {
1127 let _lock = worker.model_load_lock.lock().unwrap();
1128 let unloaded = {
1129 let mut cache = worker.model_cache.lock().unwrap();
1130 cache.unload_active()
1131 };
1132 if unloaded.is_some() {
1133 device::reclaim_gpu_memory(worker.gpu.ordinal);
1134 }
1135 unloaded
1136}
1137
1138fn record_failure(worker: &GpuWorker) {
1139 let failures = worker.consecutive_failures.fetch_add(1, Ordering::SeqCst) + 1;
1140 if failures >= 3 {
1141 let mut degraded = worker.degraded_until.write().unwrap();
1142 *degraded = Some(Instant::now() + Duration::from_secs(60));
1143 tracing::warn!(
1144 gpu = worker.gpu.ordinal,
1145 "GPU marked degraded after {failures} consecutive failures (60s cooldown)"
1146 );
1147 }
1148}
1149
1150fn clear_active_generation(worker: &GpuWorker) {
1151 let mut gen = worker.active_generation.write().unwrap();
1152 *gen = None;
1153}
1154
1155pub type ChainPrep<T, E> = Result<Result<T, E>, anyhow::Error>;
1161
1162pub fn run_chain_blocking<T, E: std::fmt::Display + std::fmt::Debug>(
1182 worker: &GpuWorker,
1183 model_name: &str,
1184 config: &mold_core::Config,
1185 hint: Option<crate::model_manager::ActivationHint>,
1186 with_engine: impl FnOnce(&mut dyn mold_inference::InferenceEngine) -> Result<T, E>,
1187) -> ChainPrep<T, E> {
1188 struct ThreadGpuGuard;
1193 impl Drop for ThreadGpuGuard {
1194 fn drop(&mut self) {
1195 mold_inference::device::clear_thread_gpu_ordinal();
1196 }
1197 }
1198 mold_inference::device::init_thread_gpu_ordinal(worker.gpu.ordinal);
1199 let _thread_gpu = ThreadGpuGuard;
1200
1201 if worker.poisoned.load(Ordering::SeqCst) {
1202 anyhow::bail!(fatal_cuda_user_message(model_name));
1203 }
1204
1205 let _load_lock = worker
1208 .model_load_lock
1209 .lock()
1210 .map_err(|e| anyhow::anyhow!("worker.model_load_lock poisoned: {e}"))?;
1211 if worker.poisoned.load(Ordering::SeqCst) {
1212 anyhow::bail!(fatal_cuda_user_message(model_name));
1213 }
1214
1215 if let Err(error) = ensure_model_ready_sync(worker, model_name, config, hint, false) {
1218 if is_fatal_cuda_error(&error) {
1219 quarantine_poisoned_worker(worker);
1220 }
1221 return Err(error);
1222 }
1223
1224 let cached = {
1226 let mut cache = worker
1227 .model_cache
1228 .lock()
1229 .map_err(|e| anyhow::anyhow!("worker.model_cache poisoned: {e}"))?;
1230 cache.take(model_name).ok_or_else(|| {
1231 anyhow::anyhow!("cache race: engine '{model_name}' vanished after ensure_model_ready")
1232 })?
1233 };
1234
1235 let mut cached = cached;
1247 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1248 with_engine(cached.engine.as_mut())
1249 }));
1250
1251 let fatal_cuda =
1252 matches!(&result, Ok(Err(error)) if has_fatal_cuda_error(&format!("{error:#}")));
1253 if fatal_cuda {
1254 quarantine_poisoned_worker(worker);
1255 drop(cached);
1256 let remaining = worker
1257 .model_cache
1258 .lock()
1259 .unwrap_or_else(|poisoned| poisoned.into_inner())
1260 .clear();
1261 drop(remaining);
1262 } else {
1263 let mut cache = worker
1266 .model_cache
1267 .lock()
1268 .unwrap_or_else(|poisoned| poisoned.into_inner());
1269 cache.restore(cached);
1270 }
1271
1272 match result {
1273 Ok(inner) => Ok(inner),
1274 Err(panic_payload) => std::panic::resume_unwind(panic_payload),
1275 }
1276}
1277
1278pub fn run_stage_blocking<T, E: std::fmt::Display + std::fmt::Debug>(
1283 worker: &GpuWorker,
1284 model_name: &str,
1285 config: &mold_core::Config,
1286 hint: Option<crate::model_manager::ActivationHint>,
1287 with_engine: impl FnOnce(&mut dyn mold_inference::InferenceEngine) -> Result<T, E>,
1288) -> ChainPrep<T, E> {
1289 run_chain_blocking(worker, model_name, config, hint, with_engine)
1292}
1293
1294#[cfg(test)]
1295mod tests {
1296 use super::*;
1297 use crate::job_registry::JobRegistry;
1298 use crate::model_cache::ModelCache;
1299 use crate::state::{GenerationJob, QueueHandle, SseCompletionPayload};
1300 use mold_core::{
1301 Config, GenerateRequest, GenerateResponse, ImageData, ModelConfig, OutputFormat,
1302 };
1303 use mold_inference::device::DiscoveredGpu;
1304 use mold_inference::shared_pool::SharedPool;
1305 use mold_inference::InferenceEngine;
1306 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
1307 use std::sync::{Arc, Mutex, RwLock};
1308 use std::time::Duration;
1309
1310 struct FakeSlowEngine {
1313 name: String,
1314 loaded: bool,
1315 load_sleep: Duration,
1316 }
1317
1318 impl FakeSlowEngine {
1319 fn boxed(name: &str, load_sleep: Duration) -> Box<dyn InferenceEngine> {
1320 Box::new(Self {
1321 name: name.to_string(),
1322 loaded: false,
1323 load_sleep,
1324 })
1325 }
1326 }
1327
1328 impl InferenceEngine for FakeSlowEngine {
1329 fn generate(&mut self, _req: &GenerateRequest) -> anyhow::Result<GenerateResponse> {
1330 unreachable!("FakeSlowEngine is not used for generation in tests")
1331 }
1332 fn model_name(&self) -> &str {
1333 &self.name
1334 }
1335 fn is_loaded(&self) -> bool {
1336 self.loaded
1337 }
1338 fn load(&mut self) -> anyhow::Result<()> {
1339 std::thread::sleep(self.load_sleep);
1340 self.loaded = true;
1341 Ok(())
1342 }
1343 fn unload(&mut self) {
1344 self.loaded = false;
1345 }
1346 }
1347
1348 fn single_worker_pool_with_parked(model: &str, load_sleep: Duration) -> Arc<GpuWorker> {
1349 let (job_tx, _job_rx) = std::sync::mpsc::sync_channel::<GpuJob>(2);
1350 let mut cache = ModelCache::new(3);
1351 cache.insert(FakeSlowEngine::boxed(model, load_sleep), 0);
1354 Arc::new(GpuWorker {
1355 gpu: DiscoveredGpu {
1356 ordinal: 0,
1357 name: "fake-gpu-0".to_string(),
1358 total_vram_bytes: 24_000_000_000,
1359 free_vram_bytes: 24_000_000_000,
1360 },
1361 model_cache: Arc::new(Mutex::new(cache)),
1362 active_generation: Arc::new(RwLock::new(None)),
1363 model_load_lock: Arc::new(Mutex::new(())),
1364 shared_pool: Arc::new(Mutex::new(SharedPool::new())),
1365 in_flight: AtomicUsize::new(0),
1366 consecutive_failures: AtomicUsize::new(0),
1367 poisoned: AtomicBool::new(false),
1368 fatal_cuda_error: Arc::new(AtomicBool::new(false)),
1369 fatal_cuda_shutdown: Arc::new(tokio::sync::Notify::new()),
1370 degraded_until: RwLock::new(None),
1371 job_tx,
1372 })
1373 }
1374
1375 fn fake_upscale_job(config: Config, upscale_model: &str) -> GpuJob {
1376 let (result_tx, _result_rx) = tokio::sync::oneshot::channel();
1377 let (queue_tx, _queue_rx) = tokio::sync::mpsc::channel(1);
1378 let mut request: GenerateRequest = serde_json::from_str(
1379 r#"{"prompt":"portrait","model":"flux-dev:q4","width":512,"height":512,"steps":4,"guidance":3.5,"batch_size":1}"#,
1380 )
1381 .unwrap();
1382 request.upscale_model = Some(upscale_model.to_string());
1383 GpuJob {
1384 id: "job-upscale-test".to_string(),
1385 model: request.model.clone(),
1386 request,
1387 completion_payload: crate::state::SseCompletionPayload::Full,
1388 progress_tx: None,
1389 result_tx,
1390 output_dir: None,
1391 config: Arc::new(tokio::sync::RwLock::new(config)),
1392 metadata_db: Arc::new(None),
1393 queue: QueueHandle::new(queue_tx),
1394 registry: JobRegistry::new(),
1395 events: crate::events::EventBroadcaster::new(),
1396 }
1397 }
1398
1399 fn fake_upscale_image() -> ImageData {
1400 ImageData {
1401 data: vec![0x89, 0x50, 0x4E, 0x47],
1402 format: OutputFormat::Png,
1403 width: 512,
1404 height: 512,
1405 index: 0,
1406 }
1407 }
1408
1409 #[test]
1410 fn fatal_cuda_errors_are_classified_as_context_poisoning() {
1411 for message in [
1412 "DriverError(CUDA_ERROR_ILLEGAL_ADDRESS, an illegal memory access was encountered)",
1413 "DriverError(CUDA_ERROR_ECC_UNCORRECTABLE, uncorrectable ECC error)",
1414 "DriverError(CUDA_ERROR_LAUNCH_FAILED, unspecified launch failure)",
1415 "DriverError(CUDA_ERROR_ASSERT, device-side assert triggered)",
1416 "DriverError(CUDA_ERROR_MISALIGNED_ADDRESS, misaligned address)",
1417 "DriverError(CUDA_ERROR_HARDWARE_STACK_ERROR, hardware stack error)",
1418 "DriverError(CUDA_ERROR_ILLEGAL_INSTRUCTION, illegal instruction)",
1419 "DriverError(CUDA_ERROR_INVALID_ADDRESS_SPACE, invalid address space)",
1420 "DriverError(CUDA_ERROR_INVALID_PC, invalid program counter)",
1421 "DriverError(CUDA_ERROR_LAUNCH_TIMEOUT, launch timed out)",
1422 ] {
1423 let err = anyhow::anyhow!(message);
1424 assert!(is_fatal_cuda_error(&err), "not classified: {message}");
1425 }
1426
1427 assert!(!is_fatal_cuda_error(&anyhow::anyhow!(
1428 "DriverError(CUDA_ERROR_OUT_OF_MEMORY, out of memory)"
1429 )));
1430 assert!(!is_fatal_cuda_error(&anyhow::anyhow!(
1431 "CublasError(CUBLAS_STATUS_NOT_INITIALIZED)"
1432 )));
1433 }
1434
1435 #[tokio::test]
1436 async fn quarantining_worker_signals_process_restart() {
1437 let worker = single_worker_pool_with_parked("flux-dev:q4", Duration::ZERO);
1438
1439 quarantine_poisoned_worker(&worker);
1440
1441 assert!(worker.poisoned.load(Ordering::SeqCst));
1442 assert!(worker.fatal_cuda_error.load(Ordering::SeqCst));
1443 tokio::time::timeout(
1444 Duration::from_millis(50),
1445 worker.fatal_cuda_shutdown.notified(),
1446 )
1447 .await
1448 .expect("fatal CUDA quarantine must wake server shutdown");
1449 }
1450
1451 #[test]
1452 fn quarantine_helper_ignores_ordinary_errors_and_latches_fatal_errors() {
1453 let worker = single_worker_pool_with_parked("flux-dev:q4", Duration::ZERO);
1454
1455 assert!(!quarantine_if_fatal_cuda_error(
1456 &worker,
1457 &anyhow::anyhow!("ordinary inference failure")
1458 ));
1459 assert!(!worker.poisoned.load(Ordering::SeqCst));
1460 assert!(ensure_worker_not_poisoned(&worker, "flux-dev:q4").is_ok());
1461
1462 let fatal =
1463 anyhow::anyhow!("DriverError(CUDA_ERROR_ILLEGAL_ADDRESS, illegal memory access)")
1464 .context("generation failed");
1465 assert!(quarantine_if_fatal_cuda_error(&worker, &fatal));
1466 assert!(worker.poisoned.load(Ordering::SeqCst));
1467 assert_eq!(worker.consecutive_failures.load(Ordering::SeqCst), 3);
1468 assert!(worker.degraded_until.read().unwrap().is_none());
1469 let error = ensure_worker_not_poisoned(&worker, "flux-dev:q4").unwrap_err();
1470 assert!(error.to_string().contains("Restart the mold server"));
1471 }
1472
1473 #[tokio::test]
1474 async fn buffered_job_is_rejected_without_touching_a_poisoned_worker() {
1475 let worker = single_worker_pool_with_parked("flux-dev:q4", Duration::ZERO);
1476 worker.poisoned.store(true, Ordering::SeqCst);
1477 worker.in_flight.store(1, Ordering::SeqCst);
1478
1479 let request = fake_upscale_job(Config::default(), "unused").request;
1480 let (queue_tx, mut queue_rx) = tokio::sync::mpsc::channel(1);
1481 let queue = QueueHandle::new(queue_tx);
1482 let (placeholder_tx, _placeholder_rx) = tokio::sync::oneshot::channel();
1483 queue
1484 .submit(
1485 GenerationJob {
1486 id: "placeholder".to_string(),
1487 request: request.clone(),
1488 completion_payload: SseCompletionPayload::Full,
1489 progress_tx: None,
1490 result_tx: placeholder_tx,
1491 output_dir: None,
1492 },
1493 1,
1494 )
1495 .await
1496 .unwrap();
1497
1498 let registry = JobRegistry::new();
1499 registry.register("buffered-job", request.model.clone());
1500 let (progress_tx, mut progress_rx) = tokio::sync::mpsc::unbounded_channel();
1501 let (result_tx, result_rx) = tokio::sync::oneshot::channel();
1502 process_job(
1503 &worker,
1504 GpuJob {
1505 id: "buffered-job".to_string(),
1506 model: request.model.clone(),
1507 request,
1508 completion_payload: SseCompletionPayload::Full,
1509 progress_tx: Some(progress_tx),
1510 result_tx,
1511 output_dir: None,
1512 config: Arc::new(tokio::sync::RwLock::new(Config::default())),
1513 metadata_db: Arc::new(None),
1514 queue: queue.clone(),
1515 registry: registry.clone(),
1516 events: crate::events::EventBroadcaster::new(),
1517 },
1518 );
1519
1520 let result = match result_rx.await.unwrap() {
1521 Err(error) => error,
1522 Ok(_) => panic!("poisoned worker unexpectedly completed buffered job"),
1523 };
1524 assert!(result.contains("worker was quarantined"));
1525 assert!(matches!(
1526 progress_rx.recv().await,
1527 Some(SseMessage::Error(_))
1528 ));
1529 assert_eq!(worker.in_flight.load(Ordering::SeqCst), 0);
1530 assert_eq!(queue.pending(), 0);
1531 assert!(registry.snapshot().entries.is_empty());
1532 assert!(worker.model_cache.lock().unwrap().contains("flux-dev:q4"));
1533 drop(queue_rx.recv().await);
1534 }
1535
1536 #[test]
1537 fn poisoned_worker_rejects_admin_and_chain_entry_points() {
1538 let worker = single_worker_pool_with_parked("fake-model", Duration::ZERO);
1539 worker.poisoned.store(true, Ordering::SeqCst);
1540 let config = Config::default();
1541
1542 let load_error = load_blocking(&worker, "fake-model", &config).unwrap_err();
1543 assert!(load_error.to_string().contains("worker was quarantined"));
1544
1545 let closure_ran = AtomicBool::new(false);
1546 let chain_error = run_chain_blocking(
1547 &worker,
1548 "fake-model",
1549 &config,
1550 None,
1551 |_engine| -> anyhow::Result<()> {
1552 closure_ran.store(true, Ordering::SeqCst);
1553 Ok(())
1554 },
1555 )
1556 .unwrap_err();
1557 assert!(chain_error.to_string().contains("worker was quarantined"));
1558 assert!(!closure_ran.load(Ordering::SeqCst));
1559 assert!(worker.model_cache.lock().unwrap().contains("fake-model"));
1560 }
1561
1562 #[test]
1563 fn worker_post_upscale_reports_missing_downloaded_model() {
1564 let worker = single_worker_pool_with_parked("flux-dev:q4", Duration::ZERO);
1565 let job = fake_upscale_job(Config::default(), "real-esrgan-x4plus:fp16");
1566 let mut response = GenerateResponse {
1567 images: vec![],
1568 video: None,
1569 generation_time_ms: 10,
1570 model: job.request.model.clone(),
1571 seed_used: 7,
1572 gpu: None,
1573 };
1574
1575 let err = upscale_generated_image_on_worker(
1576 &worker,
1577 &job,
1578 "real-esrgan-x4plus:fp16",
1579 fake_upscale_image(),
1580 &mut response,
1581 )
1582 .expect_err("worker should reject a missing upscaler config");
1583
1584 assert!(err.contains("not downloaded"), "got: {err}");
1585 }
1586
1587 #[test]
1588 fn worker_post_upscale_surfaces_missing_weights_path() {
1589 let worker = single_worker_pool_with_parked("flux-dev:q4", Duration::ZERO);
1590 let tmp = tempfile::TempDir::new().unwrap();
1591 let missing_weights = tmp.path().join("missing-upscaler.safetensors");
1592 let mut config = Config::default();
1593 config.models.insert(
1594 "real-esrgan-x4plus:fp16".to_string(),
1595 ModelConfig {
1596 transformer: Some(missing_weights.display().to_string()),
1597 ..Default::default()
1598 },
1599 );
1600 let job = fake_upscale_job(config, "real-esrgan-x4plus:fp16");
1601 let mut response = GenerateResponse {
1602 images: vec![],
1603 video: None,
1604 generation_time_ms: 10,
1605 model: job.request.model.clone(),
1606 seed_used: 7,
1607 gpu: None,
1608 };
1609
1610 let err = upscale_generated_image_on_worker(
1611 &worker,
1612 &job,
1613 "real-esrgan-x4plus:fp16",
1614 fake_upscale_image(),
1615 &mut response,
1616 )
1617 .expect_err("worker should surface missing weight files before generation completes");
1618
1619 assert!(err.contains("failed to load upscaler"), "got: {err}");
1620 assert!(err.contains("upscaler weights not found"), "got: {err}");
1621 }
1622
1623 #[test]
1624 fn run_chain_blocking_quarantines_fatal_cuda_closure_error() {
1625 let worker = single_worker_pool_with_parked("fake-model", Duration::ZERO);
1626 let config = Config::default();
1627
1628 let result = run_chain_blocking(&worker, "fake-model", &config, None, |_engine| {
1629 Err::<(), _>(
1630 anyhow::anyhow!("DriverError(CUDA_ERROR_ILLEGAL_ADDRESS, illegal memory access)")
1631 .context("stage render failed"),
1632 )
1633 })
1634 .expect("engine preparation should succeed");
1635
1636 assert!(result.is_err());
1637 assert!(worker.poisoned.load(Ordering::SeqCst));
1638 assert!(worker.fatal_cuda_error.load(Ordering::SeqCst));
1639 assert!(worker.model_cache.lock().unwrap().is_empty());
1640 }
1641
1642 #[test]
1647 fn run_chain_blocking_serializes_same_worker() {
1648 let worker = single_worker_pool_with_parked("fake-model", Duration::from_millis(30));
1649 let config = Config::default();
1650
1651 let active = Arc::new(AtomicUsize::new(0));
1652 let max_concurrent = Arc::new(AtomicUsize::new(0));
1653
1654 let instrumented = |active: Arc<AtomicUsize>, max_concurrent: Arc<AtomicUsize>| {
1655 move |_engine: &mut dyn InferenceEngine| -> anyhow::Result<()> {
1656 let now = active.fetch_add(1, Ordering::SeqCst) + 1;
1657 max_concurrent.fetch_max(now, Ordering::SeqCst);
1658 std::thread::sleep(Duration::from_millis(50));
1659 active.fetch_sub(1, Ordering::SeqCst);
1660 Ok(())
1661 }
1662 };
1663
1664 let worker_a = worker.clone();
1665 let config_a = config.clone();
1666 let a = active.clone();
1667 let m = max_concurrent.clone();
1668 let t_a = std::thread::spawn(move || {
1669 run_chain_blocking(&worker_a, "fake-model", &config_a, None, instrumented(a, m))
1670 .expect("prep ok")
1671 .expect("closure ok");
1672 });
1673
1674 let worker_b = worker.clone();
1675 let config_b = config.clone();
1676 let a = active.clone();
1677 let m = max_concurrent.clone();
1678 let t_b = std::thread::spawn(move || {
1679 run_chain_blocking(&worker_b, "fake-model", &config_b, None, instrumented(a, m))
1680 .expect("prep ok")
1681 .expect("closure ok");
1682 });
1683
1684 t_a.join().unwrap();
1685 t_b.join().unwrap();
1686
1687 assert_eq!(
1688 max_concurrent.load(Ordering::SeqCst),
1689 1,
1690 "two concurrent run_chain_blocking calls must serialize on worker.model_load_lock"
1691 );
1692 }
1693
1694 #[test]
1701 fn is_cuda_oom_detects_driver_error_string() {
1702 let oom_err = anyhow::anyhow!(r#"DriverError(CUDA_ERROR_OUT_OF_MEMORY, "out of memory")"#);
1703 assert!(
1704 is_cuda_oom(&oom_err),
1705 "must detect CUDA_ERROR_OUT_OF_MEMORY in anyhow error chain"
1706 );
1707 }
1708
1709 #[test]
1711 fn is_cuda_oom_does_not_trigger_on_regular_errors() {
1712 let reg_err = anyhow::anyhow!("safetensors file not found");
1713 assert!(
1714 !is_cuda_oom(®_err),
1715 "non-OOM error must not be classified as OOM"
1716 );
1717 }
1718
1719 #[test]
1723 fn runtime_oom_message_suggests_offload_and_smaller_frames() {
1724 let msg = oom_user_message("ltx-video-0.9.8-13b-dev:bf16");
1725 assert!(
1726 msg.contains("frames") || msg.contains("width") || msg.contains("quantized"),
1727 "OOM message must suggest reducing frames, resolution, or using a \
1728 quantized variant; got: {msg}",
1729 );
1730 assert!(
1731 !msg.contains("CUDA_ERROR_OUT_OF_MEMORY"),
1732 "OOM user message must not expose the raw CUDA driver error string; \
1733 got: {msg}",
1734 );
1735 assert!(
1736 msg.contains("ltx-video-0.9.8-13b-dev:bf16"),
1737 "OOM message must include the model name so the user knows what failed; \
1738 got: {msg}",
1739 );
1740 }
1741
1742 #[test]
1743 fn runtime_oom_message_for_sd15_1024_mentions_resolution_not_frames() {
1744 let req: GenerateRequest = serde_json::from_str(
1745 r#"{"prompt":"portrait","model":"realistic-vision-v5:fp16","width":1024,"height":1024,"steps":25,"guidance":7.5,"batch_size":1}"#,
1746 )
1747 .unwrap();
1748
1749 let msg =
1750 oom_user_message_for_request("realistic-vision-v5:fp16", Some("sd15"), Some(&req));
1751
1752 assert!(
1753 msg.contains("1024x1024"),
1754 "image OOM message should mention the requested resolution; got: {msg}"
1755 );
1756 assert!(
1757 msg.contains("512x512"),
1758 "SD1.5 OOM message should point back to the native/default size; got: {msg}"
1759 );
1760 assert!(
1761 msg.contains("checkpoint") || msg.contains("model file"),
1762 "OOM message should explain why file size is not peak VRAM; got: {msg}"
1763 );
1764 assert!(
1765 !msg.contains("--frames"),
1766 "image OOM message must not suggest video frame-count fixes; got: {msg}"
1767 );
1768 }
1769
1770 #[test]
1771 fn runtime_oom_message_for_ltx_keeps_frame_guidance() {
1772 let req: GenerateRequest = serde_json::from_str(
1773 r#"{"prompt":"camera pan","model":"ltx-video-0.9.8-13b-dev:bf16","width":768,"height":512,"steps":25,"guidance":3.5,"batch_size":1,"frames":25}"#,
1774 )
1775 .unwrap();
1776
1777 let msg = oom_user_message_for_request(
1778 "ltx-video-0.9.8-13b-dev:bf16",
1779 Some("ltx-video"),
1780 Some(&req),
1781 );
1782
1783 assert!(
1784 msg.contains("--frames") && msg.contains("25"),
1785 "video OOM message should keep frame-count guidance; got: {msg}"
1786 );
1787 assert!(
1788 msg.contains("768x512"),
1789 "video OOM message should mention the requested resolution; got: {msg}"
1790 );
1791 }
1792
1793 #[test]
1806 fn failed_load_does_not_leak_into_model_cache() {
1807 struct FailingLoadEngine {
1809 name: String,
1810 }
1811 impl InferenceEngine for FailingLoadEngine {
1812 fn generate(&mut self, _: &GenerateRequest) -> anyhow::Result<GenerateResponse> {
1813 unreachable!()
1814 }
1815 fn model_name(&self) -> &str {
1816 &self.name
1817 }
1818 fn is_loaded(&self) -> bool {
1819 false
1820 }
1821 fn load(&mut self) -> anyhow::Result<()> {
1822 anyhow::bail!(r#"DriverError(CUDA_ERROR_OUT_OF_MEMORY, "out of memory")"#)
1823 }
1824 fn unload(&mut self) {}
1825 }
1826
1827 let cache = ModelCache::new(3);
1828 let model_name = "ltx-video-0.9.8-13b-dev:bf16";
1829
1830 let mut engine: Box<dyn InferenceEngine> = Box::new(FailingLoadEngine {
1834 name: model_name.to_string(),
1835 });
1836 let load_result = engine.load();
1837
1838 assert!(
1839 load_result.is_err(),
1840 "engine.load() must fail for this test to be meaningful"
1841 );
1842 assert!(
1843 is_cuda_oom(load_result.as_ref().unwrap_err()),
1844 "load error must be classified as OOM"
1845 );
1846
1847 assert!(
1850 !cache.contains(model_name),
1851 "cache must not contain the model after a failed load — \
1852 `insert_loaded` must only be called on success"
1853 );
1854 assert!(
1855 cache.is_empty(),
1856 "cache must be completely empty after a failed load"
1857 );
1858 }
1859}