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
33#[derive(Debug, Clone, PartialEq, Eq)]
35pub enum LinkState {
36 Connecting,
38 Connected { url: String, version: String },
40 Starting { error: String },
42 Unreachable { error: String, started_daemon: bool },
44}
45
46impl LinkState {
47 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 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#[derive(Clone)]
83pub struct Replica {
84 pub observers: WorkerObservers,
86 pub cfg: SharedConfig,
88 pub busy: Arc<AtomicBool>,
89 pub paused: Arc<AtomicBool>,
90 pub registration: SharedRegistration,
91 pub status: Arc<Mutex<Option<DaemonStatus>>>,
93 pub models: Arc<Mutex<Vec<ModelEntry>>>,
94 pub link: Arc<Mutex<LinkState>>,
95 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 pub fn registered(&self) -> bool {
122 self.status.lock().as_ref().is_some_and(|s| s.registered)
123 }
124
125 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 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 pub fn logs_after(&self) -> u64 {
187 self.logs_seq.load(Ordering::SeqCst)
188 }
189
190 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 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
230pub trait DaemonStarter: Send {
232 fn is_running(&self) -> std::io::Result<bool>;
234 fn start(&self) -> std::io::Result<u32>;
236}
237
238pub struct ProcessStarter {
241 pub exe: PathBuf,
242 pub config_path: PathBuf,
243}
244
245pub 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 #[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 #[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 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
311pub 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 pub fn with_spawn_backoff(mut self, backoff: Duration) -> Self {
333 self.spawn_backoff = backoff;
334 self
335 }
336
337 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 #[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#[derive(Debug, Clone, PartialEq, Eq)]
480pub enum Action {
481 SetPaused(bool),
482 Load(String),
483 Unload(String),
484 ResetRegistration,
485 Shutdown,
486}
487
488pub 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
522pub 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 #[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 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}