Skip to main content

koan_core/helpers/
watch.rs

1//! The library watch and auto-sync threads, and the lock that lets one process at a time run them.
2
3use std::path::{Path, PathBuf};
4use std::sync::atomic::{AtomicU64, Ordering};
5
6use crate::config::Config;
7use crate::db::connection::Database;
8use crate::index::scanner::ScanResult;
9
10use super::*;
11
12/// The right to scan and watch the library at `db_path`, held by one process
13/// at a time: an exclusive `flock` on `watch.lock` beside the database. Two
14/// servers share a database while one replaces the other, and two scanners
15/// would only contend for its write lock. `None` while another process holds
16/// it. The kernel drops the lock when the process ends, however it ends, so a
17/// killed server cannot keep it.
18pub fn try_watch_lock(db_path: &Path) -> std::io::Result<Option<std::fs::File>> {
19    let file = watch_lock_file(db_path)?;
20    match file.try_lock() {
21        Ok(()) => Ok(Some(file)),
22        Err(std::fs::TryLockError::WouldBlock) => Ok(None),
23        Err(std::fs::TryLockError::Error(e)) => Err(e),
24    }
25}
26
27/// [`try_watch_lock`], waiting as long as another process holds it. The
28/// kernel wakes the waiter when the holder lets go; nothing polls.
29pub fn watch_lock(db_path: &Path) -> std::io::Result<std::fs::File> {
30    let file = watch_lock_file(db_path)?;
31    file.lock()?;
32    Ok(file)
33}
34
35fn watch_lock_file(db_path: &Path) -> std::io::Result<std::fs::File> {
36    std::fs::File::options()
37        .create(true)
38        .truncate(false)
39        .write(true)
40        .open(db_path.with_file_name("watch.lock"))
41}
42
43/// What the library watch reports about a scan it runs.
44pub enum WatchScan<'a> {
45    /// A scan is starting, and why: `start-up`, `folder change`, `periodic`
46    /// or `new folder`. It may yet wait for another scan to leave the lane.
47    Started(&'static str),
48    /// Files whose tags were read, of those found so far to need reading.
49    Read {
50        done: u64,
51        total: u64,
52    },
53    Finished(&'a ScanResult),
54}
55
56/// Index files that appear in the library folders while koan is running.
57///
58/// One incremental scan shortly after startup — the walk is a fraction of a
59/// second even across fifty thousand files, and everything unchanged is skipped
60/// on its mtime and size — then a scan of whatever the folders say changed.
61///
62/// Only the directories events name are scanned: walking the whole library for
63/// one new album costs a spinning disk minutes. Events that cannot change the
64/// index — access, metadata, Syncthing's bookkeeping, partial downloads — are
65/// dropped before they count (see `index::watch`). The whole library is scanned
66/// only when the watcher reports it lost events, or when so many directories
67/// changed at once that walking them one by one would cost more.
68///
69/// Changes are debounced: copying an album in produces a burst of events, and
70/// scanning once per file would be both slow and pointless. A scan that lands
71/// halfway through a move is corrected by the scan the rest of the move's
72/// events bring, since a directory scan removes what is no longer under it.
73///
74/// The folder list is re-read and each folder's identity checked every half
75/// minute, so a folder added in settings is watched without a restart, and a
76/// volume unmounted and mounted again is watched afresh and rescanned.
77///
78/// `on_scan` is told when each scan starts, how far it has got and how it
79/// ended, so a UI can show it.
80pub fn spawn_library_watch(
81    db_path: std::path::PathBuf,
82    on_scan: impl Fn(WatchScan) + Send + Sync + 'static,
83) -> Option<std::thread::JoinHandle<()>> {
84    use std::collections::BTreeSet;
85    use std::time::{Duration, Instant};
86
87    use notify::{RecursiveMode, Watcher};
88
89    use crate::index::scanner::{self, ScanOptions};
90    use crate::index::watch::{WatchedRoot, scan_target};
91
92    // Copying an album in is a burst of events. Wait for it to stop before
93    // scanning, rather than scanning per file.
94    const SETTLE: Duration = Duration::from_secs(5);
95    // How often the folder list and each folder's identity are checked.
96    const CHECK: Duration = Duration::from_secs(30);
97    // Past this many directories one walk of the library is cheaper than many.
98    const MAX_DIRS: usize = 200;
99    // A full scan now and then, for the events a platform drops without
100    // asking for a rescan. Unchanged files are skipped on mtime and size, so an
101    // idle one is a second's work.
102    const RESCAN: Duration = Duration::from_secs(15 * 60);
103    // Files read between progress reports.
104    const REPORT_EVERY: u64 = 50;
105
106    std::thread::Builder::new()
107        .name("koan-library-watch".into())
108        .spawn(move || {
109            // Another process watching this library, as an outgoing server
110            // does while its replacement starts, keeps the watch until it
111            // exits; this thread sleeps in the kernel until then. Serving
112            // does not wait for it. A directory that cannot hold the lock
113            // file is one koan is not sharing, so it watches as before.
114            let _lock = match try_watch_lock(&db_path) {
115                Ok(Some(lock)) => Some(lock),
116                Ok(None) => {
117                    log::info!(
118                        "another koan is watching this library; serving, and waiting to take over"
119                    );
120                    let lock = watch_lock(&db_path);
121                    if lock.is_ok() {
122                        log::info!("library watch: taken over");
123                    }
124                    lock.inspect_err(|e| log::warn!("library watch: lock: {e}"))
125                        .ok()
126                }
127                Err(e) => {
128                    log::warn!("library watch: no lock beside the database: {e}");
129                    None
130                }
131            };
132            let scan = |reason: &'static str, folders: &[PathBuf], dirs: Option<&[PathBuf]>| {
133                if folders.is_empty() {
134                    return;
135                }
136                let Ok(db) = Database::open_existing(&db_path) else {
137                    return;
138                };
139                // A newer koan sharing the database has upgraded it: what
140                // this build would write is no longer its shape.
141                if crate::db::pool::understood(&db.conn).is_err() {
142                    return;
143                }
144                on_scan(WatchScan::Started(reason));
145                let to_read = std::sync::Arc::new(AtomicU64::new(0));
146                let opts = ScanOptions {
147                    to_read: Some(to_read.clone()),
148                    ..Default::default()
149                };
150                let read = AtomicU64::new(0);
151                let on_track = |_: scanner::ScanEvent| {
152                    let done = read.fetch_add(1, Ordering::Relaxed) + 1;
153                    if done.is_multiple_of(REPORT_EVERY) {
154                        let total = to_read.load(Ordering::Relaxed);
155                        on_scan(WatchScan::Read { done, total });
156                    }
157                };
158                // A scan that panics is reported as stopped part way, and the
159                // watch carries on: the next change or rescan tries again.
160                let result =
161                    std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| match dirs {
162                        Some(dirs) => scanner::scan_dirs(&db, folders, dirs, opts, Some(&on_track)),
163                        None => scanner::full_scan(&db, folders, opts, Some(&on_track)),
164                    }))
165                    .unwrap_or_else(|_| {
166                        log::error!("{reason} scan panicked");
167                        let mut stopped = ScanResult::default();
168                        stopped.incomplete = true;
169                        stopped
170                    });
171                on_scan(WatchScan::Finished(&result));
172                log::info!(
173                    "{reason} scan: {} added, {} updated, {} removed, {} unchanged",
174                    result.added,
175                    result.updated,
176                    result.removed,
177                    result.skipped
178                );
179            };
180            let folders = || Config::cached().library.folders.clone();
181
182            let (tx, rx) = std::sync::mpsc::channel();
183            let Ok(mut watcher) = notify::recommended_watcher(move |event| {
184                let _ = tx.send(event);
185            }) else {
186                log::warn!("could not watch the library folders");
187                return;
188            };
189
190            // Brings the watches in line with the configured folders as they
191            // are now, returning the ones newly watched — added in settings, or
192            // back from being unmounted — whose changes nobody heard.
193            let mut roots: Vec<WatchedRoot> = Vec::new();
194            let mut rewatch = |roots: &mut Vec<WatchedRoot>| {
195                let wanted: Vec<WatchedRoot> = folders()
196                    .iter()
197                    .filter_map(|f| WatchedRoot::resolve(f))
198                    .collect();
199                roots.retain(|root| {
200                    let keep = wanted.contains(root);
201                    if !keep {
202                        let _ = watcher.unwatch(&root.path);
203                    }
204                    keep
205                });
206                let mut fresh = Vec::new();
207                for root in wanted {
208                    if roots.contains(&root) {
209                        continue;
210                    }
211                    match watcher.watch(&root.path, RecursiveMode::Recursive) {
212                        Ok(()) => {
213                            fresh.push(root.path.clone());
214                            roots.push(root);
215                        }
216                        Err(e) => log::warn!("could not watch {}: {e}", root.path.display()),
217                    }
218                }
219                fresh
220            };
221
222            // After the first frame and the first track, not competing with them.
223            std::thread::sleep(Duration::from_secs(3));
224            rewatch(&mut roots);
225            scan("start-up", &folders(), None);
226
227            let mut dirs = BTreeSet::new();
228            let mut everything = false;
229            let mut settle_at: Option<Instant> = None;
230            let mut check_at = Instant::now() + CHECK;
231            let mut rescan_at = Instant::now() + RESCAN;
232            loop {
233                // Changes heard meanwhile wait in the channel; a rescan that
234                // fell due runs as soon as this returns.
235                crate::quiet::wait_until_awake();
236                let now = Instant::now();
237                let wake = settle_at
238                    .map_or(check_at, |at| at.min(check_at))
239                    .min(rescan_at);
240                match rx.recv_timeout(wake.saturating_duration_since(now)) {
241                    Ok(Ok(event)) if event.need_rescan() => {
242                        everything = true;
243                        settle_at = Some(Instant::now() + SETTLE);
244                    }
245                    Ok(Ok(event)) => {
246                        let mut heard = false;
247                        for dir in event
248                            .paths
249                            .iter()
250                            .filter_map(|p| scan_target(&event.kind, p, &roots))
251                        {
252                            dirs.insert(dir);
253                            heard = true;
254                        }
255                        if heard {
256                            settle_at = Some(Instant::now() + SETTLE);
257                        }
258                    }
259                    Ok(Err(e)) => log::debug!("library watch: {e}"),
260                    Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
261                    Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
262                }
263
264                let now = Instant::now();
265                if settle_at.is_some_and(|at| now >= at) {
266                    let changed =
267                        scanner::minimal_dirs(std::mem::take(&mut dirs).into_iter().collect());
268                    if everything || changed.len() > MAX_DIRS {
269                        scan("folder change", &folders(), None);
270                        rescan_at = Instant::now() + RESCAN;
271                    } else {
272                        scan("folder change", &folders(), Some(&changed));
273                    }
274                    everything = false;
275                    settle_at = None;
276                }
277                if now >= rescan_at {
278                    scan("periodic", &folders(), None);
279                    rescan_at = Instant::now() + RESCAN;
280                }
281                if now >= check_at {
282                    let fresh = rewatch(&mut roots);
283                    if !fresh.is_empty() {
284                        scan("new folder", &fresh, None);
285                    }
286                    check_at = Instant::now() + CHECK;
287                }
288            }
289        })
290        .ok()
291}
292
293/// Keep the library in step with the server, without being asked.
294///
295/// One sync shortly after startup, then every `auto_sync_interval_mins` for a
296/// server that cannot say when it changes. A koan server can: it sends every
297/// linked app a sync when its library or a playlist moves, and queues one for
298/// an app that was away, so a timer would only ask a question already
299/// answered. Each run walks the library only if the server says it moved —
300/// see `Walk::IfChanged` — which is what makes it cheap enough to run
301/// unattended.
302///
303/// The startup run is delayed a few seconds so it is not competing with the
304/// first frame and the first track for the disk.
305///
306/// `on_state` reports whether a sync is running, so a UI can say so rather than
307/// appearing to do nothing, and `on_progress` how far it has got.
308pub fn spawn_auto_sync(
309    db_path: std::path::PathBuf,
310    on_state: impl Fn(bool) + Send + 'static,
311    on_progress: impl Fn(crate::remote::sync::SyncProgress) + Send + Sync + 'static,
312) -> Option<std::thread::JoinHandle<()>> {
313    std::thread::Builder::new()
314        .name("koan-auto-sync".into())
315        .spawn(move || {
316            std::thread::sleep(std::time::Duration::from_secs(5));
317            loop {
318                // Due while the app was in the background: runs when it is not.
319                crate::quiet::wait_until_awake();
320                let cfg = Config::load().unwrap_or_default();
321                if !cfg.remote.enabled || !cfg.remote.auto_sync {
322                    // Re-read rather than exit: the setting can be turned on
323                    // while the app is running.
324                    std::thread::sleep(std::time::Duration::from_secs(60));
325                    continue;
326                }
327
328                if let Some(client) = subsonic_client(&cfg)
329                    && let Ok(db) = Database::open_existing(&db_path)
330                {
331                    on_state(true);
332                    match sync_remote(
333                        &db,
334                        &client,
335                        Walk::IfChanged,
336                        &cfg.remote.url,
337                        &cfg.remote.username,
338                        &on_progress,
339                    ) {
340                        Ok(s) => log::info!(
341                            "auto sync: {} artists, {} albums, {} tracks ({} albums failed); \
342                             favourites {}↑ {}↓; playlists {}↓ {}↑",
343                            s.library.artists_synced,
344                            s.library.albums_synced,
345                            s.library.tracks_synced,
346                            s.library.albums_failed,
347                            s.favourites.pushed,
348                            s.favourites.imported,
349                            s.playlists.pulled,
350                            s.playlists.pushed,
351                        ),
352                        Err(e) => log::warn!("auto sync failed: {e}"),
353                    }
354                    on_state(false);
355                }
356
357                // Once at startup and no more, or on the interval. A koan
358                // server's own syncs make the interval redundant; it is still
359                // slept, since the server signed in to can change.
360                let mins = cfg.remote.auto_sync_interval_mins;
361                if mins == 0 {
362                    return;
363                }
364                loop {
365                    std::thread::sleep(std::time::Duration::from_secs(mins * 60));
366                    if !crate::remote::profile::current().is_some_and(|p| p.links()) {
367                        break;
368                    }
369                }
370            }
371        })
372        .ok()
373}
374
375#[cfg(test)]
376mod watch_lock_tests {
377    use super::*;
378
379    /// Two processes on one database: whoever holds the lock watches, the
380    /// other waits, and takes over once the first lets go. `flock` locks
381    /// belong to the open file, so two opens in one process stand in for two
382    /// processes.
383    #[test]
384    fn one_watcher_per_database_and_the_next_takes_over() {
385        let dir = tempfile::tempdir().unwrap();
386        let db = dir.path().join("koan.db");
387        let first = try_watch_lock(&db).unwrap().expect("the first takes it");
388        assert!(try_watch_lock(&db).unwrap().is_none(), "the second waits");
389        assert!(dir.path().join("watch.lock").exists());
390        drop(first);
391        assert!(try_watch_lock(&db).unwrap().is_some(), "and then takes it");
392    }
393
394    /// The waiting side blocks in the kernel and wakes when the holder lets
395    /// go, with no retry interval to sit out.
396    #[test]
397    fn a_waiting_watcher_wakes_when_the_lock_is_released() {
398        let dir = tempfile::tempdir().unwrap();
399        let db = dir.path().join("koan.db");
400        let first = try_watch_lock(&db).unwrap().unwrap();
401        let (tx, rx) = std::sync::mpsc::channel();
402        let waiting = db.clone();
403        std::thread::spawn(move || {
404            let lock = watch_lock(&waiting);
405            let _ = tx.send(lock.is_ok());
406        });
407        assert!(
408            rx.recv_timeout(std::time::Duration::from_millis(200))
409                .is_err(),
410            "it waits while the lock is held"
411        );
412        drop(first);
413        assert_eq!(rx.recv_timeout(std::time::Duration::from_secs(5)), Ok(true));
414    }
415
416    #[test]
417    fn databases_in_different_directories_do_not_share_a_lock() {
418        let (a, b) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
419        let _a = try_watch_lock(&a.path().join("koan.db")).unwrap().unwrap();
420        assert!(try_watch_lock(&b.path().join("koan.db")).unwrap().is_some());
421    }
422}