Skip to main content

syncweb_core/daemon/
daemon.rs

1use std::{
2    collections::HashMap,
3    path::{Path, PathBuf},
4    sync::{Arc, Mutex},
5    time::{Duration, Instant},
6};
7
8use iroh_docs::NamespaceId;
9use tokio::{
10    sync::{broadcast, mpsc},
11    task::JoinHandle,
12};
13
14use crate::{
15    error::{Result, SyncwebError},
16    filter::{FilterAction, FilterEngine, FilterEntry},
17    folder::FolderManager,
18    fs::{FsWatcher, Importer},
19    node::{
20        identity::IdentityManager,
21        iroh_node::{IrohNode, RelayMode},
22    },
23    schedule::ScheduleManager,
24    stats::BandwidthStats,
25    storage::Config as AppConfig,
26    sync::{SubscribeParams, SyncEngine, cancel_session, is_active},
27};
28
29use super::{
30    DaemonHandle, DaemonState, DaemonStatus, FolderEntry, IpcServer, ManagedPool, PidLock, StateFile,
31    current_timestamp, daemon_socket_path,
32    state::{BandwidthSnapshot, DaemonStatusReport, FolderStatusReport, ScheduleStatus},
33    supervisor::{IntentControls, IntentSupervisor},
34};
35
36/// Configuration used to construct and run a daemon.
37#[derive(Clone, Debug)]
38#[non_exhaustive]
39pub struct DaemonConfig {
40    pub data_dir: PathBuf,
41    pub sync_interval: Duration,
42    pub observation_ttl: Duration,
43    pub max_retries: u32,
44    pub backoff_base: Duration,
45    pub backoff_max: Duration,
46    pub rayon_threads: usize,
47    pub log_level: String,
48    pub log_file: Option<PathBuf>,
49    pub watch_debounce: Duration,
50}
51
52impl Default for DaemonConfig {
53    fn default() -> Self {
54        Self {
55            data_dir: PathBuf::from("."),
56            sync_interval: Duration::from_mins(1),
57            observation_ttl: Duration::from_hours(1),
58            max_retries: 3,
59            backoff_base: Duration::from_secs(1),
60            backoff_max: Duration::from_mins(1),
61            rayon_threads: std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get),
62            log_level: "info".to_owned(),
63            log_file: None,
64            watch_debounce: Duration::from_millis(500),
65        }
66    }
67}
68
69impl DaemonConfig {
70    #[must_use]
71    pub fn new(data_dir: impl Into<PathBuf>) -> Self {
72        Self {
73            data_dir: data_dir.into(),
74            ..Self::default()
75        }
76    }
77}
78
79/// Owns the Iroh node, synchronization intents, IPC server, and daemon
80/// lifecycle state.
81pub struct Daemon {
82    config: DaemonConfig,
83    state_file: StateFile,
84    pid_lock: PidLock,
85    ipc_server: IpcServer,
86    intent_supervisor: IntentSupervisor,
87    folder_manager: FolderManager,
88    sync_engine: SyncEngine,
89    schedule_manager: tokio::sync::RwLock<Option<ScheduleManager>>,
90    node: Arc<IrohNode>,
91    handle: DaemonHandle,
92    sync_receiver: tokio::sync::Mutex<mpsc::UnboundedReceiver<Option<String>>>,
93    intent_tasks: Mutex<HashMap<NamespaceId, JoinHandle<()>>>,
94    intent_controls: IntentControls,
95    watchers: Mutex<HashMap<String, FsWatcher>>,
96    pending_watch_events: Mutex<HashMap<String, PendingWatch>>,
97    filter_engine: tokio::sync::RwLock<Option<FilterEngine>>,
98    archive_pool: Arc<ManagedPool>,
99}
100
101struct PendingWatch {
102    paths: HashMap<PathBuf, bool>,
103    ready_at: Instant,
104}
105
106impl Daemon {
107    /// Create a daemon, acquire its process lock, and persist its running
108    /// state.
109    ///
110    /// # Errors
111    ///
112    /// Returns an error if another daemon owns the data directory, the node
113    /// cannot be opened, or configuration cannot be parsed.
114    pub async fn new(config: DaemonConfig) -> Result<Self> {
115        std::fs::create_dir_all(&config.data_dir)?;
116        let state_file = StateFile::new(&config.data_dir);
117        let pid_lock = PidLock::new(&config.data_dir);
118        if !pid_lock.try_acquire()? {
119            return Err(SyncwebError::operation(
120                "daemon already running",
121                config.data_dir.display(),
122            ));
123        }
124
125        let app_config = match AppConfig::load(config.data_dir.join("config.toml")) {
126            Ok(value) => value,
127            Err(error) => {
128                pid_lock.release()?;
129                return Err(error);
130            }
131        };
132        let schedule_manager = match ScheduleManager::from_config(&app_config.schedule) {
133            Ok(value) if app_config.schedule != crate::schedule::ScheduleConfig::default() => Some(value),
134            Ok(_) => None,
135            Err(error) => {
136                pid_lock.release()?;
137                return Err(error);
138            }
139        };
140        let filter_engine = match load_filter(&config.data_dir) {
141            Ok(value) => value,
142            Err(error) => {
143                pid_lock.release()?;
144                return Err(error);
145            }
146        };
147        let initial_state = DaemonState::new(
148            std::process::id(),
149            String::new(),
150            current_timestamp(),
151            &config.data_dir,
152            DaemonStatus::Starting,
153        );
154        if let Err(error) = state_file.save(&initial_state) {
155            pid_lock.release()?;
156            return Err(error);
157        }
158
159        let identity = match IdentityManager::new(config.data_dir.join("identity.key")) {
160            Ok(value) => value,
161            Err(error) => {
162                let _cleanup_state = state_file.remove();
163                let _cleanup_lock = pid_lock.release();
164                return Err(error);
165            }
166        };
167        let node = match IrohNode::new(identity, config.data_dir.join("data"), RelayMode::Default).await {
168            Ok(value) => Arc::new(value),
169            Err(error) => {
170                let _cleanup_state = state_file.remove();
171                let _cleanup_lock = pid_lock.release();
172                return Err(error);
173            }
174        };
175        let folder_manager = FolderManager::new(&node);
176        let sync_engine = SyncEngine::new(
177            folder_manager.clone(),
178            node.blob_store().clone(),
179            node.docs_engine().clone(),
180            node.gossip_service().clone(),
181        );
182        let archive_pool = match ManagedPool::new("syncweb-archive", config.rayon_threads) {
183            Ok(value) => Arc::new(value),
184            Err(error) => {
185                let _cleanup_node = node.stop().await;
186                let _cleanup_state = state_file.remove();
187                let _cleanup_lock = pid_lock.release();
188                return Err(SyncwebError::operation("failed to create daemon thread pool", error));
189            }
190        };
191
192        let initial_handle = DaemonHandle::new(initial_state);
193        {
194            let mut state = initial_handle.state.write().await;
195            state.node_id = node.endpoint().id().to_string();
196            state.status = DaemonStatus::Running;
197            let running_state = state.clone();
198            drop(state);
199            if let Err(error) = state_file.save(&running_state) {
200                let _cleanup_node = node.stop().await;
201                let _cleanup_state = state_file.remove();
202                let _cleanup_lock = pid_lock.release();
203                return Err(error);
204            }
205        }
206        let (sync_sender, sync_receiver) = mpsc::unbounded_channel();
207        let handle = DaemonHandle::with_channels_and_reload(
208            initial_handle.state.clone(),
209            initial_handle.folder_registry.clone(),
210            initial_handle.shutdown_sender.clone(),
211            sync_sender,
212            initial_handle.reload_requested.clone(),
213        );
214        let ipc_server = IpcServer::with_archive_context(
215            daemon_socket_path(&config.data_dir),
216            handle.clone(),
217            node.clone(),
218            archive_pool.clone(),
219        );
220        let intent_supervisor = IntentSupervisor::new(config.max_retries, config.backoff_base, config.backoff_max);
221
222        Ok(Self {
223            config,
224            state_file,
225            pid_lock,
226            ipc_server,
227            intent_supervisor,
228            folder_manager,
229            sync_engine,
230            schedule_manager: tokio::sync::RwLock::new(schedule_manager),
231            node,
232            handle,
233            sync_receiver: tokio::sync::Mutex::new(sync_receiver),
234            intent_tasks: Mutex::new(HashMap::new()),
235            intent_controls: Arc::new(Mutex::new(HashMap::new())),
236            watchers: Mutex::new(HashMap::new()),
237            pending_watch_events: Mutex::new(HashMap::new()),
238            filter_engine: tokio::sync::RwLock::new(filter_engine),
239            archive_pool,
240        })
241    }
242
243    /// Run the daemon until IPC or operating-system shutdown is requested.
244    ///
245    /// # Errors
246    ///
247    /// Returns an error if an IPC listener, sync cycle, or cleanup operation
248    /// fails.
249    pub async fn run(&self) -> Result<()> {
250        let run_result = self.run_inner().await;
251        let cleanup_result = self.shutdown_resources().await;
252        match run_result {
253            Err(error) => {
254                cleanup_result?;
255                Err(error)
256            }
257            Ok(()) => cleanup_result,
258        }
259    }
260
261    /// Return a snapshot of the daemon lifecycle state.
262    pub async fn state(&self) -> DaemonState {
263        self.handle.state.read().await.clone()
264    }
265
266    /// Return the daemon's fixed archive pool.
267    #[must_use]
268    pub fn archive_pool(&self) -> &ManagedPool {
269        self.archive_pool.as_ref()
270    }
271
272    async fn run_inner(&self) -> Result<()> {
273        tracing::debug!(
274            rayon_threads = self.archive_pool.thread_count(),
275            "daemon runtime initialized"
276        );
277        self.load_folders().await?;
278        self.start_watching().await?;
279        let server = self.ipc_server.clone();
280        let mut server_task = tokio::spawn(async move { server.serve().await });
281        let mut shutdown = self.handle.shutdown_sender.subscribe();
282        let shutdown_sender = self.handle.shutdown_sender.clone();
283        let mut signal_task = Box::pin(self.handle_signals(shutdown_sender.clone()));
284        let interval_duration = self.config.sync_interval.max(Duration::from_millis(1));
285        let mut interval = tokio::time::interval(interval_duration);
286        let mut watch_interval = tokio::time::interval(Duration::from_millis(100));
287        if let Err(error) = self.run_cycle().await {
288            tracing::error!(%error, "initial daemon cycle failed");
289        }
290        let result = self
291            .run_event_loop(
292                &mut server_task,
293                &mut signal_task,
294                &mut shutdown,
295                &shutdown_sender,
296                &mut interval,
297                &mut watch_interval,
298            )
299            .await;
300        send_shutdown(&shutdown_sender);
301        if !server_task.is_finished() {
302            match server_task.await {
303                Ok(server_result) => server_result?,
304                Err(error) => return Err(SyncwebError::operation("daemon IPC task failed", error)),
305            }
306        }
307        result
308    }
309
310    async fn run_event_loop(
311        &self,
312        server_task: &mut JoinHandle<Result<()>>,
313        signal_task: &mut std::pin::Pin<std::boxed::Box<impl std::future::Future<Output = Result<()>> + Send>>,
314        shutdown: &mut broadcast::Receiver<()>,
315        shutdown_sender: &broadcast::Sender<()>,
316        interval: &mut tokio::time::Interval,
317        watch_interval: &mut tokio::time::Interval,
318    ) -> Result<()> {
319        loop {
320            tokio::select! {
321                signal_result = &mut *signal_task => {
322                    signal_result?;
323                    send_shutdown(shutdown_sender);
324                    break Ok(());
325                }
326                shutdown_result = shutdown.recv() => {
327                    if matches!(shutdown_result, Ok(()) | Err(broadcast::error::RecvError::Closed)) {
328                        break Ok(());
329                    }
330                }
331                server_result = &mut *server_task => {
332                    match server_result {
333                        Ok(result) => break result,
334                        Err(error) => break Err(SyncwebError::operation("daemon IPC task failed", error)),
335                    }
336                }
337                trigger = self.receive_sync_trigger() => {
338                    match trigger {
339                        Some(namespace) => self.run_trigger(namespace).await?,
340                        None => break Ok(()),
341                    }
342                }
343                _ = watch_interval.tick() => self.handle_watch_events().await?,
344                _ = interval.tick() => self.run_cycle().await?,
345            }
346        }
347    }
348
349    async fn run_cycle(&self) -> Result<()> {
350        self.reload_if_requested().await?;
351        self.load_folders().await?;
352        self.start_watching().await?;
353        let statuses = self.handle.folder_registry.read().await.statuses();
354        for folder in statuses {
355            let namespace = folder
356                .namespace
357                .parse::<NamespaceId>()
358                .map_err(|error| SyncwebError::operation("invalid managed folder namespace", error))?;
359            let folder_name = (!folder.path.as_os_str().is_empty()).then(|| folder.path.to_string_lossy().into_owned());
360            let active = {
361                let schedule_manager = self.schedule_manager.read().await;
362                schedule_manager
363                    .as_ref()
364                    .is_none_or(|manager| manager.is_active(folder_name.as_deref()))
365            };
366            self.start_supervision(namespace).await?;
367            if active {
368                self.set_intent_active(namespace, true)?;
369            } else {
370                self.set_intent_active(namespace, false)?;
371            }
372        }
373        self.save_status_report().await?;
374        Ok(())
375    }
376
377    async fn run_trigger(&self, namespace: Option<String>) -> Result<()> {
378        match namespace {
379            Some(value) => {
380                let parsed_namespace = value
381                    .parse::<NamespaceId>()
382                    .map_err(|error| SyncwebError::operation("invalid sync namespace", error))?;
383                self.start_supervision(parsed_namespace).await?;
384                self.set_intent_active(parsed_namespace, true)?;
385                self.save_status_report().await?;
386                Ok(())
387            }
388            None => self.run_cycle().await,
389        }
390    }
391
392    async fn start_supervision(&self, namespace: NamespaceId) -> Result<()> {
393        if is_active(namespace) {
394            return Ok(());
395        }
396        {
397            let mut tasks = self
398                .intent_tasks
399                .lock()
400                .map_err(|error| SyncwebError::operation("daemon intent task mutex is poisoned", error))?;
401            tasks.retain(|_, task| !task.is_finished());
402            if tasks.contains_key(&namespace) {
403                return Ok(());
404            }
405        }
406        let sync = self.sync_engine.clone();
407        let supervisor = self.intent_supervisor;
408        let shutdown = self.handle.shutdown_sender.subscribe();
409        let controls = self.intent_controls.clone();
410        let filter = self.filter_engine.read().await.clone();
411        let folder_name = self
412            .handle
413            .folder_registry
414            .read()
415            .await
416            .statuses()
417            .into_iter()
418            .find(|status| status.namespace == namespace.to_string())
419            .and_then(|status| {
420                (!status.path.as_os_str().is_empty()).then(|| status.path.to_string_lossy().into_owned())
421            });
422        let bandwidth = self
423            .schedule_manager
424            .read()
425            .await
426            .as_ref()
427            .map(|manager| manager.current_limits(folder_name.as_deref()));
428        let params = bandwidth.map_or_else(SubscribeParams::default, |limits| {
429            SubscribeParams::default().with_bandwidth_limits(limits)
430        });
431        let task = tokio::spawn(async move {
432            match supervisor
433                .supervise_with_controls(&sync, namespace, params, shutdown, controls, filter)
434                .await
435            {
436                Ok(result) => {
437                    if let Some(error) = result.last_error {
438                        tracing::warn!(%namespace, retry_count = result.retry_count, %error, "supervised intent stopped");
439                    }
440                }
441                Err(error) => tracing::error!(%namespace, %error, "supervised intent failed"),
442            }
443        });
444        self.intent_tasks
445            .lock()
446            .map_err(|error| SyncwebError::operation("daemon intent task mutex is poisoned", error))?
447            .insert(namespace, task);
448        Ok(())
449    }
450
451    fn set_intent_active(&self, namespace: NamespaceId, active: bool) -> Result<()> {
452        let control = self
453            .intent_controls
454            .lock()
455            .map_err(|error| SyncwebError::operation("daemon intent control mutex is poisoned", error))?
456            .get(&namespace)
457            .cloned();
458        let Some(intent_control) = control else {
459            return Ok(());
460        };
461        let result = if active {
462            intent_control.resume()
463        } else {
464            intent_control.pause()
465        };
466        result.map_err(|error| SyncwebError::operation("failed to update scheduled intent", error))
467    }
468
469    /// Return whether the global schedule is currently active.
470    pub async fn is_in_active_window(&self) -> bool {
471        self.schedule_manager
472            .read()
473            .await
474            .as_ref()
475            .is_none_or(|manager| manager.is_active(None))
476    }
477
478    /// Return the configured download limit at the current wall-clock time.
479    pub async fn current_bandwidth_limit(&self) -> Option<u64> {
480        self.schedule_manager.read().await.as_ref().and_then(|manager| {
481            let limits = manager.current_limits(None);
482            limits.max_download.or(limits.max_upload)
483        })
484    }
485
486    async fn start_watching(&self) -> Result<()> {
487        let statuses = self.handle.folder_registry.read().await.statuses();
488        let wanted: HashMap<_, _> = statuses
489            .iter()
490            .filter(|status| !status.path.as_os_str().is_empty())
491            .map(|status| (status.namespace.clone(), status.path.clone()))
492            .collect();
493        let mut watchers = self
494            .watchers
495            .lock()
496            .map_err(|error| SyncwebError::operation("daemon watcher mutex is poisoned", error))?;
497        watchers.retain(|namespace, _| wanted.contains_key(namespace));
498        for (namespace, path) in wanted {
499            if watchers.contains_key(&namespace) {
500                continue;
501            }
502            if !path.exists() {
503                tracing::warn!(%namespace, path = %path.display(), "managed folder path does not exist; watcher deferred");
504                continue;
505            }
506            watchers.insert(namespace, FsWatcher::new(&path)?);
507        }
508        drop(watchers);
509        Ok(())
510    }
511
512    async fn handle_watch_events(&self) -> Result<()> {
513        let mut observed = Vec::new();
514        {
515            let mut watchers = self
516                .watchers
517                .lock()
518                .map_err(|error| SyncwebError::operation("daemon watcher mutex is poisoned", error))?;
519            for (namespace, watcher) in watchers.iter_mut() {
520                loop {
521                    match watcher.try_recv() {
522                        Ok(Some(event)) => observed.push((namespace.clone(), event)),
523                        Ok(None) => break,
524                        Err(error) => {
525                            tracing::warn!(%namespace, %error, "filesystem watcher event channel failed");
526                            break;
527                        }
528                    }
529                }
530            }
531        }
532
533        if !observed.is_empty() {
534            let mut pending = self
535                .pending_watch_events
536                .lock()
537                .map_err(|error| SyncwebError::operation("daemon watch queue mutex is poisoned", error))?;
538            let ready_at = Instant::now()
539                .checked_add(self.config.watch_debounce)
540                .unwrap_or_else(Instant::now);
541            for (namespace, event) in observed {
542                let removed = matches!(event.event.kind, notify::EventKind::Remove(_));
543                let entry = pending.entry(namespace).or_insert_with(|| PendingWatch {
544                    paths: HashMap::new(),
545                    ready_at,
546                });
547                entry.ready_at = ready_at;
548                for path in event.paths {
549                    entry.paths.insert(path, removed);
550                }
551            }
552            drop(pending);
553        }
554
555        let ready = {
556            let mut pending = self
557                .pending_watch_events
558                .lock()
559                .map_err(|error| SyncwebError::operation("daemon watch queue mutex is poisoned", error))?;
560            let now = Instant::now();
561            pending
562                .iter_mut()
563                .filter_map(|(namespace, batch)| {
564                    (batch.ready_at <= now).then(|| (namespace.clone(), std::mem::take(&mut batch.paths)))
565                })
566                .filter(|(_, paths)| !paths.is_empty())
567                .collect::<Vec<_>>()
568        };
569        if ready.is_empty() {
570            return Ok(());
571        }
572
573        let roots: HashMap<_, _> = self
574            .handle
575            .folder_registry
576            .read()
577            .await
578            .statuses()
579            .into_iter()
580            .map(|status| (status.namespace, status.path))
581            .collect();
582        for (namespace, paths) in ready {
583            let Some(root) = roots.get(&namespace) else {
584                continue;
585            };
586            let namespace_id = namespace
587                .parse::<NamespaceId>()
588                .map_err(|error| SyncwebError::operation("invalid watched folder namespace", error))?;
589            for (path, removed) in paths {
590                self.process_watch_event(namespace_id, root, &path, removed).await?;
591            }
592        }
593        self.save_status_report().await?;
594        Ok(())
595    }
596
597    async fn process_watch_event(&self, namespace: NamespaceId, root: &Path, path: &Path, removed: bool) -> Result<()> {
598        let relative = path.strip_prefix(root).unwrap_or(path);
599        if relative.as_os_str().is_empty() {
600            return Ok(());
601        }
602        let size = std::fs::metadata(path).map_or(0, |metadata| metadata.len());
603        let accepted = self.filter_engine.read().await.as_ref().is_none_or(|filter| {
604            filter.evaluate_for_folder(&namespace.to_string(), &FilterEntry::new(relative.to_path_buf(), size))
605                != FilterAction::Reject
606        });
607        if !accepted {
608            return Ok(());
609        }
610
611        let folder = self.folder_manager.get(namespace).await?;
612        let result = if removed || !path.exists() {
613            folder
614                .delete_entry(relative.as_os_str().as_encoded_bytes())
615                .await
616                .map(|()| 1_u64)
617        } else if path.is_file() {
618            let importer = Importer::new(
619                self.node.blob_store().clone(),
620                self.node.docs_engine().clone(),
621                folder.doc().clone(),
622                folder.author(),
623            )
624            .with_root(root);
625            importer
626                .import_path(path)
627                .await
628                .map(|entries| u64::try_from(entries.len()).unwrap_or(u64::MAX))
629        } else {
630            Ok(0)
631        };
632        match result {
633            Ok(entries) if entries > 0 => {
634                self.handle
635                    .folder_registry
636                    .write()
637                    .await
638                    .record_import(namespace, entries, current_timestamp());
639                self.start_supervision(namespace).await?;
640            }
641            Ok(_) => {}
642            Err(error) => {
643                self.handle
644                    .folder_registry
645                    .write()
646                    .await
647                    .record_error(namespace, error.to_string());
648                tracing::warn!(%namespace, path = %path.display(), %error, "filesystem change will be retried");
649                if !removed && is_recoverable_watch_error(&error) {
650                    let mut pending = self.pending_watch_events.lock().map_err(|poisoned| {
651                        SyncwebError::operation("daemon watch queue mutex is poisoned", poisoned)
652                    })?;
653                    let ready_at = Instant::now()
654                        .checked_add(self.config.watch_debounce)
655                        .unwrap_or_else(Instant::now);
656                    let entry = pending.entry(namespace.to_string()).or_insert_with(|| PendingWatch {
657                        paths: HashMap::new(),
658                        ready_at,
659                    });
660                    entry.ready_at = ready_at;
661                    entry.paths.insert(path.to_path_buf(), false);
662                    drop(pending);
663                }
664            }
665        }
666        Ok(())
667    }
668
669    async fn save_status_report(&self) -> Result<()> {
670        let state = self.handle.state.read().await.clone();
671        let statuses = self.handle.folder_registry.read().await.statuses();
672        let schedule = self.schedule_manager.read().await.clone();
673        let schedule_report = schedule.as_ref().map(|manager| {
674            let minute = current_minute();
675            let next = manager.next_active_window_start_at(None, minute);
676            let next_window_start = next.map(|next_minute| {
677                let offset = if next_minute >= minute {
678                    next_minute.saturating_sub(minute)
679                } else {
680                    1_440_u16.saturating_sub(minute).saturating_add(next_minute)
681                };
682                current_timestamp().saturating_add(u64::from(offset).saturating_mul(60))
683            });
684            ScheduleStatus {
685                in_active_window: manager.is_active(None),
686                next_window_start,
687            }
688        });
689        let bandwidth_stats = BandwidthStats::load(self.config.data_dir.join("stats.json"))?;
690        let report = DaemonStatusReport {
691            pid: state.pid,
692            node_id: state.node_id,
693            started_at: state.started_at,
694            uptime_seconds: current_timestamp().saturating_sub(state.started_at),
695            folders: statuses
696                .into_iter()
697                .map(|folder| FolderStatusReport {
698                    namespace: folder.namespace,
699                    path: folder.path,
700                    session_active: folder.session_active,
701                    last_sync_at: folder.last_sync_at,
702                    entries_synced: folder.entries_synced,
703                    errors: folder.errors,
704                })
705                .collect(),
706            bandwidth: BandwidthSnapshot {
707                upload_total: bandwidth_stats.total_upload,
708                download_total: bandwidth_stats.total_download,
709                upload_rate: 0,
710                download_rate: 0,
711            },
712            schedule: schedule_report,
713            rayon_threads: self.archive_pool.thread_count(),
714        };
715        self.state_file.save_status(&report)
716    }
717
718    async fn load_folders(&self) -> Result<()> {
719        let folders = self.folder_manager.list().await?;
720        let mut registry = self.handle.folder_registry.write().await;
721        for folder in folders {
722            let namespace = folder.namespace_id();
723            if !registry
724                .statuses()
725                .iter()
726                .any(|status| status.namespace == namespace.to_string())
727            {
728                registry.add(FolderEntry::new(namespace, PathBuf::new()))?;
729            }
730        }
731        drop(registry);
732        Ok(())
733    }
734
735    async fn reload_if_requested(&self) -> Result<()> {
736        if !self
737            .handle
738            .reload_requested
739            .swap(false, std::sync::atomic::Ordering::AcqRel)
740        {
741            return Ok(());
742        }
743        let app_config = AppConfig::load(self.config.data_dir.join("config.toml"))?;
744        let parsed_schedule = ScheduleManager::from_config(&app_config.schedule)?;
745        let schedule_manager =
746            (app_config.schedule != crate::schedule::ScheduleConfig::default()).then_some(parsed_schedule);
747        let filter = load_filter(&self.config.data_dir)?;
748        *self.schedule_manager.write().await = schedule_manager;
749        *self.filter_engine.write().await = filter;
750        let statuses = self.handle.folder_registry.read().await.statuses();
751        for status in statuses {
752            if let Ok(namespace) = status.namespace.parse::<NamespaceId>()
753                && is_active(namespace)
754                && !cancel_session(namespace)
755            {
756                tracing::warn!(%namespace, "reloaded intent did not accept cancellation");
757            }
758        }
759        tracing::info!("daemon configuration reloaded");
760        Ok(())
761    }
762
763    async fn receive_sync_trigger(&self) -> Option<Option<String>> {
764        self.sync_receiver.lock().await.recv().await
765    }
766
767    async fn shutdown_resources(&self) -> Result<()> {
768        self.handle.set_status(DaemonStatus::Stopping).await;
769        let stopping_state = self.handle.state.read().await.clone();
770        self.state_file.save(&stopping_state)?;
771        self.save_status_report().await?;
772
773        let namespaces: Vec<_> = self
774            .handle
775            .folder_registry
776            .read()
777            .await
778            .statuses()
779            .into_iter()
780            .filter_map(|status| status.namespace.parse::<NamespaceId>().ok())
781            .collect();
782        for namespace in namespaces {
783            if is_active(namespace) && !cancel_session(namespace) {
784                tracing::warn!(%namespace, "intent did not accept shutdown cancellation");
785            }
786        }
787
788        let tasks = {
789            let mut task_map = self
790                .intent_tasks
791                .lock()
792                .map_err(|error| SyncwebError::operation("daemon intent task mutex is poisoned", error))?;
793            std::mem::take(&mut *task_map)
794        };
795        for (_, task) in tasks {
796            task.await
797                .map_err(|error| SyncwebError::operation("daemon intent task failed", error))?;
798        }
799
800        let node_result = self.node.stop().await;
801        self.handle.set_status(DaemonStatus::Stopped).await;
802        let remove_result = self.state_file.remove();
803        let remove_status_result = self.state_file.remove_status();
804        let release_result = self.pid_lock.release();
805        node_result?;
806        remove_result?;
807        remove_status_result?;
808        release_result?;
809        Ok(())
810    }
811
812    async fn handle_signals(&self, shutdown: broadcast::Sender<()>) -> Result<()> {
813        #[cfg(unix)]
814        {
815            let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
816            tokio::select! {
817                result = tokio::signal::ctrl_c() => result?,
818                _ = terminate.recv() => {}
819            }
820        }
821        #[cfg(not(unix))]
822        {
823            tokio::signal::ctrl_c().await?;
824        }
825        send_shutdown(&shutdown);
826        Ok(())
827    }
828}
829
830impl Drop for Daemon {
831    fn drop(&mut self) {
832        if self.state_file.exists()
833            && let Err(error) = self.state_file.remove()
834        {
835            tracing::warn!(path = %self.state_file.path().display(), %error, "failed to remove daemon state");
836        }
837        if let Err(error) = self.state_file.remove_status() {
838            tracing::warn!(path = %self.state_file.status_path().display(), %error, "failed to remove daemon status");
839        }
840    }
841}
842
843fn send_shutdown(sender: &broadcast::Sender<()>) {
844    match sender.send(()) {
845        Ok(_) | Err(broadcast::error::SendError(())) => {}
846    }
847}
848
849fn load_filter(data_dir: &Path) -> Result<Option<FilterEngine>> {
850    let path = data_dir.join("filters.toml");
851    if path.exists() {
852        FilterEngine::load(path).map(Some)
853    } else {
854        Ok(None)
855    }
856}
857
858fn current_minute() -> u16 {
859    let seconds = std::time::SystemTime::now()
860        .duration_since(std::time::UNIX_EPOCH)
861        .map_or(0, |duration| duration.as_secs());
862    u16::try_from((seconds % 86_400).div_euclid(60)).unwrap_or(0)
863}
864
865fn is_recoverable_watch_error(error: &SyncwebError) -> bool {
866    let message = error.to_string();
867    message.contains("file changed during import") || message.contains("input path does not exist")
868}