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#[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
79pub 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 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 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 pub async fn state(&self) -> DaemonState {
263 self.handle.state.read().await.clone()
264 }
265
266 #[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 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 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}