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