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}