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.
@@ -42,6 +42,9 @@ mod kernel_watcher;
42
42
  mod shm_watcher;
43
43
 
44
44
  pub use honker_ops::attach_honker_functions;
45
+ // Shared by the loadable extension's own watcher SQL functions so every
46
+ // honker_* function coerces integer arguments the same way.
47
+ pub use honker_ops::{arg_i64, arg_opt_i64};
45
48
 
46
49
  use parking_lot::{Condvar, Mutex};
47
50
  use rusqlite::functions::FunctionFlags;
@@ -198,21 +201,25 @@ pub enum Error {
198
201
 
199
202
  /// Default PRAGMA block applied on every connection open. Rationale:
200
203
  ///
204
+ /// * `busy_timeout=5000` — wait up to 5s for the writer lock
205
+ /// before returning SQLITE_BUSY. **Must come first.** Converting
206
+ /// the journal to WAL takes a brief exclusive lock, and until the
207
+ /// timeout is set it is 0, so two processes opening the same fresh
208
+ /// file at once make one of them fail instantly with "database is
209
+ /// locked". Ordering this after `journal_mode` is a real bug, not
210
+ /// a style question.
201
211
  /// * `journal_mode=WAL` — concurrent readers with one writer.
202
212
  /// * `synchronous=NORMAL` — fsync WAL at checkpoint, not every
203
213
  /// commit. Safe against app crashes; OS crashes may lose the last
204
214
  /// few unchecked-pointed transactions.
205
- /// * `busy_timeout=5000` — wait up to 5s for the writer lock
206
- /// before returning SQLITE_BUSY.
207
215
  /// * `foreign_keys=ON` — enforce FK constraints (off by
208
216
  /// default in SQLite, a real footgun).
209
217
  /// * `cache_size=-32000` — 32MB page cache (default was 2MB).
210
218
  /// * `temp_store=MEMORY` — temp B-trees in RAM, not disk.
211
219
  /// * `wal_autocheckpoint=10000`— fsync every 10k WAL pages. Reduces
212
220
  /// fsync frequency 10× vs the default of 1k.
213
- pub const DEFAULT_PRAGMAS: &str = "PRAGMA journal_mode = WAL;
221
+ pub const DEFAULT_PRAGMAS: &str = "PRAGMA busy_timeout = 5000;
214
222
  PRAGMA synchronous = NORMAL;
215
- PRAGMA busy_timeout = 5000;
216
223
  PRAGMA foreign_keys = ON;
217
224
  PRAGMA cache_size = -32000;
218
225
  PRAGMA temp_store = MEMORY;
@@ -221,9 +228,59 @@ pub const DEFAULT_PRAGMAS: &str = "PRAGMA journal_mode = WAL;
221
228
  /// Apply the library's default PRAGMAs to an already-open connection.
222
229
  /// Idempotent.
223
230
  pub fn apply_default_pragmas(conn: &Connection) -> rusqlite::Result<()> {
231
+ // busy_timeout has to land before anything that can contend, and
232
+ // the WAL conversion needs its own retry on top — see
233
+ // `set_journal_mode_wal`.
234
+ conn.execute_batch("PRAGMA busy_timeout = 5000;")?;
235
+ set_journal_mode_wal(conn)?;
224
236
  conn.execute_batch(DEFAULT_PRAGMAS)
225
237
  }
226
238
 
239
+ /// Put the database into WAL mode, retrying while another connection
240
+ /// holds the lock.
241
+ ///
242
+ /// `PRAGMA journal_mode = WAL` takes a brief exclusive lock, and SQLite
243
+ /// does **not** run the busy handler for it — `busy_timeout` buys
244
+ /// nothing here. Two processes opening the same file at once and one
245
+ /// fails immediately with "database is locked". That is exactly what a
246
+ /// worker pool does on startup, and it is why the multiprocess
247
+ /// pressure test failed intermittently.
248
+ ///
249
+ /// WAL is a persistent property of the file, so the common case after
250
+ /// the first open is a read with no lock at all.
251
+ fn set_journal_mode_wal(conn: &Connection) -> rusqlite::Result<()> {
252
+ let already_wal = |conn: &Connection| -> rusqlite::Result<bool> {
253
+ let mode: String = conn.query_row("PRAGMA journal_mode", [], |row| row.get(0))?;
254
+ Ok(mode.eq_ignore_ascii_case("wal"))
255
+ };
256
+
257
+ if already_wal(conn)? {
258
+ return Ok(());
259
+ }
260
+
261
+ // Bounded to roughly the busy_timeout above, so a genuinely stuck
262
+ // lock still surfaces as an error rather than hanging.
263
+ let deadline = std::time::Instant::now() + std::time::Duration::from_millis(5000);
264
+ let mut backoff = std::time::Duration::from_millis(1);
265
+ loop {
266
+ match conn.execute_batch("PRAGMA journal_mode = WAL;") {
267
+ Ok(()) => return Ok(()),
268
+ Err(e) => {
269
+ // Someone else may have completed the conversion while
270
+ // we were waiting; that is a success, not a failure.
271
+ if already_wal(conn).unwrap_or(false) {
272
+ return Ok(());
273
+ }
274
+ if std::time::Instant::now() >= deadline {
275
+ return Err(e);
276
+ }
277
+ std::thread::sleep(backoff);
278
+ backoff = (backoff * 2).min(std::time::Duration::from_millis(50));
279
+ }
280
+ }
281
+ }
282
+ }
283
+
227
284
  // ---------------------------------------------------------------------
228
285
  // notify() SQL function + notifications schema
229
286
  // ---------------------------------------------------------------------
@@ -1556,9 +1613,10 @@ mod tests {
1556
1613
  // try_recv returns Err(Empty) for "alive but no msg",
1557
1614
  // Err(Disconnected) for "watcher died, sender cleared".
1558
1615
  // Use blocking recv with a poll instead.
1559
- match rx.recv_timeout(Duration::from_millis(100)) {
1560
- Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
1561
- _ => {}
1616
+ if let Err(std::sync::mpsc::RecvTimeoutError::Disconnected) =
1617
+ rx.recv_timeout(Duration::from_millis(100))
1618
+ {
1619
+ break;
1562
1620
  }
1563
1621
  }
1564
1622
  if std::time::Instant::now() > deadline {
@@ -1914,7 +1972,7 @@ mod tests {
1914
1972
  .map(|s| s.success())
1915
1973
  .unwrap_or(false)
1916
1974
  })
1917
- .map(|s| *s);
1975
+ .copied();
1918
1976
  let Some(python) = python else {
1919
1977
  eprintln!(
1920
1978
  "writer_killed_mid_workload_leaves_db_consistent: \
@@ -1992,11 +2050,10 @@ while True:
1992
2050
  );
1993
2051
  }
1994
2052
  if let Ok(c) = read_conn.query_row("SELECT count(*) FROM q", [], |r| r.get::<_, i64>(0))
2053
+ && c > 0
1995
2054
  {
1996
- if c > 0 {
1997
- high_water = c;
1998
- break;
1999
- }
2055
+ high_water = c;
2056
+ break;
2000
2057
  }
2001
2058
  std::thread::sleep(Duration::from_millis(50));
2002
2059
  }
@@ -2377,20 +2434,33 @@ while True:
2377
2434
  /// Run the wake/listen suite against the kernel-watch backend.
2378
2435
  /// Each commit separated by 20 ms ensures both the 1 ms poller
2379
2436
  /// and the kernel-watch loop have time to fire before the next.
2437
+ ///
2438
+ /// Windows: ReadDirectoryChangesW coalesces and drops notifications,
2439
+ /// so the no-missed-commit assertion below does not hold there —
2440
+ /// observed 3 wakes for 5 commits on a CI runner. kernel_watcher.rs
2441
+ /// documents missed wakes as permitted, and this backend has no
2442
+ /// data_version verification or safety-net poll behind it, so there
2443
+ /// is nothing to recover the lost event. Windows still runs the test
2444
+ /// and enforces the runaway-wake bound; only its lower bound is waived.
2445
+ /// Linux and macOS deliver reliably and stay gated.
2380
2446
  #[test]
2381
2447
  #[cfg(feature = "kernel-watcher")]
2382
2448
  fn kernel_watcher_detects_all_commits() {
2383
2449
  use std::sync::atomic::{AtomicU32, Ordering as AO};
2384
2450
 
2385
- let tmp = std::env::temp_dir().join(format!(
2386
- "honker-kernel-watcher-{}-{}",
2451
+ // The kernel backend watches the database's parent directory.
2452
+ // Give this test an isolated directory so parallel tests and
2453
+ // unrelated /tmp activity cannot inflate the runaway-wake count.
2454
+ let tmp_dir = std::env::temp_dir().join(format!(
2455
+ "honker-kernel-watcher-dir-{}-{}",
2387
2456
  std::process::id(),
2388
2457
  std::time::SystemTime::now()
2389
2458
  .duration_since(std::time::UNIX_EPOCH)
2390
2459
  .unwrap()
2391
2460
  .subsec_nanos()
2392
2461
  ));
2393
- let _ = std::fs::remove_file(&tmp);
2462
+ std::fs::create_dir(&tmp_dir).unwrap();
2463
+ let tmp = tmp_dir.join("app.db");
2394
2464
 
2395
2465
  let writer = open_conn(tmp.to_str().unwrap(), false).unwrap();
2396
2466
  writer.execute_batch("CREATE TABLE t (x INT)").unwrap();
@@ -2432,19 +2502,391 @@ while True:
2432
2502
 
2433
2503
  let observed = count.load(AO::SeqCst);
2434
2504
  drop(watcher);
2505
+ drop(writer);
2435
2506
  let _ = std::fs::remove_file(&tmp);
2436
2507
  let _ = std::fs::remove_file(format!("{}-wal", tmp.display()));
2437
2508
  let _ = std::fs::remove_file(format!("{}-shm", tmp.display()));
2509
+ let _ = std::fs::remove_dir_all(&tmp_dir);
2438
2510
 
2439
2511
  // Experimental contract: spurious wakes are allowed (the backend
2440
2512
  // fires on every filesystem event, and SQLite produces several
2441
- // events per commit). The thing that must not happen is a *missed*
2442
- // commit assert at least n wakes.
2513
+ // events per commit). Keep a generous upper bound on every platform
2514
+ // so a runaway watcher cannot hide behind a Windows test carve-out.
2515
+ let upper = n * 200;
2516
+ assert!(
2517
+ observed <= upper,
2518
+ "kernel watcher detected {observed} wakes for {n} commits, \
2519
+ upper bound {upper} (runaway watcher?)"
2520
+ );
2521
+ #[cfg(not(windows))]
2443
2522
  assert!(
2444
2523
  observed >= n,
2445
2524
  "kernel watcher detected {observed} wakes for {n} commits — \
2446
2525
  missed at least one"
2447
2526
  );
2527
+ #[cfg(windows)]
2528
+ if observed < n {
2529
+ eprintln!(
2530
+ "kernel watcher under-delivered on Windows: \
2531
+ {observed} wakes for {n} commits"
2532
+ );
2533
+ }
2534
+ }
2535
+
2536
+ // -----------------------------------------------------------------
2537
+ // Issue #80 — the kernel backend must not release SQLite's locks
2538
+ // -----------------------------------------------------------------
2539
+
2540
+ /// Child-process half of the issue-#80 regression test. Inert unless
2541
+ /// `HONKER_ISSUE80_REAP_DB` is set; the parent re-execs this test
2542
+ /// binary with that variable so the open/close happens in a *separate
2543
+ /// process*. That matters: in WAL mode the last connection to close
2544
+ /// deletes `-wal` and `-shm`, but only if it can take an EXCLUSIVE
2545
+ /// lock on the main database file — which the parent's live connection
2546
+ /// is supposed to prevent.
2547
+ /// Byte offsets SQLite locks, from `os_unix.c`.
2548
+ /// `UNIX_SHM_BASE = (22 + SQLITE_SHM_NLOCK) * 4 = 120`; the deadman
2549
+ /// switch sits one past the eight lock slots, at 128. On the main
2550
+ /// database file, `SHARED_FIRST = PENDING_BYTE + 2` and
2551
+ /// `SHARED_SIZE = 510`.
2552
+ #[cfg(all(
2553
+ unix,
2554
+ feature = "bundled-sqlite",
2555
+ any(feature = "kernel-watcher", feature = "shm-fast-path")
2556
+ ))]
2557
+ const SHM_DMS_BYTE: libc::off_t = 128;
2558
+ #[cfg(all(
2559
+ unix,
2560
+ feature = "bundled-sqlite",
2561
+ any(feature = "kernel-watcher", feature = "shm-fast-path")
2562
+ ))]
2563
+ const DB_SHARED_FIRST: libc::off_t = 0x4000_0000 + 2;
2564
+ #[cfg(all(
2565
+ unix,
2566
+ feature = "bundled-sqlite",
2567
+ any(feature = "kernel-watcher", feature = "shm-fast-path")
2568
+ ))]
2569
+ const DB_SHARED_SIZE: libc::off_t = 510;
2570
+
2571
+ /// Is any lock held on `[off, off+len)` of `path`?
2572
+ ///
2573
+ /// Asks for a write lock, so an existing read *or* write lock reports
2574
+ /// as a conflict. Must run in a different process than the lock holder:
2575
+ /// `F_GETLK` never reports the calling process's own locks.
2576
+ #[cfg(all(
2577
+ unix,
2578
+ feature = "bundled-sqlite",
2579
+ any(feature = "kernel-watcher", feature = "shm-fast-path")
2580
+ ))]
2581
+ fn lock_is_held(path: &std::path::Path, off: libc::off_t, len: libc::off_t) -> bool {
2582
+ let c = std::ffi::CString::new(std::os::unix::ffi::OsStrExt::as_bytes(path.as_os_str()))
2583
+ .unwrap();
2584
+ let fd = unsafe { libc::open(c.as_ptr(), libc::O_RDONLY) };
2585
+ assert!(fd >= 0, "lock probe: could not open {path:?}");
2586
+ let mut fl: libc::flock = unsafe { std::mem::zeroed() };
2587
+ fl.l_type = libc::F_WRLCK as libc::c_short;
2588
+ fl.l_whence = libc::SEEK_SET as libc::c_short;
2589
+ fl.l_start = off;
2590
+ fl.l_len = len;
2591
+ let rc = unsafe { libc::fcntl(fd, libc::F_GETLK, &mut fl) };
2592
+ unsafe { libc::close(fd) };
2593
+ assert_eq!(rc, 0, "lock probe: F_GETLK failed on {path:?}");
2594
+ fl.l_type != libc::F_UNLCK as libc::c_short
2595
+ }
2596
+
2597
+ /// Child-process half of the issue-#80 regression tests.
2598
+ ///
2599
+ /// Inert unless `HONKER_ISSUE80_PROBE_DB` is set; the parent re-execs
2600
+ /// this test binary with that variable, because `F_GETLK` never reports
2601
+ /// the calling process's own locks — the probe *must* be out-of-process.
2602
+ /// Prints one line the parent parses.
2603
+ #[test]
2604
+ #[cfg(all(
2605
+ unix,
2606
+ feature = "bundled-sqlite",
2607
+ any(feature = "kernel-watcher", feature = "shm-fast-path")
2608
+ ))]
2609
+ fn issue_80_lock_probe_child() {
2610
+ let Ok(path) = std::env::var("HONKER_ISSUE80_PROBE_DB") else {
2611
+ return;
2612
+ };
2613
+ let db = PathBuf::from(&path);
2614
+ let shm = PathBuf::from(format!("{path}-shm"));
2615
+ let dms = lock_is_held(&shm, SHM_DMS_BYTE, 1);
2616
+ let shared = lock_is_held(&db, DB_SHARED_FIRST, DB_SHARED_SIZE);
2617
+ println!("HONKER_ISSUE80_RESULT shm_dms={dms} db_shared={shared}");
2618
+ }
2619
+
2620
+ /// Which of SQLite's two lock-bearing files this process still holds.
2621
+ #[cfg(all(
2622
+ unix,
2623
+ feature = "bundled-sqlite",
2624
+ any(feature = "kernel-watcher", feature = "shm-fast-path")
2625
+ ))]
2626
+ #[derive(Debug, PartialEq, Eq)]
2627
+ struct SqliteLockState {
2628
+ /// The `-shm` deadman-switch lock. Held for a WAL connection's life.
2629
+ shm_dms: bool,
2630
+ /// The main database file's SHARED lock. Same lifetime in WAL mode.
2631
+ db_shared: bool,
2632
+ }
2633
+
2634
+ #[cfg(all(
2635
+ unix,
2636
+ feature = "bundled-sqlite",
2637
+ any(feature = "kernel-watcher", feature = "shm-fast-path")
2638
+ ))]
2639
+ fn probe_sqlite_locks_from_another_process(db_path: &std::path::Path) -> SqliteLockState {
2640
+ let exe = std::env::current_exe().expect("current_exe");
2641
+ let out = std::process::Command::new(exe)
2642
+ .args([
2643
+ "--exact",
2644
+ "tests::issue_80_lock_probe_child",
2645
+ "--test-threads=1",
2646
+ "--nocapture",
2647
+ ])
2648
+ .env("HONKER_ISSUE80_PROBE_DB", db_path)
2649
+ .output()
2650
+ .expect("spawn lock probe child");
2651
+ let stdout = String::from_utf8_lossy(&out.stdout);
2652
+ // `contains`, not `starts_with`: under --nocapture libtest prints
2653
+ // the test name without a trailing newline, so the child's own
2654
+ // println lands on the same line.
2655
+ let line = stdout
2656
+ .lines()
2657
+ .find(|l| l.contains("HONKER_ISSUE80_RESULT"))
2658
+ .unwrap_or_else(|| {
2659
+ panic!(
2660
+ "lock probe child produced no result: {stdout}{}",
2661
+ String::from_utf8_lossy(&out.stderr)
2662
+ )
2663
+ });
2664
+ SqliteLockState {
2665
+ shm_dms: line.contains("shm_dms=true"),
2666
+ db_shared: line.contains("db_shared=true"),
2667
+ }
2668
+ }
2669
+
2670
+ /// The control perturbation: raw `open()` + `close()` on the two files
2671
+ /// SQLite locks, i.e. what the pre-fix backends did. Every "real
2672
+ /// backend" arm is compared against this.
2673
+ #[cfg(all(
2674
+ unix,
2675
+ feature = "bundled-sqlite",
2676
+ any(feature = "kernel-watcher", feature = "shm-fast-path")
2677
+ ))]
2678
+ fn issue_80_raw_close_lock_bearing_files(db: &std::path::Path) {
2679
+ let shm = PathBuf::from(format!("{}-shm", db.display()));
2680
+ for path in [db, shm.as_path()] {
2681
+ let c =
2682
+ std::ffi::CString::new(std::os::unix::ffi::OsStrExt::as_bytes(path.as_os_str()))
2683
+ .unwrap();
2684
+ // O_RDONLY, not O_EVTONLY: this arm must compile and behave
2685
+ // identically on every unix, and the lock-drop does not depend
2686
+ // on the open mode.
2687
+ let fd = unsafe { libc::open(c.as_ptr(), libc::O_RDONLY) };
2688
+ assert!(fd >= 0, "control: could not open {path:?}");
2689
+ unsafe { libc::close(fd) };
2690
+ }
2691
+ }
2692
+
2693
+ /// Spawn `backend`, let it settle, then shut it down — while this
2694
+ /// process's SQLite connection is still open.
2695
+ ///
2696
+ /// Shutdown is the case that matters. Both backends hold their
2697
+ /// descriptors for the watcher's lifetime, so nothing is released
2698
+ /// until something closes them. A test that only spawns the watcher
2699
+ /// and never stops it passes against the broken code — that mistake
2700
+ /// was made once already while writing these tests.
2701
+ #[cfg(all(
2702
+ unix,
2703
+ feature = "bundled-sqlite",
2704
+ any(feature = "kernel-watcher", feature = "shm-fast-path")
2705
+ ))]
2706
+ fn issue_80_run_backend_then_shutdown(backend: WatcherBackend, db: &std::path::Path) {
2707
+ let watcher = UpdateWatcher::spawn_with_config(
2708
+ db.to_path_buf(),
2709
+ || {},
2710
+ WatcherConfig::with_backend(backend),
2711
+ );
2712
+ std::thread::sleep(Duration::from_millis(200));
2713
+ watcher.join().expect("watcher thread panicked");
2714
+ }
2715
+
2716
+ /// Hold a live WAL connection, apply `perturb`, then ask another
2717
+ /// process which of SQLite's locks this process still holds.
2718
+ ///
2719
+ /// Asserting the locks directly, rather than a downstream symptom, is
2720
+ /// deliberate. The two ways lock loss shows up are *different*: losing
2721
+ /// the database file's SHARED lock lets another process reap
2722
+ /// `-wal`/`-shm`, while losing only the `-shm` DMS lock instead lets a
2723
+ /// fresh connection win the deadman race and `ftruncate` `-shm` under a
2724
+ /// live mapping. A test that watches for reaping alone is blind to the
2725
+ /// second, and the shm-fast-path defect is exactly the second.
2726
+ #[cfg(all(
2727
+ unix,
2728
+ feature = "bundled-sqlite",
2729
+ any(feature = "kernel-watcher", feature = "shm-fast-path")
2730
+ ))]
2731
+ fn sqlite_locks_after(perturb: impl FnOnce(&std::path::Path)) -> SqliteLockState {
2732
+ let tmp = std::env::temp_dir().join(format!(
2733
+ "honker-issue80-{}-{}",
2734
+ std::process::id(),
2735
+ std::time::SystemTime::now()
2736
+ .duration_since(std::time::UNIX_EPOCH)
2737
+ .unwrap()
2738
+ .as_nanos()
2739
+ ));
2740
+ let wal = PathBuf::from(format!("{}-wal", tmp.display()));
2741
+ let shm = PathBuf::from(format!("{}-shm", tmp.display()));
2742
+ for p in [&tmp, &wal, &shm] {
2743
+ let _ = std::fs::remove_file(p);
2744
+ }
2745
+
2746
+ // Live WAL connection: takes a SHARED lock on the db file and the
2747
+ // DMS read lock on -shm, and holds both for its whole lifetime.
2748
+ let conn = open_conn(tmp.to_str().unwrap(), false).unwrap();
2749
+ conn.execute_batch("CREATE TABLE t (x INT)").unwrap();
2750
+ conn.execute("INSERT INTO t VALUES (1)", []).unwrap();
2751
+ let _: i64 = conn
2752
+ .query_row("SELECT count(*) FROM t", [], |r| r.get(0))
2753
+ .unwrap();
2754
+ assert!(wal.exists() && shm.exists(), "setup: -wal/-shm not created");
2755
+
2756
+ perturb(&tmp);
2757
+
2758
+ assert!(
2759
+ wal.exists() && shm.exists(),
2760
+ "precondition: -wal/-shm vanished before the lock probe ran"
2761
+ );
2762
+ let state = probe_sqlite_locks_from_another_process(&tmp);
2763
+
2764
+ drop(conn);
2765
+ for p in [&tmp, &wal, &shm] {
2766
+ let _ = std::fs::remove_file(p);
2767
+ }
2768
+ state
2769
+ }
2770
+
2771
+ /// Issue #80: the kernel-watch backend must never release this
2772
+ /// process's SQLite POSIX locks.
2773
+ ///
2774
+ /// When it does, another process's last-connection close deletes
2775
+ /// `-wal`/`-shm` out from under our live connection. The connection
2776
+ /// keeps working against unlinked files, so its next `COMMIT` returns
2777
+ /// `SQLITE_OK` and is then invisible to every other process — a
2778
+ /// successful commit that never lands. The SIGBUS seen in CI is the
2779
+ /// same lock loss taking the other branch, where a fresh connection
2780
+ /// wins the DMS race and `ftruncate`s `-shm` under a live mapping.
2781
+ ///
2782
+ /// # This test proves its own sensitivity
2783
+ ///
2784
+ /// It only means something where SQLite uses *process-scoped* POSIX
2785
+ /// locks (`fcntl(F_SETLK)`). Apple's system libsqlite3 uses
2786
+ /// `fcntl(F_OFD_SETLK)` instead — open-file-description locks, which a
2787
+ /// foreign `close()` cannot release — so on a macOS build linked
2788
+ /// against the system library the property is unfalsifiable and a
2789
+ /// green result would mean nothing. The control arm below asserts the
2790
+ /// defect *is* reproducible in this build before trusting the real
2791
+ /// arm. Build with `--features bundled-sqlite` on macOS.
2792
+ ///
2793
+ /// That is why the whole issue-#80 proof is gated on `bundled-sqlite`:
2794
+ /// it is the only feature that guarantees an upstream amalgamation,
2795
+ /// whose `os_unix.c` uses `fcntl(F_SETLK)`. CI runs the experimental
2796
+ /// backends with that feature on macOS and Windows.
2797
+ #[test]
2798
+ #[cfg(all(unix, feature = "kernel-watcher", feature = "bundled-sqlite"))]
2799
+ fn kernel_watcher_does_not_release_sqlite_wal_locks() {
2800
+ let held = SqliteLockState {
2801
+ shm_dms: true,
2802
+ db_shared: true,
2803
+ };
2804
+
2805
+ let control = sqlite_locks_after(issue_80_raw_close_lock_bearing_files);
2806
+ assert_ne!(
2807
+ control,
2808
+ held,
2809
+ "control arm did not reproduce the defect: a raw open()+close() on \
2810
+ the database file and -shm left SQLite's locks intact, so this \
2811
+ build cannot detect issue #80 at all. SQLite {} is linked; Apple's \
2812
+ system libsqlite3 uses F_OFD_SETLK, whose locks survive a foreign \
2813
+ close. Re-run with --features bundled-sqlite.",
2814
+ rusqlite::version()
2815
+ );
2816
+
2817
+ let baseline = sqlite_locks_after(|_| {});
2818
+ assert_eq!(
2819
+ baseline, held,
2820
+ "a live WAL connection should hold both the -shm deadman lock and \
2821
+ the database file's SHARED lock; the probe is measuring the wrong \
2822
+ byte ranges"
2823
+ );
2824
+
2825
+ let after = sqlite_locks_after(|db| {
2826
+ issue_80_run_backend_then_shutdown(WatcherBackend::KernelWatch, db)
2827
+ });
2828
+ assert_eq!(
2829
+ after, held,
2830
+ "kernel-watch backend released this process's SQLite locks on \
2831
+ shutdown while a live WAL connection was open. It must not hold a \
2832
+ descriptor on the database file or -shm — see \
2833
+ kernel_watcher::macos::candidate_paths and issue #80."
2834
+ );
2835
+ }
2836
+
2837
+ /// Issue #80, shm-fast-path half: the `-shm` fast path must never
2838
+ /// release this process's SQLite WAL-index locks either.
2839
+ ///
2840
+ /// This backend had the defect at three sites, and `probe()` is the
2841
+ /// worst of them: it ran on *every* `honker.open()`, after the caller's
2842
+ /// writer connection had already created `-wal`/`-shm`, so a plain
2843
+ /// `File::open` + drop dropped the whole process's WAL-index locks
2844
+ /// once per open — no churn or contention needed. The other two are
2845
+ /// the identity-change reopen and the watcher's own shutdown.
2846
+ ///
2847
+ /// Both arms below go through the same sensitivity control as the
2848
+ /// kernel test; see that test for why `bundled-sqlite` is required.
2849
+ #[test]
2850
+ #[cfg(all(unix, feature = "shm-fast-path", feature = "bundled-sqlite"))]
2851
+ fn shm_fast_path_does_not_release_sqlite_wal_locks() {
2852
+ let held = SqliteLockState {
2853
+ shm_dms: true,
2854
+ db_shared: true,
2855
+ };
2856
+
2857
+ let control = sqlite_locks_after(issue_80_raw_close_lock_bearing_files);
2858
+ assert_ne!(
2859
+ control,
2860
+ held,
2861
+ "control arm did not reproduce the defect, so this build cannot \
2862
+ detect issue #80. SQLite {} is linked. Re-run with \
2863
+ --features bundled-sqlite.",
2864
+ rusqlite::version()
2865
+ );
2866
+
2867
+ // probe() alone, with no watcher ever spawned. This is the site
2868
+ // that fired on every honker.open().
2869
+ let after_probe = sqlite_locks_after(|db| {
2870
+ WatcherBackend::ShmFastPath
2871
+ .probe(db)
2872
+ .expect("shm probe should succeed on a live WAL database");
2873
+ });
2874
+ assert_eq!(
2875
+ after_probe, held,
2876
+ "shm-fast-path probe() released this process's SQLite locks. \
2877
+ probe() must acquire its -shm descriptor through the registry, \
2878
+ never File::open + drop — see shm_watcher.rs and issue #80."
2879
+ );
2880
+
2881
+ // Full watcher lifecycle, shut down while the connection is live.
2882
+ let after_watcher = sqlite_locks_after(|db| {
2883
+ issue_80_run_backend_then_shutdown(WatcherBackend::ShmFastPath, db)
2884
+ });
2885
+ assert_eq!(
2886
+ after_watcher, held,
2887
+ "shm-fast-path released this process's SQLite locks on watcher \
2888
+ shutdown while a live WAL connection was open — see issue #80."
2889
+ );
2448
2890
  }
