Files
Loki/internal/loki/llm_slotpersist_test.go
T
MichaelandClaude Opus 5.5 28ba44eb46 Slots : SLOT_PERSIST garde l'état de la discussion à la bascule de preset, en opt-in
Passer de A à B puis revenir à A relance llama-server deux fois, et la
conversation de A est recalculée en entier au retour. Avec SLOT_PERSIST=on
(off par défaut), Loki demande au moteur d'écrire l'état du slot 0 juste
avant la bascule, et le recharge au retour avant le premier message de la
discussion — seulement si tout concorde, sinon calcul normal, en silence.

llama.cpp ne vérifie à la relecture que types et tailles : rien ne dit quel
build ni quels réglages ont calculé ce cache. La clé de validité est donc
l'empreinte de tout ce qui touche au calcul, prise par « loki serve » au
lancement : ligne de commande complète (modèle, CTX, types KV, NGL, lots,
gabarit, SPEC, EXTRA_ARGS…), LLAMA_ARG_*/GGML_*/CUDA_*, taille et date du
modèle et de ses tranches, du projecteur, du brouillon, du binaire et de
ses bibliothèques. Pas de cas « mise à jour du moteur » : elle change la clé.

- --slot-save-path posé aussi pour SLOT_PERSIST accepté, sous les mêmes
  conditions de sécurité que l'isolation du lot 1 (boucle locale ou clé
  d'API) ; refusé avec le décodage spéculatif (le save ne garde pas le
  brouillon ; /slots speculative revérifié à chaque fois), des poids sur
  CPU, un --slot-save-path à la main ; la ligne reste alors celle d'avant
