Skip to main content

syncweb_core/daemon/
daemon.rs

1use std::{
2    collections::{HashMap, HashSet},
3    net::SocketAddr,
4    path::{Path, PathBuf},
5    sync::{Arc, Mutex, RwLock},
6    time::{Duration, Instant},
7};
8
9use iroh_docs::NamespaceId;
10use n0_future::StreamExt;
11use tokio::{
12    sync::{broadcast, mpsc, oneshot},
13    task::JoinHandle,
14};
15
16use crate::{
17    constants::NETWORK_MEMBERS_KEY,
18    error::{Result, SyncwebError},
19    filter::{FilterAction, FilterEngine, FilterEntry},
20    folder::{FolderManager, PublicSubscription},
21    fs::{FsWatcher, Importer},
22    indexing::IndexingService,
23    net::{NetworkLogger, NetworkManager, RelayManager, RelayManagerOptions},
24    node::{identity::IdentityManager, iroh_node::IrohNode},
25    schedule::ScheduleManager,
26    storage::{config::SubscribeFilters, node_db::NodeDatabase, stats_db::StatsDatabase},
27    sync::{SubscribeParams, SyncEngine, cancel_session, is_active},
28    transfer_limits::{Direction, PendingTransfer, TransferBudget, TransferLimits, TransferQueue},
29};
30
31use crate::node::iroh_node::discovery_scope;
32
33use super::{
34    DaemonHandle, DaemonState, DaemonStatus, FolderEntry, IpcServer, ManagedPool, PidLock, WatchConfig,
35    current_timestamp, daemon_socket_path,
36    state::{BandwidthSnapshot, DaemonStatusReport, ScheduleStatus, load_filter_engine},
37    supervisor::{BandwidthAccountant, DEFAULT_RETRY_WINDOW, IntentControls, IntentSupervisor, SupervisionOptions},
38};
39
40/// Configuration used to construct and run a daemon.
41#[derive(Clone, Debug)]
42#[non_exhaustive]
43pub struct DaemonConfig {
44    pub data_dir: PathBuf,
45    pub network: Option<String>,
46    pub sync_interval: Duration,
47    pub observation_ttl: Duration,
48    /// Window after the first intent attempt during which failures are retried.
49    pub retry_window: Duration,
50    pub backoff_base: Duration,
51    pub backoff_max: Duration,
52    pub rayon_threads: usize,
53    pub log_level: String,
54    pub log_file: Option<PathBuf>,
55    pub watch_debounce: Duration,
56    pub relay_mode: crate::node::iroh_node::RelayMode,
57    pub media_listen: Option<SocketAddr>,
58    pub discovery: crate::node::iroh_node::DiscoveryConfig,
59    /// Shared address lookup for tests that need multiple daemons to discover each other.
60    pub address_lookup: Option<iroh::address_lookup::memory::MemoryLookup>,
61    /// Syncthing (BEP) relay configuration. When `enabled`, the daemon starts
62    /// a relay manager and installs its custom transport on the endpoint.
63    pub bep: crate::storage::config::BepConfig,
64}
65
66impl Default for DaemonConfig {
67    fn default() -> Self {
68        Self {
69            data_dir: PathBuf::from("."),
70            network: None,
71            sync_interval: Duration::from_mins(1),
72            observation_ttl: Duration::from_hours(1),
73            retry_window: DEFAULT_RETRY_WINDOW,
74            backoff_base: Duration::from_secs(1),
75            backoff_max: Duration::from_mins(1),
76            rayon_threads: std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get),
77            log_level: "info".to_owned(),
78            log_file: None,
79            watch_debounce: Duration::from_millis(500),
80            relay_mode: crate::node::iroh_node::RelayMode::Default,
81            media_listen: None,
82            discovery: crate::node::iroh_node::DiscoveryConfig::default(),
83            address_lookup: None,
84            bep: crate::storage::config::BepConfig::default(),
85        }
86    }
87}
88
89impl DaemonConfig {
90    #[must_use]
91    pub fn new(data_dir: impl Into<PathBuf>) -> Self {
92        Self {
93            data_dir: data_dir.into(),
94            ..Self::default()
95        }
96    }
97}
98
99/// Owns the Iroh node, synchronization intents, IPC server, and daemon
100/// lifecycle state.
101pub struct Daemon {
102    config: DaemonConfig,
103    node_db: NodeDatabase,
104    stats_db: StatsDatabase,
105    pid_lock: PidLock,
106    ipc_server: IpcServer,
107    intent_supervisor: IntentSupervisor,
108    folder_manager: FolderManager,
109    sync_engine: SyncEngine,
110    schedule_manager: tokio::sync::RwLock<Option<ScheduleManager>>,
111    transfer_budget: Arc<TransferBudget>,
112    transfer_queue: Arc<TransferQueue>,
113    /// Folders whose downloads were deferred to satisfy a download quota, so
114    /// the default download policy can be restored when limits are removed.
115    deferred_downloads: Mutex<HashSet<NamespaceId>>,
116    node: Arc<IrohNode>,
117    handle: DaemonHandle,
118    sync_receiver: tokio::sync::Mutex<mpsc::UnboundedReceiver<Option<String>>>,
119    intent_tasks: Mutex<HashMap<NamespaceId, Option<JoinHandle<()>>>>,
120    intent_controls: IntentControls,
121    watchers: Mutex<HashMap<String, FsWatcher>>,
122    pending_watch_events: Mutex<HashMap<String, PendingWatch>>,
123    filter_engine: tokio::sync::RwLock<Option<FilterEngine>>,
124    archive_pool: Arc<ManagedPool>,
125    network_logger: NetworkLogger,
126    network_manager: tokio::sync::RwLock<NetworkManager>,
127    maintenance_task: tokio::sync::Mutex<Option<JoinHandle<()>>>,
128    indexing: Option<IndexingService>,
129}
130
131struct PendingWatch {
132    paths: HashMap<PathBuf, bool>,
133    ready_at: Instant,
134}
135
136/// The filter and ignore-pattern set applied to one folder's watch events.
137#[derive(Clone, Copy)]
138struct WatchEvaluation<'a> {
139    filter: Option<&'a FilterEngine>,
140    exclude: &'a [String],
141}
142
143/// The daemon's spawned listener tasks: Unix-socket IPC, WebSocket bridge, and
144/// the optional media HTTP server.
145type ServerTasks = (
146    JoinHandle<Result<()>>,
147    Option<JoinHandle<Result<()>>>,
148    Option<JoinHandle<Result<()>>>,
149);
150
151type SignalTask<'a> = std::pin::Pin<std::boxed::Box<dyn std::future::Future<Output = Result<()>> + Send + 'a>>;
152
153impl Daemon {
154    /// Return a clone of the daemon's iroh endpoint, useful for awaiting
155    /// relay connectivity in tests.
156    #[must_use]
157    pub fn endpoint(&self) -> iroh::Endpoint {
158        self.node.endpoint().clone()
159    }
160
161    #[expect(
162        clippy::too_many_arguments,
163        reason = "daemon bootstrap passes each subsystem its config"
164    )]
165    async fn open_identity_and_node(
166        data_dir: &Path,
167        node_db: &NodeDatabase,
168        pid_lock: &PidLock,
169        relay_mode: crate::node::iroh_node::RelayMode,
170        discovery: crate::node::iroh_node::DiscoveryConfig,
171        member_keys: Arc<RwLock<HashSet<iroh::PublicKey>>>,
172        address_lookup: Option<iroh::address_lookup::memory::MemoryLookup>,
173        bep: &crate::storage::config::BepConfig,
174    ) -> Result<(Arc<IrohNode>, FolderManager, SyncEngine)> {
175        let identity = IdentityManager::new(data_dir.join("identity.key")).inspect_err(|_| {
176            let _ = node_db.remove_lifecycle();
177            let _ = pid_lock.release();
178        })?;
179        let err_cleanup = |_: &_| {
180            let _ = node_db.remove_lifecycle();
181            let _ = pid_lock.release();
182        };
183        let relay = if bep.enabled {
184            let options = RelayManagerOptions::new(bep.relay_config(), data_dir.join("relay"));
185            Some(RelayManager::start(options, identity.node_id()).inspect_err(err_cleanup)?)
186        } else {
187            None
188        };
189        let node = Arc::new(match address_lookup {
190            Some(lookup) => IrohNode::new_with_address_lookup_and_relay(
191                identity,
192                data_dir.join("data"),
193                relay_mode,
194                lookup,
195                discovery,
196                member_keys,
197                relay,
198            )
199            .await
200            .inspect_err(err_cleanup)?,
201            None => IrohNode::new_with_relay(
202                identity,
203                data_dir.join("data"),
204                relay_mode,
205                member_keys,
206                discovery,
207                relay,
208            )
209            .await
210            .inspect_err(err_cleanup)?,
211        });
212        let folder_manager = FolderManager::new(&node);
213        let sync_engine = SyncEngine::new(
214            folder_manager.clone(),
215            node.blob_store().clone(),
216            node.docs_engine().clone(),
217            Some(node.topic_tracker().clone()),
218        );
219        Ok((node, folder_manager, sync_engine))
220    }
221
222    /// Open node and stats databases and acquire the process lock.
223    ///
224    /// # Errors
225    ///
226    /// Returns an error if a database cannot be opened or the lock is held by
227    /// another process.
228    fn init_databases(config: &DaemonConfig) -> Result<(NodeDatabase, StatsDatabase, PidLock)> {
229        let node_db = NodeDatabase::open(config.data_dir.join("node.db"))?;
230        let stats_db = StatsDatabase::open(config.data_dir.join("stats.db"))?;
231        let pid_lock = PidLock::new(&config.data_dir);
232        if !pid_lock.try_acquire()? {
233            return Err(SyncwebError::operation(
234                "daemon already running",
235                config.data_dir.display(),
236            ));
237        }
238        Ok((node_db, stats_db, pid_lock))
239    }
240
241    /// Load app config, schedule manager, filter engine, and write initial lifecycle state.
242    ///
243    /// # Errors
244    ///
245    /// Returns an error if loading config, filters, or saving the lifecycle fails.
246    fn load_app_state(
247        node_db: &NodeDatabase,
248        pid_lock: &PidLock,
249        data_dir: &Path,
250    ) -> Result<(
251        Option<ScheduleManager>,
252        Option<FilterEngine>,
253        DaemonState,
254        TransferLimits,
255    )> {
256        let app_config = node_db.load_app_config().inspect_err(|_error| {
257            let _ = pid_lock.release();
258        })?;
259        let use_schedule = app_config.schedule != crate::schedule::ScheduleConfig::default();
260        let schedule_manager = match ScheduleManager::from_config(&app_config.schedule) {
261            Ok(s) if use_schedule => Some(s),
262            Ok(_) => None,
263            Err(error) => {
264                let _ = pid_lock.release();
265                return Err(error);
266            }
267        };
268        let transfer_limits = TransferLimits::from_config(&app_config.transfer_limits).inspect_err(|_error| {
269            let _ = pid_lock.release();
270        })?;
271        let filter_engine = load_filter_engine(node_db, data_dir).inspect_err(|_error| {
272            let _ = pid_lock.release();
273        })?;
274        let initial_state = DaemonState::new(
275            std::process::id(),
276            String::new(),
277            current_timestamp(),
278            data_dir,
279            DaemonStatus::Starting,
280        );
281        node_db.save_lifecycle(&initial_state).inspect_err(|_error| {
282            let _ = pid_lock.release();
283        })?;
284        Ok((schedule_manager, filter_engine, initial_state, transfer_limits))
285    }
286
287    /// Create a daemon, acquire its process lock, and persist its running
288    /// state.
289    ///
290    /// # Errors
291    ///
292    /// Returns an error if another daemon owns the data directory, the node
293    /// cannot be opened, or configuration cannot be parsed.
294    pub async fn new(config: DaemonConfig) -> Result<Self> {
295        std::fs::create_dir_all(&config.data_dir)?;
296        let (node_db, stats_db, pid_lock) = Self::init_databases(&config)?;
297        node_db.recover_transfer_jobs()?;
298        let (schedule_manager, filter_engine, initial_state, transfer_limits) =
299            Self::load_app_state(&node_db, &pid_lock, &config.data_dir)?;
300
301        let member_keys: Arc<RwLock<HashSet<iroh::PublicKey>>> = {
302            let networks = node_db.list_networks()?;
303            let keys: HashSet<iroh::PublicKey> = networks.iter().flat_map(|n| n.members.iter().copied()).collect();
304            Arc::new(RwLock::new(keys))
305        };
306        let mut node_discovery = config.discovery.clone();
307        node_discovery.scope = discovery_scope(config.network.as_deref());
308        let (node, folder_manager, mut sync_engine) = match Self::open_identity_and_node(
309            &config.data_dir,
310            &node_db,
311            &pid_lock,
312            config.relay_mode.clone(),
313            node_discovery,
314            member_keys.clone(),
315            config.address_lookup.clone(),
316            &config.bep,
317        )
318        .await
319        {
320            Ok(result) => result,
321            Err(error) => {
322                let _ = node_db.remove_lifecycle();
323                let _ = pid_lock.release();
324                return Err(error);
325            }
326        };
327        sync_engine = sync_engine
328            .with_node_db(node_db.clone())
329            .with_reconciler(build_reconciler(node.clone(), folder_manager.clone(), node_db.clone()));
330
331        let archive_pool = match ManagedPool::new("syncweb-archive", config.rayon_threads) {
332            Ok(value) => Arc::new(value),
333            Err(error) => {
334                let _ = node.stop().await;
335                let _ = node_db.remove_lifecycle();
336                let _ = pid_lock.release();
337                return Err(SyncwebError::operation("failed to create daemon thread pool", error));
338            }
339        };
340
341        let initial_handle = DaemonHandle::new(initial_state);
342        {
343            let mut state = initial_handle.state.write().await;
344            state.node_id = node.endpoint().id().to_string();
345            state.status = DaemonStatus::Running;
346            let running_state = state.clone();
347            drop(state);
348            if let Err(error) = node_db.save_lifecycle(&running_state) {
349                let _ = node.stop().await;
350                let _ = node_db.remove_lifecycle();
351                let _ = pid_lock.release();
352                return Err(error);
353            }
354        }
355        let (sync_sender, sync_receiver) = mpsc::unbounded_channel();
356        let handle = DaemonHandle::with_channels_and_reload(
357            initial_handle.state.clone(),
358            initial_handle.folder_registry.clone(),
359            initial_handle.shutdown_sender.clone(),
360            sync_sender,
361            initial_handle.reload_requested.clone(),
362        );
363        let indexing = match IndexingService::new(config.data_dir.join("indexing.sqlite")) {
364            Ok(indexing) => Some(indexing),
365            Err(error) => {
366                tracing::warn!(%error, "failed to open indexing database; catalog channels disabled");
367                None
368            }
369        };
370        let intent_supervisor = IntentSupervisor::new(config.retry_window, config.backoff_base, config.backoff_max);
371        let network_logger = NetworkLogger::new(stats_db.clone());
372        let local_node_id = node.endpoint().id();
373        let nm_result =
374            Self::create_network_manager(&node_db, &local_node_id, &network_logger, Arc::clone(&member_keys));
375        let network_manager = match nm_result {
376            Ok(nm) => nm,
377            Err(error) => {
378                tracing::warn!("failed to create network manager with logger: {error}");
379                NetworkManager::new(node_db.clone(), local_node_id, member_keys)?
380            }
381        };
382        let mut ipc_server = Self::build_ipc_server(
383            &config,
384            &handle,
385            &node,
386            &archive_pool,
387            &folder_manager,
388            &node_db,
389            indexing.clone(),
390        );
391        ipc_server = ipc_server.with_network_manager(Arc::new(tokio::sync::RwLock::new(network_manager.clone())));
392
393        Ok(Self {
394            config,
395            node_db,
396            stats_db,
397            pid_lock,
398            ipc_server,
399            intent_supervisor,
400            folder_manager,
401            sync_engine,
402            schedule_manager: tokio::sync::RwLock::new(schedule_manager),
403            transfer_budget: Arc::new(TransferBudget::new(transfer_limits)),
404            transfer_queue: Arc::new(TransferQueue::new()),
405            deferred_downloads: Mutex::new(HashSet::new()),
406            node,
407            handle,
408            sync_receiver: tokio::sync::Mutex::new(sync_receiver),
409            intent_tasks: Mutex::new(HashMap::new()),
410            intent_controls: Arc::new(Mutex::new(HashMap::new())),
411            watchers: Mutex::new(HashMap::new()),
412            pending_watch_events: Mutex::new(HashMap::new()),
413            filter_engine: tokio::sync::RwLock::new(filter_engine),
414            archive_pool,
415            network_logger,
416            network_manager: tokio::sync::RwLock::new(network_manager),
417            maintenance_task: tokio::sync::Mutex::new(None),
418            indexing,
419        })
420    }
421
422    fn create_network_manager(
423        node_db: &NodeDatabase,
424        local_node_id: &iroh::PublicKey,
425        network_logger: &NetworkLogger,
426        member_keys: Arc<RwLock<HashSet<iroh::PublicKey>>>,
427    ) -> Result<NetworkManager> {
428        NetworkManager::with_logger(node_db.clone(), *local_node_id, network_logger.clone(), member_keys)
429    }
430
431    fn build_ipc_server(
432        config: &DaemonConfig,
433        handle: &DaemonHandle,
434        node: &Arc<IrohNode>,
435        archive_pool: &Arc<ManagedPool>,
436        folder_manager: &FolderManager,
437        node_db: &NodeDatabase,
438        indexing: Option<IndexingService>,
439    ) -> IpcServer {
440        IpcServer::with_archive_context(
441            daemon_socket_path(&config.data_dir),
442            config.data_dir.clone(),
443            handle.clone(),
444            node.clone(),
445            archive_pool.clone(),
446            indexing,
447        )
448        .with_folder_manager(folder_manager.clone())
449        .with_node_db(node_db.clone())
450    }
451
452    /// Run the daemon until IPC or operating-system shutdown is requested.
453    ///
454    /// # Errors
455    ///
456    /// Returns an error if an IPC listener, sync cycle, or cleanup operation
457    /// fails.
458    pub async fn run(&self) -> Result<()> {
459        let run_result = self.run_inner().await;
460        let cleanup_result = self.shutdown_resources().await;
461        match run_result {
462            Err(error) => {
463                cleanup_result?;
464                Err(error)
465            }
466            Ok(()) => cleanup_result,
467        }
468    }
469
470    /// Return a snapshot of the daemon lifecycle state.
471    pub async fn state(&self) -> DaemonState {
472        self.handle.state.read().await.clone()
473    }
474
475    /// Return the daemon's fixed archive pool.
476    #[must_use]
477    pub fn archive_pool(&self) -> &ManagedPool {
478        self.archive_pool.as_ref()
479    }
480
481    async fn run_inner(&self) -> Result<()> {
482        tracing::debug!(
483            rayon_threads = self.archive_pool.thread_count(),
484            "daemon runtime initialized"
485        );
486        self.run_initial_setup().await?;
487        let (mut server_task, bridge_task, media_task) = self.spawn_server_tasks()?;
488        let (mut shutdown, mut signal_task, mut interval, mut watch_interval, shutdown_sender) = self.runtime_timers();
489        if let Err(error) = self.run_cycle().await {
490            tracing::error!(%error, "initial daemon cycle failed");
491        }
492        self.spawn_listeners().await;
493        let result = self
494            .run_event_loop(
495                &mut server_task,
496                &mut signal_task,
497                &mut shutdown,
498                &shutdown_sender,
499                &mut interval,
500                &mut watch_interval,
501            )
502            .await;
503        send_shutdown(&shutdown_sender);
504        self.wait_for_server_tasks(server_task, bridge_task, media_task).await?;
505        result
506    }
507
508    async fn run_initial_setup(&self) -> Result<()> {
509        self.load_folders().await?;
510        self.reannounce_folders().await;
511        self.load_subscriptions().await?;
512        self.start_watching().await?;
513        self.spawn_membership_listeners().await;
514        self.subscribe_channels().await;
515        Ok(())
516    }
517
518    fn runtime_timers(
519        &self,
520    ) -> (
521        broadcast::Receiver<()>,
522        SignalTask<'_>,
523        tokio::time::Interval,
524        tokio::time::Interval,
525        broadcast::Sender<()>,
526    ) {
527        let shutdown = self.handle.shutdown_sender.subscribe();
528        let shutdown_sender = self.handle.shutdown_sender.clone();
529        let signal_task: SignalTask<'_> = Box::pin(self.handle_signals(shutdown_sender.clone()));
530        let interval_duration = self.config.sync_interval.max(Duration::from_millis(1));
531        let interval = tokio::time::interval(interval_duration);
532        let watch_interval = tokio::time::interval(Duration::from_millis(100));
533        (shutdown, signal_task, interval, watch_interval, shutdown_sender)
534    }
535
536    async fn spawn_listeners(&self) {
537        self.spawn_maintenance_task().await;
538    }
539
540    fn spawn_server_tasks(&self) -> Result<ServerTasks> {
541        let server = self.ipc_server.clone();
542        let server_task = tokio::spawn(async move { server.serve().await });
543
544        let bridge =
545            crate::media::BridgeServer::new(crate::media::bridge::DEFAULT_BRIDGE_LISTEN, self.ipc_server.clone())?;
546        let bridge_shutdown = self.handle.shutdown_sender.clone();
547        let bridge_task = tokio::spawn(async move { bridge.run(bridge_shutdown).await });
548
549        let media_task = self.config.media_listen.map(|addr| {
550            let media_srv = crate::media::MediaServer::new(addr, self.node.blob_store().clone());
551            let media_shutdown = self.handle.shutdown_sender.clone();
552            tokio::spawn(async move { media_srv.run(media_shutdown).await })
553        });
554
555        Ok((server_task, Some(bridge_task), media_task))
556    }
557
558    async fn wait_for_server_tasks(
559        &self,
560        server_task: JoinHandle<Result<()>>,
561        bridge_task: Option<JoinHandle<Result<()>>>,
562        media_task: Option<JoinHandle<Result<()>>>,
563    ) -> Result<()> {
564        if !server_task.is_finished() {
565            match server_task.await {
566                Ok(server_result) => server_result?,
567                Err(error) => return Err(SyncwebError::operation("daemon IPC task failed", error)),
568            }
569        }
570        if let Some(task) = bridge_task
571            && !task.is_finished()
572            && let Err(error) = task.await
573        {
574            tracing::warn!(%error, "websocket bridge task failed");
575        }
576        if let Some(task) = media_task
577            && !task.is_finished()
578            && let Err(error) = task.await
579        {
580            tracing::warn!(%error, "media server task failed");
581        }
582        Ok(())
583    }
584
585    async fn run_event_loop(
586        &self,
587        server_task: &mut JoinHandle<Result<()>>,
588        signal_task: &mut SignalTask<'_>,
589        shutdown: &mut broadcast::Receiver<()>,
590        shutdown_sender: &broadcast::Sender<()>,
591        interval: &mut tokio::time::Interval,
592        watch_interval: &mut tokio::time::Interval,
593    ) -> Result<()> {
594        loop {
595            tokio::select! {
596                signal_result = &mut *signal_task => {
597                    signal_result?;
598                    send_shutdown(shutdown_sender);
599                    break Ok(());
600                }
601                shutdown_result = shutdown.recv() => {
602                    if matches!(shutdown_result, Ok(()) | Err(broadcast::error::RecvError::Closed)) {
603                        break Ok(());
604                    }
605                }
606                server_result = &mut *server_task => {
607                    match server_result {
608                        Ok(result) => break result,
609                        Err(error) => break Err(SyncwebError::operation("daemon IPC task failed", error)),
610                    }
611                }
612                trigger = self.receive_sync_trigger() => {
613                    match trigger {
614                        Some(namespace) => self.run_trigger(namespace).await?,
615                        None => break Ok(()),
616                    }
617                }
618                _ = watch_interval.tick() => self.handle_watch_events().await?,
619                _ = interval.tick() => self.run_cycle().await?,
620            }
621        }
622    }
623
624    async fn run_cycle(&self) -> Result<()> {
625        self.reload_if_requested().await?;
626        if let Err(error) = self.load_folders().await {
627            tracing::warn!(%error, "folder load cycle failed — continuing");
628        }
629        self.start_watching().await?;
630        self.refresh_transfer_limits();
631        let live_folders = self.enabled_subscribe_filters();
632        let statuses = self.handle.folder_registry.read().await.statuses();
633        for folder in statuses {
634            let namespace = folder
635                .namespace
636                .parse::<NamespaceId>()
637                .map_err(|error| SyncwebError::operation("invalid managed folder namespace", error))?;
638            let folder_name = (!folder.path.as_os_str().is_empty()).then(|| folder.path.to_string_lossy().into_owned());
639            let Some(filters) = live_folders.get(&namespace) else {
640                if is_active(namespace) && !cancel_session(namespace) {
641                    tracing::warn!(%namespace, "live syncing is disabled; intent did not accept cancellation");
642                }
643                continue;
644            };
645            self.apply_folder_schedule(namespace, filters, folder_name.as_deref())
646                .await?;
647        }
648        self.enforce_transfer_limits().await?;
649        self.save_status_report().await?;
650        Ok(())
651    }
652
653    /// Start or stop one folder's live session according to its schedule.
654    async fn apply_folder_schedule(
655        &self,
656        namespace: NamespaceId,
657        filters: &SubscribeFilters,
658        folder_name: Option<&str>,
659    ) -> Result<()> {
660        let active = {
661            let schedule_manager = self.schedule_manager.read().await;
662            schedule_manager
663                .as_ref()
664                .is_none_or(|manager| manager.is_active(folder_name))
665        };
666        if active {
667            self.start_supervision(namespace, filters).await?;
668            self.set_intent_active(namespace, true)
669        } else if cancel_session(namespace) {
670            tracing::debug!(%namespace, "outside scheduled active window; pausing intent");
671            Ok(())
672        } else {
673            Ok(())
674        }
675    }
676
677    /// Apply the configured per-hour/day/month transfer limits.
678    ///
679    /// When a download quota is configured, iroh-docs eager downloads are
680    /// disabled for managed folders and remote blobs are queued; the queue is
681    /// drained only while the rolling-window budget allows. When an upload
682    /// quota is exhausted, live sessions are paused so the node stops seeding
683    /// until the window frees budget.
684    /// Re-read `[transfer_limits]` from the node database into the live budget.
685    fn refresh_transfer_limits(&self) {
686        if let Ok(config) = self.node_db.load_app_config()
687            && let Ok(limits) = TransferLimits::from_config(&config.transfer_limits)
688        {
689            self.transfer_budget.set_limits(limits);
690        }
691    }
692
693    async fn enforce_transfer_limits(&self) -> Result<()> {
694        let download_limited = !self.transfer_budget.limits().download.is_unlimited();
695        let upload_limited = !self.transfer_budget.limits().upload.is_unlimited();
696        if !download_limited && !upload_limited {
697            return Ok(());
698        }
699
700        if upload_limited && self.transfer_budget.available(Direction::Upload) == 0 {
701            self.pause_sessions_for_upload_limit().await;
702        }
703
704        if !download_limited {
705            return self.restore_deferred_downloads().await;
706        }
707
708        let statuses = self.handle.folder_registry.read().await.statuses();
709        let mut deferred_now = Vec::new();
710        for status in &statuses {
711            if let Ok(namespace) = status.namespace.parse::<NamespaceId>() {
712                self.defer_folder_downloads(namespace, &mut deferred_now).await;
713            }
714        }
715        self.record_deferred_downloads(deferred_now)?;
716        self.drain_transfer_queue().await;
717        Ok(())
718    }
719
720    /// Stop seeding by cancelling every live session while upload budget is spent.
721    async fn pause_sessions_for_upload_limit(&self) {
722        let statuses = self.handle.folder_registry.read().await.statuses();
723        for status in &statuses {
724            if let Ok(namespace) = status.namespace.parse::<NamespaceId>()
725                && cancel_session(namespace)
726            {
727                tracing::info!(%namespace, "upload budget exhausted; pausing live session");
728            }
729        }
730    }
731
732    /// Restore the default download policy for folders we previously deferred.
733    async fn restore_deferred_downloads(&self) -> Result<()> {
734        let to_clear: Vec<NamespaceId> = {
735            let mut deferred = self
736                .deferred_downloads
737                .lock()
738                .map_err(|error| SyncwebError::operation("deferred download mutex is poisoned", error))?;
739            deferred.drain().collect()
740        };
741        for namespace in to_clear {
742            if let Ok(folder) = self.folder_manager.get(namespace).await {
743                let _ = folder.clear_metadata_only().await;
744            }
745        }
746        Ok(())
747    }
748
749    /// Disable eager downloads for a folder and queue its remote blobs.
750    async fn defer_folder_downloads(&self, namespace: NamespaceId, deferred: &mut Vec<NamespaceId>) {
751        let Ok(folder) = self.folder_manager.get(namespace).await else {
752            return;
753        };
754        if let Err(error) = folder.set_metadata_only().await {
755            tracing::debug!(%namespace, %error, "could not defer content downloads for transfer limits");
756            return;
757        }
758        deferred.push(namespace);
759        let Ok(entries) = folder.content_entries().await else {
760            return;
761        };
762        for entry in entries {
763            let hash = entry.content_hash();
764            if folder.has_local(hash).await.unwrap_or(false) {
765                continue;
766            }
767            let path = PathBuf::from(String::from_utf8_lossy(entry.key()).into_owned());
768            self.transfer_queue
769                .enqueue(PendingTransfer::new(namespace, hash, entry.content_len(), path));
770        }
771    }
772
773    fn record_deferred_downloads(&self, deferred_now: Vec<NamespaceId>) -> Result<()> {
774        let mut deferred = self
775            .deferred_downloads
776            .lock()
777            .map_err(|error| SyncwebError::operation("deferred download mutex is poisoned", error))?;
778        deferred.extend(deferred_now);
779        drop(deferred);
780        Ok(())
781    }
782
783    /// Fetch queued blobs while the download budget allows.
784    async fn drain_transfer_queue(&self) {
785        while let Some(item) = self.transfer_queue.pop_fitting(&self.transfer_budget) {
786            let peers = self
787                .node
788                .topic_tracker()
789                .find_peers(item.namespace)
790                .await
791                .unwrap_or_default();
792            let mut fetched = false;
793            for peer in peers {
794                if self
795                    .node
796                    .blob_store()
797                    .force_fetch_from_peer(self.node.endpoint(), &peer, item.hash)
798                    .await
799                    .is_ok()
800                {
801                    fetched = true;
802                    break;
803                }
804            }
805            if fetched {
806                self.transfer_budget.record(Direction::Download, item.size);
807                let network_id = self
808                    .network_manager
809                    .read()
810                    .await
811                    .network_for_folder(&item.namespace)
812                    .ok()
813                    .flatten();
814                let folder = item.namespace.to_string();
815                if let Err(error) = self.network_logger.record_bandwidth_download(
816                    item.size,
817                    1,
818                    Some(&folder),
819                    None,
820                    network_id.as_deref(),
821                ) {
822                    tracing::warn!(%error, "failed to record queued download");
823                }
824                tracing::debug!(
825                    namespace = %item.namespace,
826                    hash = %item.hash,
827                    size = item.size,
828                    queued = self.transfer_queue.len(),
829                    "queued transfer fetched within budget"
830                );
831            } else {
832                // No peer served it yet; keep it queued for a later cycle.
833                self.transfer_queue.enqueue(item);
834                break;
835            }
836        }
837    }
838
839    async fn run_trigger(&self, namespace: Option<String>) -> Result<()> {
840        match namespace {
841            Some(value) => self.trigger_namespace(value).await,
842            None => self.run_cycle().await,
843        }
844    }
845
846    async fn trigger_namespace(&self, value: String) -> Result<()> {
847        let parsed_namespace = value
848            .parse::<NamespaceId>()
849            .map_err(|error| SyncwebError::operation("invalid sync namespace", error))?;
850        let tracked = self
851            .handle
852            .folder_registry
853            .read()
854            .await
855            .statuses()
856            .iter()
857            .any(|status| status.namespace == value);
858        if !tracked {
859            tracing::warn!(%parsed_namespace, "not a syncweb folder; nothing to synchronize");
860            return Ok(());
861        }
862        let Some(filters) = self.enabled_subscribe_filters().get(&parsed_namespace).cloned() else {
863            tracing::warn!(
864                %parsed_namespace,
865                "live syncing is disabled for this folder; enable it with `join --subscribe` \
866                 or `config set <folder>.subscribe on`"
867            );
868            return Ok(());
869        };
870        if !self.schedule_active_for(parsed_namespace).await {
871            tracing::debug!(%parsed_namespace, "outside scheduled active window; not starting sync");
872            if cancel_session(parsed_namespace) {
873                self.save_status_report().await?;
874            }
875            return Ok(());
876        }
877        self.start_supervision(parsed_namespace, &filters).await?;
878        self.set_intent_active(parsed_namespace, true)?;
879        self.save_status_report().await?;
880        Ok(())
881    }
882
883    /// Return whether the schedule lets a folder synchronize right now.
884    async fn schedule_active_for(&self, namespace: NamespaceId) -> bool {
885        let folder_name = self
886            .handle
887            .folder_registry
888            .read()
889            .await
890            .statuses()
891            .into_iter()
892            .find(|status| status.namespace == namespace.to_string())
893            .and_then(|status| {
894                (!status.path.as_os_str().is_empty()).then(|| status.path.to_string_lossy().into_owned())
895            });
896        let schedule_manager = self.schedule_manager.read().await;
897        schedule_manager
898            .as_ref()
899            .is_none_or(|manager| manager.is_active(folder_name.as_deref()))
900    }
901
902    /// The `subscribe-changes` folders with live syncing enabled, mapped to their filters.
903    fn enabled_subscribe_filters(&self) -> std::collections::BTreeMap<NamespaceId, SubscribeFilters> {
904        let Ok(config) = self.node_db.load_app_config() else {
905            return std::collections::BTreeMap::new();
906        };
907        config
908            .subscribe
909            .folders
910            .into_iter()
911            .filter(|(_, entry)| entry.enabled)
912            .filter_map(|(namespace, entry)| namespace.parse::<NamespaceId>().ok().map(|id| (id, entry.filters)))
913            .collect()
914    }
915
916    async fn start_supervision(&self, namespace: NamespaceId, filters: &SubscribeFilters) -> Result<()> {
917        self.refresh_transfer_limits();
918        {
919            let mut tasks = self
920                .intent_tasks
921                .lock()
922                .map_err(|error| SyncwebError::operation("daemon intent task mutex is poisoned", error))?;
923            tasks.retain(|_, task| task.as_ref().is_none_or(|handle| !handle.is_finished()));
924            if tasks.contains_key(&namespace) || is_active(namespace) {
925                return Ok(());
926            }
927            tasks.insert(namespace, None);
928        }
929        // With a download quota configured, defer content before iroh-docs can
930        // eagerly fetch it; the transfer queue refills it within budget.
931        if !self.transfer_budget.limits().download.is_unlimited()
932            && let Ok(folder) = self.folder_manager.get(namespace).await
933            && let Err(error) = folder.set_metadata_only().await
934        {
935            tracing::debug!(%namespace, %error, "could not defer downloads before supervising");
936        }
937        let network_id = {
938            let guard = self.network_manager.read().await;
939            guard.network_for_folder(&namespace)?
940        };
941        let session_id = network_id.as_ref().and_then(|net_id| {
942            self.network_logger
943                .record_sync_start(net_id, &namespace.to_string())
944                .ok()
945        });
946        let sync = self.sync_engine.clone();
947        let supervisor = self.intent_supervisor;
948        let shutdown = self.handle.shutdown_sender.subscribe();
949        let controls = self.intent_controls.clone();
950        let filter = self.filter_engine.read().await.clone();
951        let network_logger = self.network_logger.clone();
952        let params = match SubscribeParams::from_filters(filters) {
953            Ok(params) => params,
954            Err(error) => {
955                tracing::warn!(%namespace, %error, "invalid subscription filters; supervising without them");
956                SubscribeParams::default()
957            }
958        };
959        let (ready_sender, ready_receiver) = oneshot::channel();
960        let accountant = BandwidthAccountant::new(network_logger.clone(), network_id.clone(), namespace.to_string());
961        let task = tokio::spawn(async move {
962            let options =
963                SupervisionOptions::with_ready(controls, filter, ready_sender).with_bandwidth(accountant.clone());
964            let result = supervisor
965                .supervise_with_controls_and_ready(&sync, namespace, params, shutdown, options)
966                .await;
967            if network_id.is_some()
968                && let Some(sid) = session_id
969            {
970                match &result {
971                    Ok(supervised) => {
972                        let has_error = supervised.last_error.is_some();
973                        let bytes = accountant.total();
974                        let files = supervised.files_transferred;
975                        let errors = u64::from(has_error);
976                        let status = if has_error { "failed" } else { "completed" };
977                        let _ = network_logger.record_sync_finish(sid, files, bytes, errors, status);
978                    }
979                    Err(_) => {
980                        let _ = network_logger.record_sync_finish(sid, 0, 0, 1, "failed");
981                    }
982                }
983            }
984            match &result {
985                Ok(supervised) => {
986                    if let Some(error) = &supervised.last_error {
987                        tracing::warn!(%namespace, retry_count = supervised.retry_count, %error, "supervised intent stopped");
988                    }
989                }
990                Err(error) => tracing::error!(%namespace, %error, "supervised intent failed"),
991            }
992        });
993        self.intent_tasks
994            .lock()
995            .map_err(|error| SyncwebError::operation("daemon intent task mutex is poisoned", error))?
996            .insert(namespace, Some(task));
997        ready_receiver
998            .await
999            .map_err(|error| SyncwebError::operation("daemon intent startup notification failed", error))?;
1000        Ok(())
1001    }
1002
1003    fn set_intent_active(&self, namespace: NamespaceId, active: bool) -> Result<()> {
1004        let control = self
1005            .intent_controls
1006            .lock()
1007            .map_err(|error| SyncwebError::operation("daemon intent control mutex is poisoned", error))?
1008            .get(&namespace)
1009            .cloned();
1010        let Some(intent_control) = control else {
1011            return Ok(());
1012        };
1013        let result = if active {
1014            intent_control.resume()
1015        } else {
1016            intent_control.pause()
1017        };
1018        result.map_err(|error| SyncwebError::operation("failed to update scheduled intent", error))
1019    }
1020
1021    /// Return whether the global schedule is currently active.
1022    pub async fn is_in_active_window(&self) -> bool {
1023        self.schedule_manager
1024            .read()
1025            .await
1026            .as_ref()
1027            .is_none_or(|manager| manager.is_active(None))
1028    }
1029
1030    async fn start_watching(&self) -> Result<()> {
1031        let statuses = self.handle.folder_registry.read().await.statuses();
1032        let wanted: HashMap<_, _> = statuses
1033            .iter()
1034            .filter(|status| !status.path.as_os_str().is_empty())
1035            .map(|status| (status.namespace.clone(), status.path.clone()))
1036            .collect();
1037        let candidates = self.watcher_candidates(&wanted)?;
1038        let mut to_add = Vec::new();
1039        for (namespace, path) in candidates {
1040            if self.watcher_allowed(&namespace, &path).await {
1041                to_add.push((namespace, path));
1042            }
1043        }
1044        self.install_watchers(to_add)
1045    }
1046
1047    /// Drop watchers whose folder left the registry and return the newly-wanted
1048    /// namespaces. The watcher mutex is a `std::sync::Mutex`, so it is dropped
1049    /// before the async capability lookup in [`Self::watcher_allowed`].
1050    fn watcher_candidates(&self, wanted: &HashMap<String, PathBuf>) -> Result<Vec<(String, PathBuf)>> {
1051        let mut watchers = self
1052            .watchers
1053            .lock()
1054            .map_err(|error| SyncwebError::operation("daemon watcher mutex is poisoned", error))?;
1055        watchers.retain(|namespace, _| wanted.contains_key(namespace));
1056        let candidates = wanted
1057            .iter()
1058            .filter(|(namespace, _)| !watchers.contains_key(*namespace))
1059            .map(|(namespace, path)| (namespace.clone(), path.clone()))
1060            .collect();
1061        drop(watchers);
1062        Ok(candidates)
1063    }
1064
1065    /// Whether a filesystem watcher should be created for `namespace`.
1066    ///
1067    /// A replica joined from a read-only ticket cannot accept local writes:
1068    /// watching it would try to import every materialized file and fail in a
1069    /// retry loop, so it is skipped entirely.
1070    async fn watcher_allowed(&self, namespace: &str, path: &Path) -> bool {
1071        let Ok(namespace_id) = namespace.parse::<NamespaceId>() else {
1072            return false;
1073        };
1074        match self.folder_manager.get(namespace_id).await {
1075            Ok(folder) if !folder.is_writable() => {
1076                tracing::debug!(%namespace, "read-only folder; not watching for local changes");
1077                false
1078            }
1079            Ok(_) if path.exists() => true,
1080            Ok(_) => {
1081                tracing::warn!(%namespace, path = %path.display(), "managed folder path does not exist; watcher deferred");
1082                false
1083            }
1084            Err(error) => {
1085                tracing::debug!(%namespace, %error, "folder capability unavailable; watcher deferred");
1086                false
1087            }
1088        }
1089    }
1090
1091    fn install_watchers(&self, to_add: Vec<(String, PathBuf)>) -> Result<()> {
1092        let mut watchers = self
1093            .watchers
1094            .lock()
1095            .map_err(|error| SyncwebError::operation("daemon watcher mutex is poisoned", error))?;
1096        for (namespace, path) in to_add {
1097            if let std::collections::hash_map::Entry::Vacant(slot) = watchers.entry(namespace) {
1098                slot.insert(FsWatcher::new(&path)?);
1099            }
1100        }
1101        Ok(())
1102    }
1103
1104    async fn handle_watch_events(&self) -> Result<()> {
1105        // Reconcile watchers with the registry on every tick so a `watch`
1106        // registration is picked up promptly rather than at the next sync
1107        // interval.
1108        self.start_watching().await?;
1109        let mut observed = Vec::new();
1110        {
1111            let mut watchers = self
1112                .watchers
1113                .lock()
1114                .map_err(|error| SyncwebError::operation("daemon watcher mutex is poisoned", error))?;
1115            for (namespace, watcher) in watchers.iter_mut() {
1116                loop {
1117                    match watcher.try_recv() {
1118                        Ok(Some(event)) => observed.push((namespace.clone(), event)),
1119                        Ok(None) => break,
1120                        Err(error) => {
1121                            tracing::warn!(%namespace, %error, "filesystem watcher event channel failed");
1122                            break;
1123                        }
1124                    }
1125                }
1126            }
1127        }
1128
1129        if !observed.is_empty() {
1130            let mut pending = self
1131                .pending_watch_events
1132                .lock()
1133                .map_err(|error| SyncwebError::operation("daemon watch queue mutex is poisoned", error))?;
1134            let ready_at = Instant::now()
1135                .checked_add(self.config.watch_debounce)
1136                .unwrap_or_else(Instant::now);
1137            for (namespace, event) in observed {
1138                let removed = matches!(event.event.kind, notify::EventKind::Remove(_));
1139                let entry = pending.entry(namespace).or_insert_with(|| PendingWatch {
1140                    paths: HashMap::new(),
1141                    ready_at,
1142                });
1143                entry.ready_at = ready_at;
1144                for path in event.paths {
1145                    entry.paths.insert(path, removed);
1146                }
1147            }
1148            drop(pending);
1149        }
1150
1151        let ready = {
1152            let mut pending = self
1153                .pending_watch_events
1154                .lock()
1155                .map_err(|error| SyncwebError::operation("daemon watch queue mutex is poisoned", error))?;
1156            let now = Instant::now();
1157            pending
1158                .iter_mut()
1159                .filter_map(|(namespace, batch)| {
1160                    (batch.ready_at <= now).then(|| (namespace.clone(), std::mem::take(&mut batch.paths)))
1161                })
1162                .filter(|(_, paths)| !paths.is_empty())
1163                .collect::<Vec<_>>()
1164        };
1165        if ready.is_empty() {
1166            return Ok(());
1167        }
1168
1169        let (roots, watch_configs): (HashMap<_, _>, HashMap<_, _>) = {
1170            let registry = self.handle.folder_registry.read().await;
1171            let statuses = registry.statuses();
1172            let roots = statuses
1173                .iter()
1174                .map(|status| (status.namespace.clone(), status.path.clone()))
1175                .collect();
1176            let watch_configs = statuses
1177                .iter()
1178                .map(|status| (status.namespace.clone(), registry.watch_for(&status.namespace)))
1179                .collect();
1180            drop(registry);
1181            (roots, watch_configs)
1182        };
1183        let live_folders = self.enabled_subscribe_filters();
1184        let global_engine = self.filter_engine.read().await.clone();
1185        for (namespace, paths) in ready {
1186            let Some(root) = roots.get(&namespace) else {
1187                continue;
1188            };
1189            let namespace_id = namespace
1190                .parse::<NamespaceId>()
1191                .map_err(|error| SyncwebError::operation("invalid watched folder namespace", error))?;
1192            let watch = watch_configs.get(&namespace).cloned().unwrap_or_default();
1193            let folder_engine = watch.compiled_filter()?;
1194            let evaluation = WatchEvaluation {
1195                filter: folder_engine.as_ref().or(global_engine.as_ref()),
1196                exclude: &watch.exclude,
1197            };
1198            for (path, removed) in paths {
1199                self.process_watch_event(
1200                    namespace_id,
1201                    root,
1202                    &path,
1203                    removed,
1204                    live_folders.get(&namespace_id),
1205                    evaluation,
1206                )
1207                .await?;
1208            }
1209        }
1210        self.save_status_report().await?;
1211        Ok(())
1212    }
1213
1214    async fn process_watch_event(
1215        &self,
1216        namespace: NamespaceId,
1217        root: &Path,
1218        path: &Path,
1219        removed: bool,
1220        live_filters: Option<&SubscribeFilters>,
1221        evaluation: WatchEvaluation<'_>,
1222    ) -> Result<()> {
1223        let relative = path.strip_prefix(root).unwrap_or(path);
1224        if relative.as_os_str().is_empty() {
1225            return Ok(());
1226        }
1227        let size = std::fs::metadata(path).map_or(0, |metadata| metadata.len());
1228        let accepted = evaluation.filter.is_none_or(|active| {
1229            active.evaluate_for_folder(&namespace.to_string(), &FilterEntry::new(relative.to_path_buf(), size))
1230                != FilterAction::Reject
1231        });
1232        if !accepted {
1233            return Ok(());
1234        }
1235
1236        let folder = self.folder_manager.get(namespace).await?;
1237        // A read-only replica has no write key, so importing or deleting here
1238        // can only fail. Ignore the event instead of looping on the error.
1239        if !folder.is_writable() {
1240            tracing::debug!(%namespace, path = %path.display(), "ignoring filesystem change for read-only folder");
1241            return Ok(());
1242        }
1243        let result = if removed || !path.exists() {
1244            folder
1245                .delete_entry(relative.as_os_str().as_encoded_bytes())
1246                .await
1247                .map(|()| 1_u64)
1248        } else if path.is_file() {
1249            let importer = Importer::new(
1250                self.node.blob_store().clone(),
1251                self.node.docs_engine().clone(),
1252                folder.doc().clone(),
1253                folder.author(),
1254            )
1255            .with_root(root)
1256            .with_ignore_patterns(evaluation.exclude.to_vec());
1257            importer
1258                .import_path(path)
1259                .await
1260                .map(|entries| u64::try_from(entries.len()).unwrap_or(u64::MAX))
1261        } else {
1262            Ok(0)
1263        };
1264        match result {
1265            Ok(entries) if entries > 0 => {
1266                self.handle
1267                    .folder_registry
1268                    .write()
1269                    .await
1270                    .record_import(namespace, entries, current_timestamp());
1271                if let Some(filters) = live_filters {
1272                    self.start_supervision(namespace, filters).await?;
1273                }
1274            }
1275            Ok(_) => {}
1276            Err(error) => {
1277                self.handle
1278                    .folder_registry
1279                    .write()
1280                    .await
1281                    .record_error(namespace, error.to_string());
1282                tracing::warn!(%namespace, path = %path.display(), %error, "filesystem change will be retried");
1283                if !removed && is_recoverable_watch_error(&error) {
1284                    let mut pending = self.pending_watch_events.lock().map_err(|poisoned| {
1285                        SyncwebError::operation("daemon watch queue mutex is poisoned", poisoned)
1286                    })?;
1287                    let ready_at = Instant::now()
1288                        .checked_add(self.config.watch_debounce)
1289                        .unwrap_or_else(Instant::now);
1290                    let entry = pending.entry(namespace.to_string()).or_insert_with(|| PendingWatch {
1291                        paths: HashMap::new(),
1292                        ready_at,
1293                    });
1294                    entry.ready_at = ready_at;
1295                    entry.paths.insert(path.to_path_buf(), false);
1296                    drop(pending);
1297                }
1298            }
1299        }
1300        Ok(())
1301    }
1302
1303    async fn save_status_report(&self) -> Result<()> {
1304        let state = self.handle.state.read().await.clone();
1305        let statuses = self.handle.folder_registry.read().await.statuses();
1306        let schedule = self.schedule_manager.read().await.clone();
1307        let schedule_report = schedule.as_ref().map(|manager| {
1308            let minute = crate::schedule::current_minute();
1309            let next = manager.next_active_window_start_at(None, minute);
1310            let next_window_start = next.map(|next_minute| {
1311                let offset = if next_minute >= minute {
1312                    next_minute.saturating_sub(minute)
1313                } else {
1314                    1_440_u16.saturating_sub(minute).saturating_add(next_minute)
1315                };
1316                current_timestamp().saturating_add(u64::from(offset).saturating_mul(60))
1317            });
1318            ScheduleStatus {
1319                in_active_window: manager.is_active(None),
1320                next_window_start,
1321            }
1322        });
1323        let current_stats = self.stats_db.current_stats().unwrap_or_default();
1324        let report = DaemonStatusReport {
1325            pid: state.pid,
1326            node_id: state.node_id,
1327            started_at: state.started_at,
1328            uptime_seconds: current_timestamp().saturating_sub(state.started_at),
1329            folders: statuses.into_iter().collect(),
1330            bandwidth: BandwidthSnapshot {
1331                upload_total: current_stats.total_upload,
1332                download_total: current_stats.total_download,
1333                upload_rate: 0,
1334                download_rate: 0,
1335            },
1336            schedule: schedule_report,
1337            rayon_threads: self.archive_pool.thread_count(),
1338        };
1339        self.node_db.save_status(&report)
1340    }
1341
1342    async fn load_folders(&self) -> Result<()> {
1343        let folders = self.folder_manager.list().await?;
1344        let folder_mounts = self
1345            .node_db
1346            .load_folder_mounts()?
1347            .into_iter()
1348            .collect::<HashMap<_, _>>();
1349        let watch_configs: HashMap<String, WatchConfig> = self
1350            .node_db
1351            .load_folder_watch_configs()?
1352            .into_iter()
1353            .filter_map(|(namespace, encoded)| {
1354                serde_json::from_str::<WatchConfig>(&encoded)
1355                    .ok()
1356                    .map(|watch| (namespace, watch))
1357            })
1358            .collect();
1359        let mut registry = self.handle.folder_registry.write().await;
1360        for folder in folders {
1361            let namespace_key = folder.namespace_id().to_string();
1362            if registry.is_removed(&namespace_key) {
1363                continue;
1364            }
1365            if !registry
1366                .statuses()
1367                .iter()
1368                .any(|status| status.namespace == namespace_key)
1369            {
1370                let path = folder_mounts.get(&namespace_key).cloned().unwrap_or_default();
1371                let watch = watch_configs.get(&namespace_key).cloned().unwrap_or_default();
1372                registry.add(
1373                    FolderEntry::new(folder.namespace_id(), path)
1374                        .with_mode(folder.mode().to_string())
1375                        .with_watch(watch),
1376                )?;
1377            }
1378        }
1379        drop(registry);
1380        Ok(())
1381    }
1382
1383    async fn reannounce_folders(&self) {
1384        let Ok(folders) = self.folder_manager.list().await else {
1385            return;
1386        };
1387        for folder in &folders {
1388            self.folder_manager.announce_namespace(folder.namespace_id()).await;
1389        }
1390    }
1391
1392    async fn load_subscriptions(&self) -> Result<()> {
1393        let subscriptions = self.node_db.load_subscriptions_with_sizes()?;
1394        if subscriptions.is_empty() {
1395            return Ok(());
1396        }
1397        let hashes: Vec<_> = subscriptions.iter().map(|(hash, _)| *hash).collect();
1398        self.folder_manager.seed_subscriptions(hashes).await;
1399
1400        let mut registry = self.handle.folder_registry.write().await;
1401        for (hash, size) in subscriptions {
1402            let subscription = PublicSubscription::new(hash, None, size);
1403            registry.add_subscription(subscription);
1404        }
1405        drop(registry);
1406        Ok(())
1407    }
1408
1409    async fn reload_if_requested(&self) -> Result<()> {
1410        if !self
1411            .handle
1412            .reload_requested
1413            .swap(false, std::sync::atomic::Ordering::AcqRel)
1414        {
1415            return Ok(());
1416        }
1417        let app_config = self.node_db.load_app_config()?;
1418        let parsed_schedule = ScheduleManager::from_config(&app_config.schedule)?;
1419        let schedule_manager =
1420            (app_config.schedule != crate::schedule::ScheduleConfig::default()).then_some(parsed_schedule);
1421        let filter = load_filter_engine(&self.node_db, &self.config.data_dir)?;
1422        *self.schedule_manager.write().await = schedule_manager;
1423        *self.filter_engine.write().await = filter;
1424        let statuses = self.handle.folder_registry.read().await.statuses();
1425        for status in statuses {
1426            if let Ok(namespace) = status.namespace.parse::<NamespaceId>()
1427                && is_active(namespace)
1428                && !cancel_session(namespace)
1429            {
1430                tracing::warn!(%namespace, "reloaded intent did not accept cancellation");
1431            }
1432        }
1433        tracing::info!("daemon configuration reloaded");
1434        Ok(())
1435    }
1436
1437    async fn receive_sync_trigger(&self) -> Option<Option<String>> {
1438        self.sync_receiver.lock().await.recv().await
1439    }
1440
1441    async fn shutdown_resources(&self) -> Result<()> {
1442        self.handle.set_status(DaemonStatus::Stopping).await;
1443        let stopping_state = self.handle.state.read().await.clone();
1444        self.node_db.save_lifecycle(&stopping_state)?;
1445        self.save_status_report().await?;
1446
1447        self.cancel_active_sessions().await;
1448        self.join_intent_tasks().await?;
1449        self.abort_listeners().await;
1450        self.finalize_node_stop().await
1451    }
1452
1453    async fn cancel_active_sessions(&self) {
1454        let namespaces: Vec<_> = self
1455            .handle
1456            .folder_registry
1457            .read()
1458            .await
1459            .statuses()
1460            .into_iter()
1461            .filter_map(|status| status.namespace.parse::<NamespaceId>().ok())
1462            .collect();
1463        for namespace in namespaces {
1464            if is_active(namespace) && !cancel_session(namespace) {
1465                tracing::warn!(%namespace, "intent did not accept shutdown cancellation");
1466            }
1467        }
1468    }
1469
1470    async fn join_intent_tasks(&self) -> Result<()> {
1471        let tasks = {
1472            let mut task_map = self
1473                .intent_tasks
1474                .lock()
1475                .map_err(|error| SyncwebError::operation("daemon intent task mutex is poisoned", error))?;
1476            std::mem::take(&mut *task_map)
1477        };
1478        for (namespace, task) in tasks {
1479            if let Some(handle) = task {
1480                match tokio::time::timeout(Duration::from_secs(10), handle).await {
1481                    Ok(Ok(())) => {}
1482                    Ok(Err(error)) => {
1483                        tracing::warn!(%namespace, %error, "daemon intent task failed");
1484                    }
1485                    Err(_) => {
1486                        tracing::warn!(%namespace, "daemon intent task did not shut down within 10s timeout");
1487                    }
1488                }
1489            }
1490        }
1491        Ok(())
1492    }
1493
1494    async fn abort_listeners(&self) {
1495        let maintenance_handle = self.maintenance_task.lock().await.take();
1496        if let Some(inner) = maintenance_handle {
1497            inner.abort();
1498        }
1499    }
1500
1501    async fn finalize_node_stop(&self) -> Result<()> {
1502        let node_result = tokio::time::timeout(Duration::from_secs(30), self.node.stop()).await;
1503        self.handle.set_status(DaemonStatus::Stopped).await;
1504        let remove_result = self.node_db.remove_lifecycle();
1505        let remove_status_result = self.node_db.remove_status();
1506        let release_result = self.pid_lock.release();
1507        match node_result {
1508            Ok(Ok(())) => {}
1509            Ok(Err(error)) => tracing::warn!(%error, "node stop reported an error"),
1510            Err(_) => tracing::warn!("node stop timed out after 30s"),
1511        }
1512        remove_result?;
1513        remove_status_result?;
1514        release_result?;
1515        Ok(())
1516    }
1517
1518    /// Spawn a background task that periodically vacuums databases with
1519    /// excessive freelist pages.
1520    async fn spawn_maintenance_task(&self) {
1521        let node_db = self.node_db.clone();
1522        let stats_db = self.stats_db.clone();
1523        let mut shutdown = self.handle.shutdown_sender.subscribe();
1524        let handle: tokio::task::JoinHandle<()> = tokio::spawn(async move {
1525            let mut interval = tokio::time::interval(Duration::from_hours(24));
1526            loop {
1527                tokio::select! {
1528                    _ = shutdown.recv() => {
1529                        tracing::info!("maintenance task shutting down");
1530                        break;
1531                    }
1532                    _ = interval.tick() => {
1533                        let dbs: [&dyn crate::storage::Vacuumable; 2] = [&node_db, &stats_db];
1534                        for db in dbs {
1535                            match db.freelist_count() {
1536                                Ok(count) if count > 100 => {
1537                                    tracing::info!(freelist = %count, "running vacuum");
1538                                    if let Err(error) = db.vacuum() {
1539                                        tracing::warn!(%error, "maintenance vacuum failed");
1540                                    }
1541                                }
1542                                Ok(_) => {}
1543                                Err(error) => tracing::warn!(%error, "maintenance freelist query failed"),
1544                            }
1545                        }
1546                    }
1547                }
1548            }
1549        });
1550        *self.maintenance_task.lock().await = Some(handle);
1551    }
1552
1553    async fn subscribe_channels(&self) {
1554        let Some(ref indexing) = self.indexing else {
1555            tracing::debug!("indexing unavailable; skipping catalog channel subscription");
1556            return;
1557        };
1558        let Some(app_config) = self.load_channel_config() else {
1559            return;
1560        };
1561        let author = match self.node.docs_engine().author().await {
1562            Ok(a) => a,
1563            Err(error) => {
1564                tracing::warn!(%error, "failed to get author for channel subscription");
1565                return;
1566            }
1567        };
1568        let catalog_service = indexing.catalog_service(self.node.docs_engine(), self.node.blob_store(), author);
1569        for (channel_name, channel_config) in &app_config.channels {
1570            self.subscribe_one_channel(indexing, &catalog_service, channel_name, channel_config)
1571                .await;
1572        }
1573    }
1574
1575    fn load_channel_config(&self) -> Option<crate::storage::config::Config> {
1576        let config_path = self.config.data_dir.join("config.toml");
1577        match crate::storage::config::Config::load(&config_path) {
1578            Ok(c) if !c.channels.is_empty() => Some(c),
1579            Ok(_) => None,
1580            Err(error) => {
1581                tracing::warn!(%error, "failed to load config for channel subscription");
1582                None
1583            }
1584        }
1585    }
1586
1587    async fn subscribe_one_channel(
1588        &self,
1589        indexing: &IndexingService,
1590        catalog_service: &crate::indexing::CatalogService,
1591        channel_name: &str,
1592        channel_config: &crate::storage::config::ChannelConfig,
1593    ) {
1594        let Ok(ticket) = channel_config.ticket.parse::<iroh_docs::DocTicket>() else {
1595            tracing::warn!(%channel_name, "invalid channel doc ticket; skipping");
1596            return;
1597        };
1598        let catalog = match catalog_service.subscribe(ticket).await {
1599            Ok(c) => c,
1600            Err(error) => {
1601                tracing::warn!(%channel_name, %error, "failed to subscribe to catalog channel");
1602                return;
1603            }
1604        };
1605        Self::record_channel_namespace(indexing, channel_name, &catalog);
1606    }
1607
1608    fn record_channel_namespace(indexing: &IndexingService, channel_name: &str, catalog: &crate::indexing::Catalog) {
1609        let namespace_id = catalog.namespace_id();
1610        if let Err(error) = indexing.database().set_channel_namespace(channel_name, namespace_id) {
1611            tracing::warn!(%channel_name, %error, "failed to persist channel namespace");
1612        }
1613        tracing::info!(%channel_name, %namespace_id, "subscribed to catalog channel");
1614    }
1615
1616    async fn spawn_membership_listeners(&self) {
1617        let networks: Vec<_> = {
1618            let guard = self.network_manager.read().await;
1619            guard.list().into_iter().cloned().collect::<Vec<_>>()
1620        };
1621        let docs_engine = self.node.docs_engine().clone();
1622        let blob_store = self.node.blob_store().clone();
1623        let shutdown = self.handle.shutdown_sender.subscribe();
1624        let local_pk = self.node.endpoint().id();
1625        let node_db = self.node_db.clone();
1626        for network in networks {
1627            let Some(ref doc_ticket_str) = network.doc_ticket else {
1628                continue;
1629            };
1630            let Ok(doc_ticket) = doc_ticket_str.parse::<iroh_docs::DocTicket>() else {
1631                tracing::warn!(network_id = %network.id, "invalid doc_ticket in network");
1632                continue;
1633            };
1634            let de = docs_engine.clone();
1635            let bs = blob_store.clone();
1636            let mut member_shutdown = shutdown.resubscribe();
1637            let network_id = network.id;
1638            let local_key_str = local_pk.to_string();
1639            let db = node_db.clone();
1640            tokio::spawn(async move {
1641                let doc = match de.import_ticket(doc_ticket).await {
1642                    Ok(doc) => doc,
1643                    Err(error) => {
1644                        tracing::warn!(%network_id, %error, "failed to import membership doc from ticket");
1645                        return;
1646                    }
1647                };
1648                if let Err(error) = de.start_sync(&doc, Vec::new()).await {
1649                    tracing::warn!(%network_id, %error, "failed to start membership doc sync");
1650                }
1651                let mut stream = match de.watch(&doc).await {
1652                    Ok(stream) => stream,
1653                    Err(error) => {
1654                        tracing::warn!(%network_id, %error, "failed to watch membership doc");
1655                        return;
1656                    }
1657                };
1658                loop {
1659                    tokio::select! {
1660                        _ = member_shutdown.recv() => {
1661                            tracing::debug!(%network_id, "membership listener shutting down");
1662                            break;
1663                        }
1664                        event = stream.next() => {
1665                            let Some(event_result) = event else {
1666                                break;
1667                            };
1668                            match event_result {
1669                                Ok(iroh_docs::engine::LiveEvent::InsertLocal { entry } | iroh_docs::engine::LiveEvent::InsertRemote { entry, .. }) => {
1670                                    if entry.key() == NETWORK_MEMBERS_KEY {
1671                                        let hash = entry.content_hash();
1672                                        let Ok(content) = bs.get(hash).await else { continue; };
1673                                        match serde_json::from_slice::<crate::net::membership_doc::SignedMemberList>(&content) {
1674                                            Ok(member_list) => {
1675                                                if let Err(error) = member_list.verify() {
1676                                                    tracing::warn!(%network_id, %error, "invalid membership list signature");
1677                                                    continue;
1678                                                }
1679                                                let is_member = member_list.members.iter().any(|m| m.key == local_key_str);
1680                                                if is_member {
1681                                                    tracing::debug!(%network_id, members = member_list.members.len(), "membership updated");
1682                                                } else {
1683                                                    tracing::warn!(%network_id, "local node was removed from network");
1684                                                    let _ = db.delete_network(network_id);
1685                                                }
1686                                            }
1687                                            Err(error) => {
1688                                                tracing::warn!(%network_id, %error, "failed to parse membership list");
1689                                            }
1690                                        }
1691                                    }
1692                                }
1693                                Ok(_) => {}
1694                                Err(error) => {
1695                                    tracing::warn!(%network_id, %error, "membership doc event error");
1696                                }
1697                            }
1698                        }
1699                    }
1700                }
1701            });
1702        }
1703    }
1704
1705    async fn handle_signals(&self, shutdown: broadcast::Sender<()>) -> Result<()> {
1706        #[cfg(unix)]
1707        {
1708            let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
1709            tokio::select! {
1710                result = tokio::signal::ctrl_c() => result?,
1711                _ = terminate.recv() => {}
1712            }
1713        }
1714        #[cfg(not(unix))]
1715        {
1716            tokio::signal::ctrl_c().await?;
1717        }
1718        send_shutdown(&shutdown);
1719        Ok(())
1720    }
1721}
1722
1723impl Drop for Daemon {
1724    fn drop(&mut self) {
1725        if let Err(error) = self.node_db.remove_lifecycle() {
1726            tracing::warn!(%error, "failed to remove daemon lifecycle");
1727        }
1728        if let Err(error) = self.node_db.remove_status() {
1729            tracing::warn!(%error, "failed to remove daemon status");
1730        }
1731    }
1732}
1733
1734fn send_shutdown(sender: &broadcast::Sender<()>) {
1735    match sender.send(()) {
1736        Ok(_) | Err(broadcast::error::SendError(())) => {}
1737    }
1738}
1739
1740/// Always remove a namespace from a shared busy set, ignoring a poisoned lock.
1741fn fully_remove(set: &std::sync::Mutex<HashSet<NamespaceId>>, namespace: &NamespaceId) {
1742    let mut guard = set.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1743    guard.remove(namespace);
1744}
1745
1746/// Build the daemon's folder reconciler.
1747///
1748/// The reconciler is invoked by the sync engine after remote content becomes
1749/// local for a writable folder. It coalesces the burst of `ContentReady`
1750/// events from a sync pass into one trailing-edge materialization per folder:
1751/// mark the folder busy, wait a short settle window, then materialize
1752/// everything that is local. Later blob completions re-trigger the reconciler
1753/// and are picked up on the next trailing edge, so nothing is lost to timing.
1754fn build_reconciler(
1755    node: Arc<IrohNode>,
1756    manager: FolderManager,
1757    db: NodeDatabase,
1758) -> Arc<dyn Fn(NamespaceId) + Send + Sync> {
1759    const RECONCILE_SETTLE: Duration = Duration::from_millis(750);
1760    let busy = Arc::new(std::sync::Mutex::new(HashSet::<NamespaceId>::new()));
1761    Arc::new(move |namespace| {
1762        let mut busy_guard = match busy.lock() {
1763            Ok(guard) => guard,
1764            Err(poisoned) => poisoned.into_inner(),
1765        };
1766        if !busy_guard.insert(namespace) {
1767            return;
1768        }
1769        drop(busy_guard);
1770        tracing::debug!(%namespace, "folder reconciler triggered");
1771        let node_handle = node.clone();
1772        let manager_handle = manager.clone();
1773        let db_handle = db.clone();
1774        let busy_set = busy.clone();
1775        tokio::spawn(async move {
1776            tokio::time::sleep(RECONCILE_SETTLE).await;
1777            let mounts = match db_handle.load_folder_mounts() {
1778                Ok(mounts) => mounts,
1779                Err(error) => {
1780                    tracing::warn!(%namespace, %error, "reconciler could not read folder mounts");
1781                    fully_remove(&busy_set, &namespace);
1782                    return;
1783                }
1784            };
1785            let Some((_, mount_root)) = mounts
1786                .into_iter()
1787                .find(|(mounted, _)| mounted.parse::<NamespaceId>().is_ok_and(|id| id == namespace))
1788            else {
1789                fully_remove(&busy_set, &namespace);
1790                return;
1791            };
1792            let folder = match manager_handle.get(namespace).await {
1793                Ok(folder) => folder,
1794                Err(error) => {
1795                    tracing::warn!(%namespace, %error, "reconciler could not open folder");
1796                    fully_remove(&busy_set, &namespace);
1797                    return;
1798                }
1799            };
1800            // Read-only replicas materialize on explicit download only; a
1801            // subscribe replica must not auto-write the mount.
1802            if !folder.is_writable() {
1803                fully_remove(&busy_set, &namespace);
1804                return;
1805            }
1806            match crate::sync::materialize_folder_content(&node_handle, &manager_handle, namespace, &mount_root).await {
1807                Ok(outcome) => {
1808                    if outcome.summary.materialized > 0 || outcome.summary.conflicts > 0 {
1809                        tracing::info!(
1810                            %namespace,
1811                            materialized = outcome.summary.materialized,
1812                            conflicts = outcome.summary.conflicts,
1813                            pending = outcome.summary.pending,
1814                            "reconciled folder after live sync"
1815                        );
1816                    }
1817                    // Pending blobs may finish shortly after this pass; nudge
1818                    // once more so the next trailing edge picks them up.
1819                    if outcome.summary.pending > 0 {
1820                        tokio::time::sleep(RECONCILE_SETTLE).await;
1821                        let _ = crate::sync::materialize_folder_content(
1822                            &node_handle,
1823                            &manager_handle,
1824                            namespace,
1825                            &mount_root,
1826                        )
1827                        .await;
1828                    }
1829                }
1830                Err(error) => {
1831                    tracing::warn!(%namespace, %error, "reconciler failed to materialize folder");
1832                }
1833            }
1834            fully_remove(&busy_set, &namespace);
1835        });
1836    })
1837}
1838
1839fn is_recoverable_watch_error(error: &SyncwebError) -> bool {
1840    let message = error.to_string();
1841    message.contains("file changed during import") || message.contains("input path does not exist")
1842}