Skip to main content

mold_server/
gpu_worker.rs

1use crate::gpu_pool::{ActiveGeneration, GpuJob, GpuWorker};
2use crate::model_cache::ModelResidency;
3use crate::queue::{
4    apply_output_dimensions_to_metadata, apply_upscale_response_to_image_generation,
5    build_sse_complete_event, clean_error_message, save_image_to_dir, save_video_to_dir,
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
17/// Spawn the dedicated OS thread for a GPU worker.
18/// Returns the JoinHandle (caller should keep it alive).
19pub 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            // Bind this thread to its GPU ordinal so `create_device` /
27            // `reclaim_gpu_memory` can debug-assert callers don't drift onto
28            // a sibling GPU's context. See device::init_thread_gpu_ordinal.
29            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
43/// Convert an inference-crate progress event to an SSE wire event.
44fn progress_to_sse(event: mold_inference::ProgressEvent) -> SseProgressEvent {
45    event.into()
46}
47
48/// Detect a CUDA out-of-memory error anywhere in the anyhow cause chain.
49///
50/// Candle surfaces these as `DriverError(CUDA_ERROR_OUT_OF_MEMORY, …)` wrapped
51/// in an `anyhow::Error`. The string representation is the only stable signal
52/// (the cudarc error type doesn't implement `std::error::Error` downcast target
53/// in the candle re-export), so we pattern-match the formatted chain.
54pub(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
59/// Build a user-friendly error message for a CUDA OOM. The raw
60/// `DriverError(CUDA_ERROR_OUT_OF_MEMORY, …)` is opaque; replace it with
61/// actionable guidance.
62pub(crate) fn oom_user_message(model_name: &str) -> String {
63    oom_user_message_for_request(model_name, None, None)
64}
65
66pub(crate) fn oom_user_message_for_request(
67    model_name: &str,
68    family_slug: Option<&str>,
69    req: Option<&mold_core::GenerateRequest>,
70) -> String {
71    let requested_size = req
72        .map(|r| format!(" Requested size: {}x{}.", r.width, r.height))
73        .unwrap_or_default();
74    let batch_hint = match req.map(|r| r.batch_size).unwrap_or(1) {
75        0 | 1 => "keep --batch 1".to_string(),
76        n => format!("reduce --batch {n} to --batch 1"),
77    };
78
79    if family_slug.is_some_and(is_video_family) || req.and_then(|r| r.frames).is_some() {
80        let frames_hint = req
81            .and_then(|r| r.frames)
82            .map(|frames| format!("reduce --frames below {frames} (e.g. 17 or 9)"))
83            .unwrap_or_else(|| "reduce --frames (e.g. 17 or 9)".to_string());
84        return format!(
85            "GPU ran out of memory loading or running '{model_name}'.{requested_size} \
86             Try: {frames_hint}, lower --width/--height, use a quantized variant \
87             if available, or close other GPU apps."
88        );
89    }
90
91    let family_note = match family_slug {
92        Some("sd15") => {
93            if req.is_some_and(|r| r.width == 1024 && r.height == 1024) {
94                " SD1.5 defaults to 512x512; 1024x1024 is 4x the pixels and can OOM \
95                 even when the checkpoint file is only a few GB."
96            } else {
97                " SD1.5 defaults to 512x512; larger sizes multiply activation and \
98                 VAE workspace beyond the checkpoint file size."
99            }
100        }
101        Some("sdxl") => {
102            " SDXL's usual 1024x1024 size still needs activation and VAE workspace \
103             beyond the checkpoint file size."
104        }
105        Some("sd3") => " SD3 needs activation and VAE workspace beyond the checkpoint file size.",
106        Some("flux")
107        | Some("flux2")
108        | Some("qwen-image")
109        | Some("qwen-image-edit")
110        | Some("z-image")
111        | Some("wuerstchen") => {
112            " The checkpoint size is only the weights; peak VRAM also includes \
113             activations, VAE decode workspace, CUDA workspaces, and resident cache."
114        }
115        _ => {
116            " The model file size is only the weights; peak VRAM also includes \
117             activations, decoder workspace, CUDA workspaces, and resident cache."
118        }
119    };
120    let resolution_hint = match family_slug {
121        Some("sd15") => "lower --width/--height (try 768x768 or 512x512)",
122        _ => "lower --width/--height",
123    };
124
125    format!(
126        "GPU ran out of memory loading or running '{model_name}'.{requested_size}{family_note} \
127         Try: {resolution_hint}, {batch_hint}, use a smaller/quantized variant if \
128         this model provides one, run mold unload, or close other GPU apps."
129    )
130}
131
132fn is_video_family(family_slug: &str) -> bool {
133    matches!(family_slug, "ltx-video" | "ltx2" | "ltx-2" | "ltx-2.3")
134}
135
136fn upscale_generated_image_on_worker(
137    worker: &GpuWorker,
138    job: &GpuJob,
139    upscale_model: &str,
140    img: ImageData,
141    response: &mut mold_core::GenerateResponse,
142) -> Result<ImageData, String> {
143    let model_name = mold_core::manifest::resolve_model_name(upscale_model);
144    let weights_path = {
145        let config = job.config.blocking_read();
146        config
147            .models
148            .get(&model_name)
149            .and_then(|c| c.transformer.as_ref())
150            .map(std::path::PathBuf::from)
151    }
152    .ok_or_else(|| format!("upscaler model '{model_name}' is not downloaded"))?;
153
154    if let Some(ref tx) = job.progress_tx {
155        let _ = tx.send(SseMessage::Progress(SseProgressEvent::StageStart {
156            name: format!("Loading upscaler {model_name}"),
157        }));
158    }
159    let mut engine = mold_inference::create_upscale_engine(
160        model_name.clone(),
161        weights_path,
162        mold_inference::LoadStrategy::Eager,
163        worker.gpu.ordinal,
164    )
165    .map_err(|e| format!("failed to load upscaler: {e}"))?;
166    if let Some(ref tx) = job.progress_tx {
167        let tx = tx.clone();
168        engine.set_on_progress(Box::new(move |event| {
169            let _ = tx.send(SseMessage::Progress(progress_to_sse(event)));
170        }));
171    }
172    let req = mold_core::UpscaleRequest {
173        model: model_name,
174        image: img.data.clone(),
175        output_format: img.format,
176        tile_size: None,
177    };
178    let upscaled = engine
179        .upscale(&req)
180        .map_err(|e| format!("upscale failed: {e}"))?;
181    engine.clear_on_progress();
182    apply_upscale_response_to_image_generation(&job.request, response, img, upscaled)
183        .map_err(|e| format!("upscale failed: {e}"))
184}
185
186fn cuda_oom_user_message(
187    worker: &GpuWorker,
188    model_name: &str,
189    family_slug: Option<&str>,
190    req: Option<&mold_core::GenerateRequest>,
191) -> (String, bool) {
192    let base = if family_slug.is_none() && req.is_none() {
193        oom_user_message(model_name)
194    } else {
195        oom_user_message_for_request(model_name, family_slug, req)
196    };
197    let outcome = crate::gpu_pool::record_model_cuda_oom(model_name, worker.gpu.ordinal);
198    if outcome.is_unschedulable() {
199        if let Some(cooldown) = crate::gpu_pool::model_unschedulable_message(model_name) {
200            return (format!("{base} {cooldown}"), false);
201        }
202    }
203    (base, true)
204}
205
206fn process_job(worker: &GpuWorker, job: GpuJob) {
207    let model_name = job.model.clone();
208    let ordinal = worker.gpu.ordinal;
209    let job_id = job.id.clone();
210
211    // Release the global queue slot AND the registry entry when this job
212    // finishes, regardless of which exit path runs. The dispatcher only
213    // decrements when it *fails* to dispatch — once we own the GpuJob, we
214    // own both pieces of cleanup. Combining them in one drop guard keeps
215    // the two counters from drifting on early-return paths.
216    struct CleanupGuard {
217        queue: crate::state::QueueHandle,
218        registry: crate::job_registry::SharedJobRegistry,
219        id: String,
220    }
221    impl Drop for CleanupGuard {
222        fn drop(&mut self) {
223            self.queue.decrement();
224            self.registry.remove(&self.id);
225        }
226    }
227    let _cleanup = CleanupGuard {
228        queue: job.queue.clone(),
229        registry: job.registry.clone(),
230        id: job_id.clone(),
231    };
232
233    if job.result_tx.is_closed() {
234        tracing::debug!(gpu = ordinal, model = %model_name, "skipping dispatched job — client disconnected");
235        worker.in_flight.fetch_sub(1, Ordering::SeqCst);
236        return;
237    }
238
239    // Mark the registry entry as running on this specific GPU. The /api/queue
240    // listing now shows this row as `state: "running"` with `gpu: <ordinal>`.
241    job.registry.mark_running(&job_id, Some(ordinal));
242
243    tracing::info!(gpu = ordinal, model = %model_name, "dispatched job");
244
245    // Acquire per-GPU load lock — ensures only one model load at a time per GPU.
246    let _load_lock = worker.model_load_lock.lock().unwrap();
247
248    // Ensure model is loaded on this GPU.
249    let config_snapshot = job.config.blocking_read().clone();
250    let family_slug = crate::model_manager::family_for_model_sync(&model_name, &config_snapshot);
251    let activation_hint =
252        crate::model_manager::activation_hint_for_request_sync(&config_snapshot, &job.request);
253    let request_has_lora = crate::model_manager::request_has_effective_lora(&job.request);
254    if let Err(e) = ensure_model_ready_sync(
255        worker,
256        &model_name,
257        &config_snapshot,
258        activation_hint,
259        request_has_lora,
260    ) {
261        tracing::error!(gpu = ordinal, model = %model_name, "Failed to load model: {e}");
262        // Detect CUDA OOM during load: synchronize the device so subsequent
263        // allocations don't inherit a poisoned context, then surface a
264        // user-friendly message instead of the opaque DriverError string.
265        let is_oom = is_cuda_oom(&e);
266        let (err_msg, count_worker_failure) = if is_oom {
267            mold_inference::device::try_synchronize_device(ordinal);
268            cuda_oom_user_message(
269                worker,
270                &model_name,
271                family_slug.as_deref(),
272                Some(&job.request),
273            )
274        } else {
275            (
276                format!("model load error: {}", clean_error_message(&e)),
277                true,
278            )
279        };
280        if let Some(ref tx) = job.progress_tx {
281            let _ = tx.send(SseMessage::Error(SseErrorEvent {
282                message: err_msg.clone(),
283            }));
284        }
285        let _ = job.result_tx.send(Err(err_msg));
286        worker.in_flight.fetch_sub(1, Ordering::SeqCst);
287        if count_worker_failure {
288            record_failure(worker);
289        }
290        return;
291    }
292
293    // Set active generation state.
294    {
295        let mut gen = worker.active_generation.write().unwrap();
296        *gen = Some(ActiveGeneration {
297            model: model_name.clone(),
298            prompt_sha256: format!("{:x}", Sha256::digest(job.request.prompt.as_bytes())),
299            started_at_unix_ms: SystemTime::now()
300                .duration_since(UNIX_EPOCH)
301                .unwrap_or_default()
302                .as_millis() as u64,
303            started_at: Instant::now(),
304        });
305    }
306
307    if job.result_tx.is_closed() {
308        tracing::debug!(
309            gpu = ordinal,
310            model = %model_name,
311            "skipping generation after model readiness — client disconnected"
312        );
313        worker.in_flight.fetch_sub(1, Ordering::SeqCst);
314        clear_active_generation(worker);
315        return;
316    }
317
318    // Take-and-restore: remove engine from cache, release lock during inference.
319    let taken = {
320        let mut cache = worker.model_cache.lock().unwrap();
321        cache.take(&model_name)
322    };
323
324    let Some(mut cached_engine) = taken else {
325        let err_msg = "engine not found in cache after load".to_string();
326        if let Some(ref tx) = job.progress_tx {
327            let _ = tx.send(SseMessage::Error(SseErrorEvent {
328                message: err_msg.clone(),
329            }));
330        }
331        let _ = job.result_tx.send(Err(err_msg));
332        worker.in_flight.fetch_sub(1, Ordering::SeqCst);
333        clear_active_generation(worker);
334        return;
335    };
336
337    // Set progress callback if SSE streaming.
338    if let Some(ref progress_tx) = job.progress_tx {
339        let tx = progress_tx.clone();
340        cached_engine.engine.set_on_progress(Box::new(move |event| {
341            let _ = tx.send(SseMessage::Progress(progress_to_sse(event)));
342        }));
343    }
344
345    // RSS sample taken just before inference; the post-inference sample below
346    // logs the per-job delta so RAM growth can be attributed to a specific
347    // generation rather than tracked at process granularity.
348    let rss_before = crate::resources::ram_snapshot().used_by_mold;
349
350    // Watchdog: log RSS every 1s while inference runs so we can see RAM
351    // growth as it happens. The post-inference summary log can't fire when
352    // a runaway allocation crosses the OOM threshold mid-generation, so we
353    // need a heartbeat to attribute the explosion to a specific phase.
354    let watchdog_stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
355    let watchdog_handle = {
356        let stop = watchdog_stop.clone();
357        let model = model_name.clone();
358        std::thread::Builder::new()
359            .name(format!("rss-watchdog-{ordinal}"))
360            .spawn(move || {
361                let start = Instant::now();
362                while !stop.load(Ordering::SeqCst) {
363                    std::thread::sleep(Duration::from_millis(1000));
364                    if stop.load(Ordering::SeqCst) {
365                        break;
366                    }
367                    let rss = crate::resources::ram_snapshot().used_by_mold;
368                    tracing::info!(
369                        gpu = ordinal,
370                        model = %model,
371                        elapsed_s = start.elapsed().as_secs(),
372                        rss_mb = rss / 1_000_000,
373                        "rss watchdog"
374                    );
375                }
376            })
377            .expect("failed to spawn RSS watchdog")
378    };
379
380    // Run inference — cache mutex is FREE during this.
381    let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
382        cached_engine.engine.generate(&job.request)
383    }));
384
385    watchdog_stop.store(true, Ordering::SeqCst);
386    let _ = watchdog_handle.join();
387
388    // glibc keeps freed pages in per-arena heaps even after the allocations
389    // are dropped — large transient buffers from GGUF+LoRA rebuilds can leave
390    // tens of GB of unreclaimed RSS. `malloc_trim(0)` walks the arenas and
391    // returns idle pages to the OS via madvise(MADV_DONTNEED). Cheap (~ms),
392    // glibc-only, gated so we can A/B with `MOLD_MALLOC_TRIM=0`.
393    let trim_enabled = std::env::var("MOLD_MALLOC_TRIM")
394        .map(|v| v != "0")
395        .unwrap_or(true);
396    let rss_pre_trim = if trim_enabled {
397        let v = crate::resources::ram_snapshot().used_by_mold;
398        #[cfg(target_os = "linux")]
399        unsafe {
400            libc::malloc_trim(0);
401        }
402        Some(v)
403    } else {
404        None
405    };
406
407    let rss_after = crate::resources::ram_snapshot().used_by_mold;
408    let rss_delta = rss_after as i64 - rss_before as i64;
409    tracing::info!(
410        gpu = ordinal,
411        model = %model_name,
412        rss_before_mb = rss_before / 1_000_000,
413        rss_after_mb = rss_after / 1_000_000,
414        rss_delta_mb = rss_delta / 1_000_000,
415        rss_pre_trim_mb = rss_pre_trim.map(|v| v / 1_000_000).unwrap_or(0),
416        "generation memory delta"
417    );
418
419    // Clear progress callback.
420    cached_engine.engine.clear_on_progress();
421
422    // Restore engine to cache.
423    {
424        let mut cache = worker.model_cache.lock().unwrap();
425        cache.restore(cached_engine);
426    }
427
428    // Clear active generation.
429    clear_active_generation(worker);
430
431    // Decrement in-flight.
432    worker.in_flight.fetch_sub(1, Ordering::SeqCst);
433
434    match result {
435        Ok(Ok(mut response)) => {
436            // Reset failure counter on success.
437            worker.consecutive_failures.store(0, Ordering::SeqCst);
438            crate::gpu_pool::clear_model_cuda_oom(&model_name);
439
440            // Attach GPU ordinal to response.
441            response.gpu = Some(ordinal);
442
443            if response.images.is_empty() && response.video.is_none() {
444                let err_msg = "generation error: engine returned no images or video".to_string();
445                if let Some(ref tx) = job.progress_tx {
446                    let _ = tx.send(SseMessage::Error(SseErrorEvent {
447                        message: err_msg.clone(),
448                    }));
449                }
450                let _ = job.result_tx.send(Err(err_msg));
451                return;
452            }
453
454            // Extract the primary image (or video thumbnail).
455            let mut img = if !response.images.is_empty() {
456                response.images.remove(0)
457            } else if let Some(ref video) = response.video {
458                ImageData {
459                    data: video.thumbnail.clone(),
460                    format: OutputFormat::Png,
461                    width: video.width,
462                    height: video.height,
463                    index: 0,
464                }
465            } else {
466                unreachable!("checked above");
467            };
468
469            if response.video.is_none() {
470                if let Some(upscale_model) = job
471                    .request
472                    .upscale_model
473                    .as_deref()
474                    .map(str::trim)
475                    .filter(|m| !m.is_empty())
476                {
477                    match upscale_generated_image_on_worker(
478                        worker,
479                        &job,
480                        upscale_model,
481                        img.clone(),
482                        &mut response,
483                    ) {
484                        Ok(upscaled) => img = upscaled,
485                        Err(err_msg) => {
486                            if let Some(ref tx) = job.progress_tx {
487                                let _ = tx.send(SseMessage::Error(SseErrorEvent {
488                                    message: err_msg.clone(),
489                                }));
490                            }
491                            let _ = job.result_tx.send(Err(err_msg));
492                            return;
493                        }
494                    }
495                }
496            }
497
498            // Save to output directory if configured. Routes through the
499            // shared queue helpers so the metadata-DB upsert (and embedded
500            // chunks, hostname/backend tagging) cannot drift between the
501            // single-GPU and multi-GPU paths — historically this branch
502            // skipped the DB write, which left freshly-generated files
503            // invisible to /api/gallery until the next reconcile on
504            // server restart.
505            if let Some(ref dir) = job.output_dir {
506                let mut metadata = OutputMetadata::from_generate_request(
507                    &job.request,
508                    response.seed_used,
509                    None,
510                    mold_core::build_info::version_string(),
511                );
512                if response.video.is_none() {
513                    apply_output_dimensions_to_metadata(&mut metadata, &img);
514                }
515                let generation_time_ms = response.generation_time_ms as i64;
516                let db = job.metadata_db.as_ref().as_ref();
517                let events = Some(job.events.as_ref());
518                if let Some(ref video) = response.video {
519                    save_video_to_dir(
520                        dir,
521                        &video.data,
522                        &video.gif_preview,
523                        video.format,
524                        &job.model,
525                        &metadata,
526                        Some(generation_time_ms),
527                        db,
528                        events,
529                    );
530                } else {
531                    save_image_to_dir(
532                        dir,
533                        &img,
534                        &job.model,
535                        job.request.batch_size,
536                        Some(&metadata),
537                        Some(generation_time_ms),
538                        db,
539                        events,
540                    );
541                }
542            }
543
544            // Send SSE complete event. Video responses carry the actual MP4 /
545            // GIF bytes plus frames / fps / thumbnail / audio metadata so the
546            // SSE client can reconstruct a `VideoData` — without this the
547            // Discord bot silently degraded every LTX-Video / LTX-2 response
548            // into an image attachment (the synthesized thumbnail PNG).
549            if let Some(ref tx) = job.progress_tx {
550                let event = build_sse_complete_event(&response, &img);
551                let _ = tx.send(SseMessage::Complete(event));
552            }
553
554            // Send result through oneshot.
555            let _ = job.result_tx.send(Ok(GenerationJobResult {
556                image: img,
557                response,
558            }));
559        }
560        Ok(Err(e)) => {
561            tracing::warn!(gpu = ordinal, model = %model_name, "Generation failed: {e}");
562            // Detect CUDA OOM during inference: synchronize so subsequent
563            // allocations start from a clean CUDA context state, then surface
564            // a user-friendly message instead of the opaque DriverError string.
565            let is_oom = is_cuda_oom(&e);
566            let (err_msg, count_worker_failure) = if is_oom {
567                mold_inference::device::try_synchronize_device(ordinal);
568                cuda_oom_user_message(
569                    worker,
570                    &model_name,
571                    family_slug.as_deref(),
572                    Some(&job.request),
573                )
574            } else {
575                (
576                    format!("generation error: {}", clean_error_message(&e)),
577                    true,
578                )
579            };
580            if count_worker_failure {
581                record_failure(worker);
582            }
583            if let Some(ref tx) = job.progress_tx {
584                let _ = tx.send(SseMessage::Error(SseErrorEvent {
585                    message: err_msg.clone(),
586                }));
587            }
588            let _ = job.result_tx.send(Err(err_msg));
589        }
590        Err(panic_payload) => {
591            tracing::error!(gpu = ordinal, model = %model_name, "Inference panicked");
592            record_failure(worker);
593            let msg = panic_payload
594                .downcast_ref::<String>()
595                .map(|s| s.as_str())
596                .or_else(|| panic_payload.downcast_ref::<&str>().copied())
597                .unwrap_or("unknown panic");
598            let err_msg = format!("inference panicked: {msg}");
599            if let Some(ref tx) = job.progress_tx {
600                let _ = tx.send(SseMessage::Error(SseErrorEvent {
601                    message: err_msg.clone(),
602                }));
603            }
604            let _ = job.result_tx.send(Err(err_msg));
605        }
606    }
607}
608
609/// Preflight memory check with evict-to-fit recovery.
610///
611/// Wraps `model_manager::preflight_memory_guard`. On a budget failure, drops
612/// the LRU parked entry (skipping `model_name` so a parked-reload doesn't
613/// evict its own target), reclaims the GPU's CUDA pool when no engine remains
614/// resident, and retries. Loops until the preflight passes or the cache has
615/// no parked entries left to surrender — at which point the original
616/// insufficient-memory error is returned.
617///
618/// Holds the cache lock only for the brief eviction step; the engine drop and
619/// `reclaim_gpu_memory` run outside it. The caller is expected to hold
620/// `worker.model_load_lock`, which keeps a concurrent generation from slotting
621/// a fresh load into the context between our reclaim and the actual load.
622fn preflight_memory_guard_with_eviction(
623    cache_lock: &std::sync::Mutex<crate::model_cache::ModelCache>,
624    model_name: &str,
625    paths: &ModelPaths,
626    ordinal: usize,
627    hint: Option<crate::model_manager::ActivationHint>,
628) -> Result<(), crate::routes::ApiError> {
629    loop {
630        let active_vram = cache_lock
631            .lock()
632            .unwrap_or_else(|e| e.into_inner())
633            .active_vram_bytes();
634        let err = match crate::model_manager::preflight_memory_guard(
635            model_name,
636            paths,
637            active_vram,
638            ordinal,
639            hint,
640        ) {
641            Ok(()) => return Ok(()),
642            Err(e) => e,
643        };
644
645        let evicted = {
646            let mut cache = cache_lock.lock().unwrap_or_else(|e| e.into_inner());
647            cache.evict_lru_parked_except(Some(model_name))
648        };
649        let Some((evicted_name, engine)) = evicted else {
650            return Err(err);
651        };
652        tracing::info!(
653            gpu = ordinal,
654            target_model = %model_name,
655            evicted_model = %evicted_name,
656            "evicting LRU parked entry to fit incoming load"
657        );
658        // Drop outside the cache lock — `cuMemFree` and safetensor unmap
659        // can block other cache users during the drop.
660        drop(engine);
661
662        // Reclaim only when no GPU-resident engine remains. The parked-reload
663        // case has the active model still on GPU at preflight time, so we can
664        // free CPU caches via the eviction but must not nuke the primary
665        // context. The fresh-load case usually has no active model when this
666        // is hit, so the reclaim can run.
667        let safe_to_reclaim = cache_lock
668            .lock()
669            .unwrap_or_else(|e| e.into_inner())
670            .active_model()
671            .is_none();
672        if safe_to_reclaim {
673            device::reclaim_gpu_memory(ordinal);
674        }
675    }
676}
677
678fn select_load_strategy_for_worker(
679    worker: &GpuWorker,
680    model_name: &str,
681    paths: &ModelPaths,
682    hint: Option<crate::model_manager::ActivationHint>,
683) -> mold_inference::LoadStrategy {
684    let active_vram = worker
685        .model_cache
686        .lock()
687        .unwrap_or_else(|e| e.into_inner())
688        .active_vram_bytes();
689    let available =
690        crate::model_manager::effective_load_available_bytes(active_vram, worker.gpu.ordinal);
691    let strategy = crate::model_manager::select_server_load_strategy_for_device(
692        paths,
693        available,
694        Some(worker.gpu.total_vram_bytes),
695        hint,
696    );
697    if strategy == mold_inference::LoadStrategy::Sequential {
698        tracing::info!(
699            gpu = worker.gpu.ordinal,
700            model = %model_name,
701            "server load strategy degraded to sequential to fit memory budget"
702        );
703    }
704    strategy
705}
706
707/// Ensure a model is loaded on this worker's GPU.
708///
709/// Holds `worker.model_load_lock` implicitly via the caller for generation
710/// jobs; the admin API path acquires it explicitly via `load_blocking`.
711///
712/// `hint` carries the per-request activation budget (resolution + family).
713/// Pass `None` for admin / cache-prewarm loads with no resolution context.
714pub fn ensure_model_ready_sync(
715    worker: &GpuWorker,
716    model_name: &str,
717    config: &Config,
718    hint: Option<crate::model_manager::ActivationHint>,
719    request_has_lora: bool,
720) -> anyhow::Result<()> {
721    let cache = worker.model_cache.lock().unwrap();
722
723    // Already loaded?
724    if let Some(entry) = cache.get(model_name) {
725        if entry.residency == ModelResidency::Gpu {
726            let must_recreate = entry.engine.model_paths().is_some_and(|paths| {
727                crate::model_manager::request_requires_fresh_engine_for_offload_policy(
728                    paths,
729                    hint,
730                    request_has_lora,
731                )
732            });
733            if !must_recreate {
734                return Ok(());
735            }
736        }
737    }
738
739    // Check if we have it cached but not on GPU (Parked).
740    let has_cached = cache.contains(model_name);
741
742    // Snapshot the cached engine's paths (if any) for the preflight before
743    // dropping the lock. Cloning ModelPaths keeps the borrow scoped to this
744    // block. Active-VRAM is sampled inside the preflight helper itself so
745    // each retry sees fresh state.
746    let cached_paths = if has_cached {
747        cache
748            .get(model_name)
749            .and_then(|e| e.engine.model_paths().cloned())
750    } else {
751        None
752    };
753    drop(cache);
754
755    if has_cached {
756        let load_strategy = cached_paths
757            .as_ref()
758            .map(|paths| select_load_strategy_for_worker(worker, model_name, paths, hint))
759            .unwrap_or(mold_inference::LoadStrategy::Eager);
760
761        // Preflight before unloading the active model — the active model's
762        // footprint counts toward effective availability since we're about
763        // to free it. On budget failure, evict-to-fit drops parked entries
764        // (other than `model_name` itself) and retries.
765        if let Some(ref paths) = cached_paths {
766            preflight_memory_guard_with_eviction(
767                &worker.model_cache,
768                model_name,
769                paths,
770                worker.gpu.ordinal,
771                hint,
772            )
773            .map_err(|e| anyhow::anyhow!(e.error))?;
774        }
775
776        // Unload active model first.
777        {
778            let mut cache = worker.model_cache.lock().unwrap();
779            cache.unload_active();
780        }
781        device::reclaim_gpu_memory(worker.gpu.ordinal);
782
783        if load_strategy == mold_inference::LoadStrategy::Sequential {
784            let paths = cached_paths.ok_or_else(|| {
785                anyhow::anyhow!("cached engine for '{model_name}' does not expose model paths")
786            })?;
787            let old_engine = {
788                let mut cache = worker.model_cache.lock().unwrap();
789                cache
790                    .remove(model_name)
791                    .ok_or_else(|| anyhow::anyhow!("cache race: model '{model_name}' vanished"))?
792            };
793
794            let offload = crate::model_manager::server_offload_enabled_for_paths(
795                &paths,
796                hint,
797                request_has_lora,
798            );
799            let resolved_catalog_config =
800                crate::model_manager::resolve_installed_catalog_paths_for_worker(
801                    model_name, config,
802                )
803                .map_err(|e| anyhow::anyhow!(e.error))?
804                .map(|(_, config)| config);
805            let engine_config = resolved_catalog_config.as_ref().unwrap_or(config);
806            let mut engine = match mold_inference::create_engine_with_pool(
807                model_name.to_string(),
808                paths,
809                engine_config,
810                load_strategy,
811                worker.gpu.ordinal,
812                offload,
813                Some(worker.shared_pool.clone()),
814            ) {
815                Ok(engine) => engine,
816                Err(err) => {
817                    let evicted = {
818                        let mut cache = worker.model_cache.lock().unwrap();
819                        cache.insert(old_engine, 0)
820                    };
821                    drop(evicted);
822                    return Err(err);
823                }
824            };
825
826            tracing::info!(
827                gpu = worker.gpu.ordinal,
828                model = %model_name,
829                "recreating cached engine in sequential mode..."
830            );
831            let vram_baseline = device::vram_in_use_bytes(worker.gpu.ordinal);
832            if let Err(err) = engine.load() {
833                let evicted = {
834                    let mut cache = worker.model_cache.lock().unwrap();
835                    cache.insert(old_engine, 0)
836                };
837                drop(evicted);
838                return Err(err);
839            }
840            let vram = device::vram_load_delta(worker.gpu.ordinal, vram_baseline);
841            drop(old_engine);
842            let evicted = {
843                let mut cache = worker.model_cache.lock().unwrap();
844                cache.insert_loaded(model_name.to_string(), engine, vram)
845            };
846            drop(evicted);
847            return Ok(());
848        }
849
850        // Take the engine out and reload it.
851        let mut engine = {
852            let mut cache = worker.model_cache.lock().unwrap();
853            cache
854                .remove(model_name)
855                .ok_or_else(|| anyhow::anyhow!("cache race: model '{model_name}' vanished"))?
856        };
857
858        tracing::info!(
859            gpu = worker.gpu.ordinal,
860            model = %model_name,
861            "reloading cached engine..."
862        );
863        // Sample VRAM baseline before load so we can record the new model's
864        // per-load delta rather than the device-global usage.
865        let vram_baseline = device::vram_in_use_bytes(worker.gpu.ordinal);
866        engine.load()?;
867
868        let vram = device::vram_load_delta(worker.gpu.ordinal, vram_baseline);
869        // Drop any evicted engine OUTSIDE the cache lock — `cuMemFree` and
870        // safetensor unmap during the drop can block other cache users.
871        let evicted = {
872            let mut cache = worker.model_cache.lock().unwrap();
873            cache.insert_loaded(model_name.to_string(), engine, vram)
874        };
875        drop(evicted);
876        return Ok(());
877    }
878
879    // Not in cache — need to create from scratch.
880    // Resolve model paths.
881    let mut resolved_catalog_config = None;
882    let paths = if let Some(paths) = ModelPaths::resolve(model_name, config) {
883        paths
884    } else if let Some((paths, config)) =
885        crate::model_manager::resolve_installed_catalog_paths_for_worker(model_name, config)
886            .map_err(|e| anyhow::anyhow!(e.error))?
887    {
888        resolved_catalog_config = Some(config);
889        paths
890    } else {
891        return Err(
892            if model_name.starts_with("cv:") || model_name.starts_with("hf:") {
893                // Catalog IDs (cv:/hf:) reach this path through the bridge in
894                // `model_manager::install_catalog_model`, which can synthesize a
895                // ModelConfig that's missing a required field (notably `vae`)
896                // when a canonical companion was never pulled. The legacy
897                // "Run: mold pull <id>" message is misleading there because the
898                // primary checkpoint IS on disk — the companion is what's
899                // missing. Surface the catalog-specific guidance instead.
900                anyhow::anyhow!(
901                    "catalog model '{model_name}' has missing required components. \
902                 Re-pull the entry from the catalog so its companions \
903                 (CLIP-L / T5 / VAE) are fetched alongside the primary checkpoint."
904                )
905            } else {
906                anyhow::anyhow!(
907                    "model '{model_name}' is not downloaded. Run: mold pull {model_name}"
908                )
909            },
910        );
911    };
912
913    // Preflight before unloading the active model. Evict-to-fit drops parked
914    // entries on budget failure and retries before giving up.
915    preflight_memory_guard_with_eviction(
916        &worker.model_cache,
917        model_name,
918        &paths,
919        worker.gpu.ordinal,
920        hint,
921    )
922    .map_err(|e| anyhow::anyhow!(e.error))?;
923
924    let load_strategy = select_load_strategy_for_worker(worker, model_name, &paths, hint);
925
926    // Unload active model first.
927    {
928        let mut cache = worker.model_cache.lock().unwrap();
929        cache.unload_active();
930    }
931    device::reclaim_gpu_memory(worker.gpu.ordinal);
932
933    let offload =
934        crate::model_manager::server_offload_enabled_for_paths(&paths, hint, request_has_lora);
935    let engine_config = resolved_catalog_config.as_ref().unwrap_or(config);
936    let mut engine = mold_inference::create_engine_with_pool(
937        model_name.to_string(),
938        paths,
939        engine_config,
940        load_strategy,
941        worker.gpu.ordinal,
942        offload,
943        Some(worker.shared_pool.clone()),
944    )?;
945
946    tracing::info!(
947        gpu = worker.gpu.ordinal,
948        model = %model_name,
949        "loading model..."
950    );
951    // Sample VRAM baseline before load so we can record the new model's
952    // per-load delta rather than the device-global usage.
953    let vram_baseline = device::vram_in_use_bytes(worker.gpu.ordinal);
954    engine.load()?;
955
956    let vram = device::vram_load_delta(worker.gpu.ordinal, vram_baseline);
957    // Drop any evicted engine OUTSIDE the cache lock — `cuMemFree` and
958    // safetensor unmap during the drop can block other cache users.
959    let evicted = {
960        let mut cache = worker.model_cache.lock().unwrap();
961        cache.insert_loaded(model_name.to_string(), engine, vram)
962    };
963    drop(evicted);
964
965    Ok(())
966}
967
968/// Synchronously load a model on this GPU worker for the admin API.
969///
970/// Acquires the per-GPU load lock, then delegates to `ensure_model_ready_sync`.
971/// Intended to be called inside `tokio::task::spawn_blocking`. Uses the
972/// size-only peak (no resolution context) for the preflight — admin loads
973/// don't carry a request shape.
974pub fn load_blocking(worker: &GpuWorker, model_name: &str, config: &Config) -> anyhow::Result<()> {
975    let _lock = worker.model_load_lock.lock().unwrap();
976    ensure_model_ready_sync(worker, model_name, config, None, false)
977}
978
979/// Synchronously unload the currently active model on this GPU worker.
980///
981/// Returns the name of the model that was unloaded, or `None` if the GPU was
982/// already idle.
983pub fn unload_blocking(worker: &GpuWorker) -> Option<String> {
984    let _lock = worker.model_load_lock.lock().unwrap();
985    let unloaded = {
986        let mut cache = worker.model_cache.lock().unwrap();
987        cache.unload_active()
988    };
989    if unloaded.is_some() {
990        device::reclaim_gpu_memory(worker.gpu.ordinal);
991    }
992    unloaded
993}
994
995fn record_failure(worker: &GpuWorker) {
996    let failures = worker.consecutive_failures.fetch_add(1, Ordering::SeqCst) + 1;
997    if failures >= 3 {
998        let mut degraded = worker.degraded_until.write().unwrap();
999        *degraded = Some(Instant::now() + Duration::from_secs(60));
1000        tracing::warn!(
1001            gpu = worker.gpu.ordinal,
1002            "GPU marked degraded after {failures} consecutive failures (60s cooldown)"
1003        );
1004    }
1005}
1006
1007fn clear_active_generation(worker: &GpuWorker) {
1008    let mut gen = worker.active_generation.write().unwrap();
1009    *gen = None;
1010}
1011
1012/// Return type for [`run_chain_blocking`]. The outer `Result` carries
1013/// helper-prep errors (ensure_model_ready + cache take); the inner `Result`
1014/// is whatever the caller's closure returned. Closure errors pass through
1015/// unchanged so the caller can distinguish orchestrator-specific failures
1016/// (StageFailed, Invalid) from prep failures (ensure/cache).
1017pub type ChainPrep<T, E> = Result<Result<T, E>, anyhow::Error>;
1018
1019/// Run a blocking chain operation on a specific GPU worker.
1020///
1021/// Acquires `worker.model_load_lock` for the full duration, binds the current
1022/// thread to `worker.gpu.ordinal` (so `reclaim_gpu_memory` debug asserts are
1023/// satisfied), ensures the model is loaded on GPU, takes the engine out of
1024/// the worker's cache, passes it to `with_engine`, and restores the engine
1025/// unconditionally on both success and closure failure.
1026///
1027/// Safe to call from inside `tokio::task::spawn_blocking`. The calling thread
1028/// can be any thread — the `ThreadGpuGuard` clears the thread-local on return.
1029///
1030/// # Errors
1031///
1032/// Returns `Err(anyhow::Error)` from the outer Result if:
1033/// - `ensure_model_ready_sync` fails (bad config, disk IO, load error).
1034/// - The engine vanishes from the cache between ensure and take (cache race).
1035///
1036/// Returns `Ok(Err(E))` if the closure itself returned an error — caller
1037/// preserves the closure's typed error for precise HTTP status mapping.
1038pub fn run_chain_blocking<T, E>(
1039    worker: &GpuWorker,
1040    model_name: &str,
1041    config: &mold_core::Config,
1042    hint: Option<crate::model_manager::ActivationHint>,
1043    with_engine: impl FnOnce(&mut dyn mold_inference::InferenceEngine) -> Result<T, E>,
1044) -> ChainPrep<T, E> {
1045    // Bind the thread to this worker's ordinal for the duration of the call.
1046    // `reclaim_gpu_memory` inside ensure_model_ready_sync debug-asserts this
1047    // matches its ordinal argument; without it, a stray caller on an unbound
1048    // thread would panic in debug builds.
1049    struct ThreadGpuGuard;
1050    impl Drop for ThreadGpuGuard {
1051        fn drop(&mut self) {
1052            mold_inference::device::clear_thread_gpu_ordinal();
1053        }
1054    }
1055    mold_inference::device::init_thread_gpu_ordinal(worker.gpu.ordinal);
1056    let _thread_gpu = ThreadGpuGuard;
1057
1058    // Acquire the per-worker load lock. Held for the entire chain duration —
1059    // single-clip generations on this worker queue behind us on the same lock.
1060    let _load_lock = worker
1061        .model_load_lock
1062        .lock()
1063        .map_err(|e| anyhow::anyhow!("worker.model_load_lock poisoned: {e}"))?;
1064
1065    // Ensure the model is GPU-resident on this worker. Handles load-from-disk,
1066    // parked-reload, and the reclaim-on-swap path using worker.gpu.ordinal.
1067    ensure_model_ready_sync(worker, model_name, config, hint, false)?;
1068
1069    // Take the engine out of the worker's cache so the closure can mutate it.
1070    let cached = {
1071        let mut cache = worker
1072            .model_cache
1073            .lock()
1074            .map_err(|e| anyhow::anyhow!("worker.model_cache poisoned: {e}"))?;
1075        cache.take(model_name).ok_or_else(|| {
1076            anyhow::anyhow!("cache race: engine '{model_name}' vanished after ensure_model_ready")
1077        })?
1078    };
1079
1080    // Run the closure. Capture panics so we can still restore the engine
1081    // before propagating — otherwise a panic leaks the engine out of the cache.
1082    //
1083    // `AssertUnwindSafe` suppresses the compiler's UnwindSafe check because
1084    // `&mut dyn InferenceEngine` (across Box + trait object) isn't unwind-safe
1085    // by default. This is acceptable here: we only promise to prevent the
1086    // CUDA primary-context reset SEGV race, not to guarantee engine internal
1087    // state is pristine after a mid-generation panic. A panicked engine will
1088    // surface as a bad generation result to the next caller — not a crash.
1089    // `catch_unwind` + `resume_unwind` exists solely so the engine is
1090    // restored to the cache before the panic propagates up.
1091    let mut cached = cached;
1092    let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1093        with_engine(cached.engine.as_mut())
1094    }));
1095
1096    // Restore unconditionally — the comment above promises this, so we must
1097    // honour it even if the cache mutex is poisoned. Taking the inner guard
1098    // from a poisoned lock is safe here: restoring an engine reference into
1099    // a HashMap entry cannot worsen an already-corrupt state, and silently
1100    // dropping the engine (the alternative) would leak it out of the cache
1101    // and leave every future request for this model looking at a stale hole.
1102    {
1103        let mut cache = worker
1104            .model_cache
1105            .lock()
1106            .unwrap_or_else(|poisoned| poisoned.into_inner());
1107        cache.restore(cached);
1108    }
1109
1110    match result {
1111        Ok(inner) => Ok(inner),
1112        Err(panic_payload) => std::panic::resume_unwind(panic_payload),
1113    }
1114}
1115
1116/// Run a blocking chain-job stage operation on a specific GPU worker.
1117///
1118/// Lock scope is exactly one stage render; callers reacquire for each stage
1119/// so the durable chain-job runner can yield between stages.
1120pub fn run_stage_blocking<T, E>(
1121    worker: &GpuWorker,
1122    model_name: &str,
1123    config: &mold_core::Config,
1124    hint: Option<crate::model_manager::ActivationHint>,
1125    with_engine: impl FnOnce(&mut dyn mold_inference::InferenceEngine) -> Result<T, E>,
1126) -> ChainPrep<T, E> {
1127    // Same take/restore critical section as `run_chain_blocking`; the durable
1128    // runner calls this once per stage, so the lock scope is one render call.
1129    run_chain_blocking(worker, model_name, config, hint, with_engine)
1130}
1131
1132#[cfg(test)]
1133mod tests {
1134    use super::*;
1135    use crate::job_registry::JobRegistry;
1136    use crate::model_cache::ModelCache;
1137    use crate::state::QueueHandle;
1138    use mold_core::{
1139        Config, GenerateRequest, GenerateResponse, ImageData, ModelConfig, OutputFormat,
1140    };
1141    use mold_inference::device::DiscoveredGpu;
1142    use mold_inference::shared_pool::SharedPool;
1143    use mold_inference::InferenceEngine;
1144    use std::sync::atomic::{AtomicUsize, Ordering};
1145    use std::sync::{Arc, Mutex, RwLock};
1146    use std::time::Duration;
1147
1148    /// Weight-free engine that sleeps in `load()` to widen the critical-section
1149    /// window during concurrency tests.
1150    struct FakeSlowEngine {
1151        name: String,
1152        loaded: bool,
1153        load_sleep: Duration,
1154    }
1155
1156    impl FakeSlowEngine {
1157        fn boxed(name: &str, load_sleep: Duration) -> Box<dyn InferenceEngine> {
1158            Box::new(Self {
1159                name: name.to_string(),
1160                loaded: false,
1161                load_sleep,
1162            })
1163        }
1164    }
1165
1166    impl InferenceEngine for FakeSlowEngine {
1167        fn generate(&mut self, _req: &GenerateRequest) -> anyhow::Result<GenerateResponse> {
1168            unreachable!("FakeSlowEngine is not used for generation in tests")
1169        }
1170        fn model_name(&self) -> &str {
1171            &self.name
1172        }
1173        fn is_loaded(&self) -> bool {
1174            self.loaded
1175        }
1176        fn load(&mut self) -> anyhow::Result<()> {
1177            std::thread::sleep(self.load_sleep);
1178            self.loaded = true;
1179            Ok(())
1180        }
1181        fn unload(&mut self) {
1182            self.loaded = false;
1183        }
1184    }
1185
1186    fn single_worker_pool_with_parked(model: &str, load_sleep: Duration) -> Arc<GpuWorker> {
1187        let (job_tx, _job_rx) = std::sync::mpsc::sync_channel::<GpuJob>(2);
1188        let mut cache = ModelCache::new(3);
1189        // Seed as Parked so `ensure_model_ready_sync` hits its reload path
1190        // and calls `engine.load()` — that's where the sleep widens the window.
1191        cache.insert(FakeSlowEngine::boxed(model, load_sleep), 0);
1192        Arc::new(GpuWorker {
1193            gpu: DiscoveredGpu {
1194                ordinal: 0,
1195                name: "fake-gpu-0".to_string(),
1196                total_vram_bytes: 24_000_000_000,
1197                free_vram_bytes: 24_000_000_000,
1198            },
1199            model_cache: Arc::new(Mutex::new(cache)),
1200            active_generation: Arc::new(RwLock::new(None)),
1201            model_load_lock: Arc::new(Mutex::new(())),
1202            shared_pool: Arc::new(Mutex::new(SharedPool::new())),
1203            in_flight: AtomicUsize::new(0),
1204            consecutive_failures: AtomicUsize::new(0),
1205            degraded_until: RwLock::new(None),
1206            job_tx,
1207        })
1208    }
1209
1210    fn fake_upscale_job(config: Config, upscale_model: &str) -> GpuJob {
1211        let (result_tx, _result_rx) = tokio::sync::oneshot::channel();
1212        let (queue_tx, _queue_rx) = tokio::sync::mpsc::channel(1);
1213        let mut request: GenerateRequest = serde_json::from_str(
1214            r#"{"prompt":"portrait","model":"flux-dev:q4","width":512,"height":512,"steps":4,"guidance":3.5,"batch_size":1}"#,
1215        )
1216        .unwrap();
1217        request.upscale_model = Some(upscale_model.to_string());
1218        GpuJob {
1219            id: "job-upscale-test".to_string(),
1220            model: request.model.clone(),
1221            request,
1222            progress_tx: None,
1223            result_tx,
1224            output_dir: None,
1225            config: Arc::new(tokio::sync::RwLock::new(config)),
1226            metadata_db: Arc::new(None),
1227            queue: QueueHandle::new(queue_tx),
1228            registry: JobRegistry::new(),
1229            events: crate::events::EventBroadcaster::new(),
1230        }
1231    }
1232
1233    fn fake_upscale_image() -> ImageData {
1234        ImageData {
1235            data: vec![0x89, 0x50, 0x4E, 0x47],
1236            format: OutputFormat::Png,
1237            width: 512,
1238            height: 512,
1239            index: 0,
1240        }
1241    }
1242
1243    #[test]
1244    fn worker_post_upscale_reports_missing_downloaded_model() {
1245        let worker = single_worker_pool_with_parked("flux-dev:q4", Duration::ZERO);
1246        let job = fake_upscale_job(Config::default(), "real-esrgan-x4plus:fp16");
1247        let mut response = GenerateResponse {
1248            images: vec![],
1249            video: None,
1250            generation_time_ms: 10,
1251            model: job.request.model.clone(),
1252            seed_used: 7,
1253            gpu: None,
1254        };
1255
1256        let err = upscale_generated_image_on_worker(
1257            &worker,
1258            &job,
1259            "real-esrgan-x4plus:fp16",
1260            fake_upscale_image(),
1261            &mut response,
1262        )
1263        .expect_err("worker should reject a missing upscaler config");
1264
1265        assert!(err.contains("not downloaded"), "got: {err}");
1266    }
1267
1268    #[test]
1269    fn worker_post_upscale_surfaces_missing_weights_path() {
1270        let worker = single_worker_pool_with_parked("flux-dev:q4", Duration::ZERO);
1271        let tmp = tempfile::TempDir::new().unwrap();
1272        let missing_weights = tmp.path().join("missing-upscaler.safetensors");
1273        let mut config = Config::default();
1274        config.models.insert(
1275            "real-esrgan-x4plus:fp16".to_string(),
1276            ModelConfig {
1277                transformer: Some(missing_weights.display().to_string()),
1278                ..Default::default()
1279            },
1280        );
1281        let job = fake_upscale_job(config, "real-esrgan-x4plus:fp16");
1282        let mut response = GenerateResponse {
1283            images: vec![],
1284            video: None,
1285            generation_time_ms: 10,
1286            model: job.request.model.clone(),
1287            seed_used: 7,
1288            gpu: None,
1289        };
1290
1291        let err = upscale_generated_image_on_worker(
1292            &worker,
1293            &job,
1294            "real-esrgan-x4plus:fp16",
1295            fake_upscale_image(),
1296            &mut response,
1297        )
1298        .expect_err("worker should surface missing weight files before generation completes");
1299
1300        assert!(err.contains("failed to load upscaler"), "got: {err}");
1301        assert!(err.contains("upscaler weights not found"), "got: {err}");
1302    }
1303
1304    /// Two concurrent callers into `run_chain_blocking` on the same worker
1305    /// must serialize — `MAX_CONCURRENT` must never exceed 1.
1306    ///
1307    /// Fails to compile until `run_chain_blocking` is implemented in Task 2.
1308    #[test]
1309    fn run_chain_blocking_serializes_same_worker() {
1310        let worker = single_worker_pool_with_parked("fake-model", Duration::from_millis(30));
1311        let config = Config::default();
1312
1313        let active = Arc::new(AtomicUsize::new(0));
1314        let max_concurrent = Arc::new(AtomicUsize::new(0));
1315
1316        let instrumented = |active: Arc<AtomicUsize>, max_concurrent: Arc<AtomicUsize>| {
1317            move |_engine: &mut dyn InferenceEngine| -> anyhow::Result<()> {
1318                let now = active.fetch_add(1, Ordering::SeqCst) + 1;
1319                max_concurrent.fetch_max(now, Ordering::SeqCst);
1320                std::thread::sleep(Duration::from_millis(50));
1321                active.fetch_sub(1, Ordering::SeqCst);
1322                Ok(())
1323            }
1324        };
1325
1326        let worker_a = worker.clone();
1327        let config_a = config.clone();
1328        let a = active.clone();
1329        let m = max_concurrent.clone();
1330        let t_a = std::thread::spawn(move || {
1331            run_chain_blocking(&worker_a, "fake-model", &config_a, None, instrumented(a, m))
1332                .expect("prep ok")
1333                .expect("closure ok");
1334        });
1335
1336        let worker_b = worker.clone();
1337        let config_b = config.clone();
1338        let a = active.clone();
1339        let m = max_concurrent.clone();
1340        let t_b = std::thread::spawn(move || {
1341            run_chain_blocking(&worker_b, "fake-model", &config_b, None, instrumented(a, m))
1342                .expect("prep ok")
1343                .expect("closure ok");
1344        });
1345
1346        t_a.join().unwrap();
1347        t_b.join().unwrap();
1348
1349        assert_eq!(
1350            max_concurrent.load(Ordering::SeqCst),
1351            1,
1352            "two concurrent run_chain_blocking calls must serialize on worker.model_load_lock"
1353        );
1354    }
1355
1356    // ── OOM detection + message rewriting (Part 2) ────────────────────────────
1357
1358    /// `is_cuda_oom` detects the canonical `CUDA_ERROR_OUT_OF_MEMORY` error
1359    /// string. This pattern-match is the only stable signal available from
1360    /// the candle/cudarc error chain since the cudarc error type is not
1361    /// downcasted via std::error::Error in the candle re-export.
1362    #[test]
1363    fn is_cuda_oom_detects_driver_error_string() {
1364        let oom_err = anyhow::anyhow!(r#"DriverError(CUDA_ERROR_OUT_OF_MEMORY, "out of memory")"#);
1365        assert!(
1366            is_cuda_oom(&oom_err),
1367            "must detect CUDA_ERROR_OUT_OF_MEMORY in anyhow error chain"
1368        );
1369    }
1370
1371    /// A regular (non-OOM) error must not trigger the OOM path.
1372    #[test]
1373    fn is_cuda_oom_does_not_trigger_on_regular_errors() {
1374        let reg_err = anyhow::anyhow!("safetensors file not found");
1375        assert!(
1376            !is_cuda_oom(&reg_err),
1377            "non-OOM error must not be classified as OOM"
1378        );
1379    }
1380
1381    /// `oom_user_message` produces a message that mentions actionable
1382    /// mitigations — frames, resolution, or quantized variants. It must
1383    /// NOT contain the opaque CUDA driver error string.
1384    #[test]
1385    fn runtime_oom_message_suggests_offload_and_smaller_frames() {
1386        let msg = oom_user_message("ltx-video-0.9.8-13b-dev:bf16");
1387        assert!(
1388            msg.contains("frames") || msg.contains("width") || msg.contains("quantized"),
1389            "OOM message must suggest reducing frames, resolution, or using a \
1390             quantized variant; got: {msg}",
1391        );
1392        assert!(
1393            !msg.contains("CUDA_ERROR_OUT_OF_MEMORY"),
1394            "OOM user message must not expose the raw CUDA driver error string; \
1395             got: {msg}",
1396        );
1397        assert!(
1398            msg.contains("ltx-video-0.9.8-13b-dev:bf16"),
1399            "OOM message must include the model name so the user knows what failed; \
1400            got: {msg}",
1401        );
1402    }
1403
1404    #[test]
1405    fn runtime_oom_message_for_sd15_1024_mentions_resolution_not_frames() {
1406        let req: GenerateRequest = serde_json::from_str(
1407            r#"{"prompt":"portrait","model":"realistic-vision-v5:fp16","width":1024,"height":1024,"steps":25,"guidance":7.5,"batch_size":1}"#,
1408        )
1409        .unwrap();
1410
1411        let msg =
1412            oom_user_message_for_request("realistic-vision-v5:fp16", Some("sd15"), Some(&req));
1413
1414        assert!(
1415            msg.contains("1024x1024"),
1416            "image OOM message should mention the requested resolution; got: {msg}"
1417        );
1418        assert!(
1419            msg.contains("512x512"),
1420            "SD1.5 OOM message should point back to the native/default size; got: {msg}"
1421        );
1422        assert!(
1423            msg.contains("checkpoint") || msg.contains("model file"),
1424            "OOM message should explain why file size is not peak VRAM; got: {msg}"
1425        );
1426        assert!(
1427            !msg.contains("--frames"),
1428            "image OOM message must not suggest video frame-count fixes; got: {msg}"
1429        );
1430    }
1431
1432    #[test]
1433    fn runtime_oom_message_for_ltx_keeps_frame_guidance() {
1434        let req: GenerateRequest = serde_json::from_str(
1435            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}"#,
1436        )
1437        .unwrap();
1438
1439        let msg = oom_user_message_for_request(
1440            "ltx-video-0.9.8-13b-dev:bf16",
1441            Some("ltx-video"),
1442            Some(&req),
1443        );
1444
1445        assert!(
1446            msg.contains("--frames") && msg.contains("25"),
1447            "video OOM message should keep frame-count guidance; got: {msg}"
1448        );
1449        assert!(
1450            msg.contains("768x512"),
1451            "video OOM message should mention the requested resolution; got: {msg}"
1452        );
1453    }
1454
1455    /// A failed `engine.load()` must NOT leave a phantom entry in the cache.
1456    ///
1457    /// `ensure_model_ready_sync` calls `create_engine_with_pool` then
1458    /// `engine.load()`, and only calls `cache.insert_loaded()` after success.
1459    /// This test confirms that a load failure on a fresh (non-cached) engine
1460    /// leaves the cache empty — `contains()` returns false and `in_flight`
1461    /// is clean.
1462    ///
1463    /// We can't exercise the full `ensure_model_ready_sync` path without real
1464    /// model files, so we test the cache contract directly: a failed
1465    /// `insert_loaded` attempt (via the engine's failing load) leaves the
1466    /// cache exactly as it was before.
1467    #[test]
1468    fn failed_load_does_not_leak_into_model_cache() {
1469        // Engine that always fails to load.
1470        struct FailingLoadEngine {
1471            name: String,
1472        }
1473        impl InferenceEngine for FailingLoadEngine {
1474            fn generate(&mut self, _: &GenerateRequest) -> anyhow::Result<GenerateResponse> {
1475                unreachable!()
1476            }
1477            fn model_name(&self) -> &str {
1478                &self.name
1479            }
1480            fn is_loaded(&self) -> bool {
1481                false
1482            }
1483            fn load(&mut self) -> anyhow::Result<()> {
1484                anyhow::bail!(r#"DriverError(CUDA_ERROR_OUT_OF_MEMORY, "out of memory")"#)
1485            }
1486            fn unload(&mut self) {}
1487        }
1488
1489        let cache = ModelCache::new(3);
1490        let model_name = "ltx-video-0.9.8-13b-dev:bf16";
1491
1492        // Simulate the load path: create the engine, attempt load, only
1493        // insert on success. This mirrors the exact control flow in
1494        // `ensure_model_ready_sync`.
1495        let mut engine: Box<dyn InferenceEngine> = Box::new(FailingLoadEngine {
1496            name: model_name.to_string(),
1497        });
1498        let load_result = engine.load();
1499
1500        assert!(
1501            load_result.is_err(),
1502            "engine.load() must fail for this test to be meaningful"
1503        );
1504        assert!(
1505            is_cuda_oom(load_result.as_ref().unwrap_err()),
1506            "load error must be classified as OOM"
1507        );
1508
1509        // Crucially: we do NOT call cache.insert_loaded() on failure.
1510        // The cache must remain empty.
1511        assert!(
1512            !cache.contains(model_name),
1513            "cache must not contain the model after a failed load — \
1514             `insert_loaded` must only be called on success"
1515        );
1516        assert!(
1517            cache.is_empty(),
1518            "cache must be completely empty after a failed load"
1519        );
1520    }
1521}