Skip to main content

plugmem_host/
error.rs

1//! Host-layer errors.
2
3use std::path::PathBuf;
4
5/// Every way the host layer can fail. Engine failures pass through as
6/// [`HostError::Engine`]; everything filesystem- or network-shaped is
7/// typed here.
8#[derive(Debug, thiserror::Error)]
9#[non_exhaustive]
10pub enum HostError {
11    /// The database file is exclusively locked by another process (or
12    /// another handle in this process). One file has one owner — open a
13    /// different file, or drop the other handle.
14    #[error("database at {} is locked by another process", path.display())]
15    Locked {
16        /// The database base path.
17        path: PathBuf,
18    },
19
20    /// A filesystem operation failed.
21    #[error("i/o on {}: {source}", path.display())]
22    Io {
23        /// The file the operation touched.
24        path: PathBuf,
25        /// The underlying error.
26        #[source]
27        source: std::io::Error,
28    },
29
30    /// A read-only open ([`crate::Database::open_readonly`]) found a
31    /// non-empty journal. Replaying it would mutate the engine — copying
32    /// whole arenas up from the mapped bytes (copy-on-write) — which
33    /// defeats the zero-copy intent. Open the database read-write once to
34    /// checkpoint it (fold the journal into the snapshot), then retry
35    #[error("database at {} needs a checkpoint before a read-only open (non-empty journal)", path.display())]
36    NeedsCheckpoint {
37        /// The database base path.
38        path: PathBuf,
39    },
40
41    /// The engine returned a typed error.
42    #[error(transparent)]
43    Engine(#[from] plugmem_core::Error),
44
45    /// The embedder transport or response was unusable (the message
46    /// names what exactly: status, dimension mismatch, malformed JSON).
47    #[error("embedder: {0}")]
48    Embed(String),
49}
50
51/// What to tell someone whose pool hit its ceiling.
52///
53/// Kept as one string in one place so the CLI, the MCP server and the Node
54/// binding say the same thing. The engine cannot say it: `plugmem-core` knows
55/// nothing about config files, and its message is therefore a bare byte count
56/// — true, and useless on its own.
57pub const MAX_BYTES_HINT: &str = "that ceiling is `max_bytes` (`[engine] max_bytes` in config.toml), \
58and it applies to each pool separately rather than to their sum. Its default is not a capacity \
59judgement — it is the figure that keeps every pool addressable where `usize` is 32 bits, so a \
60database written anywhere opens anywhere. Raising it costs exactly that portability: a 32-bit \
61host then refuses the file with a typed error instead of misreading it.";
62
63impl HostError {
64    /// The follow-up line for a pool that ran out of room, or `None` when this
65    /// error is something else.
66    ///
67    /// The number in the message is a setting; the setting has a name and one
68    /// specific trade-off. Callers that talk to a person should print this
69    /// after the error itself.
70    pub fn capacity_hint(&self) -> Option<&'static str> {
71        let Self::Engine(engine) = self else {
72            return None;
73        };
74        matches!(
75            engine,
76            plugmem_core::Error::CapacityExceeded { .. }
77                | plugmem_core::Error::Arena(plugmem_core::ArenaError::CapacityExceeded { .. })
78        )
79        .then_some(MAX_BYTES_HINT)
80    }
81
82    /// Shorthand for wrapping an I/O error with its path.
83    pub(crate) fn io(path: &std::path::Path, source: std::io::Error) -> Self {
84        Self::Io {
85            path: path.to_path_buf(),
86            source,
87        }
88    }
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94
95    #[test]
96    fn a_pool_ceiling_carries_its_follow_up_and_nothing_else_does() {
97        // The engine's own message for this is a bare byte count — true, and
98        // useless to somebody who does not know the number is a setting. Every
99        // surface prints this line after it, so it has to actually attach.
100        for engine in [
101            plugmem_core::Error::CapacityExceeded { what: "vectors" },
102            plugmem_core::Error::Arena(plugmem_core::ArenaError::CapacityExceeded {
103                max_bytes: 65_536,
104            }),
105        ] {
106            let hint = HostError::Engine(engine).capacity_hint();
107            assert_eq!(hint, Some(MAX_BYTES_HINT));
108            assert!(hint.unwrap().contains("max_bytes"));
109        }
110
111        // Anything else must not: a lock conflict followed by a lecture about
112        // pool sizing is worse than no follow-up at all.
113        assert_eq!(
114            HostError::Engine(plugmem_core::Error::TooLarge {
115                what: "text",
116                len: 9_000,
117                max: 4_096,
118            })
119            .capacity_hint(),
120            None
121        );
122        assert_eq!(HostError::Embed("no provider".into()).capacity_hint(), None);
123        assert_eq!(
124            HostError::Locked {
125                path: PathBuf::from("/tmp/m.plugmem"),
126            }
127            .capacity_hint(),
128            None
129        );
130    }
131}