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}