Skip to main content

syncweb_core/daemon/
state.rs

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
19/// Load the daemon's filter engine from the canonical `filters.toml` file when
20/// present, falling back to rules persisted in the node database.
21///
22/// Keeping this shared lets the daemon watcher, `watch --once`, and the
23/// CLI-filtered commands evaluate the same rules.
24///
25/// # Errors
26///
27/// Returns an error if the filter file or database cannot be read or the rules
28/// cannot be compiled.
29pub 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/// The lifecycle state persisted by a running daemon.
38#[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/// Identifying and lifecycle information for a daemon instance.
49#[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/// A persisted snapshot of daemon activity and synchronization progress.
60#[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/// Activity and error information for one managed folder.
97#[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    /// Sync mode (`sendreceive`/`receiveonly`/`sendonly`), empty when unknown.
105    #[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/// Aggregate transfer counters included in a daemon status report.
144#[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/// The current schedule state included in a daemon status report.
154#[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/// SQLite-backed persistence for daemon state and status.
181#[derive(Clone, Debug)]
182pub struct StateFile {
183    data_dir: PathBuf,
184}
185
186impl StateFile {
187    /// Store the data directory path. The database is not opened yet —
188    /// call [`db()`](Self::db) to open it lazily.
189    #[must_use]
190    pub fn new(data_dir: &Path) -> Self {
191        Self {
192            data_dir: data_dir.to_path_buf(),
193        }
194    }
195
196    /// Open (or create) the `SQLite` node database in `data_dir`.
197    ///
198    /// # Errors
199    ///
200    /// Returns an error if the database cannot be opened or initialized.
201    fn db(&self) -> Result<NodeDatabase> {
202        NodeDatabase::open(self.data_dir.join("node.db"))
203    }
204
205    /// Persist the daemon lifecycle state.
206    ///
207    /// # Errors
208    ///
209    /// Returns an error when the database cannot be written.
210    pub fn save(&self, state: &DaemonState) -> Result<()> {
211        let db = self.db()?;
212        db.save_lifecycle(state)
213    }
214
215    /// Load the daemon lifecycle state, returning `None` if absent.
216    ///
217    /// # Errors
218    ///
219    /// Returns an error when the database cannot be read.
220    pub fn load(&self) -> Result<Option<DaemonState>> {
221        let db = self.db()?;
222        db.load_lifecycle()
223    }
224
225    /// Persist the current daemon activity report.
226    ///
227    /// # Errors
228    ///
229    /// Returns an error when the database cannot be written.
230    pub fn save_status(&self, report: &DaemonStatusReport) -> Result<()> {
231        let db = self.db()?;
232        db.save_status(report)
233    }
234
235    /// Load the last daemon activity report.
236    ///
237    /// # Errors
238    ///
239    /// Returns an error when the database cannot be read.
240    pub fn load_status(&self) -> Result<Option<DaemonStatusReport>> {
241        let db = self.db()?;
242        db.load_status()
243    }
244
245    /// Check whether a lifecycle row exists (equivalent to the old file check).
246    #[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    /// Remove the daemon lifecycle row.
256    ///
257    /// # Errors
258    ///
259    /// Returns an error when the database operation fails.
260    pub fn remove(&self) -> Result<()> {
261        let db = self.db()?;
262        db.remove_lifecycle()
263    }
264
265    /// Remove the daemon status rows.
266    ///
267    /// # Errors
268    ///
269    /// Returns an error when the database operation fails.
270    pub fn remove_status(&self) -> Result<()> {
271        let db = self.db()?;
272        db.remove_status()
273    }
274
275    /// Return whether the stored lifecycle indicates a still-running daemon.
276    ///
277    /// # Errors
278    ///
279    /// Returns an error when the database cannot be read.
280    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/// Exclusive process lock preventing multiple daemon instances on the same
288/// data directory.
289#[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    /// Try to acquire and retain the lock for this process.
305    ///
306    /// The PID is written only after the OS-level lock succeeds. A `false`
307    /// result means another process currently owns the lock.
308    ///
309    /// # Errors
310    ///
311    /// Returns an error when the lock file cannot be opened or updated.
312    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    /// Release the lock held by this instance.
344    ///
345    /// # Errors
346    ///
347    /// Returns an error when the OS refuses to release the lock.
348    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    /// Read the last PID written to the lock file.
361    ///
362    /// # Errors
363    ///
364    /// Returns an error when the lock file cannot be read or its contents are
365    /// not a decimal process ID.
366    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/// Return the socket path used for a data directory.
416///
417/// Runtime sockets belong in `XDG_RUNTIME_DIR`; the data directory remains
418/// the fallback for environments without an XDG runtime directory. On macOS
419/// the Unix socket path limit (103 chars) can be exceeded with deeply nested
420/// temp directories; in that case a hashed name in the system temp dir is
421/// used instead.
422#[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/// Check whether a process ID is currently present on the local system.
442#[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        // SAFETY: signal 0 is a no-op that only checks if the process exists.
461        // It is safe even if the PID has been reused because we only get
462        // a boolean result (ESRCH means no such process).
463        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}