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