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    error::{Result, SyncwebError},
18    filter::{FilterAction, FilterEngine, FilterEntry},
19    folder::{FolderManager, PublicSubscription},
20    fs::{FsWatcher, Importer},
21    gossip::TopicChannel,
22    indexing::{
23        IndexingDatabase, IndexingService, REPORT_GOSSIP_TOPIC, ReportRecord,
24        links::{MutablePointer, PrivateLink, REVOCATION_GOSSIP_TOPIC},
25        resilience::{ReplicationBudget, ResilienceConfig},
26        wot::{ATTESTATION_GOSSIP_TOPIC, Attestation},
27    },
28    net::{NetworkLogger, NetworkManager},
29    node::{gossip_service::GossipService, identity::IdentityManager, iroh_node::IrohNode},
30    schedule::ScheduleManager,
31    storage::{node_db::NodeDatabase, stats_db::StatsDatabase},
32    sync::{SubscribeParams, SyncEngine, cancel_session, is_active},
33};
34
35use crate::node::iroh_node::discovery_scope;
36
37use super::{
38    DaemonHandle, DaemonState, DaemonStatus, FolderEntry, IpcServer, ManagedPool, PidLock, current_timestamp,
39    daemon_socket_path,
40    state::{BandwidthSnapshot, DaemonStatusReport, ScheduleStatus},
41    supervisor::{IntentControls, IntentSupervisor, SupervisionOptions},
42};
43
44/// Configuration used to construct and run a daemon.
45#[derive(Clone, Debug)]
46#[non_exhaustive]
47pub struct DaemonConfig {
48    pub data_dir: PathBuf,
49    pub network: Option<String>,
50    pub sync_interval: Duration,
51    pub observation_ttl: Duration,
52    pub max_retries: u32,
53    pub backoff_base: Duration,
54    pub backoff_max: Duration,
55    pub rayon_threads: usize,
56    pub log_level: String,
57    pub log_file: Option<PathBuf>,
58    pub watch_debounce: Duration,
59    pub relay_mode: crate::node::iroh_node::RelayMode,
60    pub media_listen: Option<SocketAddr>,
61    pub discovery: crate::node::iroh_node::DiscoveryConfig,
62}
63
64impl Default for DaemonConfig {
65    fn default() -> Self {
66        Self {
67            data_dir: PathBuf::from("."),
68            network: None,
69            sync_interval: Duration::from_mins(1),
70            observation_ttl: Duration::from_hours(1),
71            max_retries: 3,
72            backoff_base: Duration::from_secs(1),
73            backoff_max: Duration::from_mins(1),
74            rayon_threads: std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get),
75            log_level: "info".to_owned(),
76            log_file: None,
77            watch_debounce: Duration::from_millis(500),
78            relay_mode: crate::node::iroh_node::RelayMode::Default,
79            media_listen: None,
80            discovery: crate::node::iroh_node::DiscoveryConfig::default(),
81        }
82    }
83}
84
85impl DaemonConfig {
86    #[must_use]
87    pub fn new(data_dir: impl Into<PathBuf>) -> Self {
88        Self {
89            data_dir: data_dir.into(),
90            ..Self::default()
91        }
92    }
93}
94
95/// Owns the Iroh node, synchronization intents, IPC server, and daemon
96/// lifecycle state.
97pub struct Daemon {
98    config: DaemonConfig,
99    node_db: NodeDatabase,
100    stats_db: StatsDatabase,
101    pid_lock: PidLock,
102    ipc_server: IpcServer,
103    intent_supervisor: IntentSupervisor,
104    folder_manager: FolderManager,
105    sync_engine: SyncEngine,
106    schedule_manager: tokio::sync::RwLock<Option<ScheduleManager>>,
107    node: Arc<IrohNode>,
108    handle: DaemonHandle,
109    sync_receiver: tokio::sync::Mutex<mpsc::UnboundedReceiver<Option<String>>>,
110    intent_tasks: Mutex<HashMap<NamespaceId, Option<JoinHandle<()>>>>,
111    intent_controls: IntentControls,
112    watchers: Mutex<HashMap<String, FsWatcher>>,
113    pending_watch_events: Mutex<HashMap<String, PendingWatch>>,
114    filter_engine: tokio::sync::RwLock<Option<FilterEngine>>,
115    archive_pool: Arc<ManagedPool>,
116    network_logger: NetworkLogger,
117    network_manager: tokio::sync::RwLock<NetworkManager>,
118    attestation_listener: tokio::sync::Mutex<Option<JoinHandle<()>>>,
119    report_listener: tokio::sync::Mutex<Option<JoinHandle<()>>>,
120    revocation_listener: tokio::sync::Mutex<Option<JoinHandle<()>>>,
121    maintenance_task: tokio::sync::Mutex<Option<JoinHandle<()>>>,
122}
123
124struct PendingWatch {
125    paths: HashMap<PathBuf, bool>,
126    ready_at: Instant,
127}
128
129type SignalTask<'a> = std::pin::Pin<std::boxed::Box<dyn std::future::Future<Output = Result<()>> + Send + 'a>>;
130
131impl Daemon {
132    async fn open_identity_and_node(
133        data_dir: &Path,
134        node_db: &NodeDatabase,
135        pid_lock: &PidLock,
136        relay_mode: crate::node::iroh_node::RelayMode,
137        discovery: crate::node::iroh_node::DiscoveryConfig,
138        member_keys: Arc<RwLock<HashSet<iroh::PublicKey>>>,
139    ) -> Result<(Arc<IrohNode>, FolderManager, SyncEngine)> {
140        let identity = IdentityManager::new(data_dir.join("identity.key")).inspect_err(|_| {
141            let _ = node_db.remove_lifecycle();
142            let _ = pid_lock.release();
143        })?;
144        let node = Arc::new(
145            IrohNode::new(identity, data_dir.join("data"), relay_mode, member_keys, discovery)
146                .await
147                .inspect_err(|_| {
148                    let _ = node_db.remove_lifecycle();
149                    let _ = pid_lock.release();
150                })?,
151        );
152        let folder_manager = FolderManager::new(&node);
153        let sync_engine = SyncEngine::new(
154            folder_manager.clone(),
155            node.blob_store().clone(),
156            node.docs_engine().clone(),
157            Some(node.topic_tracker().clone()),
158        );
159        Ok((node, folder_manager, sync_engine))
160    }
161
162    /// Open node and stats databases and acquire the process lock.
163    ///
164    /// # Errors
165    ///
166    /// Returns an error if a database cannot be opened or the lock is held by
167    /// another process.
168    fn init_databases(config: &DaemonConfig) -> Result<(NodeDatabase, StatsDatabase, PidLock)> {
169        let node_db = NodeDatabase::open(config.data_dir.join("node.db"))?;
170        let stats_db = StatsDatabase::open(config.data_dir.join("stats.db"))?;
171        let pid_lock = PidLock::new(&config.data_dir);
172        if !pid_lock.try_acquire()? {
173            return Err(SyncwebError::operation(
174                "daemon already running",
175                config.data_dir.display(),
176            ));
177        }
178        Ok((node_db, stats_db, pid_lock))
179    }
180
181    /// Load app config, schedule manager, filter engine, and write initial lifecycle state.
182    ///
183    /// # Errors
184    ///
185    /// Returns an error if loading config, filters, or saving the lifecycle fails.
186    fn load_app_state(
187        node_db: &NodeDatabase,
188        pid_lock: &PidLock,
189        data_dir: &Path,
190    ) -> Result<(Option<ScheduleManager>, Option<FilterEngine>, DaemonState)> {
191        let app_config = node_db.load_app_config().inspect_err(|_error| {
192            let _ = pid_lock.release();
193        })?;
194        let use_schedule = app_config.schedule != crate::schedule::ScheduleConfig::default();
195        let schedule_manager = match ScheduleManager::from_config(&app_config.schedule) {
196            Ok(s) if use_schedule => Some(s),
197            Ok(_) => None,
198            Err(error) => {
199                let _ = pid_lock.release();
200                return Err(error);
201            }
202        };
203        let filter_engine = node_db.load_filter_engine().inspect_err(|_error| {
204            let _ = pid_lock.release();
205        })?;
206        let initial_state = DaemonState::new(
207            std::process::id(),
208            String::new(),
209            current_timestamp(),
210            data_dir,
211            DaemonStatus::Starting,
212        );
213        node_db.save_lifecycle(&initial_state).inspect_err(|_error| {
214            let _ = pid_lock.release();
215        })?;
216        Ok((schedule_manager, filter_engine, initial_state))
217    }
218
219    /// Create a daemon, acquire its process lock, and persist its running
220    /// state.
221    ///
222    /// # Errors
223    ///
224    /// Returns an error if another daemon owns the data directory, the node
225    /// cannot be opened, or configuration cannot be parsed.
226    pub async fn new(config: DaemonConfig) -> Result<Self> {
227        std::fs::create_dir_all(&config.data_dir)?;
228        let (node_db, stats_db, pid_lock) = Self::init_databases(&config)?;
229        let (schedule_manager, filter_engine, initial_state) =
230            Self::load_app_state(&node_db, &pid_lock, &config.data_dir)?;
231
232        let member_keys: Arc<RwLock<HashSet<iroh::PublicKey>>> = {
233            let networks = node_db.list_networks()?;
234            let keys: HashSet<iroh::PublicKey> = networks.iter().flat_map(|n| n.members.iter().copied()).collect();
235            Arc::new(RwLock::new(keys))
236        };
237        let mut node_discovery = config.discovery.clone();
238        node_discovery.scope = discovery_scope(config.network.as_deref());
239        let (node, folder_manager, mut sync_engine) = match Self::open_identity_and_node(
240            &config.data_dir,
241            &node_db,
242            &pid_lock,
243            config.relay_mode.clone(),
244            node_discovery,
245            member_keys.clone(),
246        )
247        .await
248        {
249            Ok(result) => result,
250            Err(error) => {
251                let _ = node_db.remove_lifecycle();
252                let _ = pid_lock.release();
253                return Err(error);
254            }
255        };
256        sync_engine = sync_engine.with_node_db(node_db.clone());
257
258        let archive_pool = match ManagedPool::new("syncweb-archive", config.rayon_threads) {
259            Ok(value) => Arc::new(value),
260            Err(error) => {
261                let _ = node.stop().await;
262                let _ = node_db.remove_lifecycle();
263                let _ = pid_lock.release();
264                return Err(SyncwebError::operation("failed to create daemon thread pool", error));
265            }
266        };
267
268        let initial_handle = DaemonHandle::new(initial_state);
269        {
270            let mut state = initial_handle.state.write().await;
271            state.node_id = node.endpoint().id().to_string();
272            state.status = DaemonStatus::Running;
273            let running_state = state.clone();
274            drop(state);
275            if let Err(error) = node_db.save_lifecycle(&running_state) {
276                let _ = node.stop().await;
277                let _ = node_db.remove_lifecycle();
278                let _ = pid_lock.release();
279                return Err(error);
280            }
281        }
282        let (sync_sender, sync_receiver) = mpsc::unbounded_channel();
283        let handle = DaemonHandle::with_channels_and_reload(
284            initial_handle.state.clone(),
285            initial_handle.folder_registry.clone(),
286            initial_handle.shutdown_sender.clone(),
287            sync_sender,
288            initial_handle.reload_requested.clone(),
289        );
290        let resilience = match IndexingService::new(config.data_dir.join("indexing.sqlite")) {
291            Ok(indexing) => Some(indexing.resilience_service(ResilienceConfig::new(ReplicationBudget::default()))),
292            Err(error) => {
293                tracing::warn!(%error, "failed to open indexing database; resilience disabled");
294                None
295            }
296        };
297        let intent_supervisor = IntentSupervisor::new(config.max_retries, config.backoff_base, config.backoff_max);
298        let network_logger = NetworkLogger::new(stats_db.clone());
299        let local_node_id = node.endpoint().id();
300        let nm_result =
301            Self::create_network_manager(&node_db, &local_node_id, &network_logger, Arc::clone(&member_keys));
302        let network_manager = match nm_result {
303            Ok(nm) => nm,
304            Err(error) => {
305                tracing::warn!("failed to create network manager with logger: {error}");
306                NetworkManager::new(node_db.clone(), local_node_id, member_keys)?
307            }
308        };
309        let mut ipc_server = Self::build_ipc_server(&config, &handle, &node, &archive_pool, &folder_manager, &node_db);
310        ipc_server = match resilience {
311            Some(service) => ipc_server.with_resilience(service),
312            None => ipc_server,
313        };
314        ipc_server = ipc_server.with_network_manager(Arc::new(tokio::sync::RwLock::new(network_manager.clone())));
315
316        Ok(Self {
317            config,
318            node_db,
319            stats_db,
320            pid_lock,
321            ipc_server,
322            intent_supervisor,
323            folder_manager,
324            sync_engine,
325            schedule_manager: tokio::sync::RwLock::new(schedule_manager),
326            node,
327            handle,
328            sync_receiver: tokio::sync::Mutex::new(sync_receiver),
329            intent_tasks: Mutex::new(HashMap::new()),
330            intent_controls: Arc::new(Mutex::new(HashMap::new())),
331            watchers: Mutex::new(HashMap::new()),
332            pending_watch_events: Mutex::new(HashMap::new()),
333            filter_engine: tokio::sync::RwLock::new(filter_engine),
334            archive_pool,
335            network_logger,
336            network_manager: tokio::sync::RwLock::new(network_manager),
337            attestation_listener: tokio::sync::Mutex::new(None),
338            report_listener: tokio::sync::Mutex::new(None),
339            revocation_listener: tokio::sync::Mutex::new(None),
340            maintenance_task: tokio::sync::Mutex::new(None),
341        })
342    }
343
344    fn create_network_manager(
345        node_db: &NodeDatabase,
346        local_node_id: &iroh::PublicKey,
347        network_logger: &NetworkLogger,
348        member_keys: Arc<RwLock<HashSet<iroh::PublicKey>>>,
349    ) -> Result<NetworkManager> {
350        NetworkManager::with_logger(node_db.clone(), *local_node_id, network_logger.clone(), member_keys)
351    }
352
353    fn build_ipc_server(
354        config: &DaemonConfig,
355        handle: &DaemonHandle,
356        node: &Arc<IrohNode>,
357        archive_pool: &Arc<ManagedPool>,
358        folder_manager: &FolderManager,
359        node_db: &NodeDatabase,
360    ) -> IpcServer {
361        IpcServer::with_archive_context(
362            daemon_socket_path(&config.data_dir),
363            handle.clone(),
364            node.clone(),
365            archive_pool.clone(),
366        )
367        .with_folder_manager(folder_manager.clone())
368        .with_node_db(node_db.clone())
369    }
370
371    /// Run the daemon until IPC or operating-system shutdown is requested.
372    ///
373    /// # Errors
374    ///
375    /// Returns an error if an IPC listener, sync cycle, or cleanup operation
376    /// fails.
377    pub async fn run(&self) -> Result<()> {
378        let run_result = self.run_inner().await;
379        let cleanup_result = self.shutdown_resources().await;
380        match run_result {
381            Err(error) => {
382                cleanup_result?;
383                Err(error)
384            }
385            Ok(()) => cleanup_result,
386        }
387    }
388
389    /// Return a snapshot of the daemon lifecycle state.
390    pub async fn state(&self) -> DaemonState {
391        self.handle.state.read().await.clone()
392    }
393
394    /// Return the daemon's fixed archive pool.
395    #[must_use]
396    pub fn archive_pool(&self) -> &ManagedPool {
397        self.archive_pool.as_ref()
398    }
399
400    async fn run_inner(&self) -> Result<()> {
401        tracing::debug!(
402            rayon_threads = self.archive_pool.thread_count(),
403            "daemon runtime initialized"
404        );
405        self.run_initial_setup().await?;
406        let (mut server_task, media_task) = self.spawn_server_tasks();
407        let (mut shutdown, mut signal_task, mut interval, mut watch_interval, shutdown_sender) = self.runtime_timers();
408        if let Err(error) = self.run_cycle().await {
409            tracing::error!(%error, "initial daemon cycle failed");
410        }
411        self.spawn_listeners().await;
412        let result = self
413            .run_event_loop(
414                &mut server_task,
415                &mut signal_task,
416                &mut shutdown,
417                &shutdown_sender,
418                &mut interval,
419                &mut watch_interval,
420            )
421            .await;
422        send_shutdown(&shutdown_sender);
423        self.wait_for_server_tasks(server_task, media_task).await?;
424        result
425    }
426
427    async fn run_initial_setup(&self) -> Result<()> {
428        self.load_folders().await?;
429        self.reannounce_folders().await;
430        self.load_subscriptions().await?;
431        self.start_watching().await?;
432        self.subscribe_network_gossip().await;
433        self.spawn_membership_listeners().await;
434        Ok(())
435    }
436
437    fn runtime_timers(
438        &self,
439    ) -> (
440        broadcast::Receiver<()>,
441        SignalTask<'_>,
442        tokio::time::Interval,
443        tokio::time::Interval,
444        broadcast::Sender<()>,
445    ) {
446        let shutdown = self.handle.shutdown_sender.subscribe();
447        let shutdown_sender = self.handle.shutdown_sender.clone();
448        let signal_task: SignalTask<'_> = Box::pin(self.handle_signals(shutdown_sender.clone()));
449        let interval_duration = self.config.sync_interval.max(Duration::from_millis(1));
450        let interval = tokio::time::interval(interval_duration);
451        let watch_interval = tokio::time::interval(Duration::from_millis(100));
452        (shutdown, signal_task, interval, watch_interval, shutdown_sender)
453    }
454
455    async fn spawn_listeners(&self) {
456        self.spawn_attestation_listener().await;
457        self.spawn_report_listener().await;
458        self.spawn_revocation_listener().await;
459        self.spawn_maintenance_task().await;
460    }
461
462    fn spawn_server_tasks(&self) -> (JoinHandle<Result<()>>, Option<JoinHandle<Result<()>>>) {
463        let server = self.ipc_server.clone();
464        let server_task = tokio::spawn(async move { server.serve().await });
465
466        let media_task = self.config.media_listen.map(|addr| {
467            let media_srv = crate::media::MediaServer::new(addr, self.node.blob_store().clone());
468            let shutdown = self.handle.shutdown_sender.clone();
469            tokio::spawn(async move { media_srv.run(shutdown).await })
470        });
471
472        (server_task, media_task)
473    }
474
475    async fn wait_for_server_tasks(
476        &self,
477        server_task: JoinHandle<Result<()>>,
478        media_task: Option<JoinHandle<Result<()>>>,
479    ) -> Result<()> {
480        if !server_task.is_finished() {
481            match server_task.await {
482                Ok(server_result) => server_result?,
483                Err(error) => return Err(SyncwebError::operation("daemon IPC task failed", error)),
484            }
485        }
486        if let Some(task) = media_task
487            && !task.is_finished()
488            && let Err(error) = task.await
489        {
490            tracing::warn!(%error, "media server task failed");
491        }
492        Ok(())
493    }
494
495    async fn run_event_loop(
496        &self,
497        server_task: &mut JoinHandle<Result<()>>,
498        signal_task: &mut SignalTask<'_>,
499        shutdown: &mut broadcast::Receiver<()>,
500        shutdown_sender: &broadcast::Sender<()>,
501        interval: &mut tokio::time::Interval,
502        watch_interval: &mut tokio::time::Interval,
503    ) -> Result<()> {
504        loop {
505            tokio::select! {
506                signal_result = &mut *signal_task => {
507                    signal_result?;
508                    send_shutdown(shutdown_sender);
509                    break Ok(());
510                }
511                shutdown_result = shutdown.recv() => {
512                    if matches!(shutdown_result, Ok(()) | Err(broadcast::error::RecvError::Closed)) {
513                        break Ok(());
514                    }
515                }
516                server_result = &mut *server_task => {
517                    match server_result {
518                        Ok(result) => break result,
519                        Err(error) => break Err(SyncwebError::operation("daemon IPC task failed", error)),
520                    }
521                }
522                trigger = self.receive_sync_trigger() => {
523                    match trigger {
524                        Some(namespace) => self.run_trigger(namespace).await?,
525                        None => break Ok(()),
526                    }
527                }
528                _ = watch_interval.tick() => self.handle_watch_events().await?,
529                _ = interval.tick() => self.run_cycle().await?,
530            }
531        }
532    }
533
534    async fn run_cycle(&self) -> Result<()> {
535        self.reload_if_requested().await?;
536        if let Err(error) = self.load_folders().await {
537            tracing::warn!(%error, "folder load cycle failed — continuing");
538        }
539        self.start_watching().await?;
540        let statuses = self.handle.folder_registry.read().await.statuses();
541        for folder in statuses {
542            let namespace = folder
543                .namespace
544                .parse::<NamespaceId>()
545                .map_err(|error| SyncwebError::operation("invalid managed folder namespace", error))?;
546            let folder_name = (!folder.path.as_os_str().is_empty()).then(|| folder.path.to_string_lossy().into_owned());
547            let active = {
548                let schedule_manager = self.schedule_manager.read().await;
549                schedule_manager
550                    .as_ref()
551                    .is_none_or(|manager| manager.is_active(folder_name.as_deref()))
552            };
553            self.start_supervision(namespace).await?;
554            if active {
555                self.set_intent_active(namespace, true)?;
556            } else {
557                self.set_intent_active(namespace, false)?;
558            }
559        }
560        self.save_status_report().await?;
561        Ok(())
562    }
563
564    async fn run_trigger(&self, namespace: Option<String>) -> Result<()> {
565        match namespace {
566            Some(value) => {
567                let parsed_namespace = value
568                    .parse::<NamespaceId>()
569                    .map_err(|error| SyncwebError::operation("invalid sync namespace", error))?;
570                self.start_supervision(parsed_namespace).await?;
571                self.set_intent_active(parsed_namespace, true)?;
572                self.save_status_report().await?;
573                Ok(())
574            }
575            None => self.run_cycle().await,
576        }
577    }
578
579    async fn start_supervision(&self, namespace: NamespaceId) -> Result<()> {
580        {
581            let mut tasks = self
582                .intent_tasks
583                .lock()
584                .map_err(|error| SyncwebError::operation("daemon intent task mutex is poisoned", error))?;
585            tasks.retain(|_, task| task.as_ref().is_none_or(|handle| !handle.is_finished()));
586            if tasks.contains_key(&namespace) || is_active(namespace) {
587                return Ok(());
588            }
589            tasks.insert(namespace, None);
590        }
591        let network_id = {
592            let guard = self.network_manager.read().await;
593            guard.network_for_folder(&namespace)?
594        };
595        let session_id = network_id.as_ref().and_then(|net_id| {
596            self.network_logger
597                .record_sync_start(net_id, &namespace.to_string())
598                .ok()
599        });
600        let sync = self.sync_engine.clone();
601        let supervisor = self.intent_supervisor;
602        let shutdown = self.handle.shutdown_sender.subscribe();
603        let controls = self.intent_controls.clone();
604        let filter = self.filter_engine.read().await.clone();
605        let network_logger = self.network_logger.clone();
606        let folder_name = self
607            .handle
608            .folder_registry
609            .read()
610            .await
611            .statuses()
612            .into_iter()
613            .find(|status| status.namespace == namespace.to_string())
614            .and_then(|status| {
615                (!status.path.as_os_str().is_empty()).then(|| status.path.to_string_lossy().into_owned())
616            });
617        let bandwidth = self
618            .schedule_manager
619            .read()
620            .await
621            .as_ref()
622            .map(|manager| manager.current_limits(folder_name.as_deref()));
623        let params = bandwidth.map_or_else(SubscribeParams::default, |limits| {
624            SubscribeParams::default().with_bandwidth_limits(limits)
625        });
626        let (ready_sender, ready_receiver) = oneshot::channel();
627        let task = tokio::spawn(async move {
628            let result = supervisor
629                .supervise_with_controls_and_ready(
630                    &sync,
631                    namespace,
632                    params,
633                    shutdown,
634                    SupervisionOptions::with_ready(controls, filter, ready_sender),
635                )
636                .await;
637            if network_id.is_some()
638                && let Some(sid) = session_id
639            {
640                match &result {
641                    Ok(supervised) => {
642                        let has_error = supervised.last_error.is_some();
643                        let files = 0;
644                        let bytes = 0;
645                        let errors = u64::from(has_error);
646                        let status = if has_error { "failed" } else { "completed" };
647                        let _ = network_logger.record_sync_finish(sid, files, bytes, errors, status);
648                    }
649                    Err(_) => {
650                        let _ = network_logger.record_sync_finish(sid, 0, 0, 1, "failed");
651                    }
652                }
653            }
654            match &result {
655                Ok(supervised) => {
656                    if let Some(error) = &supervised.last_error {
657                        tracing::warn!(%namespace, retry_count = supervised.retry_count, %error, "supervised intent stopped");
658                    }
659                }
660                Err(error) => tracing::error!(%namespace, %error, "supervised intent failed"),
661            }
662        });
663        self.intent_tasks
664            .lock()
665            .map_err(|error| SyncwebError::operation("daemon intent task mutex is poisoned", error))?
666            .insert(namespace, Some(task));
667        ready_receiver
668            .await
669            .map_err(|error| SyncwebError::operation("daemon intent startup notification failed", error))?;
670        Ok(())
671    }
672
673    fn set_intent_active(&self, namespace: NamespaceId, active: bool) -> Result<()> {
674        let control = self
675            .intent_controls
676            .lock()
677            .map_err(|error| SyncwebError::operation("daemon intent control mutex is poisoned", error))?
678            .get(&namespace)
679            .cloned();
680        let Some(intent_control) = control else {
681            return Ok(());
682        };
683        let result = if active {
684            intent_control.resume()
685        } else {
686            intent_control.pause()
687        };
688        result.map_err(|error| SyncwebError::operation("failed to update scheduled intent", error))
689    }
690
691    /// Return whether the global schedule is currently active.
692    pub async fn is_in_active_window(&self) -> bool {
693        self.schedule_manager
694            .read()
695            .await
696            .as_ref()
697            .is_none_or(|manager| manager.is_active(None))
698    }
699
700    /// Return the configured download limit at the current wall-clock time.
701    pub async fn current_bandwidth_limit(&self) -> Option<u64> {
702        self.schedule_manager.read().await.as_ref().and_then(|manager| {
703            let limits = manager.current_limits(None);
704            limits.max_download.or(limits.max_upload)
705        })
706    }
707
708    async fn start_watching(&self) -> Result<()> {
709        let statuses = self.handle.folder_registry.read().await.statuses();
710        let wanted: HashMap<_, _> = statuses
711            .iter()
712            .filter(|status| !status.path.as_os_str().is_empty())
713            .map(|status| (status.namespace.clone(), status.path.clone()))
714            .collect();
715        let mut watchers = self
716            .watchers
717            .lock()
718            .map_err(|error| SyncwebError::operation("daemon watcher mutex is poisoned", error))?;
719        watchers.retain(|namespace, _| wanted.contains_key(namespace));
720        for (namespace, path) in wanted {
721            if watchers.contains_key(&namespace) {
722                continue;
723            }
724            if !path.exists() {
725                tracing::warn!(%namespace, path = %path.display(), "managed folder path does not exist; watcher deferred");
726                continue;
727            }
728            watchers.insert(namespace, FsWatcher::new(&path)?);
729        }
730        drop(watchers);
731        Ok(())
732    }
733
734    async fn handle_watch_events(&self) -> Result<()> {
735        let mut observed = Vec::new();
736        {
737            let mut watchers = self
738                .watchers
739                .lock()
740                .map_err(|error| SyncwebError::operation("daemon watcher mutex is poisoned", error))?;
741            for (namespace, watcher) in watchers.iter_mut() {
742                loop {
743                    match watcher.try_recv() {
744                        Ok(Some(event)) => observed.push((namespace.clone(), event)),
745                        Ok(None) => break,
746                        Err(error) => {
747                            tracing::warn!(%namespace, %error, "filesystem watcher event channel failed");
748                            break;
749                        }
750                    }
751                }
752            }
753        }
754
755        if !observed.is_empty() {
756            let mut pending = self
757                .pending_watch_events
758                .lock()
759                .map_err(|error| SyncwebError::operation("daemon watch queue mutex is poisoned", error))?;
760            let ready_at = Instant::now()
761                .checked_add(self.config.watch_debounce)
762                .unwrap_or_else(Instant::now);
763            for (namespace, event) in observed {
764                let removed = matches!(event.event.kind, notify::EventKind::Remove(_));
765                let entry = pending.entry(namespace).or_insert_with(|| PendingWatch {
766                    paths: HashMap::new(),
767                    ready_at,
768                });
769                entry.ready_at = ready_at;
770                for path in event.paths {
771                    entry.paths.insert(path, removed);
772                }
773            }
774            drop(pending);
775        }
776
777        let ready = {
778            let mut pending = self
779                .pending_watch_events
780                .lock()
781                .map_err(|error| SyncwebError::operation("daemon watch queue mutex is poisoned", error))?;
782            let now = Instant::now();
783            pending
784                .iter_mut()
785                .filter_map(|(namespace, batch)| {
786                    (batch.ready_at <= now).then(|| (namespace.clone(), std::mem::take(&mut batch.paths)))
787                })
788                .filter(|(_, paths)| !paths.is_empty())
789                .collect::<Vec<_>>()
790        };
791        if ready.is_empty() {
792            return Ok(());
793        }
794
795        let roots: HashMap<_, _> = self
796            .handle
797            .folder_registry
798            .read()
799            .await
800            .statuses()
801            .into_iter()
802            .map(|status| (status.namespace, status.path))
803            .collect();
804        for (namespace, paths) in ready {
805            let Some(root) = roots.get(&namespace) else {
806                continue;
807            };
808            let namespace_id = namespace
809                .parse::<NamespaceId>()
810                .map_err(|error| SyncwebError::operation("invalid watched folder namespace", error))?;
811            for (path, removed) in paths {
812                self.process_watch_event(namespace_id, root, &path, removed).await?;
813            }
814        }
815        self.save_status_report().await?;
816        Ok(())
817    }
818
819    async fn process_watch_event(&self, namespace: NamespaceId, root: &Path, path: &Path, removed: bool) -> Result<()> {
820        let relative = path.strip_prefix(root).unwrap_or(path);
821        if relative.as_os_str().is_empty() {
822            return Ok(());
823        }
824        let size = std::fs::metadata(path).map_or(0, |metadata| metadata.len());
825        let accepted = self.filter_engine.read().await.as_ref().is_none_or(|filter| {
826            filter.evaluate_for_folder(&namespace.to_string(), &FilterEntry::new(relative.to_path_buf(), size))
827                != FilterAction::Reject
828        });
829        if !accepted {
830            return Ok(());
831        }
832
833        let folder = self.folder_manager.get(namespace).await?;
834        let result = if removed || !path.exists() {
835            folder
836                .delete_entry(relative.as_os_str().as_encoded_bytes())
837                .await
838                .map(|()| 1_u64)
839        } else if path.is_file() {
840            let importer = Importer::new(
841                self.node.blob_store().clone(),
842                self.node.docs_engine().clone(),
843                folder.doc().clone(),
844                folder.author(),
845            )
846            .with_root(root);
847            importer
848                .import_path(path)
849                .await
850                .map(|entries| u64::try_from(entries.len()).unwrap_or(u64::MAX))
851        } else {
852            Ok(0)
853        };
854        match result {
855            Ok(entries) if entries > 0 => {
856                self.handle
857                    .folder_registry
858                    .write()
859                    .await
860                    .record_import(namespace, entries, current_timestamp());
861                self.start_supervision(namespace).await?;
862            }
863            Ok(_) => {}
864            Err(error) => {
865                self.handle
866                    .folder_registry
867                    .write()
868                    .await
869                    .record_error(namespace, error.to_string());
870                tracing::warn!(%namespace, path = %path.display(), %error, "filesystem change will be retried");
871                if !removed && is_recoverable_watch_error(&error) {
872                    let mut pending = self.pending_watch_events.lock().map_err(|poisoned| {
873                        SyncwebError::operation("daemon watch queue mutex is poisoned", poisoned)
874                    })?;
875                    let ready_at = Instant::now()
876                        .checked_add(self.config.watch_debounce)
877                        .unwrap_or_else(Instant::now);
878                    let entry = pending.entry(namespace.to_string()).or_insert_with(|| PendingWatch {
879                        paths: HashMap::new(),
880                        ready_at,
881                    });
882                    entry.ready_at = ready_at;
883                    entry.paths.insert(path.to_path_buf(), false);
884                    drop(pending);
885                }
886            }
887        }
888        Ok(())
889    }
890
891    async fn save_status_report(&self) -> Result<()> {
892        let state = self.handle.state.read().await.clone();
893        let statuses = self.handle.folder_registry.read().await.statuses();
894        let schedule = self.schedule_manager.read().await.clone();
895        let schedule_report = schedule.as_ref().map(|manager| {
896            let minute = crate::schedule::current_minute();
897            let next = manager.next_active_window_start_at(None, minute);
898            let next_window_start = next.map(|next_minute| {
899                let offset = if next_minute >= minute {
900                    next_minute.saturating_sub(minute)
901                } else {
902                    1_440_u16.saturating_sub(minute).saturating_add(next_minute)
903                };
904                current_timestamp().saturating_add(u64::from(offset).saturating_mul(60))
905            });
906            ScheduleStatus {
907                in_active_window: manager.is_active(None),
908                next_window_start,
909            }
910        });
911        let current_stats = self.stats_db.current_stats().unwrap_or_default();
912        let report = DaemonStatusReport {
913            pid: state.pid,
914            node_id: state.node_id,
915            started_at: state.started_at,
916            uptime_seconds: current_timestamp().saturating_sub(state.started_at),
917            folders: statuses.into_iter().collect(),
918            bandwidth: BandwidthSnapshot {
919                upload_total: current_stats.total_upload,
920                download_total: current_stats.total_download,
921                upload_rate: 0,
922                download_rate: 0,
923            },
924            schedule: schedule_report,
925            rayon_threads: self.archive_pool.thread_count(),
926        };
927        self.node_db.save_status(&report)
928    }
929
930    async fn load_folders(&self) -> Result<()> {
931        let folders = self.folder_manager.list().await?;
932        let mut registry = self.handle.folder_registry.write().await;
933        for folder in folders {
934            let namespace_key = folder.namespace_id().to_string();
935            if registry.is_removed(&namespace_key) {
936                continue;
937            }
938            if !registry
939                .statuses()
940                .iter()
941                .any(|status| status.namespace == namespace_key)
942            {
943                registry.add(FolderEntry::new(folder.namespace_id(), PathBuf::new()))?;
944            }
945        }
946        drop(registry);
947        Ok(())
948    }
949
950    async fn reannounce_folders(&self) {
951        let Ok(folders) = self.folder_manager.list().await else {
952            return;
953        };
954        for folder in &folders {
955            self.folder_manager.announce_namespace(folder.namespace_id()).await;
956        }
957    }
958
959    async fn load_subscriptions(&self) -> Result<()> {
960        let subscriptions = self.node_db.load_subscriptions_with_sizes()?;
961        if subscriptions.is_empty() {
962            return Ok(());
963        }
964        let hashes: Vec<_> = subscriptions.iter().map(|(hash, _)| *hash).collect();
965        self.folder_manager.seed_subscriptions(hashes).await;
966
967        let mut registry = self.handle.folder_registry.write().await;
968        for (hash, size) in subscriptions {
969            let subscription = PublicSubscription::new(hash, None, size);
970            registry.add_subscription(subscription);
971        }
972        drop(registry);
973        Ok(())
974    }
975
976    async fn reload_if_requested(&self) -> Result<()> {
977        if !self
978            .handle
979            .reload_requested
980            .swap(false, std::sync::atomic::Ordering::AcqRel)
981        {
982            return Ok(());
983        }
984        let app_config = self.node_db.load_app_config()?;
985        let parsed_schedule = ScheduleManager::from_config(&app_config.schedule)?;
986        let schedule_manager =
987            (app_config.schedule != crate::schedule::ScheduleConfig::default()).then_some(parsed_schedule);
988        let filter = self.node_db.load_filter_engine()?;
989        *self.schedule_manager.write().await = schedule_manager;
990        *self.filter_engine.write().await = filter;
991        let statuses = self.handle.folder_registry.read().await.statuses();
992        for status in statuses {
993            if let Ok(namespace) = status.namespace.parse::<NamespaceId>()
994                && is_active(namespace)
995                && !cancel_session(namespace)
996            {
997                tracing::warn!(%namespace, "reloaded intent did not accept cancellation");
998            }
999        }
1000        tracing::info!("daemon configuration reloaded");
1001        Ok(())
1002    }
1003
1004    async fn receive_sync_trigger(&self) -> Option<Option<String>> {
1005        self.sync_receiver.lock().await.recv().await
1006    }
1007
1008    async fn shutdown_resources(&self) -> Result<()> {
1009        self.handle.set_status(DaemonStatus::Stopping).await;
1010        let stopping_state = self.handle.state.read().await.clone();
1011        self.node_db.save_lifecycle(&stopping_state)?;
1012        self.save_status_report().await?;
1013
1014        self.cancel_active_sessions().await;
1015        self.join_intent_tasks().await?;
1016        self.abort_listeners().await;
1017        self.finalize_node_stop().await
1018    }
1019
1020    async fn cancel_active_sessions(&self) {
1021        let namespaces: Vec<_> = self
1022            .handle
1023            .folder_registry
1024            .read()
1025            .await
1026            .statuses()
1027            .into_iter()
1028            .filter_map(|status| status.namespace.parse::<NamespaceId>().ok())
1029            .collect();
1030        for namespace in namespaces {
1031            if is_active(namespace) && !cancel_session(namespace) {
1032                tracing::warn!(%namespace, "intent did not accept shutdown cancellation");
1033            }
1034        }
1035    }
1036
1037    async fn join_intent_tasks(&self) -> Result<()> {
1038        let tasks = {
1039            let mut task_map = self
1040                .intent_tasks
1041                .lock()
1042                .map_err(|error| SyncwebError::operation("daemon intent task mutex is poisoned", error))?;
1043            std::mem::take(&mut *task_map)
1044        };
1045        for (namespace, task) in tasks {
1046            if let Some(handle) = task {
1047                match tokio::time::timeout(Duration::from_secs(10), handle).await {
1048                    Ok(Ok(())) => {}
1049                    Ok(Err(error)) => {
1050                        tracing::warn!(%namespace, %error, "daemon intent task failed");
1051                    }
1052                    Err(_) => {
1053                        tracing::warn!(%namespace, "daemon intent task did not shut down within 10s timeout");
1054                    }
1055                }
1056            }
1057        }
1058        Ok(())
1059    }
1060
1061    async fn abort_listeners(&self) {
1062        let attestation_handle = self.attestation_listener.lock().await.take();
1063        if let Some(inner) = attestation_handle {
1064            inner.abort();
1065        }
1066        let report_handle = self.report_listener.lock().await.take();
1067        if let Some(inner) = report_handle {
1068            inner.abort();
1069        }
1070        let revocation_handle = self.revocation_listener.lock().await.take();
1071        if let Some(inner) = revocation_handle {
1072            inner.abort();
1073        }
1074        let maintenance_handle = self.maintenance_task.lock().await.take();
1075        if let Some(inner) = maintenance_handle {
1076            inner.abort();
1077        }
1078    }
1079
1080    async fn finalize_node_stop(&self) -> Result<()> {
1081        let node_result = tokio::time::timeout(Duration::from_secs(30), self.node.stop()).await;
1082        self.handle.set_status(DaemonStatus::Stopped).await;
1083        let remove_result = self.node_db.remove_lifecycle();
1084        let remove_status_result = self.node_db.remove_status();
1085        let release_result = self.pid_lock.release();
1086        match node_result {
1087            Ok(Ok(())) => {}
1088            Ok(Err(error)) => tracing::warn!(%error, "node stop reported an error"),
1089            Err(_) => tracing::warn!("node stop timed out after 30s"),
1090        }
1091        remove_result?;
1092        remove_status_result?;
1093        release_result?;
1094        Ok(())
1095    }
1096
1097    async fn spawn_attestation_listener(&self) {
1098        let gossip_service = self.node.gossip_service().clone();
1099        let data_dir = self.config.data_dir.clone();
1100        let shutdown = self.handle.shutdown_sender.subscribe();
1101        let handle = tokio::spawn(async move {
1102            if let Err(error) = listen_for_attestations(gossip_service, data_dir, shutdown).await {
1103                tracing::error!(%error, "attestation gossip listener failed");
1104            }
1105        });
1106        *self.attestation_listener.lock().await = Some(handle);
1107    }
1108
1109    async fn spawn_report_listener(&self) {
1110        let gossip_service = self.node.gossip_service().clone();
1111        let data_dir = self.config.data_dir.clone();
1112        let shutdown = self.handle.shutdown_sender.subscribe();
1113        let handle = tokio::spawn(async move {
1114            if let Err(error) = listen_for_reports(gossip_service, data_dir, shutdown).await {
1115                tracing::error!(%error, "report gossip listener failed");
1116            }
1117        });
1118        *self.report_listener.lock().await = Some(handle);
1119    }
1120
1121    async fn spawn_revocation_listener(&self) {
1122        let gossip_service = self.node.gossip_service().clone();
1123        let data_dir = self.config.data_dir.clone();
1124        let shutdown = self.handle.shutdown_sender.subscribe();
1125        let handle = tokio::spawn(async move {
1126            if let Err(error) = listen_for_revocations(gossip_service, data_dir, shutdown).await {
1127                tracing::error!(%error, "revocation gossip listener failed");
1128            }
1129        });
1130        *self.revocation_listener.lock().await = Some(handle);
1131    }
1132
1133    /// Spawn a background task that periodically vacuums databases with
1134    /// excessive freelist pages.
1135    async fn spawn_maintenance_task(&self) {
1136        let node_db = self.node_db.clone();
1137        let stats_db = self.stats_db.clone();
1138        let mut shutdown = self.handle.shutdown_sender.subscribe();
1139        let handle: tokio::task::JoinHandle<()> = tokio::spawn(async move {
1140            let mut interval = tokio::time::interval(Duration::from_hours(24));
1141            loop {
1142                tokio::select! {
1143                    _ = shutdown.recv() => {
1144                        tracing::info!("maintenance task shutting down");
1145                        break;
1146                    }
1147                    _ = interval.tick() => {
1148                        let dbs: [&dyn crate::storage::Vacuumable; 2] = [&node_db, &stats_db];
1149                        for db in dbs {
1150                            match db.freelist_count() {
1151                                Ok(count) if count > 100 => {
1152                                    tracing::info!(freelist = %count, "running vacuum");
1153                                    if let Err(error) = db.vacuum() {
1154                                        tracing::warn!(%error, "maintenance vacuum failed");
1155                                    }
1156                                }
1157                                Ok(_) => {}
1158                                Err(error) => tracing::warn!(%error, "maintenance freelist query failed"),
1159                            }
1160                        }
1161                    }
1162                }
1163            }
1164        });
1165        *self.maintenance_task.lock().await = Some(handle);
1166    }
1167
1168    async fn subscribe_network_gossip(&self) {
1169        let networks: Vec<_> = {
1170            let guard = self.network_manager.read().await;
1171            guard.list().into_iter().cloned().collect::<Vec<_>>()
1172        };
1173        for network in &networks {
1174            let network_id = network.id;
1175            let topic = network.topic;
1176            let members: Vec<_> = network
1177                .members
1178                .iter()
1179                .copied()
1180                .filter(|m| *m != self.node.endpoint().id())
1181                .collect();
1182            match self.node.gossip_service().subscribe(topic, members).await {
1183                Ok(_topic) => {
1184                    tracing::debug!(%network_id, "subscribed to network gossip topic");
1185                }
1186                Err(error) => {
1187                    tracing::warn!(%network_id, %error, "failed to subscribe to network gossip topic");
1188                }
1189            }
1190        }
1191    }
1192
1193    async fn spawn_membership_listeners(&self) {
1194        let networks: Vec<_> = {
1195            let guard = self.network_manager.read().await;
1196            guard.list().into_iter().cloned().collect::<Vec<_>>()
1197        };
1198        let docs_engine = self.node.docs_engine().clone();
1199        let blob_store = self.node.blob_store().clone();
1200        let shutdown = self.handle.shutdown_sender.subscribe();
1201        let local_pk = self.node.endpoint().id();
1202        let node_db = self.node_db.clone();
1203        for network in networks {
1204            let Some(ref doc_ticket_str) = network.doc_ticket else {
1205                continue;
1206            };
1207            let Ok(doc_ticket) = doc_ticket_str.parse::<iroh_docs::DocTicket>() else {
1208                tracing::warn!(network_id = %network.id, "invalid doc_ticket in network");
1209                continue;
1210            };
1211            let de = docs_engine.clone();
1212            let bs = blob_store.clone();
1213            let mut member_shutdown = shutdown.resubscribe();
1214            let network_id = network.id;
1215            let local_key_str = local_pk.to_string();
1216            let db = node_db.clone();
1217            tokio::spawn(async move {
1218                let doc = match de.import_ticket(doc_ticket).await {
1219                    Ok(doc) => doc,
1220                    Err(error) => {
1221                        tracing::warn!(%network_id, %error, "failed to import membership doc from ticket");
1222                        return;
1223                    }
1224                };
1225                if let Err(error) = de.start_sync(&doc, Vec::new()).await {
1226                    tracing::warn!(%network_id, %error, "failed to start membership doc sync");
1227                }
1228                let mut stream = match de.watch(&doc).await {
1229                    Ok(stream) => stream,
1230                    Err(error) => {
1231                        tracing::warn!(%network_id, %error, "failed to watch membership doc");
1232                        return;
1233                    }
1234                };
1235                loop {
1236                    tokio::select! {
1237                        _ = member_shutdown.recv() => {
1238                            tracing::debug!(%network_id, "membership listener shutting down");
1239                            break;
1240                        }
1241                        event = stream.next() => {
1242                            let Some(event_result) = event else {
1243                                break;
1244                            };
1245                            match event_result {
1246                                Ok(iroh_docs::engine::LiveEvent::InsertLocal { entry } | iroh_docs::engine::LiveEvent::InsertRemote { entry, .. }) => {
1247                                    if entry.key() == b"sys/network/members" {
1248                                        let hash = entry.content_hash();
1249                                        let Ok(content) = bs.get(hash).await else { continue; };
1250                                        match serde_json::from_slice::<crate::net::membership_doc::SignedMemberList>(&content) {
1251                                            Ok(member_list) => {
1252                                                if let Err(error) = member_list.verify() {
1253                                                    tracing::warn!(%network_id, %error, "invalid membership list signature");
1254                                                    continue;
1255                                                }
1256                                                let is_member = member_list.members.iter().any(|m| m.key == local_key_str);
1257                                                if is_member {
1258                                                    tracing::debug!(%network_id, members = member_list.members.len(), "membership updated");
1259                                                } else {
1260                                                    tracing::warn!(%network_id, "local node was removed from network");
1261                                                    let _ = db.delete_network(network_id);
1262                                                }
1263                                            }
1264                                            Err(error) => {
1265                                                tracing::warn!(%network_id, %error, "failed to parse membership list");
1266                                            }
1267                                        }
1268                                    }
1269                                }
1270                                Ok(_) => {}
1271                                Err(error) => {
1272                                    tracing::warn!(%network_id, %error, "membership doc event error");
1273                                }
1274                            }
1275                        }
1276                    }
1277                }
1278            });
1279        }
1280    }
1281
1282    async fn handle_signals(&self, shutdown: broadcast::Sender<()>) -> Result<()> {
1283        #[cfg(unix)]
1284        {
1285            let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
1286            tokio::select! {
1287                result = tokio::signal::ctrl_c() => result?,
1288                _ = terminate.recv() => {}
1289            }
1290        }
1291        #[cfg(not(unix))]
1292        {
1293            tokio::signal::ctrl_c().await?;
1294        }
1295        send_shutdown(&shutdown);
1296        Ok(())
1297    }
1298}
1299
1300impl Drop for Daemon {
1301    fn drop(&mut self) {
1302        if let Err(error) = self.node_db.remove_lifecycle() {
1303            tracing::warn!(%error, "failed to remove daemon lifecycle");
1304        }
1305        if let Err(error) = self.node_db.remove_status() {
1306            tracing::warn!(%error, "failed to remove daemon status");
1307        }
1308    }
1309}
1310
1311fn send_shutdown(sender: &broadcast::Sender<()>) {
1312    match sender.send(()) {
1313        Ok(_) | Err(broadcast::error::SendError(())) => {}
1314    }
1315}
1316
1317fn is_recoverable_watch_error(error: &SyncwebError) -> bool {
1318    let message = error.to_string();
1319    message.contains("file changed during import") || message.contains("input path does not exist")
1320}
1321
1322fn attestation_topic_id() -> iroh_gossip::TopicId {
1323    iroh_gossip::TopicId::from_bytes(*blake3::hash(ATTESTATION_GOSSIP_TOPIC).as_bytes())
1324}
1325
1326fn persist_incoming_attestation(att: &Attestation, db: &IndexingDatabase, existing: &mut Vec<Attestation>) {
1327    if existing.contains(att) {
1328        return;
1329    }
1330    existing.push(att.clone());
1331    if let Err(error) = db.save_attestations(existing) {
1332        tracing::warn!(%error, "failed to persist incoming attestation");
1333    }
1334    tracing::debug!(
1335        content = %att.content,
1336        issuer = %att.issuer,
1337        kind = %att.kind,
1338        "attestation received via gossip"
1339    );
1340}
1341
1342async fn listen_for_attestations(
1343    gossip_service: GossipService,
1344    data_dir: PathBuf,
1345    mut shutdown: broadcast::Receiver<()>,
1346) -> Result<()> {
1347    let topic = gossip_service
1348        .subscribe_and_join(attestation_topic_id(), Vec::new())
1349        .await?;
1350    let (sender, receiver) = GossipService::split(topic);
1351    let topic_channel = TopicChannel::<Attestation>::new(
1352        Arc::new(gossip_service.inner().clone()),
1353        ATTESTATION_GOSSIP_TOPIC,
1354        sender,
1355    );
1356    let mut stream = topic_channel.receive_from(receiver);
1357    let db = IndexingDatabase::open(data_dir.join("indexing.sqlite"))?;
1358    let mut existing = db.load_attestations()?;
1359
1360    loop {
1361        tokio::select! {
1362            _ = shutdown.recv() => {
1363                tracing::info!("attestation listener shutting down");
1364                break Ok(());
1365            }
1366            msg = stream.next() => {
1367                let Some(att) = msg else {
1368                    break Ok(());
1369                };
1370                persist_incoming_attestation(&att, &db, &mut existing);
1371            }
1372        }
1373    }
1374}
1375
1376fn report_topic_id() -> iroh_gossip::TopicId {
1377    iroh_gossip::TopicId::from_bytes(*blake3::hash(REPORT_GOSSIP_TOPIC).as_bytes())
1378}
1379
1380fn revocation_topic_id() -> iroh_gossip::TopicId {
1381    iroh_gossip::TopicId::from_bytes(*blake3::hash(REVOCATION_GOSSIP_TOPIC).as_bytes())
1382}
1383
1384async fn listen_for_revocations(
1385    gossip_service: GossipService,
1386    data_dir: PathBuf,
1387    mut shutdown: broadcast::Receiver<()>,
1388) -> Result<()> {
1389    let topic = gossip_service
1390        .subscribe_and_join(revocation_topic_id(), Vec::new())
1391        .await?;
1392    let (sender, receiver) = GossipService::split(topic);
1393    let topic_channel = TopicChannel::<PrivateLink>::new(
1394        Arc::new(gossip_service.inner().clone()),
1395        REVOCATION_GOSSIP_TOPIC,
1396        sender,
1397    );
1398    let mut stream = topic_channel.receive_from(receiver);
1399    let db = IndexingDatabase::open(data_dir.join("indexing.sqlite"))?;
1400    let (pointers, mirrors, mut revoked) = db.load_links()?;
1401
1402    loop {
1403        tokio::select! {
1404            _ = shutdown.recv() => {
1405                tracing::info!("revocation listener shutting down");
1406                break Ok(());
1407            }
1408            msg = stream.next() => {
1409                handle_revocation_message(msg, &mut revoked, &pointers, &mirrors, &db);
1410            }
1411        }
1412    }
1413}
1414
1415fn handle_revocation_message(
1416    msg: Option<PrivateLink>,
1417    revoked: &mut Vec<PrivateLink>,
1418    pointers: &[MutablePointer],
1419    mirrors: &[String],
1420    db: &IndexingDatabase,
1421) {
1422    let Some(revocation) = msg else {
1423        return;
1424    };
1425    if !revoked.contains(&revocation) {
1426        revoked.push(revocation.clone());
1427        tracing::debug!(
1428            manifest = %revocation.manifest,
1429            "revocation received via gossip"
1430        );
1431        if let Err(error) = db.save_links(pointers, mirrors, revoked) {
1432            tracing::warn!(%error, "failed to persist incoming revocation");
1433        }
1434    }
1435}
1436
1437async fn listen_for_reports(
1438    gossip_service: GossipService,
1439    data_dir: PathBuf,
1440    mut shutdown: broadcast::Receiver<()>,
1441) -> Result<()> {
1442    let topic = gossip_service.subscribe_and_join(report_topic_id(), Vec::new()).await?;
1443    let (sender, receiver) = GossipService::split(topic);
1444    let topic_channel =
1445        TopicChannel::<ReportRecord>::new(Arc::new(gossip_service.inner().clone()), REPORT_GOSSIP_TOPIC, sender);
1446    let mut stream = topic_channel.receive_from(receiver);
1447    let db = IndexingDatabase::open(data_dir.join("indexing.sqlite"))?;
1448    let mut existing = db.load_content_reports()?;
1449
1450    loop {
1451        tokio::select! {
1452            _ = shutdown.recv() => {
1453                tracing::info!("report listener shutting down");
1454                break Ok(());
1455            }
1456            msg = stream.next() => {
1457                if !handle_report_message(msg, &mut existing, &db) {
1458                    break Ok(());
1459                }
1460            }
1461        }
1462    }
1463}
1464
1465fn handle_report_message(msg: Option<ReportRecord>, existing: &mut Vec<ReportRecord>, db: &IndexingDatabase) -> bool {
1466    let Some(report) = msg else {
1467        return false;
1468    };
1469    tracing::debug!(
1470        content = %report.content,
1471        reason = %report.reason,
1472        "report received via gossip"
1473    );
1474    if !existing.contains(&report) {
1475        existing.push(report);
1476        if let Err(error) = db.save_content_reports(existing) {
1477            tracing::warn!(%error, "failed to persist incoming report");
1478        }
1479    }
1480    true
1481}