Skip to main content

mold_server/
job_registry.rs

1//! Server-side ledger of in-flight generation jobs.
2//!
3//! The web UI tracks each "Generate" click as a card in `useGenerateStream`
4//! and relies on the SSE stream as the only signal that the work is still
5//! happening. When that stream silently drops (network blip, proxy idle
6//! timeout, server restart, browser tab suspended past keepalive), the card
7//! gets stuck `running` forever because no terminal event arrives.
8//!
9//! `JobRegistry` is the server's authoritative list of "things still owed an
10//! output" — every job between `submit()` and worker completion. The new
11//! `GET /api/queue` endpoint exposes this list so the SPA can poll it and
12//! dead-letter cards whose server-assigned `id` is no longer present.
13//!
14//! The registry deliberately doesn't track *completed* jobs — the gallery DB
15//! is the source of truth for those. Anything in here is currently queued or
16//! actively running on some worker.
17
18use crate::events::EventBroadcaster;
19use mold_core::ServerEvent;
20use serde::Serialize;
21use std::sync::{Arc, RwLock};
22use std::time::{SystemTime, UNIX_EPOCH};
23use tokio::sync::Notify;
24
25/// Wire-facing job state. Mirrors the actual lifecycle:
26///
27/// - `queued` — accepted by `submit()`, sitting in the channel awaiting a
28///   dispatcher decision OR the dispatcher is mid-retry across workers.
29/// - `running` — handed off to a GPU worker thread; flipping happens when
30///   the worker pulls the job off its channel and starts loading / inferring.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, utoipa::ToSchema)]
32#[serde(rename_all = "snake_case")]
33pub enum JobLifecycle {
34    Queued,
35    Running,
36}
37
38/// One row in the `GET /api/queue` response.
39///
40/// `position` is the 0-based FIFO index at the time of the snapshot — 0 is at
41/// the head (about to be dispatched, or already running on a worker), N-1 is
42/// the most recently submitted. Position is derived from insertion order, so
43/// it shifts as earlier jobs finish and drop out.
44#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
45pub struct JobEntry {
46    pub id: String,
47    pub model: String,
48    pub state: JobLifecycle,
49    pub started_at_unix_ms: u64,
50    pub position: usize,
51    /// GPU ordinal currently running this job (`null` for queued rows).
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub gpu: Option<usize>,
54    /// Preferred GPU ordinal for queued jobs (`None` means Auto).
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub target_gpu: Option<usize>,
57}
58
59/// Whole-queue listing returned by `GET /api/queue`. Wrapped in a struct so
60/// the response can grow extra fields (totals, byte counts, etc.) without a
61/// breaking change.
62#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
63pub struct QueueListing {
64    pub entries: Vec<JobEntry>,
65}
66
67#[derive(Debug, Clone)]
68struct EntryInternal {
69    id: String,
70    model: String,
71    state: JobLifecycle,
72    started_at_unix_ms: u64,
73    gpu: Option<usize>,
74    target_gpu: Option<usize>,
75    /// Cancellation signal for `DELETE /api/queue/:id`. The submitting
76    /// handler holds the clone returned by `register*()` and selects on
77    /// `notified()` alongside the job's result channel; `cancel_queued`
78    /// fires `notify_one()` so the permit survives even when the cancel
79    /// lands before the waiter starts awaiting.
80    cancel: Arc<Notify>,
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub enum TargetGpuUpdateError {
85    NotFound,
86    AlreadyRunning,
87}
88
89/// Why a `DELETE /api/queue/:id` cancel attempt was rejected.
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub enum QueuedJobCancelError {
92    NotFound,
93    AlreadyRunning,
94}
95
96/// The registry itself. Construct via `JobRegistry::new` and share through
97/// `AppState`. All mutation is fire-and-forget — if the inner lock is
98/// poisoned (extremely unlikely in practice) we recover from the inner
99/// state rather than propagating the panic into the dispatcher hot path.
100pub struct JobRegistry {
101    inner: RwLock<Vec<EntryInternal>>,
102    /// Optional lifecycle broadcast (`GET /api/events`). Emitting from the
103    /// registry — rather than each call site — guarantees every submit /
104    /// promote / terminal path produces exactly one event. `None` keeps
105    /// event-less construction (tests) cheap.
106    events: Option<Arc<EventBroadcaster>>,
107}
108
109/// Cheap-cloneable handle. Workers and routes pass this around by value.
110pub type SharedJobRegistry = Arc<JobRegistry>;
111
112impl JobRegistry {
113    pub fn new() -> SharedJobRegistry {
114        Arc::new(Self {
115            inner: RwLock::new(Vec::new()),
116            events: None,
117        })
118    }
119
120    /// Like [`JobRegistry::new`] but mirrors every lifecycle change onto the
121    /// server-wide event broadcast.
122    pub fn with_events(events: Arc<EventBroadcaster>) -> SharedJobRegistry {
123        Arc::new(Self {
124            inner: RwLock::new(Vec::new()),
125            events: Some(events),
126        })
127    }
128
129    /// Publish outside the registry lock — callers must have dropped the
130    /// write guard first so a slow broadcast can never extend the critical
131    /// section.
132    fn emit(&self, event: ServerEvent) {
133        if let Some(events) = &self.events {
134            events.publish(event);
135        }
136    }
137
138    /// Insert a freshly-submitted job at the tail in `Queued` state.
139    /// Returns the job's cancellation signal — see `register_with_target_gpu`.
140    pub fn register(&self, id: impl Into<String>, model: impl Into<String>) -> Arc<Notify> {
141        self.register_with_target_gpu(id, model, None)
142    }
143
144    /// Insert a freshly-submitted job with an optional queued lane target.
145    ///
146    /// Returns the job's cancellation signal. The submitting handler must
147    /// hold it and select on `notified()` alongside the result channel —
148    /// `cancel_queued` resolves it when `DELETE /api/queue/:id` removes the
149    /// entry. Callers that never wait (tests poking the registry directly)
150    /// can drop the handle.
151    pub fn register_with_target_gpu(
152        &self,
153        id: impl Into<String>,
154        model: impl Into<String>,
155        target_gpu: Option<usize>,
156    ) -> Arc<Notify> {
157        let started_at_unix_ms = SystemTime::now()
158            .duration_since(UNIX_EPOCH)
159            .unwrap_or_default()
160            .as_millis() as u64;
161        let id = id.into();
162        let model = model.into();
163        let cancel = Arc::new(Notify::new());
164        {
165            let mut entries = self.inner.write().unwrap_or_else(|e| e.into_inner());
166            entries.push(EntryInternal {
167                id: id.clone(),
168                model: model.clone(),
169                state: JobLifecycle::Queued,
170                started_at_unix_ms,
171                gpu: None,
172                target_gpu,
173                cancel: cancel.clone(),
174            });
175        }
176        self.emit(ServerEvent::JobQueued { id, model });
177        cancel
178    }
179
180    /// Cancel a still-queued job: remove its entry and fire the cancel
181    /// signal returned by `register*()` so the waiting request future
182    /// resolves with a cancellation error. Running jobs are not cancelable
183    /// — the GPU worker owns them and there is no safe preemption point.
184    ///
185    /// The state check and removal happen under the same write lock that
186    /// `mark_running` takes, so a job can never be both cancelled and
187    /// promoted. (A worker that already dequeued the job before the cancel
188    /// landed will observe the closed result channel and skip it.)
189    pub fn cancel_queued(&self, id: &str) -> Result<(), QueuedJobCancelError> {
190        {
191            let mut entries = self.inner.write().unwrap_or_else(|e| e.into_inner());
192            let Some(pos) = entries.iter().position(|e| e.id == id) else {
193                return Err(QueuedJobCancelError::NotFound);
194            };
195            if entries[pos].state == JobLifecycle::Running {
196                return Err(QueuedJobCancelError::AlreadyRunning);
197            }
198            let entry = entries.remove(pos);
199            entry.cancel.notify_one();
200        }
201        self.emit(ServerEvent::JobEnded { id: id.to_string() });
202        Ok(())
203    }
204
205    /// Cancel every still-queued job in one pass, backing `DELETE /api/queue`.
206    /// Under a single write lock this removes each `Queued` entry and fires its
207    /// cancel signal; running jobs are left untouched (same rule as
208    /// [`cancel_queued`](Self::cancel_queued) — a GPU worker owns them). After
209    /// dropping the lock it emits one `JobEnded` per cancelled job (the
210    /// emit-outside-lock discipline). Returns the number of jobs cancelled.
211    pub fn cancel_all_queued(&self) -> usize {
212        let cancelled_ids = {
213            let mut entries = self.inner.write().unwrap_or_else(|e| e.into_inner());
214            let mut ids = Vec::new();
215            entries.retain(|e| {
216                if e.state == JobLifecycle::Queued {
217                    e.cancel.notify_one();
218                    ids.push(e.id.clone());
219                    false
220                } else {
221                    true
222                }
223            });
224            ids
225        };
226        for id in &cancelled_ids {
227            self.emit(ServerEvent::JobEnded { id: id.clone() });
228        }
229        cancelled_ids.len()
230    }
231
232    /// Promote a registry entry from `Queued` to `Running`. No-op if `id`
233    /// isn't present (the entry may have been removed concurrently).
234    pub fn mark_running(&self, id: &str, gpu: Option<usize>) {
235        let model = {
236            let mut entries = self.inner.write().unwrap_or_else(|e| e.into_inner());
237            entries.iter_mut().find(|e| e.id == id).map(|e| {
238                e.state = JobLifecycle::Running;
239                e.gpu = gpu;
240                e.target_gpu = None;
241                e.model.clone()
242            })
243        };
244        if let Some(model) = model {
245            self.emit(ServerEvent::JobStarted {
246                id: id.to_string(),
247                model,
248                gpu,
249            });
250        }
251    }
252
253    pub fn set_target_gpu(
254        &self,
255        id: &str,
256        target_gpu: Option<usize>,
257    ) -> Result<(), TargetGpuUpdateError> {
258        let mut entries = self.inner.write().unwrap_or_else(|e| e.into_inner());
259        let Some(e) = entries.iter_mut().find(|e| e.id == id) else {
260            return Err(TargetGpuUpdateError::NotFound);
261        };
262        if e.state == JobLifecycle::Running {
263            return Err(TargetGpuUpdateError::AlreadyRunning);
264        }
265        e.target_gpu = target_gpu;
266        Ok(())
267    }
268
269    pub fn target_gpu(&self, id: &str) -> Option<Option<usize>> {
270        let entries = self.inner.read().unwrap_or_else(|e| e.into_inner());
271        entries.iter().find(|e| e.id == id).map(|e| e.target_gpu)
272    }
273
274    pub fn entry(&self, id: &str) -> Option<JobEntry> {
275        let entries = self.inner.read().unwrap_or_else(|e| e.into_inner());
276        entries.iter().enumerate().find_map(|(i, e)| {
277            (e.id == id).then(|| JobEntry {
278                id: e.id.clone(),
279                model: e.model.clone(),
280                state: e.state,
281                started_at_unix_ms: e.started_at_unix_ms,
282                position: i,
283                gpu: e.gpu,
284                target_gpu: e.target_gpu,
285            })
286        })
287    }
288
289    /// Drop the entry — call once on every terminal path (success, error,
290    /// client-disconnect skip, dispatch failure). Idempotent.
291    pub fn remove(&self, id: &str) {
292        if id.is_empty() {
293            return;
294        }
295        let removed = {
296            let mut entries = self.inner.write().unwrap_or_else(|e| e.into_inner());
297            let before = entries.len();
298            entries.retain(|e| e.id != id);
299            entries.len() != before
300        };
301        // `remove` is called on every terminal path and is deliberately
302        // idempotent — only the call that actually dropped the entry emits,
303        // so subscribers see exactly one `job_ended` per job.
304        if removed {
305            self.emit(ServerEvent::JobEnded { id: id.to_string() });
306        }
307    }
308
309    /// Snapshot the registry as a wire-shaped listing. Positions are assigned
310    /// in insertion order at snapshot time.
311    pub fn snapshot(&self) -> QueueListing {
312        let entries = self.inner.read().unwrap_or_else(|e| e.into_inner());
313        let out = entries
314            .iter()
315            .enumerate()
316            .map(|(i, e)| JobEntry {
317                id: e.id.clone(),
318                model: e.model.clone(),
319                state: e.state,
320                started_at_unix_ms: e.started_at_unix_ms,
321                position: i,
322                gpu: e.gpu,
323                target_gpu: e.target_gpu,
324            })
325            .collect();
326        QueueListing { entries: out }
327    }
328
329    /// Currently-tracked job count. Exposed for tests and metrics.
330    pub fn len(&self) -> usize {
331        self.inner.read().unwrap_or_else(|e| e.into_inner()).len()
332    }
333
334    /// Returns true when nothing is queued or running. Public so other
335    /// callers (metrics, integration tests) can check emptiness without
336    /// allocating a full snapshot.
337    pub fn is_empty(&self) -> bool {
338        self.len() == 0
339    }
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345
346    #[test]
347    fn register_appends_in_fifo_order_with_queued_state() {
348        let reg = JobRegistry::new();
349        reg.register("a", "flux-dev:fp16");
350        reg.register("b", "sdxl:q8");
351        let snap = reg.snapshot();
352        assert_eq!(snap.entries.len(), 2);
353        assert_eq!(snap.entries[0].id, "a");
354        assert_eq!(snap.entries[0].position, 0);
355        assert_eq!(snap.entries[0].state, JobLifecycle::Queued);
356        assert_eq!(snap.entries[1].id, "b");
357        assert_eq!(snap.entries[1].position, 1);
358    }
359
360    #[test]
361    fn mark_running_flips_state_and_records_gpu_ordinal() {
362        let reg = JobRegistry::new();
363        reg.register("a", "flux-dev:fp16");
364        reg.mark_running("a", Some(1));
365        let snap = reg.snapshot();
366        assert_eq!(snap.entries[0].state, JobLifecycle::Running);
367        assert_eq!(snap.entries[0].gpu, Some(1));
368    }
369
370    #[test]
371    fn queued_entries_can_carry_target_gpu_metadata() {
372        let reg = JobRegistry::new();
373        reg.register_with_target_gpu("a", "flux-dev:fp16", Some(1));
374        let snap = reg.snapshot();
375        assert_eq!(snap.entries[0].state, JobLifecycle::Queued);
376        assert_eq!(snap.entries[0].target_gpu, Some(1));
377        assert_eq!(snap.entries[0].gpu, None);
378    }
379
380    #[test]
381    fn target_gpu_updates_only_apply_to_queued_entries() {
382        let reg = JobRegistry::new();
383        reg.register("a", "flux-dev:fp16");
384        reg.set_target_gpu("a", Some(1)).unwrap();
385        assert_eq!(reg.target_gpu("a"), Some(Some(1)));
386
387        reg.mark_running("a", Some(1));
388        let err = reg.set_target_gpu("a", None).unwrap_err();
389        assert_eq!(err, TargetGpuUpdateError::AlreadyRunning);
390        assert_eq!(reg.target_gpu("a"), Some(None));
391    }
392
393    #[test]
394    fn mark_running_is_a_noop_for_unknown_ids() {
395        let reg = JobRegistry::new();
396        reg.register("a", "flux-dev:fp16");
397        // No panic, no insertion — bogus id is ignored entirely.
398        reg.mark_running("not-here", Some(0));
399        let snap = reg.snapshot();
400        assert_eq!(snap.entries.len(), 1);
401        assert_eq!(snap.entries[0].state, JobLifecycle::Queued);
402    }
403
404    #[test]
405    fn remove_compacts_positions_for_the_survivors() {
406        let reg = JobRegistry::new();
407        reg.register("a", "flux-dev:fp16");
408        reg.register("b", "sdxl:q8");
409        reg.register("c", "ltx-video:q8");
410        reg.remove("b");
411        let snap = reg.snapshot();
412        assert_eq!(snap.entries.len(), 2);
413        assert_eq!(snap.entries[0].id, "a");
414        assert_eq!(snap.entries[0].position, 0);
415        assert_eq!(snap.entries[1].id, "c");
416        assert_eq!(snap.entries[1].position, 1);
417    }
418
419    #[test]
420    fn remove_is_idempotent_and_ignores_empty_ids() {
421        // The worker's QueueSlot drop guard removes unconditionally — if the
422        // dispatcher already removed the entry on an error path, the worker's
423        // remove must not panic. Same for jobs that bypassed the registry
424        // entirely (id == "").
425        let reg = JobRegistry::new();
426        reg.register("a", "flux-dev:fp16");
427        reg.remove("a");
428        reg.remove("a"); // second remove is a no-op
429        reg.remove("");
430        reg.remove("never-existed");
431        assert!(reg.is_empty());
432    }
433
434    #[test]
435    fn cancel_queued_removes_the_entry() {
436        let reg = JobRegistry::new();
437        reg.register("a", "flux-dev:fp16");
438        reg.register("b", "sdxl:q8");
439        reg.cancel_queued("a").unwrap();
440        let snap = reg.snapshot();
441        assert_eq!(snap.entries.len(), 1);
442        assert_eq!(snap.entries[0].id, "b");
443        assert_eq!(snap.entries[0].position, 0);
444    }
445
446    #[test]
447    fn cancel_queued_rejects_running_jobs_and_keeps_the_entry() {
448        let reg = JobRegistry::new();
449        reg.register("a", "flux-dev:fp16");
450        reg.mark_running("a", Some(0));
451        let err = reg.cancel_queued("a").unwrap_err();
452        assert_eq!(err, QueuedJobCancelError::AlreadyRunning);
453        assert_eq!(reg.len(), 1, "running entry must survive a cancel attempt");
454    }
455
456    #[test]
457    fn cancel_queued_unknown_id_is_not_found() {
458        let reg = JobRegistry::new();
459        let err = reg.cancel_queued("never-existed").unwrap_err();
460        assert_eq!(err, QueuedJobCancelError::NotFound);
461    }
462
463    #[test]
464    fn cancel_all_queued_removes_only_queued_and_returns_count() {
465        let reg = JobRegistry::new();
466        reg.register("a", "flux-dev:fp16");
467        reg.register("b", "sdxl:q8");
468        reg.register("c", "ltx-video:q8");
469        // `b` is running — it must survive the bulk cancel.
470        reg.mark_running("b", Some(0));
471
472        let cancelled = reg.cancel_all_queued();
473        assert_eq!(cancelled, 2, "both queued jobs cancelled, running one kept");
474        let snap = reg.snapshot();
475        assert_eq!(snap.entries.len(), 1);
476        assert_eq!(snap.entries[0].id, "b");
477        assert_eq!(snap.entries[0].state, JobLifecycle::Running);
478    }
479
480    #[test]
481    fn cancel_all_queued_on_empty_registry_returns_zero() {
482        let reg = JobRegistry::new();
483        assert_eq!(reg.cancel_all_queued(), 0);
484    }
485
486    #[tokio::test]
487    async fn cancel_all_queued_signals_every_registered_waiter() {
488        // Each queued job's cancel handle must resolve — cancel_all_queued
489        // fires notify_one() per entry, so the permit survives even when the
490        // cancel lands before the waiter awaits.
491        let reg = JobRegistry::new();
492        let cancel_a = reg.register("a", "flux-dev:fp16");
493        let cancel_b = reg.register("b", "sdxl:q8");
494        assert_eq!(reg.cancel_all_queued(), 2);
495        tokio::time::timeout(std::time::Duration::from_secs(1), cancel_a.notified())
496            .await
497            .expect("cancel signal for a must resolve");
498        tokio::time::timeout(std::time::Duration::from_secs(1), cancel_b.notified())
499            .await
500            .expect("cancel signal for b must resolve");
501    }
502
503    #[tokio::test]
504    async fn cancel_queued_signals_the_registered_waiter() {
505        // The handle returned by register() must resolve `notified()` even
506        // when the cancel fires before the waiter starts awaiting — Notify
507        // stores the permit from notify_one().
508        let reg = JobRegistry::new();
509        let cancel = reg.register("a", "flux-dev:fp16");
510        reg.cancel_queued("a").unwrap();
511        tokio::time::timeout(std::time::Duration::from_secs(1), cancel.notified())
512            .await
513            .expect("cancel signal must resolve the waiter");
514    }
515
516    #[test]
517    fn snapshot_serializes_with_snake_case_state_and_omits_gpu_when_queued() {
518        // Wire contract: queued rows must NOT carry a `gpu` field at all
519        // (clients shouldn't see `"gpu": null` and infer GPU 0). The state
520        // tag is lowercase to match the rest of the SSE/JSON style.
521        let reg = JobRegistry::new();
522        reg.register("a", "flux-dev:fp16");
523        let snap = reg.snapshot();
524        let json = serde_json::to_string(&snap.entries[0]).unwrap();
525        assert!(json.contains(r#""state":"queued""#), "got: {json}");
526        assert!(
527            !json.contains("gpu"),
528            "queued row leaked a gpu field: {json}"
529        );
530
531        reg.mark_running("a", Some(0));
532        let snap2 = reg.snapshot();
533        let json2 = serde_json::to_string(&snap2.entries[0]).unwrap();
534        assert!(json2.contains(r#""state":"running""#));
535        assert!(json2.contains(r#""gpu":0"#));
536    }
537
538    mod event_emission {
539        use super::*;
540        use crate::events::EventBroadcaster;
541        use mold_core::ServerEvent;
542        use tokio::sync::broadcast::error::TryRecvError;
543
544        fn wired() -> (
545            SharedJobRegistry,
546            tokio::sync::broadcast::Receiver<ServerEvent>,
547        ) {
548            let events = EventBroadcaster::new();
549            let rx = events.subscribe();
550            (JobRegistry::with_events(events), rx)
551        }
552
553        #[test]
554        fn register_emits_job_queued() {
555            let (reg, mut rx) = wired();
556            reg.register("a", "flux-dev:fp16");
557            match rx.try_recv().unwrap() {
558                ServerEvent::JobQueued { id, model } => {
559                    assert_eq!(id, "a");
560                    assert_eq!(model, "flux-dev:fp16");
561                }
562                other => panic!("expected job_queued, got {other:?}"),
563            }
564        }
565
566        #[test]
567        fn mark_running_emits_job_started_with_model_and_gpu() {
568            let (reg, mut rx) = wired();
569            reg.register("a", "flux-dev:fp16");
570            let _ = rx.try_recv(); // drain job_queued
571            reg.mark_running("a", Some(1));
572            match rx.try_recv().unwrap() {
573                ServerEvent::JobStarted { id, model, gpu } => {
574                    assert_eq!(id, "a");
575                    assert_eq!(model, "flux-dev:fp16");
576                    assert_eq!(gpu, Some(1));
577                }
578                other => panic!("expected job_started, got {other:?}"),
579            }
580        }
581
582        #[test]
583        fn mark_running_unknown_id_emits_nothing() {
584            let (reg, mut rx) = wired();
585            reg.mark_running("ghost", None);
586            assert!(matches!(rx.try_recv(), Err(TryRecvError::Empty)));
587        }
588
589        #[test]
590        fn remove_emits_job_ended_exactly_once_across_double_call() {
591            let (reg, mut rx) = wired();
592            reg.register("a", "flux-dev:fp16");
593            let _ = rx.try_recv(); // drain job_queued
594            reg.remove("a");
595            reg.remove("a"); // idempotent second call on another terminal path
596            match rx.try_recv().unwrap() {
597                ServerEvent::JobEnded { id } => assert_eq!(id, "a"),
598                other => panic!("expected job_ended, got {other:?}"),
599            }
600            assert!(
601                matches!(rx.try_recv(), Err(TryRecvError::Empty)),
602                "second remove must not emit a duplicate job_ended"
603            );
604        }
605
606        #[test]
607        fn cancel_queued_emits_job_ended() {
608            let (reg, mut rx) = wired();
609            reg.register("a", "flux-dev:fp16");
610            let _ = rx.try_recv(); // drain job_queued
611            reg.cancel_queued("a").unwrap();
612            match rx.try_recv().unwrap() {
613                ServerEvent::JobEnded { id } => assert_eq!(id, "a"),
614                other => panic!("expected job_ended, got {other:?}"),
615            }
616        }
617
618        #[test]
619        fn cancel_all_queued_emits_exactly_one_job_ended_per_cancelled_job() {
620            let (reg, mut rx) = wired();
621            reg.register("a", "flux-dev:fp16");
622            reg.register("b", "sdxl:q8");
623            reg.register("c", "ltx-video:q8");
624            reg.mark_running("c", Some(0));
625            // Drain the three job_queued + one job_started emissions.
626            while rx.try_recv().is_ok() {}
627
628            assert_eq!(reg.cancel_all_queued(), 2);
629            let mut ended = Vec::new();
630            while let Ok(ev) = rx.try_recv() {
631                match ev {
632                    ServerEvent::JobEnded { id } => ended.push(id),
633                    other => panic!("expected only job_ended, got {other:?}"),
634                }
635            }
636            ended.sort();
637            assert_eq!(ended, vec!["a".to_string(), "b".to_string()]);
638        }
639    }
640}