Skip to main content

studio_worker/
daemon_link.rs

1//! The tray UI's link to its daemon.
2//!
3//! A [`Poller`] reads the daemon's state once a second over the local API
4//! and applies it to a [`Replica`]: a local copy of the daemon's observers
5//! that the UI tabs render from.  When the daemon cannot be reached the
6//! replica is emptied (no stale data) and, when no daemon holds the daemon
7//! lock, one is started.  [`perform`] carries the operator's actions back.
8
9use std::path::{Path, PathBuf};
10use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
11use std::sync::Arc;
12use std::time::{Duration, Instant};
13
14use parking_lot::Mutex;
15
16use crate::auto_register::{RegistrationState, SharedRegistration};
17use crate::config::{Config, SharedConfig};
18use crate::daemon_api::{DaemonStatus, EditableConfig, LogsPage, ModelEntry};
19use crate::daemon_client::{ClientError, DaemonClient};
20use crate::job_log::JobLog;
21use crate::runtime::{SessionState, WorkerObservers, RECENT_LOGS_CAP};
22
23const TRACE_TARGET: &str = "studio_worker::daemon_link";
24
25/// How often the poller reads the daemon.
26pub const POLL_INTERVAL: Duration = Duration::from_secs(1);
27
28/// Shortest gap between two daemon starts.  A daemon takes a few seconds to
29/// take its lock and bind; starting another sooner only adds a process that
30/// exits at once.
31pub const SPAWN_BACKOFF: Duration = Duration::from_secs(10);
32
33/// The UI's view of its daemon.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub enum LinkState {
36    /// Not polled yet.
37    Connecting,
38    /// The daemon answers.
39    Connected { url: String, version: String },
40    /// A daemon holds the lock but does not answer (yet).
41    Starting { error: String },
42    /// No daemon answers and none holds the lock.
43    Unreachable { error: String, started_daemon: bool },
44}
45
46impl LinkState {
47    /// Stable one-word name, for logs.
48    pub fn kind(&self) -> &'static str {
49        match self {
50            LinkState::Connecting => "connecting",
51            LinkState::Connected { .. } => "connected",
52            LinkState::Starting { .. } => "starting",
53            LinkState::Unreachable { .. } => "unreachable",
54        }
55    }
56
57    pub fn is_connected(&self) -> bool {
58        matches!(self, LinkState::Connected { .. })
59    }
60
61    /// One line for the UI's status line.
62    pub fn summary(&self) -> String {
63        match self {
64            LinkState::Connecting => "connecting to the worker daemon…".into(),
65            LinkState::Connected { url, version } => format!("daemon v{version} at {url}"),
66            LinkState::Starting { .. } => {
67                "the worker daemon is starting (it holds its lock but does not answer yet)…".into()
68            }
69            LinkState::Unreachable {
70                started_daemon: true,
71                ..
72            } => "daemon not reachable; started one, waiting for it to answer…".into(),
73            LinkState::Unreachable {
74                started_daemon: false,
75                ..
76            } => "daemon not reachable; retrying…".into(),
77        }
78    }
79}
80
81/// The UI's copy of the daemon's state.  Cheap to clone.
82#[derive(Clone)]
83pub struct Replica {
84    /// The daemon's observers, as the tabs read them.
85    pub observers: WorkerObservers,
86    /// The daemon's operator-editable config and worker id (no secrets).
87    pub cfg: SharedConfig,
88    pub busy: Arc<AtomicBool>,
89    pub paused: Arc<AtomicBool>,
90    pub registration: SharedRegistration,
91    /// The last snapshot, whole.
92    pub status: Arc<Mutex<Option<DaemonStatus>>>,
93    pub models: Arc<Mutex<Vec<ModelEntry>>>,
94    pub link: Arc<Mutex<LinkState>>,
95    /// The job whose log the UI shows; the poller keeps its log fresh.
96    pub selected_job: Arc<Mutex<Option<String>>>,
97    pub selected_log: Arc<Mutex<Option<(String, JobLog)>>>,
98    logs_seq: Arc<AtomicU64>,
99}
100
101impl Default for Replica {
102    fn default() -> Self {
103        Self {
104            observers: WorkerObservers::default(),
105            cfg: crate::config::shared(Config::default()),
106            busy: Arc::default(),
107            paused: Arc::default(),
108            registration: crate::auto_register::shared_initial(),
109            status: Arc::default(),
110            models: Arc::default(),
111            link: Arc::new(Mutex::new(LinkState::Connecting)),
112            selected_job: Arc::default(),
113            selected_log: Arc::default(),
114            logs_seq: Arc::default(),
115        }
116    }
117}
118
119impl Replica {
120    /// Whether the daemon is registered with the studio.
121    pub fn registered(&self) -> bool {
122        self.status.lock().as_ref().is_some_and(|s| s.registered)
123    }
124
125    /// Mirror a snapshot into the replica.
126    pub fn apply_status(&self, status: DaemonStatus) {
127        let o = &self.observers;
128        let active: Vec<_> = status.active_jobs.iter().map(|j| j.to_current()).collect();
129        *o.current_job.lock() = status
130            .current_job_id
131            .as_ref()
132            .and_then(|id| active.iter().find(|j| &j.job_id == id).cloned());
133        *o.active_jobs.lock() = active;
134        *o.recent_jobs.lock() = status
135            .recent_jobs
136            .iter()
137            .filter_map(|j| j.to_recent())
138            .collect();
139        *o.local_jobs.lock() = status
140            .local_jobs
141            .iter()
142            .filter_map(|j| j.to_recent())
143            .collect();
144        *o.local_api_url.lock() = status.local_api_url.clone();
145        *o.last_heartbeat.lock() = status.heartbeat.clone();
146        *o.session_state.lock() = status.session.clone();
147        *o.gpu_runtime.lock() = status.gpu_runtime.clone();
148        {
149            let mut cfg = self.cfg.lock();
150            status.config.apply_to(&mut cfg);
151            cfg.worker_id = status.worker_id.clone();
152        }
153        self.busy.store(status.busy, Ordering::SeqCst);
154        self.paused.store(status.paused, Ordering::SeqCst);
155        *self.registration.lock() = status.registration.clone();
156        let with_thumbnail: Vec<String> = status
157            .active_jobs
158            .iter()
159            .chain(&status.recent_jobs)
160            .chain(&status.local_jobs)
161            .filter(|j| j.has_thumbnail)
162            .map(|j| j.job_id.clone())
163            .collect();
164        o.thumbnails
165            .retain(|id| with_thumbnail.iter().any(|j| j == id));
166        *self.status.lock() = Some(status);
167    }
168
169    /// Job ids whose thumbnail the daemon has and the replica lacks.
170    pub fn missing_thumbnails(&self) -> Vec<String> {
171        let status = self.status.lock();
172        let Some(status) = status.as_ref() else {
173            return Vec::new();
174        };
175        status
176            .active_jobs
177            .iter()
178            .chain(&status.recent_jobs)
179            .chain(&status.local_jobs)
180            .filter(|j| j.has_thumbnail && !self.observers.thumbnails.contains(&j.job_id))
181            .map(|j| j.job_id.clone())
182            .collect()
183    }
184
185    /// The sequence number to ask new log entries after.
186    pub fn logs_after(&self) -> u64 {
187        self.logs_seq.load(Ordering::SeqCst)
188    }
189
190    /// Append a page of log entries; a page from a restarted daemon (its
191    /// sequence number went backwards) replaces the ring.
192    pub fn apply_logs(&self, page: LogsPage) {
193        let mut ring = self.observers.recent_logs.lock();
194        if page.seq < self.logs_seq.load(Ordering::SeqCst) {
195            ring.clear();
196        }
197        ring.extend(page.entries);
198        while ring.len() > RECENT_LOGS_CAP {
199            ring.pop_front();
200        }
201        self.logs_seq.store(page.seq, Ordering::SeqCst);
202        self.observers
203            .recent_logs_seq
204            .store(page.seq, Ordering::SeqCst);
205    }
206
207    /// Forget everything the daemon said, so nothing stale is shown.
208    pub fn clear(&self) {
209        let o = &self.observers;
210        *o.current_job.lock() = None;
211        o.active_jobs.lock().clear();
212        o.recent_jobs.lock().clear();
213        o.local_jobs.lock().clear();
214        *o.local_api_url.lock() = None;
215        *o.last_heartbeat.lock() = None;
216        *o.session_state.lock() = SessionState::default();
217        *o.gpu_runtime.lock() = None;
218        o.recent_logs.lock().clear();
219        o.thumbnails.clear();
220        self.logs_seq.store(0, Ordering::SeqCst);
221        self.busy.store(false, Ordering::SeqCst);
222        self.paused.store(false, Ordering::SeqCst);
223        *self.registration.lock() = RegistrationState::Pristine;
224        *self.status.lock() = None;
225        self.models.lock().clear();
226        *self.selected_log.lock() = None;
227    }
228}
229
230/// Starts the daemon when none runs.
231pub trait DaemonStarter: Send {
232    /// Whether a daemon holds the daemon lock.
233    fn is_running(&self) -> std::io::Result<bool>;
234    /// Start a detached daemon; answers its pid.
235    fn start(&self) -> std::io::Result<u32>;
236}
237
238/// Starts `<exe> --config <path> run`, detached, output appended to
239/// `<config dir>/daemon.log`.
240pub struct ProcessStarter {
241    pub exe: PathBuf,
242    pub config_path: PathBuf,
243}
244
245/// The file a UI-started daemon writes its output to.
246pub fn daemon_log_path(config_path: &Path) -> PathBuf {
247    config_path
248        .parent()
249        .unwrap_or_else(|| Path::new("."))
250        .join("daemon.log")
251}
252
253impl DaemonStarter for ProcessStarter {
254    fn is_running(&self) -> std::io::Result<bool> {
255        crate::daemon_lock::is_held(&self.config_path)
256    }
257
258    // Spawns a real, detached worker process; exercised by the end-to-end
259    // run, not by unit tests (which substitute a fake starter).
260    #[cfg_attr(coverage_nightly, coverage(off))]
261    fn start(&self) -> std::io::Result<u32> {
262        use std::process::{Command, Stdio};
263        let log = std::fs::OpenOptions::new()
264            .create(true)
265            .append(true)
266            .open(daemon_log_path(&self.config_path))?;
267        let mut cmd = Command::new(&self.exe);
268        cmd.arg("--config")
269            .arg(&self.config_path)
270            .arg("run")
271            .stdin(Stdio::null())
272            .stdout(log.try_clone()?)
273            .stderr(log);
274        // Its own process group / no console, so it outlives the UI.
275        #[cfg(unix)]
276        {
277            use std::os::unix::process::CommandExt as _;
278            cmd.process_group(0);
279        }
280        #[cfg(windows)]
281        {
282            use std::os::windows::process::CommandExt as _;
283            const DETACHED_PROCESS: u32 = 0x0000_0008;
284            const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
285            const CREATE_NO_WINDOW: u32 = 0x0800_0000;
286            cmd.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW);
287        }
288        let mut child = cmd.spawn()?;
289        let pid = child.id();
290        // Reap it when it exits, and say so.
291        std::thread::spawn(move || match child.wait() {
292            Ok(status) => tracing::info!(
293                target: TRACE_TARGET,
294                op = "daemon_spawn",
295                pid,
296                status = %status,
297                "the daemon this UI started has exited"
298            ),
299            Err(e) => tracing::warn!(
300                target: TRACE_TARGET,
301                op = "daemon_spawn",
302                pid,
303                error = %e,
304                "could not wait for the daemon this UI started"
305            ),
306        });
307        Ok(pid)
308    }
309}
310
311/// Polls the daemon into a [`Replica`] and starts it when absent.
312pub struct Poller {
313    replica: Replica,
314    config_path: PathBuf,
315    starter: Box<dyn DaemonStarter>,
316    last_spawn: Option<Instant>,
317    spawn_backoff: Duration,
318}
319
320impl Poller {
321    pub fn new(replica: Replica, config_path: PathBuf, starter: Box<dyn DaemonStarter>) -> Self {
322        Self {
323            replica,
324            config_path,
325            starter,
326            last_spawn: None,
327            spawn_backoff: SPAWN_BACKOFF,
328        }
329    }
330
331    /// Override [`SPAWN_BACKOFF`] (tests).
332    pub fn with_spawn_backoff(mut self, backoff: Duration) -> Self {
333        self.spawn_backoff = backoff;
334        self
335    }
336
337    /// Poll once and update the link state.
338    pub fn tick(&mut self) {
339        let state = match self.poll() {
340            Ok(state) => state,
341            Err(err) => {
342                self.replica.clear();
343                self.recover(err)
344            }
345        };
346        self.set_link(state);
347    }
348
349    fn poll(&self) -> Result<LinkState, ClientError> {
350        let client = DaemonClient::discover(&self.config_path)?;
351        let status = client.status()?;
352        let page = client.logs(self.replica.logs_after())?;
353        let models = client.models()?;
354        let selected = self.replica.selected_job.lock().clone();
355        let selected_log = match selected {
356            Some(id) => client.job_log(&id)?.map(|log| (id, log)),
357            None => None,
358        };
359        let state = LinkState::Connected {
360            url: client.url().to_string(),
361            version: status.version.clone(),
362        };
363        self.replica.apply_status(status);
364        self.replica.apply_logs(page);
365        *self.replica.models.lock() = models;
366        *self.replica.selected_log.lock() = selected_log;
367        for id in self.replica.missing_thumbnails() {
368            if let Some(png) = client.thumbnail(&id)? {
369                self.replica.observers.thumbnails.insert(&id, png);
370            }
371        }
372        Ok(state)
373    }
374
375    fn recover(&mut self, err: ClientError) -> LinkState {
376        let error = err.to_string();
377        match self.starter.is_running() {
378            Ok(true) => LinkState::Starting { error },
379            Ok(false) => {
380                let due = self
381                    .last_spawn
382                    .is_none_or(|at| at.elapsed() >= self.spawn_backoff);
383                if !due {
384                    return LinkState::Unreachable {
385                        error,
386                        started_daemon: true,
387                    };
388                }
389                self.last_spawn = Some(Instant::now());
390                match self.starter.start() {
391                    Ok(pid) => {
392                        tracing::info!(
393                            target: TRACE_TARGET,
394                            op = "daemon_spawn",
395                            pid,
396                            log = %daemon_log_path(&self.config_path).display(),
397                            "no daemon running; started one"
398                        );
399                        LinkState::Unreachable {
400                            error,
401                            started_daemon: true,
402                        }
403                    }
404                    Err(e) => {
405                        tracing::warn!(
406                            target: TRACE_TARGET,
407                            op = "daemon_spawn",
408                            error = %e,
409                            "no daemon running and starting one failed"
410                        );
411                        LinkState::Unreachable {
412                            error: format!("{error}; starting a daemon failed: {e}"),
413                            started_daemon: false,
414                        }
415                    }
416                }
417            }
418            Err(e) => {
419                tracing::warn!(
420                    target: TRACE_TARGET,
421                    op = "link",
422                    error = %e,
423                    "could not check the daemon lock"
424                );
425                LinkState::Unreachable {
426                    error: format!("{error}; daemon lock unreadable: {e}"),
427                    started_daemon: false,
428                }
429            }
430        }
431    }
432
433    fn set_link(&self, state: LinkState) {
434        let mut link = self.replica.link.lock();
435        if link.kind() != state.kind() {
436            match &state {
437                LinkState::Connected { url, version } => tracing::info!(
438                    target: TRACE_TARGET,
439                    op = "link",
440                    from = link.kind(),
441                    to = state.kind(),
442                    url = %url,
443                    version = %version,
444                    "daemon reachable"
445                ),
446                LinkState::Starting { error } | LinkState::Unreachable { error, .. } => {
447                    tracing::warn!(
448                        target: TRACE_TARGET,
449                        op = "link",
450                        from = link.kind(),
451                        to = state.kind(),
452                        error = %error,
453                        "daemon not reachable"
454                    )
455                }
456                LinkState::Connecting => {}
457            }
458        }
459        *link = state;
460    }
461
462    /// Poll every [`POLL_INTERVAL`] until `stop`, calling `changed` after
463    /// each poll (the UI asks for a repaint).
464    // The loop only sequences `tick` (unit-tested) with sleeps.
465    #[cfg_attr(coverage_nightly, coverage(off))]
466    pub fn run(mut self, stop: Arc<AtomicBool>, changed: impl Fn()) {
467        while !stop.load(Ordering::SeqCst) {
468            self.tick();
469            changed();
470            let until = Instant::now() + POLL_INTERVAL;
471            while Instant::now() < until && !stop.load(Ordering::SeqCst) {
472                std::thread::sleep(Duration::from_millis(50));
473            }
474        }
475    }
476}
477
478/// An operator action sent to the daemon.
479#[derive(Debug, Clone, PartialEq, Eq)]
480pub enum Action {
481    SetPaused(bool),
482    Load(String),
483    Unload(String),
484    ResetRegistration,
485    Shutdown,
486}
487
488/// Carry `action` to the daemon of the config at `config_path`.  Answers
489/// the one line the UI shows: what happened, or why it did not.
490pub fn perform(config_path: &Path, action: &Action) -> Result<String, String> {
491    let outcome = DaemonClient::discover(config_path).and_then(|client| match action {
492        Action::SetPaused(true) => client.set_paused(true).map(|()| "paused".to_string()),
493        Action::SetPaused(false) => client.set_paused(false).map(|()| "resumed".to_string()),
494        Action::Load(id) => client.load_model(id).map(|state| format!("{id}: {state}")),
495        Action::Unload(id) => client
496            .unload_model(id)
497            .map(|state| format!("{id}: {state}")),
498        Action::ResetRegistration => client
499            .reset_registration()
500            .map(|()| "registration reset; asking the studio again".to_string()),
501        Action::Shutdown => client.shutdown().map(|()| "daemon stopping".to_string()),
502    });
503    match &outcome {
504        Ok(done) => tracing::info!(
505            target: TRACE_TARGET,
506            op = "action",
507            action = ?action,
508            outcome = %done,
509            "action carried to the daemon"
510        ),
511        Err(err) => tracing::warn!(
512            target: TRACE_TARGET,
513            op = "action",
514            action = ?action,
515            error = %err,
516            "action refused or not delivered"
517        ),
518    }
519    outcome.map_err(|e| e.to_string())
520}
521
522/// Send the Config tab's edit to the daemon; answers what it saved.
523pub fn save_config(config_path: &Path, edit: &EditableConfig) -> Result<EditableConfig, String> {
524    let outcome = DaemonClient::discover(config_path).and_then(|client| client.put_config(edit));
525    if let Err(err) = &outcome {
526        tracing::warn!(
527            target: TRACE_TARGET,
528            op = "action",
529            action = "save_config",
530            error = %err,
531            "config not saved"
532        );
533    }
534    outcome.map_err(|e| e.to_string())
535}
536
537#[cfg(test)]
538mod tests {
539    use super::*;
540    use crate::test_support::DaemonHarness;
541    use std::sync::atomic::AtomicU32;
542
543    /// A starter that records starts and reports a configurable lock.
544    #[derive(Clone, Default)]
545    struct FakeStarter {
546        running: Arc<AtomicBool>,
547        starts: Arc<AtomicU32>,
548        fail: bool,
549    }
550
551    impl DaemonStarter for FakeStarter {
552        fn is_running(&self) -> std::io::Result<bool> {
553            Ok(self.running.load(Ordering::SeqCst))
554        }
555        fn start(&self) -> std::io::Result<u32> {
556            self.starts.fetch_add(1, Ordering::SeqCst);
557            if self.fail {
558                return Err(std::io::Error::other("no exe"));
559            }
560            Ok(4242)
561        }
562    }
563
564    fn poller_for(config: &Path, starter: FakeStarter) -> (Poller, Replica) {
565        let replica = Replica::default();
566        let poller = Poller::new(replica.clone(), config.to_path_buf(), Box::new(starter));
567        (poller, replica)
568    }
569
570    #[test]
571    fn a_reachable_daemon_fills_the_replica() {
572        crate::test_support::install_job_log_capture();
573        let daemon = DaemonHarness::start();
574        let job_id = daemon.run_image_job();
575        daemon.push_log("hello from the daemon");
576        daemon.control.set_paused(true);
577        let starter = FakeStarter::default();
578        let (mut poller, replica) = poller_for(&daemon.config_path, starter.clone());
579        *replica.selected_job.lock() = Some(job_id.clone());
580
581        poller.tick();
582
583        assert!(replica.link.lock().is_connected());
584        assert!(replica.paused.load(Ordering::SeqCst));
585        assert_eq!(replica.observers.local_jobs.lock()[0].job_id, job_id);
586        assert!(replica.observers.thumbnails.contains(&job_id));
587        assert_eq!(replica.models.lock().len(), 2);
588        assert!(replica
589            .observers
590            .recent_logs
591            .lock()
592            .iter()
593            .any(|e| e.message == "hello from the daemon"));
594        let (id, log) = replica.selected_log.lock().clone().expect("selected log");
595        assert_eq!(id, job_id);
596        assert!(!log.lines.is_empty());
597        assert_eq!(
598            replica.cfg.lock().vram_threshold_gb,
599            Config::default().vram_threshold_gb
600        );
601        assert_eq!(starter.starts.load(Ordering::SeqCst), 0);
602
603        // A second tick only appends log entries that are new.
604        daemon.push_log("second");
605        poller.tick();
606        let messages: Vec<_> = replica
607            .observers
608            .recent_logs
609            .lock()
610            .iter()
611            .map(|e| e.message.clone())
612            .collect();
613        assert_eq!(messages.iter().filter(|m| *m == "second").count(), 1);
614        assert_eq!(
615            messages
616                .iter()
617                .filter(|m| *m == "hello from the daemon")
618                .count(),
619            1
620        );
621    }
622
623    #[test]
624    fn no_daemon_empties_the_replica_and_starts_one_with_backoff() {
625        let dir = tempfile::tempdir().unwrap();
626        let config = dir.path().join("config.toml");
627        let starter = FakeStarter::default();
628        let (poller, replica) = poller_for(&config, starter.clone());
629        let mut poller = poller.with_spawn_backoff(Duration::from_secs(60));
630        replica
631            .observers
632            .local_jobs
633            .lock()
634            .push_front(crate::runtime::RecentJob {
635                job_id: "stale".into(),
636                kind: crate::types::TaskKind::Image,
637                model: "m".into(),
638                prompt: String::new(),
639                outcome: crate::runtime::JobOutcome::Completed,
640                started_at: chrono::Utc::now(),
641                finished_at: chrono::Utc::now(),
642                source: crate::runtime::JobSource::Local,
643            });
644
645        let logs = crate::test_support::capture({
646            let replica = replica.clone();
647            move || {
648                poller.tick();
649                poller.tick();
650                assert!(matches!(
651                    *replica.link.lock(),
652                    LinkState::Unreachable {
653                        started_daemon: true,
654                        ..
655                    }
656                ));
657            }
658        });
659
660        assert_eq!(starter.starts.load(Ordering::SeqCst), 1, "backoff holds");
661        assert!(
662            replica.observers.local_jobs.lock().is_empty(),
663            "no stale data"
664        );
665        assert!(logs.contains("op=\"daemon_spawn\""), "{logs}");
666        assert!(logs.contains("pid=4242"), "{logs}");
667        assert!(logs.contains("op=\"link\""), "{logs}");
668        assert_eq!(
669            logs.matches("to=\"unreachable\"").count(),
670            1,
671            "a link change is logged once: {logs}"
672        );
673    }
674
675    #[test]
676    fn a_held_lock_means_starting_and_no_second_daemon() {
677        let dir = tempfile::tempdir().unwrap();
678        let starter = FakeStarter::default();
679        starter.running.store(true, Ordering::SeqCst);
680        let (mut poller, replica) = poller_for(&dir.path().join("config.toml"), starter.clone());
681        poller.tick();
682        assert_eq!(replica.link.lock().kind(), "starting");
683        assert_eq!(starter.starts.load(Ordering::SeqCst), 0);
684    }
685
686    #[test]
687    fn a_failed_start_is_reported() {
688        let dir = tempfile::tempdir().unwrap();
689        let starter = FakeStarter {
690            fail: true,
691            ..Default::default()
692        };
693        let (mut poller, replica) = poller_for(&dir.path().join("config.toml"), starter);
694        poller.tick();
695        let link = replica.link.lock().clone();
696        assert!(
697            matches!(&link, LinkState::Unreachable { error, started_daemon: false } if error.contains("starting a daemon failed")),
698            "{link:?}"
699        );
700    }
701
702    #[test]
703    fn the_process_starter_reads_the_real_lock() {
704        let dir = tempfile::tempdir().unwrap();
705        let config = dir.path().join("config.toml");
706        let starter = ProcessStarter {
707            exe: PathBuf::from("studio-worker"),
708            config_path: config.clone(),
709        };
710        assert!(!starter.is_running().unwrap());
711        let _lock = crate::daemon_lock::acquire_with(&config, 1, Duration::from_millis(1)).unwrap();
712        assert!(starter.is_running().unwrap());
713        assert_eq!(daemon_log_path(&config), dir.path().join("daemon.log"));
714    }
715
716    #[test]
717    fn a_restarted_daemon_replaces_the_log_ring() {
718        let replica = Replica::default();
719        let entry = |m: &str| crate::types::LogEntry {
720            ts: "t".into(),
721            level: "info".into(),
722            category: "c".into(),
723            message: m.into(),
724            job_id: None,
725        };
726        replica.apply_logs(LogsPage {
727            entries: vec![entry("old-1"), entry("old-2")],
728            seq: 50,
729        });
730        replica.apply_logs(LogsPage {
731            entries: vec![entry("new-1")],
732            seq: 1,
733        });
734        let ring = replica.observers.recent_logs.lock();
735        assert_eq!(ring.len(), 1);
736        assert_eq!(ring[0].message, "new-1");
737        assert_eq!(replica.logs_after(), 1);
738    }
739
740    #[test]
741    fn link_summaries_name_the_situation() {
742        let connected = LinkState::Connected {
743            url: "http://127.0.0.1:1".into(),
744            version: "1.2.3".into(),
745        };
746        assert!(connected.summary().contains("v1.2.3"));
747        assert!(LinkState::Connecting.summary().contains("connecting"));
748        assert!(LinkState::Starting { error: "e".into() }
749            .summary()
750            .contains("starting"));
751        assert!(LinkState::Unreachable {
752            error: "e".into(),
753            started_daemon: true
754        }
755        .summary()
756        .contains("started one"));
757        assert!(LinkState::Unreachable {
758            error: "e".into(),
759            started_daemon: false
760        }
761        .summary()
762        .contains("retrying"));
763    }
764
765    #[test]
766    fn actions_reach_the_daemon_and_refusals_come_back() {
767        let daemon = DaemonHarness::start();
768        let path = daemon.config_path.clone();
769        assert_eq!(perform(&path, &Action::SetPaused(true)).unwrap(), "paused");
770        assert!(daemon.control.paused.load(Ordering::SeqCst));
771        assert_eq!(
772            perform(&path, &Action::SetPaused(false)).unwrap(),
773            "resumed"
774        );
775        assert!(perform(&path, &Action::Load("chat".into()))
776            .unwrap()
777            .starts_with("chat: "));
778        daemon.wait_state("chat", "loaded");
779        assert!(perform(&path, &Action::Unload("chat".into())).is_ok());
780        let refused = perform(&path, &Action::ResetRegistration).unwrap_err();
781        assert!(refused.contains("not_rejected"), "{refused}");
782
783        let mut edit = EditableConfig::from_config(&Config::default());
784        edit.vram_threshold_gb = 4.0;
785        assert_eq!(save_config(&path, &edit).unwrap().vram_threshold_gb, 4.0);
786        edit.api_base_url = "bad".into();
787        assert!(save_config(&path, &edit)
788            .unwrap_err()
789            .contains("invalid_config"));
790
791        assert_eq!(
792            perform(&path, &Action::Shutdown).unwrap(),
793            "daemon stopping"
794        );
795        assert!(daemon.control.stop.load(Ordering::SeqCst));
796    }
797
798    #[test]
799    fn an_action_without_a_daemon_says_so() {
800        let dir = tempfile::tempdir().unwrap();
801        let err = perform(&dir.path().join("config.toml"), &Action::SetPaused(true)).unwrap_err();
802        assert!(err.contains("not reachable"), "{err}");
803    }
804}