Skip to main content

mold_server/
queue.rs

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