- sauvegarde depuis l'interface ou une tâche, avant l'arrêt : le slot doit
  porter un tour de la discussion (tampon d'engineMarkMain), ≥ 4096 jetons,
  ≤ 8 Gio estimés, double de place libre ; nom provisoire, renommé
  seulement si aucune requête n'est partie pendant l'écriture
- rechargement avant la première requête de la discussion (ou son
  préchauffage) : même clé de moteur, même discussion, slot 0 vierge (/slots
  sans id_task) ; une seule tentative, fichier retiré ; toute erreur =
  calcul normal
- deux fichiers au plus ; clé retirée = tout effacé au lancement suivant ;
  SLOT_PERSIST préservé à la bascule comme HOST (sinon B effaçait l'état de A)
- README : SIDE_SLOT, le cache KV plein ne déclenche jamais de compaction
- tests : plan et refus, ligne inchangée, empreinte (clé d'API exclue,
  fichiers et réglages inclus), rotation, séquence save puis restore sur un
  faux moteur, refus à la sauvegarde et au rechargement, autre clé, défaut
  sans aucun appel

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:17:08 +02:00

443 lines
14 KiB
Go

package loki
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"strings"
"sync"
"testing"
"time"
)
// persistSI : moteur qui connaît --slot-save-path, dossier prêt, boucle locale.
func persistSI() serveSysInfo {
si := denseGPU()
si.SlotDir = "/data/slots"
return si
}
func TestSlotPersistPlan(t *testing.T) {
loop := map[string]string{"SLOT_PERSIST": "on", "HOST": "127.0.0.1"}
with := func(kv ...string) map[string]string {
m := map[string]string{}
for k, v := range loop {
m[k] = v
}
for i := 0; i+1 < len(kv); i += 2 {
m[kv[i]] = kv[i+1]
}
return m
}
cases := []struct {
name string
cfg map[string]string
extra []string
si func(*serveSysInfo)
ok bool
why string
}{
{name: "clé absente", cfg: map[string]string{"HOST": "127.0.0.1"}},
{name: "boucle locale", cfg: loop, ok: true},
{name: "0.0.0.0 avec clé d'API", cfg: with("HOST", "0.0.0.0"), si: func(s *serveSysInfo) { s.APIKey = "k" }, ok: true},
{name: "0.0.0.0 sans clé", cfg: with("HOST", ""), why: "sans clé"},
{name: "SPEC=auto", cfg: with("SPEC", "auto"), why: "spéculatif"},
{name: "brouillon dans EXTRA_ARGS", cfg: loop, extra: []string{"-md", "d.gguf"}, why: "spéculatif"},
{name: "--slot-save-path à la main", cfg: loop, extra: []string{"--slot-save-path", "/x"}, why: "à la main"},
{name: "variable LLAMA_ARG_SLOT_SAVE_PATH", cfg: loop, si: func(s *serveSysInfo) { s.ArgEnv["LLAMA_ARG_SLOT_SAVE_PATH"] = "/x" }, why: "à la main"},
{name: "experts sur CPU", cfg: loop, extra: []string{"--n-cpu-moe", "30"}, why: "CPU"},
{name: "moteur sans le drapeau", cfg: loop, si: func(s *serveSysInfo) { s.Help = helpRecent }, why: "--slot-save-path"},
{name: "dossier indisponible", cfg: loop, si: func(s *serveSysInfo) { s.SlotDir = "" }, why: "dossier"},
{name: "preset externe", cfg: with(extKeyFlag, "1"), why: "externe"},
}
for _, c := range cases {
si := persistSI()
if c.si != nil {
c.si(&si)
}
ok, why := slotPersistPlan(c.cfg, c.extra, si)
if ok != c.ok || !strings.Contains(why, c.why) {
t.Errorf("%s : ok=%v why=%q", c.name, ok, why)
}
}
}
// --slot-save-path : posé pour SLOT_PERSIST même quand l'isolation n'en veut
// pas (cache coupé) ; sans la clé, la règle du lot 1 inchangée.
func TestSlotPersistSlotSaveArgs(t *testing.T) {
si := serveSysInfo{Help: helpCacheRAM, SlotDir: "/data/slots", ArgEnv: map[string]string{}}
base := map[string]string{"HOST": "127.0.0.1", "CACHE_RAM": "0"}
if got := slotSaveArgs(base, nil, si); got != nil {
t.Fatalf("sans la clé : %v", got)
}
on := map[string]string{"HOST": "127.0.0.1", "CACHE_RAM": "0", "SLOT_PERSIST": "on"}
if got := slotSaveArgs(on, nil, si); !reflect.DeepEqual(got, []string{"--slot-save-path", "/data/slots"}) {
t.Fatalf("avec la clé : %v", got)
}
open := map[string]string{"CACHE_RAM": "0", "SLOT_PERSIST": "on"}
if got := slotSaveArgs(open, nil, si); got != nil {
t.Fatalf("0.0.0.0 sans clé : %v", got)
}
}
// Refus : la ligne est celle sans la clé, une note de plus.
func TestSlotPersistRefusLigneInchangee(t *testing.T) {
si := persistSI()
cfg := map[string]string{"CTX": "32768", "SPEC": "auto"}
want, _, wantNotes := buildServeArgs(cfg, nil, "/bin/llama-server", si)
cfg["SLOT_PERSIST"] = "on"
got, _, notes := buildServeArgs(cfg, nil, "/bin/llama-server", si)
if !reflect.DeepEqual(got, want) || len(notes) != len(wantNotes)+1 {
t.Fatalf("refus :\n%v\n%v\n%q", got, want, notes)
}
}
func TestSlotPersistKey(t *testing.T) {
dir := t.TempDir()
bin := filepath.Join(dir, "llama-server")
lib := filepath.Join(dir, "libggml-cuda.so")
model := filepath.Join(dir, "m.gguf")
for _, p := range []string{bin, lib, model} {
if err := os.WriteFile(p, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
}
si := serveSysInfo{Model: model}
args := []string{bin, "-m", model, "-c", "32768", "--api-key", "secret"}
k0 := slotPersistKey(args, nil, si, bin)
if len(k0) != 16 || k0 != slotPersistKey(args, nil, si, bin) {
t.Fatalf("clé instable : %q", k0)
}
if slotPersistKey([]string{bin, "-m", model, "-c", "32768", "--api-key", "autre"}, nil, si, bin) != k0 {
t.Fatal("la clé d'API ne doit pas entrer dans l'empreinte")
}
if slotPersistKey([]string{bin, "-m", model, "-c", "65536"}, nil, si, bin) == k0 {
t.Fatal("CTX ignoré")
}
if slotPersistKey(args, map[string]string{"GGML_CUDA_GRAPH_OPT": "1"}, si, bin) == k0 {
t.Fatal("environnement ignoré")
}
later := time.Now().Add(time.Hour)
for _, p := range []string{lib, model, bin} {
if err := os.Chtimes(p, later, later); err != nil {
t.Fatal(err)
}
k := slotPersistKey(args, nil, si, bin)
if k == k0 {
t.Fatalf("%s modifié, clé inchangée", filepath.Base(p))
}
k0 = k
}
}
func TestSlotPersistFichiers(t *testing.T) {
home := t.TempDir()
dir := prepareSlotDir(home, true)
names := []string{"persist-0123456789abcdef-000000000001.bin", "persist-0123456789abcdef-000000000002.bin",
"persist-fedcba9876543210-000000000003.bin"}
for i, n := range append(append([]string{}, names...), "tmp-"+names[0], "autre.bin") {
p := filepath.Join(dir, n)
if err := os.WriteFile(p, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
at := time.Now().Add(time.Duration(i) * time.Minute)
_ = os.Chtimes(p, at, at)
}
prepareSlotDir(home, true)
slotPersistPrune(dir)
left, _ := os.ReadDir(dir)
var got []string
for _, e := range left {
got = append(got, e.Name())
}
if !reflect.DeepEqual(got, names[1:]) {
t.Fatalf("restent %v, attendu les deux plus récents %v", got, names[1:])
}
prepareSlotDir(home, false)
if left, _ := os.ReadDir(dir); len(left) != 0 {
t.Fatalf("clé retirée : %d fichier(s) gardé(s)", len(left))
}
if readSlotPersistMarker(home) != "" {
t.Fatal("marqueur absent : clé vide attendue")
}
writeSlotPersistMarker(home, "0123456789abcdef")
if readSlotPersistMarker(home) != "0123456789abcdef" {
t.Fatal("marqueur illisible")
}
writeSlotPersistMarker(home, "")
if readSlotPersistMarker(home) != "" {
t.Fatal("marqueur non retiré")
}
}
// moteurSlots : faux llama-server pour /slots, save et restore. Un save écrit
// le fichier dans le dossier des slots, comme le vrai.
type moteurSlots struct {
mu sync.Mutex
actions []string
used, spec bool
restoreFail bool
tokens int
}
func (m *moteurSlots) start(t *testing.T, home string) {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
m.mu.Lock()
defer m.mu.Unlock()
switch {
case r.URL.Path == "/slots" && r.Method == http.MethodGet:
s := map[string]any{"id": 0, "is_processing": false, "speculative": m.spec, "n_prompt_tokens": m.tokens}
if m.used {
s["id_task"] = 7
}
sendJSON(w, 200, []any{s})
case r.URL.Path == "/slots/0" && r.Method == http.MethodPost:
var body struct {
Filename string `json:"filename"`
}
b, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(b, &body)
act := r.URL.Query().Get("action")
m.actions = append(m.actions, act+" "+body.Filename)
if act == "save" {
_ = os.WriteFile(filepath.Join(home, "slots", body.Filename), []byte("kv"), 0o600)
sendJSON(w, 200, map[string]any{"n_saved": m.tokens, "n_written": 2 << 20})
return
}
if m.restoreFail {
sendJSON(w, 400, map[string]any{"error": map[string]any{"message": "Unable to restore slot"}})
return
}
sendJSON(w, 200, map[string]any{"n_restored": m.tokens, "n_read": 2 << 20})
default:
http.NotFound(w, r)
}
}))
t.Cleanup(srv.Close)
_, port, _ := strings.Cut(strings.TrimPrefix(srv.URL, "http://"), ":")
if err := SetConfigKey("PORT", port); err != nil {
t.Fatal(err)
}
}
func (m *moteurSlots) got() []string {
m.mu.Lock()
defer m.mu.Unlock()
return append([]string(nil), m.actions...)
}
const persistTestKey = "0123456789abcdef"
// persistSetup : discussion « c1 » dont le tour vient de passer par le slot.
func persistSetup(t *testing.T, on bool) (*moteurSlots, string) {
t.Helper()
home := testHome(t)
if err := os.MkdirAll(filepath.Join(home, "slots"), 0o700); err != nil {
t.Fatal(err)
}
for k, v := range map[string]string{"MODEL": "m.gguf", "CTX": "32768"} {
if err := SetConfigKey(k, v); err != nil {
t.Fatal(err)
}
}
if on {
if err := SetConfigKey("SLOT_PERSIST", "on"); err != nil {
t.Fatal(err)
}
}
writeSlotPersistMarker(home, persistTestKey)
prev := slotPersistSize
slotPersistSize = func(map[string]string, int) int64 { return 1 << 20 }
reset := func() {
engineGate.mu.Lock()
engineGate.main = engineMainStamp{}
engineGate.mu.Unlock()
}
reset()
t.Cleanup(func() { slotPersistSize = prev; reset() })
m := &moteurSlots{tokens: 20000}
m.start(t, home)
end, seq := engineRequestBegin(nil)
end()
engineMarkMain(seq, "c1")
return m, home
}
// Bascule puis retour : gardé sous la clé du moteur, rechargé une fois avant
// la première requête de la discussion, puis retiré.
func TestSlotPersistSauveEtRecharge(t *testing.T) {
m, home := persistSetup(t, true)
snap := slotPersistPrepare()
if snap == nil {
t.Fatal("rien à garder")
}
snap.save()
name := slotPersistName(persistTestKey, "c1")
path := filepath.Join(home, "slots", name)
if _, err := os.Stat(path); err != nil {
t.Fatalf("état non gardé : %v (%v)", err, m.got())
}
if _, err := os.Stat(filepath.Join(home, "slots", "tmp-"+name)); !os.IsNotExist(err) {
t.Fatal("fichier provisoire resté")
}
ep := resolveChatEndpoint()
slotPersistRestore(ep, "autre") // autre discussion : rien
slotPersistRestore(ep, "c1")
slotPersistRestore(ep, "c1") // une seule fois
want := []string{"save tmp-" + name, "restore " + name}
if got := m.got(); !reflect.DeepEqual(got, want) {
t.Fatalf("appels %v, attendu %v", got, want)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatal("fichier gardé après la tentative")
}
}
// Sans la clé : ni relevé, ni appel au moteur.
func TestSlotPersistDefautRien(t *testing.T) {
m, home := persistSetup(t, false)
if snap := slotPersistPrepare(); snap != nil {
t.Fatal("relevé sans la clé")
}
var snap *slotPersistSnap
snap.save()
name := slotPersistName(persistTestKey, "c1")
if err := os.WriteFile(filepath.Join(home, "slots", name), []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
slotPersistRestore(resolveChatEndpoint(), "c1")
if got := m.got(); len(got) != 0 {
t.Fatalf("appels sans la clé : %v", got)
}
}
// Rien n'est gardé si le slot ne porte plus le tour, si un brouillon tourne,
// si l'état est trop court ou trop gros, ou si une requête part pendant
// l'écriture.
func TestSlotPersistSauvegardeRefusee(t *testing.T) {
m, home := persistSetup(t, true)
end := engineRequestStart() // une autre requête est passée par le slot
end()
if slotPersistPrepare() != nil {
t.Fatal("relevé alors que le slot ne porte plus le tour")
}
for _, c := range []struct {
name string
set func()
}{
{"brouillon spéculatif", func() { m.spec = true }},
{"contexte court", func() { m.tokens = 100 }},
{"état trop gros", func() { slotPersistSize = func(map[string]string, int) int64 { return slotPersistMaxBytes + 1 } }},
} {
m.mu.Lock()
m.spec, m.tokens = false, 20000
m.mu.Unlock()
slotPersistSize = func(map[string]string, int) int64 { return 1 << 20 }
e, seq := engineRequestBegin(nil)
e()
engineMarkMain(seq, "c1")
snap := slotPersistPrepare()
m.mu.Lock()
c.set()
m.mu.Unlock()
snap.save()
if got := m.got(); len(got) != 0 {
t.Fatalf("%s : appels %v", c.name, got)
}
}
// Une requête entre le relevé et l'écriture : fichier jeté.
slotPersistSize = func(map[string]string, int) int64 { return 1 << 20 }
m.mu.Lock()
m.spec, m.tokens = false, 20000
m.mu.Unlock()
e, seq := engineRequestBegin(nil)
e()
engineMarkMain(seq, "c1")
snap := slotPersistPrepare()
e2 := engineRequestStart()
e2()
snap.save()
left, _ := os.ReadDir(filepath.Join(home, "slots"))
if len(left) != 0 {
t.Fatalf("fichier gardé malgré une requête pendant l'écriture : %v", left[0].Name())
}
}
// Rechargement refusé (slot déjà servi, brouillon) ou raté : calcul normal,
// fichier retiré, aucune erreur.
func TestSlotPersistRechargementPrudent(t *testing.T) {
for _, c := range []struct {
name string
set func(*moteurSlots)
restore bool
}{
{"slot déjà servi", func(m *moteurSlots) { m.used = true }, false},
{"brouillon spéculatif", func(m *moteurSlots) { m.spec = true }, false},
{"échec du moteur", func(m *moteurSlots) { m.restoreFail = true }, true},
} {
t.Run(c.name, func(t *testing.T) {
m, home := persistSetup(t, true)
name := slotPersistName(persistTestKey, "c1")
path := filepath.Join(home, "slots", name)
if err := os.WriteFile(path, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
c.set(m)
slotPersistRestore(resolveChatEndpoint(), "c1")
if got := m.got(); (len(got) == 1) != c.restore {
t.Fatalf("appels %v", got)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatal("fichier gardé après la tentative")
}
})
}
}
// Clé du moteur différente (autre build, autres réglages) : le fichier n'est
// même pas cherché.
func TestSlotPersistAutreMoteur(t *testing.T) {
m, home := persistSetup(t, true)
name := slotPersistName(persistTestKey, "c1")
if err := os.WriteFile(filepath.Join(home, "slots", name), []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
writeSlotPersistMarker(home, "fedcba9876543210")
slotPersistRestore(resolveChatEndpoint(), "c1")
if got := m.got(); len(got) != 0 {
t.Fatalf("rechargé sous une autre clé : %v", got)
}
}
// SLOT_PERSIST survit à la bascule (A → B sans la clé → A) : sans ça, le
// lancement de B effacerait l'état gardé de A. Un preset qui la pose gagne.
func TestSlotPersistSurvitALaBascule(t *testing.T) {
home := testHome(t)
setConfig(t, "MODEL=a.gguf\nSLOT_PERSIST=on\n")
b := filepath.Join(home, "b.env")
if err := os.WriteFile(b, []byte("MODEL=b.gguf\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := applyPresetFile(b); err != nil {
t.Fatal(err)
}
if got := ReadConfig()["SLOT_PERSIST"]; got != "on" {
t.Fatalf("SLOT_PERSIST = %q après bascule", got)
}
c := filepath.Join(home, "c.env")
if err := os.WriteFile(c, []byte("MODEL=c.gguf\nSLOT_PERSIST=off\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := applyPresetFile(c); err != nil {
t.Fatal(err)
}
if got := ReadConfig()["SLOT_PERSIST"]; got != "off" {
t.Fatalf("le preset qui pose la clé doit gagner : %q", got)
}
}