Skip to main content

plugmem_host/
workspace.rs

1//! A workspace: many databases under one directory, addressed by name.
2//!
3//! **The default is one database.** Nothing here switches on by itself — a
4//! caller that opens a [`crate::Database`] at a path behaves exactly as it did
5//! before this module existed. A workspace is what you reach for when one
6//! process serves many independent memories (a chat per conversation, a
7//! database per tenant) and wants them to stay independent.
8//!
9//! The layout is deliberately mechanical:
10//!
11//! ```text
12//! <root>/registry.plugmem      the registry — an ordinary plugmem database
13//! <root>/db/<name>.plugmem     the databases themselves
14//! ```
15//!
16//! Two properties fall out of it, and both are the point:
17//!
18//! - **a name is not a path, and cannot become one.** [`DbName`] admits only
19//!   `[a-z0-9][a-z0-9_-]*`, so `..`, `/`, a drive letter or an absolute path
20//!   are not filtered out — they are unrepresentable. Resolution is then a
21//!   join, with nothing to get wrong;
22//! - **the directory is the truth.** [`WorkspaceLayout::list`] reads the
23//!   filesystem, never the registry. The registry (see the `registry` module)
24//!   is a searchable index over descriptions and can be rebuilt from the
25//!   databases themselves; losing it costs search, not data.
26//!
27//! The registry lives in the root while the databases live one level down, so
28//! no name can ever collide with it.
29
30mod registry;
31
32use std::fmt;
33use std::ops::Deref;
34use std::path::{Path, PathBuf};
35use std::sync::atomic::{AtomicU64, Ordering};
36use std::sync::{Mutex, MutexGuard};
37
38use crate::{Database, HostError};
39
40pub use registry::{
41    ARCHIVED_TAG, DbEntry, Description, ENTRY_TAG, ReindexReport, SELF_ENTITY, WorkspaceIssue,
42};
43
44/// Longest database name a workspace accepts, in bytes.
45///
46/// Names are ASCII, so this is also the character count. The limit exists so a
47/// name plus the extension stays comfortably inside the shortest filename limit
48/// worth caring about (255 bytes on ext4/APFS/NTFS) with room for the sidecar
49/// suffixes (`.lock`, `.jrnl`, `.snap.N`) the storage layer appends.
50pub const MAX_DB_NAME: usize = 64;
51
52/// Directory, below the workspace root, holding the databases.
53const DB_DIR: &str = "db";
54
55/// The registry's file name, directly in the workspace root.
56const REGISTRY_FILE: &str = "registry.plugmem";
57
58/// Extension of a database file. The storage layer appends its sidecar
59/// suffixes *after* this (`chat-42.plugmem.lock`), so matching on the
60/// extension picks out base files and nothing else.
61const DB_EXT: &str = "plugmem";
62
63/// Names Windows resolves to devices rather than files, in every directory and
64/// **whatever extension is appended** — `con.plugmem` opens the console, not a
65/// file called `con.plugmem`.
66///
67/// Refused on every platform, not only on Windows. A workspace is a directory
68/// someone may copy between machines, and a memory that exists on Linux and
69/// silently becomes the printer port on Windows is the worst kind of portability
70/// bug: it appears at someone else's desk, on data that was already fine.
71///
72/// Written out rather than taken from a crate (`sanitize-filename` and friends
73/// exist) because those *rewrite* an arbitrary string into a safe filename,
74/// which is a different job: here the alphabet has already refused separators,
75/// dots, colons, `$`, control bytes, non-ASCII, uppercase and trailing spaces,
76/// and this list is the entire remainder. It is 22 strings, frozen by Windows
77/// for thirty years — `CONIN$` and the superscript `COM¹` forms cannot be
78/// spelled in the alphabet at all. A dependency would be carrying a sanitizer
79/// to do the part we already did.
80const RESERVED_DEVICE_NAMES: &[&str] = &[
81    "con", "prn", "aux", "nul", "com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8",
82    "com9", "lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9",
83];
84
85/// Why a string is not a usable database name.
86///
87/// A typed reason rather than a message, so a caller (and a test) can react to
88/// the specific problem instead of matching on prose.
89#[derive(Clone, Copy, Debug, PartialEq, Eq)]
90#[non_exhaustive]
91pub enum NameProblem {
92    /// The name was empty.
93    Empty,
94    /// The name was longer than [`MAX_DB_NAME`].
95    TooLong,
96    /// The first character was neither a lowercase ASCII letter nor a digit.
97    /// Leading `-`, `_` and `.` are refused so a name can never be read as a
98    /// flag or as a relative path component.
99    LeadingChar,
100    /// Some character was outside `[a-z0-9_-]`.
101    Character,
102    /// The name is a Windows device name (`con`, `nul`, `com1`, …). Refused
103    /// everywhere so a workspace stays portable between machines.
104    ReservedDevice,
105}
106
107impl fmt::Display for NameProblem {
108    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
109        match self {
110            Self::Empty => f.write_str("it is empty"),
111            Self::TooLong => write!(f, "it is longer than {MAX_DB_NAME} bytes"),
112            Self::LeadingChar => f.write_str("it must start with a lowercase letter or a digit"),
113            Self::Character => {
114                f.write_str("it may hold only lowercase letters, digits, '-' and '_'")
115            }
116            Self::ReservedDevice => f.write_str(
117                "it is a Windows device name, which would open a device rather than a file there",
118            ),
119        }
120    }
121}
122
123/// A validated database name — the only thing a workspace resolves.
124///
125/// Construct it with [`DbName::parse`]; there is no other way in, which is what
126/// makes "a name is not a path" a property of the type rather than a rule
127/// somebody has to remember at every call site.
128#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
129pub struct DbName(String);
130
131impl DbName {
132    /// Validates `s` as a database name.
133    ///
134    /// The rule: ASCII, first character `[a-z0-9]`, the rest `[a-z0-9_-]`,
135    /// length `1..=`[`MAX_DB_NAME`].
136    ///
137    /// # Errors
138    ///
139    /// [`WorkspaceError::BadName`] carrying the specific [`NameProblem`].
140    pub fn parse(s: &str) -> Result<Self, WorkspaceError> {
141        let bad = |why| {
142            Err(WorkspaceError::BadName {
143                name: s.to_string(),
144                why,
145            })
146        };
147        let Some(&first) = s.as_bytes().first() else {
148            return bad(NameProblem::Empty);
149        };
150        if !first.is_ascii_lowercase() && !first.is_ascii_digit() {
151            return bad(NameProblem::LeadingChar);
152        }
153        if !s.bytes().all(is_name_byte) {
154            return bad(NameProblem::Character);
155        }
156        if s.len() > MAX_DB_NAME {
157            return bad(NameProblem::TooLong);
158        }
159        if RESERVED_DEVICE_NAMES.contains(&s) {
160            return bad(NameProblem::ReservedDevice);
161        }
162        Ok(DbName(s.to_string()))
163    }
164
165    /// The name as a string slice.
166    pub fn as_str(&self) -> &str {
167        &self.0
168    }
169}
170
171impl fmt::Display for DbName {
172    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
173        f.write_str(&self.0)
174    }
175}
176
177/// True for a byte allowed anywhere in a name.
178fn is_name_byte(b: u8) -> bool {
179    b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_'
180}
181
182/// Every way a workspace operation can fail.
183///
184/// Opening one database still fails as a [`HostError`]; this type adds the
185/// failures that only exist once databases are addressed by name.
186#[derive(Debug, thiserror::Error)]
187#[non_exhaustive]
188pub enum WorkspaceError {
189    /// The string is not a usable database name.
190    #[error("{name:?} is not a usable database name: {why}")]
191    BadName {
192        /// The rejected string, as given.
193        name: String,
194        /// What specifically was wrong with it.
195        why: NameProblem,
196    },
197
198    /// No database of that name exists, and the caller asked not to create one.
199    #[error("no database named {name} in this workspace (looked for {})", path.display())]
200    NoSuchDatabase {
201        /// The name that did not resolve.
202        name: DbName,
203        /// Where it would have been.
204        path: PathBuf,
205    },
206
207    /// The database is open for writing elsewhere.
208    ///
209    /// Distinct from [`HostError::Locked`] so the message can name the database
210    /// rather than a path the caller never typed. One local database has one writer, and
211    /// in a workspace the other writer is usually a long-running sidecar that
212    /// will release it once the handle goes idle.
213    #[error(
214        "database {name} is in use by another process; it is released once that process closes it (a pooled handle does so after its idle timeout)"
215    )]
216    Busy {
217        /// The database that is held elsewhere.
218        name: DbName,
219    },
220
221    /// Every pooled database is in use, so opening another would exceed the
222    /// configured hard ceiling.
223    #[error(
224        "workspace has {max_open} active databases (the max_open limit); retry after one call finishes or raise the limit"
225    )]
226    AtCapacity {
227        /// The configured effective ceiling.
228        max_open: usize,
229    },
230
231    /// A caller asked to release a database while a scoped operation was using
232    /// it.
233    #[error("database {name} is in use by an active workspace operation")]
234    InUse {
235        /// The memory that cannot be released yet.
236        name: DbName,
237    },
238
239    /// A filesystem operation on the workspace itself failed.
240    #[error("i/o on {}: {source}", path.display())]
241    Io {
242        /// The path the operation touched.
243        path: PathBuf,
244        /// The underlying error.
245        #[source]
246        source: std::io::Error,
247    },
248
249    /// Opening or using one of the databases failed.
250    #[error(transparent)]
251    Host(#[from] HostError),
252}
253
254impl WorkspaceError {
255    /// Shorthand for wrapping an I/O error with its path.
256    pub(crate) fn io(path: &Path, source: std::io::Error) -> Self {
257        Self::Io {
258            path: path.to_path_buf(),
259            source,
260        }
261    }
262}
263
264/// Where a workspace keeps its files.
265///
266/// Pure path arithmetic and one directory listing — it opens nothing and locks
267/// nothing. A caller that only needs "which file is `work`?" (the CLI resolving
268/// `--db work`) wants this and not the handle pool.
269#[derive(Clone, Debug, PartialEq, Eq)]
270pub struct WorkspaceLayout {
271    root: PathBuf,
272}
273
274impl WorkspaceLayout {
275    /// A layout rooted at `root`. Creates nothing; the directories appear when
276    /// a database is first written.
277    pub fn new(root: impl Into<PathBuf>) -> Self {
278        Self { root: root.into() }
279    }
280
281    /// The workspace root.
282    pub fn root(&self) -> &Path {
283        &self.root
284    }
285
286    /// The directory holding the databases, `<root>/db`.
287    pub fn db_dir(&self) -> PathBuf {
288        self.root.join(DB_DIR)
289    }
290
291    /// The file backing `name`, `<root>/db/<name>.plugmem`.
292    ///
293    /// A join of validated components, so the result is always inside
294    /// [`WorkspaceLayout::db_dir`].
295    pub fn path_of(&self, name: &DbName) -> PathBuf {
296        self.db_dir().join(format!("{}.{DB_EXT}", name.0))
297    }
298
299    /// The registry's file, `<root>/registry.plugmem`. It sits in the root
300    /// rather than beside the databases, so no name can collide with it.
301    pub fn registry_path(&self) -> PathBuf {
302        self.root.join(REGISTRY_FILE)
303    }
304
305    /// Whether `name` is a database on disk.
306    ///
307    /// Asks the storage layer rather than stat-ing the path: a database that
308    /// has been written to but not yet checkpointed has no file at its base
309    /// path, and treating it as absent would mean creating over it.
310    pub fn exists(&self, name: &DbName) -> bool {
311        crate::storage::database_exists(&self.path_of(name))
312    }
313
314    /// Every database in the workspace, sorted by name.
315    ///
316    /// Reads the directory — the filesystem is the truth, the registry is only
317    /// an index over it. A missing `db/` is an empty workspace, not an error.
318    /// Files whose name is not a database's are skipped here and reported by
319    /// the registry's `verify`, which is where a person is asking about
320    /// consistency rather than about what they can open.
321    ///
322    /// One database is several files (`chat-42.plugmem`, `.journal`, `.lock`,
323    /// `.snap.N`), so the listing folds them back to one name each: a name
324    /// cannot contain a dot, so everything up to the first dot is the candidate,
325    /// and the storage layer confirms whether a database is really there.
326    ///
327    /// # Errors
328    ///
329    /// [`WorkspaceError::Io`] if the directory exists but cannot be read.
330    pub fn list(&self) -> Result<Vec<DbName>, WorkspaceError> {
331        let dir = self.db_dir();
332        let entries = match std::fs::read_dir(&dir) {
333            Ok(entries) => entries,
334            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
335            Err(e) => return Err(WorkspaceError::io(&dir, e)),
336        };
337        let mut names = Vec::new();
338        for entry in entries {
339            let entry = entry.map_err(|e| WorkspaceError::io(&dir, e))?;
340            let file_name = entry.file_name();
341            let Some(candidate) = file_name.to_str().and_then(|n| n.split('.').next()) else {
342                continue;
343            };
344            if let Ok(name) = DbName::parse(candidate)
345                && !names.contains(&name)
346                && self.exists(&name)
347            {
348                names.push(name);
349            }
350        }
351        names.sort_unstable();
352        Ok(names)
353    }
354}
355
356/// How many databases a workspace keeps open, and for how long.
357#[derive(Clone, Copy, Debug, PartialEq, Eq)]
358pub struct WorkspaceLimits {
359    /// Most databases held open at once. The least recently used is closed to
360    /// make room. `0` is read as `1` — a pool that holds nothing would reopen
361    /// on every call.
362    pub max_open: usize,
363    /// How long a database may sit unused before [`Workspace::close_idle`]
364    /// closes it. `0` disables the sweep, so handles stay until evicted.
365    ///
366    /// This is not a memory knob, it is a *liveness* knob: an open writer holds
367    /// the file's exclusive lock, so nothing else on the machine can touch that
368    /// database until the handle goes. A long-running server that never let go
369    /// would make its databases permanently unreachable from the CLI.
370    pub idle_timeout_ms: u64,
371}
372
373/// Handles kept open by default. Small on purpose: reopening a database the
374/// size of a chat is milliseconds, so caching is a latency win, not a
375/// requirement, and every extra handle is a lock somebody else cannot take.
376pub const DEFAULT_MAX_OPEN: usize = 16;
377
378/// Default idle window before a pooled handle is closed.
379pub const DEFAULT_IDLE_TIMEOUT_MS: u64 = 60_000;
380
381/// Descriptors one open database occupies: its lock, its journal, the mapped
382/// generation, and room for the staging file a checkpoint writes.
383const FILES_PER_OPEN_DATABASE: usize = 4;
384
385/// The lowest per-process descriptor limit worth planning for — the usual POSIX
386/// soft limit.
387const ASSUMED_FD_LIMIT: usize = 1024;
388
389/// Descriptors left for the rest of the process: stdio, the config file, the
390/// registry, and the embedder's sockets.
391const RESERVED_FDS: usize = 64;
392
393/// Most databases a pool will hold open, whatever it is configured with.
394///
395/// Derived rather than picked: `max_open` can come from a config file, and a
396/// file must not be able to talk a process into running itself out of file
397/// descriptors. Exceeding this would not fail at the pool — it would fail at
398/// some unrelated `open` elsewhere in the program, which is the worst place to
399/// find out.
400pub const MAX_OPEN_CEILING: usize = (ASSUMED_FD_LIMIT - RESERVED_FDS) / FILES_PER_OPEN_DATABASE;
401
402const _: () = {
403    assert!(MAX_OPEN_CEILING > DEFAULT_MAX_OPEN);
404};
405
406impl Default for WorkspaceLimits {
407    fn default() -> Self {
408        Self {
409            max_open: DEFAULT_MAX_OPEN,
410            idle_timeout_ms: DEFAULT_IDLE_TIMEOUT_MS,
411        }
412    }
413}
414
415impl WorkspaceLimits {
416    /// The effective ceiling: at least one, never more than
417    /// [`MAX_OPEN_CEILING`]. Clamped here, in one place, because `max_open` can
418    /// arrive from a config file and neither end of the range is safe to trust.
419    pub fn ceiling(&self) -> usize {
420        self.max_open.clamp(1, MAX_OPEN_CEILING)
421    }
422}
423
424/// Whether resolving a name that has no file yet creates one.
425#[derive(Clone, Copy, Debug, PartialEq, Eq)]
426pub enum IfMissing {
427    /// Create the database. What makes a workspace usable without a
428    /// registration step: a new chat writes, and its memory exists.
429    Create,
430    /// Fail with [`WorkspaceError::NoSuchDatabase`]. What reads should do, so a
431    /// misspelled name is diagnosed instead of silently answering nothing.
432    Fail,
433}
434
435/// Opens one database at a path. Supplied by the caller so this module stays
436/// out of the business of configuration — [`crate::Settings`] builds one that
437/// applies the engine config, the maintenance policy and a shared embedder.
438pub type Opener = Box<dyn Fn(&Path) -> Result<Database, HostError> + Send + Sync>;
439
440/// One open database and when it was last handed out.
441struct Pooled {
442    name: DbName,
443    db: Database,
444    last_used_ms: u64,
445    /// Scoped callers currently using `db`. An active entry is pinned: LRU,
446    /// the idle sweep and explicit release must leave it in the pool.
447    active: usize,
448    /// Stable identity for a lease even if unrelated Vec entries move.
449    token: u64,
450}
451
452/// A database borrowed from a [`Workspace`] for one operation.
453///
454/// Unlike [`Workspace::get`], this value participates in the pool's ownership
455/// accounting. While it lives, its entry cannot be evicted, swept as idle or
456/// explicitly released. Dropping it first drops the temporary [`Database`]
457/// clone and then marks the pooled entry inactive, so another caller can never
458/// evict the pool owner while an unaccounted clone still holds the file lock.
459///
460/// Language bindings use a lease inside one verb and never store it in their
461/// public objects. A long-lived FFI reference therefore names a memory without
462/// becoming another owner of its open file.
463pub struct WorkspaceLease<'a> {
464    workspace: &'a Workspace,
465    db: Option<Database>,
466    token: u64,
467}
468
469impl Deref for WorkspaceLease<'_> {
470    type Target = Database;
471
472    fn deref(&self) -> &Self::Target {
473        self.db
474            .as_ref()
475            .expect("a live workspace lease always owns its database")
476    }
477}
478
479impl Drop for WorkspaceLease<'_> {
480    fn drop(&mut self) {
481        // Drop the transient Arc before making the pool entry evictable. If the
482        // order were reversed, another thread could remove the pool owner and
483        // race an open against this lease's still-live file lock.
484        drop(self.db.take());
485        let mut pool = self.workspace.pooled();
486        if let Some(slot) = pool.iter_mut().find(|p| p.token == self.token) {
487            debug_assert!(slot.active > 0);
488            slot.active = slot.active.saturating_sub(1);
489        }
490    }
491}
492
493/// Many databases in one directory, opened on demand and kept open for a while.
494///
495/// The pool is a flat `Vec` rather than a map: `max_open` is a handful, and a
496/// linear scan of a handful is cheaper than hashing — the same reason the
497/// engine's own structures stay flat.
498///
499/// **A returned [`Database`] outlives its pool entry.** The handle is an `Arc`,
500/// so eviction drops the pool's copy and nothing else; the file lock is
501/// released when the *last* clone goes. A caller that parks a handle for hours
502/// keeps the database locked for hours, whatever the idle timeout says. Hold
503/// one for the length of a request, as the MCP server does, and this never
504/// comes up.
505///
506/// # What the pool lock covers
507///
508/// One `Mutex` guards the pool, and every path mutates it — a *hit* writes
509/// `last_used_ms`, so an `RwLock` would buy nothing and cost more. It is not
510/// the engine lock: a handle is cloned out and the verb runs with the pool
511/// released, so two threads working in two databases never meet here.
512///
513/// Two things are worth knowing about how long it is held.
514///
515/// [`Workspace::get`] holds it **across the open** — creating the file,
516/// taking the lock, mapping the snapshot, replaying the journal. That is
517/// deliberate (see the comment in the body: dropping it first lets two threads
518/// of one process race for a file and hand the loser a `Busy` it caused
519/// itself), and the cost is that a cold open of one database queues a hit on
520/// an unrelated one. With a pool that warms in a few requests and a `max_open`
521/// in the tens, that queue is short.
522///
523/// The closing paths do the opposite: they take the evicted entries out under
524/// the lock and drop them **after** releasing it. Today that only defers
525/// closing a few file descriptors, since nothing in the handle has a `Drop`.
526/// It is written that way so it stays true if one ever gains one — a
527/// checkpoint-on-close would otherwise turn a timer tick into disk I/O under a
528/// lock every worker wants, which is a stall with no visible cause.
529pub struct Workspace {
530    layout: WorkspaceLayout,
531    open: Opener,
532    limits: WorkspaceLimits,
533    pool: Mutex<Vec<Pooled>>,
534    registry: Mutex<Option<Database>>,
535    next_token: AtomicU64,
536}
537
538impl Workspace {
539    /// A workspace over `layout`, opening databases with `open`.
540    pub fn new(layout: WorkspaceLayout, open: Opener, limits: WorkspaceLimits) -> Self {
541        Self {
542            layout,
543            open,
544            limits,
545            pool: Mutex::new(Vec::new()),
546            registry: Mutex::new(None),
547            next_token: AtomicU64::new(1),
548        }
549    }
550
551    /// The registry database, opened on first use.
552    ///
553    /// Lazily, and that matters: a process that only ever resolves names it was
554    /// given never opens the registry, so it neither creates the file nor holds
555    /// a lock on it. The registry is a search index — a caller that is not
556    /// searching should not pay for it, and two processes that never search can
557    /// share one workspace without contending over it.
558    ///
559    /// It lives outside the handle pool because it is not one of the databases:
560    /// it has no [`DbName`], it is never evicted, and it is never handed out by
561    /// [`Workspace::get`].
562    ///
563    /// # Errors
564    ///
565    /// [`WorkspaceError::Io`] if the root cannot be created, or whatever the
566    /// open reports — including [`HostError::Locked`] if another process holds
567    /// the registry.
568    pub fn registry(&self) -> Result<Database, WorkspaceError> {
569        let mut slot = self.registry.lock().unwrap_or_else(|e| e.into_inner());
570        if let Some(db) = slot.as_ref() {
571            return Ok(db.clone());
572        }
573        let root = self.layout.root();
574        std::fs::create_dir_all(root).map_err(|e| WorkspaceError::io(root, e))?;
575        let db = (self.open)(&self.layout.registry_path())?;
576        *slot = Some(db.clone());
577        Ok(db)
578    }
579
580    /// Closes the registry handle, if one is open. Returns whether there was
581    /// one. The same liveness concern as [`Workspace::close_idle`]: a held
582    /// registry is a registry no other process can write.
583    pub fn close_registry(&self) -> bool {
584        self.registry
585            .lock()
586            .unwrap_or_else(|e| e.into_inner())
587            .take()
588            .is_some()
589    }
590
591    /// Where the files are.
592    pub fn layout(&self) -> &WorkspaceLayout {
593        &self.layout
594    }
595
596    /// The limits in force.
597    pub fn limits(&self) -> WorkspaceLimits {
598        self.limits
599    }
600
601    /// How many databases are open right now. Observability for tests and
602    /// `stats`; not a number to make decisions on, since it moves.
603    pub fn open_count(&self) -> usize {
604        self.pooled().len()
605    }
606
607    /// Resolves `name` to an open database, opening it if it is not pooled.
608    ///
609    /// `now_ms` is the host clock (unix milliseconds), used only for the idle
610    /// bookkeeping — it is passed in rather than read here for the same reason
611    /// every verb takes `now`: the host owns time.
612    ///
613    /// # Errors
614    ///
615    /// [`WorkspaceError::NoSuchDatabase`] when the file is absent and `missing`
616    /// is [`IfMissing::Fail`]; [`WorkspaceError::Busy`] when another process
617    /// holds the writer; [`WorkspaceError::Io`] if the directory cannot be
618    /// created; [`WorkspaceError::Host`] for anything the open itself rejects.
619    pub fn get(
620        &self,
621        name: &DbName,
622        now_ms: u64,
623        missing: IfMissing,
624    ) -> Result<Database, WorkspaceError> {
625        self.acquire(name, now_ms, missing, false).map(|(db, _)| db)
626    }
627
628    /// Borrows a named database for one scoped operation.
629    ///
630    /// The returned lease dereferences to [`Database`] but, unlike a clone from
631    /// [`Workspace::get`], pins its pool entry until it is dropped. This is the
632    /// safe ownership shape for an FFI call: the language object keeps only the
633    /// name, obtains a lease inside one verb, and cannot accidentally keep the
634    /// file lock alive through garbage-collector reachability.
635    ///
636    /// If every slot is active, this returns [`WorkspaceError::AtCapacity`]
637    /// immediately. It never waits for another operation while holding a pool
638    /// or engine lock.
639    pub fn lease(
640        &self,
641        name: &DbName,
642        now_ms: u64,
643        missing: IfMissing,
644    ) -> Result<WorkspaceLease<'_>, WorkspaceError> {
645        let (db, token) = self.acquire(name, now_ms, missing, true)?;
646        Ok(WorkspaceLease {
647            workspace: self,
648            db: Some(db),
649            token,
650        })
651    }
652
653    /// Resolves one pooled database and optionally pins it for a scoped lease.
654    fn acquire(
655        &self,
656        name: &DbName,
657        now_ms: u64,
658        missing: IfMissing,
659        pin: bool,
660    ) -> Result<(Database, u64), WorkspaceError> {
661        // The lock is held across the open, deliberately. Releasing it first
662        // would let two callers race to open the same file, and the loser would
663        // see a spurious `Busy` — from its own process. An open is bounded
664        // (the lock is tried, never waited on), so the cost is a short queue.
665        let mut pool = self.pooled();
666
667        if let Some(slot) = pool.iter_mut().find(|p| &p.name == name) {
668            slot.last_used_ms = now_ms;
669            if pin {
670                slot.active = slot.active.saturating_add(1);
671            }
672            return Ok((slot.db.clone(), slot.token));
673        }
674
675        let path = self.layout.path_of(name);
676        if !self.layout.exists(name) {
677            if missing == IfMissing::Fail {
678                return Err(WorkspaceError::NoSuchDatabase {
679                    name: name.clone(),
680                    path,
681                });
682            }
683            let dir = self.layout.db_dir();
684            std::fs::create_dir_all(&dir).map_err(|e| WorkspaceError::io(&dir, e))?;
685        }
686
687        // Make room *before* opening, so the ceiling counts this database too.
688        let ceiling = self.limits.ceiling();
689        while pool.len() >= ceiling {
690            let Some(lru) = Self::least_recently_used_available(&pool) else {
691                return Err(WorkspaceError::AtCapacity { max_open: ceiling });
692            };
693            pool.remove(lru);
694        }
695
696        let db = (self.open)(&path).map_err(|e| match e {
697            HostError::Locked { .. } => WorkspaceError::Busy { name: name.clone() },
698            other => WorkspaceError::Host(other),
699        })?;
700        let token = self.next_token.fetch_add(1, Ordering::Relaxed);
701        pool.push(Pooled {
702            name: name.clone(),
703            db: db.clone(),
704            last_used_ms: now_ms,
705            active: usize::from(pin),
706            token,
707        });
708        Ok((db, token))
709    }
710
711    /// Closes every database unused for longer than the idle timeout, returning
712    /// how many were closed. A no-op when the timeout is `0`.
713    ///
714    /// Call it on a timer. Nothing else releases a file lock a server is
715    /// holding on a database nobody is asking about.
716    pub fn close_idle(&self, now_ms: u64) -> usize {
717        let timeout = self.limits.idle_timeout_ms;
718        if timeout == 0 {
719            return 0;
720        }
721        // `extract_if` rather than `retain` so the entries come *out* instead of
722        // being dropped in place: the guard is a temporary, so it is released at
723        // the semicolon and the handles die on the next line, unlocked.
724        //
725        // `saturating_sub`: a clock that stepped backwards leaves handles open
726        // rather than closing all of them at once.
727        let closing: Vec<Pooled> = self
728            .pooled()
729            .extract_if(.., |p| {
730                p.active == 0 && now_ms.saturating_sub(p.last_used_ms) >= timeout
731            })
732            .collect();
733        closing.len()
734    }
735
736    /// Releases one inactive pooled database, returning whether it was open.
737    ///
738    /// Logical references to `name` remain valid: their next operation opens it
739    /// again. An active operation is never interrupted; it returns
740    /// [`WorkspaceError::InUse`] instead.
741    pub fn release(&self, name: &DbName) -> Result<bool, WorkspaceError> {
742        let closing = {
743            let mut pool = self.pooled();
744            let Some(index) = pool.iter().position(|p| &p.name == name) else {
745                return Ok(false);
746            };
747            if pool[index].active > 0 {
748                return Err(WorkspaceError::InUse { name: name.clone() });
749            }
750            pool.remove(index)
751        };
752        drop(closing);
753        Ok(true)
754    }
755
756    /// Closes every open handle. The pool's copies, that is — see the note on
757    /// [`Workspace`] about clones the caller still holds.
758    pub fn close_all(&self) -> usize {
759        // Same shape as `close_idle`: the handles leave the pool under the lock
760        // and are dropped once it is released.
761        let closing = std::mem::take(&mut *self.pooled());
762        closing.len()
763    }
764
765    /// The pool guard. A panic in a verb cannot leave the pool half-updated
766    /// (every mutation here is a single push, remove or retain), so a poisoned
767    /// lock is recovered — the same rule the engine lock follows.
768    fn pooled(&self) -> MutexGuard<'_, Vec<Pooled>> {
769        self.pool.lock().unwrap_or_else(|e| e.into_inner())
770    }
771
772    /// Index of the least recently used inactive entry.
773    fn least_recently_used_available(pool: &[Pooled]) -> Option<usize> {
774        let mut oldest: Option<usize> = None;
775        for (i, p) in pool.iter().enumerate() {
776            let is_older = match oldest {
777                Some(candidate) => p.last_used_ms < pool[candidate].last_used_ms,
778                None => true,
779            };
780            if p.active == 0 && is_older {
781                oldest = Some(i);
782            }
783        }
784        oldest
785    }
786}
787
788impl fmt::Debug for Workspace {
789    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
790        f.debug_struct("Workspace")
791            .field("root", &self.layout.root())
792            .field("limits", &self.limits)
793            .field("open", &self.open_count())
794            .finish()
795    }
796}
797
798/// Fixtures shared by this module's tests and the registry's.
799#[cfg(test)]
800pub(crate) mod testkit {
801    use std::path::PathBuf;
802    use std::sync::Arc;
803    use std::sync::atomic::{AtomicUsize, Ordering};
804
805    use super::{DbName, Opener, Workspace, WorkspaceLayout, WorkspaceLimits};
806    use crate::Database;
807
808    /// A unique temp directory; removed on drop.
809    pub(crate) struct TempDir(pub PathBuf);
810
811    impl TempDir {
812        pub(crate) fn new(tag: &str) -> Self {
813            let dir = std::env::temp_dir().join(format!(
814                "plugmem-workspace-{tag}-{}-{}",
815                std::process::id(),
816                std::time::SystemTime::now()
817                    .duration_since(std::time::UNIX_EPOCH)
818                    .unwrap()
819                    .as_nanos()
820            ));
821            std::fs::create_dir_all(&dir).unwrap();
822            TempDir(dir)
823        }
824    }
825
826    impl Drop for TempDir {
827        fn drop(&mut self) {
828            let _ = std::fs::remove_dir_all(&self.0);
829        }
830    }
831
832    /// A workspace of plain default databases, plus a count of how many times
833    /// the opener actually ran — the only way to tell a pool hit from a reopen.
834    pub(crate) fn workspace(
835        tmp: &TempDir,
836        limits: WorkspaceLimits,
837    ) -> (Workspace, Arc<AtomicUsize>) {
838        let opens = Arc::new(AtomicUsize::new(0));
839        let counted = Arc::clone(&opens);
840        let open: Opener = Box::new(move |path: &std::path::Path| {
841            counted.fetch_add(1, Ordering::SeqCst);
842            Ok(Database::open(path, crate::Config::default())?.0)
843        });
844        (
845            Workspace::new(WorkspaceLayout::new(&tmp.0), open, limits),
846            opens,
847        )
848    }
849
850    /// A name that must parse.
851    pub(crate) fn name(s: &str) -> DbName {
852        DbName::parse(s).unwrap()
853    }
854}
855
856#[cfg(test)]
857mod tests {
858    use std::sync::atomic::Ordering;
859
860    use super::testkit::{TempDir, name, workspace};
861    use super::*;
862
863    fn problem(s: &str) -> NameProblem {
864        match DbName::parse(s) {
865            Err(WorkspaceError::BadName { why, .. }) => why,
866            other => panic!("expected {s:?} to be refused, got {other:?}"),
867        }
868    }
869
870    #[test]
871    fn a_name_admits_only_the_safe_alphabet() {
872        for ok in [
873            "a",
874            "0",
875            "chat-42",
876            "common",
877            "x_y-9",
878            &"a".repeat(MAX_DB_NAME),
879        ] {
880            assert_eq!(DbName::parse(ok).unwrap().as_str(), ok);
881        }
882
883        assert_eq!(problem(""), NameProblem::Empty);
884        assert_eq!(problem(&"a".repeat(MAX_DB_NAME + 1)), NameProblem::TooLong);
885
886        // A leading character that could be read as a flag, a path component or
887        // a hidden file is refused before anything else looks at the string.
888        // A non-ASCII name trips this same check, because its first *byte* is
889        // already outside the alphabet.
890        for bad in ["-x", "_x", ".x", "..", "/x", "Ab", "чат"] {
891            assert_eq!(problem(bad), NameProblem::LeadingChar, "{bad:?}");
892        }
893
894        // Path separators, dots, spaces, uppercase and non-ASCII are not
895        // filtered late — they simply are not names.
896        for bad in ["a/b", "a\\b", "a.b", "a b", "aB", "a:b", "aчат", "a\0b"] {
897            assert_eq!(problem(bad), NameProblem::Character, "{bad:?}");
898        }
899
900        // Windows resolves these to devices in every directory, extension or
901        // no extension, so `con.plugmem` is the console. Refused everywhere,
902        // because a workspace directory is a thing people copy between
903        // machines.
904        for bad in ["con", "nul", "prn", "aux", "com1", "lpt9"] {
905            assert_eq!(problem(bad), NameProblem::ReservedDevice, "{bad:?}");
906        }
907        // Only the exact names — a memory called `console` is fine.
908        for ok in ["console", "con1", "com0", "com10", "nula"] {
909            assert!(DbName::parse(ok).is_ok(), "{ok:?}");
910        }
911    }
912
913    #[test]
914    fn a_name_prints_as_itself() {
915        assert_eq!(DbName::parse("chat-42").unwrap().to_string(), "chat-42");
916        assert_eq!(NameProblem::Empty.to_string(), "it is empty");
917        assert_eq!(
918            NameProblem::TooLong.to_string(),
919            format!("it is longer than {MAX_DB_NAME} bytes")
920        );
921        assert!(NameProblem::LeadingChar.to_string().contains("start with"));
922        assert!(NameProblem::Character.to_string().contains("lowercase"));
923        assert!(
924            NameProblem::ReservedDevice
925                .to_string()
926                .contains("Windows device name")
927        );
928    }
929
930    #[test]
931    fn the_layout_puts_the_registry_out_of_reach_of_names() {
932        let layout = WorkspaceLayout::new("/ws");
933        let name = DbName::parse("chat-42").unwrap();
934
935        assert_eq!(layout.root(), Path::new("/ws"));
936        assert_eq!(layout.db_dir(), Path::new("/ws/db"));
937        assert_eq!(layout.path_of(&name), Path::new("/ws/db/chat-42.plugmem"));
938        assert_eq!(layout.registry_path(), Path::new("/ws/registry.plugmem"));
939
940        // The registry is one level above the databases, so no name — not even
941        // one spelled like the registry file — can resolve onto it.
942        let lookalike = DbName::parse("registry").unwrap();
943        assert_ne!(layout.path_of(&lookalike), layout.registry_path());
944    }
945
946    #[test]
947    fn listing_reads_the_directory_and_ignores_what_is_not_a_database() {
948        let tmp = TempDir::new("list");
949        let layout = WorkspaceLayout::new(&tmp.0);
950
951        // A workspace nobody has written to yet lists nothing rather than failing.
952        assert!(layout.list().unwrap().is_empty());
953
954        std::fs::create_dir_all(layout.db_dir()).unwrap();
955        for file in [
956            "chat-42.plugmem",
957            "common.plugmem",
958            // Sidecars of a database already counted: folded back to its name,
959            // not listed again.
960            "chat-42.plugmem.lock",
961            "chat-42.plugmem.journal",
962            "chat-42.plugmem.snap.3",
963            // Neither is an unrelated file, nor one whose stem is not a name.
964            "notes.txt",
965            "Chat-43.plugmem",
966        ] {
967            std::fs::write(layout.db_dir().join(file), b"").unwrap();
968        }
969
970        let names: Vec<String> = layout
971            .list()
972            .unwrap()
973            .iter()
974            .map(DbName::to_string)
975            .collect();
976        assert_eq!(names, ["chat-42", "common"]);
977
978        assert!(layout.exists(&DbName::parse("chat-42").unwrap()));
979        assert!(!layout.exists(&DbName::parse("nope").unwrap()));
980    }
981
982    #[test]
983    fn a_database_exists_before_its_first_checkpoint() {
984        let tmp = TempDir::new("list-uncheckpointed");
985        let layout = WorkspaceLayout::new(&tmp.0);
986        let fresh = DbName::parse("fresh").unwrap();
987        std::fs::create_dir_all(layout.db_dir()).unwrap();
988
989        // The base path holds the published snapshot, and that is written by
990        // the first checkpoint — so a brand-new database has a journal and no
991        // base file. Reporting it as absent would mean creating over live data.
992        let (db, _) = Database::open(layout.path_of(&fresh), crate::Config::default()).unwrap();
993        db.remember(crate::RememberInput::text(1_000, "not yet checkpointed"))
994            .unwrap();
995        assert!(!layout.path_of(&fresh).exists());
996        assert!(layout.exists(&fresh));
997        assert_eq!(layout.list().unwrap(), [fresh]);
998    }
999
1000    #[test]
1001    fn an_unreadable_directory_is_an_error_not_an_empty_workspace() {
1002        let tmp = TempDir::new("list-io");
1003        let layout = WorkspaceLayout::new(&tmp.0);
1004        // `db` is a *file*, so reading it as a directory fails with something
1005        // other than NotFound — the caller must hear about it.
1006        std::fs::write(layout.db_dir(), b"not a directory").unwrap();
1007        assert!(matches!(layout.list(), Err(WorkspaceError::Io { .. })));
1008    }
1009
1010    #[test]
1011    fn every_failure_names_what_the_caller_typed() {
1012        let busy = WorkspaceError::Busy {
1013            name: DbName::parse("chat-42").unwrap(),
1014        };
1015        assert!(busy.to_string().contains("chat-42"));
1016
1017        let host = WorkspaceError::from(HostError::Embed("no".into()));
1018        assert!(matches!(host, WorkspaceError::Host(HostError::Embed(_))));
1019
1020        let io = WorkspaceError::io(
1021            Path::new("/ws"),
1022            std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied"),
1023        );
1024        assert!(io.to_string().contains("/ws"));
1025
1026        let missing = WorkspaceError::NoSuchDatabase {
1027            name: DbName::parse("gone").unwrap(),
1028            path: PathBuf::from("/ws/db/gone.plugmem"),
1029        };
1030        assert!(missing.to_string().contains("gone"));
1031    }
1032
1033    #[test]
1034    fn a_pooled_database_is_reused_and_a_missing_one_is_created_only_on_request() {
1035        let tmp = TempDir::new("pool-reuse");
1036        let (ws, opens) = workspace(&tmp, WorkspaceLimits::default());
1037        let chat = name("chat-42");
1038
1039        // Reading a name nobody has written names the name, not a path the
1040        // caller never typed.
1041        let missed = ws.get(&chat, 1_000, IfMissing::Fail).unwrap_err();
1042        assert!(
1043            matches!(&missed, WorkspaceError::NoSuchDatabase { name, .. } if name == &chat),
1044            "{missed}"
1045        );
1046        assert_eq!(opens.load(Ordering::SeqCst), 0);
1047        assert!(!ws.layout().db_dir().exists());
1048
1049        // Writing creates it, directory and all.
1050        let db = ws.get(&chat, 1_000, IfMissing::Create).unwrap();
1051        db.remember(crate::RememberInput::text(1_000, "prefers tokio"))
1052            .unwrap();
1053        assert!(ws.layout().exists(&chat));
1054        assert_eq!(ws.open_count(), 1);
1055
1056        // The second call is a pool hit: same state, no reopen.
1057        let again = ws.get(&chat, 2_000, IfMissing::Fail).unwrap();
1058        assert_eq!(again.stats().facts, 1);
1059        assert_eq!(opens.load(Ordering::SeqCst), 1);
1060
1061        assert!(format!("{ws:?}").contains("open: 1"));
1062    }
1063
1064    #[test]
1065    fn databases_in_one_workspace_do_not_see_each_other() {
1066        let tmp = TempDir::new("isolation");
1067        let (ws, _) = workspace(&tmp, WorkspaceLimits::default());
1068
1069        for (db, text) in [
1070            ("chat-42", "the sky is blue"),
1071            ("chat-43", "the sky is red"),
1072        ] {
1073            ws.get(&name(db), 1_000, IfMissing::Create)
1074                .unwrap()
1075                .remember(crate::RememberInput::text(1_000, text))
1076                .unwrap();
1077        }
1078
1079        // The same query, the same fact id in each — and each answers only for
1080        // itself. Ids are per database, which is why they can be plain `[f1]`.
1081        for (db, expected) in [("chat-42", "blue"), ("chat-43", "red")] {
1082            let out = ws
1083                .get(&name(db), 2_000, IfMissing::Fail)
1084                .unwrap()
1085                .recall(crate::RecallQuery::text(2_000, "sky"))
1086                .unwrap();
1087            assert_eq!(out.facts.len(), 1, "{db}");
1088            assert!(out.rendered.contains(expected), "{db}: {}", out.rendered);
1089        }
1090    }
1091
1092    #[test]
1093    fn the_ceiling_evicts_the_least_recently_used() {
1094        let tmp = TempDir::new("pool-evict");
1095        let (ws, opens) = workspace(
1096            &tmp,
1097            WorkspaceLimits {
1098                max_open: 2,
1099                ..WorkspaceLimits::default()
1100            },
1101        );
1102
1103        // Three databases through a pool of two, touching `a` in between so it
1104        // is `b` that is coldest when `c` arrives.
1105        ws.get(&name("a"), 1_000, IfMissing::Create).unwrap();
1106        ws.get(&name("b"), 2_000, IfMissing::Create).unwrap();
1107        ws.get(&name("a"), 3_000, IfMissing::Fail).unwrap();
1108        ws.get(&name("c"), 4_000, IfMissing::Create).unwrap();
1109        assert_eq!(ws.open_count(), 2);
1110        assert_eq!(opens.load(Ordering::SeqCst), 3);
1111
1112        // `a` is still pooled; `b` was evicted and has to be reopened.
1113        ws.get(&name("a"), 5_000, IfMissing::Fail).unwrap();
1114        assert_eq!(opens.load(Ordering::SeqCst), 3);
1115        ws.get(&name("b"), 6_000, IfMissing::Fail).unwrap();
1116        assert_eq!(opens.load(Ordering::SeqCst), 4);
1117    }
1118
1119    #[test]
1120    fn a_ceiling_of_zero_still_serves_one_database() {
1121        let tmp = TempDir::new("pool-zero");
1122        let (ws, opens) = workspace(
1123            &tmp,
1124            WorkspaceLimits {
1125                max_open: 0,
1126                idle_timeout_ms: 0,
1127            },
1128        );
1129        ws.get(&name("a"), 1_000, IfMissing::Create).unwrap();
1130        ws.get(&name("b"), 2_000, IfMissing::Create).unwrap();
1131        assert_eq!(ws.open_count(), 1);
1132        assert_eq!(opens.load(Ordering::SeqCst), 2);
1133
1134        // A zero timeout disables the sweep rather than closing everything.
1135        assert_eq!(ws.close_idle(u64::MAX), 0);
1136        assert_eq!(ws.open_count(), 1);
1137    }
1138
1139    #[test]
1140    fn an_idle_database_is_closed_and_its_lock_released() {
1141        let tmp = TempDir::new("pool-idle");
1142        let (ws, _) = workspace(
1143            &tmp,
1144            WorkspaceLimits {
1145                max_open: 8,
1146                idle_timeout_ms: 1_000,
1147            },
1148        );
1149        let chat = name("chat-42");
1150        let path = ws.layout().path_of(&chat);
1151        drop(ws.get(&chat, 1_000, IfMissing::Create).unwrap());
1152
1153        // Inside the window nothing moves, and the file stays locked.
1154        assert_eq!(ws.close_idle(1_500), 0);
1155        assert!(matches!(
1156            Database::open(&path, crate::Config::default()),
1157            Err(HostError::Locked { .. })
1158        ));
1159
1160        // A clock that stepped backwards must not close everything.
1161        assert_eq!(ws.close_idle(500), 0);
1162
1163        // Past it, the handle goes and the database is reachable again — this
1164        // is the whole point of the timeout, not memory.
1165        assert_eq!(ws.close_idle(2_000), 1);
1166        assert_eq!(ws.open_count(), 0);
1167        assert!(Database::open(&path, crate::Config::default()).is_ok());
1168    }
1169
1170    #[test]
1171    fn a_scoped_lease_cannot_be_swept_released_or_evicted() {
1172        let tmp = TempDir::new("pool-lease-pin");
1173        let (ws, opens) = workspace(
1174            &tmp,
1175            WorkspaceLimits {
1176                max_open: 1,
1177                idle_timeout_ms: 1,
1178            },
1179        );
1180        let a = name("a");
1181        let b = name("b");
1182        let path = ws.layout().path_of(&a);
1183        let lease = ws.lease(&a, 1_000, IfMissing::Create).unwrap();
1184
1185        // The wall clock may advance past the idle window, but an operation is
1186        // not idle and must keep both its pool entry and file lock.
1187        assert_eq!(ws.close_idle(u64::MAX), 0);
1188        assert!(matches!(
1189            ws.release(&a),
1190            Err(WorkspaceError::InUse { name }) if name == a
1191        ));
1192        assert!(matches!(
1193            ws.lease(&b, 2_000, IfMissing::Create),
1194            Err(WorkspaceError::AtCapacity { max_open: 1 })
1195        ));
1196        assert_eq!(opens.load(Ordering::SeqCst), 1);
1197        assert!(matches!(
1198            Database::open(&path, crate::Config::default()),
1199            Err(HostError::Locked { .. })
1200        ));
1201
1202        drop(lease);
1203        assert!(ws.release(&a).unwrap());
1204        assert!(!ws.release(&a).unwrap());
1205        assert!(Database::open(&path, crate::Config::default()).is_ok());
1206    }
1207
1208    #[test]
1209    fn lru_evicts_an_inactive_entry_instead_of_an_active_one() {
1210        let tmp = TempDir::new("pool-lease-lru");
1211        let (ws, opens) = workspace(
1212            &tmp,
1213            WorkspaceLimits {
1214                max_open: 2,
1215                idle_timeout_ms: 0,
1216            },
1217        );
1218        let a = name("a");
1219        let b = name("b");
1220        let c = name("c");
1221        let a_lease = ws.lease(&a, 1_000, IfMissing::Create).unwrap();
1222        drop(ws.get(&b, 2_000, IfMissing::Create).unwrap());
1223
1224        // Although `a` is older, only inactive `b` is eligible to make room.
1225        let c_lease = ws.lease(&c, 3_000, IfMissing::Create).unwrap();
1226        assert_eq!(ws.open_count(), 2);
1227        assert_eq!(opens.load(Ordering::SeqCst), 3);
1228        assert_eq!(a_lease.stats().facts, 0);
1229        drop(c_lease);
1230        drop(a_lease);
1231
1232        // `a` remained pooled; `b` has to be opened again.
1233        drop(ws.get(&a, 4_000, IfMissing::Fail).unwrap());
1234        assert_eq!(opens.load(Ordering::SeqCst), 3);
1235        drop(ws.get(&b, 5_000, IfMissing::Fail).unwrap());
1236        assert_eq!(opens.load(Ordering::SeqCst), 4);
1237    }
1238
1239    #[test]
1240    fn same_name_leases_share_one_slot_until_the_last_drop() {
1241        let tmp = TempDir::new("pool-lease-shared");
1242        let (ws, opens) = workspace(
1243            &tmp,
1244            WorkspaceLimits {
1245                max_open: 1,
1246                idle_timeout_ms: 0,
1247            },
1248        );
1249        let a = name("a");
1250        let first = ws.lease(&a, 1_000, IfMissing::Create).unwrap();
1251        let second = ws.lease(&a, 2_000, IfMissing::Fail).unwrap();
1252        assert_eq!(opens.load(Ordering::SeqCst), 1);
1253
1254        drop(first);
1255        assert!(matches!(ws.release(&a), Err(WorkspaceError::InUse { .. })));
1256        assert_eq!(second.stats().facts, 0);
1257
1258        drop(second);
1259        assert!(ws.release(&a).unwrap());
1260        assert_eq!(ws.open_count(), 0);
1261    }
1262
1263    #[test]
1264    fn auto_maintain_preserves_scoped_workspace_ownership_and_tag_catalogue() {
1265        let tmp = TempDir::new("pool-auto-maintain");
1266        let open: Opener = Box::new(|path| {
1267            Ok(Database::builder(crate::Config::default())
1268                .maintain_every_forgets(1)
1269                .open(path)?
1270                .0)
1271        });
1272        let ws = Workspace::new(
1273            WorkspaceLayout::new(&tmp.0),
1274            open,
1275            WorkspaceLimits {
1276                max_open: 1,
1277                idle_timeout_ms: 0,
1278            },
1279        );
1280        let chat = name("chat");
1281        let id = {
1282            let lease = ws.lease(&chat, 1, IfMissing::Create).unwrap();
1283            lease
1284                .remember(crate::RememberInput {
1285                    tags: &["temporary"],
1286                    ..crate::RememberInput::text(1, "short lived")
1287                })
1288                .unwrap()
1289                .id
1290        };
1291        {
1292            let lease = ws.lease(&chat, 2, IfMissing::Fail).unwrap();
1293            assert!(lease.forget(2, id).unwrap());
1294            assert_eq!(lease.stats().tombstones, 0, "auto maintain purged it");
1295            assert!(
1296                lease
1297                    .list_tags(crate::TagQuery::default())
1298                    .unwrap()
1299                    .items
1300                    .is_empty()
1301            );
1302        }
1303        assert!(ws.release(&chat).unwrap());
1304        assert!(Database::open(ws.layout().path_of(&chat), crate::Config::default()).is_ok());
1305    }
1306
1307    #[test]
1308    fn dropping_a_lease_after_unwind_makes_the_entry_available_again() {
1309        let tmp = TempDir::new("pool-lease-unwind");
1310        let (ws, _) = workspace(
1311            &tmp,
1312            WorkspaceLimits {
1313                max_open: 1,
1314                idle_timeout_ms: 0,
1315            },
1316        );
1317        let a = name("a");
1318        let b = name("b");
1319
1320        let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1321            let _lease = ws.lease(&a, 1_000, IfMissing::Create).unwrap();
1322            panic!("stand in for a binding conversion panic");
1323        }));
1324        assert!(panicked.is_err());
1325
1326        // The Drop guard ran during unwinding, so the one-slot pool can evict
1327        // `a` and serve `b` instead of remaining permanently busy.
1328        assert!(ws.lease(&b, 2_000, IfMissing::Create).is_ok());
1329    }
1330
1331    #[test]
1332    fn close_all_may_remove_a_leased_pool_entry_without_breaking_lease_drop() {
1333        let tmp = TempDir::new("pool-lease-close-all");
1334        let (ws, _) = workspace(&tmp, WorkspaceLimits::default());
1335        let a = name("a");
1336        let path = ws.layout().path_of(&a);
1337        let lease = ws.lease(&a, 1_000, IfMissing::Create).unwrap();
1338
1339        assert_eq!(ws.close_all(), 1);
1340        assert_eq!(ws.open_count(), 0);
1341        assert!(matches!(
1342            Database::open(&path, crate::Config::default()),
1343            Err(HostError::Locked { .. })
1344        ));
1345        drop(lease);
1346        assert!(Database::open(&path, crate::Config::default()).is_ok());
1347    }
1348
1349    #[test]
1350    fn a_handle_held_by_a_caller_outlives_its_pool_entry() {
1351        let tmp = TempDir::new("pool-outlive");
1352        let (ws, _) = workspace(&tmp, WorkspaceLimits::default());
1353        let chat = name("chat-42");
1354        let held = ws.get(&chat, 1_000, IfMissing::Create).unwrap();
1355        let path = ws.layout().path_of(&chat);
1356
1357        assert_eq!(ws.close_all(), 1);
1358        assert_eq!(ws.open_count(), 0);
1359
1360        // The pool let go; the caller did not, so the lock is still taken and
1361        // the handle still works. Documented on `Workspace`, checked here.
1362        held.remember(crate::RememberInput::text(2_000, "still mine"))
1363            .unwrap();
1364        assert!(matches!(
1365            Database::open(&path, crate::Config::default()),
1366            Err(HostError::Locked { .. })
1367        ));
1368        drop(held);
1369        assert!(Database::open(&path, crate::Config::default()).is_ok());
1370    }
1371
1372    #[test]
1373    fn a_database_held_by_another_process_is_reported_by_name() {
1374        let tmp = TempDir::new("pool-busy");
1375        let (ws, _) = workspace(&tmp, WorkspaceLimits::default());
1376        let chat = name("chat-42");
1377        let path = ws.layout().path_of(&chat);
1378        std::fs::create_dir_all(ws.layout().db_dir()).unwrap();
1379
1380        // Stand in for the other process with a handle the pool knows nothing
1381        // about: the lock is what matters, not who holds it.
1382        let outsider = Database::open(&path, crate::Config::default()).unwrap().0;
1383        let e = ws.get(&chat, 1_000, IfMissing::Create).unwrap_err();
1384        assert!(matches!(&e, WorkspaceError::Busy { name } if name == &chat));
1385        assert!(e.to_string().contains("chat-42"), "{e}");
1386
1387        drop(outsider);
1388        assert!(ws.get(&chat, 2_000, IfMissing::Fail).is_ok());
1389    }
1390
1391    #[test]
1392    fn an_open_that_fails_for_another_reason_keeps_its_own_error() {
1393        let tmp = TempDir::new("pool-open-err");
1394        let open: Opener = Box::new(|_| Err(HostError::Embed("no provider".into())));
1395        let ws = Workspace::new(
1396            WorkspaceLayout::new(&tmp.0),
1397            open,
1398            WorkspaceLimits::default(),
1399        );
1400        let e = ws.get(&name("a"), 1_000, IfMissing::Create).unwrap_err();
1401        assert!(
1402            matches!(e, WorkspaceError::Host(HostError::Embed(_))),
1403            "{e}"
1404        );
1405    }
1406
1407    #[test]
1408    fn a_directory_that_cannot_be_created_is_an_error_not_a_panic() {
1409        let tmp = TempDir::new("pool-mkdir");
1410        // `db` is a file, so `create_dir_all` cannot make it a directory.
1411        std::fs::write(tmp.0.join(DB_DIR), b"in the way").unwrap();
1412        let (ws, _) = workspace(&tmp, WorkspaceLimits::default());
1413        assert!(matches!(
1414            ws.get(&name("a"), 1_000, IfMissing::Create),
1415            Err(WorkspaceError::Io { .. })
1416        ));
1417    }
1418
1419    proptest::proptest! {
1420        /// The property the whole design rests on: whatever a caller sends,
1421        /// either it is refused, or the file it names sits directly inside
1422        /// `<root>/db` — one component, no traversal, no absolute path, no
1423        /// device name. Checked over arbitrary strings rather than a list of
1424        /// attacks somebody thought of.
1425        #[test]
1426        fn a_name_that_parses_can_only_resolve_inside_the_workspace(s in ".*") {
1427            let Ok(name) = DbName::parse(&s) else { return Ok(()) };
1428            let layout = WorkspaceLayout::new("/ws");
1429            let path = layout.path_of(&name);
1430
1431            let rest: Vec<_> = path
1432                .strip_prefix(layout.db_dir())
1433                .expect("resolved outside the workspace")
1434                .components()
1435                .collect();
1436            let expected = format!("{s}.{DB_EXT}");
1437            proptest::prop_assert_eq!(rest.len(), 1);
1438            proptest::prop_assert_eq!(
1439                path.file_name().and_then(|n| n.to_str()),
1440                Some(expected.as_str())
1441            );
1442        }
1443    }
1444}