2449
2891
 
2450
2892
  /// Prove that the shm fast path fires on the same commits as the
@@ -2551,14 +2993,15 @@ while True:
2551
2993
  // backend.
2552
2994
  // -----------------------------------------------------------------
2553
2995
 
2554
- /// Drive `n` committed inserts through `writer`, spaced
2555
- /// `spacing_ms` apart, and return how many `on_change()` calls the
2556
- /// watcher observed (with the initial drain already deducted).
2996
+ /// Drive `n` committed inserts through `writer`, giving the watcher
2997
+ /// up to `observation_ms` to acknowledge each one before sending the
2998
+ /// next, and return how many `on_change()` calls were observed (with
2999
+ /// the initial drain already deducted).
2557
3000
  fn drive_and_count_wakes(
2558
3001
  backend: WatcherBackend,
2559
3002
  db_path: PathBuf,
2560
3003
  n: u32,
2561
- spacing_ms: u64,
3004
+ observation_ms: u64,
2562
3005
  ) -> u32 {
2563
3006
  use std::sync::atomic::{AtomicU32, Ordering as AO};
2564
3007
 
@@ -2581,7 +3024,10 @@ while True:
2581
3024
  writer
2582
3025
  .execute(&format!("INSERT INTO t VALUES ({i})"), [])
2583
3026
  .unwrap();
2584
- std::thread::sleep(Duration::from_millis(spacing_ms));
3027
+ let deadline = std::time::Instant::now() + Duration::from_millis(observation_ms);
3028
+ while count.load(AO::SeqCst) < i && std::time::Instant::now() < deadline {
3029
+ std::thread::sleep(Duration::from_millis(1));
3030
+ }
2585
3031
  }
