673 lines
25 KiB
Rust
673 lines
25 KiB
Rust
//! Interruptible, streaming search service.
|
||
//!
|
||
//! One dedicated worker thread owns the cascade. Callers send queries via
|
||
//! [`SearchService::search`]; results stream back over an mpsc receiver as
|
||
//! [`SearchUpdate`] events tagged with a generation number. Starting a new
|
||
//! search bumps the generation and interrupts the in-flight SQLite
|
||
//! statement, so a keystroke never waits on the previous query.
|
||
//!
|
||
//! Cancellation is two-layer:
|
||
//! - **cooperative** — the cascade compares its generation against the
|
||
//! latest every few hundred rows and stops silently when stale;
|
||
//! - **interrupt** — [`rusqlite::InterruptHandle::interrupt`] kills the
|
||
//! statement currently executing. An interrupted stale search is normal
|
||
//! cancellation, not an error.
|
||
//!
|
||
//! The interrupt handle is stored tagged with the generation that owns it,
|
||
//! and only ever fired at a generation the counter has already moved past:
|
||
//! interrupting the *current* generation would surface as "Search failed:
|
||
//! interrupted" instead of results, and the worker really can dequeue and
|
||
//! start a request before the caller that queued it gets back onto the CPU.
|
||
//!
|
||
//! Consumers that want a plain blocking search (the CLI mode) skip the
|
||
//! service entirely and call [`cascade::run`] with a collecting sink.
|
||
//!
|
||
//! The worker holds one connection across requests and releases it after
|
||
//! [`IDLE_RELEASE`] of quiet; [`Worker::take_connection`] covers when it
|
||
//! must be reopened instead of reused.
|
||
|
||
pub mod cascade;
|
||
pub mod duplicates;
|
||
pub mod fuzzy;
|
||
pub mod prefilter;
|
||
|
||
use std::path::{Path, PathBuf};
|
||
use std::sync::atomic::{AtomicU64, Ordering};
|
||
use std::sync::{mpsc, Arc, Mutex};
|
||
use std::thread::JoinHandle;
|
||
use std::time::Duration;
|
||
|
||
use rusqlite::Connection;
|
||
|
||
use crate::db;
|
||
use crate::query::split::split_for_cascade;
|
||
use crate::snippet::Snippet;
|
||
|
||
pub use cascade::Outcome;
|
||
pub use duplicates::{find_duplicate_groups, DuplicateGroup};
|
||
|
||
/// How long the worker keeps its connection after the last request. Not held
|
||
/// forever: an open handle on a deleted index keeps its blocks allocated on
|
||
/// disk, and an open reader stops SQLite from resetting the WAL.
|
||
const IDLE_RELEASE: Duration = Duration::from_secs(30);
|
||
|
||
/// One search result. `rank` is the sort key (lower = better): integer
|
||
/// part = cascade stage (1–11), fraction = occurrence-count or
|
||
/// edit-distance tiebreak. Batches arrive already rank-ordered and later
|
||
/// batches only append, so a rank-sorted view never reshuffles.
|
||
#[derive(Debug, Clone)]
|
||
pub struct SearchHit {
|
||
pub file_id: i64,
|
||
pub name: String,
|
||
pub path: String,
|
||
pub size: u64,
|
||
pub mtime: i64,
|
||
pub rank: f64,
|
||
pub stage: u8,
|
||
/// The matched span in context: the filename for name stages, the full
|
||
/// path for path stages, a window of the body for full-text stages
|
||
/// (absent there when document text isn't stored).
|
||
pub snippet: Option<Snippet>,
|
||
}
|
||
|
||
/// Which field a hit's [`SearchHit::snippet`] excerpts, derived from the
|
||
/// cascade stage. See the rank table at the top of [`crate::search::cascade`].
|
||
///
|
||
/// Frontends branch on this rather than on the raw stage number, so a new tier
|
||
/// is classified in one place instead of in every renderer.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum MatchField {
|
||
Name,
|
||
Contents,
|
||
Path,
|
||
}
|
||
|
||
/// How a [`MatchField::Contents`] hit matched its body — what has to be
|
||
/// re-run to cut its snippet again from the file as it now stands.
|
||
///
|
||
/// The two are not interchangeable: an exact tier's snippet is cut around the
|
||
/// literal term, and a fuzzy tier's around a bitap match the literal is
|
||
/// usually *absent* from. Re-cutting a fuzzy hit as if it were exact finds
|
||
/// nothing and reads as "the file no longer matches".
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum ContentTier {
|
||
/// Stages 5 and 6: the body contains the term as written.
|
||
Exact,
|
||
/// Stage 8: the body contains something within the fuzzy edit budget of
|
||
/// the term.
|
||
Fuzzy,
|
||
}
|
||
|
||
impl SearchHit {
|
||
pub fn match_field(&self) -> MatchField {
|
||
match self.stage {
|
||
1..=4 | 7 => MatchField::Name,
|
||
5 | 6 | 8 => MatchField::Contents,
|
||
// 9..=11, and whatever a later tier adds: the path is the safe
|
||
// reading, since it is the one field every hit carries in full.
|
||
_ => MatchField::Path,
|
||
}
|
||
}
|
||
|
||
/// `Some` for a hit whose snippet is a window on the file's body.
|
||
pub fn content_tier(&self) -> Option<ContentTier> {
|
||
match self.stage {
|
||
5 | 6 => Some(ContentTier::Exact),
|
||
8 => Some(ContentTier::Fuzzy),
|
||
_ => None,
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Debug, Clone)]
|
||
pub enum SearchUpdate {
|
||
Started {
|
||
generation: u64,
|
||
},
|
||
Hits {
|
||
generation: u64,
|
||
hits: Vec<SearchHit>,
|
||
},
|
||
Completed {
|
||
generation: u64,
|
||
total: usize,
|
||
limited: bool,
|
||
},
|
||
Error {
|
||
generation: u64,
|
||
message: String,
|
||
},
|
||
}
|
||
|
||
impl SearchUpdate {
|
||
pub fn generation(&self) -> u64 {
|
||
match self {
|
||
SearchUpdate::Started { generation }
|
||
| SearchUpdate::Hits { generation, .. }
|
||
| SearchUpdate::Completed { generation, .. }
|
||
| SearchUpdate::Error { generation, .. } => *generation,
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Debug, Clone)]
|
||
pub struct SearchOptions {
|
||
/// Enable the fuzzy stages (ranks 7, 8 and 11).
|
||
pub fuzzy: bool,
|
||
/// Ceiling on the fuzzy edit budget (`[search].fuzzy_max_edits`); see
|
||
/// [`fuzzy::edit_budget`]. 0 disables the fuzzy stages.
|
||
pub fuzzy_max_edits: usize,
|
||
/// Hard cap on total hits per search (`[search].display_limit`).
|
||
pub limit: usize,
|
||
/// Streaming batch size (`[search].results_per_page`).
|
||
pub batch: usize,
|
||
/// Session-scoped ignore patterns (GUI chips), same glob semantics as
|
||
/// the config's `ignore_patterns`. Applied before the display cap.
|
||
pub session_ignores: Vec<String>,
|
||
}
|
||
|
||
impl Default for SearchOptions {
|
||
fn default() -> Self {
|
||
SearchOptions {
|
||
fuzzy: false,
|
||
fuzzy_max_edits: 2,
|
||
limit: 1000,
|
||
batch: 100,
|
||
session_ignores: Vec::new(),
|
||
}
|
||
}
|
||
}
|
||
|
||
struct SearchRequest {
|
||
generation: u64,
|
||
input: String,
|
||
options: SearchOptions,
|
||
}
|
||
|
||
/// The search the worker is executing right now, and the handle that kills
|
||
/// its statement, tagged with the generation that owns it.
|
||
type InFlight = Arc<Mutex<Option<(u64, rusqlite::InterruptHandle)>>>;
|
||
|
||
/// One-shot channel the worker answers a release request on.
|
||
type ReleaseAck = Arc<Mutex<Option<mpsc::Sender<()>>>>;
|
||
|
||
/// How long [`SearchService::release_connection`] waits for the worker to
|
||
/// answer. Long enough for it to finish a statement and drop the handle,
|
||
/// short enough that a wedged worker cannot hold up a rebuild.
|
||
const RELEASE_WAIT: Duration = Duration::from_secs(2);
|
||
|
||
/// What the worker thread accepts. A search is the overwhelming majority;
|
||
/// the release exists because dropping the held connection is something only
|
||
/// the worker can do, and it is parked in a 30-second `recv_timeout` where a
|
||
/// flag would not reach it.
|
||
enum WorkerMsg {
|
||
Search(SearchRequest),
|
||
/// Drop the held connection now, so whoever asked can delete or replace
|
||
/// the index file. On Windows an open handle makes that fail outright;
|
||
/// everywhere else it keeps the deleted inode's blocks pinned.
|
||
ReleaseConnection,
|
||
}
|
||
|
||
pub struct SearchService {
|
||
req_tx: mpsc::Sender<WorkerMsg>,
|
||
latest_gen: Arc<AtomicU64>,
|
||
in_flight: InFlight,
|
||
db_path: Arc<Mutex<PathBuf>>,
|
||
/// Where the worker reports that it has let the connection go; see
|
||
/// [`SearchService::release_connection`].
|
||
release_ack: ReleaseAck,
|
||
handle: Option<JoinHandle<()>>,
|
||
}
|
||
|
||
impl SearchService {
|
||
/// Spawn the worker. `notify` is invoked after every update event so
|
||
/// an egui frontend can `request_repaint` (pass a no-op for headless
|
||
/// use). Returns the service handle plus the update receiver, which
|
||
/// the caller drains non-blockingly.
|
||
pub fn new(
|
||
db_path: PathBuf,
|
||
notify: Arc<dyn Fn() + Send + Sync>,
|
||
) -> (SearchService, mpsc::Receiver<SearchUpdate>) {
|
||
Self::new_with_idle_release(db_path, notify, IDLE_RELEASE)
|
||
}
|
||
|
||
/// [`Self::new`] with an explicit connection-release window (tests use
|
||
/// short ones).
|
||
pub fn new_with_idle_release(
|
||
db_path: PathBuf,
|
||
notify: Arc<dyn Fn() + Send + Sync>,
|
||
idle_release: Duration,
|
||
) -> (SearchService, mpsc::Receiver<SearchUpdate>) {
|
||
let (req_tx, req_rx) = mpsc::channel::<WorkerMsg>();
|
||
let (update_tx, update_rx) = mpsc::channel::<SearchUpdate>();
|
||
let latest_gen = Arc::new(AtomicU64::new(0));
|
||
let in_flight: InFlight = Arc::new(Mutex::new(None));
|
||
let db_path = Arc::new(Mutex::new(db_path));
|
||
let release_ack: ReleaseAck = Arc::new(Mutex::new(None));
|
||
|
||
let worker = Worker {
|
||
req_rx,
|
||
update_tx,
|
||
notify,
|
||
latest_gen: latest_gen.clone(),
|
||
in_flight: in_flight.clone(),
|
||
db_path: db_path.clone(),
|
||
release_ack: release_ack.clone(),
|
||
open: None,
|
||
idle_release,
|
||
};
|
||
let handle = std::thread::Builder::new()
|
||
.name("qs-search".into())
|
||
.spawn(move || worker.run())
|
||
.expect("spawn search worker");
|
||
|
||
(
|
||
SearchService {
|
||
req_tx,
|
||
latest_gen,
|
||
in_flight,
|
||
db_path,
|
||
release_ack,
|
||
handle: Some(handle),
|
||
},
|
||
update_rx,
|
||
)
|
||
}
|
||
|
||
/// Start a new search, cancelling any in-flight one. Returns the
|
||
/// generation whose events to keep.
|
||
pub fn search(&self, input: &str, options: SearchOptions) -> u64 {
|
||
let generation = self.latest_gen.fetch_add(1, Ordering::SeqCst) + 1;
|
||
// Interrupt before enqueueing: an idle worker can dequeue the new
|
||
// request and be mid-statement within microseconds.
|
||
self.interrupt_stale();
|
||
let _ = self.req_tx.send(WorkerMsg::Search(SearchRequest {
|
||
generation,
|
||
input: input.to_string(),
|
||
options,
|
||
}));
|
||
generation
|
||
}
|
||
|
||
/// Drop the connection the worker is holding, and wait briefly for it.
|
||
///
|
||
/// The worker keeps its connection for [`IDLE_RELEASE`] after the last
|
||
/// query so a typing session runs against a warm page cache. That is the
|
||
/// right default and the wrong thing to be holding when the index file is
|
||
/// about to be deleted: on Windows the delete fails outright, and a
|
||
/// "Rebuild index" that silently did not rebuild is worse than a slow one.
|
||
///
|
||
/// Cancelled first, and not merely as a courtesy. The worker coalesces its
|
||
/// queue, so a search enqueued just before this — a keystroke, then a
|
||
/// click on *Rebuild index* — is still current when the release is found,
|
||
/// and the worker would acknowledge the release and then **reopen** the
|
||
/// connection to serve it. Bumping the generation makes that request stale,
|
||
/// so it is dropped at the generation check instead. Results against a file
|
||
/// about to be deleted are worth nothing anyway.
|
||
///
|
||
/// Best-effort past that — it returns once the worker acknowledges or the
|
||
/// wait elapses, and the caller proceeds either way.
|
||
pub fn release_connection(&self) {
|
||
self.cancel();
|
||
let (ack_tx, ack_rx) = mpsc::channel();
|
||
*crate::lock_ok(&self.release_ack) = Some(ack_tx);
|
||
if self.req_tx.send(WorkerMsg::ReleaseConnection).is_err() {
|
||
return;
|
||
}
|
||
let _ = ack_rx.recv_timeout(RELEASE_WAIT);
|
||
}
|
||
|
||
/// Cancel without starting anything new.
|
||
pub fn cancel(&self) {
|
||
self.latest_gen.fetch_add(1, Ordering::SeqCst);
|
||
self.interrupt_stale();
|
||
}
|
||
|
||
/// Point subsequent searches at a different index file.
|
||
pub fn set_db_path(&self, path: PathBuf) {
|
||
*crate::lock_ok(&self.db_path) = path;
|
||
self.cancel();
|
||
}
|
||
|
||
/// Kill the running statement — but only if the generation counter has
|
||
/// already moved past the search that owns it. Interrupting the *newest*
|
||
/// search does not cancel it, it fails it as
|
||
/// `Search failed: interrupted`; and the worker can pick up a request
|
||
/// before the thread that queued it runs again.
|
||
fn interrupt_stale(&self) {
|
||
let latest = self.latest_gen.load(Ordering::SeqCst);
|
||
if let Some((generation, handle)) = crate::lock_ok(&self.in_flight).as_ref() {
|
||
if *generation != latest {
|
||
handle.interrupt();
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Cancel, close the request channel, and join the worker.
|
||
pub fn shutdown(self) {
|
||
self.cancel();
|
||
let SearchService { req_tx, handle, .. } = self;
|
||
drop(req_tx);
|
||
if let Some(handle) = handle {
|
||
let _ = handle.join();
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Map SQLite-level errors to the tagged strings frontends key off.
|
||
/// `DATABASE_CORRUPTED:` drives the GUI's recovery dialog.
|
||
pub fn classify_sql_err(error_msg: &str) -> String {
|
||
if error_msg.starts_with(db::KEY_MISMATCH_PREFIX) {
|
||
// Must never fall into the corruption bucket — the recovery dialog
|
||
// would offer to delete an index that is perfectly intact.
|
||
error_msg.to_string()
|
||
} else if error_msg.contains("malformed")
|
||
|| error_msg.contains("corrupt")
|
||
|| error_msg.contains("database disk image is malformed")
|
||
{
|
||
format!("DATABASE_CORRUPTED: {}", error_msg)
|
||
} else if error_msg.contains("fts5: syntax error") {
|
||
"Search syntax error: the search term contains characters that cannot be processed."
|
||
.to_string()
|
||
} else {
|
||
format!("Search failed: {}", error_msg)
|
||
}
|
||
}
|
||
|
||
struct Worker {
|
||
req_rx: mpsc::Receiver<WorkerMsg>,
|
||
update_tx: mpsc::Sender<SearchUpdate>,
|
||
notify: Arc<dyn Fn() + Send + Sync>,
|
||
latest_gen: Arc<AtomicU64>,
|
||
in_flight: InFlight,
|
||
db_path: Arc<Mutex<PathBuf>>,
|
||
release_ack: ReleaseAck,
|
||
/// The connection, and the index generation and path it was opened
|
||
/// against. See [`Worker::take_connection`].
|
||
open: Option<OpenIndex>,
|
||
/// How long `open` survives with no requests; [`IDLE_RELEASE`] outside
|
||
/// tests.
|
||
idle_release: Duration,
|
||
}
|
||
|
||
/// A connection held across requests, tagged with what it was opened on.
|
||
struct OpenIndex {
|
||
conn: Connection,
|
||
epoch: u64,
|
||
path: PathBuf,
|
||
}
|
||
|
||
impl Worker {
|
||
fn run(mut self) {
|
||
loop {
|
||
let first = match self.req_rx.recv_timeout(self.idle_release) {
|
||
Ok(WorkerMsg::ReleaseConnection) => {
|
||
self.release();
|
||
continue;
|
||
}
|
||
Ok(WorkerMsg::Search(req)) => req,
|
||
Err(mpsc::RecvTimeoutError::Timeout) => {
|
||
// Dropping the connection frees `PRAGMAS_SEARCH`'s 32 MiB
|
||
// page cache to glibc, which parks it in an arena rather
|
||
// than returning it to the kernel. Without the trim a
|
||
// single typing session raises the process floor by ~42 MiB
|
||
// for as long as it runs — measured on a 77k-file index,
|
||
// where an idle GUI sat at 76 MiB `RssAnon` instead of 34.
|
||
// Gated on there having *been* a connection so this is once
|
||
// per session→idle transition, never a repeating tick:
|
||
// `malloc_trim` walks every arena and costs milliseconds.
|
||
// The coordinator's writer settles the same way in
|
||
// `go_idle`.
|
||
if self.open.take().is_some() {
|
||
crate::platform::release_free_heap();
|
||
}
|
||
continue;
|
||
}
|
||
Err(mpsc::RecvTimeoutError::Disconnected) => return,
|
||
};
|
||
// A fast typist queues several requests; only the newest one
|
||
// matters. A release queued behind them is *not* superseded — it
|
||
// is the one message that must not be coalesced away, so it is
|
||
// handled where it is found. Whether the search below still runs
|
||
// is then the generation check's business:
|
||
// `release_connection` cancels before it sends, so anything
|
||
// enqueued ahead of the release is stale and drops out rather than
|
||
// reopening the connection that was just let go of.
|
||
let mut req = first;
|
||
while let Ok(newer) = self.req_rx.try_recv() {
|
||
match newer {
|
||
WorkerMsg::Search(newer) => req = newer,
|
||
WorkerMsg::ReleaseConnection => self.release(),
|
||
}
|
||
}
|
||
if req.generation != self.latest_gen.load(Ordering::SeqCst) {
|
||
continue;
|
||
}
|
||
self.handle(req);
|
||
}
|
||
}
|
||
|
||
/// Drop the held connection and tell whoever asked.
|
||
///
|
||
/// The same trim `run`'s idle path does, for the same reason: glibc parks
|
||
/// `PRAGMAS_SEARCH`'s page cache in an arena rather than returning it.
|
||
fn release(&mut self) {
|
||
if self.open.take().is_some() {
|
||
crate::platform::release_free_heap();
|
||
}
|
||
if let Some(ack) = crate::lock_ok(&self.release_ack).take() {
|
||
let _ = ack.send(());
|
||
}
|
||
}
|
||
|
||
/// Take the connection to run this request on, reopening if the one held
|
||
/// cannot be reused. Reuse is what keeps
|
||
/// [`crate::db::schema::PRAGMAS_SEARCH`]'s page cache warm across
|
||
/// keystrokes.
|
||
///
|
||
/// Reopened when either half of what it was opened against has changed:
|
||
/// the path (config points at a different index) or the epoch (the file
|
||
/// at the *same* path was replaced — rebuild, clear, schema-drift wipe —
|
||
/// which no path comparison can catch, and which would leave this worker
|
||
/// querying a deleted inode and pinning its blocks on disk).
|
||
fn take_connection(&mut self, db_path: &Path) -> Result<OpenIndex, String> {
|
||
let epoch = db::index_epoch();
|
||
if let Some(open) = self.open.take() {
|
||
if open.epoch == epoch && open.path == db_path {
|
||
return Ok(open);
|
||
}
|
||
// Dropped here, before the open below, so the handle on the old
|
||
// index is gone before a handle on the new one exists.
|
||
drop(open);
|
||
}
|
||
Ok(OpenIndex {
|
||
conn: db::open::open_search_reader(&db_path.to_string_lossy())?,
|
||
epoch,
|
||
path: db_path.to_path_buf(),
|
||
})
|
||
}
|
||
|
||
fn send(&self, update: SearchUpdate) {
|
||
let _ = self.update_tx.send(update);
|
||
(self.notify)();
|
||
}
|
||
|
||
fn handle(&mut self, req: SearchRequest) {
|
||
let generation = req.generation;
|
||
self.send(SearchUpdate::Started { generation });
|
||
|
||
let split = match split_for_cascade(&req.input) {
|
||
Ok(s) => s,
|
||
Err(e) => {
|
||
self.send(SearchUpdate::Error {
|
||
generation,
|
||
message: e.to_string(),
|
||
});
|
||
return;
|
||
}
|
||
};
|
||
|
||
let db_path = crate::lock_ok(&self.db_path).clone();
|
||
let open = match self.take_connection(&db_path) {
|
||
Ok(c) => c,
|
||
Err(e) => {
|
||
self.send(SearchUpdate::Error {
|
||
generation,
|
||
message: classify_sql_err(&e),
|
||
});
|
||
return;
|
||
}
|
||
};
|
||
// Publish the handle tagged with the generation it kills, before the
|
||
// first statement runs.
|
||
*crate::lock_ok(&self.in_flight) = Some((generation, open.conn.get_interrupt_handle()));
|
||
|
||
let mut sink = |hits: Vec<SearchHit>| {
|
||
self.send(SearchUpdate::Hits { generation, hits });
|
||
};
|
||
let outcome = cascade::run(
|
||
&open.conn,
|
||
&split,
|
||
&req.options,
|
||
generation,
|
||
&self.latest_gen,
|
||
&mut sink,
|
||
);
|
||
|
||
*crate::lock_ok(&self.in_flight) = None;
|
||
|
||
// Kept only if it still works: a failed cascade may have failed
|
||
// *because* of this connection, and putting it back would wedge every
|
||
// later search behind the same bad handle.
|
||
if outcome.is_ok() {
|
||
self.open = Some(open);
|
||
}
|
||
|
||
match outcome {
|
||
Ok(Some(Outcome { total, limited })) => self.send(SearchUpdate::Completed {
|
||
generation,
|
||
total,
|
||
limited,
|
||
}),
|
||
// Cancelled — the newer generation owns the UI now.
|
||
Ok(None) => {}
|
||
Err(e) => self.send(SearchUpdate::Error {
|
||
generation,
|
||
message: classify_sql_err(&e),
|
||
}),
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
/// Start a query long enough to be killed while it is executing, on its
|
||
/// own thread. Returns the handle that kills it and the result channel.
|
||
fn spawn_slow_query() -> (
|
||
rusqlite::InterruptHandle,
|
||
mpsc::Receiver<rusqlite::Result<i64>>,
|
||
) {
|
||
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||
let handle = conn.get_interrupt_handle();
|
||
let (tx, rx) = mpsc::channel();
|
||
std::thread::spawn(move || {
|
||
let counted = conn.query_row(
|
||
"WITH RECURSIVE c(x) AS (SELECT 1 UNION ALL SELECT x + 1 FROM c WHERE x < 1000000) \
|
||
SELECT count(*) FROM c",
|
||
[],
|
||
|row| row.get::<_, i64>(0),
|
||
);
|
||
let _ = tx.send(counted);
|
||
});
|
||
(handle, rx)
|
||
}
|
||
|
||
/// Cancel repeatedly for as long as the query runs, so every window in
|
||
/// which an interrupt could land is exercised rather than hoped past.
|
||
fn cancel_until_done(
|
||
service: &SearchService,
|
||
rx: &mpsc::Receiver<rusqlite::Result<i64>>,
|
||
) -> rusqlite::Result<i64> {
|
||
loop {
|
||
match rx.recv_timeout(std::time::Duration::from_millis(1)) {
|
||
Ok(result) => return result,
|
||
Err(mpsc::RecvTimeoutError::Timeout) => service.interrupt_stale(),
|
||
Err(mpsc::RecvTimeoutError::Disconnected) => panic!("query thread died"),
|
||
}
|
||
}
|
||
}
|
||
|
||
fn idle_service() -> SearchService {
|
||
// Nothing is ever enqueued, so the path is never opened.
|
||
SearchService::new(PathBuf::from("/nonexistent"), Arc::new(|| {})).0
|
||
}
|
||
|
||
/// Typing the next character must not kill the search that character
|
||
/// started: killing the newest generation surfaces as
|
||
/// "Search failed: interrupted" instead of results.
|
||
#[test]
|
||
fn cancelling_spares_the_newest_generation() {
|
||
let service = idle_service();
|
||
let (handle, rx) = spawn_slow_query();
|
||
service.latest_gen.store(7, Ordering::SeqCst);
|
||
*service.in_flight.lock().unwrap() = Some((7, handle));
|
||
|
||
let result = cancel_until_done(&service, &rx);
|
||
*service.in_flight.lock().unwrap() = None;
|
||
assert_eq!(
|
||
result.ok(),
|
||
Some(1_000_000),
|
||
"the newest generation was interrupted"
|
||
);
|
||
service.shutdown();
|
||
}
|
||
|
||
/// The other half: a generation the counter has moved past still dies
|
||
/// promptly, which is what keeps a keystroke from waiting on the previous
|
||
/// query.
|
||
#[test]
|
||
fn cancelling_kills_a_superseded_generation() {
|
||
let service = idle_service();
|
||
let (handle, rx) = spawn_slow_query();
|
||
service.latest_gen.store(8, Ordering::SeqCst);
|
||
*service.in_flight.lock().unwrap() = Some((7, handle));
|
||
|
||
let err = cancel_until_done(&service, &rx)
|
||
.expect_err("a superseded generation must be interrupted");
|
||
*service.in_flight.lock().unwrap() = None;
|
||
assert_eq!(
|
||
err.sqlite_error_code(),
|
||
Some(rusqlite::ErrorCode::OperationInterrupted),
|
||
"unexpected error: {}",
|
||
err
|
||
);
|
||
service.shutdown();
|
||
}
|
||
|
||
#[test]
|
||
fn key_mismatch_is_never_classified_as_corruption() {
|
||
let msg = format!(
|
||
"{}index at /tmp/x.sqlite: wrong password (or the file is not a QuickSearch index)",
|
||
db::KEY_MISMATCH_PREFIX
|
||
);
|
||
let classified = classify_sql_err(&msg);
|
||
assert_eq!(classified, msg, "must pass through verbatim");
|
||
assert!(!classified.starts_with("DATABASE_CORRUPTED:"));
|
||
|
||
// The raw SQLite wording for an undecryptable page must not land in
|
||
// the corruption bucket either; pin that.
|
||
let raw = "Failed to read database at /tmp/x.sqlite: file is not a database";
|
||
assert!(!classify_sql_err(raw).starts_with("DATABASE_CORRUPTED:"));
|
||
}
|
||
|
||
#[test]
|
||
fn corruption_and_syntax_classification_still_work() {
|
||
assert!(
|
||
classify_sql_err("database disk image is malformed").starts_with("DATABASE_CORRUPTED:")
|
||
);
|
||
assert!(classify_sql_err("fts5: syntax error near \"NEAR\"").starts_with("Search syntax"));
|
||
assert!(classify_sql_err("no such table: files").starts_with("Search failed:"));
|
||
}
|
||
}
|