Skip to main content

mold_server/
queue.rs

1use std::sync::Arc;
2
3use base64::Engine as _;
4use mold_core::{ImageData, OutputFormat, SseCompleteEvent, SseErrorEvent, SseProgressEvent};
5use sha2::{Digest, Sha256};
6use std::time::{Instant, SystemTime, UNIX_EPOCH};
7
8use crate::model_manager;
9use crate::state::{
10    ActiveGenerationSnapshot, AppState, GenerationJob, GenerationJobResult, SseMessage,
11};
12
13/// Convert an inference-crate progress event to an SSE wire event.
14fn progress_to_sse(event: mold_inference::ProgressEvent) -> SseProgressEvent {
15    event.into()
16}
17
18/// Strips backtrace frames from candle error messages.
19pub(crate) fn clean_error_message(e: &anyhow::Error) -> String {
20    let full = format!("{e}");
21    let mut lines: Vec<&str> = Vec::new();
22    for line in full.lines() {
23        let trimmed = line.trim_start();
24        if (trimmed.starts_with("0:") || trimmed.starts_with("1:"))
25            && trimmed.len() > 3
26            && trimmed
27                .as_bytes()
28                .first()
29                .is_some_and(|b| b.is_ascii_digit())
30        {
31            break;
32        }
33        if trimmed.len() > 2
34            && trimmed.as_bytes()[0].is_ascii_digit()
35            && trimmed.contains("::")
36            && trimmed.contains("at ")
37        {
38            break;
39        }
40        lines.push(line);
41    }
42    let msg = lines.join("\n").trim().to_string();
43    if msg.is_empty() {
44        format!("{}", e.root_cause())
45    } else {
46        msg
47    }
48}
49
50fn set_active_generation(state: &AppState, model: &str, prompt: &str) {
51    let prompt_sha256 = format!("{:x}", Sha256::digest(prompt.as_bytes()));
52    let started_at_unix_ms = SystemTime::now()
53        .duration_since(UNIX_EPOCH)
54        .unwrap_or_default()
55        .as_millis() as u64;
56
57    let mut active = state
58        .active_generation
59        .write()
60        .unwrap_or_else(|e| e.into_inner());
61    *active = Some(ActiveGenerationSnapshot {
62        model: model.to_string(),
63        prompt_sha256,
64        started_at_unix_ms,
65        started_at: Instant::now(),
66    });
67}
68
69fn clear_active_generation(state: &AppState) {
70    let mut active = state
71        .active_generation
72        .write()
73        .unwrap_or_else(|e| e.into_inner());
74    *active = None;
75}
76
77fn save_image_to_dir(
78    dir: &std::path::Path,
79    img: &mold_core::ImageData,
80    model: &str,
81    batch_size: u32,
82) {
83    if let Err(e) = std::fs::create_dir_all(dir) {
84        tracing::warn!("failed to create output dir {}: {e}", dir.display());
85        return;
86    }
87    let timestamp_ms = SystemTime::now()
88        .duration_since(UNIX_EPOCH)
89        .unwrap_or_default()
90        .as_millis() as u64;
91    let ext = img.format.to_string();
92    let filename =
93        mold_core::default_output_filename(model, timestamp_ms, &ext, batch_size, img.index);
94    let path = dir.join(&filename);
95    match std::fs::write(&path, &img.data) {
96        Ok(()) => tracing::info!("saved image to {}", path.display()),
97        Err(e) => tracing::warn!("failed to save image to {}: {e}", path.display()),
98    }
99}
100
101/// Runs the generation queue worker loop. Processes one job at a time (FIFO).
102/// Exits when the sender half of the channel is dropped (server shutdown).
103pub async fn run_queue_worker(
104    mut job_rx: tokio::sync::mpsc::Receiver<GenerationJob>,
105    state: AppState,
106) {
107    tracing::debug!("generation queue worker started");
108    while let Some(job) = job_rx.recv().await {
109        #[cfg(feature = "metrics")]
110        crate::metrics::record_queue_depth(state.queue.pending());
111        process_job(&state, job).await;
112        state.queue.decrement();
113        #[cfg(feature = "metrics")]
114        crate::metrics::record_queue_depth(state.queue.pending());
115    }
116    tracing::info!("generation queue worker shutting down");
117}
118
119async fn process_job(state: &AppState, job: GenerationJob) {
120    // Check if client already disconnected before doing any work
121    if job.result_tx.is_closed() {
122        tracing::debug!("skipping queued job — client disconnected");
123        return;
124    }
125
126    // Send "now processing" event (position 0)
127    if let Some(ref tx) = job.progress_tx {
128        let _ = tx.send(SseMessage::Progress(SseProgressEvent::Queued {
129            position: 0,
130        }));
131    }
132
133    // 1. Ensure model is ready (with progress forwarding)
134    let progress_callback = job.progress_tx.as_ref().map(|tx| {
135        let tx = tx.clone();
136        Arc::new(move |event: mold_inference::ProgressEvent| {
137            let _ = tx.send(SseMessage::Progress(progress_to_sse(event)));
138        }) as model_manager::EngineProgressCallback
139    });
140
141    if let Err(api_err) =
142        model_manager::ensure_model_ready(state, &job.request.model, progress_callback).await
143    {
144        let err_msg = api_err.error.clone();
145        if let Some(ref tx) = job.progress_tx {
146            let _ = tx.send(SseMessage::Error(SseErrorEvent {
147                message: err_msg.clone(),
148            }));
149        }
150        let _ = job.result_tx.send(Err(err_msg));
151        return;
152    }
153
154    // 2. Low-memory warning (MPS/unified memory only — observability aid)
155    #[cfg(target_os = "macos")]
156    if let Some(available) = mold_inference::device::available_system_memory_bytes() {
157        if available < 1_000_000_000 {
158            tracing::warn!(
159                available_mb = available / 1_000_000,
160                "low memory before inference — system may become unstable"
161            );
162        }
163    }
164
165    // 3. Run inference in spawn_blocking
166    let model_cache = state.model_cache.clone();
167    let active_gen = state.active_generation.clone();
168    let gen_state = state.clone();
169    let gen_req = job.request.clone();
170    let progress_tx = job.progress_tx.clone();
171
172    #[cfg(feature = "metrics")]
173    let inference_start = Instant::now();
174    let result = tokio::task::spawn_blocking(move || {
175        std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
176            let mut guard = model_cache.blocking_lock();
177            let entry = guard.get_mut(&gen_req.model).ok_or_else(|| {
178                anyhow::anyhow!("no engine available after model readiness check")
179            })?;
180            let e = &mut entry.engine;
181            set_active_generation(&gen_state, &gen_req.model, &gen_req.prompt);
182
183            // Install progress callback for the generate phase
184            if let Some(ref ptx) = progress_tx {
185                let ptx = ptx.clone();
186                e.set_on_progress(Box::new(move |event| {
187                    let _ = ptx.send(SseMessage::Progress(progress_to_sse(event)));
188                }));
189            } else {
190                e.clear_on_progress();
191            }
192
193            let generate_result = e.generate(&gen_req);
194            if progress_tx.is_some() {
195                e.clear_on_progress();
196            }
197            clear_active_generation(&gen_state);
198            generate_result
199        }))
200    })
201    .await;
202
203    #[cfg(feature = "metrics")]
204    let inference_duration = inference_start.elapsed().as_secs_f64();
205
206    match result {
207        Ok(Ok(Ok(mut response))) => {
208            #[cfg(feature = "metrics")]
209            crate::metrics::record_generation(&job.request.model, inference_duration);
210
211            if response.images.is_empty() && response.video.is_none() {
212                let err_msg = "generation error: engine returned no images or video".to_string();
213                if let Some(ref tx) = job.progress_tx {
214                    let _ = tx.send(SseMessage::Error(SseErrorEvent {
215                        message: err_msg.clone(),
216                    }));
217                }
218                let _ = job.result_tx.send(Err(err_msg));
219                return;
220            }
221            // For video-only responses, synthesize an ImageData from the thumbnail
222            // so the existing queue/SSE pipeline can handle it.
223            let img = if !response.images.is_empty() {
224                response.images.remove(0)
225            } else if let Some(ref video) = response.video {
226                ImageData {
227                    data: video.thumbnail.clone(),
228                    format: OutputFormat::Png,
229                    width: video.width,
230                    height: video.height,
231                    index: 0,
232                }
233            } else {
234                unreachable!("checked above");
235            };
236
237            // Save to output directory if configured
238            if let Some(ref dir) = job.output_dir {
239                let dir = dir.clone();
240                let model = job.request.model.clone();
241                let batch_size = job.request.batch_size;
242                // For video responses, save the actual video data (not just the thumbnail)
243                if let Some(ref video) = response.video {
244                    let video_data = video.data.clone();
245                    let ext = video.format.extension().to_string();
246                    tokio::task::spawn_blocking(move || {
247                        let ts = std::time::SystemTime::now()
248                            .duration_since(std::time::UNIX_EPOCH)
249                            .map(|d| d.as_millis() as u64)
250                            .unwrap_or(0);
251                        let filename = mold_core::default_output_filename(&model, ts, &ext, 1, 0);
252                        let path = std::path::Path::new(&dir).join(filename);
253                        if let Err(e) = std::fs::write(&path, &video_data) {
254                            tracing::error!("failed to save video to {}: {e}", path.display());
255                        }
256                    });
257                } else {
258                    let img_clone = img.clone();
259                    tokio::task::spawn_blocking(move || {
260                        save_image_to_dir(&dir, &img_clone, &model, batch_size);
261                    });
262                }
263            }
264
265            // Send SSE complete event
266            if let Some(ref tx) = job.progress_tx {
267                let b64 = base64::engine::general_purpose::STANDARD;
268                let event = if let Some(ref video) = response.video {
269                    // Video response: encode the actual video data + metadata
270                    SseCompleteEvent {
271                        image: b64.encode(&video.data),
272                        format: video.format,
273                        width: video.width,
274                        height: video.height,
275                        seed_used: response.seed_used,
276                        generation_time_ms: response.generation_time_ms,
277                        model: response.model.clone(),
278                        video_frames: Some(video.frames),
279                        video_fps: Some(video.fps),
280                        video_thumbnail: Some(b64.encode(&video.thumbnail)),
281                        video_gif_preview: if video.gif_preview.is_empty() {
282                            None
283                        } else {
284                            Some(b64.encode(&video.gif_preview))
285                        },
286                        video_has_audio: video.has_audio,
287                        video_duration_ms: video.duration_ms,
288                        video_audio_sample_rate: video.audio_sample_rate,
289                        video_audio_channels: video.audio_channels,
290                    }
291                } else {
292                    // Image response: same as before
293                    SseCompleteEvent {
294                        image: b64.encode(&img.data),
295                        format: img.format,
296                        width: img.width,
297                        height: img.height,
298                        seed_used: response.seed_used,
299                        generation_time_ms: response.generation_time_ms,
300                        model: response.model.clone(),
301                        video_frames: None,
302                        video_fps: None,
303                        video_thumbnail: None,
304                        video_gif_preview: None,
305                        video_has_audio: false,
306                        video_duration_ms: None,
307                        video_audio_sample_rate: None,
308                        video_audio_channels: None,
309                    }
310                };
311                let _ = tx.send(SseMessage::Complete(event));
312            }
313
314            // Send result through oneshot
315            let _ = job.result_tx.send(Ok(GenerationJobResult {
316                image: img,
317                response,
318            }));
319        }
320        Ok(Ok(Err(e))) => {
321            #[cfg(feature = "metrics")]
322            crate::metrics::record_generation_error(&job.request.model);
323
324            *active_gen.write().unwrap_or_else(|e| e.into_inner()) = None;
325            tracing::error!("generation error: {e:#}");
326            let err_msg = format!("generation error: {}", clean_error_message(&e));
327            if let Some(ref tx) = job.progress_tx {
328                let _ = tx.send(SseMessage::Error(SseErrorEvent {
329                    message: err_msg.clone(),
330                }));
331            }
332            let _ = job.result_tx.send(Err(err_msg));
333        }
334        Ok(Err(panic_payload)) => {
335            #[cfg(feature = "metrics")]
336            crate::metrics::record_generation_error(&job.request.model);
337
338            *active_gen.write().unwrap_or_else(|e| e.into_inner()) = None;
339            let msg = panic_payload
340                .downcast_ref::<String>()
341                .map(|s| s.as_str())
342                .or_else(|| panic_payload.downcast_ref::<&str>().copied())
343                .unwrap_or("unknown panic");
344            tracing::error!("inference panicked: {msg}");
345            let err_msg = format!("inference panicked: {msg}");
346            if let Some(ref tx) = job.progress_tx {
347                let _ = tx.send(SseMessage::Error(SseErrorEvent {
348                    message: err_msg.clone(),
349                }));
350            }
351            let _ = job.result_tx.send(Err(err_msg));
352        }
353        Err(join_err) => {
354            #[cfg(feature = "metrics")]
355            crate::metrics::record_generation_error(&job.request.model);
356
357            *active_gen.write().unwrap_or_else(|e| e.into_inner()) = None;
358            tracing::error!("inference task join error: {join_err:?}");
359            let err_msg = "inference task failed".to_string();
360            if let Some(ref tx) = job.progress_tx {
361                let _ = tx.send(SseMessage::Error(SseErrorEvent {
362                    message: err_msg.clone(),
363                }));
364            }
365            let _ = job.result_tx.send(Err(err_msg));
366        }
367    }
368}