Skip to main content

gglib_core/paths/
slots.rs

1//! Slot cache path helpers.
2//!
3//! Shared utilities for constructing per-model KV cache slot filenames,
4//! used by both gglib-runtime (purge) and gglib-proxy (save/restore).
5//!
6//! ## Why a flat `{model_id}__{session}.bin` name, not a subdirectory
7//!
8//! llama-server's `/slots/{id}?action=save|restore` endpoint validates the
9//! `filename` field with `fs_validate_filename`, which rejects any path
10//! separator ("Invalid filename", HTTP 400) as a path-traversal defense. A
11//! `{model_id}/{session}.bin` name (an earlier subdirectory-based layout) is
12//! therefore rejected outright, silently breaking every save/restore. Encoding
13//! the model id as a filename *prefix* instead keeps per-model scoping (purge
14//! removes only one model's files, matched by prefix) while sending llama-server
15//! a separator-free name it accepts.
16
17use std::path::{Path, PathBuf};
18
19/// Filename for a model+session slot cache file: `{model_id}__{session_id}.bin`.
20///
21/// This is both the on-disk name (directly under `slot_dir`) and the
22/// `filename` value sent to llama-server's save/restore endpoint — they must
23/// match, and both must be free of path separators (see the module docs).
24pub fn slot_file_name(model_id: u32, session_id: &str) -> String {
25    format!("{model_id}__{session_id}.bin")
26}
27
28/// Full on-disk path for a model+session slot cache file (flat under `slot_dir`).
29pub fn slot_bin_path(slot_dir: &Path, model_id: u32, session_id: &str) -> PathBuf {
30    slot_dir.join(slot_file_name(model_id, session_id))
31}
32
33/// Filename prefix identifying all of one model's slot files: `{model_id}__`.
34///
35/// Used by purge to remove only the swapped-out model's files. The trailing
36/// `__` delimiter is load-bearing: it prevents model `1`'s prefix (`1__`) from
37/// matching model `11`'s files (`11__…`).
38pub fn slot_model_prefix(model_id: u32) -> String {
39    format!("{model_id}__")
40}
41
42/// Filename for an in-flight save: `{model_id}__{session_id}.{nonce}.tmp`.
43///
44/// llama-server is asked to write here instead of directly to the final
45/// `.bin` name, so a save that times out or is retried while the server is
46/// still writing can never produce a torn file at the name restore/eviction
47/// actually read — those only ever see `*.bin` (see [`slot_file_name`]). The
48/// caller renames this to the final name only after a confirmed-complete
49/// write; `nonce` (a per-attempt counter) keeps concurrent attempts for the
50/// same session from writing the same temp file.
51pub fn slot_tmp_file_name(model_id: u32, session_id: &str, nonce: u64) -> String {
52    format!("{model_id}__{session_id}.{nonce}.tmp")
53}
54
55/// Recover the session id from a slot file stem (`{model_id}__{session}`).
56///
57/// Splits on the **first** `__`. Model ids are numeric and contain no `__`, so
58/// the first `__` is always the model/session delimiter — this correctly
59/// recovers the session even when the session id itself contains `__`.
60/// Returns `None` for a stem with no `__` (e.g. a legacy pre-namespacing file),
61/// which callers treat as "not one of our namespaced files".
62pub fn slot_session_from_stem(stem: &str) -> Option<&str> {
63    stem.split_once("__").map(|(_, session)| session)
64}
65
66#[cfg(test)]
67mod tests {
68    use super::*;
69
70    #[test]
71    fn slot_file_name_encodes_model_and_session() {
72        assert_eq!(slot_file_name(42, "planner"), "42__planner.bin");
73    }
74
75    #[test]
76    fn slot_bin_path_is_flat_under_slot_dir() {
77        let base = Path::new("/tmp/slots");
78        assert_eq!(
79            slot_bin_path(base, 7, "abc"),
80            PathBuf::from("/tmp/slots/7__abc.bin")
81        );
82    }
83
84    #[test]
85    fn slot_model_prefix_includes_delimiter() {
86        assert_eq!(slot_model_prefix(1), "1__");
87        // The delimiter guards against 1__ matching 11__.
88        assert!(!"11__x.bin".starts_with(&slot_model_prefix(1)));
89        assert!("1__x.bin".starts_with(&slot_model_prefix(1)));
90    }
91
92    #[test]
93    fn slot_session_from_stem_recovers_session() {
94        assert_eq!(slot_session_from_stem("42__planner"), Some("planner"));
95    }
96
97    #[test]
98    fn slot_session_from_stem_handles_session_with_double_underscore() {
99        // Session ids may themselves contain `__`; only the first split counts.
100        assert_eq!(slot_session_from_stem("3__a__b"), Some("a__b"));
101    }
102
103    #[test]
104    fn slot_session_from_stem_none_for_legacy_flat_name() {
105        assert_eq!(slot_session_from_stem("auto-deadbeef"), None);
106    }
107
108    #[test]
109    fn slot_tmp_file_name_encodes_model_session_and_nonce() {
110        assert_eq!(slot_tmp_file_name(42, "planner", 7), "42__planner.7.tmp");
111    }
112
113    #[test]
114    fn slot_tmp_file_name_never_has_bin_extension() {
115        let name = slot_tmp_file_name(1, "s", 0);
116        assert_eq!(
117            Path::new(&name).extension(),
118            Some(std::ffi::OsStr::new("tmp"))
119        );
120    }
121
122    #[test]
123    fn slot_tmp_file_name_is_unique_per_nonce() {
124        assert_ne!(slot_tmp_file_name(1, "s", 0), slot_tmp_file_name(1, "s", 1));
125    }
126}