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