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.
- checksums.yaml +4 -4
- data/ext/honker/honker-core/Cargo.toml +9 -3
- data/ext/honker/honker-core/src/cron.rs +3 -3
- data/ext/honker/honker-core/src/honker_ops.rs +258 -46
- data/ext/honker/honker-core/src/kernel_watcher.rs +191 -24
- data/ext/honker/honker-core/src/lib.rs +577 -36
- data/ext/honker/honker-core/src/shm_watcher.rs +237 -22
- data/ext/honker/honker-extension/Cargo.toml +4 -3
- data/ext/honker/honker-extension/src/lib.rs +7 -9
- data/lib/honker/version.rb +1 -1
- metadata +1 -1
|
@@ -6,9 +6,15 @@
|
|
|
6
6
|
//! # Contract
|
|
7
7
|
//!
|
|
8
8
|
//! `on_change()` fires on every relevant filesystem event observed on
|
|
9
|
-
//! the database
|
|
10
|
-
//!
|
|
11
|
-
//!
|
|
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
|
|
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/-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
|
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
|
|
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
|
+
}
|