1use std::{
2 fs::{self, File, OpenOptions},
3 io::{BufReader, Read, Seek, SeekFrom, 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};
13use crate::storage::node_db::NodeDatabase;
14
15const LOCK_FILE_NAME: &str = "daemon.lock";
16const SOCKET_FILE_NAME: &str = "daemon.sock";
17const RUNTIME_SOCKET_FILE_PREFIX: &str = "syncweb-";
18
19#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
21#[serde(rename_all = "lowercase")]
22#[non_exhaustive]
23pub enum DaemonStatus {
24 Starting,
25 Running,
26 Stopping,
27 Stopped,
28}
29
30#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
32#[non_exhaustive]
33pub struct DaemonState {
34 pub pid: u32,
35 pub node_id: String,
36 pub started_at: u64,
37 pub data_dir: PathBuf,
38 pub status: DaemonStatus,
39}
40
41#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
43#[non_exhaustive]
44pub struct DaemonStatusReport {
45 pub pid: u32,
46 pub node_id: String,
47 pub started_at: u64,
48 pub uptime_seconds: u64,
49 pub folders: Vec<FolderStatusReport>,
50 pub bandwidth: BandwidthSnapshot,
51 pub schedule: Option<ScheduleStatus>,
52 pub rayon_threads: usize,
53}
54
55impl DaemonStatusReport {
56 #[must_use]
57 pub fn from_state(
58 state: &DaemonState,
59 uptime_seconds: u64,
60 folders: Vec<FolderStatusReport>,
61 bandwidth: BandwidthSnapshot,
62 schedule: Option<ScheduleStatus>,
63 rayon_threads: usize,
64 ) -> Self {
65 Self {
66 pid: state.pid,
67 node_id: state.node_id.clone(),
68 started_at: state.started_at,
69 uptime_seconds,
70 folders,
71 bandwidth,
72 schedule,
73 rayon_threads,
74 }
75 }
76}
77
78#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
80#[non_exhaustive]
81pub struct FolderStatusReport {
82 pub namespace: String,
83 pub path: PathBuf,
84 #[serde(default = "default_kind")]
85 pub kind: String,
86 pub session_active: bool,
87 pub last_sync_at: Option<u64>,
88 #[serde(default)]
89 pub sync_count: u64,
90 pub entries_synced: u64,
91 pub errors: Vec<String>,
92}
93
94fn default_kind() -> String {
95 "folder".to_owned()
96}
97
98impl FolderStatusReport {
99 #[must_use]
100 pub fn new(
101 namespace: impl Into<String>,
102 path: impl Into<PathBuf>,
103 session_active: bool,
104 last_sync_at: Option<u64>,
105 entries_synced: u64,
106 errors: Vec<String>,
107 ) -> Self {
108 Self {
109 namespace: namespace.into(),
110 path: path.into(),
111 kind: "folder".to_owned(),
112 session_active,
113 last_sync_at,
114 sync_count: 0,
115 entries_synced,
116 errors,
117 }
118 }
119}
120
121#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
123#[non_exhaustive]
124pub struct BandwidthSnapshot {
125 pub upload_total: u64,
126 pub download_total: u64,
127 pub upload_rate: u64,
128 pub download_rate: u64,
129}
130
131#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
133#[non_exhaustive]
134pub struct ScheduleStatus {
135 pub in_active_window: bool,
136 pub next_window_start: Option<u64>,
137}
138
139impl DaemonState {
140 #[must_use]
141 pub fn new(
142 pid: u32,
143 node_id: impl Into<String>,
144 started_at: u64,
145 data_dir: impl Into<PathBuf>,
146 status: DaemonStatus,
147 ) -> Self {
148 Self {
149 pid,
150 node_id: node_id.into(),
151 started_at,
152 data_dir: data_dir.into(),
153 status,
154 }
155 }
156}
157
158#[derive(Clone, Debug)]
160pub struct StateFile {
161 data_dir: PathBuf,
162}
163
164impl StateFile {
165 #[must_use]
168 pub fn new(data_dir: &Path) -> Self {
169 Self {
170 data_dir: data_dir.to_path_buf(),
171 }
172 }
173
174 fn db(&self) -> Result<NodeDatabase> {
180 NodeDatabase::open(self.data_dir.join("node.db"))
181 }
182
183 pub fn save(&self, state: &DaemonState) -> Result<()> {
189 let db = self.db()?;
190 db.save_lifecycle(state)
191 }
192
193 pub fn load(&self) -> Result<Option<DaemonState>> {
199 let db = self.db()?;
200 db.load_lifecycle()
201 }
202
203 pub fn save_status(&self, report: &DaemonStatusReport) -> Result<()> {
209 let db = self.db()?;
210 db.save_status(report)
211 }
212
213 pub fn load_status(&self) -> Result<Option<DaemonStatusReport>> {
219 let db = self.db()?;
220 db.load_status()
221 }
222
223 #[must_use]
225 pub fn exists(&self) -> bool {
226 self.db()
227 .ok()
228 .and_then(|db| db.load_lifecycle().ok())
229 .flatten()
230 .is_some()
231 }
232
233 pub fn remove(&self) -> Result<()> {
239 let db = self.db()?;
240 db.remove_lifecycle()
241 }
242
243 pub fn remove_status(&self) -> Result<()> {
249 let db = self.db()?;
250 db.remove_status()
251 }
252
253 pub fn is_running(&self) -> Result<bool> {
259 Ok(self.load()?.is_some_and(|state| {
260 matches!(state.status, DaemonStatus::Starting | DaemonStatus::Running) && pid_is_alive(state.pid)
261 }))
262 }
263}
264
265#[derive(Debug)]
268pub struct PidLock {
269 lock_path: PathBuf,
270 lock: Mutex<Option<File>>,
271}
272
273impl PidLock {
274 #[must_use]
275 pub fn new(data_dir: &Path) -> Self {
276 Self {
277 lock_path: data_dir.join(LOCK_FILE_NAME),
278 lock: Mutex::new(None),
279 }
280 }
281
282 pub fn try_acquire(&self) -> Result<bool> {
291 let mut held = self
292 .lock
293 .lock()
294 .map_err(|error| SyncwebError::operation("daemon lock mutex is poisoned", error))?;
295 if held.is_some() {
296 return Ok(true);
297 }
298 if let Some(parent) = self.lock_path.parent() {
299 fs::create_dir_all(parent)?;
300 }
301 let mut candidate = OpenOptions::new()
302 .create(true)
303 .truncate(false)
304 .read(true)
305 .write(true)
306 .open(&self.lock_path)?;
307 if let Err(error) = candidate.try_lock_exclusive() {
308 if error.kind() == std::io::ErrorKind::WouldBlock {
309 return Ok(false);
310 }
311 return Err(error.into());
312 }
313 candidate.set_len(0)?;
314 writeln!(candidate, "{}", std::process::id())?;
315 candidate.sync_all()?;
316 *held = Some(candidate);
317 drop(held);
318 Ok(true)
319 }
320
321 pub fn release(&self) -> Result<()> {
327 let mut held = self
328 .lock
329 .lock()
330 .map_err(|error| SyncwebError::operation("daemon lock mutex is poisoned", error))?;
331 if let Some(file) = held.take() {
332 file.unlock()?;
333 }
334 drop(held);
335 Ok(())
336 }
337
338 pub fn owner_pid(&self) -> Result<Option<u32>> {
345 let mut held = self
346 .lock
347 .lock()
348 .map_err(|error| SyncwebError::operation("daemon lock mutex is poisoned", error))?;
349 if let Some(file) = held.as_mut() {
350 file.seek(SeekFrom::Start(0))?;
351 let mut contents = String::new();
352 let mut reader = BufReader::new(&mut *file);
353 reader.read_to_string(&mut contents)?;
354 return Self::parse_pid_from_contents(&contents);
355 }
356 let contents = match fs::read_to_string(&self.lock_path) {
357 Ok(contents) => contents,
358 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
359 Err(error) => return Err(error.into()),
360 };
361 drop(held);
362 Self::parse_pid_from_contents(&contents)
363 }
364
365 fn parse_pid_from_contents(contents: &str) -> Result<Option<u32>> {
366 contents
367 .split_whitespace()
368 .next()
369 .map(|value| {
370 value
371 .parse()
372 .map_err(|error| SyncwebError::operation("invalid daemon lock PID", error))
373 })
374 .transpose()
375 }
376
377 #[must_use]
378 pub fn lock_path(&self) -> &Path {
379 &self.lock_path
380 }
381}
382
383impl Drop for PidLock {
384 fn drop(&mut self) {
385 if let Ok(mut held) = self.lock.lock()
386 && let Some(file) = held.take()
387 {
388 let _ = file.unlock();
389 }
390 }
391}
392
393#[must_use]
401pub fn daemon_socket_path(data_dir: &Path) -> PathBuf {
402 if let Some(runtime_dir) = std::env::var_os("XDG_RUNTIME_DIR").filter(|value| !value.is_empty()) {
403 let canonical_data_dir = fs::canonicalize(data_dir).unwrap_or_else(|_| data_dir.to_path_buf());
404 let digest = blake3::hash(canonical_data_dir.to_string_lossy().as_bytes());
405 let suffix: String = digest.to_hex().chars().take(16).collect();
406 return PathBuf::from(runtime_dir).join(format!("{RUNTIME_SOCKET_FILE_PREFIX}{suffix}.sock"));
407 }
408 let socket = data_dir.join(SOCKET_FILE_NAME);
409 #[cfg(target_os = "macos")]
410 if socket.as_os_str().len() >= 100 {
411 let canonical_data_dir = fs::canonicalize(data_dir).unwrap_or_else(|_| data_dir.to_path_buf());
412 let digest = blake3::hash(canonical_data_dir.to_string_lossy().as_bytes());
413 let suffix: String = digest.to_hex().chars().take(16).collect();
414 return std::env::temp_dir().join(format!("{RUNTIME_SOCKET_FILE_PREFIX}{suffix}.sock"));
415 }
416 socket
417}
418
419#[must_use]
421pub fn pid_is_alive(pid: u32) -> bool {
422 if pid == 0 {
423 return false;
424 }
425 if pid == std::process::id() {
426 return true;
427 }
428 #[cfg(target_os = "linux")]
429 {
430 Path::new("/proc").join(pid.to_string()).exists()
431 }
432 #[cfg(all(unix, not(target_os = "linux")))]
433 {
434 use libc::kill;
435 let Ok(pid_i32) = i32::try_from(pid) else {
436 return false;
437 };
438 unsafe { kill(pid_i32, 0) == 0 }
442 }
443 #[cfg(windows)]
444 {
445 fn is_process_alive(pid: u32) -> bool {
446 use std::ffi::c_void;
447
448 const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000;
449 const STILL_ACTIVE: u32 = 259;
450
451 type HANDLE = *mut c_void;
452
453 unsafe extern "system" {
454 fn OpenProcess(dwDesiredAccess: u32, bInheritHandle: i32, dwProcessId: u32) -> HANDLE;
455 fn GetExitCodeProcess(hProcess: HANDLE, lpExitCode: *mut u32) -> i32;
456 fn CloseHandle(hObject: HANDLE) -> i32;
457 }
458
459 let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
460 if handle.is_null() {
461 return false;
462 }
463 let mut exit_code: u32 = 0;
464 let result = unsafe { GetExitCodeProcess(handle, &mut exit_code) };
465 unsafe { CloseHandle(handle) };
466 result != 0 && exit_code == STILL_ACTIVE
467 }
468 is_process_alive(pid)
469 }
470 #[cfg(not(any(unix, windows)))]
471 {
472 true
473 }
474}
475
476#[must_use]
477pub fn current_timestamp() -> u64 {
478 SystemTime::now()
479 .duration_since(UNIX_EPOCH)
480 .map_or(0, |duration| duration.as_secs())
481}
482
483#[cfg(test)]
484mod tests {
485 use super::*;
486
487 fn test_dir() -> PathBuf {
488 std::env::temp_dir().join(format!("syncweb-state-test-{}", uuid::Uuid::new_v4()))
489 }
490
491 #[test]
492 fn daemon_status_serialization() {
493 let cases = [
494 (DaemonStatus::Starting, r#""starting""#),
495 (DaemonStatus::Running, r#""running""#),
496 (DaemonStatus::Stopping, r#""stopping""#),
497 (DaemonStatus::Stopped, r#""stopped""#),
498 ];
499 for (status, expected) in cases {
500 let json = serde_json::to_string(&status).unwrap();
501 assert_eq!(json, expected);
502 let deserialized: DaemonStatus = serde_json::from_str(&json).unwrap();
503 assert_eq!(deserialized, status);
504 }
505 }
506
507 #[test]
508 fn daemon_state_serialization() {
509 let state = DaemonState::new(
510 12345,
511 "node-id",
512 1000,
513 PathBuf::from("/tmp/data"),
514 DaemonStatus::Running,
515 );
516 let json = serde_json::to_string_pretty(&state).unwrap();
517 let deserialized: DaemonState = serde_json::from_str(&json).unwrap();
518 assert_eq!(deserialized, state);
519 }
520
521 #[test]
522 fn state_file_save_and_load() {
523 let dir = test_dir();
524 let state_file = StateFile::new(&dir);
525 let state = DaemonState::new(std::process::id(), "test-node", 42, dir.clone(), DaemonStatus::Running);
526 state_file.save(&state).unwrap();
527 assert!(state_file.exists());
528 let loaded = state_file.load().unwrap().expect("state should exist");
529 assert_eq!(loaded.pid, state.pid);
530 assert_eq!(loaded.node_id, state.node_id);
531 assert_eq!(loaded.started_at, state.started_at);
532 assert_eq!(loaded.status, state.status);
533 let _ = std::fs::remove_dir_all(&dir);
534 }
535
536 #[test]
537 fn state_file_missing_returns_none() {
538 let dir = test_dir();
539 let state_file = StateFile::new(&dir);
540 assert!(!state_file.exists());
541 let loaded = state_file.load().unwrap();
542 assert!(loaded.is_none());
543 let _ = std::fs::remove_dir_all(&dir);
544 }
545
546 #[test]
547 fn state_file_corrupted_returns_error() {
548 let dir = test_dir();
549 let state_file = StateFile::new(&dir);
550 state_file
551 .save(&DaemonState::new(1, "node", 1, dir.clone(), DaemonStatus::Starting))
552 .unwrap();
553 let loaded = state_file.load().unwrap();
554 assert!(loaded.is_some());
555 assert_eq!(loaded.unwrap().pid, 1);
556
557 state_file.remove().unwrap();
558 let loaded_after_remove = state_file.load().unwrap();
559 assert!(loaded_after_remove.is_none());
560 let _ = std::fs::remove_dir_all(&dir);
561 }
562
563 #[test]
564 fn state_file_remove() {
565 let dir = test_dir();
566 let state_file = StateFile::new(&dir);
567 let state = DaemonState::new(1, "node", 1, dir.clone(), DaemonStatus::Starting);
568 state_file.save(&state).unwrap();
569 assert!(state_file.exists());
570 state_file.remove().unwrap();
571 assert!(!state_file.exists());
572 state_file.remove().unwrap();
573 let _ = std::fs::remove_dir_all(&dir);
574 }
575
576 #[test]
577 fn state_file_is_running_returns_false_for_stale() {
578 let dir = test_dir();
579 let state_file = StateFile::new(&dir);
580 let state = DaemonState::new(u32::MAX, "stale", 1, dir.clone(), DaemonStatus::Running);
581 state_file.save(&state).unwrap();
582 assert!(!state_file.is_running().unwrap());
583 let _ = std::fs::remove_dir_all(&dir);
584 }
585
586 #[test]
587 fn status_report_save_and_load() {
588 let dir = test_dir();
589 let state_file = StateFile::new(&dir);
590 let report = DaemonStatusReport::from_state(
591 &DaemonState::new(1, "node", 100, dir.clone(), DaemonStatus::Running),
592 10,
593 vec![],
594 BandwidthSnapshot::default(),
595 None,
596 4,
597 );
598 state_file.save_status(&report).unwrap();
599 let loaded = state_file.load_status().unwrap().expect("report should exist");
600 assert_eq!(loaded.pid, report.pid);
601 assert_eq!(loaded.node_id, report.node_id);
602 assert_eq!(loaded.uptime_seconds, report.uptime_seconds);
603 let _ = std::fs::remove_dir_all(&dir);
604 }
605
606 #[test]
607 fn pid_lock_acquire_success() {
608 let dir = test_dir();
609 std::fs::create_dir_all(&dir).unwrap();
610 let lock = PidLock::new(&dir);
611 assert!(lock.try_acquire().unwrap());
612 assert!(lock.lock_path().exists());
613 let pid = lock.owner_pid().unwrap().expect("pid should be written");
614 assert_eq!(pid, std::process::id());
615 lock.release().unwrap();
616 let _ = std::fs::remove_dir_all(&dir);
617 }
618
619 #[cfg(unix)]
620 #[test]
621 fn pid_lock_acquire_conflict() {
622 let dir = test_dir();
623 std::fs::create_dir_all(&dir).unwrap();
624 let first = PidLock::new(&dir);
625 assert!(first.try_acquire().unwrap());
626 let second = PidLock::new(&dir);
627 assert!(!second.try_acquire().unwrap());
628 first.release().unwrap();
629 let _ = std::fs::remove_dir_all(&dir);
630 }
631
632 #[test]
633 fn pid_lock_release_releases_lock() {
634 let dir = test_dir();
635 std::fs::create_dir_all(&dir).unwrap();
636 let lock = PidLock::new(&dir);
637 assert!(lock.try_acquire().unwrap());
638 lock.release().unwrap();
639 let reacquire = PidLock::new(&dir);
640 assert!(reacquire.try_acquire().unwrap());
641 reacquire.release().unwrap();
642 let _ = std::fs::remove_dir_all(&dir);
643 }
644
645 #[test]
646 fn pid_lock_drop_releases() {
647 let dir = test_dir();
648 std::fs::create_dir_all(&dir).unwrap();
649 {
650 let lock = PidLock::new(&dir);
651 assert!(lock.try_acquire().unwrap());
652 }
653 let reacquire = PidLock::new(&dir);
654 assert!(reacquire.try_acquire().unwrap());
655 reacquire.release().unwrap();
656 let _ = std::fs::remove_dir_all(&dir);
657 }
658
659 #[test]
660 fn pid_is_alive_checks_current_and_zero() {
661 assert!(pid_is_alive(std::process::id()));
662 assert!(!pid_is_alive(0));
663 }
664
665 #[test]
666 fn daemon_socket_path_returns_valid_path() {
667 let dir = test_dir();
668 let path = daemon_socket_path(&dir);
669 assert!(path.file_name().unwrap().to_string_lossy().ends_with(".sock"));
670 let _ = std::fs::remove_dir_all(&dir);
671 }
672
673 #[test]
674 fn pid_lock_stale_detection() {
675 let dir = test_dir();
676 std::fs::create_dir_all(&dir).unwrap();
677 let lock = PidLock::new(&dir);
678 assert!(lock.try_acquire().unwrap());
679 let pid = lock.owner_pid().unwrap().expect("pid should be written");
680 assert_eq!(pid, std::process::id());
681 lock.release().unwrap();
682 assert!(!pid_is_alive(99999_u32));
683 let state_file = StateFile::new(&dir);
684 state_file
685 .save(&DaemonState::new(99999, "stale-daemon", 1, &dir, DaemonStatus::Running))
686 .unwrap();
687 assert!(!state_file.is_running().unwrap());
688 let _ = std::fs::remove_dir_all(&dir);
689 }
690
691 #[test]
692 fn pid_lock_state_file_consistency() {
693 let dir = test_dir();
694 std::fs::create_dir_all(&dir).unwrap();
695 let lock = PidLock::new(&dir);
696 assert!(lock.try_acquire().unwrap());
697 let state_file = StateFile::new(&dir);
698 state_file
699 .save(&DaemonState::new(
700 std::process::id(),
701 "consistent",
702 current_timestamp(),
703 &dir,
704 DaemonStatus::Running,
705 ))
706 .unwrap();
707 assert!(state_file.exists());
708 assert!(lock.lock_path().exists());
709 assert_eq!(lock.owner_pid().unwrap(), Some(std::process::id()));
710 lock.release().unwrap();
711 state_file.remove().unwrap();
712 let _ = std::fs::remove_dir_all(&dir);
713 }
714
715 #[cfg(unix)]
716 #[test]
717 fn pid_lock_concurrent_start_race() {
718 let dir = test_dir();
719 std::fs::create_dir_all(&dir).unwrap();
720 let first = PidLock::new(&dir);
721 assert!(first.try_acquire().unwrap());
722 let second = PidLock::new(&dir);
723 assert!(!second.try_acquire().unwrap());
724 first.release().unwrap();
725 assert!(second.try_acquire().unwrap());
726 second.release().unwrap();
727 let _ = std::fs::remove_dir_all(&dir);
728 }
729
730 #[test]
731 fn status_report_serialization_json() {
732 let report = DaemonStatusReport::from_state(
733 &DaemonState::new(42, "ns-id", 100, PathBuf::from("/data"), DaemonStatus::Running),
734 10,
735 vec![FolderStatusReport::new(
736 "folder1",
737 PathBuf::from("/foo"),
738 true,
739 Some(200),
740 5,
741 vec![],
742 )],
743 BandwidthSnapshot::default(),
744 Some(ScheduleStatus {
745 in_active_window: true,
746 next_window_start: Some(300),
747 }),
748 4,
749 );
750 let json = serde_json::to_string_pretty(&report).unwrap();
751 assert!(json.contains("folder1"));
752 assert!(json.contains("uptime_seconds"));
753 let deserialized: DaemonStatusReport = serde_json::from_str(&json).unwrap();
754 assert_eq!(deserialized.pid, report.pid);
755 assert_eq!(deserialized.folders.len(), 1);
756 assert!(deserialized.schedule.unwrap().in_active_window);
757 }
758
759 #[test]
760 fn daemon_status_report_includes_folders() {
761 let report = DaemonStatusReport::from_state(
762 &DaemonState::new(1, "node", 1, PathBuf::from("/tmp"), DaemonStatus::Running),
763 5,
764 vec![
765 FolderStatusReport::new("ns1", PathBuf::from("/a"), true, None, 3, vec![]),
766 FolderStatusReport::new("ns2", PathBuf::from("/b"), false, Some(10), 0, vec!["error1".into()]),
767 ],
768 BandwidthSnapshot::default(),
769 None,
770 2,
771 );
772 assert_eq!(report.folders.len(), 2);
773 assert!(report.folders.iter().any(|f| f.namespace == "ns1"));
774 assert!(
775 report
776 .folders
777 .iter()
778 .any(|f| f.namespace == "ns2" && !f.session_active && f.errors.len() == 1)
779 );
780 }
781}