Skip to main content

meerkat_sqlite/
watch.rs

1//! Cross-process change watch for one SQLite database file.
2//!
3//! Another process's commit to a database is invisible to this process's
4//! in-memory signals. This watch turns file-system notifications on the
5//! database, its `-wal` and `-shm` sidecars and their directory into
6//! coalesced ticks for a consumer that then reads durable state.
7//!
8//! A notification is a HINT, never a guarantee: SQLite writes WAL frames
9//! before the WAL index makes the commit visible, so a reader woken by an
10//! event can still read the old state, and no later event is promised. The
11//! watch therefore also ticks on a bounded sweep interval; the consumer's
12//! sweep read is the eventual-wake guarantee. Neither the watch nor the sweep
13//! ever defines permission, custody or expiry: correctness rests with the
14//! consumer's durable reads and its own fences.
15//!
16//! This is the contract the mob event bus has used since its external watch
17//! landed, extracted so every store shares one implementation.
18//!
19//! Teardown is explicit. Dropping a watch, or [`SqliteWatchStopHandle::request_stop`],
20//! only asks the worker to return and never blocks: a tick already running
21//! finishes on its own thread, unjoined. [`SqliteChangeWatch::stop_and_join`]
22//! is the one operation that waits for the worker, and it cannot do so from
23//! the worker's own thread.
24
25use std::path::{Path, PathBuf};
26use std::sync::atomic::{AtomicBool, Ordering};
27use std::sync::{Arc, mpsc};
28use std::thread;
29use std::time::{Duration, Instant};
30
31use notify::{RecursiveMode, Watcher as _};
32
33/// Default sweep interval: the bounded eventual-wake guarantee.
34pub const SQLITE_WATCH_SWEEP: Duration = Duration::from_millis(5_000);
35/// Settle delay after a notification, so a burst of events (a commit touches
36/// the WAL and its index) coalesces into one tick.
37const SQLITE_WATCH_COALESCE: Duration = Duration::from_millis(10);
38const SQLITE_WATCH_RECOVERY_BACKOFF_MIN: Duration = Duration::from_millis(100);
39const SQLITE_WATCH_RECOVERY_BACKOFF_MAX: Duration = Duration::from_millis(30_000);
40
41/// Why the consumer is being asked to look.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum SqliteWatchTick {
44    /// A relevant file-system notification arrived (coalesced).
45    Changed,
46    /// The sweep interval elapsed without a notification.
47    Sweep,
48}
49
50/// What the consumer's tick achieved.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum SqliteWatchControl {
53    /// The tick was handled.
54    Handled,
55    /// The consumer's durable read failed: ticks are suppressed until a
56    /// bounded exponential backoff elapses, so a notification storm cannot
57    /// hammer a failing store.
58    Failed,
59    /// The consumer is gone: stop the watch.
60    Stop,
61}
62
63/// How [`SqliteChangeWatch::stop_and_join`] ended.
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65#[must_use]
66pub enum SqliteWatchJoin {
67    /// The worker returned and was joined: no tick is running and none will
68    /// run.
69    Joined,
70    /// The worker was joined after a tick panicked: no tick is running and
71    /// none will run.
72    JoinedAfterPanic,
73    /// Called on the worker's own thread, from inside a tick. The stop is
74    /// requested, but a thread cannot join itself: the worker returns after
75    /// the current tick, unjoined. The join handle stays in this watch, so
76    /// whoever holds the watch must keep it and join it from another thread.
77    /// Never a settled outcome.
78    UnjoinedOnWorkerThread,
79}
80
81/// Asks a watch's worker to return, without owning the worker. Cheap to clone;
82/// never blocks and never joins.
83#[derive(Debug, Clone)]
84pub struct SqliteWatchStopHandle {
85    control: Arc<WatchControl>,
86}
87
88impl SqliteWatchStopHandle {
89    /// Ask the worker to return. Once it observes the request it starts no
90    /// further tick; a tick already running finishes first. Never blocks.
91    pub fn request_stop(&self) {
92        self.control.request_stop();
93    }
94}
95
96#[derive(Debug)]
97struct WatchControl {
98    stop_requested: Arc<AtomicBool>,
99    wake: mpsc::Sender<()>,
100}
101
102impl WatchControl {
103    fn request_stop(&self) {
104        self.stop_requested.store(true, Ordering::Release);
105        // Wake a worker waiting for its next tick. A worker that already
106        // returned has dropped the receiver; nothing is left to wake.
107        let _ = self.wake.send(());
108    }
109}
110
111/// Join custody of a watch's worker thread.
112enum WorkerCustody {
113    Running(thread::JoinHandle<()>),
114    Joined(SqliteWatchJoin),
115}
116
117/// A running watch: the notification source plus custody of its worker
118/// thread.
119///
120/// Dropping it requests the stop and never blocks or joins: the worker
121/// returns at its next wake, after any tick that is running, unjoined. Use
122/// [`Self::stop_and_join`] to wait for the worker.
123pub struct SqliteChangeWatch {
124    control: Arc<WatchControl>,
125    watcher: Option<notify::RecommendedWatcher>,
126    worker: WorkerCustody,
127}
128
129impl std::fmt::Debug for SqliteChangeWatch {
130    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
131        f.debug_struct("SqliteChangeWatch").finish_non_exhaustive()
132    }
133}
134
135impl SqliteChangeWatch {
136    /// A handle that can request this watch's stop after the watch itself has
137    /// moved elsewhere.
138    pub fn stop_handle(&self) -> SqliteWatchStopHandle {
139        SqliteWatchStopHandle {
140            control: Arc::clone(&self.control),
141        }
142    }
143
144    /// Ask the worker to return; see [`SqliteWatchStopHandle::request_stop`].
145    pub fn request_stop(&self) {
146        self.control.request_stop();
147    }
148
149    /// Whether the calling thread is this watch's worker, i.e. the caller is
150    /// running inside one of its ticks.
151    pub fn on_worker_thread(&self) -> bool {
152        self.worker_thread_id() == Some(thread::current().id())
153    }
154
155    /// The worker thread's identity until the worker is joined.
156    pub fn worker_thread_id(&self) -> Option<thread::ThreadId> {
157        match &self.worker {
158            WorkerCustody::Running(worker) => Some(worker.thread().id()),
159            WorkerCustody::Joined(_) => None,
160        }
161    }
162
163    /// Whether the worker has returned (joined or not). A returned worker
164    /// runs no tick again.
165    pub fn is_finished(&self) -> bool {
166        match &self.worker {
167            WorkerCustody::Running(worker) => worker.is_finished(),
168            WorkerCustody::Joined(_) => true,
169        }
170    }
171
172    /// Request the stop, stop the notifications, and wait for the worker to
173    /// return: a tick that is running finishes first. Unbounded by design: it
174    /// waits exactly as long as that tick runs.
175    ///
176    /// On the worker's own thread it cannot wait; it returns
177    /// [`SqliteWatchJoin::UnjoinedOnWorkerThread`] and keeps the join handle
178    /// in this watch, so a later call from another thread still joins.
179    /// Calling it again after a join returns the recorded outcome.
180    pub fn stop_and_join(&mut self) -> SqliteWatchJoin {
181        self.control.request_stop();
182        drop(self.watcher.take());
183        if self.on_worker_thread() {
184            return SqliteWatchJoin::UnjoinedOnWorkerThread;
185        }
186        let outcome = match std::mem::replace(
187            &mut self.worker,
188            WorkerCustody::Joined(SqliteWatchJoin::Joined),
189        ) {
190            WorkerCustody::Running(worker) => match worker.join() {
191                Ok(()) => SqliteWatchJoin::Joined,
192                Err(_) => SqliteWatchJoin::JoinedAfterPanic,
193            },
194            WorkerCustody::Joined(outcome) => outcome,
195        };
196        self.worker = WorkerCustody::Joined(outcome);
197        outcome
198    }
199}
200
201impl Drop for SqliteChangeWatch {
202    fn drop(&mut self) {
203        // Never block or join here: the last owner can drop a watch on any
204        // thread, including its own worker's.
205        self.control.request_stop();
206    }
207}
208
209fn sidecar_path(path: &Path, suffix: &str) -> PathBuf {
210    let mut value = path.as_os_str().to_os_string();
211    value.push(suffix);
212    PathBuf::from(value)
213}
214
215/// The database file and its sidecars.
216pub fn sqlite_watch_paths(path: &Path) -> Vec<PathBuf> {
217    vec![
218        path.to_path_buf(),
219        sidecar_path(path, "-wal"),
220        sidecar_path(path, "-shm"),
221    ]
222}
223
224fn sqlite_watch_targets(db_path: &Path) -> Result<(PathBuf, Vec<PathBuf>), String> {
225    let file_name = db_path
226        .file_name()
227        .ok_or_else(|| format!("{} has no database filename", db_path.display()))?;
228    let parent = db_path
229        .parent()
230        .filter(|parent| !parent.as_os_str().is_empty())
231        .unwrap_or_else(|| Path::new("."));
232    let parent = parent.canonicalize().map_err(|error| {
233        format!(
234            "cannot resolve {} for sqlite watch: {error}",
235            parent.display()
236        )
237    })?;
238    // FSEvents reports canonical paths, including /private/var for /var on
239    // macOS. Resolve only the existing directory: database and sidecar files
240    // may not exist yet, or may have been removed when an event is delivered.
241    let watched_paths = sqlite_watch_paths(&parent.join(file_name));
242    Ok((parent, watched_paths))
243}
244
245/// Whether a notification concerns the database (its files or directory).
246pub fn sqlite_watch_event_relevant(
247    event: &notify::Event,
248    parent: &Path,
249    watched_paths: &[PathBuf],
250) -> bool {
251    if matches!(event.kind, notify::EventKind::Access(_)) {
252        return false;
253    }
254    if event.paths.is_empty() {
255        return true;
256    }
257    event.paths.iter().any(|path| {
258        path == parent
259            || watched_paths.iter().any(|watched| {
260                path == watched
261                    || (path.parent() == watched.parent()
262                        && path.file_name() == watched.file_name())
263            })
264    })
265}
266
267/// Start watching `db_path`. `on_tick` runs on a dedicated thread named
268/// `thread_name`, once per coalesced notification and once per `sweep`
269/// without one, until the watch is stopped or `on_tick` returns
270/// [`SqliteWatchControl::Stop`].
271pub fn start_sqlite_change_watch(
272    db_path: &Path,
273    thread_name: &str,
274    sweep: Duration,
275    mut on_tick: impl FnMut(SqliteWatchTick) -> SqliteWatchControl + Send + 'static,
276) -> Result<SqliteChangeWatch, String> {
277    let (parent, watched_paths) = sqlite_watch_targets(db_path)?;
278    let (wake_tx, wake_rx) = mpsc::channel::<()>();
279    let stop_requested = Arc::new(AtomicBool::new(false));
280    let worker_stop_requested = Arc::clone(&stop_requested);
281
282    let callback_parent = parent.clone();
283    let notification_wake = wake_tx.clone();
284    let mut watcher = notify::recommended_watcher(move |result: notify::Result<notify::Event>| {
285        match result {
286            Ok(event) if sqlite_watch_event_relevant(&event, &callback_parent, &watched_paths) => {
287                let _ = notification_wake.send(());
288            }
289            Ok(_) => {}
290            Err(error) => {
291                // The sweep bounds anything a watcher error lost.
292                tracing::warn!(error = %error, "sqlite change watch reported an error");
293            }
294        }
295    })
296    .map_err(|error| format!("cannot create the sqlite change watcher: {error}"))?;
297    watcher
298        .watch(&parent, RecursiveMode::NonRecursive)
299        .map_err(|error| format!("cannot watch {}: {error}", parent.display()))?;
300
301    let worker = thread::Builder::new()
302        .name(thread_name.to_string())
303        .spawn(move || {
304            let mut recovery_backoff = SQLITE_WATCH_RECOVERY_BACKOFF_MIN;
305            let mut recovery_deadline: Option<Instant> = None;
306            loop {
307                let wait = recovery_deadline
308                    .map(|deadline| deadline.saturating_duration_since(Instant::now()))
309                    .unwrap_or(sweep);
310                let tick = match wake_rx.recv_timeout(wait) {
311                    Ok(()) => SqliteWatchTick::Changed,
312                    Err(mpsc::RecvTimeoutError::Timeout) => SqliteWatchTick::Sweep,
313                    // The watcher (and every sender) is gone: stop.
314                    Err(mpsc::RecvTimeoutError::Disconnected) => break,
315                };
316                if worker_stop_requested.load(Ordering::Acquire) {
317                    break;
318                }
319                if tick == SqliteWatchTick::Changed {
320                    thread::sleep(SQLITE_WATCH_COALESCE);
321                }
322                while wake_rx.try_recv().is_ok() {}
323                // A stop requested while the burst settled still precedes
324                // this tick.
325                if worker_stop_requested.load(Ordering::Acquire) {
326                    break;
327                }
328                if recovery_deadline.is_some_and(|deadline| Instant::now() < deadline) {
329                    // A notification storm must not bypass a failed read's
330                    // recovery deadline.
331                    continue;
332                }
333                match on_tick(tick) {
334                    SqliteWatchControl::Handled => {
335                        recovery_deadline = None;
336                        recovery_backoff = SQLITE_WATCH_RECOVERY_BACKOFF_MIN;
337                    }
338                    SqliteWatchControl::Failed => {
339                        recovery_deadline = Some(Instant::now() + recovery_backoff);
340                        recovery_backoff = recovery_backoff
341                            .saturating_mul(2)
342                            .min(SQLITE_WATCH_RECOVERY_BACKOFF_MAX);
343                    }
344                    SqliteWatchControl::Stop => break,
345                }
346            }
347        })
348        .map_err(|error| format!("cannot start the sqlite change watch thread: {error}"))?;
349
350    Ok(SqliteChangeWatch {
351        control: Arc::new(WatchControl {
352            stop_requested,
353            wake: wake_tx,
354        }),
355        watcher: Some(watcher),
356        worker: WorkerCustody::Running(worker),
357    })
358}
359
360#[cfg(test)]
361#[allow(clippy::expect_used, clippy::unwrap_used)]
362mod tests {
363    use super::*;
364    use std::sync::atomic::AtomicUsize;
365
366    fn assert_database_write_ticks_changed(db: &Path) {
367        std::fs::write(db, b"").unwrap();
368        let (tick_tx, tick_rx) = mpsc::channel();
369        let _watch = start_sqlite_change_watch(db, "test-watch", SQLITE_WATCH_SWEEP, move |tick| {
370            let _ = tick_tx.send(tick);
371            SqliteWatchControl::Handled
372        })
373        .expect("watch starts");
374        std::fs::write(sidecar_path(db, "-wal"), b"frame").unwrap();
375        let tick = tick_rx
376            .recv_timeout(Duration::from_secs(10))
377            .expect("the write is observed within the hang guard");
378        assert_eq!(tick, SqliteWatchTick::Changed);
379    }
380
381    #[test]
382    fn a_write_to_the_database_ticks_changed() {
383        let dir = tempfile::tempdir().unwrap();
384        assert_database_write_ticks_changed(&dir.path().join("store.sqlite3"));
385    }
386
387    #[cfg(unix)]
388    #[test]
389    fn symlinked_directory_targets_match_canonical_events_for_missing_files() {
390        let dir = tempfile::tempdir().unwrap();
391        let actual = dir.path().join("actual");
392        let alias = dir.path().join("alias");
393        std::fs::create_dir(&actual).unwrap();
394        std::os::unix::fs::symlink(&actual, &alias).unwrap();
395        let db = alias.join("store.sqlite3");
396        let (parent, watched_paths) = sqlite_watch_targets(&db).unwrap();
397        let canonical_parent = actual.canonicalize().unwrap();
398        assert_eq!(parent, canonical_parent);
399        assert_eq!(
400            watched_paths,
401            sqlite_watch_paths(&canonical_parent.join("store.sqlite3"))
402        );
403        assert!(!db.exists());
404        for path in &watched_paths {
405            assert!(!path.exists());
406            for kind in [
407                notify::EventKind::Create(notify::event::CreateKind::File),
408                notify::EventKind::Remove(notify::event::RemoveKind::File),
409            ] {
410                let event = notify::Event::new(kind).add_path(path.clone());
411                assert!(sqlite_watch_event_relevant(&event, &parent, &watched_paths));
412            }
413        }
414        for path in [
415            canonical_parent.join("other.sqlite3-wal"),
416            canonical_parent.join("other").join("store.sqlite3-wal"),
417        ] {
418            let event = notify::Event::new(notify::EventKind::Any).add_path(path);
419            assert!(!sqlite_watch_event_relevant(
420                &event,
421                &parent,
422                &watched_paths
423            ));
424        }
425        let access = notify::Event::new(notify::EventKind::Access(notify::event::AccessKind::Any))
426            .add_path(watched_paths[0].clone());
427        assert!(!sqlite_watch_event_relevant(
428            &access,
429            &parent,
430            &watched_paths
431        ));
432    }
433
434    #[cfg(unix)]
435    #[test]
436    fn a_write_through_a_symlinked_directory_ticks_changed() {
437        let dir = tempfile::tempdir().unwrap();
438        let actual = dir.path().join("actual");
439        let alias = dir.path().join("alias");
440        std::fs::create_dir(&actual).unwrap();
441        std::os::unix::fs::symlink(&actual, &alias).unwrap();
442        assert_database_write_ticks_changed(&alias.join("store.sqlite3"));
443    }
444
445    #[test]
446    fn the_sweep_ticks_without_a_notification() {
447        let dir = tempfile::tempdir().unwrap();
448        let db = dir.path().join("store.sqlite3");
449        let (tick_tx, tick_rx) = mpsc::channel();
450        let _watch =
451            start_sqlite_change_watch(&db, "test-sweep", Duration::from_millis(50), move |tick| {
452                let _ = tick_tx.send(tick);
453                SqliteWatchControl::Handled
454            })
455            .expect("watch starts");
456        let tick = tick_rx
457            .recv_timeout(Duration::from_secs(10))
458            .expect("the sweep ticks within the hang guard");
459        assert_eq!(tick, SqliteWatchTick::Sweep);
460    }
461
462    #[test]
463    fn stop_ends_the_worker() {
464        let dir = tempfile::tempdir().unwrap();
465        let db = dir.path().join("store.sqlite3");
466        let (tick_tx, tick_rx) = mpsc::channel();
467        let _watch =
468            start_sqlite_change_watch(&db, "test-stop", Duration::from_millis(20), move |tick| {
469                let _ = tick_tx.send(tick);
470                SqliteWatchControl::Stop
471            })
472            .expect("watch starts");
473        tick_rx
474            .recv_timeout(Duration::from_secs(10))
475            .expect("one tick");
476        assert!(
477            tick_rx.recv_timeout(Duration::from_millis(500)).is_err(),
478            "no tick after Stop: the worker ended (its sender is gone)"
479        );
480    }
481
482    /// A WAL database holding one row, as the stores keep theirs.
483    fn wal_database(dir: &Path) -> PathBuf {
484        let db = dir.join("store.sqlite3");
485        let conn = crate::open(&db, crate::ConnectionProfile::PRIMARY).unwrap();
486        conn.execute_batch("CREATE TABLE t (v INTEGER); INSERT INTO t (v) VALUES (1);")
487            .unwrap();
488        db
489    }
490
491    /// Counts ticks, and reports once when the worker drops its tick closure,
492    /// which it does as it returns.
493    struct TickProbe {
494        ticks: Arc<AtomicUsize>,
495        returned: mpsc::Sender<()>,
496    }
497
498    impl TickProbe {
499        fn tick(&self) {
500            self.ticks.fetch_add(1, Ordering::SeqCst);
501        }
502    }
503
504    impl Drop for TickProbe {
505        fn drop(&mut self) {
506            let _ = self.returned.send(());
507        }
508    }
509
510    #[test]
511    fn stop_and_join_waits_for_the_tick_in_flight() {
512        let dir = tempfile::tempdir().unwrap();
513        let db = wal_database(dir.path());
514        let (entered_tx, entered_rx) = mpsc::channel();
515        let (release_tx, release_rx) = mpsc::channel::<()>();
516        let finished = Arc::new(AtomicBool::new(false));
517        let tick_finished = Arc::clone(&finished);
518        let tick_db = db.clone();
519        let mut watch = start_sqlite_change_watch(
520            &db,
521            "test-held-tick",
522            Duration::from_millis(20),
523            move |_tick| {
524                if tick_finished.load(Ordering::SeqCst) {
525                    return SqliteWatchControl::Handled;
526                }
527                // The held tick keeps a read connection open, as a store
528                // catch-up does.
529                let conn = crate::open(&tick_db, crate::ConnectionProfile::ReadOnly).unwrap();
530                let _ = entered_tx.send(());
531                let _ = release_rx.recv();
532                let value: i64 = conn
533                    .query_row("SELECT v FROM t", [], |row| row.get(0))
534                    .unwrap();
535                assert_eq!(value, 1);
536                drop(conn);
537                tick_finished.store(true, Ordering::SeqCst);
538                SqliteWatchControl::Handled
539            },
540        )
541        .expect("watch starts");
542        entered_rx
543            .recv_timeout(Duration::from_secs(10))
544            .expect("a tick is held within the hang guard");
545
546        let (stopping_tx, stopping_rx) = mpsc::channel();
547        let stopper_finished = Arc::clone(&finished);
548        let stopper = thread::spawn(move || {
549            let _ = stopping_tx.send(());
550            let outcome = watch.stop_and_join();
551            (outcome, stopper_finished.load(Ordering::SeqCst))
552        });
553        stopping_rx.recv().unwrap();
554        release_tx.send(()).unwrap();
555        let (outcome, finished_at_return) = stopper.join().unwrap();
556
557        assert!(
558            finished_at_return,
559            "stop_and_join returned while a tick still held its connection"
560        );
561        assert_eq!(outcome, SqliteWatchJoin::Joined);
562        // The joined worker can never open the database again, so nothing
563        // recreates a file while the directory goes: no sleep, no retry.
564        std::fs::remove_dir_all(dir.path()).unwrap();
565        assert!(!dir.path().exists());
566    }
567
568    #[test]
569    fn stop_and_join_on_the_worker_thread_is_unjoined_and_keeps_custody() {
570        let dir = tempfile::tempdir().unwrap();
571        let db = dir.path().join("store.sqlite3");
572        let custody: Arc<std::sync::Mutex<Option<SqliteChangeWatch>>> = Arc::default();
573        let tick_custody = Arc::clone(&custody);
574        let (outcome_tx, outcome_rx) = mpsc::channel();
575        let watch = start_sqlite_change_watch(
576            &db,
577            "test-self-stop",
578            Duration::from_millis(20),
579            move |_| {
580                let outcome = tick_custody
581                    .lock()
582                    .unwrap()
583                    .as_mut()
584                    .map(SqliteChangeWatch::stop_and_join);
585                if let Some(outcome) = outcome {
586                    let _ = outcome_tx.send(outcome);
587                }
588                SqliteWatchControl::Handled
589            },
590        )
591        .expect("watch starts");
592        *custody.lock().unwrap() = Some(watch);
593
594        let on_worker = outcome_rx
595            .recv_timeout(Duration::from_secs(10))
596            .expect("a tick stops its own watch within the hang guard");
597        assert_eq!(on_worker, SqliteWatchJoin::UnjoinedOnWorkerThread);
598
599        // The self-stop kept the join handle, so from here the worker is
600        // really joined rather than reported as gone.
601        let mut watch = custody
602            .lock()
603            .unwrap()
604            .take()
605            .expect("the watch stays in custody");
606        assert!(!watch.on_worker_thread());
607        assert_eq!(watch.stop_and_join(), SqliteWatchJoin::Joined);
608        assert!(watch.is_finished());
609        assert_eq!(
610            watch.stop_and_join(),
611            SqliteWatchJoin::Joined,
612            "a repeated stop reports the recorded join"
613        );
614    }
615
616    #[test]
617    fn dropping_the_watch_requests_the_stop_without_blocking() {
618        let dir = tempfile::tempdir().unwrap();
619        let db = dir.path().join("store.sqlite3");
620        let (entered_tx, entered_rx) = mpsc::channel();
621        let (release_tx, release_rx) = mpsc::channel::<()>();
622        let (returned_tx, returned_rx) = mpsc::channel();
623        let ticks = Arc::new(AtomicUsize::new(0));
624        let probe = TickProbe {
625            ticks: Arc::clone(&ticks),
626            returned: returned_tx,
627        };
628        let watch =
629            start_sqlite_change_watch(&db, "test-drop", Duration::from_millis(20), move |_| {
630                probe.tick();
631                let _ = entered_tx.send(());
632                let _ = release_rx.recv();
633                SqliteWatchControl::Handled
634            })
635            .expect("watch starts");
636        entered_rx
637            .recv_timeout(Duration::from_secs(10))
638            .expect("a tick is held within the hang guard");
639
640        // Dropping never waits for the held tick: this thread releases the
641        // tick only after the drop returned.
642        drop(watch);
643        release_tx.send(()).unwrap();
644        returned_rx
645            .recv_timeout(Duration::from_secs(10))
646            .expect("the worker returns after the held tick");
647        assert_eq!(
648            ticks.load(Ordering::SeqCst),
649            1,
650            "no tick starts after the stop request"
651        );
652    }
653}