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                if let Some(ref video) = response.video {
518                    save_video_to_dir(
519                        dir,
520                        &video.data,
521                        &video.gif_preview,
522                        video.format,
523                        &job.model,
524                        &metadata,
525                        Some(generation_time_ms),
526                        db,
527                    );
528                } else {
529                    save_image_to_dir(
530                        dir,
531                        &img,
532                        &job.model,
533                        job.request.batch_size,
534                        Some(&metadata),
535                        Some(generation_time_ms),
536                        db,
537                    );
538                }
539            }
540
541            // Send SSE complete event. Video responses carry the actual MP4 /
542            // GIF bytes plus frames / fps / thumbnail / audio metadata so the
543            // SSE client can reconstruct a `VideoData` — without this the
544            // Discord bot silently degraded every LTX-Video / LTX-2 response
545            // into an image attachment (the synthesized thumbnail PNG).
546            if let Some(ref tx) = job.progress_tx {
547                let event = build_sse_complete_event(&response, &img);
548                let _ = tx.send(SseMessage::Complete(event));
549            }
550
551            // Send result through oneshot.
552            let _ = job.result_tx.send(Ok(GenerationJobResult {
553                image: img,
554                response,
555            }));
556        }
557        Ok(Err(e)) => {
558            tracing::warn!(gpu = ordinal, model = %model_name, "Generation failed: {e}");
559            // Detect CUDA OOM during inference: synchronize so subsequent
560            // allocations start from a clean CUDA context state, then surface
561            // a user-friendly message instead of the opaque DriverError string.
562            let is_oom = is_cuda_oom(&e);
563            let (err_msg, count_worker_failure) = if is_oom {
564                mold_inference::device::try_synchronize_device(ordinal);
565                cuda_oom_user_message(
566                    worker,
567                    &model_name,
568                    family_slug.as_deref(),
569                    Some(&job.request),
570                )
571            } else {
572                (
573                    format!("generation error: {}", clean_error_message(&e)),
574                    true,
575                )
576            };
577            if count_worker_failure {
578                record_failure(worker);
579            }
580            if let Some(ref tx) = job.progress_tx {
581                let _ = tx.send(SseMessage::Error(SseErrorEvent {
582                    message: err_msg.clone(),
583                }));
584            }
585            let _ = job.result_tx.send(Err(err_msg));
586        }
587        Err(panic_payload) => {
588            tracing::error!(gpu = ordinal, model = %model_name, "Inference panicked");
589            record_failure(worker);
590            let msg = panic_payload
591                .downcast_ref::<String>()
592                .map(|s| s.as_str())
593                .or_else(|| panic_payload.downcast_ref::<&str>().copied())
594                .unwrap_or("unknown panic");
595            let err_msg = format!("inference panicked: {msg}");
596            if let Some(ref tx) = job.progress_tx {
597                let _ = tx.send(SseMessage::Error(SseErrorEvent {
598                    message: err_msg.clone(),
599                }));
600            }
601            let _ = job.result_tx.send(Err(err_msg));
602        }
603    }
604}
605
606/// Preflight memory check with evict-to-fit recovery.
607///
608/// Wraps `model_manager::preflight_memory_guard`. On a budget failure, drops
609/// the LRU parked entry (skipping `model_name` so a parked-reload doesn't
610/// evict its own target), reclaims the GPU's CUDA pool when no engine remains
611/// resident, and retries. Loops until the preflight passes or the cache has
612/// no parked entries left to surrender — at which point the original
613/// insufficient-memory error is returned.
614///
615/// Holds the cache lock only for the brief eviction step; the engine drop and
616/// `reclaim_gpu_memory` run outside it. The caller is expected to hold
617/// `worker.model_load_lock`, which keeps a concurrent generation from slotting
618/// a fresh load into the context between our reclaim and the actual load.
619fn preflight_memory_guard_with_eviction(
620    cache_lock: &std::sync::Mutex<crate::model_cache::ModelCache>,
621    model_name: &str,
622    paths: &ModelPaths,
623    ordinal: usize,
624    hint: Option<crate::model_manager::ActivationHint>,
625) -> Result<(), crate::routes::ApiError> {
626    loop {
627        let active_vram = cache_lock
628            .lock()
629            .unwrap_or_else(|e| e.into_inner())
630            .active_vram_bytes();
631        let err = match crate::model_manager::preflight_memory_guard(
632            model_name,
633            paths,
634            active_vram,
635            ordinal,
636            hint,
637        ) {
638            Ok(()) => return Ok(()),
639            Err(e) => e,
640        };
641
642        let evicted = {
643            let mut cache = cache_lock.lock().unwrap_or_else(|e| e.into_inner());
644            cache.evict_lru_parked_except(Some(model_name))
645        };
646        let Some((evicted_name, engine)) = evicted else {
647            return Err(err);
648        };
649        tracing::info!(
650            gpu = ordinal,
651            target_model = %model_name,
652            evicted_model = %evicted_name,
653            "evicting LRU parked entry to fit incoming load"
654        );
655        // Drop outside the cache lock — `cuMemFree` and safetensor unmap
656        // can block other cache users during the drop.
657        drop(engine);
658
659        // Reclaim only when no GPU-resident engine remains. The parked-reload
660        // case has the active model still on GPU at preflight time, so we can
661        // free CPU caches via the eviction but must not nuke the primary
662        // context. The fresh-load case usually has no active model when this
663        // is hit, so the reclaim can run.
664        let safe_to_reclaim = cache_lock
665            .lock()
666            .unwrap_or_else(|e| e.into_inner())
667            .active_model()
668            .is_none();
669        if safe_to_reclaim {
670            device::reclaim_gpu_memory(ordinal);
671        }
672    }
673}
674
675fn select_load_strategy_for_worker(
676    worker: &GpuWorker,
677    model_name: &str,
678    paths: &ModelPaths,
679    hint: Option<crate::model_manager::ActivationHint>,
680) -> mold_inference::LoadStrategy {
681    let active_vram = worker
682        .model_cache
683        .lock()
684        .unwrap_or_else(|e| e.into_inner())
685        .active_vram_bytes();
686    let available =
687        crate::model_manager::effective_load_available_bytes(active_vram, worker.gpu.ordinal);
688    let strategy = crate::model_manager::select_server_load_strategy_for_device(
689        paths,
690        available,
691        Some(worker.gpu.total_vram_bytes),
692        hint,
693    );
694    if strategy == mold_inference::LoadStrategy::Sequential {
695        tracing::info!(
696            gpu = worker.gpu.ordinal,
697            model = %model_name,
698            "server load strategy degraded to sequential to fit memory budget"
699        );
700    }
701    strategy
702}
703
704/// Ensure a model is loaded on this worker's GPU.
705///
706/// Holds `worker.model_load_lock` implicitly via the caller for generation
707/// jobs; the admin API path acquires it explicitly via `load_blocking`.
708///
709/// `hint` carries the per-request activation budget (resolution + family).
710/// Pass `None` for admin / cache-prewarm loads with no resolution context.
711pub fn ensure_model_ready_sync(
712    worker: &GpuWorker,
713    model_name: &str,
714    config: &Config,
715    hint: Option<crate::model_manager::ActivationHint>,
716    request_has_lora: bool,
717) -> anyhow::Result<()> {
718    let cache = worker.model_cache.lock().unwrap();
719
720    // Already loaded?
721    if let Some(entry) = cache.get(model_name) {
722        if entry.residency == ModelResidency::Gpu {
723            let must_recreate = entry.engine.model_paths().is_some_and(|paths| {
724                crate::model_manager::request_requires_fresh_engine_for_offload_policy(
725                    paths,
726                    hint,
727                    request_has_lora,
728                )
729            });
730            if !must_recreate {
731                return Ok(());
732            }
733        }
734    }
735
736    // Check if we have it cached but not on GPU (Parked).
737    let has_cached = cache.contains(model_name);
738
739    // Snapshot the cached engine's paths (if any) for the preflight before
740    // dropping the lock. Cloning ModelPaths keeps the borrow scoped to this
741    // block. Active-VRAM is sampled inside the preflight helper itself so
742    // each retry sees fresh state.
743    let cached_paths = if has_cached {
744        cache
745            .get(model_name)
746            .and_then(|e| e.engine.model_paths().cloned())
747    } else {
748        None
749    };
750    drop(cache);
751
752    if has_cached {
753        let load_strategy = cached_paths
754            .as_ref()
755            .map(|paths| select_load_strategy_for_worker(worker, model_name, paths, hint))
756            .unwrap_or(mold_inference::LoadStrategy::Eager);
757
758        // Preflight before unloading the active model — the active model's
759        // footprint counts toward effective availability since we're about
760        // to free it. On budget failure, evict-to-fit drops parked entries
761        // (other than `model_name` itself) and retries.
762        if let Some(ref paths) = cached_paths {
763            preflight_memory_guard_with_eviction(
764                &worker.model_cache,
765                model_name,
766                paths,
767                worker.gpu.ordinal,
768                hint,
769            )
770            .map_err(|e| anyhow::anyhow!(e.error))?;
771        }
772
773        // Unload active model first.
774        {
775            let mut cache = worker.model_cache.lock().unwrap();
776            cache.unload_active();
777        }
778        device::reclaim_gpu_memory(worker.gpu.ordinal);
779
780        if load_strategy == mold_inference::LoadStrategy::Sequential {
781            let paths = cached_paths.ok_or_else(|| {
782                anyhow::anyhow!("cached engine for '{model_name}' does not expose model paths")
783            })?;
784            let old_engine = {
785                let mut cache = worker.model_cache.lock().unwrap();
786                cache
787                    .remove(model_name)
788                    .ok_or_else(|| anyhow::anyhow!("cache race: model '{model_name}' vanished"))?
789            };
790
791            let offload = crate::model_manager::server_offload_enabled_for_paths(
792                &paths,
793                hint,
794                request_has_lora,
795            );
796            let resolved_catalog_config =
797                crate::model_manager::resolve_installed_catalog_paths_for_worker(
798                    model_name, config,
799                )
800                .map_err(|e| anyhow::anyhow!(e.error))?
801                .map(|(_, config)| config);
802            let engine_config = resolved_catalog_config.as_ref().unwrap_or(config);
803            let mut engine = match mold_inference::create_engine_with_pool(
804                model_name.to_string(),
805                paths,
806                engine_config,
807                load_strategy,
808                worker.gpu.ordinal,
809                offload,
810                Some(worker.shared_pool.clone()),
811            ) {
812                Ok(engine) => engine,
813                Err(err) => {
814                    let evicted = {
815                        let mut cache = worker.model_cache.lock().unwrap();
816                        cache.insert(old_engine, 0)
817                    };
818                    drop(evicted);
819                    return Err(err);
820                }
821            };
822
823            tracing::info!(
824                gpu = worker.gpu.ordinal,
825                model = %model_name,
826                "recreating cached engine in sequential mode..."
827            );
828            let vram_baseline = device::vram_in_use_bytes(worker.gpu.ordinal);
829            if let Err(err) = engine.load() {
830                let evicted = {
831                    let mut cache = worker.model_cache.lock().unwrap();
832                    cache.insert(old_engine, 0)
833                };
834                drop(evicted);
835                return Err(err);
836            }
837            let vram = device::vram_load_delta(worker.gpu.ordinal, vram_baseline);
838            drop(old_engine);
839            let evicted = {
840                let mut cache = worker.model_cache.lock().unwrap();
841                cache.insert_loaded(model_name.to_string(), engine, vram)
842            };
843            drop(evicted);
844            return Ok(());
845        }
846
847        // Take the engine out and reload it.
848        let mut engine = {
849            let mut cache = worker.model_cache.lock().unwrap();
850            cache
851                .remove(model_name)
852                .ok_or_else(|| anyhow::anyhow!("cache race: model '{model_name}' vanished"))?
853        };
854
855        tracing::info!(
856            gpu = worker.gpu.ordinal,
857            model = %model_name,
858            "reloading cached engine..."
859        );
860        // Sample VRAM baseline before load so we can record the new model's
861        // per-load delta rather than the device-global usage.
862        let vram_baseline = device::vram_in_use_bytes(worker.gpu.ordinal);
863        engine.load()?;
864
865        let vram = device::vram_load_delta(worker.gpu.ordinal, vram_baseline);
866        // Drop any evicted engine OUTSIDE the cache lock — `cuMemFree` and
867        // safetensor unmap during the drop can block other cache users.
868        let evicted = {
869            let mut cache = worker.model_cache.lock().unwrap();
870            cache.insert_loaded(model_name.to_string(), engine, vram)
871        };
872        drop(evicted);
873        return Ok(());
874    }
875
876    // Not in cache — need to create from scratch.
877    // Resolve model paths.
878    let mut resolved_catalog_config = None;
879    let paths = if let Some(paths) = ModelPaths::resolve(model_name, config) {
880        paths
881    } else if let Some((paths, config)) =
882        crate::model_manager::resolve_installed_catalog_paths_for_worker(model_name, config)
883            .map_err(|e| anyhow::anyhow!(e.error))?
884    {
885        resolved_catalog_config = Some(config);
886        paths
887    } else {
888        return Err(
889            if model_name.starts_with("cv:") || model_name.starts_with("hf:") {
890                // Catalog IDs (cv:/hf:) reach this path through the bridge in
891                // `model_manager::install_catalog_model`, which can synthesize a
892                // ModelConfig that's missing a required field (notably `vae`)
893                // when a canonical companion was never pulled. The legacy
894                // "Run: mold pull <id>" message is misleading there because the
895                // primary checkpoint IS on disk — the companion is what's
896                // missing. Surface the catalog-specific guidance instead.
897                anyhow::anyhow!(
898                    "catalog model '{model_name}' has missing required components. \
899                 Re-pull the entry from the catalog so its companions \
900                 (CLIP-L / T5 / VAE) are fetched alongside the primary checkpoint."
901                )
902            } else {
903                anyhow::anyhow!(
904                    "model '{model_name}' is not downloaded. Run: mold pull {model_name}"
905                )
906            },
907        );
908    };
909
910    // Preflight before unloading the active model. Evict-to-fit drops parked
911    // entries on budget failure and retries before giving up.
912    preflight_memory_guard_with_eviction(
913        &worker.model_cache,
914        model_name,
915        &paths,
916        worker.gpu.ordinal,
917        hint,
918    )
919    .map_err(|e| anyhow::anyhow!(e.error))?;
920
921    let load_strategy = select_load_strategy_for_worker(worker, model_name, &paths, hint);
922
923    // Unload active model first.
924    {
925        let mut cache = worker.model_cache.lock().unwrap();
926        cache.unload_active();
927    }
928    device::reclaim_gpu_memory(worker.gpu.ordinal);
929
930    let offload =
931        crate::model_manager::server_offload_enabled_for_paths(&paths, hint, request_has_lora);
932    let engine_config = resolved_catalog_config.as_ref().unwrap_or(config);
933    let mut engine = mold_inference::create_engine_with_pool(
934        model_name.to_string(),
935        paths,
936        engine_config,
937        load_strategy,
938        worker.gpu.ordinal,
939        offload,
940        Some(worker.shared_pool.clone()),
941    )?;
942
943    tracing::info!(
944        gpu = worker.gpu.ordinal,
945        model = %model_name,
946        "loading model..."
947    );
948    // Sample VRAM baseline before load so we can record the new model's
949    // per-load delta rather than the device-global usage.
950    let vram_baseline = device::vram_in_use_bytes(worker.gpu.ordinal);
951    engine.load()?;
952
953    let vram = device::vram_load_delta(worker.gpu.ordinal, vram_baseline);
954    // Drop any evicted engine OUTSIDE the cache lock — `cuMemFree` and
955    // safetensor unmap during the drop can block other cache users.
956    let evicted = {
957        let mut cache = worker.model_cache.lock().unwrap();
958        cache.insert_loaded(model_name.to_string(), engine, vram)
959    };
960    drop(evicted);
961
962    Ok(())
963}
964
965/// Synchronously load a model on this GPU worker for the admin API.
966///
967/// Acquires the per-GPU load lock, then delegates to `ensure_model_ready_sync`.
968/// Intended to be called inside `tokio::task::spawn_blocking`. Uses the
969/// size-only peak (no resolution context) for the preflight — admin loads
970/// don't carry a request shape.
971pub fn load_blocking(worker: &GpuWorker, model_name: &str, config: &Config) -> anyhow::Result<()> {
972    let _lock = worker.model_load_lock.lock().unwrap();
973    ensure_model_ready_sync(worker, model_name, config, None, false)
974}
975
976/// Synchronously unload the currently active model on this GPU worker.
977///
978/// Returns the name of the model that was unloaded, or `None` if the GPU was
979/// already idle.
980pub fn unload_blocking(worker: &GpuWorker) -> Option<String> {
981    let _lock = worker.model_load_lock.lock().unwrap();
982    let unloaded = {
983        let mut cache = worker.model_cache.lock().unwrap();
984        cache.unload_active()
985    };
986    if unloaded.is_some() {
987        device::reclaim_gpu_memory(worker.gpu.ordinal);
988    }
989    unloaded
990}
991
992fn record_failure(worker: &GpuWorker) {
993    let failures = worker.consecutive_failures.fetch_add(1, Ordering::SeqCst) + 1;
994    if failures >= 3 {
995        let mut degraded = worker.degraded_until.write().unwrap();
996        *degraded = Some(Instant::now() + Duration::from_secs(60));
997        tracing::warn!(
998            gpu = worker.gpu.ordinal,
999            "GPU marked degraded after {failures} consecutive failures (60s cooldown)"
1000        );
1001    }
1002}
1003
1004fn clear_active_generation(worker: &GpuWorker) {
1005    let mut gen = worker.active_generation.write().unwrap();
1006    *gen = None;
1007}
1008
1009/// Return type for [`run_chain_blocking`]. The outer `Result` carries
1010/// helper-prep errors (ensure_model_ready + cache take); the inner `Result`
1011/// is whatever the caller's closure returned. Closure errors pass through
1012/// unchanged so the caller can distinguish orchestrator-specific failures
1013/// (StageFailed, Invalid) from prep failures (ensure/cache).
1014pub type ChainPrep<T, E> = Result<Result<T, E>, anyhow::Error>;
1015
1016/// Run a blocking chain operation on a specific GPU worker.
1017///
1018/// Acquires `worker.model_load_lock` for the full duration, binds the current
1019/// thread to `worker.gpu.ordinal` (so `reclaim_gpu_memory` debug asserts are
1020/// satisfied), ensures the model is loaded on GPU, takes the engine out of
1021/// the worker's cache, passes it to `with_engine`, and restores the engine
1022/// unconditionally on both success and closure failure.
1023///
1024/// Safe to call from inside `tokio::task::spawn_blocking`. The calling thread
1025/// can be any thread — the `ThreadGpuGuard` clears the thread-local on return.
1026///
1027/// # Errors
1028///
1029/// Returns `Err(anyhow::Error)` from the outer Result if:
1030/// - `ensure_model_ready_sync` fails (bad config, disk IO, load error).
1031/// - The engine vanishes from the cache between ensure and take (cache race).
1032///
1033/// Returns `Ok(Err(E))` if the closure itself returned an error — caller
1034/// preserves the closure's typed error for precise HTTP status mapping.
1035pub fn run_chain_blocking<T, E>(
1036    worker: &GpuWorker,
1037    model_name: &str,
1038    config: &mold_core::Config,
1039    hint: Option<crate::model_manager::ActivationHint>,
1040    with_engine: impl FnOnce(&mut dyn mold_inference::InferenceEngine) -> Result<T, E>,
1041) -> ChainPrep<T, E> {
1042    // Bind the thread to this worker's ordinal for the duration of the call.
1043    // `reclaim_gpu_memory` inside ensure_model_ready_sync debug-asserts this
1044    // matches its ordinal argument; without it, a stray caller on an unbound
1045    // thread would panic in debug builds.
1046    struct ThreadGpuGuard;
1047    impl Drop for ThreadGpuGuard {
1048        fn drop(&mut self) {
1049            mold_inference::device::clear_thread_gpu_ordinal();
1050        }
1051    }
1052    mold_inference::device::init_thread_gpu_ordinal(worker.gpu.ordinal);
1053    let _thread_gpu = ThreadGpuGuard;
1054
1055    // Acquire the per-worker load lock. Held for the entire chain duration —
1056    // single-clip generations on this worker queue behind us on the same lock.
1057    let _load_lock = worker
1058        .model_load_lock
1059        .lock()
1060        .map_err(|e| anyhow::anyhow!("worker.model_load_lock poisoned: {e}"))?;
1061
1062    // Ensure the model is GPU-resident on this worker. Handles load-from-disk,
1063    // parked-reload, and the reclaim-on-swap path using worker.gpu.ordinal.
1064    ensure_model_ready_sync(worker, model_name, config, hint, false)?;
1065
1066    // Take the engine out of the worker's cache so the closure can mutate it.
1067    let cached = {
1068        let mut cache = worker
1069            .model_cache
1070            .lock()
1071            .map_err(|e| anyhow::anyhow!("worker.model_cache poisoned: {e}"))?;
1072        cache.take(model_name).ok_or_else(|| {
1073            anyhow::anyhow!("cache race: engine '{model_name}' vanished after ensure_model_ready")
1074        })?
1075    };
1076
1077    // Run the closure. Capture panics so we can still restore the engine
1078    // before propagating — otherwise a panic leaks the engine out of the cache.
1079    //
1080    // `AssertUnwindSafe` suppresses the compiler's UnwindSafe check because
1081    // `&mut dyn InferenceEngine` (across Box + trait object) isn't unwind-safe
1082    // by default. This is acceptable here: we only promise to prevent the
1083    // CUDA primary-context reset SEGV race, not to guarantee engine internal
1084    // state is pristine after a mid-generation panic. A panicked engine will
1085    // surface as a bad generation result to the next caller — not a crash.
1086    // `catch_unwind` + `resume_unwind` exists solely so the engine is
1087    // restored to the cache before the panic propagates up.
1088    let mut cached = cached;
1089    let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1090        with_engine(cached.engine.as_mut())
1091    }));
1092
1093    // Restore unconditionally — the comment above promises this, so we must
1094    // honour it even if the cache mutex is poisoned. Taking the inner guard
1095    // from a poisoned lock is safe here: restoring an engine reference into
1096    // a HashMap entry cannot worsen an already-corrupt state, and silently
1097    // dropping the engine (the alternative) would leak it out of the cache
1098    // and leave every future request for this model looking at a stale hole.
1099    {
1100        let mut cache = worker
1101            .model_cache
1102            .lock()
1103            .unwrap_or_else(|poisoned| poisoned.into_inner());
1104        cache.restore(cached);
1105    }
1106
1107    match result {
1108        Ok(inner) => Ok(inner),
1109        Err(panic_payload) => std::panic::resume_unwind(panic_payload),
1110    }
1111}
1112
1113/// Run a blocking chain-job stage operation on a specific GPU worker.
1114///
1115/// Lock scope is exactly one stage render; callers reacquire for each stage
1116/// so the durable chain-job runner can yield between stages.
1117pub fn run_stage_blocking<T, E>(
1118    worker: &GpuWorker,
1119    model_name: &str,
1120    config: &mold_core::Config,
1121    hint: Option<crate::model_manager::ActivationHint>,
1122    with_engine: impl FnOnce(&mut dyn mold_inference::InferenceEngine) -> Result<T, E>,
1123) -> ChainPrep<T, E> {
1124    // Same take/restore critical section as `run_chain_blocking`; the durable
1125    // runner calls this once per stage, so the lock scope is one render call.
1126    run_chain_blocking(worker, model_name, config, hint, with_engine)
1127}
1128
1129#[cfg(test)]
1130mod tests {
1131    use super::*;
1132    use crate::job_registry::JobRegistry;
1133    use crate::model_cache::ModelCache;
1134    use crate::state::QueueHandle;
1135    use mold_core::{
1136        Config, GenerateRequest, GenerateResponse, ImageData, ModelConfig, OutputFormat,
1137    };
1138    use mold_inference::device::DiscoveredGpu;
1139    use mold_inference::shared_pool::SharedPool;
1140    use mold_inference::InferenceEngine;
1141    use std::sync::atomic::{AtomicUsize, Ordering};
1142    use std::sync::{Arc, Mutex, RwLock};
1143    use std::time::Duration;
1144
1145    /// Weight-free engine that sleeps in `load()` to widen the critical-section
1146    /// window during concurrency tests.
1147    struct FakeSlowEngine {
1148        name: String,
1149        loaded: bool,
1150        load_sleep: Duration,
1151    }
1152
1153    impl FakeSlowEngine {
1154        fn boxed(name: &str, load_sleep: Duration) -> Box<dyn InferenceEngine> {
1155            Box::new(Self {
1156                name: name.to_string(),
1157                loaded: false,
1158                load_sleep,
1159            })
1160        }
1161    }
1162
1163    impl InferenceEngine for FakeSlowEngine {
1164        fn generate(&mut self, _req: &GenerateRequest) -> anyhow::Result<GenerateResponse> {
1165            unreachable!("FakeSlowEngine is not used for generation in tests")
1166        }
1167        fn model_name(&self) -> &str {
1168            &self.name
1169        }
1170        fn is_loaded(&self) -> bool {
1171            self.loaded
1172        }
1173        fn load(&mut self) -> anyhow::Result<()> {
1174            std::thread::sleep(self.load_sleep);
1175            self.loaded = true;
1176            Ok(())
1177        }
1178        fn unload(&mut self) {
1179            self.loaded = false;
1180        }
1181    }
1182
1183    fn single_worker_pool_with_parked(model: &str, load_sleep: Duration) -> Arc<GpuWorker> {
1184        let (job_tx, _job_rx) = std::sync::mpsc::sync_channel::<GpuJob>(2);
1185        let mut cache = ModelCache::new(3);
1186        // Seed as Parked so `ensure_model_ready_sync` hits its reload path
1187        // and calls `engine.load()` — that's where the sleep widens the window.
1188        cache.insert(FakeSlowEngine::boxed(model, load_sleep), 0);
1189        Arc::new(GpuWorker {
1190            gpu: DiscoveredGpu {
1191                ordinal: 0,
1192                name: "fake-gpu-0".to_string(),
1193                total_vram_bytes: 24_000_000_000,
1194                free_vram_bytes: 24_000_000_000,
1195            },
1196            model_cache: Arc::new(Mutex::new(cache)),
1197            active_generation: Arc::new(RwLock::new(None)),
1198            model_load_lock: Arc::new(Mutex::new(())),
1199            shared_pool: Arc::new(Mutex::new(SharedPool::new())),
1200            in_flight: AtomicUsize::new(0),
1201            consecutive_failures: AtomicUsize::new(0),
1202            degraded_until: RwLock::new(None),
1203            job_tx,
1204        })
1205    }
1206
1207    fn fake_upscale_job(config: Config, upscale_model: &str) -> GpuJob {
1208        let (result_tx, _result_rx) = tokio::sync::oneshot::channel();
1209        let (queue_tx, _queue_rx) = tokio::sync::mpsc::channel(1);
1210        let mut request: GenerateRequest = serde_json::from_str(
1211            r#"{"prompt":"portrait","model":"flux-dev:q4","width":512,"height":512,"steps":4,"guidance":3.5,"batch_size":1}"#,
1212        )
1213        .unwrap();
1214        request.upscale_model = Some(upscale_model.to_string());
1215        GpuJob {
1216            id: "job-upscale-test".to_string(),
1217            model: request.model.clone(),
1218            request,
1219            progress_tx: None,
1220            result_tx,
1221            output_dir: None,
1222            config: Arc::new(tokio::sync::RwLock::new(config)),
1223            metadata_db: Arc::new(None),
1224            queue: QueueHandle::new(queue_tx),
1225            registry: JobRegistry::new(),
1226        }
1227    }
1228
1229    fn fake_upscale_image() -> ImageData {
1230        ImageData {
1231            data: vec![0x89, 0x50, 0x4E, 0x47],
1232            format: OutputFormat::Png,
1233            width: 512,
1234            height: 512,
1235            index: 0,
1236        }
1237    }
1238
1239    #[test]
1240    fn worker_post_upscale_reports_missing_downloaded_model() {
1241        let worker = single_worker_pool_with_parked("flux-dev:q4", Duration::ZERO);
1242        let job = fake_upscale_job(Config::default(), "real-esrgan-x4plus:fp16");
1243        let mut response = GenerateResponse {
1244            images: vec![],
1245            video: None,
1246            generation_time_ms: 10,
1247            model: job.request.model.clone(),
1248            seed_used: 7,
1249            gpu: None,
1250        };
1251
1252        let err = upscale_generated_image_on_worker(
1253            &worker,
1254            &job,
1255            "real-esrgan-x4plus:fp16",
1256            fake_upscale_image(),
1257            &mut response,
1258        )
1259        .expect_err("worker should reject a missing upscaler config");
1260
1261        assert!(err.contains("not downloaded"), "got: {err}");
1262    }
1263
1264    #[test]
1265    fn worker_post_upscale_surfaces_missing_weights_path() {
1266        let worker = single_worker_pool_with_parked("flux-dev:q4", Duration::ZERO);
1267        let tmp = tempfile::TempDir::new().unwrap();
1268        let missing_weights = tmp.path().join("missing-upscaler.safetensors");
1269        let mut config = Config::default();
1270        config.models.insert(
1271            "real-esrgan-x4plus:fp16".to_string(),
1272            ModelConfig {
1273                transformer: Some(missing_weights.display().to_string()),
1274                ..Default::default()
1275            },
1276        );
1277        let job = fake_upscale_job(config, "real-esrgan-x4plus:fp16");
1278        let mut response = GenerateResponse {
1279            images: vec![],
1280            video: None,
1281            generation_time_ms: 10,
1282            model: job.request.model.clone(),
1283            seed_used: 7,
1284            gpu: None,
1285        };
1286
1287        let err = upscale_generated_image_on_worker(
1288            &worker,
1289            &job,
1290            "real-esrgan-x4plus:fp16",
1291            fake_upscale_image(),
1292            &mut response,
1293        )
1294        .expect_err("worker should surface missing weight files before generation completes");
1295
1296        assert!(err.contains("failed to load upscaler"), "got: {err}");
1297        assert!(err.contains("upscaler weights not found"), "got: {err}");
1298    }
1299
1300    /// Two concurrent callers into `run_chain_blocking` on the same worker
1301    /// must serialize — `MAX_CONCURRENT` must never exceed 1.
1302    ///
1303    /// Fails to compile until `run_chain_blocking` is implemented in Task 2.
1304    #[test]
1305    fn run_chain_blocking_serializes_same_worker() {
1306        let worker = single_worker_pool_with_parked("fake-model", Duration::from_millis(30));
1307        let config = Config::default();
1308
1309        let active = Arc::new(AtomicUsize::new(0));
1310        let max_concurrent = Arc::new(AtomicUsize::new(0));
1311
1312        let instrumented = |active: Arc<AtomicUsize>, max_concurrent: Arc<AtomicUsize>| {
1313            move |_engine: &mut dyn InferenceEngine| -> anyhow::Result<()> {
1314                let now = active.fetch_add(1, Ordering::SeqCst) + 1;
1315                max_concurrent.fetch_max(now, Ordering::SeqCst);
1316                std::thread::sleep(Duration::from_millis(50));
1317                active.fetch_sub(1, Ordering::SeqCst);
1318                Ok(())
1319            }
1320        };
1321
1322        let worker_a = worker.clone();
1323        let config_a = config.clone();
1324        let a = active.clone();
1325        let m = max_concurrent.clone();
1326        let t_a = std::thread::spawn(move || {
1327            run_chain_blocking(&worker_a, "fake-model", &config_a, None, instrumented(a, m))
1328                .expect("prep ok")
1329                .expect("closure ok");
1330        });
1331
1332        let worker_b = worker.clone();
1333        let config_b = config.clone();
1334        let a = active.clone();
1335        let m = max_concurrent.clone();
1336        let t_b = std::thread::spawn(move || {
1337            run_chain_blocking(&worker_b, "fake-model", &config_b, None, instrumented(a, m))
1338                .expect("prep ok")
1339                .expect("closure ok");
1340        });
1341
1342        t_a.join().unwrap();
1343        t_b.join().unwrap();
1344
1345        assert_eq!(
1346            max_concurrent.load(Ordering::SeqCst),
1347            1,
1348            "two concurrent run_chain_blocking calls must serialize on worker.model_load_lock"
1349        );
1350    }
1351
1352    // ── OOM detection + message rewriting (Part 2) ────────────────────────────
1353
1354    /// `is_cuda_oom` detects the canonical `CUDA_ERROR_OUT_OF_MEMORY` error
1355    /// string. This pattern-match is the only stable signal available from
1356    /// the candle/cudarc error chain since the cudarc error type is not
1357    /// downcasted via std::error::Error in the candle re-export.
1358    #[test]
1359    fn is_cuda_oom_detects_driver_error_string() {
1360        let oom_err = anyhow::anyhow!(r#"DriverError(CUDA_ERROR_OUT_OF_MEMORY, "out of memory")"#);
1361        assert!(
1362            is_cuda_oom(&oom_err),
1363            "must detect CUDA_ERROR_OUT_OF_MEMORY in anyhow error chain"
1364        );
1365    }
1366
1367    /// A regular (non-OOM) error must not trigger the OOM path.
1368    #[test]
1369    fn is_cuda_oom_does_not_trigger_on_regular_errors() {
1370        let reg_err = anyhow::anyhow!("safetensors file not found");
1371        assert!(
1372            !is_cuda_oom(&reg_err),
1373            "non-OOM error must not be classified as OOM"
1374        );
1375    }
1376
1377    /// `oom_user_message` produces a message that mentions actionable
1378    /// mitigations — frames, resolution, or quantized variants. It must
1379    /// NOT contain the opaque CUDA driver error string.
1380    #[test]
1381    fn runtime_oom_message_suggests_offload_and_smaller_frames() {
1382        let msg = oom_user_message("ltx-video-0.9.8-13b-dev:bf16");
1383        assert!(
1384            msg.contains("frames") || msg.contains("width") || msg.contains("quantized"),
1385            "OOM message must suggest reducing frames, resolution, or using a \
1386             quantized variant; got: {msg}",
1387        );
1388        assert!(
1389            !msg.contains("CUDA_ERROR_OUT_OF_MEMORY"),
1390            "OOM user message must not expose the raw CUDA driver error string; \
1391             got: {msg}",
1392        );
1393        assert!(
1394            msg.contains("ltx-video-0.9.8-13b-dev:bf16"),
1395            "OOM message must include the model name so the user knows what failed; \
1396            got: {msg}",
1397        );
1398    }
1399
1400    #[test]
1401    fn runtime_oom_message_for_sd15_1024_mentions_resolution_not_frames() {
1402        let req: GenerateRequest = serde_json::from_str(
1403            r#"{"prompt":"portrait","model":"realistic-vision-v5:fp16","width":1024,"height":1024,"steps":25,"guidance":7.5,"batch_size":1}"#,
1404        )
1405        .unwrap();
1406
1407        let msg =
1408            oom_user_message_for_request("realistic-vision-v5:fp16", Some("sd15"), Some(&req));
1409
1410        assert!(
1411            msg.contains("1024x1024"),
1412            "image OOM message should mention the requested resolution; got: {msg}"
1413        );
1414        assert!(
1415            msg.contains("512x512"),
1416            "SD1.5 OOM message should point back to the native/default size; got: {msg}"
1417        );
1418        assert!(
1419            msg.contains("checkpoint") || msg.contains("model file"),
1420            "OOM message should explain why file size is not peak VRAM; got: {msg}"
1421        );
1422        assert!(
1423            !msg.contains("--frames"),
1424            "image OOM message must not suggest video frame-count fixes; got: {msg}"
1425        );
1426    }
1427
1428    #[test]
1429    fn runtime_oom_message_for_ltx_keeps_frame_guidance() {
1430        let req: GenerateRequest = serde_json::from_str(
1431            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}"#,
1432        )
1433        .unwrap();
1434
1435        let msg = oom_user_message_for_request(
1436            "ltx-video-0.9.8-13b-dev:bf16",
1437            Some("ltx-video"),
1438            Some(&req),
1439        );
1440
1441        assert!(
1442            msg.contains("--frames") && msg.contains("25"),
1443            "video OOM message should keep frame-count guidance; got: {msg}"
1444        );
1445        assert!(
1446            msg.contains("768x512"),
1447            "video OOM message should mention the requested resolution; got: {msg}"
1448        );
1449    }
1450
1451    /// A failed `engine.load()` must NOT leave a phantom entry in the cache.
1452    ///
1453    /// `ensure_model_ready_sync` calls `create_engine_with_pool` then
1454    /// `engine.load()`, and only calls `cache.insert_loaded()` after success.
1455    /// This test confirms that a load failure on a fresh (non-cached) engine
1456    /// leaves the cache empty — `contains()` returns false and `in_flight`
1457    /// is clean.
1458    ///
1459    /// We can't exercise the full `ensure_model_ready_sync` path without real
1460    /// model files, so we test the cache contract directly: a failed
1461    /// `insert_loaded` attempt (via the engine's failing load) leaves the
1462    /// cache exactly as it was before.
1463    #[test]
1464    fn failed_load_does_not_leak_into_model_cache() {
1465        // Engine that always fails to load.
1466        struct FailingLoadEngine {
1467            name: String,
1468        }
1469        impl InferenceEngine for FailingLoadEngine {
1470            fn generate(&mut self, _: &GenerateRequest) -> anyhow::Result<GenerateResponse> {
1471                unreachable!()
1472            }
1473            fn model_name(&self) -> &str {
1474                &self.name
1475            }
1476            fn is_loaded(&self) -> bool {
1477                false
1478            }
1479            fn load(&mut self) -> anyhow::Result<()> {
1480                anyhow::bail!(r#"DriverError(CUDA_ERROR_OUT_OF_MEMORY, "out of memory")"#)
1481            }
1482            fn unload(&mut self) {}
1483        }
1484
1485        let cache = ModelCache::new(3);
1486        let model_name = "ltx-video-0.9.8-13b-dev:bf16";
1487
1488        // Simulate the load path: create the engine, attempt load, only
1489        // insert on success. This mirrors the exact control flow in
1490        // `ensure_model_ready_sync`.
1491        let mut engine: Box<dyn InferenceEngine> = Box::new(FailingLoadEngine {
1492            name: model_name.to_string(),
1493        });
1494        let load_result = engine.load();
1495
1496        assert!(
1497            load_result.is_err(),
1498            "engine.load() must fail for this test to be meaningful"
1499        );
1500        assert!(
1501            is_cuda_oom(load_result.as_ref().unwrap_err()),
1502            "load error must be classified as OOM"
1503        );
1504
1505        // Crucially: we do NOT call cache.insert_loaded() on failure.
1506        // The cache must remain empty.
1507        assert!(
1508            !cache.contains(model_name),
1509            "cache must not contain the model after a failed load — \
1510             `insert_loaded` must only be called on success"
1511        );
1512        assert!(
1513            cache.is_empty(),
1514            "cache must be completely empty after a failed load"
1515        );
1516    }
1517}