2586
3032
  // Drain the slowest safety net (kernel = 500 ms) + one cycle.
2587
3033
  std::thread::sleep(Duration::from_millis(700));
@@ -2596,8 +3042,11 @@ while True:
2596
3042
  /// detects every committed insert. Tolerates +1 wake (a commit
2597
3043
  /// straddling the drain boundary) but does not tolerate misses.
2598
3044
  fn watcher_works_in_journal_mode(backend: WatcherBackend, mode: &str) {
2599
- let tmp = std::env::temp_dir().join(format!(
2600
- "honker-watcher-{}-{}-{}-{}",
3045
+ // Kernel watchers observe every entry event in the parent
3046
+ // directory. Isolate the database so upper bounds measure this
3047
+ // watcher, not unrelated files created by parallel tests.
3048
+ let tmp_dir = std::env::temp_dir().join(format!(
3049
+ "honker-watcher-dir-{}-{}-{}-{}",
2601
3050
  mode.to_ascii_lowercase(),
2602
3051
  std::process::id(),
2603
3052
  std::time::SystemTime::now()
@@ -2612,7 +3061,8 @@ while True:
2612
3061
  WatcherBackend::ShmFastPath => "shm",
2613
3062
  },
2614
3063
  ));
2615
- let _ = std::fs::remove_file(&tmp);
3064
+ std::fs::create_dir(&tmp_dir).unwrap();
3065
+ let tmp = tmp_dir.join("app.db");
2616
3066
 
2617
3067
  // Watcher inherits the file's journal mode, so set it before opening.
2618
3068
  let setup = Connection::open(&tmp).unwrap();
@@ -2646,13 +3096,17 @@ while True:
2646
3096
  };
2647
3097
 
2648
3098
  let n: u32 = 5;
2649
- let observed = drive_and_count_wakes(backend.clone(), tmp.clone(), n, 30);
3099
+ // Wait for each wake before sending the next commit. The watcher is
3100
+ // deliberately coalescing, so fixed delays can turn five distinct
3101
+ // test commits into four valid wakes under a loaded scheduler.
3102
+ let observed = drive_and_count_wakes(backend.clone(), tmp.clone(), n, 100);
2650
3103
 
2651
3104
  drop(_pinning);
2652
3105
  let _ = std::fs::remove_file(&tmp);
2653
3106
  let _ = std::fs::remove_file(format!("{}-wal", tmp.display()));
2654
3107
  let _ = std::fs::remove_file(format!("{}-shm", tmp.display()));
2655
3108
  let _ = std::fs::remove_file(format!("{}-journal", tmp.display()));
3109
+ let _ = std::fs::remove_dir_all(&tmp_dir);
2656
3110
 
2657
3111
  // Polling/shm dedupe → ~1 wake per commit. Kernel fires per
2658
3112
  // filesystem event (inotify is granular) → upper bound is just
@@ -2664,16 +3118,27 @@ while True:
2664
3118
  #[cfg(feature = "shm-fast-path")]
2665
3119
  WatcherBackend::ShmFastPath => n + 1,
2666
3120
  };
