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