honker 0.1.2 → 0.3.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.
@@ -0,0 +1,434 @@
1
+ //! Optional kernel-watch backend (feature = `kernel-watcher`).
2
+ //!
3
+ //! **Experimental.** Weaker correctness contract than the polling
4
+ //! backend, in exchange for lower idle CPU and lower wake latency.
5
+ //!
6
+ //! # Contract
7
+ //!
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:
12
+ //!
13
+ //! - **Spurious wakes are possible.** Any file change in the directory
14
+ //! (other apps writing nearby files, the OS touching metadata, etc.)
15
+ //! produces a wake. Consumers re-read state on every wake anyway, so
16
+ //! this is wasted work, not incorrect.
17
+ //!
18
+ //! - **Missed wakes are possible.** If the OS drops or coalesces
19
+ //! notifications, or fails to deliver an event for a SQLite commit,
20
+ //! `on_change()` will not fire for that commit. The consumer's
21
+ //! `idle_poll_s` (default 5 s) is the only backstop.
22
+ //!
23
+ //! - **Setup failures raise at `open()`.** [`probe`] runs at
24
+ //! `honker.open()` time and surfaces any init failure as an error
25
+ //! so the user knows immediately. No silent backend disable.
26
+ //!
27
+ //! Tests assert that wakes do fire, with bounded latency, on the
28
+ //! platforms we support. If a test fails, the backend is broken on
29
+ //! that platform — not "fall back to polling and pretend it worked".
30
+
31
+ use crate::stat_identity;
32
+ use std::path::PathBuf;
33
+ use std::sync::Arc;
34
+ use std::sync::atomic::{AtomicBool, Ordering};
35
+ use std::time::{Duration, Instant};
36
+
37
+ #[cfg(target_os = "macos")]
38
+ use macos::{probe_kqueue, run_kqueue_loop};
39
+ #[cfg(not(target_os = "macos"))]
40
+ use notify::{RecursiveMode, Watcher};
41
+ #[cfg(not(target_os = "macos"))]
42
+ use std::collections::HashSet;
43
+ #[cfg(not(target_os = "macos"))]
44
+ use std::path::Path;
45
+ #[cfg(not(target_os = "macos"))]
46
+ use std::sync::mpsc;
47
+
48
+ /// How long `recv_timeout` blocks before sampling the stop flag.
49
+ /// Bounds graceful shutdown latency at this value.
50
+ const RX_POLL_MS: u64 = 50;
51
+ /// Cadence for the dead-man's switch (db-file replacement detection).
52
+ /// Same as the polling backend so file-replacement detection latency
53
+ /// doesn't depend on which backend the user picked.
54
+ const IDENTITY_CHECK_MS: u64 = 100;
55
+
56
+ pub(crate) fn run_kernel_watch_loop<F>(
57
+ db_path: PathBuf,
58
+ on_change: F,
59
+ stop: Arc<AtomicBool>,
60
+ ready: std::sync::mpsc::SyncSender<()>,
61
+ ) where
62
+ F: Fn() + Send + 'static,
63
+ {
64
+ #[cfg(target_os = "macos")]
65
+ {
66
+ run_kqueue_loop(db_path, on_change, stop, ready);
67
+ return;
68
+ }
69
+
70
+ #[cfg(not(target_os = "macos"))]
71
+ {
72
+ let (tx, rx) = mpsc::channel::<notify::Result<notify::Event>>();
73
+ let mut watcher = match notify::recommended_watcher(tx) {
74
+ Ok(w) => w,
75
+ Err(e) => {
76
+ eprintln!("honker: kernel-watcher init failed: {e}. Backend disabled.");
77
+ return;
78
+ }
79
+ };
80
+
81
+ // Attach watches: db file (catches in-place writes), parent dir
82
+ // (catches journal/wal/shm create+delete), and sidecars directly
83
+ // when present. SQLite can create the WAL after watcher startup,
84
+ // so retry per-file attaches on parent-dir activity and on the
85
+ // dead-man cadence.
86
+ let watch_dir = db_path
87
+ .parent()
88
+ .unwrap_or(std::path::Path::new("."))
89
+ .to_path_buf();
90
+ let wal = PathBuf::from(format!("{}-wal", db_path.display()));
91
+ let shm = PathBuf::from(format!("{}-shm", db_path.display()));
92
+ let journal = PathBuf::from(format!("{}-journal", db_path.display()));
93
+
94
+ let mut watched = HashSet::new();
95
+ let mut attached = 0;
96
+ for path in [&watch_dir, &db_path, &wal, &shm, &journal] {
97
+ if attach_watch(&mut watcher, &mut watched, path) {
98
+ attached += 1;
99
+ }
100
+ }
101
+ if attached == 0 {
102
+ eprintln!(
103
+ "honker: kernel-watcher couldn't attach to db dir or -wal/-shm. Backend disabled."
104
+ );
105
+ return;
106
+ }
107
+
108
+ // Dead-man's switch: snapshot db inode; panic if it changes
109
+ // (atomic rename, litestream restore, NFS remount). Per-file
110
+ // watches would silently sit on the dead inode otherwise.
111
+ let initial_id = match stat_identity(&db_path) {
112
+ Ok(id) => id,
113
+ Err(e) => {
114
+ eprintln!("honker: failed to stat database for identity check: {e}");
115
+ (0, 0)
116
+ }
117
+ };
118
+ let mut last_id_check = Instant::now();
119
+ let _ = ready.send(());
120
+ drop(ready);
121
+
122
+ while !stop.load(Ordering::Acquire) {
123
+ match rx.recv_timeout(Duration::from_millis(RX_POLL_MS)) {
124
+ Ok(Ok(_event)) => {
125
+ for path in [&db_path, &wal, &shm, &journal] {
126
+ let _ = attach_watch(&mut watcher, &mut watched, path);
127
+ }
128
+ on_change();
129
+ }
130
+ Ok(Err(e)) => {
131
+ eprintln!("honker: kernel-watcher event error: {e}");
132
+ on_change();
133
+ }
134
+ Err(mpsc::RecvTimeoutError::Disconnected) => break,
135
+ _ => {}
136
+ }
137
+ if last_id_check.elapsed() >= Duration::from_millis(IDENTITY_CHECK_MS) {
138
+ for path in [&db_path, &wal, &shm, &journal] {
139
+ let _ = attach_watch(&mut watcher, &mut watched, path);
140
+ }
141
+ if check_db_identity(&db_path, initial_id) {
142
+ on_change();
143
+ }
144
+ last_id_check = Instant::now();
145
+ }
146
+ }
147
+ }
148
+ }
149
+
150
+ #[cfg(not(target_os = "macos"))]
151
+ fn attach_watch<W: Watcher>(watcher: &mut W, watched: &mut HashSet<PathBuf>, path: &Path) -> bool {
152
+ if watched.contains(path) {
153
+ return false;
154
+ }
155
+ if watcher.watch(path, RecursiveMode::NonRecursive).is_ok() {
156
+ watched.insert(path.to_path_buf());
157
+ return true;
158
+ }
159
+ false
160
+ }
161
+
162
+ /// Panics if the db file at `db_path` has been replaced since startup.
163
+ /// Returns `true` on stat error so caller can fire a conservative wake.
164
+ fn check_db_identity(db_path: &std::path::Path, initial: (u64, u64)) -> bool {
165
+ match stat_identity(db_path) {
166
+ Ok(current) => {
167
+ if current != initial {
168
+ panic!(
169
+ "honker: database file replaced: \
170
+ expected (dev={}, ino={}), found (dev={}, ino={}) at {:?}. \
171
+ The watcher cannot recover; \
172
+ close the Database and reopen with honker.open().",
173
+ initial.0, initial.1, current.0, current.1, db_path
174
+ );
175
+ }
176
+ false
177
+ }
178
+ Err(e) => {
179
+ eprintln!("honker: stat identity check failed: {e}");
180
+ true
181
+ }
182
+ }
183
+ }
184
+
185
+ /// Probe at `honker.open()` so a misconfigured backend errors
186
+ /// immediately instead of silently producing no wakes.
187
+ pub(crate) fn probe(db_path: &std::path::Path) -> Result<(), String> {
188
+ #[cfg(target_os = "macos")]
189
+ {
190
+ return probe_kqueue(db_path);
191
+ }
192
+
193
+ #[cfg(not(target_os = "macos"))]
194
+ {
195
+ let (tx, _rx) = mpsc::channel::<notify::Result<notify::Event>>();
196
+ let mut w =
197
+ notify::recommended_watcher(tx).map_err(|e| format!("notify init failed: {e}"))?;
198
+ let dir = db_path.parent().unwrap_or(std::path::Path::new("."));
199
+ w.watch(dir, RecursiveMode::NonRecursive)
200
+ .map_err(|e| format!("can't watch {dir:?}: {e}"))?;
201
+ Ok(())
202
+ }
203
+ }
204
+
205
+ #[cfg(target_os = "macos")]
206
+ mod macos {
207
+ use super::*;
208
+ use std::ffi::CString;
209
+ use std::os::unix::ffi::OsStrExt;
210
+ use std::path::{Path, PathBuf};
211
+ use std::ptr;
212
+
213
+ struct Kqueue {
214
+ fd: libc::c_int,
215
+ }
216
+
217
+ impl Kqueue {
218
+ fn new() -> Result<Self, String> {
219
+ let fd = unsafe { libc::kqueue() };
220
+ if fd < 0 {
221
+ return Err(format!(
222
+ "kqueue failed: {}",
223
+ std::io::Error::last_os_error()
224
+ ));
225
+ }
226
+ Ok(Self { fd })
227
+ }
228
+
229
+ fn add_vnode(&self, fd: libc::c_int) -> Result<(), String> {
230
+ let mut event = libc::kevent {
231
+ ident: fd as libc::uintptr_t,
232
+ filter: libc::EVFILT_VNODE,
233
+ flags: libc::EV_ADD | libc::EV_ENABLE | libc::EV_CLEAR,
234
+ fflags: libc::NOTE_WRITE
235
+ | libc::NOTE_EXTEND
236
+ | libc::NOTE_ATTRIB
237
+ | libc::NOTE_DELETE
238
+ | libc::NOTE_RENAME
239
+ | libc::NOTE_REVOKE,
240
+ data: 0,
241
+ udata: ptr::null_mut(),
242
+ };
243
+ let n =
244
+ unsafe { libc::kevent(self.fd, &mut event, 1, ptr::null_mut(), 0, ptr::null()) };
245
+ if n < 0 {
246
+ Err(format!(
247
+ "kevent add failed: {}",
248
+ std::io::Error::last_os_error()
249
+ ))
250
+ } else {
251
+ Ok(())
252
+ }
253
+ }
254
+
255
+ fn wait_one(&self, timeout: Duration) -> Result<Option<libc::kevent>, String> {
256
+ let mut event = libc::kevent {
257
+ ident: 0,
258
+ filter: 0,
259
+ flags: 0,
260
+ fflags: 0,
261
+ data: 0,
262
+ udata: ptr::null_mut(),
263
+ };
264
+ let ts = libc::timespec {
265
+ tv_sec: timeout.as_secs() as libc::time_t,
266
+ tv_nsec: i64::from(timeout.subsec_nanos()) as libc::c_long,
267
+ };
268
+ let n = unsafe { libc::kevent(self.fd, ptr::null(), 0, &mut event, 1, &ts) };
269
+ if n < 0 {
270
+ Err(format!(
271
+ "kevent wait failed: {}",
272
+ std::io::Error::last_os_error()
273
+ ))
274
+ } else if n == 0 {
275
+ Ok(None)
276
+ } else {
277
+ Ok(Some(event))
278
+ }
279
+ }
280
+ }
281
+
282
+ impl Drop for Kqueue {
283
+ fn drop(&mut self) {
284
+ unsafe {
285
+ libc::close(self.fd);
286
+ }
287
+ }
288
+ }
289
+
290
+ struct WatchedPath {
291
+ path: PathBuf,
292
+ fd: libc::c_int,
293
+ }
294
+
295
+ impl Drop for WatchedPath {
296
+ fn drop(&mut self) {
297
+ unsafe {
298
+ libc::close(self.fd);
299
+ }
300
+ }
301
+ }
302
+
303
+ fn open_event_fd(path: &Path) -> Result<libc::c_int, String> {
304
+ let c_path = CString::new(path.as_os_str().as_bytes())
305
+ .map_err(|_| format!("path contains NUL byte: {path:?}"))?;
306
+ let fd = unsafe { libc::open(c_path.as_ptr(), libc::O_EVTONLY) };
307
+ if fd < 0 {
308
+ Err(format!(
309
+ "open {path:?} failed: {}",
310
+ std::io::Error::last_os_error()
311
+ ))
312
+ } else {
313
+ Ok(fd)
314
+ }
315
+ }
316
+
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());
321
+ }
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
327
+ }
328
+
329
+ fn attach_path(kq: &Kqueue, path: PathBuf, watched: &mut Vec<WatchedPath>) -> bool {
330
+ if watched.iter().any(|w| w.path == path) {
331
+ return false;
332
+ }
333
+ let fd = match open_event_fd(&path) {
334
+ Ok(fd) => fd,
335
+ Err(_) => return false,
336
+ };
337
+ if let Err(e) = kq.add_vnode(fd) {
338
+ eprintln!("honker: kqueue couldn't watch {path:?}: {e}");
339
+ unsafe {
340
+ libc::close(fd);
341
+ }
342
+ return false;
343
+ }
344
+ watched.push(WatchedPath { path, fd });
345
+ true
346
+ }
347
+
348
+ fn attach_existing(kq: &Kqueue, db_path: &Path, watched: &mut Vec<WatchedPath>) -> usize {
349
+ candidate_paths(db_path)
350
+ .into_iter()
351
+ .filter(|path| path.exists())
352
+ .filter(|path| attach_path(kq, path.clone(), watched))
353
+ .count()
354
+ }
355
+
356
+ fn prune_deleted(event: &libc::kevent, watched: &mut Vec<WatchedPath>) {
357
+ if event.fflags & (libc::NOTE_DELETE | libc::NOTE_RENAME | libc::NOTE_REVOKE) == 0 {
358
+ return;
359
+ }
360
+ let ident = event.ident as libc::c_int;
361
+ watched.retain(|w| w.fd != ident);
362
+ }
363
+
364
+ pub(super) fn run_kqueue_loop<F>(
365
+ db_path: PathBuf,
366
+ on_change: F,
367
+ stop: Arc<AtomicBool>,
368
+ ready: std::sync::mpsc::SyncSender<()>,
369
+ ) where
370
+ F: Fn() + Send + 'static,
371
+ {
372
+ let kq = match Kqueue::new() {
373
+ Ok(kq) => kq,
374
+ Err(e) => {
375
+ eprintln!("honker: kernel-watcher init failed: {e}. Backend disabled.");
376
+ return;
377
+ }
378
+ };
379
+ let mut watched = Vec::new();
380
+ let attached = attach_existing(&kq, &db_path, &mut watched);
381
+ if attached == 0 {
382
+ eprintln!(
383
+ "honker: kqueue couldn't attach to db dir or database files. Backend disabled."
384
+ );
385
+ return;
386
+ }
387
+
388
+ let initial_id = match stat_identity(&db_path) {
389
+ Ok(id) => id,
390
+ Err(e) => {
391
+ eprintln!("honker: failed to stat database for identity check: {e}");
392
+ (0, 0)
393
+ }
394
+ };
395
+ let mut last_id_check = Instant::now();
396
+ let _ = ready.send(());
397
+ drop(ready);
398
+
399
+ while !stop.load(Ordering::Acquire) {
400
+ match kq.wait_one(Duration::from_millis(RX_POLL_MS)) {
401
+ Ok(Some(event)) => {
402
+ prune_deleted(&event, &mut watched);
403
+ let _ = attach_existing(&kq, &db_path, &mut watched);
404
+ on_change();
405
+ }
406
+ Ok(None) => {
407
+ let _ = attach_existing(&kq, &db_path, &mut watched);
408
+ }
409
+ Err(e) => {
410
+ eprintln!("honker: kqueue event error: {e}");
411
+ on_change();
412
+ }
413
+ }
414
+
415
+ if last_id_check.elapsed() >= Duration::from_millis(IDENTITY_CHECK_MS) {
416
+ if check_db_identity(&db_path, initial_id) {
417
+ on_change();
418
+ }
419
+ last_id_check = Instant::now();
420
+ }
421
+ }
422
+ }
423
+
424
+ pub(super) fn probe_kqueue(db_path: &Path) -> Result<(), String> {
425
+ let kq = Kqueue::new()?;
426
+ let dir = db_path.parent().unwrap_or(Path::new("."));
427
+ let dir_fd = open_event_fd(dir)?;
428
+ let result = kq.add_vnode(dir_fd);
429
+ unsafe {
430
+ libc::close(dir_fd);
431
+ }
432
+ result.map_err(|e| format!("can't watch {dir:?}: {e}"))
433
+ }
434
+ }