2667
- assert!(
2668
- observed >= n,
2669
- "journal_mode={mode}: observed {observed} wakes for {n} commits \
2670
- (missed at least one)"
2671
- );
2672
3121
  assert!(
2673
3122
  observed <= upper,
2674
3123
  "journal_mode={mode}: observed {observed} wakes for {n} commits, \
2675
3124
  upper bound {upper} (runaway watcher?)"
2676
3125
  );
3126
+ #[cfg(feature = "kernel-watcher")]
3127
+ let allows_missed_wakes = cfg!(windows) && matches!(backend, WatcherBackend::KernelWatch);
3128
+ #[cfg(not(feature = "kernel-watcher"))]
3129
+ let allows_missed_wakes = false;
3130
+ if allows_missed_wakes && observed < n {
3131
+ eprintln!(
3132
+ "journal_mode={mode}: kernel watcher under-delivered on Windows: \
3133
+ {observed} wakes for {n} commits"
3134
+ );
3135
+ } else {
3136
+ assert!(
3137
+ observed >= n,
3138
+ "journal_mode={mode}: observed {observed} wakes for {n} commits \
3139
+ (missed at least one)"
3140
+ );
3141
+ }
2677
3142
  }
2678
3143
 
2679
3144
  // ---- Polling × every supported journal mode (regression coverage) ----
