use std::panic::AssertUnwindSafe;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex, MutexGuard, OnceLock, PoisonError, mpsc};
use std::thread;
use std::time::{Duration, Instant};
use notify::{RecursiveMode, Watcher as _};
pub const SQLITE_WATCH_SWEEP: Duration = Duration::from_millis(5_000);
const SQLITE_WATCH_COALESCE: Duration = Duration::from_millis(10);
const SQLITE_WATCH_RECOVERY_BACKOFF_MIN: Duration = Duration::from_millis(100);
const SQLITE_WATCH_RECOVERY_BACKOFF_MAX: Duration = Duration::from_millis(30_000);
const MAX_REGISTRATIONS_IN_FLIGHT: usize = 64;
static REGISTRATIONS_IN_FLIGHT: AtomicUsize = AtomicUsize::new(0);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SqliteWatchTick {
Changed,
Sweep,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SqliteWatchControl {
Handled,
Failed,
Stop,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[must_use]
pub enum SqliteWatchJoin {
Joined,
JoinedAfterPanic,
UnjoinedOnWorkerThread,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum SqliteWatchRegistration {
Pending,
Registered,
Failed(String),
Cancelled,
}
#[derive(Debug, Clone)]
pub struct SqliteWatchStopHandle {
control: Arc<WatchControl>,
}
impl SqliteWatchStopHandle {
pub fn request_stop(&self) {
self.control.request_stop();
}
}
#[derive(Debug)]
struct WatchControl {
stop_requested: Arc<AtomicBool>,
wake: mpsc::Sender<()>,
}
impl WatchControl {
fn request_stop(&self) {
self.stop_requested.store(true, Ordering::Release);
let _ = self.wake.send(());
}
}
enum WorkerCustody {
Running(thread::JoinHandle<()>),
Joined(SqliteWatchJoin),
}
enum RegistrationSlot {
Pending,
Registered(Option<Box<dyn WatchSource>>),
Failed(String),
Cancelled,
}
struct InFlightPlace;
impl InFlightPlace {
fn take() -> Result<Self, usize> {
REGISTRATIONS_IN_FLIGHT
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |running| {
(running < MAX_REGISTRATIONS_IN_FLIGHT).then_some(running + 1)
})
.map(|_| Self)
}
}
impl Drop for InFlightPlace {
fn drop(&mut self) {
REGISTRATIONS_IN_FLIGHT.fetch_sub(1, Ordering::AcqRel);
}
}
struct RegistrationThread {
handle: thread::JoinHandle<()>,
directory: PathBuf,
name: String,
started: Instant,
}
impl RegistrationThread {
fn describe(&self) -> SqliteWatchRetainedRegistration {
SqliteWatchRetainedRegistration {
directory: self.directory.clone(),
thread_name: self.name.clone(),
running_for: self.started.elapsed(),
}
}
}
struct RegistrationCustody {
slot: Arc<Mutex<RegistrationSlot>>,
thread: Option<RegistrationThread>,
}
impl RegistrationCustody {
fn spawn(
watch_name: &str,
backend: Box<dyn WatchBackend>,
targets: WatchTargets,
wake: mpsc::Sender<()>,
stop_requested: Arc<AtomicBool>,
) -> Self {
let slot = Arc::new(Mutex::new(RegistrationSlot::Pending));
let place = match InFlightPlace::take() {
Ok(place) => place,
Err(running) => {
settle_failed(
&slot,
format!(
"{running} sqlite change watch notification registrations are already \
pending (at most {MAX_REGISTRATIONS_IN_FLIGHT})"
),
);
return Self { slot, thread: None };
}
};
let thread_slot = Arc::clone(&slot);
let directory = targets.parent.clone();
let name = format!("{watch_name}-register");
let started = Instant::now();
let spawned = thread::Builder::new().name(name.clone()).spawn(move || {
let _place = place;
register(
backend.as_ref(),
&targets,
wake,
&stop_requested,
&thread_slot,
);
});
match spawned {
Ok(handle) => Self {
slot,
thread: Some(RegistrationThread {
handle,
directory,
name,
started,
}),
},
Err(error) => {
settle_failed(
&slot,
format!("cannot start the sqlite change watch registration thread: {error}"),
);
Self { slot, thread: None }
}
}
}
fn state(&self) -> SqliteWatchRegistration {
match &*lock(&self.slot) {
RegistrationSlot::Pending => SqliteWatchRegistration::Pending,
RegistrationSlot::Registered(_) => SqliteWatchRegistration::Registered,
RegistrationSlot::Failed(reason) => SqliteWatchRegistration::Failed(reason.clone()),
RegistrationSlot::Cancelled => SqliteWatchRegistration::Cancelled,
}
}
fn pending(&self) -> Option<SqliteWatchRetainedRegistration> {
if !matches!(*lock(&self.slot), RegistrationSlot::Pending) {
return None;
}
self.thread.as_ref().map(RegistrationThread::describe)
}
fn stop(&mut self) {
let source = match &mut *lock(&self.slot) {
RegistrationSlot::Registered(source) => source.take(),
_ => None,
};
drop(source);
if self
.thread
.as_ref()
.is_some_and(|thread| thread.handle.is_finished())
&& let Some(thread) = self.thread.take()
{
let _ = thread.handle.join();
}
}
fn release(&mut self) {
self.stop();
if let Some(thread) = self.thread.take() {
retain_registration(thread);
}
}
}
pub struct SqliteChangeWatch {
control: Arc<WatchControl>,
registration: RegistrationCustody,
worker: WorkerCustody,
}
impl std::fmt::Debug for SqliteChangeWatch {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SqliteChangeWatch").finish_non_exhaustive()
}
}
impl SqliteChangeWatch {
pub fn stop_handle(&self) -> SqliteWatchStopHandle {
SqliteWatchStopHandle {
control: Arc::clone(&self.control),
}
}
pub fn request_stop(&self) {
self.control.request_stop();
}
pub fn on_worker_thread(&self) -> bool {
self.worker_thread_id() == Some(thread::current().id())
}
pub fn worker_thread_id(&self) -> Option<thread::ThreadId> {
match &self.worker {
WorkerCustody::Running(worker) => Some(worker.thread().id()),
WorkerCustody::Joined(_) => None,
}
}
pub fn is_finished(&self) -> bool {
match &self.worker {
WorkerCustody::Running(worker) => worker.is_finished(),
WorkerCustody::Joined(_) => true,
}
}
pub fn registration(&self) -> SqliteWatchRegistration {
self.registration.state()
}
pub fn pending_registration(&self) -> Option<SqliteWatchRetainedRegistration> {
self.registration.pending()
}
pub fn stop_and_join(&mut self) -> SqliteWatchJoin {
self.control.request_stop();
self.registration.stop();
if self.on_worker_thread() {
return SqliteWatchJoin::UnjoinedOnWorkerThread;
}
let outcome = match std::mem::replace(
&mut self.worker,
WorkerCustody::Joined(SqliteWatchJoin::Joined),
) {
WorkerCustody::Running(worker) => match worker.join() {
Ok(()) => SqliteWatchJoin::Joined,
Err(_) => SqliteWatchJoin::JoinedAfterPanic,
},
WorkerCustody::Joined(outcome) => outcome,
};
self.worker = WorkerCustody::Joined(outcome);
outcome
}
}
impl Drop for SqliteChangeWatch {
fn drop(&mut self) {
self.control.request_stop();
self.registration.release();
}
}
static RETAINED_REGISTRATIONS: OnceLock<Mutex<Vec<RegistrationThread>>> = OnceLock::new();
fn retained() -> &'static Mutex<Vec<RegistrationThread>> {
RETAINED_REGISTRATIONS.get_or_init(Mutex::default)
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct SqliteWatchRetainedRegistration {
pub directory: PathBuf,
pub thread_name: String,
pub running_for: Duration,
}
pub fn retained_registrations() -> Vec<SqliteWatchRetainedRegistration> {
let mut retained = lock(retained());
reap(&mut retained);
retained.iter().map(RegistrationThread::describe).collect()
}
fn retain_registration(thread: RegistrationThread) {
let mut retained = lock(retained());
reap(&mut retained);
retained.push(thread);
}
fn reap(retained: &mut Vec<RegistrationThread>) {
let (returned, running): (Vec<_>, Vec<_>) = std::mem::take(retained)
.into_iter()
.partition(|thread| thread.handle.is_finished());
*retained = running;
for thread in returned {
let _ = thread.handle.join();
}
}
fn sidecar_path(path: &Path, suffix: &str) -> PathBuf {
let mut value = path.as_os_str().to_os_string();
value.push(suffix);
PathBuf::from(value)
}
pub fn sqlite_watch_paths(path: &Path) -> Vec<PathBuf> {
vec![
path.to_path_buf(),
sidecar_path(path, "-wal"),
sidecar_path(path, "-shm"),
]
}
fn sqlite_watch_targets(db_path: &Path) -> Result<(PathBuf, Vec<PathBuf>), String> {
let file_name = db_path
.file_name()
.ok_or_else(|| format!("{} has no database filename", db_path.display()))?;
let parent = db_path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."));
let parent = parent.canonicalize().map_err(|error| {
format!(
"cannot resolve {} for sqlite watch: {error}",
parent.display()
)
})?;
let watched_paths = sqlite_watch_paths(&parent.join(file_name));
Ok((parent, watched_paths))
}
pub fn sqlite_watch_event_relevant(
event: ¬ify::Event,
parent: &Path,
watched_paths: &[PathBuf],
) -> bool {
if matches!(event.kind, notify::EventKind::Access(_)) {
return false;
}
if event.paths.is_empty() {
return true;
}
event.paths.iter().any(|path| {
path == parent
|| watched_paths.iter().any(|watched| {
path == watched
|| (path.parent() == watched.parent()
&& path.file_name() == watched.file_name())
})
})
}
struct WatchTargets {
parent: PathBuf,
watched_paths: Vec<PathBuf>,
}
trait WatchBackend: Send {
fn create(
&self,
targets: &WatchTargets,
wake: mpsc::Sender<()>,
) -> Result<Box<dyn WatchSource>, String>;
}
trait WatchSource: Send {
fn watch(&mut self, directory: &Path) -> Result<(), String>;
}
struct NotifyBackend;
impl WatchBackend for NotifyBackend {
fn create(
&self,
targets: &WatchTargets,
wake: mpsc::Sender<()>,
) -> Result<Box<dyn WatchSource>, String> {
let parent = targets.parent.clone();
let watched_paths = targets.watched_paths.clone();
let watcher = notify::recommended_watcher(move |result: notify::Result<notify::Event>| {
match result {
Ok(event) if sqlite_watch_event_relevant(&event, &parent, &watched_paths) => {
let _ = wake.send(());
}
Ok(_) => {}
Err(error) => {
tracing::warn!(error = %error, "sqlite change watch reported an error");
}
}
})
.map_err(|error| format!("cannot create the sqlite change watcher: {error}"))?;
Ok(Box::new(NotifySource(watcher)))
}
}
struct NotifySource(notify::RecommendedWatcher);
impl WatchSource for NotifySource {
fn watch(&mut self, directory: &Path) -> Result<(), String> {
self.0
.watch(directory, RecursiveMode::NonRecursive)
.map_err(|error| format!("cannot watch {}: {error}", directory.display()))
}
}
#[cfg_attr(not(any(test, feature = "test-support")), allow(unused_variables))]
fn watch_backend(parent: &Path) -> Box<dyn WatchBackend> {
#[cfg(any(test, feature = "test-support"))]
if let Some(held) = test_support::held_backend(parent) {
return held;
}
Box::new(NotifyBackend)
}
fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
mutex.lock().unwrap_or_else(PoisonError::into_inner)
}
pub fn start_sqlite_change_watch(
db_path: &Path,
thread_name: &str,
sweep: Duration,
on_tick: impl FnMut(SqliteWatchTick) -> SqliteWatchControl + Send + 'static,
) -> Result<SqliteChangeWatch, String> {
start_with_backend(db_path, thread_name, sweep, on_tick, watch_backend)
}
fn start_with_backend(
db_path: &Path,
thread_name: &str,
sweep: Duration,
mut on_tick: impl FnMut(SqliteWatchTick) -> SqliteWatchControl + Send + 'static,
backend: impl FnOnce(&Path) -> Box<dyn WatchBackend>,
) -> Result<SqliteChangeWatch, String> {
let (parent, watched_paths) = sqlite_watch_targets(db_path)?;
let backend = backend(&parent);
let targets = WatchTargets {
parent,
watched_paths,
};
let (wake_tx, wake_rx) = mpsc::channel::<()>();
let stop_requested = Arc::new(AtomicBool::new(false));
let worker_stop_requested = Arc::clone(&stop_requested);
let worker = thread::Builder::new()
.name(thread_name.to_string())
.spawn(move || {
let mut recovery_backoff = SQLITE_WATCH_RECOVERY_BACKOFF_MIN;
let mut recovery_deadline: Option<Instant> = None;
loop {
let wait = recovery_deadline
.map(|deadline| deadline.saturating_duration_since(Instant::now()))
.unwrap_or(sweep);
let tick = match wake_rx.recv_timeout(wait) {
Ok(()) => SqliteWatchTick::Changed,
Err(mpsc::RecvTimeoutError::Timeout) => SqliteWatchTick::Sweep,
Err(mpsc::RecvTimeoutError::Disconnected) => break,
};
if worker_stop_requested.load(Ordering::Acquire) {
break;
}
if tick == SqliteWatchTick::Changed {
thread::sleep(SQLITE_WATCH_COALESCE);
}
while wake_rx.try_recv().is_ok() {}
if worker_stop_requested.load(Ordering::Acquire) {
break;
}
if recovery_deadline.is_some_and(|deadline| Instant::now() < deadline) {
continue;
}
match on_tick(tick) {
SqliteWatchControl::Handled => {
recovery_deadline = None;
recovery_backoff = SQLITE_WATCH_RECOVERY_BACKOFF_MIN;
}
SqliteWatchControl::Failed => {
recovery_deadline = Some(Instant::now() + recovery_backoff);
recovery_backoff = recovery_backoff
.saturating_mul(2)
.min(SQLITE_WATCH_RECOVERY_BACKOFF_MAX);
}
SqliteWatchControl::Stop => break,
}
}
})
.map_err(|error| format!("cannot start the sqlite change watch thread: {error}"))?;
let registration = RegistrationCustody::spawn(
thread_name,
backend,
targets,
wake_tx.clone(),
Arc::clone(&stop_requested),
);
Ok(SqliteChangeWatch {
control: Arc::new(WatchControl {
stop_requested,
wake: wake_tx,
}),
registration,
worker: WorkerCustody::Running(worker),
})
}
fn register(
backend: &dyn WatchBackend,
targets: &WatchTargets,
wake: mpsc::Sender<()>,
stop_requested: &AtomicBool,
slot: &Mutex<RegistrationSlot>,
) {
if stop_requested.load(Ordering::Acquire) {
*lock(slot) = RegistrationSlot::Cancelled;
return;
}
let mut source = match backend.create(targets, wake) {
Ok(source) => source,
Err(reason) => {
settle_error(slot, stop_requested, reason);
return;
}
};
match std::panic::catch_unwind(AssertUnwindSafe(|| source.watch(&targets.parent))) {
Ok(Ok(())) => {}
Ok(Err(reason)) => {
drop(source);
settle_error(slot, stop_requested, reason);
return;
}
Err(_) => {
std::mem::forget(source);
settle_failed(
slot,
"registering the notification source panicked: its event loop is not running"
.to_string(),
);
return;
}
}
{
let mut settled = lock(slot);
if !stop_requested.load(Ordering::Acquire) {
*settled = RegistrationSlot::Registered(Some(source));
return;
}
}
drop(source);
*lock(slot) = RegistrationSlot::Cancelled;
}
fn settle_error(slot: &Mutex<RegistrationSlot>, stop_requested: &AtomicBool, reason: String) {
if stop_requested.load(Ordering::Acquire) {
*lock(slot) = RegistrationSlot::Cancelled;
} else {
settle_failed(slot, reason);
}
}
fn settle_failed(slot: &Mutex<RegistrationSlot>, reason: String) {
tracing::warn!(
%reason,
"sqlite change watch notifications are unavailable; the watch runs on its sweep alone"
);
*lock(slot) = RegistrationSlot::Failed(reason);
}
#[cfg(any(test, feature = "test-support"))]
pub mod test_support {
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Condvar, Mutex, OnceLock, PoisonError, mpsc};
use std::time::{Duration, Instant};
use super::{WatchBackend, WatchSource, WatchTargets, lock};
static HOLDS: OnceLock<Mutex<HashMap<PathBuf, Arc<Hold>>>> = OnceLock::new();
fn holds() -> &'static Mutex<HashMap<PathBuf, Arc<Hold>>> {
HOLDS.get_or_init(Mutex::default)
}
#[derive(Default)]
struct Hold {
gate: Mutex<Gate>,
changed: Condvar,
live_sources: AtomicUsize,
dropped_sources: AtomicUsize,
}
#[derive(Default)]
struct Gate {
released: bool,
entered: usize,
}
pub struct HeldRegistrations {
directory: PathBuf,
hold: Arc<Hold>,
}
pub fn hold_registrations_in(directory: &Path) -> std::io::Result<HeldRegistrations> {
let directory = directory.canonicalize()?;
let hold = Arc::new(Hold::default());
let mut holds = lock(holds());
if holds.contains_key(&directory) {
return Err(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
format!("{} is already held", directory.display()),
));
}
holds.insert(directory.clone(), Arc::clone(&hold));
Ok(HeldRegistrations { directory, hold })
}
impl HeldRegistrations {
pub fn wait_entered(&self, count: usize, bound: Duration) -> bool {
let deadline = Instant::now() + bound;
let mut gate = lock(&self.hold.gate);
while gate.entered < count {
let Some(left) = deadline.checked_duration_since(Instant::now()) else {
return false;
};
gate = self
.hold
.changed
.wait_timeout(gate, left)
.unwrap_or_else(PoisonError::into_inner)
.0;
}
true
}
pub fn release(&self) {
lock(&self.hold.gate).released = true;
self.hold.changed.notify_all();
}
pub fn live_sources(&self) -> usize {
self.hold.live_sources.load(Ordering::SeqCst)
}
pub fn dropped_sources(&self) -> usize {
self.hold.dropped_sources.load(Ordering::SeqCst)
}
}
impl Drop for HeldRegistrations {
fn drop(&mut self) {
lock(holds()).remove(&self.directory);
self.release();
}
}
pub(super) fn held_backend(parent: &Path) -> Option<Box<dyn WatchBackend>> {
let hold = lock(holds()).get(parent).cloned()?;
Some(Box::new(HeldBackend(hold)))
}
struct HeldBackend(Arc<Hold>);
impl WatchBackend for HeldBackend {
fn create(
&self,
_targets: &WatchTargets,
wake: mpsc::Sender<()>,
) -> Result<Box<dyn WatchSource>, String> {
self.0.live_sources.fetch_add(1, Ordering::SeqCst);
Ok(Box::new(HeldSource {
hold: Arc::clone(&self.0),
_wake: wake,
}))
}
}
struct HeldSource {
hold: Arc<Hold>,
_wake: mpsc::Sender<()>,
}
impl WatchSource for HeldSource {
fn watch(&mut self, _directory: &Path) -> Result<(), String> {
let mut gate = lock(&self.hold.gate);
gate.entered += 1;
self.hold.changed.notify_all();
while !gate.released {
gate = self
.hold
.changed
.wait(gate)
.unwrap_or_else(PoisonError::into_inner);
}
Ok(())
}
}
impl Drop for HeldSource {
fn drop(&mut self) {
self.hold.live_sources.fetch_sub(1, Ordering::SeqCst);
self.hold.dropped_sources.fetch_add(1, Ordering::SeqCst);
}
}
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used)]
mod tests {
use super::*;
use std::sync::atomic::AtomicUsize;
fn settled_registration(watch: &SqliteChangeWatch) -> SqliteWatchRegistration {
let deadline = Instant::now() + HANG_GUARD;
loop {
let registration = watch.registration();
if registration != SqliteWatchRegistration::Pending || Instant::now() >= deadline {
return registration;
}
thread::sleep(Duration::from_millis(2));
}
}
fn retained_in(directory: &Path) -> Vec<SqliteWatchRetainedRegistration> {
retained_registrations()
.into_iter()
.filter(|retained| retained.directory == directory)
.collect()
}
fn assert_database_write_ticks_changed(db: &Path) {
std::fs::write(db, b"").unwrap();
let (tick_tx, tick_rx) = mpsc::channel();
let watch = start_sqlite_change_watch(db, "test-watch", SQLITE_WATCH_SWEEP, move |tick| {
let _ = tick_tx.send(tick);
SqliteWatchControl::Handled
})
.expect("watch starts");
assert_eq!(
settled_registration(&watch),
SqliteWatchRegistration::Registered
);
std::fs::write(sidecar_path(db, "-wal"), b"frame").unwrap();
let tick = tick_rx
.recv_timeout(Duration::from_secs(10))
.expect("the write is observed within the hang guard");
assert_eq!(tick, SqliteWatchTick::Changed);
}
#[test]
fn a_write_to_the_database_ticks_changed() {
let dir = tempfile::tempdir().unwrap();
assert_database_write_ticks_changed(&dir.path().join("store.sqlite3"));
}
#[cfg(unix)]
#[test]
fn symlinked_directory_targets_match_canonical_events_for_missing_files() {
let dir = tempfile::tempdir().unwrap();
let actual = dir.path().join("actual");
let alias = dir.path().join("alias");
std::fs::create_dir(&actual).unwrap();
std::os::unix::fs::symlink(&actual, &alias).unwrap();
let db = alias.join("store.sqlite3");
let (parent, watched_paths) = sqlite_watch_targets(&db).unwrap();
let canonical_parent = actual.canonicalize().unwrap();
assert_eq!(parent, canonical_parent);
assert_eq!(
watched_paths,
sqlite_watch_paths(&canonical_parent.join("store.sqlite3"))
);
assert!(!db.exists());
for path in &watched_paths {
assert!(!path.exists());
for kind in [
notify::EventKind::Create(notify::event::CreateKind::File),
notify::EventKind::Remove(notify::event::RemoveKind::File),
] {
let event = notify::Event::new(kind).add_path(path.clone());
assert!(sqlite_watch_event_relevant(&event, &parent, &watched_paths));
}
}
for path in [
canonical_parent.join("other.sqlite3-wal"),
canonical_parent.join("other").join("store.sqlite3-wal"),
] {
let event = notify::Event::new(notify::EventKind::Any).add_path(path);
assert!(!sqlite_watch_event_relevant(
&event,
&parent,
&watched_paths
));
}
let access = notify::Event::new(notify::EventKind::Access(notify::event::AccessKind::Any))
.add_path(watched_paths[0].clone());
assert!(!sqlite_watch_event_relevant(
&access,
&parent,
&watched_paths
));
}
#[cfg(unix)]
#[test]
fn a_write_through_a_symlinked_directory_ticks_changed() {
let dir = tempfile::tempdir().unwrap();
let actual = dir.path().join("actual");
let alias = dir.path().join("alias");
std::fs::create_dir(&actual).unwrap();
std::os::unix::fs::symlink(&actual, &alias).unwrap();
assert_database_write_ticks_changed(&alias.join("store.sqlite3"));
}
#[test]
fn the_sweep_ticks_without_a_notification() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("store.sqlite3");
let (tick_tx, tick_rx) = mpsc::channel();
let _watch =
start_sqlite_change_watch(&db, "test-sweep", Duration::from_millis(50), move |tick| {
let _ = tick_tx.send(tick);
SqliteWatchControl::Handled
})
.expect("watch starts");
let tick = tick_rx
.recv_timeout(Duration::from_secs(10))
.expect("the sweep ticks within the hang guard");
assert_eq!(tick, SqliteWatchTick::Sweep);
}
#[test]
fn stop_ends_the_worker() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("store.sqlite3");
let (tick_tx, tick_rx) = mpsc::channel();
let _watch =
start_sqlite_change_watch(&db, "test-stop", Duration::from_millis(20), move |tick| {
let _ = tick_tx.send(tick);
SqliteWatchControl::Stop
})
.expect("watch starts");
tick_rx
.recv_timeout(Duration::from_secs(10))
.expect("one tick");
assert!(
tick_rx.recv_timeout(Duration::from_millis(500)).is_err(),
"no tick after Stop: the worker ended (its sender is gone)"
);
}
fn wal_database(dir: &Path) -> PathBuf {
let db = dir.join("store.sqlite3");
let conn = crate::open(&db, crate::ConnectionProfile::PRIMARY).unwrap();
conn.execute_batch("CREATE TABLE t (v INTEGER); INSERT INTO t (v) VALUES (1);")
.unwrap();
db
}
struct TickProbe {
ticks: Arc<AtomicUsize>,
returned: mpsc::Sender<()>,
}
impl TickProbe {
fn tick(&self) {
self.ticks.fetch_add(1, Ordering::SeqCst);
}
}
impl Drop for TickProbe {
fn drop(&mut self) {
let _ = self.returned.send(());
}
}
#[test]
fn stop_and_join_waits_for_the_tick_in_flight() {
let dir = tempfile::tempdir().unwrap();
let db = wal_database(dir.path());
let (entered_tx, entered_rx) = mpsc::channel();
let (release_tx, release_rx) = mpsc::channel::<()>();
let finished = Arc::new(AtomicBool::new(false));
let tick_finished = Arc::clone(&finished);
let tick_db = db.clone();
let mut watch = start_sqlite_change_watch(
&db,
"test-held-tick",
Duration::from_millis(20),
move |_tick| {
if tick_finished.load(Ordering::SeqCst) {
return SqliteWatchControl::Handled;
}
let conn = crate::open(&tick_db, crate::ConnectionProfile::ReadOnly).unwrap();
let _ = entered_tx.send(());
let _ = release_rx.recv();
let value: i64 = conn
.query_row("SELECT v FROM t", [], |row| row.get(0))
.unwrap();
assert_eq!(value, 1);
drop(conn);
tick_finished.store(true, Ordering::SeqCst);
SqliteWatchControl::Handled
},
)
.expect("watch starts");
entered_rx
.recv_timeout(Duration::from_secs(10))
.expect("a tick is held within the hang guard");
let (stopping_tx, stopping_rx) = mpsc::channel();
let stopper_finished = Arc::clone(&finished);
let stopper = thread::spawn(move || {
let _ = stopping_tx.send(());
let outcome = watch.stop_and_join();
(outcome, stopper_finished.load(Ordering::SeqCst))
});
stopping_rx.recv().unwrap();
release_tx.send(()).unwrap();
let (outcome, finished_at_return) = stopper.join().unwrap();
assert!(
finished_at_return,
"stop_and_join returned while a tick still held its connection"
);
assert_eq!(outcome, SqliteWatchJoin::Joined);
std::fs::remove_dir_all(dir.path()).unwrap();
assert!(!dir.path().exists());
}
#[test]
fn stop_and_join_on_the_worker_thread_is_unjoined_and_keeps_custody() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("store.sqlite3");
let custody: Arc<std::sync::Mutex<Option<SqliteChangeWatch>>> = Arc::default();
let tick_custody = Arc::clone(&custody);
let (outcome_tx, outcome_rx) = mpsc::channel();
let watch = start_sqlite_change_watch(
&db,
"test-self-stop",
Duration::from_millis(20),
move |_| {
let outcome = tick_custody
.lock()
.unwrap()
.as_mut()
.map(SqliteChangeWatch::stop_and_join);
if let Some(outcome) = outcome {
let _ = outcome_tx.send(outcome);
}
SqliteWatchControl::Handled
},
)
.expect("watch starts");
*custody.lock().unwrap() = Some(watch);
let on_worker = outcome_rx
.recv_timeout(Duration::from_secs(10))
.expect("a tick stops its own watch within the hang guard");
assert_eq!(on_worker, SqliteWatchJoin::UnjoinedOnWorkerThread);
let mut watch = custody
.lock()
.unwrap()
.take()
.expect("the watch stays in custody");
assert!(!watch.on_worker_thread());
assert_eq!(watch.stop_and_join(), SqliteWatchJoin::Joined);
assert!(watch.is_finished());
assert_eq!(
watch.stop_and_join(),
SqliteWatchJoin::Joined,
"a repeated stop reports the recorded join"
);
}
#[test]
fn dropping_the_watch_requests_the_stop_without_blocking() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("store.sqlite3");
let (entered_tx, entered_rx) = mpsc::channel();
let (release_tx, release_rx) = mpsc::channel::<()>();
let (returned_tx, returned_rx) = mpsc::channel();
let ticks = Arc::new(AtomicUsize::new(0));
let probe = TickProbe {
ticks: Arc::clone(&ticks),
returned: returned_tx,
};
let watch =
start_sqlite_change_watch(&db, "test-drop", Duration::from_millis(20), move |_| {
probe.tick();
let _ = entered_tx.send(());
let _ = release_rx.recv();
SqliteWatchControl::Handled
})
.expect("watch starts");
entered_rx
.recv_timeout(Duration::from_secs(10))
.expect("a tick is held within the hang guard");
drop(watch);
release_tx.send(()).unwrap();
returned_rx
.recv_timeout(Duration::from_secs(10))
.expect("the worker returns after the held tick");
assert_eq!(
ticks.load(Ordering::SeqCst),
1,
"no tick starts after the stop request"
);
}
const HELD_BOUND: Duration = Duration::from_secs(5);
const HANG_GUARD: Duration = Duration::from_secs(10);
#[test]
fn a_held_registration_does_not_hold_the_start() {
let dir = tempfile::tempdir().unwrap();
let held = test_support::hold_registrations_in(dir.path()).unwrap();
let db = dir.path().join("store.sqlite3");
let (started_tx, started_rx) = mpsc::channel();
let starter = thread::spawn(move || {
let watch =
start_sqlite_change_watch(&db, "test-held-start", SQLITE_WATCH_SWEEP, |_| {
SqliteWatchControl::Handled
});
let _ = started_tx.send(());
watch
});
assert!(
held.wait_entered(1, HANG_GUARD),
"the registration reached its platform call"
);
let started = started_rx.recv_timeout(HELD_BOUND);
held.release();
let watch = starter.join().unwrap().expect("watch starts");
assert_eq!(
started,
Ok(()),
"the start waited for the held notification registration"
);
drop(watch);
}
#[test]
fn the_sweep_ticks_while_the_registration_is_held() {
let dir = tempfile::tempdir().unwrap();
let held = test_support::hold_registrations_in(dir.path()).unwrap();
let db = dir.path().join("store.sqlite3");
let (tick_tx, tick_rx) = mpsc::channel();
let starter = thread::spawn(move || {
start_sqlite_change_watch(
&db,
"test-held-sweep",
Duration::from_millis(20),
move |tick| {
let _ = tick_tx.send(tick);
SqliteWatchControl::Handled
},
)
});
assert!(
held.wait_entered(1, HANG_GUARD),
"the registration reached its platform call"
);
let tick = tick_rx.recv_timeout(HELD_BOUND);
held.release();
let watch = starter.join().unwrap().expect("watch starts");
assert_eq!(
tick,
Ok(SqliteWatchTick::Sweep),
"no sweep ran while the notification registration was held"
);
drop(watch);
}
struct FailingBackend;
struct FailingSource;
impl WatchBackend for FailingBackend {
fn create(
&self,
_targets: &WatchTargets,
_wake: mpsc::Sender<()>,
) -> Result<Box<dyn WatchSource>, String> {
Ok(Box::new(FailingSource))
}
}
impl WatchSource for FailingSource {
fn watch(&mut self, directory: &Path) -> Result<(), String> {
Err(format!(
"cannot watch {}: the watch limit is reached",
directory.display()
))
}
}
#[test]
fn a_failed_registration_keeps_the_sweep() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("store.sqlite3");
let (tick_tx, tick_rx) = mpsc::channel();
let watch = start_with_backend(
&db,
"test-failed-registration",
Duration::from_millis(20),
move |tick| {
let _ = tick_tx.send(tick);
SqliteWatchControl::Handled
},
|_| Box::new(FailingBackend),
)
.expect("a failed notification registration leaves the watch on its sweep");
assert_eq!(tick_rx.recv_timeout(HANG_GUARD), Ok(SqliteWatchTick::Sweep));
drop(watch);
}
#[test]
fn stopping_cancels_a_held_registration_and_keeps_its_thread() {
let dir = tempfile::tempdir().unwrap();
let held = test_support::hold_registrations_in(dir.path()).unwrap();
let directory = dir.path().canonicalize().unwrap();
let db = dir.path().join("store.sqlite3");
let mut watch =
start_sqlite_change_watch(&db, "test-held-stop", Duration::from_millis(20), |_| {
SqliteWatchControl::Handled
})
.expect("watch starts");
assert!(
held.wait_entered(1, HANG_GUARD),
"the registration reached its platform call"
);
assert_eq!(watch.stop_and_join(), SqliteWatchJoin::Joined);
assert_eq!(watch.registration(), SqliteWatchRegistration::Pending);
assert_eq!(held.live_sources(), 1);
held.release();
assert_eq!(
settled_registration(&watch),
SqliteWatchRegistration::Cancelled
);
assert_eq!((held.live_sources(), held.dropped_sources()), (0, 1));
assert!(retained_in(&directory).is_empty());
assert_eq!(watch.stop_and_join(), SqliteWatchJoin::Joined);
}
#[test]
fn dropping_a_watch_retains_its_held_registration_until_it_returns() {
let dir = tempfile::tempdir().unwrap();
let held = test_support::hold_registrations_in(dir.path()).unwrap();
let directory = dir.path().canonicalize().unwrap();
let db = dir.path().join("store.sqlite3");
let watch =
start_sqlite_change_watch(&db, "test-held-drop", Duration::from_millis(20), |_| {
SqliteWatchControl::Handled
})
.expect("watch starts");
assert!(
held.wait_entered(1, HANG_GUARD),
"the registration reached its platform call"
);
drop(watch);
let retained = retained_in(&directory);
assert_eq!(retained.len(), 1, "{retained:?}");
assert_eq!(retained[0].thread_name, "test-held-drop-register");
assert_eq!(held.live_sources(), 1);
held.release();
let deadline = Instant::now() + HANG_GUARD;
while !retained_in(&directory).is_empty() && Instant::now() < deadline {
thread::sleep(Duration::from_millis(2));
}
assert!(
retained_in(&directory).is_empty(),
"the process joins a retained registration once it returns"
);
assert_eq!((held.live_sources(), held.dropped_sources()), (0, 1));
}
#[test]
fn a_pending_registration_is_described_as_the_process_retains_it() {
let dir = tempfile::tempdir().unwrap();
let held = test_support::hold_registrations_in(dir.path()).unwrap();
let directory = dir.path().canonicalize().unwrap();
let db = dir.path().join("store.sqlite3");
let watch =
start_sqlite_change_watch(&db, "test-held-pending", Duration::from_millis(20), |_| {
SqliteWatchControl::Handled
})
.expect("watch starts");
assert!(
held.wait_entered(1, HANG_GUARD),
"the registration reached its platform call"
);
watch.request_stop();
let pending = watch
.pending_registration()
.expect("a held registration is pending");
assert_eq!(pending.directory, directory);
assert_eq!(pending.thread_name, "test-held-pending-register");
drop(watch);
let retained = retained_in(&directory);
assert_eq!(retained.len(), 1, "{retained:?}");
assert_eq!(
(&retained[0].directory, &retained[0].thread_name),
(&pending.directory, &pending.thread_name)
);
held.release();
let deadline = Instant::now() + HANG_GUARD;
while !retained_in(&directory).is_empty() && Instant::now() < deadline {
thread::sleep(Duration::from_millis(2));
}
assert!(retained_in(&directory).is_empty());
}
#[test]
fn a_settled_registration_is_not_pending() {
let dir = tempfile::tempdir().unwrap();
let held = test_support::hold_registrations_in(dir.path()).unwrap();
held.release();
let db = dir.path().join("store.sqlite3");
let watch =
start_sqlite_change_watch(&db, "test-settled-pending", SQLITE_WATCH_SWEEP, |_| {
SqliteWatchControl::Handled
})
.expect("watch starts");
assert_eq!(
settled_registration(&watch),
SqliteWatchRegistration::Registered
);
assert_eq!(watch.pending_registration(), None);
watch.request_stop();
assert_eq!(watch.pending_registration(), None);
}
#[test]
fn a_failed_registration_reports_its_reason() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("store.sqlite3");
let watch = start_with_backend(
&db,
"test-failed-reason",
SQLITE_WATCH_SWEEP,
|_| SqliteWatchControl::Handled,
|_| Box::new(FailingBackend),
)
.expect("watch starts");
let registration = settled_registration(&watch);
assert!(
matches!(
®istration,
SqliteWatchRegistration::Failed(reason)
if reason.contains("the watch limit is reached")
),
"{registration:?}"
);
}
const PANIC_CHILD: &str = "MEERKAT_TEST_SQLITE_WATCH_PANIC_CHILD";
const PANIC_CHILD_DONE: &str = "SQLITE_WATCH_PANIC_CONTAINED";
struct PanickingBackend;
struct PanickingSource;
impl WatchBackend for PanickingBackend {
fn create(
&self,
_targets: &WatchTargets,
_wake: mpsc::Sender<()>,
) -> Result<Box<dyn WatchSource>, String> {
Ok(Box::new(PanickingSource))
}
}
#[allow(clippy::panic)]
impl WatchSource for PanickingSource {
fn watch(&mut self, _directory: &Path) -> Result<(), String> {
panic!("the notification event loop is gone");
}
}
#[allow(clippy::panic)]
impl Drop for PanickingSource {
fn drop(&mut self) {
panic!("the notification event loop is gone");
}
}
#[test]
fn a_panicking_registration_leaves_the_sweep_running() {
if std::env::var_os(PANIC_CHILD).is_some() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("store.sqlite3");
let (tick_tx, tick_rx) = mpsc::channel();
let watch = start_with_backend(
&db,
"test-panicking-registration",
Duration::from_millis(20),
move |tick| {
let _ = tick_tx.send(tick);
SqliteWatchControl::Handled
},
|_| Box::new(PanickingBackend),
)
.expect("watch starts");
let registration = settled_registration(&watch);
assert!(
matches!(registration, SqliteWatchRegistration::Failed(_)),
"{registration:?}"
);
assert_eq!(tick_rx.recv_timeout(HANG_GUARD), Ok(SqliteWatchTick::Sweep));
drop(watch);
println!("\n{PANIC_CHILD_DONE}");
return;
}
#[allow(clippy::disallowed_methods)] let output = std::process::Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
"watch::tests::a_panicking_registration_leaves_the_sweep_running",
"--nocapture",
"--test-threads=1",
])
.env(PANIC_CHILD, "1")
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
output.status.success() && stdout.contains(PANIC_CHILD_DONE),
"the watch did not survive its panicking registration ({}):\n{stdout}\n{}",
output.status,
String::from_utf8_lossy(&output.stderr)
);
}
const ALONE_CHILD: &str = "MEERKAT_TEST_SQLITE_WATCH_ALONE_CHILD";
const ALONE_CHILD_DONE: &str = "SQLITE_WATCH_ALONE_TEST_PASSED";
fn alone_in_child_process(test: &str, body: impl FnOnce()) {
if std::env::var_os(ALONE_CHILD).is_some() {
body();
println!("\n{ALONE_CHILD_DONE}");
return;
}
#[allow(clippy::disallowed_methods)] let output = std::process::Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
&format!("watch::tests::{test}"),
"--nocapture",
"--test-threads=1",
])
.env(ALONE_CHILD, "1")
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
output.status.success() && stdout.contains(ALONE_CHILD_DONE),
"{test} failed alone in its child process ({}):\n{stdout}\n{}",
output.status,
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn registrations_past_the_cap_leave_new_watches_on_their_sweep() {
alone_in_child_process(
"registrations_past_the_cap_leave_new_watches_on_their_sweep",
|| {
assert_eq!(REGISTRATIONS_IN_FLIGHT.load(Ordering::Acquire), 0);
let dir = tempfile::tempdir().unwrap();
let held = test_support::hold_registrations_in(dir.path()).unwrap();
let watches: Vec<_> = (0..MAX_REGISTRATIONS_IN_FLIGHT)
.map(|index| {
start_sqlite_change_watch(
&dir.path().join(format!("store-{index}.sqlite3")),
"test-cap-held",
SQLITE_WATCH_SWEEP,
|_| SqliteWatchControl::Handled,
)
.expect("watch starts")
})
.collect();
assert!(
held.wait_entered(MAX_REGISTRATIONS_IN_FLIGHT, HANG_GUARD),
"every registration under the cap reached its platform call"
);
let (tick_tx, tick_rx) = mpsc::channel();
let over = start_sqlite_change_watch(
&dir.path().join("store-over.sqlite3"),
"test-cap-over",
Duration::from_millis(20),
move |tick| {
let _ = tick_tx.send(tick);
SqliteWatchControl::Handled
},
)
.expect("a watch past the cap still starts");
let registration = over.registration();
assert!(
matches!(
®istration,
SqliteWatchRegistration::Failed(reason) if reason.contains("already pending")
),
"{registration:?}"
);
assert!(
over.registration.thread.is_none(),
"no registration thread started past the cap"
);
assert_eq!(held.live_sources(), MAX_REGISTRATIONS_IN_FLIGHT);
assert_eq!(tick_rx.recv_timeout(HANG_GUARD), Ok(SqliteWatchTick::Sweep));
held.release();
for watch in &watches {
assert_eq!(
settled_registration(watch),
SqliteWatchRegistration::Registered
);
}
let deadline = Instant::now() + HANG_GUARD;
while REGISTRATIONS_IN_FLIGHT.load(Ordering::Acquire) > 0
&& Instant::now() < deadline
{
thread::sleep(Duration::from_millis(2));
}
let fresh_dir = tempfile::tempdir().unwrap();
let fresh = start_sqlite_change_watch(
&fresh_dir.path().join("store.sqlite3"),
"test-cap-fresh",
SQLITE_WATCH_SWEEP,
|_| SqliteWatchControl::Handled,
)
.expect("watch starts");
assert_eq!(
settled_registration(&fresh),
SqliteWatchRegistration::Registered,
"a fresh watch registers once every place is given back"
);
drop(watches);
drop(over);
},
);
}
#[test]
fn a_held_burst_of_64_registrations_all_reach_their_platform_call() {
alone_in_child_process(
"a_held_burst_of_64_registrations_all_reach_their_platform_call",
|| {
const BURST: usize = 64;
assert_eq!(REGISTRATIONS_IN_FLIGHT.load(Ordering::Acquire), 0);
let dir = tempfile::tempdir().unwrap();
let held = test_support::hold_registrations_in(dir.path()).unwrap();
let watches: Vec<_> = (0..BURST)
.map(|index| {
start_sqlite_change_watch(
&dir.path().join(format!("store-{index}.sqlite3")),
"test-burst-held",
SQLITE_WATCH_SWEEP,
|_| SqliteWatchControl::Handled,
)
.expect("watch starts")
})
.collect();
let refused = watches
.iter()
.filter(|watch| {
matches!(watch.registration(), SqliteWatchRegistration::Failed(_))
})
.count();
assert_eq!(refused, 0, "registrations of the burst refused at the cap");
assert!(
held.wait_entered(BURST, HANG_GUARD),
"every registration of the burst reached its platform call"
);
held.release();
for watch in &watches {
assert_eq!(
settled_registration(watch),
SqliteWatchRegistration::Registered
);
}
},
);
}
static WARNINGS: Mutex<Vec<(Option<String>, String)>> = Mutex::new(Vec::new());
struct WarningCapture;
struct MessageField(String);
impl tracing::field::Visit for MessageField {
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
if field.name() == "message" {
self.0 = format!("{value:?}");
}
}
}
impl tracing::Subscriber for WarningCapture {
fn enabled(&self, metadata: &tracing::Metadata<'_>) -> bool {
*metadata.level() == tracing::Level::WARN
}
fn new_span(&self, _span: &tracing::span::Attributes<'_>) -> tracing::span::Id {
tracing::span::Id::from_u64(1)
}
fn record(&self, _span: &tracing::span::Id, _values: &tracing::span::Record<'_>) {}
fn record_follows_from(&self, _span: &tracing::span::Id, _follows: &tracing::span::Id) {}
fn event(&self, event: &tracing::Event<'_>) {
let mut message = MessageField(String::new());
event.record(&mut message);
lock(&WARNINGS).push((thread::current().name().map(str::to_string), message.0));
}
fn enter(&self, _span: &tracing::span::Id) {}
fn exit(&self, _span: &tracing::span::Id) {}
}
fn capture_warnings() {
static INSTALLED: OnceLock<bool> = OnceLock::new();
let installed = *INSTALLED
.get_or_init(|| tracing::subscriber::set_global_default(WarningCapture).is_ok());
assert!(
installed,
"the warning capture is this process's global subscriber"
);
}
fn warnings_from(thread_name: &str) -> Vec<String> {
lock(&WARNINGS)
.iter()
.filter(|(name, _)| name.as_deref() == Some(thread_name))
.map(|(_, message)| message.clone())
.collect()
}
struct GatedFailingBackend {
entered: mpsc::Sender<()>,
release: Mutex<Option<mpsc::Receiver<()>>>,
}
struct GatedFailingSource {
entered: mpsc::Sender<()>,
release: mpsc::Receiver<()>,
}
impl WatchBackend for GatedFailingBackend {
fn create(
&self,
_targets: &WatchTargets,
_wake: mpsc::Sender<()>,
) -> Result<Box<dyn WatchSource>, String> {
let release = lock(&self.release)
.take()
.ok_or_else(|| "created twice".to_string())?;
Ok(Box::new(GatedFailingSource {
entered: self.entered.clone(),
release,
}))
}
}
impl WatchSource for GatedFailingSource {
fn watch(&mut self, directory: &Path) -> Result<(), String> {
let _ = self.entered.send(());
let _ = self.release.recv();
Err(format!(
"cannot watch {}: the watch limit is reached",
directory.display()
))
}
}
fn start_failing_after_release(
dir: &Path,
name: &str,
) -> (SqliteChangeWatch, mpsc::Receiver<()>, mpsc::Sender<()>) {
let (entered_tx, entered_rx) = mpsc::channel();
let (release_tx, release_rx) = mpsc::channel();
let backend = GatedFailingBackend {
entered: entered_tx,
release: Mutex::new(Some(release_rx)),
};
let watch = start_with_backend(
&dir.join("store.sqlite3"),
name,
SQLITE_WATCH_SWEEP,
|_| SqliteWatchControl::Handled,
move |_| Box::new(backend),
)
.expect("watch starts");
(watch, entered_rx, release_tx)
}
#[test]
fn a_registration_failing_after_its_watch_stopped_is_cancelled_quietly() {
capture_warnings();
let live_dir = tempfile::tempdir().unwrap();
let (live, live_entered, live_release) =
start_failing_after_release(live_dir.path(), "test-fail-live");
live_entered
.recv_timeout(HANG_GUARD)
.expect("the live registration reached its platform call");
live_release.send(()).unwrap();
assert!(matches!(
settled_registration(&live),
SqliteWatchRegistration::Failed(_)
));
assert_eq!(warnings_from("test-fail-live-register").len(), 1);
let stopped_dir = tempfile::tempdir().unwrap();
let (mut stopped, stopped_entered, stopped_release) =
start_failing_after_release(stopped_dir.path(), "test-fail-stopped");
stopped_entered
.recv_timeout(HANG_GUARD)
.expect("the registration reached its platform call");
assert_eq!(stopped.stop_and_join(), SqliteWatchJoin::Joined);
stopped_release.send(()).unwrap();
assert_eq!(
(
settled_registration(&stopped),
warnings_from("test-fail-stopped-register")
),
(SqliteWatchRegistration::Cancelled, Vec::<String>::new())
);
}
}