Skip to main content

studio_worker/
host.rs

1//! The model host: owns loaded models, drives each model's lifecycle,
2//! persists residency and enforces admission and exclusive groups
3//! (see `docs/runtime/model-lifecycle.md`).
4//!
5//! Synchronous by design (the local API is a thread-pool server): loads
6//! and unloads run on their own threads and report back through the
7//! lifecycle.  Lock order is always `entries` before `residency`.
8
9use crate::admission::{self, FreeMemory, MemoryProbe, Refused};
10use crate::catalog::{Catalog, CatalogModel};
11use crate::lifecycle::{Command, Lifecycle, ModelState};
12use crate::residency::Residency;
13use chrono::{DateTime, Utc};
14use parking_lot::{Condvar, Mutex, MutexGuard};
15use std::any::Any;
16use std::collections::HashMap;
17use std::sync::atomic::{AtomicBool, Ordering};
18use std::sync::{mpsc, Arc};
19use std::time::{Duration, Instant};
20
21const TRACE_TARGET: &str = "studio_worker::lifecycle";
22
23/// How long an unload waits for the request in flight to notice it was
24/// cancelled before freeing anyway (the request keeps the weights alive
25/// until it returns).  A streaming chunk takes well under a second.
26/// Safe range 1..=60 s.
27pub const DRAIN_TIMEOUT: Duration = Duration::from_secs(10);
28
29/// How long a swap waits for the outgoing group member to unload before
30/// loading anyway.  Covers `DRAIN_TIMEOUT` plus freeing.  Safe range
31/// `DRAIN_TIMEOUT`..=120 s.
32pub const SWAP_TIMEOUT: Duration = Duration::from_secs(30);
33
34/// A model whose weights are in memory.  Engines downcast it back to
35/// their own type through `as_any`.
36pub trait LoadedModel: Send + Sync {
37    fn as_any(&self) -> &dyn Any;
38
39    /// The chat interface, for loaded LLMs.
40    fn as_chat(&self) -> Option<&dyn ChatModel> {
41        None
42    }
43
44    /// The streaming interface, for loaded speech models.
45    fn as_stream(&self) -> Option<&dyn StreamingModel> {
46        None
47    }
48}
49
50/// A loaded streaming speech model.  Each `open` is an independent
51/// utterance state over the shared weights.
52pub trait StreamingModel {
53    fn open(
54        &self,
55    ) -> anyhow::Result<Box<dyn crate::stt_stream::session::StreamingTranscriber + '_>>;
56}
57
58/// A loaded model that answers chat completions.
59pub trait ChatModel {
60    /// Run one completion; `cancelled` turns true when an unload starts
61    /// (or a streaming client leaves).  `on_piece` receives the answer's
62    /// text as it is generated, stop strings already cut.  Returns OpenAI
63    /// `chat.completion`-shaped JSON for the whole answer.
64    fn chat(
65        &self,
66        params: crate::types::LlmParams,
67        cancelled: &dyn Fn() -> bool,
68        on_piece: &mut dyn FnMut(&str),
69    ) -> anyhow::Result<serde_json::Value>;
70
71    /// The model's token ids for `text`; `add_special` adds BOS as the
72    /// model would for a prompt.
73    fn tokenize(&self, text: &str, add_special: bool) -> anyhow::Result<Vec<i32>>;
74}
75
76/// Loads catalogue models into memory.  Freed by dropping the result.
77pub trait ModelRuntime: Send + Sync {
78    fn load(&self, model: &CatalogModel) -> anyhow::Result<Arc<dyn LoadedModel>>;
79
80    /// Whether this runtime has an in-process loader for `model`'s engine;
81    /// the tray UI offers Load only when it does.
82    fn can_load(&self, _model: &CatalogModel) -> bool {
83        true
84    }
85}
86
87/// One model's observable status.
88#[derive(Debug, Clone, PartialEq)]
89pub struct ModelStatus {
90    pub id: String,
91    pub state: ModelState,
92    pub resident: bool,
93    pub since: DateTime<Utc>,
94}
95
96#[derive(Debug, thiserror::Error)]
97pub enum HostError {
98    #[error("unknown model: {0}")]
99    UnknownModel(String),
100    #[error("model is disabled: {0}")]
101    Disabled(String),
102    #[error(transparent)]
103    Refused(#[from] Refused),
104    #[error("model {id} is not loaded ({state})")]
105    NotLoaded { id: String, state: &'static str },
106    #[error("model {0} is busy serving another request")]
107    LaneBusy(String),
108    #[error("could not persist residency: {0}")]
109    Persist(#[from] std::io::Error),
110}
111
112/// The serving path of one loaded model: one request at a time, and a
113/// cancel flag an unload raises so a long request (a stream) can end.
114pub struct Lane {
115    busy: Mutex<()>,
116    cancel: AtomicBool,
117}
118
119impl Lane {
120    fn new() -> Self {
121        Self {
122            busy: Mutex::new(()),
123            cancel: AtomicBool::new(false),
124        }
125    }
126
127    /// True once an unload has started; long requests should return.
128    pub fn cancelled(&self) -> bool {
129        self.cancel.load(Ordering::SeqCst)
130    }
131}
132
133struct Entry {
134    lifecycle: Lifecycle,
135    since: DateTime<Utc>,
136    loaded: Option<(Arc<dyn LoadedModel>, Arc<Lane>)>,
137}
138
139impl Entry {
140    fn new() -> Self {
141        Self {
142            lifecycle: Lifecycle::new(),
143            since: Utc::now(),
144            loaded: None,
145        }
146    }
147}
148
149struct Inner {
150    catalog: Arc<Mutex<Catalog>>,
151    runtime: Arc<dyn ModelRuntime>,
152    probe: Arc<dyn MemoryProbe + Send + Sync>,
153    residency: Mutex<Residency>,
154    entries: Mutex<HashMap<String, Entry>>,
155    changed: Condvar,
156    subscribers: Mutex<Vec<mpsc::Sender<ModelStatus>>>,
157}
158
159/// Cheap to clone; every clone is the same host.
160#[derive(Clone)]
161pub struct ModelHost {
162    inner: Arc<Inner>,
163}
164
165impl ModelHost {
166    pub fn new(
167        catalog: Arc<Mutex<Catalog>>,
168        runtime: Arc<dyn ModelRuntime>,
169        probe: Arc<dyn MemoryProbe + Send + Sync>,
170        residency: Residency,
171    ) -> Self {
172        Self {
173            inner: Arc::new(Inner {
174                catalog,
175                runtime,
176                probe,
177                residency: Mutex::new(residency),
178                entries: Mutex::new(HashMap::new()),
179                changed: Condvar::new(),
180                subscribers: Mutex::new(Vec::new()),
181            }),
182        }
183    }
184
185    /// Status of one catalogue model.
186    pub fn status(&self, id: &str) -> Result<ModelStatus, HostError> {
187        self.catalogue_model(id)?;
188        let mut entries = self.inner.entries.lock();
189        Ok(self.status_locked(&mut entries, id))
190    }
191
192    /// Status of every catalogue model, in catalogue order.
193    pub fn statuses(&self) -> Vec<ModelStatus> {
194        let ids: Vec<String> = self
195            .inner
196            .catalog
197            .lock()
198            .list()
199            .iter()
200            .map(|m| m.id.clone())
201            .collect();
202        let mut entries = self.inner.entries.lock();
203        ids.iter()
204            .map(|id| self.status_locked(&mut entries, id))
205            .collect()
206    }
207
208    /// Receive every state transition from now on.
209    pub fn subscribe(&self) -> mpsc::Receiver<ModelStatus> {
210        let (tx, rx) = mpsc::channel();
211        self.inner.subscribers.lock().push(tx);
212        rx
213    }
214
215    /// Sum of the estimates of models holding (or about to hold) memory.
216    pub fn loaded_gib(&self) -> f32 {
217        let catalog = self.inner.catalog.lock().list().to_vec();
218        let entries = self.inner.entries.lock();
219        loaded_gib(&catalog, &entries)
220    }
221
222    /// Load `id` and mark it resident.  Answers the state after the
223    /// request: `loading`, or `loaded` when it already was.
224    pub fn load(&self, id: &str) -> Result<ModelStatus, HostError> {
225        let model = self.catalogue_model(id)?;
226        if !model.enabled {
227            return Err(HostError::Disabled(id.to_string()));
228        }
229        let catalog = self.inner.catalog.lock().list().to_vec();
230        let mut entries = self.inner.entries.lock();
231        let needs_load = matches!(
232            entry(&mut entries, id).lifecycle.state(),
233            ModelState::Unloaded | ModelState::Failed { .. }
234        );
235        let swap_out: Vec<String> = match &model.exclusive_group {
236            Some(group) => catalog
237                .iter()
238                .filter(|m| m.id != id && m.exclusive_group.as_ref() == Some(group))
239                .filter(|m| {
240                    entries.get(&m.id).is_some_and(|e| {
241                        matches!(
242                            e.lifecycle.state(),
243                            ModelState::Loading | ModelState::Loaded
244                        )
245                    })
246                })
247                .map(|m| m.id.clone())
248                .collect(),
249            None => Vec::new(),
250        };
251        if needs_load {
252            let freed: f32 = catalog
253                .iter()
254                .filter(|m| swap_out.contains(&m.id))
255                .map(|m| m.vram_gb_estimate)
256                .sum();
257            let free =
258                admission::free_now(self.inner.probe.as_ref(), loaded_gib(&catalog, &entries));
259            let free = credit(free, freed);
260            if let Err(refused) = admission::admit(model.vram_gb_estimate, &free) {
261                tracing::warn!(
262                    target: TRACE_TARGET,
263                    op = "admit",
264                    model = id,
265                    error = %refused,
266                    "load refused"
267                );
268                return Err(refused.into());
269            }
270        }
271        self.inner.residency.lock().set(id, true)?;
272        for other in &swap_out {
273            self.inner.residency.lock().set(other, false)?;
274            self.request_unload_locked(&mut entries, other, "swap");
275        }
276        let from = entry(&mut entries, id).lifecycle.state().clone();
277        let command = entry(&mut entries, id).lifecycle.request_load();
278        self.after_transition(&mut entries, id, "load", &from, None);
279        if command == Command::BeginLoad {
280            self.spawn_load(model, swap_out);
281        }
282        Ok(self.status_locked(&mut entries, id))
283    }
284
285    /// Unload `id` and clear its residency.
286    pub fn unload(&self, id: &str) -> Result<ModelStatus, HostError> {
287        self.catalogue_model(id)?;
288        let mut entries = self.inner.entries.lock();
289        self.inner.residency.lock().set(id, false)?;
290        self.request_unload_locked(&mut entries, id, "unload");
291        Ok(self.status_locked(&mut entries, id))
292    }
293
294    /// Load every resident model, in catalogue order.  Failures are
295    /// logged; a refused or failed model stays resident for next time.
296    pub fn restore_residents(&self) {
297        let resident: Vec<String> = self
298            .inner
299            .residency
300            .lock()
301            .ids()
302            .map(String::from)
303            .collect();
304        let catalog_ids: Vec<String> = self
305            .inner
306            .catalog
307            .lock()
308            .list()
309            .iter()
310            .map(|m| m.id.clone())
311            .collect();
312        for id in resident.iter().filter(|id| !catalog_ids.contains(id)) {
313            tracing::warn!(
314                target: TRACE_TARGET,
315                op = "restore",
316                model = %id,
317                "resident model is not in the catalogue; skipped"
318            );
319        }
320        for id in catalog_ids.iter().filter(|id| resident.contains(id)) {
321            match self.load(id) {
322                Ok(_) => tracing::info!(
323                    target: TRACE_TARGET,
324                    op = "restore",
325                    model = %id,
326                    "restoring resident model"
327                ),
328                Err(err) => tracing::warn!(
329                    target: TRACE_TARGET,
330                    op = "restore",
331                    model = %id,
332                    error = %err,
333                    "resident model not restored; stays resident for the next start"
334                ),
335            }
336        }
337    }
338
339    /// Serve one request on `id`'s lane.  Blocks while the lane is busy.
340    pub fn with_lane<R>(
341        &self,
342        id: &str,
343        f: impl FnOnce(&dyn LoadedModel, &Lane) -> R,
344    ) -> Result<R, HostError> {
345        let (model, lane) = self.lane_of(id)?;
346        let _busy = lane.busy.lock();
347        Self::serve(id, model.as_ref(), &lane, f)
348    }
349
350    /// Like [`Self::with_lane`] but refuses (`LaneBusy`) instead of waiting,
351    /// for long requests such as a stream that would otherwise queue.
352    pub fn try_with_lane<R>(
353        &self,
354        id: &str,
355        f: impl FnOnce(&dyn LoadedModel, &Lane) -> R,
356    ) -> Result<R, HostError> {
357        let (model, lane) = self.lane_of(id)?;
358        let Some(_busy) = lane.busy.try_lock() else {
359            return Err(HostError::LaneBusy(id.to_string()));
360        };
361        Self::serve(id, model.as_ref(), &lane, f)
362    }
363
364    fn serve<R>(
365        id: &str,
366        model: &dyn LoadedModel,
367        lane: &Lane,
368        f: impl FnOnce(&dyn LoadedModel, &Lane) -> R,
369    ) -> Result<R, HostError> {
370        if lane.cancelled() {
371            return Err(HostError::NotLoaded {
372                id: id.to_string(),
373                state: ModelState::Unloading.name(),
374            });
375        }
376        Ok(f(model, lane))
377    }
378
379    fn lane_of(&self, id: &str) -> Result<(Arc<dyn LoadedModel>, Arc<Lane>), HostError> {
380        let mut entries = self.inner.entries.lock();
381        let e = entry(&mut entries, id);
382        match (&e.loaded, e.lifecycle.state().serves()) {
383            (Some((m, l)), true) => Ok((m.clone(), l.clone())),
384            _ => Err(HostError::NotLoaded {
385                id: id.to_string(),
386                state: e.lifecycle.state().name(),
387            }),
388        }
389    }
390
391    /// Whether `model` can be loaded (its engine has an in-process loader).
392    pub fn can_load(&self, model: &CatalogModel) -> bool {
393        self.inner.runtime.can_load(model)
394    }
395
396    /// Block until `id`'s state satisfies `pred`, or `timeout` passes.
397    pub fn wait_for(
398        &self,
399        id: &str,
400        pred: impl Fn(&ModelState) -> bool,
401        timeout: Duration,
402    ) -> Option<ModelStatus> {
403        let deadline = Instant::now() + timeout;
404        let mut entries = self.inner.entries.lock();
405        loop {
406            if pred(entry(&mut entries, id).lifecycle.state()) {
407                return Some(self.status_locked(&mut entries, id));
408            }
409            if self
410                .inner
411                .changed
412                .wait_until(&mut entries, deadline)
413                .timed_out()
414            {
415                return None;
416            }
417        }
418    }
419
420    fn catalogue_model(&self, id: &str) -> Result<CatalogModel, HostError> {
421        self.inner
422            .catalog
423            .lock()
424            .get(id)
425            .cloned()
426            .ok_or_else(|| HostError::UnknownModel(id.to_string()))
427    }
428
429    fn status_locked(&self, entries: &mut HashMap<String, Entry>, id: &str) -> ModelStatus {
430        let e = entry(entries, id);
431        ModelStatus {
432            id: id.to_string(),
433            state: e.lifecycle.state().clone(),
434            resident: self.inner.residency.lock().is_resident(id),
435            since: e.since,
436        }
437    }
438
439    fn request_unload_locked(
440        &self,
441        entries: &mut HashMap<String, Entry>,
442        id: &str,
443        op: &'static str,
444    ) {
445        let from = entry(entries, id).lifecycle.state().clone();
446        let command = entry(entries, id).lifecycle.request_unload();
447        self.after_transition(entries, id, op, &from, None);
448        if command == Command::BeginUnload {
449            self.spawn_unload(entries, id);
450        }
451    }
452
453    /// Log, timestamp and publish a transition if the state changed.
454    fn after_transition(
455        &self,
456        entries: &mut HashMap<String, Entry>,
457        id: &str,
458        op: &'static str,
459        from: &ModelState,
460        error: Option<&str>,
461    ) {
462        let e = entry(entries, id);
463        let to = e.lifecycle.state().clone();
464        if &to == from {
465            return;
466        }
467        e.since = Utc::now();
468        match error {
469            None => tracing::info!(
470                target: TRACE_TARGET,
471                op,
472                model = id,
473                from = from.name(),
474                to = to.name(),
475                "model state changed"
476            ),
477            Some(error) => tracing::warn!(
478                target: TRACE_TARGET,
479                op,
480                model = id,
481                from = from.name(),
482                to = to.name(),
483                error,
484                "model state changed"
485            ),
486        }
487        let status = self.status_locked(entries, id);
488        self.inner
489            .subscribers
490            .lock()
491            .retain(|tx| tx.send(status.clone()).is_ok());
492        self.inner.changed.notify_all();
493    }
494
495    fn spawn_load(&self, model: CatalogModel, wait_for_unloaded: Vec<String>) {
496        let host = self.clone();
497        std::thread::spawn(move || {
498            for other in &wait_for_unloaded {
499                if host
500                    .wait_for(
501                        other,
502                        |s| matches!(s, ModelState::Unloaded | ModelState::Failed { .. }),
503                        SWAP_TIMEOUT,
504                    )
505                    .is_none()
506                {
507                    tracing::warn!(
508                        target: TRACE_TARGET,
509                        op = "swap",
510                        model = %model.id,
511                        outgoing = %other,
512                        "outgoing model did not unload in time; loading anyway"
513                    );
514                }
515            }
516            let result = host.inner.runtime.load(&model);
517            host.finish_load(&model.id, result);
518        });
519    }
520
521    fn finish_load(&self, id: &str, result: anyhow::Result<Arc<dyn LoadedModel>>) {
522        let mut entries = self.inner.entries.lock();
523        let from = entry(&mut entries, id).lifecycle.state().clone();
524        let (outcome, loaded) = match result {
525            Ok(m) => (Ok(()), Some((m, Arc::new(Lane::new())))),
526            Err(err) => (Err(format!("{err:#}")), None),
527        };
528        let error = outcome.as_ref().err().cloned();
529        let e = entry(&mut entries, id);
530        match e.lifecycle.load_finished(outcome) {
531            Ok(command) => {
532                e.loaded = loaded;
533                self.after_transition(&mut entries, id, "load", &from, error.as_deref());
534                if command == Command::BeginUnload {
535                    self.spawn_unload(&mut entries, id);
536                }
537            }
538            Err(unexpected) => tracing::error!(
539                target: TRACE_TARGET,
540                op = "load",
541                model = id,
542                error = %unexpected,
543                "load finished in a state that never started it; result dropped"
544            ),
545        }
546    }
547
548    fn spawn_unload(&self, entries: &mut HashMap<String, Entry>, id: &str) {
549        let lane = entry(entries, id).loaded.as_ref().map(|(_, l)| l.clone());
550        let host = self.clone();
551        let id = id.to_string();
552        std::thread::spawn(move || {
553            if let Some(lane) = lane {
554                lane.cancel.store(true, Ordering::SeqCst);
555                if lane.busy.try_lock_for(DRAIN_TIMEOUT).is_none() {
556                    tracing::warn!(
557                        target: TRACE_TARGET,
558                        op = "unload",
559                        model = %id,
560                        "request in flight did not end in time; memory frees when it returns"
561                    );
562                }
563            }
564            let mut entries = host.inner.entries.lock();
565            let from = entry(&mut entries, &id).lifecycle.state().clone();
566            let e = entry(&mut entries, &id);
567            e.loaded = None;
568            match e.lifecycle.unload_finished(Ok(())) {
569                Ok(_) => host.after_transition(&mut entries, &id, "unload", &from, None),
570                Err(unexpected) => tracing::error!(
571                    target: TRACE_TARGET,
572                    op = "unload",
573                    model = %id,
574                    error = %unexpected,
575                    "unload finished in a state that never started it"
576                ),
577            }
578        });
579    }
580}
581
582fn entry<'a>(entries: &'a mut HashMap<String, Entry>, id: &str) -> &'a mut Entry {
583    entries.entry(id.to_string()).or_insert_with(Entry::new)
584}
585
586fn loaded_gib(catalog: &[CatalogModel], entries: &MutexGuard<'_, HashMap<String, Entry>>) -> f32 {
587    catalog
588        .iter()
589        .filter(|m| {
590            entries.get(&m.id).is_some_and(|e| {
591                matches!(
592                    e.lifecycle.state(),
593                    ModelState::Loading | ModelState::Loaded | ModelState::Unloading
594                )
595            })
596        })
597        .map(|m| m.vram_gb_estimate)
598        .sum()
599}
600
601/// Credit memory a swap will free to the measured free memory.
602fn credit(free: FreeMemory, freed_gib: f32) -> FreeMemory {
603    match free {
604        FreeMemory::Unknown => FreeMemory::Unknown,
605        other if freed_gib == 0.0 => other,
606        other => FreeMemory::Probed {
607            gib: other.gib() + freed_gib,
608        },
609    }
610}
611
612#[cfg(test)]
613mod tests {
614    use super::*;
615    use crate::catalog::{Catalog, CatalogModel};
616    use crate::lifecycle::ModelState;
617    use crate::test_support::FixedProbe;
618    use crate::types::{ModelEngine, ModelSource, TaskKind};
619    use std::sync::atomic::AtomicUsize;
620    use std::time::Duration;
621
622    const WAIT: Duration = Duration::from_secs(5);
623
624    struct FakeLoaded {
625        id: String,
626        drops: Arc<AtomicUsize>,
627    }
628    impl LoadedModel for FakeLoaded {
629        fn as_any(&self) -> &dyn std::any::Any {
630            self
631        }
632    }
633    impl Drop for FakeLoaded {
634        fn drop(&mut self) {
635            self.drops.fetch_add(1, Ordering::SeqCst);
636        }
637    }
638
639    /// Loads succeed unless the id is in `fail`; `gate` holds loads until opened.
640    #[derive(Default)]
641    struct FakeRuntime {
642        fail: Mutex<Vec<String>>,
643        gate: Mutex<bool>,
644        gate_cv: Condvar,
645        loads: Mutex<Vec<String>>,
646        drops: Arc<AtomicUsize>,
647    }
648    impl FakeRuntime {
649        fn open() -> Arc<Self> {
650            let r = Self::default();
651            *r.gate.lock() = true;
652            Arc::new(r)
653        }
654        fn held() -> Arc<Self> {
655            Arc::new(Self::default())
656        }
657        fn release(&self) {
658            *self.gate.lock() = true;
659            self.gate_cv.notify_all();
660        }
661    }
662    impl ModelRuntime for FakeRuntime {
663        fn load(&self, model: &CatalogModel) -> anyhow::Result<Arc<dyn LoadedModel>> {
664            let mut open = self.gate.lock();
665            while !*open {
666                self.gate_cv.wait(&mut open);
667            }
668            drop(open);
669            self.loads.lock().push(model.id.clone());
670            if self.fail.lock().contains(&model.id) {
671                anyhow::bail!("cannot load {}", model.id);
672            }
673            Ok(Arc::new(FakeLoaded {
674                id: model.id.clone(),
675                drops: self.drops.clone(),
676            }))
677        }
678    }
679
680    fn model(id: &str, gib: f32, group: Option<&str>) -> CatalogModel {
681        CatalogModel {
682            id: id.into(),
683            display_name: id.into(),
684            kind: TaskKind::AudioStt,
685            vram_gb_estimate: gib,
686            description: None,
687            source: ModelSource {
688                engine: ModelEngine::Synthetic,
689                files: vec![],
690                cli_defaults: Default::default(),
691            },
692            enabled: true,
693            origin: "local".into(),
694            exclusive_group: group.map(Into::into),
695        }
696    }
697
698    struct Fixture {
699        host: ModelHost,
700        _dir: tempfile::TempDir,
701        residency_path: std::path::PathBuf,
702    }
703
704    fn fixture(models: Vec<CatalogModel>, free_gib: f32, runtime: Arc<FakeRuntime>) -> Fixture {
705        let dir = tempfile::tempdir().unwrap();
706        let residency_path = dir.path().join("residency.json");
707        fixture_in(dir, residency_path, models, free_gib, runtime)
708    }
709
710    fn fixture_in(
711        dir: tempfile::TempDir,
712        residency_path: std::path::PathBuf,
713        models: Vec<CatalogModel>,
714        free_gib: f32,
715        runtime: Arc<FakeRuntime>,
716    ) -> Fixture {
717        let catalog = Arc::new(Mutex::new(Catalog {
718            models,
719            ..Default::default()
720        }));
721        let residency = Residency::load_for_serving(Some(residency_path.clone()));
722        let host = ModelHost::new(catalog, runtime, Arc::new(FixedProbe(free_gib)), residency);
723        Fixture {
724            host,
725            _dir: dir,
726            residency_path,
727        }
728    }
729
730    fn state_of(host: &ModelHost, id: &str) -> ModelState {
731        host.status(id).unwrap().state
732    }
733
734    #[test]
735    fn every_catalogue_model_starts_unloaded_and_not_resident() {
736        let f = fixture(vec![model("a", 1.0, None)], 20.0, FakeRuntime::open());
737        let s = f.host.status("a").unwrap();
738        assert_eq!(s.state, ModelState::Unloaded);
739        assert!(!s.resident);
740        assert_eq!(f.host.statuses().len(), 1);
741    }
742
743    #[test]
744    fn load_reaches_loaded_and_marks_resident() {
745        let f = fixture(vec![model("a", 1.0, None)], 20.0, FakeRuntime::open());
746        let s = f.host.load("a").unwrap();
747        assert!(matches!(s.state, ModelState::Loading | ModelState::Loaded));
748        assert!(s.resident);
749        let s = f.host.wait_for("a", ModelState::serves, WAIT).unwrap();
750        assert_eq!(s.state, ModelState::Loaded);
751        assert!(std::fs::read_to_string(&f.residency_path)
752            .unwrap()
753            .contains("\"a\""));
754    }
755
756    #[test]
757    fn a_held_load_shows_loading() {
758        let rt = FakeRuntime::held();
759        let f = fixture(vec![model("a", 1.0, None)], 20.0, rt.clone());
760        assert_eq!(f.host.load("a").unwrap().state, ModelState::Loading);
761        assert_eq!(state_of(&f.host, "a"), ModelState::Loading);
762        rt.release();
763        f.host.wait_for("a", ModelState::serves, WAIT).unwrap();
764    }
765
766    #[test]
767    fn a_second_load_while_loading_starts_nothing_new() {
768        let rt = FakeRuntime::held();
769        let f = fixture(vec![model("a", 1.0, None)], 20.0, rt.clone());
770        f.host.load("a").unwrap();
771        f.host.load("a").unwrap();
772        rt.release();
773        f.host.wait_for("a", ModelState::serves, WAIT).unwrap();
774        assert_eq!(rt.loads.lock().len(), 1);
775    }
776
777    #[test]
778    fn a_refused_load_changes_nothing() {
779        let f = fixture(vec![model("a", 8.0, None)], 5.0, FakeRuntime::open());
780        let err = f.host.load("a").unwrap_err();
781        assert!(matches!(err, HostError::Refused(_)), "{err}");
782        let s = f.host.status("a").unwrap();
783        assert_eq!(s.state, ModelState::Unloaded);
784        assert!(!s.resident);
785        assert!(!f.residency_path.exists());
786    }
787
788    #[test]
789    fn unknown_and_disabled_models_are_rejected_by_name() {
790        let mut off = model("off", 1.0, None);
791        off.enabled = false;
792        let f = fixture(vec![off], 20.0, FakeRuntime::open());
793        assert!(matches!(f.host.load("nope"), Err(HostError::UnknownModel(id)) if id == "nope"));
794        assert!(matches!(
795            f.host.status("nope"),
796            Err(HostError::UnknownModel(_))
797        ));
798        assert!(matches!(
799            f.host.unload("nope"),
800            Err(HostError::UnknownModel(_))
801        ));
802        assert!(matches!(f.host.load("off"), Err(HostError::Disabled(id)) if id == "off"));
803    }
804
805    #[test]
806    fn a_failed_load_shows_failed_with_the_reason_and_stays_resident() {
807        let rt = FakeRuntime::open();
808        rt.fail.lock().push("a".into());
809        let f = fixture(vec![model("a", 1.0, None)], 20.0, rt);
810        f.host.load("a").unwrap();
811        let s = f
812            .host
813            .wait_for("a", |s| matches!(s, ModelState::Failed { .. }), WAIT)
814            .unwrap();
815        match s.state {
816            ModelState::Failed { reason } => assert!(reason.contains("cannot load a"), "{reason}"),
817            other => panic!("{other:?}"),
818        }
819        assert!(s.resident, "the wish survives so the next start retries");
820    }
821
822    #[test]
823    fn unload_frees_the_model_and_clears_residency() {
824        let rt = FakeRuntime::open();
825        let f = fixture(vec![model("a", 1.0, None)], 20.0, rt.clone());
826        f.host.load("a").unwrap();
827        f.host.wait_for("a", ModelState::serves, WAIT).unwrap();
828        let s = f.host.unload("a").unwrap();
829        assert!(!s.resident);
830        f.host
831            .wait_for("a", |s| *s == ModelState::Unloaded, WAIT)
832            .unwrap();
833        assert_eq!(rt.drops.load(Ordering::SeqCst), 1, "weights dropped");
834        assert!(!std::fs::read_to_string(&f.residency_path)
835            .unwrap()
836            .contains("\"a\""));
837    }
838
839    #[test]
840    fn unload_of_an_unloaded_model_is_a_noop() {
841        let f = fixture(vec![model("a", 1.0, None)], 20.0, FakeRuntime::open());
842        assert_eq!(f.host.unload("a").unwrap().state, ModelState::Unloaded);
843    }
844
845    #[test]
846    fn unload_waits_for_the_request_in_flight_and_signals_it() {
847        let rt = FakeRuntime::open();
848        let f = fixture(vec![model("a", 1.0, None)], 20.0, rt.clone());
849        f.host.load("a").unwrap();
850        f.host.wait_for("a", ModelState::serves, WAIT).unwrap();
851        let host = f.host.clone();
852        let (started_tx, started_rx) = std::sync::mpsc::channel();
853        let serving = std::thread::spawn(move || {
854            host.with_lane("a", |_m, lane| {
855                started_tx.send(()).unwrap();
856                while !lane.cancelled() {
857                    std::thread::sleep(Duration::from_millis(5));
858                }
859                "stopped"
860            })
861        });
862        started_rx.recv_timeout(WAIT).unwrap();
863        f.host.unload("a").unwrap();
864        assert_eq!(serving.join().unwrap().unwrap(), "stopped");
865        f.host
866            .wait_for("a", |s| *s == ModelState::Unloaded, WAIT)
867            .unwrap();
868    }
869
870    #[test]
871    fn serving_needs_a_loaded_model() {
872        let f = fixture(vec![model("a", 1.0, None)], 20.0, FakeRuntime::open());
873        let err = f.host.with_lane("a", |_m, _l| ()).unwrap_err();
874        assert!(
875            matches!(&err, HostError::NotLoaded { id, state } if id == "a" && *state == "unloaded"),
876            "{err}"
877        );
878    }
879
880    #[test]
881    fn try_with_lane_refuses_a_busy_lane_instead_of_waiting() {
882        let f = fixture(vec![model("a", 1.0, None)], 20.0, FakeRuntime::open());
883        f.host.load("a").unwrap();
884        f.host.wait_for("a", ModelState::serves, WAIT).unwrap();
885        let host = f.host.clone();
886        let (held_tx, held_rx) = std::sync::mpsc::channel();
887        let (release_tx, release_rx) = std::sync::mpsc::channel::<()>();
888        let holder = std::thread::spawn(move || {
889            host.with_lane("a", |_m, _l| {
890                held_tx.send(()).unwrap();
891                release_rx.recv().unwrap();
892            })
893            .unwrap()
894        });
895        held_rx.recv_timeout(WAIT).unwrap();
896        let err = f.host.try_with_lane("a", |_m, _l| ()).unwrap_err();
897        assert!(
898            matches!(&err, HostError::LaneBusy(id) if id == "a"),
899            "{err}"
900        );
901        assert_eq!(err.to_string(), "model a is busy serving another request");
902        release_tx.send(()).unwrap();
903        holder.join().unwrap();
904        assert!(f.host.try_with_lane("a", |_m, _l| ()).is_ok());
905    }
906
907    #[test]
908    fn try_with_lane_needs_a_loaded_model() {
909        let f = fixture(vec![model("a", 1.0, None)], 20.0, FakeRuntime::open());
910        assert!(matches!(
911            f.host.try_with_lane("a", |_m, _l| ()),
912            Err(HostError::NotLoaded { .. })
913        ));
914    }
915
916    #[test]
917    fn serving_hands_out_the_loaded_model() {
918        let f = fixture(vec![model("a", 1.0, None)], 20.0, FakeRuntime::open());
919        f.host.load("a").unwrap();
920        f.host.wait_for("a", ModelState::serves, WAIT).unwrap();
921        let id = f
922            .host
923            .with_lane("a", |m, _l| {
924                m.as_any().downcast_ref::<FakeLoaded>().unwrap().id.clone()
925            })
926            .unwrap();
927        assert_eq!(id, "a");
928    }
929
930    #[test]
931    fn a_lane_serves_one_request_at_a_time() {
932        let f = fixture(vec![model("a", 1.0, None)], 20.0, FakeRuntime::open());
933        f.host.load("a").unwrap();
934        f.host.wait_for("a", ModelState::serves, WAIT).unwrap();
935        let active = Arc::new(AtomicUsize::new(0));
936        let peak = Arc::new(AtomicUsize::new(0));
937        let threads: Vec<_> = (0..4)
938            .map(|_| {
939                let (host, active, peak) = (f.host.clone(), active.clone(), peak.clone());
940                std::thread::spawn(move || {
941                    host.with_lane("a", |_m, _l| {
942                        let now = active.fetch_add(1, Ordering::SeqCst) + 1;
943                        peak.fetch_max(now, Ordering::SeqCst);
944                        std::thread::sleep(Duration::from_millis(10));
945                        active.fetch_sub(1, Ordering::SeqCst);
946                    })
947                    .unwrap()
948                })
949            })
950            .collect();
951        threads.into_iter().for_each(|t| t.join().unwrap());
952        assert_eq!(peak.load(Ordering::SeqCst), 1);
953    }
954
955    #[test]
956    fn loading_a_group_member_swaps_out_the_other() {
957        let rt = FakeRuntime::open();
958        let f = fixture(
959            vec![model("a", 3.0, Some("stt")), model("b", 3.0, Some("stt"))],
960            20.0,
961            rt.clone(),
962        );
963        f.host.load("a").unwrap();
964        f.host.wait_for("a", ModelState::serves, WAIT).unwrap();
965        f.host.load("b").unwrap();
966        f.host.wait_for("b", ModelState::serves, WAIT).unwrap();
967        let a = f.host.status("a").unwrap();
968        assert_eq!(a.state, ModelState::Unloaded);
969        assert!(!a.resident, "swapped out means no longer wished");
970        assert!(f.host.status("b").unwrap().resident);
971        assert_eq!(rt.drops.load(Ordering::SeqCst), 1);
972    }
973
974    #[test]
975    fn a_swap_is_admitted_against_the_memory_it_frees() {
976        // 4 GiB free; `a` (3 GiB) is loaded; `b` needs 5 GiB: 4 + 3 - 1 margin = 6 fits.
977        let f = fixture(
978            vec![model("a", 3.0, Some("stt")), model("b", 5.0, Some("stt"))],
979            4.0,
980            FakeRuntime::open(),
981        );
982        // Load `a` first on a host with room for it.
983        f.host.load("a").unwrap();
984        f.host.wait_for("a", ModelState::serves, WAIT).unwrap();
985        f.host.load("b").unwrap();
986        f.host.wait_for("b", ModelState::serves, WAIT).unwrap();
987    }
988
989    #[test]
990    fn models_outside_a_group_are_left_alone() {
991        let f = fixture(
992            vec![model("a", 1.0, Some("stt")), model("llm", 1.0, None)],
993            20.0,
994            FakeRuntime::open(),
995        );
996        f.host.load("llm").unwrap();
997        f.host.load("a").unwrap();
998        f.host.wait_for("a", ModelState::serves, WAIT).unwrap();
999        f.host.wait_for("llm", ModelState::serves, WAIT).unwrap();
1000    }
1001
1002    #[test]
1003    fn restore_loads_residents_in_catalogue_order_and_skips_unknown_ids() {
1004        let dir = tempfile::tempdir().unwrap();
1005        let path = dir.path().join("residency.json");
1006        std::fs::write(&path, r#"{"version":1,"resident":["b","gone","a"]}"#).unwrap();
1007        let rt = FakeRuntime::open();
1008        let f = fixture_in(
1009            dir,
1010            path,
1011            vec![
1012                model("a", 1.0, None),
1013                model("b", 1.0, None),
1014                model("c", 1.0, None),
1015            ],
1016            20.0,
1017            rt.clone(),
1018        );
1019        let logs = crate::test_support::capture({
1020            let host = f.host.clone();
1021            move || host.restore_residents()
1022        });
1023        f.host.wait_for("a", ModelState::serves, WAIT).unwrap();
1024        f.host.wait_for("b", ModelState::serves, WAIT).unwrap();
1025        assert_eq!(state_of(&f.host, "c"), ModelState::Unloaded);
1026        assert!(
1027            logs.contains("resident model is not in the catalogue"),
1028            "{logs}"
1029        );
1030        assert!(logs.contains("gone"), "{logs}");
1031    }
1032
1033    #[test]
1034    fn transitions_are_published_to_subscribers() {
1035        let f = fixture(vec![model("a", 1.0, None)], 20.0, FakeRuntime::open());
1036        let rx = f.host.subscribe();
1037        f.host.load("a").unwrap();
1038        let seen: Vec<String> = (0..2)
1039            .map(|_| rx.recv_timeout(WAIT).unwrap().state.name().to_string())
1040            .collect();
1041        assert_eq!(seen, ["loading", "loaded"]);
1042    }
1043
1044    #[test]
1045    fn transitions_leave_a_lifecycle_breadcrumb() {
1046        let f = fixture(vec![model("a", 1.0, None)], 20.0, FakeRuntime::open());
1047        let logs = crate::test_support::capture({
1048            let host = f.host.clone();
1049            move || {
1050                host.load("a").unwrap();
1051                host.wait_for("a", ModelState::serves, WAIT).unwrap();
1052            }
1053        });
1054        assert!(
1055            logs.contains("op=\"load\"") || logs.contains("op=load"),
1056            "{logs}"
1057        );
1058        assert!(
1059            logs.contains("from=\"unloaded\"") || logs.contains("from=unloaded"),
1060            "{logs}"
1061        );
1062    }
1063
1064    #[test]
1065    fn loaded_estimates_are_summed_for_accounting() {
1066        let f = fixture(
1067            vec![model("a", 1.5, None), model("b", 2.0, None)],
1068            20.0,
1069            FakeRuntime::open(),
1070        );
1071        f.host.load("a").unwrap();
1072        f.host.load("b").unwrap();
1073        f.host.wait_for("a", ModelState::serves, WAIT).unwrap();
1074        f.host.wait_for("b", ModelState::serves, WAIT).unwrap();
1075        assert_eq!(f.host.loaded_gib(), 3.5);
1076    }
1077
1078    #[test]
1079    fn host_errors_read_well() {
1080        assert_eq!(
1081            HostError::UnknownModel("x".into()).to_string(),
1082            "unknown model: x"
1083        );
1084        assert_eq!(
1085            HostError::Disabled("x".into()).to_string(),
1086            "model is disabled: x"
1087        );
1088        assert_eq!(
1089            HostError::NotLoaded {
1090                id: "x".into(),
1091                state: "loading"
1092            }
1093            .to_string(),
1094            "model x is not loaded (loading)"
1095        );
1096    }
1097}