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