1use std::{
2 fs::{self, File, OpenOptions},
3 io::Write,
4 path::{Path, PathBuf},
5 sync::Mutex,
6 time::{SystemTime, UNIX_EPOCH},
7};
8
9use fs2::FileExt;
10use serde::{Deserialize, Serialize};
11
12use crate::error::{Result, SyncwebError};
13
14const STATE_FILE_NAME: &str = "daemon.state";
15const STATUS_FILE_NAME: &str = "daemon.status";
16const LOCK_FILE_NAME: &str = "daemon.lock";
17const SOCKET_FILE_NAME: &str = "daemon.sock";
18const RUNTIME_SOCKET_FILE_PREFIX: &str = "syncweb-";
19
20#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
22#[serde(rename_all = "lowercase")]
23#[non_exhaustive]
24pub enum DaemonStatus {
25 Starting,
26 Running,
27 Stopping,
28 Stopped,
29}
30
31#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
33#[non_exhaustive]
34pub struct DaemonState {
35 pub pid: u32,
36 pub node_id: String,
37 pub started_at: u64,
38 pub data_dir: PathBuf,
39 pub status: DaemonStatus,
40}
41
42#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
44#[non_exhaustive]
45pub struct DaemonStatusReport {
46 pub pid: u32,
47 pub node_id: String,
48 pub started_at: u64,
49 pub uptime_seconds: u64,
50 pub folders: Vec<FolderStatusReport>,
51 pub bandwidth: BandwidthSnapshot,
52 pub schedule: Option<ScheduleStatus>,
53 pub rayon_threads: usize,
54}
55
56impl DaemonStatusReport {
57 #[must_use]
58 pub fn from_state(
59 state: &DaemonState,
60 uptime_seconds: u64,
61 folders: Vec<FolderStatusReport>,
62 bandwidth: BandwidthSnapshot,
63 schedule: Option<ScheduleStatus>,
64 rayon_threads: usize,
65 ) -> Self {
66 Self {
67 pid: state.pid,
68 node_id: state.node_id.clone(),
69 started_at: state.started_at,
70 uptime_seconds,
71 folders,
72 bandwidth,
73 schedule,
74 rayon_threads,
75 }
76 }
77}
78
79#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
81#[non_exhaustive]
82pub struct FolderStatusReport {
83 pub namespace: String,
84 pub path: PathBuf,
85 pub session_active: bool,
86 pub last_sync_at: Option<u64>,
87 pub entries_synced: u64,
88 pub errors: Vec<String>,
89}
90
91impl FolderStatusReport {
92 #[must_use]
93 pub fn new(
94 namespace: impl Into<String>,
95 path: impl Into<PathBuf>,
96 session_active: bool,
97 last_sync_at: Option<u64>,
98 entries_synced: u64,
99 errors: Vec<String>,
100 ) -> Self {
101 Self {
102 namespace: namespace.into(),
103 path: path.into(),
104 session_active,
105 last_sync_at,
106 entries_synced,
107 errors,
108 }
109 }
110}
111
112#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
114#[non_exhaustive]
115pub struct BandwidthSnapshot {
116 pub upload_total: u64,
117 pub download_total: u64,
118 pub upload_rate: u64,
119 pub download_rate: u64,
120}
121
122#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
124#[non_exhaustive]
125pub struct ScheduleStatus {
126 pub in_active_window: bool,
127 pub next_window_start: Option<u64>,
128}
129
130impl DaemonState {
131 #[must_use]
132 pub fn new(
133 pid: u32,
134 node_id: impl Into<String>,
135 started_at: u64,
136 data_dir: impl Into<PathBuf>,
137 status: DaemonStatus,
138 ) -> Self {
139 Self {
140 pid,
141 node_id: node_id.into(),
142 started_at,
143 data_dir: data_dir.into(),
144 status,
145 }
146 }
147}
148
149#[derive(Clone, Debug)]
151pub struct StateFile {
152 path: PathBuf,
153}
154
155impl StateFile {
156 #[must_use]
157 pub fn new(data_dir: &Path) -> Self {
158 Self {
159 path: data_dir.join(STATE_FILE_NAME),
160 }
161 }
162
163 pub fn save(&self, state: &DaemonState) -> Result<()> {
169 let bytes = serde_json::to_vec_pretty(state)
170 .map_err(|error| SyncwebError::operation("failed to serialize daemon state", error))?;
171 Self::save_json(&self.path, STATE_FILE_NAME, &bytes)
172 }
173
174 pub fn load(&self) -> Result<Option<DaemonState>> {
180 Self::load_json(&self.path, "daemon state")
181 }
182
183 pub fn save_status(&self, report: &DaemonStatusReport) -> Result<()> {
189 let bytes = serde_json::to_vec_pretty(report)
190 .map_err(|error| SyncwebError::operation("failed to serialize daemon status", error))?;
191 Self::save_json(&self.status_path(), STATUS_FILE_NAME, &bytes)
192 }
193
194 pub fn load_status(&self) -> Result<Option<DaemonStatusReport>> {
200 Self::load_json(&self.status_path(), "daemon status")
201 }
202
203 #[must_use]
204 pub fn exists(&self) -> bool {
205 self.path.exists()
206 }
207
208 pub fn remove(&self) -> Result<()> {
214 match fs::remove_file(&self.path) {
215 Ok(()) => Ok(()),
216 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
217 Err(error) => Err(error.into()),
218 }
219 }
220
221 pub fn remove_status(&self) -> Result<()> {
227 remove_if_exists(&self.status_path())
228 }
229
230 pub fn is_running(&self) -> Result<bool> {
239 Ok(self.load()?.is_some_and(|state| {
240 matches!(state.status, DaemonStatus::Starting | DaemonStatus::Running) && pid_is_alive(state.pid)
241 }))
242 }
243
244 #[must_use]
245 pub fn path(&self) -> &Path {
246 &self.path
247 }
248
249 #[must_use]
250 pub fn status_path(&self) -> PathBuf {
251 self.path.with_file_name(STATUS_FILE_NAME)
252 }
253
254 fn save_json(path: &Path, file_name: &str, bytes: &[u8]) -> Result<()> {
255 if let Some(parent) = path.parent() {
256 fs::create_dir_all(parent)?;
257 }
258 let temporary = path.with_file_name(format!("{file_name}.tmp-{}", std::process::id()));
259 let mut file = File::create(&temporary)?;
260 file.write_all(bytes)?;
261 file.sync_all()?;
262 fs::rename(&temporary, path)?;
263 Ok(())
264 }
265
266 fn load_json<T: for<'de> Deserialize<'de>>(path: &Path, label: &str) -> Result<Option<T>> {
267 let contents = match fs::read_to_string(path) {
268 Ok(contents) => contents,
269 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
270 Err(error) => return Err(error.into()),
271 };
272 serde_json::from_str(&contents).map(Some).map_err(|error| {
273 SyncwebError::operation("failed to deserialize daemon report", format!("{label}: {error}"))
274 })
275 }
276}
277
278fn remove_if_exists(path: &Path) -> Result<()> {
279 match fs::remove_file(path) {
280 Ok(()) => Ok(()),
281 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
282 Err(error) => Err(error.into()),
283 }
284}
285
286#[derive(Debug)]
288pub struct PidLock {
289 lock_path: PathBuf,
290 state_path: PathBuf,
291 lock: Mutex<Option<File>>,
292}
293
294impl PidLock {
295 #[must_use]
296 pub fn new(data_dir: &Path) -> Self {
297 Self {
298 lock_path: data_dir.join(LOCK_FILE_NAME),
299 state_path: StateFile::new(data_dir).path,
300 lock: Mutex::new(None),
301 }
302 }
303
304 pub fn try_acquire(&self) -> Result<bool> {
313 let mut held = self
314 .lock
315 .lock()
316 .map_err(|error| SyncwebError::operation("daemon lock mutex is poisoned", error))?;
317 if held.is_some() {
318 return Ok(true);
319 }
320 if let Some(parent) = self.lock_path.parent() {
321 fs::create_dir_all(parent)?;
322 }
323 let mut candidate = OpenOptions::new()
324 .create(true)
325 .truncate(false)
326 .read(true)
327 .write(true)
328 .open(&self.lock_path)?;
329 if let Err(error) = candidate.try_lock_exclusive() {
330 if error.kind() == std::io::ErrorKind::WouldBlock {
331 return Ok(false);
332 }
333 return Err(error.into());
334 }
335 candidate.set_len(0)?;
336 writeln!(candidate, "{}", std::process::id())?;
337 candidate.sync_all()?;
338 *held = Some(candidate);
339 drop(held);
340 Ok(true)
341 }
342
343 pub fn release(&self) -> Result<()> {
349 let mut held = self
350 .lock
351 .lock()
352 .map_err(|error| SyncwebError::operation("daemon lock mutex is poisoned", error))?;
353 if let Some(file) = held.take() {
354 file.unlock()?;
355 }
356 drop(held);
357 Ok(())
358 }
359
360 pub fn owner_pid(&self) -> Result<Option<u32>> {
367 let contents = match fs::read_to_string(&self.lock_path) {
368 Ok(contents) => contents,
369 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
370 Err(error) => return Err(error.into()),
371 };
372 contents
373 .split_whitespace()
374 .next()
375 .map(|value| {
376 value
377 .parse()
378 .map_err(|error| SyncwebError::operation("invalid daemon lock PID", error))
379 })
380 .transpose()
381 }
382
383 #[must_use]
384 pub fn lock_path(&self) -> &Path {
385 &self.lock_path
386 }
387
388 #[must_use]
389 pub fn state_path(&self) -> &Path {
390 &self.state_path
391 }
392}
393
394impl Drop for PidLock {
395 fn drop(&mut self) {
396 if let Ok(mut held) = self.lock.lock()
397 && let Some(file) = held.take()
398 {
399 let _ = file.unlock();
400 }
401 }
402}
403
404#[must_use]
409pub fn daemon_socket_path(data_dir: &Path) -> PathBuf {
410 std::env::var_os("XDG_RUNTIME_DIR")
411 .filter(|value| !value.is_empty())
412 .map_or_else(
413 || data_dir.join(SOCKET_FILE_NAME),
414 |runtime_dir| {
415 let canonical_data_dir = fs::canonicalize(data_dir).unwrap_or_else(|_| data_dir.to_path_buf());
416 let digest = blake3::hash(canonical_data_dir.to_string_lossy().as_bytes());
417 let suffix: String = digest.to_hex().chars().take(16).collect();
418 PathBuf::from(runtime_dir).join(format!("{RUNTIME_SOCKET_FILE_PREFIX}{suffix}.sock"))
419 },
420 )
421}
422
423#[must_use]
425pub fn pid_is_alive(pid: u32) -> bool {
426 if pid == 0 {
427 return false;
428 }
429 if pid == std::process::id() {
430 return true;
431 }
432 #[cfg(unix)]
433 {
434 Path::new("/proc").join(pid.to_string()).exists()
435 }
436 #[cfg(not(unix))]
437 {
438 false
439 }
440}
441
442#[must_use]
443pub fn current_timestamp() -> u64 {
444 SystemTime::now()
445 .duration_since(UNIX_EPOCH)
446 .map_or(0, |duration| duration.as_secs())
447}
448
449#[cfg(test)]
450mod tests {
451 use super::*;
452
453 fn test_dir() -> PathBuf {
454 std::env::temp_dir().join(format!("syncweb-state-test-{}", uuid::Uuid::new_v4()))
455 }
456
457 #[test]
458 fn daemon_status_serialization() {
459 let cases = [
460 (DaemonStatus::Starting, r#""starting""#),
461 (DaemonStatus::Running, r#""running""#),
462 (DaemonStatus::Stopping, r#""stopping""#),
463 (DaemonStatus::Stopped, r#""stopped""#),
464 ];
465 for (status, expected) in cases {
466 let json = serde_json::to_string(&status).unwrap();
467 assert_eq!(json, expected);
468 let deserialized: DaemonStatus = serde_json::from_str(&json).unwrap();
469 assert_eq!(deserialized, status);
470 }
471 }
472
473 #[test]
474 fn daemon_state_serialization() {
475 let state = DaemonState::new(
476 12345,
477 "node-id",
478 1000,
479 PathBuf::from("/tmp/data"),
480 DaemonStatus::Running,
481 );
482 let json = serde_json::to_string_pretty(&state).unwrap();
483 let deserialized: DaemonState = serde_json::from_str(&json).unwrap();
484 assert_eq!(deserialized, state);
485 }
486
487 #[test]
488 fn state_file_save_and_load() {
489 let dir = test_dir();
490 let state_file = StateFile::new(&dir);
491 let state = DaemonState::new(std::process::id(), "test-node", 42, dir.clone(), DaemonStatus::Running);
492 state_file.save(&state).unwrap();
493 assert!(state_file.exists());
494 let loaded = state_file.load().unwrap().expect("state should exist");
495 assert_eq!(loaded.pid, state.pid);
496 assert_eq!(loaded.node_id, state.node_id);
497 assert_eq!(loaded.started_at, state.started_at);
498 assert_eq!(loaded.status, state.status);
499 let _ = std::fs::remove_dir_all(&dir);
500 }
501
502 #[test]
503 fn state_file_missing_returns_none() {
504 let dir = test_dir();
505 let state_file = StateFile::new(&dir);
506 assert!(!state_file.exists());
507 let loaded = state_file.load().unwrap();
508 assert!(loaded.is_none());
509 let _ = std::fs::remove_dir_all(&dir);
510 }
511
512 #[test]
513 fn state_file_corrupted_returns_error() {
514 let dir = test_dir();
515 std::fs::create_dir_all(&dir).unwrap();
516 std::fs::write(dir.join("daemon.state"), b"not valid json").unwrap();
517 let state_file = StateFile::new(&dir);
518 let result = state_file.load();
519 assert!(result.is_err());
520 let error = result.unwrap_err();
521 assert!(error.to_string().contains("daemon state"));
522 let _ = std::fs::remove_dir_all(&dir);
523 }
524
525 #[test]
526 fn state_file_remove() {
527 let dir = test_dir();
528 let state_file = StateFile::new(&dir);
529 let state = DaemonState::new(1, "node", 1, dir.clone(), DaemonStatus::Starting);
530 state_file.save(&state).unwrap();
531 assert!(state_file.exists());
532 state_file.remove().unwrap();
533 assert!(!state_file.exists());
534 state_file.remove().unwrap();
535 let _ = std::fs::remove_dir_all(&dir);
536 }
537
538 #[test]
539 fn state_file_is_running_returns_false_for_stale() {
540 let dir = test_dir();
541 let state_file = StateFile::new(&dir);
542 let state = DaemonState::new(u32::MAX, "stale", 1, dir.clone(), DaemonStatus::Running);
543 state_file.save(&state).unwrap();
544 assert!(!state_file.is_running().unwrap());
545 let _ = std::fs::remove_dir_all(&dir);
546 }
547
548 #[test]
549 fn status_report_save_and_load() {
550 let dir = test_dir();
551 let state_file = StateFile::new(&dir);
552 let report = DaemonStatusReport::from_state(
553 &DaemonState::new(1, "node", 100, dir.clone(), DaemonStatus::Running),
554 10,
555 vec![],
556 BandwidthSnapshot::default(),
557 None,
558 4,
559 );
560 state_file.save_status(&report).unwrap();
561 let loaded = state_file.load_status().unwrap().expect("report should exist");
562 assert_eq!(loaded.pid, report.pid);
563 assert_eq!(loaded.node_id, report.node_id);
564 assert_eq!(loaded.uptime_seconds, report.uptime_seconds);
565 let _ = std::fs::remove_dir_all(&dir);
566 }
567
568 #[test]
569 fn pid_lock_acquire_success() {
570 let dir = test_dir();
571 std::fs::create_dir_all(&dir).unwrap();
572 let lock = PidLock::new(&dir);
573 assert!(lock.try_acquire().unwrap());
574 assert!(lock.lock_path().exists());
575 let pid = lock.owner_pid().unwrap().expect("pid should be written");
576 assert_eq!(pid, std::process::id());
577 lock.release().unwrap();
578 let _ = std::fs::remove_dir_all(&dir);
579 }
580
581 #[test]
582 fn pid_lock_acquire_conflict() {
583 let dir = test_dir();
584 std::fs::create_dir_all(&dir).unwrap();
585 let first = PidLock::new(&dir);
586 assert!(first.try_acquire().unwrap());
587 let second = PidLock::new(&dir);
588 assert!(!second.try_acquire().unwrap());
589 first.release().unwrap();
590 let _ = std::fs::remove_dir_all(&dir);
591 }
592
593 #[test]
594 fn pid_lock_release_releases_lock() {
595 let dir = test_dir();
596 std::fs::create_dir_all(&dir).unwrap();
597 let lock = PidLock::new(&dir);
598 assert!(lock.try_acquire().unwrap());
599 lock.release().unwrap();
600 let reacquire = PidLock::new(&dir);
601 assert!(reacquire.try_acquire().unwrap());
602 reacquire.release().unwrap();
603 let _ = std::fs::remove_dir_all(&dir);
604 }
605
606 #[test]
607 fn pid_lock_drop_releases() {
608 let dir = test_dir();
609 std::fs::create_dir_all(&dir).unwrap();
610 {
611 let lock = PidLock::new(&dir);
612 assert!(lock.try_acquire().unwrap());
613 }
614 let reacquire = PidLock::new(&dir);
615 assert!(reacquire.try_acquire().unwrap());
616 reacquire.release().unwrap();
617 let _ = std::fs::remove_dir_all(&dir);
618 }
619
620 #[test]
621 fn pid_is_alive_checks_current_and_zero() {
622 assert!(pid_is_alive(std::process::id()));
623 assert!(!pid_is_alive(0));
624 }
625
626 #[test]
627 fn daemon_socket_path_returns_valid_path() {
628 let dir = test_dir();
629 let path = daemon_socket_path(&dir);
630 assert!(path.file_name().unwrap().to_string_lossy().ends_with(".sock"));
631 let _ = std::fs::remove_dir_all(&dir);
632 }
633
634 #[test]
635 fn pid_lock_stale_detection() {
636 let dir = test_dir();
637 std::fs::create_dir_all(&dir).unwrap();
638 let lock = PidLock::new(&dir);
639 assert!(lock.try_acquire().unwrap());
640 let pid = lock.owner_pid().unwrap().expect("pid should be written");
641 assert_eq!(pid, std::process::id());
642 lock.release().unwrap();
643 assert!(!pid_is_alive(99999_u32));
644 let state_file = StateFile::new(&dir);
645 state_file
646 .save(&DaemonState::new(99999, "stale-daemon", 1, &dir, DaemonStatus::Running))
647 .unwrap();
648 assert!(!state_file.is_running().unwrap());
649 let _ = std::fs::remove_dir_all(&dir);
650 }
651
652 #[test]
653 fn pid_lock_state_file_consistency() {
654 let dir = test_dir();
655 std::fs::create_dir_all(&dir).unwrap();
656 let lock = PidLock::new(&dir);
657 assert!(lock.try_acquire().unwrap());
658 let state_file = StateFile::new(&dir);
659 state_file
660 .save(&DaemonState::new(
661 std::process::id(),
662 "consistent",
663 current_timestamp(),
664 &dir,
665 DaemonStatus::Running,
666 ))
667 .unwrap();
668 assert!(state_file.exists());
669 assert!(lock.lock_path().exists());
670 assert_eq!(lock.owner_pid().unwrap(), Some(std::process::id()));
671 lock.release().unwrap();
672 state_file.remove().unwrap();
673 let _ = std::fs::remove_dir_all(&dir);
674 }
675
676 #[test]
677 fn pid_lock_concurrent_start_race() {
678 let dir = test_dir();
679 std::fs::create_dir_all(&dir).unwrap();
680 let first = PidLock::new(&dir);
681 assert!(first.try_acquire().unwrap());
682 let second = PidLock::new(&dir);
683 assert!(!second.try_acquire().unwrap());
684 first.release().unwrap();
685 assert!(second.try_acquire().unwrap());
686 second.release().unwrap();
687 let _ = std::fs::remove_dir_all(&dir);
688 }
689
690 #[test]
691 fn status_report_serialization_json() {
692 let report = DaemonStatusReport::from_state(
693 &DaemonState::new(42, "ns-id", 100, PathBuf::from("/data"), DaemonStatus::Running),
694 10,
695 vec![FolderStatusReport::new(
696 "folder1",
697 PathBuf::from("/foo"),
698 true,
699 Some(200),
700 5,
701 vec![],
702 )],
703 BandwidthSnapshot::default(),
704 Some(ScheduleStatus {
705 in_active_window: true,
706 next_window_start: Some(300),
707 }),
708 4,
709 );
710 let json = serde_json::to_string_pretty(&report).unwrap();
711 assert!(json.contains("folder1"));
712 assert!(json.contains("uptime_seconds"));
713 let deserialized: DaemonStatusReport = serde_json::from_str(&json).unwrap();
714 assert_eq!(deserialized.pid, report.pid);
715 assert_eq!(deserialized.folders.len(), 1);
716 assert!(deserialized.schedule.unwrap().in_active_window);
717 }
718
719 #[test]
720 fn daemon_status_report_includes_folders() {
721 let report = DaemonStatusReport::from_state(
722 &DaemonState::new(1, "node", 1, PathBuf::from("/tmp"), DaemonStatus::Running),
723 5,
724 vec![
725 FolderStatusReport::new("ns1", PathBuf::from("/a"), true, None, 3, vec![]),
726 FolderStatusReport::new("ns2", PathBuf::from("/b"), false, Some(10), 0, vec!["error1".into()]),
727 ],
728 BandwidthSnapshot::default(),
729 None,
730 2,
731 );
732 assert_eq!(report.folders.len(), 2);
733 assert!(report.folders.iter().any(|f| f.namespace == "ns1"));
734 assert!(
735 report
736 .folders
737 .iter()
738 .any(|f| f.namespace == "ns2" && !f.session_active && f.errors.len() == 1)
739 );
740 }
741}