Skip to main content

mold_server/
queue.rs

1use std::collections::VecDeque;
2use std::sync::Arc;
3
4use base64::Engine as _;
5use mold_core::{
6    ImageData, OutputFormat, OutputMetadata, SseCompleteEvent, SseErrorEvent, SseProgressEvent,
7};
8use mold_db::{GenerationRecord, MetadataDb, RecordSource};
9use sha2::{Digest, Sha256};
10use std::sync::atomic::Ordering;
11use std::time::{Instant, SystemTime, UNIX_EPOCH};
12
13use crate::gpu_pool::GpuJob;
14use crate::model_manager;
15use crate::state::{
16    ActiveGenerationSnapshot, AppState, GenerationJob, GenerationJobResult, SseMessage,
17};
18
19/// Convert an inference-crate progress event to an SSE wire event.
20fn progress_to_sse(event: mold_inference::ProgressEvent) -> SseProgressEvent {
21    event.into()
22}
23
24/// Strips backtrace frames from candle error messages.
25///
26/// Renders the full anyhow cause chain (`{:#}`) so wrappers like
27/// `with_context("mmap single-file checkpoint at …")` carry their root cause
28/// through to the wire — otherwise users see the outer wrapper only.
29pub(crate) fn clean_error_message(e: &anyhow::Error) -> String {
30    let full = format!("{e:#}");
31    let mut lines: Vec<&str> = Vec::new();
32    for line in full.lines() {
33        let trimmed = line.trim_start();
34        if (trimmed.starts_with("0:") || trimmed.starts_with("1:"))
35            && trimmed.len() > 3
36            && trimmed
37                .as_bytes()
38                .first()
39                .is_some_and(|b| b.is_ascii_digit())
40        {
41            break;
42        }
43        if trimmed.len() > 2
44            && trimmed.as_bytes()[0].is_ascii_digit()
45            && trimmed.contains("::")
46            && trimmed.contains("at ")
47        {
48            break;
49        }
50        lines.push(line);
51    }
52    let msg = lines.join("\n").trim().to_string();
53    if msg.is_empty() {
54        format!("{}", e.root_cause())
55    } else {
56        msg
57    }
58}
59
60fn set_active_generation(state: &AppState, model: &str, prompt: &str) {
61    let prompt_sha256 = format!("{:x}", Sha256::digest(prompt.as_bytes()));
62    let started_at_unix_ms = SystemTime::now()
63        .duration_since(UNIX_EPOCH)
64        .unwrap_or_default()
65        .as_millis() as u64;
66
67    let mut active = state
68        .active_generation
69        .write()
70        .unwrap_or_else(|e| e.into_inner());
71    *active = Some(ActiveGenerationSnapshot {
72        model: model.to_string(),
73        prompt_sha256,
74        started_at_unix_ms,
75        started_at: Instant::now(),
76    });
77}
78
79fn clear_active_generation(state: &AppState) {
80    let mut active = state
81        .active_generation
82        .write()
83        .unwrap_or_else(|e| e.into_inner());
84    *active = None;
85}
86
87/// Save an image to disk and (best-effort) record a row in the metadata DB.
88///
89/// Errors writing to disk are logged and skipped. DB errors are also logged
90/// but do not fail the save — the file is the source of truth.
91///
92/// Shared between the legacy single-GPU `process_job` (this file) and the
93/// per-GPU worker (`gpu_worker.rs`). Keep these on one helper so the DB
94/// upsert can never silently regress on one path while the other keeps
95/// working.
96pub(crate) fn save_image_to_dir(
97    dir: &std::path::Path,
98    img: &mold_core::ImageData,
99    model: &str,
100    batch_size: u32,
101    metadata: Option<&OutputMetadata>,
102    generation_time_ms: Option<i64>,
103    db: Option<&MetadataDb>,
104) {
105    if let Err(e) = std::fs::create_dir_all(dir) {
106        tracing::warn!("failed to create output dir {}: {e}", dir.display());
107        return;
108    }
109    let now = SystemTime::now()
110        .duration_since(UNIX_EPOCH)
111        .unwrap_or_default();
112    let timestamp_ms = now.as_millis() as u64;
113    let ext = img.format.to_string();
114    let filename =
115        mold_core::default_output_filename(model, timestamp_ms, &ext, batch_size, img.index);
116    let path = dir.join(&filename);
117    match std::fs::write(&path, &img.data) {
118        Ok(()) => tracing::info!("saved image to {}", path.display()),
119        Err(e) => {
120            tracing::warn!("failed to save image to {}: {e}", path.display());
121            return;
122        }
123    }
124    if let (Some(db), Some(meta)) = (db, metadata) {
125        let mut rec = GenerationRecord::from_save(
126            dir,
127            filename,
128            img.format,
129            meta.clone(),
130            RecordSource::Server,
131            now.as_millis() as i64,
132        );
133        rec.stat_from_disk(&path);
134        rec.generation_time_ms = generation_time_ms;
135        rec.hostname = hostname_string();
136        rec.backend = current_backend_label();
137        if let Err(e) = db.upsert(&rec) {
138            tracing::warn!("metadata DB upsert failed for {}: {e:#}", rec.filename);
139        }
140    }
141}
142
143/// Save a video file to disk and (best-effort) record its metadata row.
144/// Mirrors `save_image_to_dir` for the video-output path. See that helper
145/// for the multi-path-callers note.
146///
147/// When `gif_preview` is non-empty, also persists
148/// `$MOLD_HOME/cache/previews/<filename>.preview.gif`. The gallery preview
149/// endpoint (`GET /api/gallery/preview/:filename`) streams from that path
150/// so remote TUI clients can animate the detail pane without re-fetching
151/// the full MP4.
152#[allow(clippy::too_many_arguments)]
153pub(crate) fn save_video_to_dir(
154    dir: &std::path::Path,
155    bytes: &[u8],
156    gif_preview: &[u8],
157    format: OutputFormat,
158    model: &str,
159    metadata: &OutputMetadata,
160    generation_time_ms: Option<i64>,
161    db: Option<&MetadataDb>,
162) {
163    if let Err(e) = std::fs::create_dir_all(dir) {
164        tracing::warn!("failed to create output dir {}: {e}", dir.display());
165        return;
166    }
167    let now = SystemTime::now()
168        .duration_since(UNIX_EPOCH)
169        .unwrap_or_default();
170    let ts = now.as_millis() as u64;
171    let ext = format.extension();
172    let filename = mold_core::default_output_filename(model, ts, ext, 1, 0);
173    let path = dir.join(&filename);
174    if let Err(e) = std::fs::write(&path, bytes) {
175        tracing::error!("failed to save video to {}: {e}", path.display());
176        return;
177    }
178    if !gif_preview.is_empty() {
179        save_video_preview_gif(&filename, gif_preview);
180    }
181    if let Some(db) = db {
182        let mut rec = GenerationRecord::from_save(
183            dir,
184            filename,
185            format,
186            metadata.clone(),
187            RecordSource::Server,
188            now.as_millis() as i64,
189        );
190        rec.stat_from_disk(&path);
191        rec.generation_time_ms = generation_time_ms;
192        rec.hostname = hostname_string();
193        rec.backend = current_backend_label();
194        if let Err(e) = db.upsert(&rec) {
195            tracing::warn!("metadata DB upsert failed for {}: {e:#}", rec.filename);
196        }
197    }
198}
199
200fn requested_post_upscale_model(req: &mold_core::GenerateRequest) -> Option<&str> {
201    req.upscale_model
202        .as_deref()
203        .map(str::trim)
204        .filter(|m| !m.is_empty())
205}
206
207pub(crate) fn apply_output_dimensions_to_metadata(metadata: &mut OutputMetadata, img: &ImageData) {
208    metadata.width = img.width;
209    metadata.height = img.height;
210}
211
212pub(crate) fn apply_upscale_response_to_image_generation(
213    req: &mold_core::GenerateRequest,
214    response: &mut mold_core::GenerateResponse,
215    original: ImageData,
216    upscaled: mold_core::UpscaleResponse,
217) -> anyhow::Result<ImageData> {
218    if response.video.is_some() || requested_post_upscale_model(req).is_none() {
219        return Ok(original);
220    }
221    if upscaled.image.data.is_empty() {
222        anyhow::bail!("upscaler returned an empty image");
223    }
224    response.generation_time_ms = response
225        .generation_time_ms
226        .saturating_add(upscaled.upscale_time_ms);
227    Ok(ImageData {
228        index: original.index,
229        ..upscaled.image
230    })
231}
232
233async fn upscale_generated_image_on_single_worker(
234    state: &AppState,
235    req: &mold_core::GenerateRequest,
236    img: ImageData,
237    progress_tx: Option<&tokio::sync::mpsc::UnboundedSender<SseMessage>>,
238) -> Result<ImageData, String> {
239    let Some(upscale_model) = requested_post_upscale_model(req).map(str::to_string) else {
240        return Ok(img);
241    };
242    let model_name = mold_core::manifest::resolve_model_name(&upscale_model);
243    if let Some(tx) = progress_tx {
244        let _ = tx.send(SseMessage::Progress(SseProgressEvent::StageStart {
245            name: format!("Loading upscaler {model_name}"),
246        }));
247    }
248
249    let needs_pull = {
250        let config = state.config.read().await;
251        config
252            .models
253            .get(&model_name)
254            .and_then(|c| c.transformer.as_ref())
255            .is_none()
256    };
257    if needs_pull {
258        if mold_core::manifest::find_manifest(&model_name).is_none() {
259            return Err(format!("unknown upscaler model '{model_name}'"));
260        }
261        model_manager::pull_model(state, &model_name, None)
262            .await
263            .map_err(|e| format!("failed to pull upscaler model: {}", e.error))?;
264    }
265
266    let weights_path = {
267        let config = state.config.read().await;
268        config
269            .models
270            .get(&model_name)
271            .and_then(|c| c.transformer.as_ref())
272            .map(std::path::PathBuf::from)
273    }
274    .ok_or_else(|| format!("upscaler model '{model_name}' not configured after pull"))?;
275
276    let upscale_req = mold_core::UpscaleRequest {
277        model: model_name.clone(),
278        image: img.data.clone(),
279        output_format: img.format,
280        tile_size: None,
281    };
282    let upscaler_cache = state.upscaler_cache.clone();
283    let progress_tx_for_blocking = progress_tx.cloned();
284    let upscaled =
285        tokio::task::spawn_blocking(move || -> anyhow::Result<mold_core::UpscaleResponse> {
286            let mut cache = upscaler_cache.lock().unwrap_or_else(|e| e.into_inner());
287            let needs_new = cache.as_ref().is_none_or(|e| e.model_name() != model_name);
288            if needs_new {
289                let new_engine = mold_inference::create_upscale_engine(
290                    model_name.clone(),
291                    weights_path,
292                    mold_inference::LoadStrategy::Eager,
293                    0,
294                )?;
295                *cache = Some(new_engine);
296            }
297            let engine = cache.as_mut().unwrap();
298            if let Some(tx) = progress_tx_for_blocking {
299                engine.set_on_progress(Box::new(move |event| {
300                    let _ = tx.send(SseMessage::Progress(progress_to_sse(event)));
301                }));
302            }
303            let result = engine.upscale(&upscale_req);
304            engine.clear_on_progress();
305            result
306        })
307        .await
308        .map_err(|e| format!("upscale task failed: {e}"))?
309        .map_err(|e| format!("upscale failed: {e}"))?;
310
311    let mut response = mold_core::GenerateResponse {
312        images: vec![],
313        video: None,
314        generation_time_ms: 0,
315        model: req.model.clone(),
316        seed_used: req.seed.unwrap_or(0),
317        gpu: None,
318    };
319    apply_upscale_response_to_image_generation(req, &mut response, img, upscaled)
320        .map_err(|e| format!("upscale failed: {e}"))
321}
322
323/// Persist a video's `.preview.gif` sidecar to the server's preview cache
324/// (`$MOLD_HOME/cache/previews/<filename>.preview.gif`). Best-effort —
325/// warnings log and return so a failure here never fails the save path.
326///
327/// Shared with the multi-GPU worker path (`gpu_worker::process_job`) so
328/// video outputs land a preview regardless of which save flow wrote the
329/// MP4; otherwise `/api/gallery/preview/:filename` would 404 whenever the
330/// server is running with GPU workers enabled.
331pub(crate) fn save_video_preview_gif(filename: &str, gif_bytes: &[u8]) {
332    let preview_dir = mold_core::Config::mold_dir()
333        .unwrap_or_else(|| std::path::PathBuf::from(".mold"))
334        .join("cache")
335        .join("previews");
336    save_video_preview_gif_to(&preview_dir, filename, gif_bytes);
337}
338
339/// Testable inner of [`save_video_preview_gif`] that accepts an explicit
340/// preview directory (lets unit tests exercise the write path without
341/// racing on the `MOLD_HOME` env var).
342fn save_video_preview_gif_to(preview_dir: &std::path::Path, filename: &str, gif_bytes: &[u8]) {
343    if let Err(e) = std::fs::create_dir_all(preview_dir) {
344        tracing::warn!(
345            "failed to create preview cache dir {}: {e}",
346            preview_dir.display()
347        );
348        return;
349    }
350    let preview_path = preview_dir.join(format!("{filename}.preview.gif"));
351    if let Err(e) = std::fs::write(&preview_path, gif_bytes) {
352        tracing::warn!(
353            "failed to write preview gif {}: {e}",
354            preview_path.display()
355        );
356    }
357}
358
359/// Best-effort hostname for the `hostname` DB column. Falls back to `None`.
360fn hostname_string() -> Option<String> {
361    hostname::get().ok().and_then(|s| s.into_string().ok())
362}
363
364/// Compile-time backend label so DB rows say where the work happened.
365fn current_backend_label() -> Option<String> {
366    if cfg!(feature = "cuda") {
367        Some("cuda".into())
368    } else if cfg!(feature = "metal") {
369        Some("metal".into())
370    } else {
371        Some("cpu".into())
372    }
373}
374
375/// Build the SSE `complete` wire event from a finished generation response.
376///
377/// Video responses encode the actual video bytes (MP4/GIF/APNG/WebP) as the
378/// payload and populate every `video_*` metadata field; image responses
379/// encode the image bytes with the video fields cleared. `img` is the
380/// `ImageData` chosen by the caller — either the first generated image or an
381/// `ImageData` synthesized from the video thumbnail (the single-primary-image
382/// shape that the internal `GenerationJobResult` still expects).
383///
384/// Shared between the single-GPU path (`process_job` in this file) and the
385/// multi-GPU path (`gpu_worker::process_job`) so the two can never drift on
386/// which `video_*` fields are populated. Before this helper existed the
387/// multi-GPU worker always encoded the thumbnail PNG as the payload and
388/// hard-coded every `video_*` field to `None`, which silently degraded every
389/// LTX-Video / LTX-2 generation into an image response on hosts with at
390/// least one GPU worker.
391pub(crate) fn build_sse_complete_event(
392    response: &mold_core::GenerateResponse,
393    img: &mold_core::ImageData,
394) -> SseCompleteEvent {
395    let b64 = base64::engine::general_purpose::STANDARD;
396    if let Some(ref video) = response.video {
397        SseCompleteEvent {
398            image: b64.encode(&video.data),
399            format: video.format,
400            width: video.width,
401            height: video.height,
402            seed_used: response.seed_used,
403            generation_time_ms: response.generation_time_ms,
404            model: response.model.clone(),
405            video_frames: Some(video.frames),
406            video_fps: Some(video.fps),
407            video_thumbnail: Some(b64.encode(&video.thumbnail)),
408            video_gif_preview: if video.gif_preview.is_empty() {
409                None
410            } else {
411                Some(b64.encode(&video.gif_preview))
412            },
413            video_has_audio: video.has_audio,
414            video_duration_ms: video.duration_ms,
415            video_audio_sample_rate: video.audio_sample_rate,
416            video_audio_channels: video.audio_channels,
417            gpu: response.gpu,
418        }
419    } else {
420        SseCompleteEvent {
421            image: b64.encode(&img.data),
422            format: img.format,
423            width: img.width,
424            height: img.height,
425            seed_used: response.seed_used,
426            generation_time_ms: response.generation_time_ms,
427            model: response.model.clone(),
428            video_frames: None,
429            video_fps: None,
430            video_thumbnail: None,
431            video_gif_preview: None,
432            video_has_audio: false,
433            video_duration_ms: None,
434            video_audio_sample_rate: None,
435            video_audio_channels: None,
436            gpu: response.gpu,
437        }
438    }
439}
440
441/// Runs the generation queue worker loop. Processes one job at a time (FIFO),
442/// but uses a small bounded lookahead buffer to prefer jobs whose model is
443/// already loaded — minimizing model swaps when the queue interleaves models.
444/// Exits when the sender half of the channel is dropped (server shutdown).
445pub async fn run_queue_worker(
446    mut job_rx: tokio::sync::mpsc::Receiver<GenerationJob>,
447    state: AppState,
448) {
449    tracing::debug!("generation queue worker started");
450    let buffer_size = resolve_lookahead_buffer();
451    let max_deferrals = resolve_max_deferrals();
452    let mut buffer: VecDeque<BufferedJob> = VecDeque::with_capacity(buffer_size);
453
454    loop {
455        if buffer.is_empty() {
456            match job_rx.recv().await {
457                Some(j) => buffer.push_back(BufferedJob::new(j)),
458                None => break,
459            }
460        }
461        // Top up the buffer without blocking — drain the channel up to capacity.
462        top_up_buffer(&mut buffer, &mut job_rx, buffer_size);
463
464        let loaded = single_gpu_loaded_models(&state).await;
465        let job = pick_next_job(&mut buffer, &loaded, max_deferrals);
466        let job_id = job.id.clone();
467
468        #[cfg(feature = "metrics")]
469        crate::metrics::record_queue_depth(state.queue.pending());
470        process_job(&state, job).await;
471        state.queue.decrement();
472        // Drop the registry entry on every terminal path — the worker
473        // here doesn't own a drop guard, so we do it inline alongside
474        // the queue counter decrement.
475        state.job_registry.remove(&job_id);
476        #[cfg(feature = "metrics")]
477        crate::metrics::record_queue_depth(state.queue.pending());
478    }
479    tracing::info!("generation queue worker shutting down");
480}
481
482async fn single_gpu_loaded_models(state: &AppState) -> std::collections::HashSet<String> {
483    let mut set = std::collections::HashSet::new();
484    let cache = state.model_cache.lock().await;
485    if let Some(name) = cache.active_model() {
486        set.insert(name.to_string());
487    }
488    set
489}
490
491/// Build the set of "currently loaded somewhere" model names across every
492/// worker in the multi-GPU pool. A worker counts the model as loaded if
493/// either it's in the worker's cache as Gpu-resident OR it's the worker's
494/// `active_generation` (covering the take-and-restore window where the
495/// cache entry briefly disappears).
496fn multi_gpu_loaded_models(state: &AppState) -> std::collections::HashSet<String> {
497    let mut set = std::collections::HashSet::new();
498    for worker in &state.gpu_pool.workers {
499        if let Ok(active_gen) = worker.active_generation.read() {
500            if let Some(g) = active_gen.as_ref() {
501                set.insert(g.model.clone());
502            }
503        }
504        if let Ok(cache) = worker.model_cache.lock() {
505            if let Some(name) = cache.active_model() {
506                set.insert(name.to_string());
507            }
508        }
509    }
510    set
511}
512
513/// In-flight wrapper that tracks how many times the picker has skipped this
514/// job. Once the count exceeds `max_deferrals`, the picker force-dispatches
515/// it to bound starvation.
516pub(crate) struct BufferedJob {
517    pub(crate) job: GenerationJob,
518    pub(crate) deferred: usize,
519}
520
521impl BufferedJob {
522    fn new(job: GenerationJob) -> Self {
523        Self { job, deferred: 0 }
524    }
525}
526
527/// Drain the receive channel into the lookahead buffer, capped at
528/// `buffer_size`. Returns when the buffer is full or the channel has no
529/// immediately-available jobs (the receiver is unchanged on `Empty`). Pure
530/// helper extracted so tests can lock in the cap as a load-bearing invariant
531/// without spinning up the full async dispatcher.
532pub(crate) fn top_up_buffer(
533    buffer: &mut VecDeque<BufferedJob>,
534    job_rx: &mut tokio::sync::mpsc::Receiver<GenerationJob>,
535    buffer_size: usize,
536) {
537    while buffer.len() < buffer_size {
538        match job_rx.try_recv() {
539            Ok(j) => buffer.push_back(BufferedJob::new(j)),
540            Err(_) => break,
541        }
542    }
543}
544
545/// Pure picker for the lookahead buffer. Selects the buffered job whose
546/// model is already loaded somewhere in `loaded`; ties broken by arrival
547/// order (front of the deque wins). The head's `deferred` count bounds
548/// starvation: if the head has been skipped `max_deferrals` times, it wins
549/// regardless of `loaded` membership.
550///
551/// The returned job is removed from the buffer; remaining buffered jobs that
552/// were skipped have their `deferred` count incremented. Increments
553/// `mold_queue_reorders_total` whenever a non-head job is picked.
554pub(crate) fn pick_next_job(
555    buffer: &mut VecDeque<BufferedJob>,
556    loaded: &std::collections::HashSet<String>,
557    max_deferrals: usize,
558) -> GenerationJob {
559    debug_assert!(
560        !buffer.is_empty(),
561        "pick_next_job requires non-empty buffer"
562    );
563
564    // Force-dispatch the head if it's hit the starvation budget.
565    if let Some(head) = buffer.pop_front_if(|head| head.deferred >= max_deferrals) {
566        return head.job;
567    }
568
569    // Find the front-most buffered job whose model is already loaded.
570    let pick_idx = buffer
571        .iter()
572        .position(|b| loaded.contains(&b.job.request.model))
573        .unwrap_or(0);
574
575    if pick_idx > 0 {
576        for (i, b) in buffer.iter_mut().enumerate() {
577            if i < pick_idx {
578                b.deferred += 1;
579            }
580        }
581        let model = buffer[pick_idx].job.request.model.clone();
582        tracing::debug!(
583            picked_model = %model,
584            head_model = %buffer.front().map(|b| b.job.request.model.as_str()).unwrap_or(""),
585            picked_index = pick_idx,
586            "queue reorder picked non-head job"
587        );
588        #[cfg(feature = "metrics")]
589        crate::metrics::record_queue_reorder();
590    }
591
592    buffer.remove(pick_idx).expect("pick_idx in range").job
593}
594
595pub(crate) const DEFAULT_LOOKAHEAD_BUFFER: usize = 8;
596pub(crate) const DEFAULT_MAX_DEFERRALS: usize = 3;
597pub(crate) const LOOKAHEAD_BUFFER_ENV: &str = "MOLD_QUEUE_LOOKAHEAD_BUFFER";
598pub(crate) const MAX_DEFERRALS_ENV: &str = "MOLD_QUEUE_MAX_DEFERRALS";
599const LOOKAHEAD_BUFFER_LOWER: usize = 1;
600const LOOKAHEAD_BUFFER_UPPER: usize = 64;
601const MAX_DEFERRALS_UPPER: usize = 32;
602
603/// Resolve the lookahead buffer size from env, falling back to the default.
604/// Out-of-range or unparseable values log a warning and use the default —
605/// matching the warn-then-default pattern of `resolve_max_cached_models`.
606pub(crate) fn resolve_lookahead_buffer() -> usize {
607    match std::env::var(LOOKAHEAD_BUFFER_ENV) {
608        Ok(raw) => match raw.trim().parse::<usize>() {
609            Ok(n) if (LOOKAHEAD_BUFFER_LOWER..=LOOKAHEAD_BUFFER_UPPER).contains(&n) => n,
610            Ok(n) => {
611                tracing::warn!(
612                    env = LOOKAHEAD_BUFFER_ENV,
613                    value = n,
614                    lower = LOOKAHEAD_BUFFER_LOWER,
615                    upper = LOOKAHEAD_BUFFER_UPPER,
616                    "ignoring out-of-range queue lookahead buffer; using default"
617                );
618                DEFAULT_LOOKAHEAD_BUFFER
619            }
620            Err(e) => {
621                tracing::warn!(
622                    env = LOOKAHEAD_BUFFER_ENV,
623                    raw = %raw,
624                    error = %e,
625                    "ignoring unparseable queue lookahead buffer; using default"
626                );
627                DEFAULT_LOOKAHEAD_BUFFER
628            }
629        },
630        Err(_) => DEFAULT_LOOKAHEAD_BUFFER,
631    }
632}
633
634/// Resolve the max-deferrals starvation budget from env. Out-of-range or
635/// unparseable values log a warning and use the default.
636pub(crate) fn resolve_max_deferrals() -> usize {
637    match std::env::var(MAX_DEFERRALS_ENV) {
638        Ok(raw) => match raw.trim().parse::<usize>() {
639            Ok(n) if n <= MAX_DEFERRALS_UPPER => n,
640            Ok(n) => {
641                tracing::warn!(
642                    env = MAX_DEFERRALS_ENV,
643                    value = n,
644                    upper = MAX_DEFERRALS_UPPER,
645                    "ignoring out-of-range queue max-deferrals; using default"
646                );
647                DEFAULT_MAX_DEFERRALS
648            }
649            Err(e) => {
650                tracing::warn!(
651                    env = MAX_DEFERRALS_ENV,
652                    raw = %raw,
653                    error = %e,
654                    "ignoring unparseable queue max-deferrals; using default"
655                );
656                DEFAULT_MAX_DEFERRALS
657            }
658        },
659        Err(_) => DEFAULT_MAX_DEFERRALS,
660    }
661}
662
663async fn process_job(state: &AppState, job: GenerationJob) {
664    // Check if client already disconnected before doing any work
665    if job.result_tx.is_closed() {
666        tracing::debug!("skipping queued job — client disconnected");
667        return;
668    }
669
670    // Single-GPU path: there's only one slot. `gpu=None` keeps the wire
671    // shape consistent with multi-GPU even when we don't know the ordinal.
672    state.job_registry.mark_running(&job.id, None);
673
674    // Send "now processing" event (position 0). `id` echoes the
675    // server-assigned UUID so reconnecting clients can match progress
676    // updates to their persisted card.
677    if let Some(ref tx) = job.progress_tx {
678        let _ = tx.send(SseMessage::Progress(SseProgressEvent::Queued {
679            position: 0,
680            id: job.id.clone(),
681        }));
682    }
683
684    // 1. Ensure model is ready (with progress forwarding)
685    let progress_callback = job.progress_tx.as_ref().map(|tx| {
686        let tx = tx.clone();
687        Arc::new(move |event: mold_inference::ProgressEvent| {
688            let _ = tx.send(SseMessage::Progress(progress_to_sse(event)));
689        }) as model_manager::EngineProgressCallback
690    });
691
692    let activation_hint = model_manager::activation_hint_for_request(state, &job.request).await;
693    let request_has_lora = model_manager::request_has_effective_lora(&job.request);
694    if let Err(api_err) = model_manager::ensure_model_ready(
695        state,
696        &job.request.model,
697        progress_callback,
698        activation_hint,
699        request_has_lora,
700    )
701    .await
702    {
703        let err_msg = api_err.error.clone();
704        if let Some(ref tx) = job.progress_tx {
705            let _ = tx.send(SseMessage::Error(SseErrorEvent {
706                message: err_msg.clone(),
707            }));
708        }
709        let _ = job.result_tx.send(Err(err_msg));
710        return;
711    }
712
713    // 2. Low-memory warning (MPS/unified memory only — observability aid)
714    #[cfg(target_os = "macos")]
715    if let Some(available) = mold_inference::device::available_system_memory_bytes() {
716        if available < 1_000_000_000 {
717            tracing::warn!(
718                available_mb = available / 1_000_000,
719                "low memory before inference — system may become unstable"
720            );
721        }
722    }
723
724    // 3. Take the engine out of the cache so the cache mutex stays free during
725    //    generation. Mirrors the multi-GPU `gpu_worker::process_job` pattern —
726    //    holding the cache lock through inference would block /api/models,
727    //    /api/cache, and any concurrent gallery/admin reads.
728    let taken = {
729        let mut cache = state.model_cache.lock().await;
730        cache.take(&job.request.model)
731    };
732    let Some(mut cached_engine) = taken else {
733        let err_msg = "no engine available after model readiness check".to_string();
734        if let Some(ref tx) = job.progress_tx {
735            let _ = tx.send(SseMessage::Error(SseErrorEvent {
736                message: err_msg.clone(),
737            }));
738        }
739        let _ = job.result_tx.send(Err(err_msg));
740        return;
741    };
742
743    let active_gen = state.active_generation.clone();
744    let gen_req = job.request.clone();
745    let progress_tx = job.progress_tx.clone();
746
747    set_active_generation(state, &job.request.model, &job.request.prompt);
748
749    // Install progress callback before crossing into spawn_blocking — keeps
750    // the callback installation off the blocking thread. Mirrors the pre-
751    // refactor behavior: when streaming, set the callback; when not, clear
752    // it (and only clear after generate when streaming).
753    let was_streaming = progress_tx.is_some();
754    if let Some(ref ptx) = progress_tx {
755        let ptx = ptx.clone();
756        cached_engine.engine.set_on_progress(Box::new(move |event| {
757            let _ = ptx.send(SseMessage::Progress(progress_to_sse(event)));
758        }));
759    } else {
760        cached_engine.engine.clear_on_progress();
761    }
762
763    #[cfg(feature = "metrics")]
764    let inference_start = Instant::now();
765    // RSS sample taken just before inference; the post-inference sample below
766    // logs the per-job delta so RAM growth can be attributed to a specific
767    // generation rather than tracked at process granularity.
768    let rss_before = crate::resources::ram_snapshot().used_by_mold;
769    // Run generation on the blocking pool. Move the engine in, return it back
770    // out (alongside the result + any panic payload) so we can restore it to
771    // the cache in async context regardless of outcome.
772    let join_result = tokio::task::spawn_blocking(move || {
773        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
774            cached_engine.engine.generate(&gen_req)
775        }));
776        if was_streaming {
777            cached_engine.engine.clear_on_progress();
778        }
779        (cached_engine, result)
780    })
781    .await;
782
783    let rss_after = crate::resources::ram_snapshot().used_by_mold;
784    let rss_delta = rss_after as i64 - rss_before as i64;
785    tracing::info!(
786        model = %job.request.model,
787        rss_before_mb = rss_before / 1_000_000,
788        rss_after_mb = rss_after / 1_000_000,
789        rss_delta_mb = rss_delta / 1_000_000,
790        "generation memory delta"
791    );
792
793    #[cfg(feature = "metrics")]
794    let inference_duration = inference_start.elapsed().as_secs_f64();
795
796    // Restore the engine to the cache as soon as the blocking task joins —
797    // even panics must restore so the cache isn't left with a hole. If the
798    // tokio task itself failed (JoinError), the engine is gone — restoration
799    // is impossible. Without `clear_in_flight` the model name would leak
800    // forever in `in_flight`, so `ensure_model_ready` keeps fast-pathing
801    // through `contains()` while every subsequent `take()` returns `None`,
802    // permanently jamming this model. Clear the marker so the cache will
803    // legitimately re-load the engine on the next request.
804    let result = match join_result {
805        Ok((cached_engine, panic_or_result)) => {
806            {
807                let mut cache = state.model_cache.lock().await;
808                cache.restore(cached_engine);
809            }
810            clear_active_generation(state);
811            Ok(panic_or_result)
812        }
813        Err(join_err) => {
814            {
815                let mut cache = state.model_cache.lock().await;
816                cache.clear_in_flight(&job.request.model);
817            }
818            clear_active_generation(state);
819            Err(join_err)
820        }
821    };
822
823    match result {
824        Ok(Ok(Ok(mut response))) => {
825            #[cfg(feature = "metrics")]
826            crate::metrics::record_generation(&job.request.model, inference_duration);
827
828            if response.images.is_empty() && response.video.is_none() {
829                let err_msg = "generation error: engine returned no images or video".to_string();
830                if let Some(ref tx) = job.progress_tx {
831                    let _ = tx.send(SseMessage::Error(SseErrorEvent {
832                        message: err_msg.clone(),
833                    }));
834                }
835                let _ = job.result_tx.send(Err(err_msg));
836                return;
837            }
838            // For video-only responses, synthesize an ImageData from the thumbnail
839            // so the existing queue/SSE pipeline can handle it.
840            let mut img = if !response.images.is_empty() {
841                response.images.remove(0)
842            } else if let Some(ref video) = response.video {
843                ImageData {
844                    data: video.thumbnail.clone(),
845                    format: OutputFormat::Png,
846                    width: video.width,
847                    height: video.height,
848                    index: 0,
849                }
850            } else {
851                unreachable!("checked above");
852            };
853            if response.video.is_none() && requested_post_upscale_model(&job.request).is_some() {
854                match upscale_generated_image_on_single_worker(
855                    state,
856                    &job.request,
857                    img,
858                    job.progress_tx.as_ref(),
859                )
860                .await
861                {
862                    Ok(upscaled) => {
863                        img = upscaled;
864                    }
865                    Err(err_msg) => {
866                        if let Some(ref tx) = job.progress_tx {
867                            let _ = tx.send(SseMessage::Error(SseErrorEvent {
868                                message: err_msg.clone(),
869                            }));
870                        }
871                        let _ = job.result_tx.send(Err(err_msg));
872                        return;
873                    }
874                }
875            }
876
877            // Save to output directory if configured.
878            // Builds OutputMetadata from the request + the engine's actual
879            // seed_used so the DB and embedded chunks agree.
880            if let Some(ref dir) = job.output_dir {
881                let dir = dir.clone();
882                let model = job.request.model.clone();
883                let batch_size = job.request.batch_size;
884                let generation_time_ms = response.generation_time_ms as i64;
885                let mut metadata = OutputMetadata::from_generate_request(
886                    &job.request,
887                    response.seed_used,
888                    None,
889                    mold_core::build_info::version_string(),
890                );
891                if response.video.is_none() {
892                    apply_output_dimensions_to_metadata(&mut metadata, &img);
893                }
894                let db = state.metadata_db.clone();
895                if let Some(ref video) = response.video {
896                    let video_data = video.data.clone();
897                    let video_gif_preview = video.gif_preview.clone();
898                    let video_format = video.format;
899                    let video_metadata = metadata.clone();
900                    tokio::task::spawn_blocking(move || {
901                        save_video_to_dir(
902                            &dir,
903                            &video_data,
904                            &video_gif_preview,
905                            video_format,
906                            &model,
907                            &video_metadata,
908                            Some(generation_time_ms),
909                            db.as_ref().as_ref(),
910                        );
911                    });
912                } else {
913                    let img_clone = img.clone();
914                    let metadata_clone = metadata.clone();
915                    tokio::task::spawn_blocking(move || {
916                        save_image_to_dir(
917                            &dir,
918                            &img_clone,
919                            &model,
920                            batch_size,
921                            Some(&metadata_clone),
922                            Some(generation_time_ms),
923                            db.as_ref().as_ref(),
924                        );
925                    });
926                }
927            }
928
929            // Send SSE complete event
930            if let Some(ref tx) = job.progress_tx {
931                let event = build_sse_complete_event(&response, &img);
932                let _ = tx.send(SseMessage::Complete(event));
933            }
934
935            // Send result through oneshot
936            let _ = job.result_tx.send(Ok(GenerationJobResult {
937                image: img,
938                response,
939            }));
940        }
941        Ok(Ok(Err(e))) => {
942            #[cfg(feature = "metrics")]
943            crate::metrics::record_generation_error(&job.request.model);
944
945            *active_gen.write().unwrap_or_else(|e| e.into_inner()) = None;
946            tracing::error!("generation error: {e:#}");
947            let err_msg = format!("generation error: {}", clean_error_message(&e));
948            if let Some(ref tx) = job.progress_tx {
949                let _ = tx.send(SseMessage::Error(SseErrorEvent {
950                    message: err_msg.clone(),
951                }));
952            }
953            let _ = job.result_tx.send(Err(err_msg));
954        }
955        Ok(Err(panic_payload)) => {
956            #[cfg(feature = "metrics")]
957            crate::metrics::record_generation_error(&job.request.model);
958
959            *active_gen.write().unwrap_or_else(|e| e.into_inner()) = None;
960            let msg = panic_payload
961                .downcast_ref::<String>()
962                .map(|s| s.as_str())
963                .or_else(|| panic_payload.downcast_ref::<&str>().copied())
964                .unwrap_or("unknown panic");
965            tracing::error!("inference panicked: {msg}");
966            let err_msg = format!("inference panicked: {msg}");
967            if let Some(ref tx) = job.progress_tx {
968                let _ = tx.send(SseMessage::Error(SseErrorEvent {
969                    message: err_msg.clone(),
970                }));
971            }
972            let _ = job.result_tx.send(Err(err_msg));
973        }
974        Err(join_err) => {
975            #[cfg(feature = "metrics")]
976            crate::metrics::record_generation_error(&job.request.model);
977
978            *active_gen.write().unwrap_or_else(|e| e.into_inner()) = None;
979            tracing::error!("inference task join error: {join_err:?}");
980            let err_msg = "inference task failed".to_string();
981            if let Some(ref tx) = job.progress_tx {
982                let _ = tx.send(SseMessage::Error(SseErrorEvent {
983                    message: err_msg.clone(),
984                }));
985            }
986            let _ = job.result_tx.send(Err(err_msg));
987        }
988    }
989}
990
991// ── Multi-GPU queue dispatcher ──────────────────────────────────────────────
992
993/// Runs the multi-GPU dispatch loop. Routes each generation job to the best
994/// GPU worker based on the placement strategy (model-loaded > idle > evict LRU).
995/// Uses a small lookahead buffer so an interleaved queue (`[A, B, A, B]`)
996/// doesn't force a sibling worker to swap models when one already has the
997/// right one warm.
998///
999/// Exits when the sender half of the channel is dropped (server shutdown).
1000pub async fn run_queue_dispatcher(
1001    job_rx: tokio::sync::mpsc::Receiver<GenerationJob>,
1002    state: AppState,
1003) {
1004    tracing::debug!("multi-GPU queue dispatcher started");
1005    let buffer_size = resolve_lookahead_buffer();
1006    let max_deferrals = resolve_max_deferrals();
1007    run_queue_dispatcher_with_tuning(job_rx, state, buffer_size, max_deferrals).await;
1008}
1009
1010async fn run_queue_dispatcher_with_tuning(
1011    mut job_rx: tokio::sync::mpsc::Receiver<GenerationJob>,
1012    state: AppState,
1013    buffer_size: usize,
1014    max_deferrals: usize,
1015) {
1016    let mut buffer: VecDeque<BufferedJob> = VecDeque::with_capacity(buffer_size);
1017
1018    loop {
1019        if buffer.is_empty() {
1020            match job_rx.recv().await {
1021                Some(j) => buffer.push_back(BufferedJob::new(j)),
1022                None => break,
1023            }
1024        }
1025        top_up_buffer(&mut buffer, &mut job_rx, buffer_size);
1026
1027        let loaded = multi_gpu_loaded_models(&state);
1028        let job = pick_next_job(&mut buffer, &loaded, max_deferrals);
1029
1030        #[cfg(feature = "metrics")]
1031        crate::metrics::record_queue_depth(state.queue.pending());
1032
1033        let job_id = job.id.clone();
1034        let model_name = job.request.model.clone();
1035        let estimated_vram = estimate_model_vram(&model_name);
1036
1037        if let Some(err_msg) = crate::gpu_pool::model_unschedulable_message(&model_name) {
1038            tracing::warn!(model = %model_name, "{err_msg}");
1039            if let Some(tx) = job.progress_tx {
1040                let _ = tx.send(SseMessage::Error(SseErrorEvent {
1041                    message: err_msg.clone(),
1042                }));
1043            }
1044            let _ = job.result_tx.send(Err(err_msg));
1045            state.queue.decrement();
1046            state.job_registry.remove(&job_id);
1047            #[cfg(feature = "metrics")]
1048            crate::metrics::record_queue_depth(state.queue.pending());
1049            continue;
1050        }
1051
1052        let placement_gpu = match state
1053            .gpu_pool
1054            .resolve_explicit_placement_gpu(job.request.placement.as_ref())
1055        {
1056            Ok(ordinal) => ordinal,
1057            Err(err_msg) => {
1058                tracing::warn!(model = %model_name, "{err_msg}");
1059                if let Some(tx) = job.progress_tx {
1060                    let _ = tx.send(SseMessage::Error(SseErrorEvent {
1061                        message: err_msg.clone(),
1062                    }));
1063                }
1064                let _ = job.result_tx.send(Err(err_msg));
1065                state.queue.decrement();
1066                state.job_registry.remove(&job_id);
1067                #[cfg(feature = "metrics")]
1068                crate::metrics::record_queue_depth(state.queue.pending());
1069                continue;
1070            }
1071        };
1072        let preferred_gpu = state
1073            .job_registry
1074            .target_gpu(&job_id)
1075            .flatten()
1076            .or(placement_gpu);
1077
1078        if job.result_tx.is_closed() {
1079            tracing::debug!(model = %model_name, "skipping queued multi-GPU job — client disconnected");
1080            state.queue.decrement();
1081            state.job_registry.remove(&job_id);
1082            #[cfg(feature = "metrics")]
1083            crate::metrics::record_queue_depth(state.queue.pending());
1084            continue;
1085        }
1086
1087        // Build the GpuJob once; the retry loop moves it between attempts.
1088        let mut gpu_job = Some(GpuJob {
1089            id: job.id.clone(),
1090            model: model_name.clone(),
1091            request: job.request,
1092            progress_tx: job.progress_tx,
1093            result_tx: job.result_tx,
1094            output_dir: job.output_dir,
1095            config: state.config.clone(),
1096            metadata_db: state.metadata_db.clone(),
1097            queue: state.queue.clone(),
1098            registry: state.job_registry.clone(),
1099        });
1100
1101        let mut skip: Vec<usize> = if preferred_gpu.is_none() {
1102            let failed = crate::gpu_pool::failed_ordinals_for_model(&model_name);
1103            if failed.len() < state.gpu_pool.worker_count() {
1104                failed
1105            } else {
1106                Vec::new()
1107            }
1108        } else {
1109            Vec::new()
1110        };
1111        let mut dispatched = false;
1112
1113        while !dispatched {
1114            if gpu_job
1115                .as_ref()
1116                .is_some_and(|pending| pending.result_tx.is_closed())
1117            {
1118                tracing::debug!(
1119                    model = %model_name,
1120                    "dropping queued multi-GPU job before dispatch — client disconnected"
1121                );
1122                state.queue.decrement();
1123                state.job_registry.remove(&job_id);
1124                break;
1125            }
1126
1127            let worker = if let Some(ordinal) = preferred_gpu {
1128                state.gpu_pool.worker_by_ordinal(ordinal)
1129            } else {
1130                state
1131                    .gpu_pool
1132                    .select_worker_excluding(&model_name, estimated_vram, &skip)
1133            };
1134
1135            let Some(worker) = worker else {
1136                if preferred_gpu.is_none() && state.gpu_pool.worker_count() > 0 {
1137                    tracing::warn!(
1138                        model = %model_name,
1139                        "all GPU workers are temporarily unavailable; keeping job queued"
1140                    );
1141                    tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1142                    continue;
1143                }
1144                let rejected = gpu_job
1145                    .take()
1146                    .expect("gpu_job retained after failed dispatch");
1147                let err_msg = if state.gpu_pool.worker_count() == 0 {
1148                    format!("no GPU available for model {model_name}")
1149                } else if let Some(ordinal) = preferred_gpu {
1150                    format!("gpu:{ordinal} is not available for model {model_name}")
1151                } else {
1152                    format!("no GPU worker available for model {model_name}")
1153                };
1154                tracing::error!(model = %model_name, "{err_msg}");
1155                if let Some(tx) = rejected.progress_tx {
1156                    let _ = tx.send(SseMessage::Error(SseErrorEvent {
1157                        message: err_msg.clone(),
1158                    }));
1159                }
1160                let _ = rejected.result_tx.send(Err(err_msg));
1161                state.queue.decrement();
1162                state.job_registry.remove(&job_id);
1163                break;
1164            };
1165
1166            // Increment in-flight BEFORE sending to reserve the slot.
1167            worker.in_flight.fetch_add(1, Ordering::SeqCst);
1168            let pending = gpu_job.take().expect("gpu_job present in retry loop");
1169            if preferred_gpu.is_none() {
1170                let _ = state
1171                    .job_registry
1172                    .set_target_gpu(&job_id, Some(worker.gpu.ordinal));
1173            }
1174            match worker.job_tx.try_send(pending) {
1175                Ok(()) => {
1176                    dispatched = true;
1177                }
1178                Err(std::sync::mpsc::TrySendError::Full(j)) => {
1179                    worker.in_flight.fetch_sub(1, Ordering::SeqCst);
1180                    if preferred_gpu.is_none() {
1181                        let _ = state.job_registry.set_target_gpu(&job_id, None);
1182                    }
1183                    gpu_job = Some(j);
1184                    if preferred_gpu.is_none() {
1185                        skip.push(worker.gpu.ordinal);
1186                        if skip.len() >= state.gpu_pool.worker_count().max(1) {
1187                            skip.clear();
1188                            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1189                        }
1190                    } else {
1191                        tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1192                    }
1193                }
1194                Err(std::sync::mpsc::TrySendError::Disconnected(j)) => {
1195                    worker.in_flight.fetch_sub(1, Ordering::SeqCst);
1196                    if preferred_gpu.is_none() {
1197                        let _ = state.job_registry.set_target_gpu(&job_id, None);
1198                    }
1199                    tracing::warn!(
1200                        gpu = worker.gpu.ordinal,
1201                        "GPU worker disconnected — retrying dispatch"
1202                    );
1203                    gpu_job = Some(j);
1204                    if preferred_gpu.is_none() {
1205                        skip.push(worker.gpu.ordinal);
1206                    } else {
1207                        let rejected = gpu_job.take().expect("gpu_job retained after disconnect");
1208                        let err_msg = format!(
1209                            "gpu:{} disconnected while dispatching model {model_name}",
1210                            worker.gpu.ordinal
1211                        );
1212                        if let Some(tx) = rejected.progress_tx {
1213                            let _ = tx.send(SseMessage::Error(SseErrorEvent {
1214                                message: err_msg.clone(),
1215                            }));
1216                        }
1217                        let _ = rejected.result_tx.send(Err(err_msg));
1218                        state.queue.decrement();
1219                        state.job_registry.remove(&job_id);
1220                        break;
1221                    }
1222                }
1223            }
1224        }
1225        #[cfg(feature = "metrics")]
1226        crate::metrics::record_queue_depth(state.queue.pending());
1227    }
1228    tracing::info!("multi-GPU queue dispatcher shutting down");
1229}
1230
1231/// Rough VRAM estimate for a model (used for placement decisions).
1232pub fn estimate_model_vram(model_name: &str) -> u64 {
1233    // Use a simple heuristic based on model name patterns.
1234    // Quantized models are smaller; BF16/FP16 are larger.
1235    let lower = model_name.to_lowercase();
1236    if lower.contains("flux2")
1237        && lower.contains("9b")
1238        && (lower.contains(":bf16") || lower.contains(":fp16"))
1239    {
1240        32_000_000_000 // Klein-9B BF16 needs a 32GB-class card in practice.
1241    } else if lower.contains(":q4") {
1242        6_000_000_000 // ~6GB
1243    } else if lower.contains(":q8") || lower.contains(":fp8") {
1244        12_000_000_000 // ~12GB
1245    } else if lower.contains(":bf16") || lower.contains(":fp16") {
1246        24_000_000_000 // ~24GB
1247    } else if lower.contains("sd15") || lower.contains("sd1.5") {
1248        4_000_000_000 // ~4GB
1249    } else {
1250        // SDXL (~8GB) and other models default to 8GB.
1251        8_000_000_000
1252    }
1253}
1254
1255#[cfg(test)]
1256mod tests {
1257    use super::*;
1258    use crate::gpu_pool::{GpuPool, GpuWorker};
1259    use crate::model_cache::ModelCache;
1260    use crate::state::QueueHandle;
1261    use mold_core::{GenerateRequest, ImageData, ModelConfig, OutputFormat};
1262    use mold_db::MetadataDb;
1263    use mold_inference::device::DiscoveredGpu;
1264    use mold_inference::shared_pool::SharedPool;
1265    use std::sync::atomic::AtomicUsize;
1266    use std::sync::{Arc, Mutex, RwLock};
1267    use tempfile::TempDir;
1268
1269    /// A `GenerateRequest` with the bare minimum fields populated — enough to
1270    /// hand to `OutputMetadata::from_generate_request` in tests.
1271    fn fake_request(model: &str) -> GenerateRequest {
1272        GenerateRequest {
1273            prompt: "a cat".to_string(),
1274            negative_prompt: None,
1275            model: model.to_string(),
1276            width: 512,
1277            height: 512,
1278            steps: 4,
1279            guidance: 3.5,
1280            seed: Some(7),
1281            batch_size: 1,
1282            output_format: Some(OutputFormat::Png),
1283            embed_metadata: None,
1284            scheduler: None,
1285            cfg_plus: None,
1286            source_image: None,
1287            edit_images: None,
1288            strength: 0.75,
1289            mask_image: None,
1290            control_image: None,
1291            control_model: None,
1292            control_scale: 1.0,
1293            expand: None,
1294            original_prompt: None,
1295            lora: None,
1296            frames: None,
1297            fps: None,
1298            upscale_model: None,
1299            gif_preview: false,
1300            enable_audio: None,
1301            audio_file: None,
1302            audio_file_path: None,
1303            source_video: None,
1304            source_video_path: None,
1305            keyframes: None,
1306            pipeline: None,
1307            loras: None,
1308            retake_range: None,
1309            spatial_upscale: None,
1310            temporal_upscale: None,
1311            placement: None,
1312        }
1313    }
1314
1315    fn fake_image() -> ImageData {
1316        ImageData {
1317            // PNG magic bytes — the helpers don't validate, but this keeps
1318            // the on-disk file from being trivially mistaken for empty.
1319            data: vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A],
1320            format: OutputFormat::Png,
1321            width: 512,
1322            height: 512,
1323            index: 0,
1324        }
1325    }
1326
1327    fn test_worker(
1328        ordinal: usize,
1329        channel_size: usize,
1330    ) -> (
1331        Arc<GpuWorker>,
1332        std::sync::mpsc::Receiver<crate::gpu_pool::GpuJob>,
1333    ) {
1334        let (job_tx, job_rx) = std::sync::mpsc::sync_channel(channel_size);
1335        let worker = Arc::new(GpuWorker {
1336            gpu: DiscoveredGpu {
1337                ordinal,
1338                name: format!("gpu{ordinal}"),
1339                total_vram_bytes: 24_000_000_000,
1340                free_vram_bytes: 24_000_000_000,
1341            },
1342            model_cache: Arc::new(Mutex::new(ModelCache::new(3))),
1343            active_generation: Arc::new(RwLock::new(None)),
1344            model_load_lock: Arc::new(Mutex::new(())),
1345            shared_pool: Arc::new(Mutex::new(SharedPool::new())),
1346            in_flight: AtomicUsize::new(0),
1347            consecutive_failures: AtomicUsize::new(0),
1348            degraded_until: RwLock::new(None),
1349            job_tx,
1350        });
1351        (worker, job_rx)
1352    }
1353
1354    fn empty_test_state(config: mold_core::Config) -> crate::state::AppState {
1355        crate::state::AppState::empty(
1356            config,
1357            QueueHandle::new(tokio::sync::mpsc::channel(1).0),
1358            crate::state::AppState::empty_gpu_pool(),
1359            200,
1360        )
1361    }
1362
1363    #[test]
1364    fn save_image_to_dir_writes_file_and_creates_missing_dir() {
1365        let tmp = TempDir::new().unwrap();
1366        let nested = tmp.path().join("sub/output");
1367        assert!(!nested.exists());
1368
1369        save_image_to_dir(&nested, &fake_image(), "flux-dev:q4", 1, None, None, None);
1370
1371        assert!(nested.exists(), "save should mkdir -p");
1372        let entries: Vec<_> = std::fs::read_dir(&nested).unwrap().collect();
1373        assert_eq!(entries.len(), 1);
1374        let name = entries[0].as_ref().unwrap().file_name();
1375        let name_str = name.to_string_lossy();
1376        // Filename uses model-with-colon-replaced-by-dash + ms timestamp + .png.
1377        assert!(name_str.starts_with("mold-flux-dev-q4-"), "{name_str}");
1378        assert!(name_str.ends_with(".png"), "{name_str}");
1379    }
1380
1381    #[test]
1382    fn save_image_to_dir_includes_batch_index_when_batch_size_gt_1() {
1383        let tmp = TempDir::new().unwrap();
1384        let mut img = fake_image();
1385        img.index = 3;
1386        img.format = OutputFormat::Jpeg;
1387        img.data = vec![0xFF, 0xD8, 0xFF, 0xE0]; // JPEG magic
1388
1389        save_image_to_dir(tmp.path(), &img, "sdxl", 4, None, None, None);
1390
1391        let entries: Vec<_> = std::fs::read_dir(tmp.path()).unwrap().collect();
1392        let name = entries[0]
1393            .as_ref()
1394            .unwrap()
1395            .file_name()
1396            .to_string_lossy()
1397            .to_string();
1398        assert!(
1399            name.contains("-3.jpeg"),
1400            "expected batch index suffix: {name}"
1401        );
1402    }
1403
1404    #[test]
1405    fn save_image_to_dir_upserts_metadata_row_when_db_provided() {
1406        let tmp = TempDir::new().unwrap();
1407        let db = MetadataDb::open_in_memory().unwrap();
1408        let req = fake_request("flux-dev:q4");
1409        let meta = OutputMetadata::from_generate_request(&req, 42, None, "test-version");
1410
1411        save_image_to_dir(
1412            tmp.path(),
1413            &fake_image(),
1414            "flux-dev:q4",
1415            1,
1416            Some(&meta),
1417            Some(1234),
1418            Some(&db),
1419        );
1420
1421        let rows = db.list(Some(tmp.path())).unwrap();
1422        assert_eq!(rows.len(), 1, "exactly one DB row for the saved file");
1423        let rec = &rows[0];
1424        assert_eq!(rec.metadata.prompt, "a cat");
1425        assert_eq!(rec.metadata.seed, 42);
1426        assert_eq!(rec.metadata.version, "test-version");
1427        assert_eq!(rec.format, OutputFormat::Png);
1428        assert_eq!(rec.generation_time_ms, Some(1234));
1429        // stat_from_disk should have populated the size from the actual file.
1430        assert!(rec.file_size_bytes.unwrap_or(0) > 0);
1431    }
1432
1433    #[test]
1434    fn save_image_to_dir_skips_db_when_metadata_is_none() {
1435        let tmp = TempDir::new().unwrap();
1436        let db = MetadataDb::open_in_memory().unwrap();
1437
1438        save_image_to_dir(
1439            tmp.path(),
1440            &fake_image(),
1441            "flux-dev:q4",
1442            1,
1443            None, // ← metadata absent
1444            Some(1234),
1445            Some(&db),
1446        );
1447
1448        // File still on disk, but no DB row recorded — both gates must hold
1449        // for the upsert to fire.
1450        assert_eq!(std::fs::read_dir(tmp.path()).unwrap().count(), 1);
1451        assert_eq!(db.list(None).unwrap().len(), 0);
1452    }
1453
1454    #[test]
1455    fn save_image_to_dir_invalid_path_does_not_panic() {
1456        // /dev/null is a file, not a directory — create_dir_all should fail
1457        // and the helper must log + return cleanly rather than panic.
1458        save_image_to_dir(
1459            std::path::Path::new("/dev/null/cant-mkdir-here"),
1460            &fake_image(),
1461            "test",
1462            1,
1463            None,
1464            None,
1465            None,
1466        );
1467    }
1468
1469    #[test]
1470    fn save_video_to_dir_writes_mp4_and_records_metadata() {
1471        let tmp = TempDir::new().unwrap();
1472        let db = MetadataDb::open_in_memory().unwrap();
1473        let mut req = fake_request("ltx-video:fp16");
1474        req.frames = Some(25);
1475        req.fps = Some(24);
1476        let meta = OutputMetadata::from_generate_request(&req, 99, None, "test-version");
1477
1478        // Minimal MP4-ish bytes: an `ftyp` box header. The helper writes
1479        // bytes verbatim — content validation happens at gallery scan time.
1480        let bytes = b"\x00\x00\x00\x18ftypmp42\x00\x00\x00\x00mp42isom".to_vec();
1481
1482        save_video_to_dir(
1483            tmp.path(),
1484            &bytes,
1485            b"",
1486            OutputFormat::Mp4,
1487            "ltx-video:fp16",
1488            &meta,
1489            Some(5000),
1490            Some(&db),
1491        );
1492
1493        let entries: Vec<_> = std::fs::read_dir(tmp.path()).unwrap().collect();
1494        assert_eq!(entries.len(), 1);
1495        let name = entries[0]
1496            .as_ref()
1497            .unwrap()
1498            .file_name()
1499            .to_string_lossy()
1500            .to_string();
1501        assert!(name.starts_with("mold-ltx-video-fp16-"), "{name}");
1502        assert!(name.ends_with(".mp4"), "{name}");
1503
1504        let rows = db.list(Some(tmp.path())).unwrap();
1505        assert_eq!(rows.len(), 1);
1506        assert_eq!(rows[0].format, OutputFormat::Mp4);
1507        assert_eq!(rows[0].metadata.frames, Some(25));
1508        assert_eq!(rows[0].metadata.fps, Some(24));
1509        assert_eq!(rows[0].generation_time_ms, Some(5000));
1510    }
1511
1512    #[test]
1513    fn save_video_to_dir_without_db_still_writes_file() {
1514        let tmp = TempDir::new().unwrap();
1515        let req = fake_request("ltx-video:fp16");
1516        let meta = OutputMetadata::from_generate_request(&req, 1, None, "v");
1517
1518        save_video_to_dir(
1519            tmp.path(),
1520            b"fake gif bytes",
1521            b"",
1522            OutputFormat::Gif,
1523            "ltx-video:fp16",
1524            &meta,
1525            None,
1526            None,
1527        );
1528
1529        let entries: Vec<_> = std::fs::read_dir(tmp.path()).unwrap().collect();
1530        assert_eq!(entries.len(), 1);
1531        let name = entries[0]
1532            .as_ref()
1533            .unwrap()
1534            .file_name()
1535            .to_string_lossy()
1536            .to_string();
1537        assert!(name.ends_with(".gif"), "{name}");
1538    }
1539
1540    #[test]
1541    fn save_video_to_dir_invalid_path_does_not_panic() {
1542        let req = fake_request("ltx-video:fp16");
1543        let meta = OutputMetadata::from_generate_request(&req, 1, None, "v");
1544        save_video_to_dir(
1545            std::path::Path::new("/dev/null/nope"),
1546            b"x",
1547            b"",
1548            OutputFormat::Mp4,
1549            "test",
1550            &meta,
1551            None,
1552            None,
1553        );
1554    }
1555
1556    /// `save_video_preview_gif_to` must write to
1557    /// `<preview_dir>/<filename>.preview.gif` — the exact location
1558    /// `GET /api/gallery/preview/:filename` streams from. Without this
1559    /// sidecar the preview endpoint would 404 on every real generation
1560    /// and the TUI detail pane would only ever see the PNG thumbnail
1561    /// fallback.
1562    #[test]
1563    fn save_video_preview_gif_writes_to_preview_cache() {
1564        let td = tempfile::tempdir().unwrap();
1565        let preview_dir = td.path().join("cache").join("previews");
1566
1567        const GIF: &[u8] = b"GIF89a\x01\x00\x01\x00\x00\x00\x00\x3b";
1568        save_video_preview_gif_to(&preview_dir, "ltx2-42.mp4", GIF);
1569
1570        let expected = preview_dir.join("ltx2-42.mp4.preview.gif");
1571        assert!(
1572            expected.is_file(),
1573            "preview gif should land at {}",
1574            expected.display()
1575        );
1576        assert_eq!(std::fs::read(&expected).unwrap(), GIF);
1577    }
1578
1579    #[test]
1580    fn build_sse_complete_event_video_carries_mp4_payload_and_metadata() {
1581        // Regression guard for the multi-GPU bug: if `response.video` is set,
1582        // the SSE complete event must encode the actual video bytes and
1583        // populate every `video_*` field so the client can reconstruct a
1584        // `VideoData`. Before the shared helper, `gpu_worker.rs` encoded the
1585        // thumbnail PNG and hard-coded every `video_*` field to `None`,
1586        // silently degrading every LTX-Video / LTX-2 response to an image.
1587        let video = mold_core::VideoData {
1588            data: vec![0x00, 0x00, 0x00, 0x18, b'f', b't', b'y', b'p'],
1589            format: OutputFormat::Mp4,
1590            width: 768,
1591            height: 512,
1592            frames: 25,
1593            fps: 24,
1594            thumbnail: vec![0x89, 0x50, 0x4E, 0x47],
1595            gif_preview: vec![b'G', b'I', b'F', b'8'],
1596            has_audio: true,
1597            duration_ms: Some(1040),
1598            audio_sample_rate: Some(44100),
1599            audio_channels: Some(2),
1600        };
1601        let resp = mold_core::GenerateResponse {
1602            images: vec![],
1603            video: Some(video.clone()),
1604            generation_time_ms: 1234,
1605            model: "ltx-2-19b-distilled:fp8".to_string(),
1606            seed_used: 7,
1607            gpu: Some(0),
1608        };
1609        // The `img` the caller synthesizes from the video thumbnail — must be
1610        // ignored for the video branch.
1611        let thumb_img = ImageData {
1612            data: video.thumbnail.clone(),
1613            format: OutputFormat::Png,
1614            width: video.width,
1615            height: video.height,
1616            index: 0,
1617        };
1618
1619        let event = build_sse_complete_event(&resp, &thumb_img);
1620
1621        let b64 = base64::engine::general_purpose::STANDARD;
1622        assert_eq!(event.image, b64.encode(&video.data));
1623        assert_eq!(event.format, OutputFormat::Mp4);
1624        assert_eq!(event.video_frames, Some(25));
1625        assert_eq!(event.video_fps, Some(24));
1626        assert_eq!(event.video_thumbnail, Some(b64.encode(&video.thumbnail)));
1627        assert_eq!(
1628            event.video_gif_preview,
1629            Some(b64.encode(&video.gif_preview))
1630        );
1631        assert!(event.video_has_audio);
1632        assert_eq!(event.video_duration_ms, Some(1040));
1633        assert_eq!(event.gpu, Some(0));
1634    }
1635
1636    #[test]
1637    fn build_sse_complete_event_video_empty_gif_preview_omits_field() {
1638        let video = mold_core::VideoData {
1639            data: vec![0x00, 0x00, 0x00, 0x18],
1640            format: OutputFormat::Mp4,
1641            width: 256,
1642            height: 256,
1643            frames: 17,
1644            fps: 12,
1645            thumbnail: vec![0x89, 0x50],
1646            gif_preview: Vec::new(),
1647            has_audio: false,
1648            duration_ms: None,
1649            audio_sample_rate: None,
1650            audio_channels: None,
1651        };
1652        let resp = mold_core::GenerateResponse {
1653            images: vec![],
1654            video: Some(video),
1655            generation_time_ms: 0,
1656            model: "m".to_string(),
1657            seed_used: 0,
1658            gpu: None,
1659        };
1660        let event = build_sse_complete_event(&resp, &fake_image());
1661        assert!(event.video_gif_preview.is_none());
1662        assert!(!event.video_has_audio);
1663    }
1664
1665    #[test]
1666    fn build_sse_complete_event_image_clears_all_video_fields() {
1667        let resp = mold_core::GenerateResponse {
1668            images: vec![fake_image()],
1669            video: None,
1670            generation_time_ms: 100,
1671            model: "flux-schnell:q8".to_string(),
1672            seed_used: 5,
1673            gpu: None,
1674        };
1675        let event = build_sse_complete_event(&resp, &fake_image());
1676        assert_eq!(event.format, OutputFormat::Png);
1677        assert!(event.video_frames.is_none());
1678        assert!(event.video_fps.is_none());
1679        assert!(event.video_thumbnail.is_none());
1680        assert!(event.video_gif_preview.is_none());
1681        assert!(!event.video_has_audio);
1682        assert!(event.video_duration_ms.is_none());
1683    }
1684
1685    #[test]
1686    fn post_generation_upscale_replaces_image_response_dimensions() {
1687        let mut req = fake_request("flux-dev:q4");
1688        req.upscale_model = Some("real-esrgan-x4plus:fp16".to_string());
1689        let mut response = mold_core::GenerateResponse {
1690            images: vec![],
1691            video: None,
1692            generation_time_ms: 100,
1693            model: "flux-dev:q4".to_string(),
1694            seed_used: 5,
1695            gpu: None,
1696        };
1697        let img = fake_image();
1698        let upscaled = mold_core::UpscaleResponse {
1699            image: ImageData {
1700                data: vec![1, 2, 3],
1701                format: OutputFormat::Png,
1702                width: 2048,
1703                height: 2048,
1704                index: 0,
1705            },
1706            upscale_time_ms: 42,
1707            model: "real-esrgan-x4plus:fp16".to_string(),
1708            scale_factor: 4,
1709            original_width: 512,
1710            original_height: 512,
1711        };
1712
1713        let next = apply_upscale_response_to_image_generation(&req, &mut response, img, upscaled)
1714            .expect("image upscale should apply");
1715        let event = build_sse_complete_event(&response, &next);
1716        let mut metadata =
1717            OutputMetadata::from_generate_request(&req, response.seed_used, None, "test-version");
1718        apply_output_dimensions_to_metadata(&mut metadata, &next);
1719
1720        assert_eq!(next.width, 2048);
1721        assert_eq!(next.height, 2048);
1722        assert_eq!(event.width, 2048);
1723        assert_eq!(event.height, 2048);
1724        assert_eq!(metadata.width, 2048);
1725        assert_eq!(metadata.height, 2048);
1726        assert_eq!(
1727            metadata.upscale_model.as_deref(),
1728            Some("real-esrgan-x4plus:fp16")
1729        );
1730    }
1731
1732    #[test]
1733    fn post_generation_upscale_skips_video_responses() {
1734        let mut req = fake_request("ltx-video:fp16");
1735        req.upscale_model = Some("real-esrgan-x4plus:fp16".to_string());
1736        let video = mold_core::VideoData {
1737            data: vec![0, 0, 0, 24],
1738            format: OutputFormat::Mp4,
1739            width: 512,
1740            height: 512,
1741            frames: 25,
1742            fps: 24,
1743            thumbnail: vec![9, 9],
1744            gif_preview: vec![],
1745            has_audio: false,
1746            duration_ms: None,
1747            audio_sample_rate: None,
1748            audio_channels: None,
1749        };
1750        let mut response = mold_core::GenerateResponse {
1751            images: vec![],
1752            video: Some(video),
1753            generation_time_ms: 100,
1754            model: "ltx-video:fp16".to_string(),
1755            seed_used: 5,
1756            gpu: None,
1757        };
1758        let img = fake_image();
1759        let upscaled = mold_core::UpscaleResponse {
1760            image: ImageData {
1761                data: vec![1, 2, 3],
1762                format: OutputFormat::Png,
1763                width: 2048,
1764                height: 2048,
1765                index: 0,
1766            },
1767            upscale_time_ms: 42,
1768            model: "real-esrgan-x4plus:fp16".to_string(),
1769            scale_factor: 4,
1770            original_width: 512,
1771            original_height: 512,
1772        };
1773
1774        let next = apply_upscale_response_to_image_generation(&req, &mut response, img, upscaled)
1775            .expect("video upscale should be skipped");
1776
1777        assert_eq!(next.width, 512);
1778        assert_eq!(next.height, 512);
1779        assert!(response.video.is_some());
1780    }
1781
1782    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1783    async fn single_worker_post_upscale_noops_without_model() {
1784        let state = empty_test_state(mold_core::Config::default());
1785        let req = fake_request("flux-dev:q4");
1786
1787        let next = upscale_generated_image_on_single_worker(&state, &req, fake_image(), None)
1788            .await
1789            .expect("missing upscale model should leave the image unchanged");
1790
1791        assert_eq!(next.width, 512);
1792        assert_eq!(next.height, 512);
1793        assert_eq!(next.index, 0);
1794    }
1795
1796    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1797    async fn single_worker_post_upscale_rejects_unknown_upscaler_manifest() {
1798        let state = empty_test_state(mold_core::Config::default());
1799        let mut req = fake_request("flux-dev:q4");
1800        req.upscale_model = Some("definitely-not-a-real-upscaler:fp16".to_string());
1801        let (progress_tx, mut progress_rx) = tokio::sync::mpsc::unbounded_channel();
1802
1803        let err = upscale_generated_image_on_single_worker(
1804            &state,
1805            &req,
1806            fake_image(),
1807            Some(&progress_tx),
1808        )
1809        .await
1810        .expect_err("unknown upscalers should fail before generation completes");
1811
1812        assert!(err.contains("unknown upscaler model"), "got: {err}");
1813        let first_progress = progress_rx
1814            .try_recv()
1815            .expect("loading stage should be emitted before validation fails");
1816        assert!(matches!(
1817            first_progress,
1818            SseMessage::Progress(SseProgressEvent::StageStart { .. })
1819        ));
1820    }
1821
1822    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1823    async fn single_worker_post_upscale_surfaces_missing_weights_path() {
1824        let tmp = TempDir::new().unwrap();
1825        let missing_weights = tmp.path().join("missing-upscaler.safetensors");
1826        let mut config = mold_core::Config::default();
1827        config.models.insert(
1828            "real-esrgan-x4plus:fp16".to_string(),
1829            ModelConfig {
1830                transformer: Some(missing_weights.display().to_string()),
1831                ..Default::default()
1832            },
1833        );
1834        let state = empty_test_state(config);
1835        let mut req = fake_request("flux-dev:q4");
1836        req.upscale_model = Some("real-esrgan-x4plus:fp16".to_string());
1837        let (progress_tx, mut progress_rx) = tokio::sync::mpsc::unbounded_channel();
1838
1839        let err = upscale_generated_image_on_single_worker(
1840            &state,
1841            &req,
1842            fake_image(),
1843            Some(&progress_tx),
1844        )
1845        .await
1846        .expect_err("missing weight files should be surfaced");
1847
1848        assert!(err.contains("upscale failed"), "got: {err}");
1849        assert!(err.contains("upscaler weights not found"), "got: {err}");
1850        let first_progress = progress_rx
1851            .try_recv()
1852            .expect("loading stage should be emitted before loading fails");
1853        assert!(matches!(
1854            first_progress,
1855            SseMessage::Progress(SseProgressEvent::StageStart { .. })
1856        ));
1857    }
1858
1859    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1860    async fn queue_dispatcher_waits_for_worker_capacity_instead_of_rejecting() {
1861        let (worker, worker_rx) = test_worker(0, 1);
1862        let (job_tx, job_rx) = tokio::sync::mpsc::channel(4);
1863        let queue = QueueHandle::new(job_tx.clone());
1864        let state = crate::state::AppState::empty(
1865            mold_core::Config::default(),
1866            queue.clone(),
1867            Arc::new(GpuPool {
1868                workers: vec![worker.clone()],
1869            }),
1870            8,
1871        );
1872
1873        let (filler_result_tx, _filler_result_rx) = tokio::sync::oneshot::channel();
1874        let filler_job = crate::gpu_pool::GpuJob {
1875            id: String::new(),
1876            model: "busy-model".to_string(),
1877            request: fake_request("busy-model"),
1878            progress_tx: None,
1879            result_tx: filler_result_tx,
1880            output_dir: None,
1881            config: state.config.clone(),
1882            metadata_db: state.metadata_db.clone(),
1883            queue: state.queue.clone(),
1884            registry: state.job_registry.clone(),
1885        };
1886        worker.job_tx.send(filler_job).unwrap();
1887
1888        let dispatcher = tokio::spawn(run_queue_dispatcher_with_tuning(
1889            job_rx,
1890            state.clone(),
1891            8,
1892            DEFAULT_MAX_DEFERRALS,
1893        ));
1894
1895        let (result_tx, mut result_rx) = tokio::sync::oneshot::channel();
1896        let job = crate::state::GenerationJob {
1897            id: String::new(),
1898            request: fake_request("flux-dev:q4"),
1899            progress_tx: None,
1900            result_tx,
1901            output_dir: None,
1902        };
1903        let _position = queue.submit(job, 8).await.unwrap();
1904
1905        tokio::time::sleep(std::time::Duration::from_millis(25)).await;
1906        assert!(
1907            result_rx.try_recv().is_err(),
1908            "dispatcher should keep the job pending while all worker channels are full"
1909        );
1910
1911        let _filler = worker_rx
1912            .recv()
1913            .expect("filler job should occupy the local channel");
1914        let dispatched = worker_rx
1915            .recv_timeout(std::time::Duration::from_secs(1))
1916            .expect("queued job should dispatch once capacity is available");
1917        assert_eq!(dispatched.model, "flux-dev:q4");
1918
1919        drop(job_tx);
1920        dispatcher.abort();
1921    }
1922
1923    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1924    async fn queue_dispatcher_waits_for_degraded_worker_recovery_instead_of_rejecting() {
1925        let (worker, worker_rx) = test_worker(0, 1);
1926        worker.consecutive_failures.store(3, Ordering::SeqCst);
1927        *worker.degraded_until.write().unwrap() =
1928            Some(Instant::now() + std::time::Duration::from_secs(60));
1929
1930        let (job_tx, job_rx) = tokio::sync::mpsc::channel(4);
1931        let queue = QueueHandle::new(job_tx.clone());
1932        let state = crate::state::AppState::empty(
1933            mold_core::Config::default(),
1934            queue.clone(),
1935            Arc::new(GpuPool {
1936                workers: vec![worker.clone()],
1937            }),
1938            8,
1939        );
1940        let dispatcher = tokio::spawn(run_queue_dispatcher(job_rx, state.clone()));
1941
1942        let (result_tx, mut result_rx) = tokio::sync::oneshot::channel();
1943        let job = crate::state::GenerationJob {
1944            id: String::new(),
1945            request: fake_request("flux-dev:q4"),
1946            progress_tx: None,
1947            result_tx,
1948            output_dir: None,
1949        };
1950        queue.submit(job, 8).await.unwrap();
1951
1952        tokio::time::sleep(std::time::Duration::from_millis(25)).await;
1953        assert!(
1954            result_rx.try_recv().is_err(),
1955            "dispatcher should keep the job pending while all workers are degraded"
1956        );
1957        assert!(
1958            worker_rx.try_recv().is_err(),
1959            "degraded worker must not receive work before recovery"
1960        );
1961
1962        worker.consecutive_failures.store(0, Ordering::SeqCst);
1963        *worker.degraded_until.write().unwrap() = None;
1964
1965        let dispatched = worker_rx
1966            .recv_timeout(std::time::Duration::from_secs(1))
1967            .expect("queued job should dispatch once a worker recovers");
1968        assert_eq!(dispatched.model, "flux-dev:q4");
1969
1970        drop(job_tx);
1971        dispatcher.abort();
1972    }
1973
1974    /// Regression for the take-and-restore refactor in `process_job`: when
1975    /// the engine vanishes from the cache between `ensure_model_ready` and
1976    /// `cache.take()`, the take path must produce `None` (handled with a
1977    /// clean error in `process_job`) rather than panicking. The pure cache
1978    /// invariant — `take()` on an absent model returns `None` — is what
1979    /// keeps the take-and-restore safe.
1980    #[tokio::test]
1981    async fn cache_take_on_vanished_engine_returns_none_not_panic() {
1982        use crate::model_cache::ModelCache;
1983        use mold_core::GenerateResponse;
1984        use mold_inference::InferenceEngine;
1985
1986        struct StubEngine(&'static str);
1987        impl InferenceEngine for StubEngine {
1988            fn generate(&mut self, _r: &GenerateRequest) -> anyhow::Result<GenerateResponse> {
1989                unimplemented!()
1990            }
1991            fn model_name(&self) -> &str {
1992                self.0
1993            }
1994            fn is_loaded(&self) -> bool {
1995                true
1996            }
1997            fn load(&mut self) -> anyhow::Result<()> {
1998                Ok(())
1999            }
2000        }
2001
2002        let mut cache = ModelCache::new(3);
2003        // Cache empty (engine never inserted, or evicted/removed by a
2004        // concurrent admin call between `ensure_model_ready` and `take`).
2005        assert!(cache.take("vanished-model").is_none());
2006
2007        // After a take of a present engine, a subsequent take of the same
2008        // name must also return None — guards against double-take in the
2009        // restore path.
2010        cache.insert(Box::new(StubEngine("present-model")), 0);
2011        let first = cache.take("present-model");
2012        assert!(first.is_some());
2013        assert!(
2014            cache.take("present-model").is_none(),
2015            "double-take must return None"
2016        );
2017    }
2018
2019    fn buf_job(model: &str) -> BufferedJob {
2020        let (tx, _rx) = tokio::sync::oneshot::channel();
2021        BufferedJob::new(crate::state::GenerationJob {
2022            id: String::new(),
2023            request: fake_request(model),
2024            progress_tx: None,
2025            result_tx: tx,
2026            output_dir: None,
2027        })
2028    }
2029
2030    #[test]
2031    fn pick_next_job_picks_head_when_head_model_loaded() {
2032        use std::collections::{HashSet, VecDeque};
2033        let mut buffer: VecDeque<BufferedJob> = VecDeque::new();
2034        buffer.push_back(buf_job("a"));
2035        buffer.push_back(buf_job("b"));
2036        buffer.push_back(buf_job("a"));
2037        let loaded: HashSet<String> = ["a".to_string()].into_iter().collect();
2038        let picked = pick_next_job(&mut buffer, &loaded, 3);
2039        assert_eq!(picked.request.model, "a");
2040        assert_eq!(buffer.len(), 2);
2041        assert_eq!(buffer.front().unwrap().job.request.model, "b");
2042        assert_eq!(
2043            buffer.front().unwrap().deferred,
2044            0,
2045            "head shouldn't be deferred when picker chose the head itself"
2046        );
2047    }
2048
2049    #[test]
2050    fn pick_next_job_picks_non_head_when_only_non_head_model_loaded() {
2051        use std::collections::{HashSet, VecDeque};
2052        let mut buffer: VecDeque<BufferedJob> = VecDeque::new();
2053        buffer.push_back(buf_job("a"));
2054        buffer.push_back(buf_job("b"));
2055        buffer.push_back(buf_job("a"));
2056        let loaded: HashSet<String> = ["b".to_string()].into_iter().collect();
2057        let picked = pick_next_job(&mut buffer, &loaded, 3);
2058        assert_eq!(picked.request.model, "b");
2059        assert_eq!(buffer.len(), 2);
2060        // The head ("a") was skipped once and now sits at deferral=1.
2061        assert_eq!(buffer.front().unwrap().job.request.model, "a");
2062        assert_eq!(buffer.front().unwrap().deferred, 1);
2063    }
2064
2065    #[test]
2066    fn pick_next_job_force_dispatches_head_after_max_deferrals() {
2067        use std::collections::{HashSet, VecDeque};
2068        let mut buffer: VecDeque<BufferedJob> = VecDeque::new();
2069        let mut head = buf_job("a");
2070        head.deferred = 3;
2071        buffer.push_back(head);
2072        buffer.push_back(buf_job("b"));
2073        // Even though only `b` is loaded, head ("a") has hit the budget and wins.
2074        let loaded: HashSet<String> = ["b".to_string()].into_iter().collect();
2075        let picked = pick_next_job(&mut buffer, &loaded, 3);
2076        assert_eq!(picked.request.model, "a");
2077        assert_eq!(buffer.len(), 1);
2078        assert_eq!(buffer.front().unwrap().job.request.model, "b");
2079    }
2080
2081    #[test]
2082    fn pick_next_job_falls_back_to_head_when_nothing_loaded() {
2083        use std::collections::{HashSet, VecDeque};
2084        let mut buffer: VecDeque<BufferedJob> = VecDeque::new();
2085        buffer.push_back(buf_job("a"));
2086        buffer.push_back(buf_job("b"));
2087        let loaded: HashSet<String> = HashSet::new();
2088        let picked = pick_next_job(&mut buffer, &loaded, 3);
2089        assert_eq!(picked.request.model, "a");
2090    }
2091
2092    /// Fix D: with `max_deferrals = 0`, every reorder would exceed the
2093    /// budget on the very first skip, so the picker degenerates to FIFO —
2094    /// the head wins regardless of which model is loaded.
2095    #[test]
2096    fn pick_next_job_max_deferrals_zero_picks_head_even_when_non_head_loaded() {
2097        use std::collections::{HashSet, VecDeque};
2098        let mut buffer: VecDeque<BufferedJob> = VecDeque::new();
2099        buffer.push_back(buf_job("b")); // head
2100        buffer.push_back(buf_job("a")); // non-head
2101        let loaded: HashSet<String> = ["a".to_string()].into_iter().collect();
2102        let picked = pick_next_job(&mut buffer, &loaded, 0);
2103        assert_eq!(
2104            picked.request.model, "b",
2105            "max_deferrals=0 must force FIFO — head must win even when only the non-head model is loaded"
2106        );
2107        assert_eq!(buffer.len(), 1);
2108        assert_eq!(buffer.front().unwrap().job.request.model, "a");
2109    }
2110
2111    /// Fix D: with `max_deferrals = 0` and an empty `loaded` set, the head
2112    /// is the only candidate anyway. Locks in the FIFO behaviour when
2113    /// nothing is warm.
2114    #[test]
2115    fn pick_next_job_max_deferrals_zero_with_empty_loaded_picks_head() {
2116        use std::collections::{HashSet, VecDeque};
2117        let mut buffer: VecDeque<BufferedJob> = VecDeque::new();
2118        buffer.push_back(buf_job("a")); // head
2119        buffer.push_back(buf_job("b"));
2120        let loaded: HashSet<String> = HashSet::new();
2121        let picked = pick_next_job(&mut buffer, &loaded, 0);
2122        assert_eq!(picked.request.model, "a");
2123        assert_eq!(buffer.len(), 1);
2124        assert_eq!(buffer.front().unwrap().job.request.model, "b");
2125    }
2126
2127    /// Fix E: when both head and a non-head match `loaded`, the picker must
2128    /// pick the front-most match — i.e. the first `A` in `[A, B, A, B]`
2129    /// when both `A` and `B` are loaded. Locks in arrival-order stability
2130    /// across multiple matching jobs.
2131    #[test]
2132    fn pick_next_job_picks_front_most_match_when_multiple_loaded() {
2133        use std::collections::{HashSet, VecDeque};
2134        let mut buffer: VecDeque<BufferedJob> = VecDeque::new();
2135        buffer.push_back(buf_job("a"));
2136        buffer.push_back(buf_job("b"));
2137        buffer.push_back(buf_job("a"));
2138        buffer.push_back(buf_job("b"));
2139        let loaded: HashSet<String> = ["a".to_string(), "b".to_string()].into_iter().collect();
2140        let picked = pick_next_job(&mut buffer, &loaded, 3);
2141        assert_eq!(
2142            picked.request.model, "a",
2143            "front-most match wins (the first `a`), not the loaded model with the most copies later in the buffer"
2144        );
2145        // Three jobs remain: [b, a, b]; head was the picked first `a` so the
2146        // new head is the original-index-1 `b`. Nothing was deferred because
2147        // the picker chose the head itself.
2148        assert_eq!(buffer.len(), 3);
2149        let remaining: Vec<&str> = buffer
2150            .iter()
2151            .map(|b| b.job.request.model.as_str())
2152            .collect();
2153        assert_eq!(remaining, vec!["b", "a", "b"]);
2154        assert_eq!(buffer.front().unwrap().deferred, 0);
2155    }
2156
2157    /// Integration: an interleaved `[A, B, A, B]` queue dispatched against a
2158    /// single worker that has model `A` warm should reorder so both `A` jobs
2159    /// run first, then both `B` jobs — minimizing model swaps from 4 → 1.
2160    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2161    async fn queue_dispatcher_reorders_interleaved_jobs_to_minimize_swaps() {
2162        let (worker, worker_rx) = test_worker(0, 8);
2163        // Pre-mark the worker as having model "a" loaded so the picker
2164        // recognises it as warm.
2165        {
2166            let mut cache = worker.model_cache.lock().unwrap();
2167            struct Engine(&'static str);
2168            impl mold_inference::InferenceEngine for Engine {
2169                fn generate(
2170                    &mut self,
2171                    _r: &GenerateRequest,
2172                ) -> anyhow::Result<mold_core::GenerateResponse> {
2173                    unimplemented!()
2174                }
2175                fn model_name(&self) -> &str {
2176                    self.0
2177                }
2178                fn is_loaded(&self) -> bool {
2179                    true
2180                }
2181                fn load(&mut self) -> anyhow::Result<()> {
2182                    Ok(())
2183                }
2184            }
2185            cache.insert(Box::new(Engine("a")), 0);
2186        }
2187
2188        let (job_tx, job_rx) = tokio::sync::mpsc::channel(8);
2189        let queue = QueueHandle::new(job_tx.clone());
2190        let state = crate::state::AppState::empty(
2191            mold_core::Config::default(),
2192            queue.clone(),
2193            Arc::new(GpuPool {
2194                workers: vec![worker.clone()],
2195            }),
2196            8,
2197        );
2198
2199        // Submit [a, b, a, b] BEFORE the dispatcher spins up so the buffer
2200        // top-up sees all four at once.
2201        let mut result_rxs = Vec::new();
2202        for model in ["a", "b", "a", "b"] {
2203            let (tx, rx) = tokio::sync::oneshot::channel();
2204            let job = crate::state::GenerationJob {
2205                id: String::new(),
2206                request: fake_request(model),
2207                progress_tx: None,
2208                result_tx: tx,
2209                output_dir: None,
2210            };
2211            queue.submit(job, 8).await.unwrap();
2212            result_rxs.push(rx);
2213        }
2214
2215        let dispatcher = tokio::spawn(run_queue_dispatcher(job_rx, state.clone()));
2216
2217        let mut order = Vec::new();
2218        for _ in 0..4 {
2219            let dispatched = worker_rx
2220                .recv_timeout(std::time::Duration::from_secs(2))
2221                .expect("worker should receive the dispatched job");
2222            order.push(dispatched.model);
2223        }
2224        drop(job_tx);
2225        dispatcher.abort();
2226
2227        assert_eq!(
2228            order,
2229            vec![
2230                "a".to_string(),
2231                "a".to_string(),
2232                "b".to_string(),
2233                "b".to_string(),
2234            ],
2235            "lookahead reorder should batch all `a` jobs together before swapping to `b`"
2236        );
2237    }
2238
2239    /// Fix F: the `top_up_buffer` helper must never grow the buffer past
2240    /// `buffer_size`, no matter how many jobs are sitting in the channel.
2241    /// This is the load-bearing invariant that bounds the working set the
2242    /// picker considers — without it a burst submission could let the
2243    /// dispatcher reorder across the entire pending queue, defeating the
2244    /// fairness guarantees the `deferred` counter is built around.
2245    #[tokio::test]
2246    async fn top_up_buffer_never_exceeds_capacity() {
2247        use std::collections::VecDeque;
2248        let (job_tx, mut job_rx) = tokio::sync::mpsc::channel::<GenerationJob>(32);
2249
2250        // Submit 10 jobs into the channel synchronously so the buffer's top-up
2251        // call sees them all immediately available via try_recv.
2252        for i in 0..10 {
2253            let (tx, _rx) = tokio::sync::oneshot::channel();
2254            let job = GenerationJob {
2255                id: String::new(),
2256                request: fake_request(&format!("model-{i}")),
2257                progress_tx: None,
2258                result_tx: tx,
2259                output_dir: None,
2260            };
2261            job_tx.send(job).await.unwrap();
2262        }
2263
2264        // buffer_size = 4 — top_up must stop at 4 even with 10 in the channel.
2265        let mut buffer: VecDeque<BufferedJob> = VecDeque::with_capacity(4);
2266        top_up_buffer(&mut buffer, &mut job_rx, 4);
2267        assert_eq!(
2268            buffer.len(),
2269            4,
2270            "top_up_buffer must cap at buffer_size, leaving the rest in the channel"
2271        );
2272
2273        // Drain the four buffered jobs, then top up again; the next call must
2274        // pull only the next four from the channel (FIFO order preserved).
2275        while buffer.pop_front().is_some() {}
2276        top_up_buffer(&mut buffer, &mut job_rx, 4);
2277        assert_eq!(buffer.len(), 4);
2278        let names: Vec<&str> = buffer
2279            .iter()
2280            .map(|b| b.job.request.model.as_str())
2281            .collect();
2282        assert_eq!(
2283            names,
2284            vec!["model-4", "model-5", "model-6", "model-7"],
2285            "second top-up must drain the next FIFO window from the channel"
2286        );
2287
2288        // Drop sender so the channel reports closed; remaining 2 jobs still
2289        // arrive via try_recv before the channel goes dry.
2290        drop(job_tx);
2291        while buffer.pop_front().is_some() {}
2292        top_up_buffer(&mut buffer, &mut job_rx, 4);
2293        assert_eq!(
2294            buffer.len(),
2295            2,
2296            "top_up_buffer drains the channel tail when fewer jobs than capacity remain"
2297        );
2298        let names: Vec<&str> = buffer
2299            .iter()
2300            .map(|b| b.job.request.model.as_str())
2301            .collect();
2302        assert_eq!(names, vec!["model-8", "model-9"]);
2303    }
2304
2305    /// Same invariant, but reached via the dispatcher loop (integration). A
2306    /// burst of N > buffer_size jobs must still dispatch in FIFO order with
2307    /// no jobs lost — the buffer cap can't drop traffic, only delay it. We
2308    /// drain the worker channel as fast as the dispatcher fills it, so the
2309    /// test exercises buffer rotation rather than worker-channel back-pressure.
2310    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2311    async fn queue_dispatcher_dispatches_all_jobs_when_submission_exceeds_buffer() {
2312        let (worker, worker_rx) = test_worker(0, 4);
2313        let (job_tx, job_rx) = tokio::sync::mpsc::channel(32);
2314        let queue = QueueHandle::new(job_tx.clone());
2315        let state = crate::state::AppState::empty(
2316            mold_core::Config::default(),
2317            queue.clone(),
2318            Arc::new(GpuPool {
2319                workers: vec![worker.clone()],
2320            }),
2321            32,
2322        );
2323
2324        // Drain the worker channel concurrently and decrement in_flight as
2325        // a real worker would, so the dispatcher's worker-selection sees the
2326        // worker as idle for each subsequent send (otherwise `in_flight`
2327        // grows unbounded and the worker never re-classifies as eligible
2328        // when the sync-channel fills).
2329        let drain_worker = worker.clone();
2330        let drainer = std::thread::spawn(move || {
2331            let mut order = Vec::new();
2332            while order.len() < 10 {
2333                match worker_rx.recv_timeout(std::time::Duration::from_secs(5)) {
2334                    Ok(j) => {
2335                        drain_worker.in_flight.fetch_sub(1, Ordering::SeqCst);
2336                        order.push(j.model);
2337                    }
2338                    Err(e) => panic!("drain stalled at {:?}: {e:?}", order),
2339                }
2340            }
2341            order
2342        });
2343
2344        let dispatcher = tokio::spawn(run_queue_dispatcher(job_rx, state.clone()));
2345
2346        // Submit AFTER the dispatcher and drainer are running so we exercise
2347        // the live top-up loop rather than a one-shot drain of a pre-filled
2348        // channel. Hold result_rx values past the dispatch — the dispatcher
2349        // skips jobs whose result_tx is closed, which would otherwise drop
2350        // every job before it reaches the worker channel.
2351        let mut held_rxs = Vec::new();
2352        for i in 0..10 {
2353            let (tx, rx) = tokio::sync::oneshot::channel();
2354            held_rxs.push(rx);
2355            let job = crate::state::GenerationJob {
2356                id: String::new(),
2357                request: fake_request(&format!("model-{i}")),
2358                progress_tx: None,
2359                result_tx: tx,
2360                output_dir: None,
2361            };
2362            queue.submit(job, 32).await.unwrap();
2363        }
2364
2365        let order = drainer.join().expect("drainer thread panic");
2366        drop(job_tx);
2367        dispatcher.abort();
2368
2369        let expected: Vec<String> = (0..10).map(|i| format!("model-{i}")).collect();
2370        assert_eq!(
2371            order, expected,
2372            "10 distinct jobs must come out in FIFO across buffer rotations"
2373        );
2374    }
2375
2376    /// Serializes every test that mutates queue env vars (process-global).
2377    static QUEUE_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
2378
2379    fn with_queue_env<R>(name: &str, value: Option<&str>, f: impl FnOnce() -> R) -> R {
2380        let _g = QUEUE_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2381        let prev = std::env::var(name).ok();
2382        match value {
2383            Some(v) => std::env::set_var(name, v),
2384            None => std::env::remove_var(name),
2385        }
2386        let out = f();
2387        match prev {
2388            Some(v) => std::env::set_var(name, v),
2389            None => std::env::remove_var(name),
2390        }
2391        out
2392    }
2393
2394    #[test]
2395    fn resolve_lookahead_buffer_uses_default_when_env_missing() {
2396        let n = with_queue_env(LOOKAHEAD_BUFFER_ENV, None, resolve_lookahead_buffer);
2397        assert_eq!(n, DEFAULT_LOOKAHEAD_BUFFER);
2398    }
2399
2400    #[test]
2401    fn resolve_lookahead_buffer_honors_env_within_range() {
2402        let n = with_queue_env(LOOKAHEAD_BUFFER_ENV, Some("4"), resolve_lookahead_buffer);
2403        assert_eq!(n, 4);
2404    }
2405
2406    #[test]
2407    fn resolve_lookahead_buffer_falls_back_when_out_of_range() {
2408        // 0 is below the 1 lower bound; 999 is above the 64 upper bound.
2409        let n = with_queue_env(LOOKAHEAD_BUFFER_ENV, Some("0"), resolve_lookahead_buffer);
2410        assert_eq!(n, DEFAULT_LOOKAHEAD_BUFFER);
2411        let n = with_queue_env(LOOKAHEAD_BUFFER_ENV, Some("999"), resolve_lookahead_buffer);
2412        assert_eq!(n, DEFAULT_LOOKAHEAD_BUFFER);
2413    }
2414
2415    #[test]
2416    fn resolve_lookahead_buffer_falls_back_when_unparseable() {
2417        let n = with_queue_env(
2418            LOOKAHEAD_BUFFER_ENV,
2419            Some("not-a-number"),
2420            resolve_lookahead_buffer,
2421        );
2422        assert_eq!(n, DEFAULT_LOOKAHEAD_BUFFER);
2423    }
2424
2425    #[test]
2426    fn resolve_max_deferrals_uses_default_when_env_missing() {
2427        let n = with_queue_env(MAX_DEFERRALS_ENV, None, resolve_max_deferrals);
2428        assert_eq!(n, DEFAULT_MAX_DEFERRALS);
2429    }
2430
2431    #[test]
2432    fn resolve_max_deferrals_honors_env_within_range() {
2433        // 0 is the in-range "FIFO" sentinel, 32 is the upper edge.
2434        let n = with_queue_env(MAX_DEFERRALS_ENV, Some("0"), resolve_max_deferrals);
2435        assert_eq!(n, 0);
2436        let n = with_queue_env(MAX_DEFERRALS_ENV, Some("32"), resolve_max_deferrals);
2437        assert_eq!(n, 32);
2438        let n = with_queue_env(MAX_DEFERRALS_ENV, Some("5"), resolve_max_deferrals);
2439        assert_eq!(n, 5);
2440    }
2441
2442    #[test]
2443    fn resolve_max_deferrals_falls_back_when_out_of_range() {
2444        let n = with_queue_env(MAX_DEFERRALS_ENV, Some("999"), resolve_max_deferrals);
2445        assert_eq!(n, DEFAULT_MAX_DEFERRALS);
2446    }
2447
2448    #[test]
2449    fn resolve_max_deferrals_falls_back_when_unparseable() {
2450        let n = with_queue_env(
2451            MAX_DEFERRALS_ENV,
2452            Some("not-a-number"),
2453            resolve_max_deferrals,
2454        );
2455        assert_eq!(n, DEFAULT_MAX_DEFERRALS);
2456    }
2457
2458    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2459    async fn queue_dispatcher_honors_explicit_placement_gpu() {
2460        let (worker0, rx0) = test_worker(0, 1);
2461        let (worker1, rx1) = test_worker(1, 1);
2462        let (job_tx, job_rx) = tokio::sync::mpsc::channel(4);
2463        let queue = QueueHandle::new(job_tx.clone());
2464        let state = crate::state::AppState::empty(
2465            mold_core::Config::default(),
2466            queue.clone(),
2467            Arc::new(GpuPool {
2468                workers: vec![worker0, worker1],
2469            }),
2470            8,
2471        );
2472
2473        let dispatcher = tokio::spawn(run_queue_dispatcher(job_rx, state));
2474
2475        let mut request = fake_request("flux-dev:q4");
2476        request.placement = Some(mold_core::types::DevicePlacement {
2477            text_encoders: mold_core::types::DeviceRef::Auto,
2478            advanced: Some(mold_core::types::AdvancedPlacement {
2479                transformer: mold_core::types::DeviceRef::gpu(1),
2480                ..mold_core::types::AdvancedPlacement::default()
2481            }),
2482        });
2483
2484        let (result_tx, _result_rx) = tokio::sync::oneshot::channel();
2485        let job = crate::state::GenerationJob {
2486            id: String::new(),
2487            request,
2488            progress_tx: None,
2489            result_tx,
2490            output_dir: None,
2491        };
2492        let _position = queue.submit(job, 8).await.unwrap();
2493
2494        let dispatched = rx1
2495            .recv_timeout(std::time::Duration::from_secs(1))
2496            .expect("explicit placement should route to gpu 1");
2497        assert_eq!(dispatched.model, "flux-dev:q4");
2498        assert!(rx0.try_recv().is_err(), "gpu 0 should not receive the job");
2499
2500        drop(job_tx);
2501        dispatcher.abort();
2502    }
2503
2504    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2505    async fn queue_dispatcher_records_auto_selected_gpu_before_worker_starts() {
2506        let (worker0, rx0) = test_worker(0, 1);
2507        let (worker1, rx1) = test_worker(1, 1);
2508        let (job_tx, job_rx) = tokio::sync::mpsc::channel(4);
2509        let queue = QueueHandle::new(job_tx.clone());
2510        let state = crate::state::AppState::empty(
2511            mold_core::Config::default(),
2512            queue.clone(),
2513            Arc::new(GpuPool {
2514                workers: vec![worker0, worker1],
2515            }),
2516            8,
2517        );
2518        state.job_registry.register("auto-job", "flux-dev:q4");
2519
2520        let dispatcher = tokio::spawn(run_queue_dispatcher(job_rx, state.clone()));
2521
2522        let (result_tx, _result_rx) = tokio::sync::oneshot::channel();
2523        let job = crate::state::GenerationJob {
2524            id: "auto-job".to_string(),
2525            request: fake_request("flux-dev:q4"),
2526            progress_tx: None,
2527            result_tx,
2528            output_dir: None,
2529        };
2530        let _position = queue.submit(job, 8).await.unwrap();
2531
2532        let (dispatched, ordinal) = match rx0.recv_timeout(std::time::Duration::from_secs(1)) {
2533            Ok(job) => (job, 0),
2534            Err(_) => (
2535                rx1.recv_timeout(std::time::Duration::from_secs(1))
2536                    .expect("auto job should dispatch to one GPU"),
2537                1,
2538            ),
2539        };
2540        assert_eq!(dispatched.model, "flux-dev:q4");
2541        assert_eq!(
2542            state.job_registry.target_gpu("auto-job"),
2543            Some(Some(ordinal))
2544        );
2545
2546        drop(job_tx);
2547        dispatcher.abort();
2548    }
2549}