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