@@ -2708,7 +3173,14 @@ while True:
2708
3173
  // attach to db + journal + dir to maximize coverage, and ship the
2709
3174
  // backend with documented "missed wakes possible" semantics, but
2710
3175
  // we don't gate CI on a behavior the kernel won't reliably deliver.
2711
- // WAL-mode kernel coverage stays mandatory (kernel_watcher_works_in_wal).
3176
+ //
3177
+ // Windows hits the same wall for a different reason:
3178
+ // ReadDirectoryChangesW coalesces and drops notifications, so the
3179
+ // `observed >= n` assertion in watcher_works_in_journal_mode is not
3180
+ // sound there either. Same call: don't gate CI on it.
3181
+ //
3182
+ // Linux remains gated on all three modes, and every platform except
3183
+ // Windows stays gated on WAL via kernel_watcher_detects_all_commits.
2712
3184
  #[test]
2713
3185
  #[cfg(feature = "kernel-watcher")]
2714
3186
  #[cfg_attr(
@@ -2850,10 +3322,6 @@ while True:
2850
3322
  ignore = "notify/kqueue can drop the watcher thread under CI load; functional kernel watcher tests still run"
2851
3323
  )]
2852
3324
  #[cfg(feature = "kernel-watcher")]
2853
- #[cfg_attr(
2854
- target_os = "macos",
2855
- ignore = "kqueue under CI load may deliver zero wakes"
2856
- )]
2857
3325
  fn kernel_watcher_wake_latency_is_event_driven() {
2858
3326
  let tmp = std::env::temp_dir().join(format!(
2859
3327
  "honker-kw-lat-{}-{}",
@@ -3305,3 +3773,76 @@ while True:
3305
3773
  let _ = std::fs::remove_file(format!("{}-shm", path.display()));
3306
3774
  }
3307
3775
  }
3776
+
3777
+ #[cfg(test)]
3778
+ mod open_race_tests {
3779
+ use std::sync::{Arc, Barrier};
3780
+
3781
+ /// Many connections opening the same fresh database at once must
3782
+ /// all succeed.
3783
+ ///
3784
+ /// `PRAGMA journal_mode = WAL` needs exclusive access to convert the
3785
+ /// journal, and SQLite does **not** run the busy handler for it, so
3786
+ /// `busy_timeout` buys nothing. Without the retry in
3787
+ /// `set_journal_mode_wal`, converters collide with each other and
3788
+ /// the losers fail outright with "database is locked". A worker pool
3789
+ /// starting against a fresh file is exactly that shape, which is
3790
+ /// what made `test_many_processes_enqueue_claim_and_ack_exactly_once`
3791
+ /// fail intermittently on Windows.
3792
+ ///
3793
+ /// The collision is converter-versus-converter, so it can only be
3794
+ /// provoked by racing — holding a read or write lock from a single
3795
+ /// other connection does not block the conversion. Hence the
3796
+ /// deliberately high pressure: enough rounds and openers that the
3797
+ /// old behavior fails essentially every time.
3798
+ #[test]
3799
+ fn concurrent_opens_never_return_database_is_locked() {
3800
+ const ROUNDS: usize = 24;
3801
+ const OPENERS: usize = 32;
3802
+
3803
+ for round in 0..ROUNDS {
3804
+ let dir = std::env::temp_dir().join(format!(
3805
+ "honker-open-race-{}-{round}-{:?}",
3806
+ std::process::id(),
3807
+ std::time::SystemTime::now()
3808
+ .duration_since(std::time::UNIX_EPOCH)
3809
+ .unwrap()
3810
+ .as_nanos()
3811
+ ));
3812
+ std::fs::create_dir_all(&dir).unwrap();
3813
+ let path = dir.join("pressure.db");
3814
+
3815
+ let barrier = Arc::new(Barrier::new(OPENERS));
3816
+ let handles: Vec<_> = (0..OPENERS)
3817
+ .map(|_| {
3818
+ let path = path.clone();
3819
+ let barrier = Arc::clone(&barrier);
3820
+ std::thread::spawn(move || {
3821
+ let conn = rusqlite::Connection::open(&path)?;
3822
+ // Converge on the conversion together.
3823
+ barrier.wait();
3824
+ super::apply_default_pragmas(&conn)?;
3825
+ let mode: String =
3826
+ conn.query_row("PRAGMA journal_mode", [], |row| row.get(0))?;
3827
+ assert!(
3828
+ mode.eq_ignore_ascii_case("wal"),
3829
+ "expected WAL after open, got {mode}"
3830
+ );
3831
+ Ok::<_, rusqlite::Error>(())
3832
+ })
3833
+ })
3834
+ .collect();
3835
+
3836
+ let failures: Vec<String> = handles
3837
+ .into_iter()
3838
+ .filter_map(|h| h.join().unwrap().err().map(|e| e.to_string()))
3839
+ .collect();
3840
+
3841
+ std::fs::remove_dir_all(&dir).ok();
3842
+ assert!(
3843
+ failures.is_empty(),
3844
+ "round {round}: concurrent opens failed: {failures:?}"
3845
+ );
3846
+ }
3847
+ }
3848
+ }