honker 0.3.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,9 +6,15 @@
6
6
  //! # Contract
7
7
  //!
8
8
  //! `on_change()` fires on every relevant filesystem event observed on
9
- //! the database file, its parent directory, or SQLite sidecar files
10
- //! (`-wal`, `-shm`, `-journal`). **There is no `PRAGMA data_version`
11
- //! verification and no safety-net poll.** This means:
9
+ //! the database's parent directory and its rollback/WAL sidecar files.
10
+ //! **There is no `PRAGMA data_version` verification and no safety-net
11
+ //! poll.** This means:
12
+ //!
13
+ //! Which paths are watched is a *safety* decision, not just a coverage
14
+ //! one: any backend that holds a descriptor per watched file must never
15
+ //! open the main database file or `-shm`, because closing that descriptor
16
+ //! releases the whole process's SQLite POSIX locks on it. See
17
+ //! [`macos::candidate_paths`] and issue #80.
12
18
  //!
13
19
  //! - **Spurious wakes are possible.** Any file change in the directory
14
20
  //! (other apps writing nearby files, the OS touching metadata, etc.)
@@ -64,7 +70,6 @@ pub(crate) fn run_kernel_watch_loop<F>(
64
70
  #[cfg(target_os = "macos")]
65
71
  {
66
72
  run_kqueue_loop(db_path, on_change, stop, ready);
67
- return;
68
73
  }
69
74
 
70
75
  #[cfg(not(target_os = "macos"))]
@@ -91,16 +96,18 @@ pub(crate) fn run_kernel_watch_loop<F>(
91
96
  let shm = PathBuf::from(format!("{}-shm", db_path.display()));
92
97
  let journal = PathBuf::from(format!("{}-journal", db_path.display()));
93
98
 
99
+ let targets = notify_watch_targets(&watch_dir, &db_path, &wal, &shm, &journal);
100
+
94
101
  let mut watched = HashSet::new();
95
102
  let mut attached = 0;
96
- for path in [&watch_dir, &db_path, &wal, &shm, &journal] {
103
+ for path in &targets {
97
104
  if attach_watch(&mut watcher, &mut watched, path) {
98
105
  attached += 1;
99
106
  }
100
107
  }
101
108
  if attached == 0 {
102
109
  eprintln!(
103
- "honker: kernel-watcher couldn't attach to db dir or -wal/-shm. Backend disabled."
110
+ "honker: kernel-watcher couldn't attach to db dir or -wal/-journal. Backend disabled."
104
111
  );
105
112
  return;
106
113
  }
@@ -122,7 +129,7 @@ pub(crate) fn run_kernel_watch_loop<F>(
122
129
  while !stop.load(Ordering::Acquire) {
123
130
  match rx.recv_timeout(Duration::from_millis(RX_POLL_MS)) {
124
131
  Ok(Ok(_event)) => {
125
- for path in [&db_path, &wal, &shm, &journal] {
132
+ for path in &targets {
126
133
  let _ = attach_watch(&mut watcher, &mut watched, path);
127
134
  }
128
135
  on_change();
@@ -135,7 +142,7 @@ pub(crate) fn run_kernel_watch_loop<F>(
135
142
  _ => {}
136
143
  }
137
144
  if last_id_check.elapsed() >= Duration::from_millis(IDENTITY_CHECK_MS) {
138
- for path in [&db_path, &wal, &shm, &journal] {
145
+ for path in &targets {
139
146
  let _ = attach_watch(&mut watcher, &mut watched, path);
140
147
  }
141
148
  if check_db_identity(&db_path, initial_id) {
@@ -147,6 +154,64 @@ pub(crate) fn run_kernel_watch_loop<F>(
147
154
  }
148
155
  }
149
156
 
157
+ /// Paths the `notify` backend attaches watches to.
158
+ ///
159
+ /// Which files are safe depends on whether the platform's `notify` backend
160
+ /// keeps a descriptor open per watched file:
161
+ ///
162
+ /// * **inotify** (Linux/Android) registers a watch by path on the single
163
+ /// inotify instance fd — it never holds a descriptor on the watched file,
164
+ /// so nothing this crate does can release a SQLite lock. The database
165
+ /// file and `-shm` stay in the set; dropping them would cost detection in
166
+ /// TRUNCATE/PERSIST journal modes for no safety gain.
167
+ /// * **`ReadDirectoryChangesW`** (Windows) watches directories and Windows
168
+ /// does not use POSIX advisory locks at all.
169
+ /// * **kqueue** (the BSDs) holds one `O_EVTONLY` descriptor per watched
170
+ /// file and closes it on unwatch/drop — the same hazard as the hand-rolled
171
+ /// macOS backend below. The database file and `-shm` are excluded there.
172
+ /// See [`macos::candidate_paths`] for the full explanation and issue #80.
173
+ #[cfg(not(target_os = "macos"))]
174
+ fn notify_watch_targets(
175
+ watch_dir: &Path,
176
+ db_path: &Path,
177
+ wal: &Path,
178
+ shm: &Path,
179
+ journal: &Path,
180
+ ) -> Vec<PathBuf> {
181
+ // notify::RecommendedWatcher == KqueueWatcher on these targets.
182
+ #[cfg(any(
183
+ target_os = "freebsd",
184
+ target_os = "openbsd",
185
+ target_os = "netbsd",
186
+ target_os = "dragonfly",
187
+ target_os = "ios"
188
+ ))]
189
+ {
190
+ let _ = (db_path, shm);
191
+ vec![
192
+ watch_dir.to_path_buf(),
193
+ wal.to_path_buf(),
194
+ journal.to_path_buf(),
195
+ ]
196
+ }
197
+ #[cfg(not(any(
198
+ target_os = "freebsd",
199
+ target_os = "openbsd",
200
+ target_os = "netbsd",
201
+ target_os = "dragonfly",
202
+ target_os = "ios"
203
+ )))]
204
+ {
205
+ vec![
206
+ watch_dir.to_path_buf(),
207
+ db_path.to_path_buf(),
208
+ wal.to_path_buf(),
209
+ shm.to_path_buf(),
210
+ journal.to_path_buf(),
211
+ ]
212
+ }
213
+ }
214
+
150
215
  #[cfg(not(target_os = "macos"))]
151
216
  fn attach_watch<W: Watcher>(watcher: &mut W, watched: &mut HashSet<PathBuf>, path: &Path) -> bool {
152
217
  if watched.contains(path) {
@@ -187,7 +252,7 @@ fn check_db_identity(db_path: &std::path::Path, initial: (u64, u64)) -> bool {
187
252
  pub(crate) fn probe(db_path: &std::path::Path) -> Result<(), String> {
188
253
  #[cfg(target_os = "macos")]
189
254
  {
190
- return probe_kqueue(db_path);
255
+ probe_kqueue(db_path)
191
256
  }
192
257
 
193
258
  #[cfg(not(target_os = "macos"))]
@@ -227,7 +292,7 @@ mod macos {
227
292
  }
228
293
 
229
294
  fn add_vnode(&self, fd: libc::c_int) -> Result<(), String> {
230
- let mut event = libc::kevent {
295
+ let event = libc::kevent {
231
296
  ident: fd as libc::uintptr_t,
232
297
  filter: libc::EVFILT_VNODE,
233
298
  flags: libc::EV_ADD | libc::EV_ENABLE | libc::EV_CLEAR,
@@ -240,8 +305,7 @@ mod macos {
240
305
  data: 0,
241
306
  udata: ptr::null_mut(),
242
307
  };
243
- let n =
244
- unsafe { libc::kevent(self.fd, &mut event, 1, ptr::null_mut(), 0, ptr::null()) };
308
+ let n = unsafe { libc::kevent(self.fd, &event, 1, ptr::null_mut(), 0, ptr::null()) };
245
309
  if n < 0 {
246
310
  Err(format!(
247
311
  "kevent add failed: {}",
@@ -314,16 +378,55 @@ mod macos {
314
378
  }
315
379
  }
316
380
 
317
- fn candidate_paths(db_path: &Path) -> Vec<PathBuf> {
318
- let mut paths = Vec::with_capacity(5);
319
- if let Some(parent) = db_path.parent() {
320
- paths.push(parent.to_path_buf());
381
+ /// The db's parent directory, normalized. `Path::parent()` returns
382
+ /// `Some("")` for a bare relative filename like `"a.db"`, and
383
+ /// `open("")` fails with ENOENT — so map that to `"."`.
384
+ pub(super) fn watch_dir(db_path: &Path) -> PathBuf {
385
+ match db_path.parent() {
386
+ Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(),
387
+ _ => PathBuf::from("."),
321
388
  }
322
- paths.push(db_path.to_path_buf());
323
- paths.push(PathBuf::from(format!("{}-wal", db_path.display())));
324
- paths.push(PathBuf::from(format!("{}-shm", db_path.display())));
325
- paths.push(PathBuf::from(format!("{}-journal", db_path.display())));
326
- paths
389
+ }
390
+
391
+ /// Paths kqueue may hold an open descriptor on.
392
+ ///
393
+ /// # LOCK-BEARING FILES MUST NEVER APPEAR HERE
394
+ ///
395
+ /// **Do not add the main database file or `-shm` to this list.**
396
+ /// kqueue's `EVFILT_VNODE` requires a descriptor per watched file, and
397
+ /// those descriptors get closed — on `NOTE_DELETE` pruning, on attach
398
+ /// failure, and at watcher shutdown. On POSIX, `close()` of *any*
399
+ /// descriptor for an inode releases *every* advisory lock the calling
400
+ /// process holds on that inode, including locks taken by unrelated
401
+ /// SQLite connections living in the same process. SQLite's
402
+ /// `unixInodeInfo` deferred-close list (`setPendingFd` in `os_unix.c`)
403
+ /// only defers closes of descriptors SQLite itself opened; it cannot
404
+ /// see ours.
405
+ ///
406
+ /// In WAL mode SQLite takes POSIX locks on exactly two files: the main
407
+ /// database file (a SHARED lock held for the connection's lifetime) and
408
+ /// `-shm` (the WAL-index locks plus the DMS byte). Dropping either one
409
+ /// lets another process delete `-wal`/`-shm` out from under a live
410
+ /// connection — whose next commit then lands in an unlinked WAL and is
411
+ /// silently lost — or re-run WAL-index recovery and `ftruncate` `-shm`
412
+ /// under a live mapping, which is SIGBUS. See issue #80.
413
+ ///
414
+ /// `-wal` and `-journal` are safe to watch: `os_unix.c` sets
415
+ /// `UNIXFILE_NOLOCK` for every file whose open type is not
416
+ /// `SQLITE_OPEN_MAIN_DB`, so they use `nolockIoMethods` and never carry
417
+ /// a lock. Directories are safe: SQLite opens them only to `fsync`.
418
+ ///
419
+ /// Detection is not weakened in WAL mode: every commit appends frames
420
+ /// to `-wal`, which is exactly what raises `NOTE_WRITE`/`NOTE_EXTEND`.
421
+ /// Rollback modes signal through `-journal` (DELETE unlinks it,
422
+ /// TRUNCATE truncates it, PERSIST zeroes its header) and through the
423
+ /// parent directory.
424
+ pub(super) fn candidate_paths(db_path: &Path) -> Vec<PathBuf> {
425
+ vec![
426
+ watch_dir(db_path),
427
+ PathBuf::from(format!("{}-wal", db_path.display())),
428
+ PathBuf::from(format!("{}-journal", db_path.display())),
429
+ ]
327
430
  }
328
431
 
329
432
  fn attach_path(kq: &Kqueue, path: PathBuf, watched: &mut Vec<WatchedPath>) -> bool {
@@ -380,7 +483,7 @@ mod macos {
380
483
  let attached = attach_existing(&kq, &db_path, &mut watched);
381
484
  if attached == 0 {
382
485
  eprintln!(
383
- "honker: kqueue couldn't attach to db dir or database files. Backend disabled."
486
+ "honker: kqueue couldn't attach to db dir or -wal/-journal. Backend disabled."
384
487
  );
385
488
  return;
386
489
  }
@@ -421,10 +524,14 @@ mod macos {
421
524
  }
422
525
  }
423
526
 
527
+ /// Probe only the parent directory. Directories carry no SQLite locks,
528
+ /// so the `close()` below cannot release any — see [`candidate_paths`].
529
+ /// Probing the database file or `-shm` here would drop this process's
530
+ /// SQLite locks on every `honker.open()`.
424
531
  pub(super) fn probe_kqueue(db_path: &Path) -> Result<(), String> {
425
532
  let kq = Kqueue::new()?;
426
- let dir = db_path.parent().unwrap_or(Path::new("."));
427
- let dir_fd = open_event_fd(dir)?;
533
+ let dir = watch_dir(db_path);
534
+ let dir_fd = open_event_fd(&dir)?;
428
535
  let result = kq.add_vnode(dir_fd);
429
536
  unsafe {
430
537
  libc::close(dir_fd);
@@ -432,3 +539,63 @@ mod macos {
432
539
  result.map_err(|e| format!("can't watch {dir:?}: {e}"))
433
540
  }
434
541
  }
542
+
543
+ #[cfg(test)]
544
+ mod tests {
545
+ #[cfg(target_os = "macos")]
546
+ use std::path::{Path, PathBuf};
547
+
548
+ /// Structural guard for issue #80. The kqueue backend holds one
549
+ /// descriptor per watched path and closes it on prune, on attach
550
+ /// failure, and at shutdown; on POSIX that close releases every
551
+ /// advisory lock this process holds on the inode. SQLite locks the
552
+ /// main database file and `-shm`, so neither may ever be watched.
553
+ ///
554
+ /// The behavioral proof lives in
555
+ /// `lib.rs::tests::kernel_watcher_does_not_release_sqlite_wal_locks`.
556
+ /// This test is the cheap, platform-independent tripwire that fires
557
+ /// the moment someone adds a path back to the list.
558
+ #[test]
559
+ #[cfg(target_os = "macos")]
560
+ fn candidate_paths_excludes_every_file_sqlite_locks() {
561
+ let db = Path::new("/tmp/honker-cp/app.db");
562
+ let paths = super::macos::candidate_paths(db);
563
+
564
+ assert!(
565
+ !paths.contains(&db.to_path_buf()),
566
+ "the main database file carries SQLite's SHARED lock and must \
567
+ never be kqueue-watched: {paths:?}"
568
+ );
569
+ assert!(
570
+ !paths.contains(&PathBuf::from("/tmp/honker-cp/app.db-shm")),
571
+ "-shm carries SQLite's WAL-index and DMS locks and must never \
572
+ be kqueue-watched: {paths:?}"
573
+ );
574
+ // And the wake signal we depend on in WAL mode is still there.
575
+ assert!(
576
+ paths.contains(&PathBuf::from("/tmp/honker-cp/app.db-wal")),
577
+ "-wal is the WAL-mode commit signal and must stay watched: {paths:?}"
578
+ );
579
+ assert!(
580
+ paths.contains(&PathBuf::from("/tmp/honker-cp")),
581
+ "the parent directory must stay watched: {paths:?}"
582
+ );
583
+ }
584
+
585
+ /// `Path::parent()` yields `Some("")` for a bare relative filename,
586
+ /// and `open("")` is ENOENT. The directory watch is load-bearing now
587
+ /// that the database file itself is not watched, so this must resolve
588
+ /// to `"."` rather than silently failing to attach.
589
+ #[test]
590
+ #[cfg(target_os = "macos")]
591
+ fn bare_relative_db_path_watches_the_current_directory() {
592
+ assert_eq!(
593
+ super::macos::watch_dir(Path::new("app.db")),
594
+ PathBuf::from(".")
595
+ );
596
+ assert_eq!(
597
+ super::macos::watch_dir(Path::new("/var/db/app.db")),
598
+ PathBuf::from("/var/db")
599
+ );
600
+ }
601
+ }