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