1use 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
25pub const POLL_INTERVAL: Duration = Duration::from_secs(1);
27
28pub const SPAWN_BACKOFF: Duration = Duration::from_secs(10);
32
33pub const SPAWN_GRACE: Duration = Duration::from_secs(20);
37
38#[derive(Debug, Clone, PartialEq, Eq)]
40pub enum LinkState {
41 Connecting,
43 Connected { url: String, version: String },
45 Starting { error: String },
47 Unreachable { error: String, started_daemon: bool },
49}
50
51impl LinkState {
52 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 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#[derive(Clone)]
88pub struct Replica {
89 pub observers: WorkerObservers,
91 pub cfg: SharedConfig,
93 pub busy: Arc<AtomicBool>,
94 pub paused: Arc<AtomicBool>,
95 pub registration: SharedRegistration,
96 pub status: Arc<Mutex<Option<DaemonStatus>>>,
98 pub models: Arc<Mutex<Vec<ModelEntry>>>,
99 pub link: Arc<Mutex<LinkState>>,
100 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 pub fn registered(&self) -> bool {
127 self.status.lock().as_ref().is_some_and(|s| s.registered)
128 }
129
130 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 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 pub fn logs_after(&self) -> u64 {
192 self.logs_seq.load(Ordering::SeqCst)
193 }
194
195 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 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
235pub trait DaemonStarter: Send {
237 fn is_running(&self) -> std::io::Result<bool>;
239 fn start(&self) -> std::io::Result<u32>;
241}
242
243pub struct ProcessStarter {
246 pub exe: PathBuf,
247 pub config_path: PathBuf,
248}
249
250pub 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 #[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 #[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 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
316pub struct Poller {
318 replica: Replica,
319 config_path: PathBuf,
320 starter: Box<dyn DaemonStarter>,
321 last_spawn: Option<Instant>,
322 spawn_backoff: Duration,
323 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 pub fn with_spawn_grace(mut self, grace: Duration) -> Self {
343 self.spawn_grace = grace;
344 self
345 }
346
347 pub fn with_spawn_backoff(mut self, backoff: Duration) -> Self {
349 self.spawn_backoff = backoff;
350 self
351 }
352
353 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 #[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#[derive(Debug, Clone, PartialEq, Eq)]
509pub enum Action {
510 SetPaused(bool),
511 Load(String),
512 Unload(String),
513 ResetRegistration,
514 Shutdown,
515}
516
517pub 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
551pub 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 #[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 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 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 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}