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