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::storage::node_db::NodeDatabase;
14
15const LOCK_FILE_NAME: &str = "daemon.lock";
16const SOCKET_FILE_NAME: &str = "daemon.sock";
17
18/// The lifecycle state persisted by a running daemon.
19#[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/// Identifying and lifecycle information for a daemon instance.
30#[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/// A persisted snapshot of daemon activity and synchronization progress.
41#[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/// Activity and error information for one managed folder.
78#[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    /// Sync mode (`sendreceive`/`receiveonly`/`sendonly`), empty when unknown.
86    #[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/// Aggregate transfer counters included in a daemon status report.
125#[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/// The current schedule state included in a daemon status report.
135#[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/// SQLite-backed persistence for daemon state and status.
162#[derive(Clone, Debug)]
163pub struct StateFile {
164    data_dir: PathBuf,
165}
166
167impl StateFile {
168    /// Store the data directory path. The database is not opened yet —
169    /// call [`db()`](Self::db) to open it lazily.
170    #[must_use]
171    pub fn new(data_dir: &Path) -> Self {
172        Self {
173            data_dir: data_dir.to_path_buf(),
174        }
175    }
176
177    /// Open (or create) the `SQLite` node database in `data_dir`.
178    ///
179    /// # Errors
180    ///
181    /// Returns an error if the database cannot be opened or initialized.
182    fn db(&self) -> Result<NodeDatabase> {
183        NodeDatabase::open(self.data_dir.join("node.db"))
184    }
185
186    /// Persist the daemon lifecycle state.
187    ///
188    /// # Errors
189    ///
190    /// Returns an error when the database cannot be written.
191    pub fn save(&self, state: &DaemonState) -> Result<()> {
192        let db = self.db()?;
193        db.save_lifecycle(state)
194    }
195
196    /// Load the daemon lifecycle state, returning `None` if absent.
197    ///
198    /// # Errors
199    ///
200    /// Returns an error when the database cannot be read.
201    pub fn load(&self) -> Result<Option<DaemonState>> {
202        let db = self.db()?;
203        db.load_lifecycle()
204    }
205
206    /// Persist the current daemon activity report.
207    ///
208    /// # Errors
209    ///
210    /// Returns an error when the database cannot be written.
211    pub fn save_status(&self, report: &DaemonStatusReport) -> Result<()> {
212        let db = self.db()?;
213        db.save_status(report)
214    }
215
216    /// Load the last daemon activity report.
217    ///
218    /// # Errors
219    ///
220    /// Returns an error when the database cannot be read.
221    pub fn load_status(&self) -> Result<Option<DaemonStatusReport>> {
222        let db = self.db()?;
223        db.load_status()
224    }
225
226    /// Check whether a lifecycle row exists (equivalent to the old file check).
227    #[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    /// Remove the daemon lifecycle row.
237    ///
238    /// # Errors
239    ///
240    /// Returns an error when the database operation fails.
241    pub fn remove(&self) -> Result<()> {
242        let db = self.db()?;
243        db.remove_lifecycle()
244    }
245
246    /// Remove the daemon status rows.
247    ///
248    /// # Errors
249    ///
250    /// Returns an error when the database operation fails.
251    pub fn remove_status(&self) -> Result<()> {
252        let db = self.db()?;
253        db.remove_status()
254    }
255
256    /// Return whether the stored lifecycle indicates a still-running daemon.
257    ///
258    /// # Errors
259    ///
260    /// Returns an error when the database cannot be read.
261    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/// Exclusive process lock preventing multiple daemon instances on the same
269/// data directory.
270#[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    /// Try to acquire and retain the lock for this process.
286    ///
287    /// The PID is written only after the OS-level lock succeeds. A `false`
288    /// result means another process currently owns the lock.
289    ///
290    /// # Errors
291    ///
292    /// Returns an error when the lock file cannot be opened or updated.
293    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    /// Release the lock held by this instance.
325    ///
326    /// # Errors
327    ///
328    /// Returns an error when the OS refuses to release the lock.
329    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    /// Read the last PID written to the lock file.
342    ///
343    /// # Errors
344    ///
345    /// Returns an error when the lock file cannot be read or its contents are
346    /// not a decimal process ID.
347    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/// Return the socket path used for a data directory.
397///
398/// Runtime sockets belong in `XDG_RUNTIME_DIR`; the data directory remains
399/// the fallback for environments without an XDG runtime directory. On macOS
400/// the Unix socket path limit (103 chars) can be exceeded with deeply nested
401/// temp directories; in that case a hashed name in the system temp dir is
402/// used instead.
403#[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/// Check whether a process ID is currently present on the local system.
423#[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        // SAFETY: signal 0 is a no-op that only checks if the process exists.
442        // It is safe even if the PID has been reused because we only get
443        // a boolean result (ESRCH means no such process).
444        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}