mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
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>
This commit is contained in:
1 parent
7a57322a62
commit
28ba44eb46
12 files changed
+1133
-23
No files matched your search
@@ -498,6 +498,39 @@ Ajoutées par ce fork :
|
||||
requêtes partent sans `id_slot`, comme avant. Ce que voit le modèle ne
|
||||
change pas : deux slots qui calculent en même temps donnent des logits égaux
|
||||
au bruit de virgule flottante près, comme un découpage de lots différent.
|
||||
Un « cache KV plein » sans nombre de jetons (le pool, pas le prompt) est
|
||||
rejoué tel quel et ne déclenche jamais de compaction.
|
||||
- **État du slot gardé à la bascule de preset** (clé `SLOT_PERSIST`, **off**
|
||||
par défaut, `loki config set SLOT_PERSIST on`) : passer d'un preset A à B
|
||||
puis revenir à A relance le moteur deux fois, et la conversation de A est
|
||||
recalculée en entier au retour. Avec `on`, juste avant une bascule faite
|
||||
depuis l'interface ou par une tâche, Loki demande au moteur d'écrire l'état
|
||||
du slot de la discussion (`/slots/0?action=save`, sous `LOKI_HOME/slots`),
|
||||
et le recharge au retour, avant le premier message de cette discussion.
|
||||
Conditions strictes, sinon rien n'est écrit ou rechargé et le calcul est
|
||||
normal : la dernière requête passée par le slot était bien un tour de
|
||||
cette discussion (pas une vérification, une tâche, un préchauffage ou un
|
||||
client `/v1`), au moins 4096 jetons, au plus 8 Gio estimés et le double
|
||||
de place libre ; au retour, le moteur doit avoir **exactement** la même
|
||||
empreinte — ligne de commande complète (modèle, `CTX`, types KV, `NGL`,
|
||||
lots, gabarit, `EXTRA_ARGS`…), variables `LLAMA_ARG_*`/`GGML_*`/`CUDA_*`,
|
||||
taille et date du modèle, du projecteur, du binaire et de ses bibliothèques
|
||||
— et son slot 0 ne doit encore avoir servi aucune tâche. Une mise à jour du
|
||||
moteur change l'empreinte : jamais de reprise d'un build à l'autre. Une
|
||||
seule tentative par fichier, retiré ensuite ; deux fichiers au plus,
|
||||
supprimés dès que la clé est retirée. Réglage de la machine : il survit aux
|
||||
bascules comme `HOST` (un preset qui le pose l'emporte). Refusé au lancement (note au journal)
|
||||
avec le décodage spéculatif (`SPEC`, brouillon dans `EXTRA_ARGS` : la
|
||||
sauvegarde du moteur ne garde pas l'état du brouillon, et un brouillon MTP
|
||||
détecté sur le slot bloque aussi), des poids sur CPU (MoE déporté), un
|
||||
`--slot-save-path` déjà réglé, ou un moteur joignable par d'autres sans clé
|
||||
d'API (`HOST` autre que `127.0.0.1`). `loki switch` en ligne de commande ne
|
||||
garde rien (seul le process web sait ce que porte le slot). Le gain est
|
||||
réel surtout sur un modèle dense ; sur un hybride (Qwen3.5/3.6), le fichier
|
||||
n'a pas de points de reprise et ne sert que si le message suivant prolonge
|
||||
exactement les jetons gardés (gabarit qui rend le dernier tour à
|
||||
l'identique), sinon recalcul comme avant. Ce que voit le modèle ne change
|
||||
pas : llama.cpp ne reprend un état que sur un préfixe de jetons identique.
|
||||
- **Discussions multiples** : historique complet dans la barre latérale, titre
|
||||
repris du premier message (renommable), suppression. **Chaque discussion a son
|
||||
dossier de fichiers** (`workspace/discussions/<id>/`) : les pièces jointes
|
||||
|
||||
@@ -192,7 +192,10 @@ func safePresetPath(name string) (string, error) {
|
||||
// CUDA_LAUNCH_QUEUES (files de lancement CUDA, voir launchQueuesEnv) suit la
|
||||
// même logique : un « off » posé parce que CETTE machine bloque ne doit pas
|
||||
// disparaître à la bascule suivante.
|
||||
var preservedKeys = []string{"MEM_MODE", "CRAWL4AI_URL", "WEB_ENGINE", "CUDA_VISIBLE_DEVICES", "HOST", "CUDA_LAUNCH_QUEUES"}
|
||||
// SLOT_PERSIST (état du slot gardé à la bascule, backend_serve_persist.go) est
|
||||
// par nature un réglage de bascule : posé sur A seulement, il disparaîtrait en
|
||||
// passant sur B, dont le lancement effacerait l'état gardé de A.
|
||||
var preservedKeys = []string{"MEM_MODE", "CRAWL4AI_URL", "WEB_ENGINE", "CUDA_VISIBLE_DEVICES", "HOST", "CUDA_LAUNCH_QUEUES", "SLOT_PERSIST"}
|
||||
|
||||
// softPreservedKeys : préservées SEULEMENT si le preset d'arrivée ne les définit
|
||||
// pas lui-même. CUDA_VISIBLE_DEVICES est dans ce cas : c'est d'ordinaire un
|
||||
@@ -203,7 +206,7 @@ var preservedKeys = []string{"MEM_MODE", "CRAWL4AI_URL", "WEB_ENGINE", "CUDA_VIS
|
||||
// modèle se retrouvait entièrement sur une seule carte et mourait sur
|
||||
// « cudaMalloc failed: out of memory ». Le preset explicite gagne.
|
||||
// CUDA_LAUNCH_QUEUES aussi : un preset peut l'imposer pour son modèle.
|
||||
var softPreservedKeys = map[string]bool{"CUDA_VISIBLE_DEVICES": true, "HOST": true, "CUDA_LAUNCH_QUEUES": true}
|
||||
var softPreservedKeys = map[string]bool{"CUDA_VISIBLE_DEVICES": true, "HOST": true, "CUDA_LAUNCH_QUEUES": true, "SLOT_PERSIST": true}
|
||||
|
||||
// applyPresetFile installe le preset comme configuration active, en réinjectant
|
||||
// les réglages « appareil » par-dessus. Séparé de SwitchToPreset pour être
|
||||
@@ -264,6 +267,10 @@ func presetImposesEngine(p string) bool {
|
||||
// SwitchToPreset installe le preset et redémarre le service. Les réglages
|
||||
// « appareil » (preservedKeys) sont conservés à travers la bascule.
|
||||
func SwitchToPreset(target string) error {
|
||||
// SLOT_PERSIST : relevé sur la configuration d'avant, gardé juste avant
|
||||
// l'arrêt (llm_slotpersist.go). Sans la clé, ou hors du process web (rien
|
||||
// n'y dit ce que porte le slot) : nil, rien ne se passe.
|
||||
persist := slotPersistPrepare()
|
||||
if err := applyPresetFile(target); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -277,9 +284,11 @@ func SwitchToPreset(target string) error {
|
||||
if !serviceIsActive() {
|
||||
return nil
|
||||
}
|
||||
persist.save()
|
||||
return serviceAction("stop")
|
||||
}
|
||||
fmt.Println(dim("[info] redémarrage du service..."))
|
||||
persist.save()
|
||||
return serviceAction("restart")
|
||||
}
|
||||
|
||||
|
||||
@@ -362,12 +362,24 @@ func cmdServe(args []string) error {
|
||||
if spec == "auto" {
|
||||
si.SpecAutoBlocked, specRecord = specAutoPeek(specMark)
|
||||
}
|
||||
probeSlotPersistEnv(cfg, &si)
|
||||
if strings.Contains(si.Help, "--slot-save-path") && !hasAnyFlag(extra, "--slot-save-path") {
|
||||
si.SlotDir = prepareSlotDir(LokiHome())
|
||||
si.SlotDir = prepareSlotDir(LokiHome(), slotPersistOn(cfg))
|
||||
}
|
||||
|
||||
llmArgs, env, notes := buildServeArgs(cfg, extra, bin, si)
|
||||
applyServeEnv(env)
|
||||
// SLOT_PERSIST (backend_serve_persist.go) : la clé de CE lancement, et les
|
||||
// états gardés au-delà de deux retirés. Refusé pour ce preset (brouillon,
|
||||
// poids sur CPU…) : ceux des autres presets restent, pour leur retour.
|
||||
// Clé retirée : plus aucun état gardé sur le disque.
|
||||
persistKey := ""
|
||||
if ok, _ := slotPersistPlan(cfg, extra, si); ok {
|
||||
persistKey = slotPersistKey(llmArgs, env, si, bin)
|
||||
slotPersistPrune(si.SlotDir)
|
||||
} else if !slotPersistOn(cfg) {
|
||||
slotPersistPurge(filepath.Join(LokiHome(), "slots"))
|
||||
}
|
||||
for _, n := range notes {
|
||||
fmt.Fprintln(os.Stderr, "[loki serve] "+n)
|
||||
}
|
||||
@@ -395,6 +407,10 @@ func cmdServe(args []string) error {
|
||||
specAutoSettle(specMark, si.SpecAutoBlocked, specRecord, auto)
|
||||
}
|
||||
|
||||
// Posée au dernier moment, port libre : l'ancien moteur est parti, la clé
|
||||
// lue par le process web est celle du moteur qui répondra.
|
||||
writeSlotPersistMarker(LokiHome(), persistKey)
|
||||
|
||||
fmt.Fprintf(os.Stderr, "[loki serve] %s model=%s port=%s\n",
|
||||
bin, filepath.Base(model), port)
|
||||
|
||||
@@ -644,6 +660,9 @@ func buildServeArgs(cfg map[string]string, extra []string, bin string, si serveS
|
||||
cram, cramNotes := cacheRAMArgs(cfg, extra, si)
|
||||
args = append(append(args, cram...), slotSaveArgs(cfg, extra, si)...)
|
||||
notes = append(notes, cramNotes...)
|
||||
if n := slotPersistNote(slotPersistPlan(cfg, extra, si)); n != "" {
|
||||
notes = append(notes, n)
|
||||
}
|
||||
// Points de reprise des hybrides (voir backend_serve_ckpt.go).
|
||||
ck, ckNotes := ckptArgs(cfg, extra, si)
|
||||
args = append(args, ck...)
|
||||
|
||||
@@ -450,30 +450,29 @@ func loopbackHost(h string) bool {
|
||||
// restore, qui écrivent des Gio sur le disque, à quiconque joint le moteur. On
|
||||
// ne le pose donc que si le moteur est fermé aux autres (boucle locale, ou clé
|
||||
// d'API exigée), que le dossier existe (le moteur refuse de démarrer sinon), et
|
||||
// que l'isolation servira : cache actif, CACHE_ISOLATE pas à off. Les proxys de
|
||||
// Loki refusent de leur côté tout /slots qui n'est pas une lecture.
|
||||
// qu'il servira : à l'isolation (cache actif, CACHE_ISOLATE pas à off), ou à
|
||||
// SLOT_PERSIST accepté (backend_serve_persist.go). Les proxys de Loki refusent
|
||||
// de leur côté tout /slots qui n'est pas une lecture.
|
||||
func slotSaveArgs(cfg map[string]string, extra []string, si serveSysInfo) []string {
|
||||
if si.SlotDir == "" || !strings.Contains(si.Help, "--slot-save-path") ||
|
||||
hasAnyFlag(extra, "--slot-save-path") || !cacheIsolationOn(cfg) || cacheRAMOff(cfg, extra, si.ArgEnv) {
|
||||
if si.SlotDir == "" || !strings.Contains(si.Help, "--slot-save-path") || hasAnyFlag(extra, "--slot-save-path") {
|
||||
return nil
|
||||
}
|
||||
host := flagValue(extra, "--host")
|
||||
if host == "" {
|
||||
host = cfg["HOST"]
|
||||
persist, _ := slotPersistPlan(cfg, extra, si)
|
||||
if !persist && (!cacheIsolationOn(cfg) || cacheRAMOff(cfg, extra, si.ArgEnv)) {
|
||||
return nil
|
||||
}
|
||||
if host == "" {
|
||||
host = "0.0.0.0"
|
||||
}
|
||||
if !loopbackHost(host) && si.APIKey == "" {
|
||||
if !slotSaveSafe(cfg, extra, si) {
|
||||
return nil
|
||||
}
|
||||
return []string{"--slot-save-path", si.SlotDir}
|
||||
}
|
||||
|
||||
// prepareSlotDir crée LOKI_HOME/slots (0700) et le vide : Loki n'y sauve jamais
|
||||
// rien, un fichier présent ne peut venir que d'un appel qui n'aurait pas dû
|
||||
// passer. Vide = dossier indisponible, le drapeau ne sera pas posé.
|
||||
func prepareSlotDir(home string) string {
|
||||
// prepareSlotDir crée LOKI_HOME/slots (0700) et le vide : un fichier présent ne
|
||||
// peut venir que d'un appel qui n'aurait pas dû passer. Seule exception, avec
|
||||
// keepPersist (SLOT_PERSIST demandé) : les états que Loki y a lui-même gardés
|
||||
// à la bascule de preset, au nom reconnaissable. Vide = dossier indisponible,
|
||||
// le drapeau ne sera pas posé.
|
||||
func prepareSlotDir(home string, keepPersist bool) string {
|
||||
dir := filepath.Join(home, "slots")
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return ""
|
||||
@@ -483,7 +482,7 @@ func prepareSlotDir(home string) string {
|
||||
}
|
||||
if entries, err := os.ReadDir(dir); err == nil {
|
||||
for _, e := range entries {
|
||||
if e.Type().IsRegular() {
|
||||
if e.Type().IsRegular() && !(keepPersist && slotPersistFileRe.MatchString(e.Name())) {
|
||||
_ = os.Remove(filepath.Join(dir, e.Name()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,7 +238,7 @@ func TestBuildServeArgsCacheRAM(t *testing.T) {
|
||||
|
||||
func TestPrepareSlotDir(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
dir := prepareSlotDir(home)
|
||||
dir := prepareSlotDir(home, false)
|
||||
if dir != filepath.Join(home, "slots") {
|
||||
t.Fatalf("dossier = %q", dir)
|
||||
}
|
||||
@@ -246,7 +246,7 @@ func TestPrepareSlotDir(t *testing.T) {
|
||||
if err := os.WriteFile(stray, []byte("x"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if prepareSlotDir(home) == "" {
|
||||
if prepareSlotDir(home, false) == "" {
|
||||
t.Fatal("second appel")
|
||||
}
|
||||
if _, err := os.Stat(stray); !os.IsNotExist(err) {
|
||||
@@ -258,7 +258,7 @@ func TestPrepareSlotDir(t *testing.T) {
|
||||
if err := os.WriteFile(filepath.Join(bad, "slots"), nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := prepareSlotDir(bad); got != "" {
|
||||
if got := prepareSlotDir(bad, false); got != "" {
|
||||
t.Fatalf("slots est un fichier : %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// État du slot gardé à travers une bascule de preset (SLOT_PERSIST, off par
|
||||
// défaut).
|
||||
//
|
||||
// Basculer d'un preset A vers B puis revenir à A relance llama-server deux
|
||||
// fois : au retour, la conversation de A est recalculée en entier — 30 à 80 s
|
||||
// sur un 27B dense. Avec SLOT_PERSIST=on, Loki demande au moteur, juste avant
|
||||
// la bascule, d'écrire l'état du slot 0 sur disque (/slots/0?action=save), et
|
||||
// le recharge au retour, avant la première requête de la discussion
|
||||
// (llm_slotpersist.go). llama-server ne reprend un état que sur un préfixe de
|
||||
// jetons identique : au pire, rien n'est repris.
|
||||
//
|
||||
// Mais rien, dans le fichier, ne dit quel moteur ni quels réglages ont calculé
|
||||
// ce cache : llama.cpp ne vérifie que les types et les tailles. Le recharger
|
||||
// sous un autre build, une autre fenêtre ou un autre placement donnerait au
|
||||
// modèle un contexte que ce moteur n'aurait jamais produit. D'où la clé : une
|
||||
// empreinte de TOUT ce qui touche au calcul — ligne de commande complète
|
||||
// (modèle, fenêtre, types KV, NGL, lots, gabarit, EXTRA_ARGS…), variables
|
||||
// LLAMA_ARG_*, GGML_* et CUDA_*, identité (taille, date) du modèle et de ses
|
||||
// tranches, du projecteur, du brouillon, du binaire et de ses bibliothèques.
|
||||
// Un fichier ne se recharge que sous la même clé ; une mise à jour du moteur
|
||||
// change donc tout, et n'est jamais un cas de reprise.
|
||||
//
|
||||
// Refusé d'emblée : le décodage spéculatif (la sauvegarde de llama.cpp ne
|
||||
// garde que le modèle cible, pas le brouillon MTP — l'état rechargé serait
|
||||
// incohérent pour lui), les poids sur CPU (MoE déporté : états de plusieurs
|
||||
// Gio pour un recalcul de toute façon lent), et un moteur joignable par
|
||||
// d'autres sans clé d'API : save et restore écrivent des Gio sur le disque.
|
||||
|
||||
// slotPersistOn : la clé SLOT_PERSIST est-elle posée ?
|
||||
func slotPersistOn(cfg map[string]string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(cfg["SLOT_PERSIST"])) {
|
||||
case "on", "1", "true", "yes", "oui":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// slotPersistArgEnv : variables qui contredisent SLOT_PERSIST sans drapeau.
|
||||
var slotPersistArgEnv = []string{"LLAMA_ARG_SLOT_SAVE_PATH", "LLAMA_ARG_SPEC_TYPE", "LLAMA_ARG_SPEC_DRAFT_MODEL",
|
||||
"LLAMA_ARG_SPEC_DRAFT_HF_REPO"}
|
||||
|
||||
// probeSlotPersistEnv complète ArgEnv, seulement si la clé est posée.
|
||||
func probeSlotPersistEnv(cfg map[string]string, si *serveSysInfo) {
|
||||
if !slotPersistOn(cfg) {
|
||||
return
|
||||
}
|
||||
if si.ArgEnv == nil {
|
||||
si.ArgEnv = map[string]string{}
|
||||
}
|
||||
for _, k := range slotPersistArgEnv {
|
||||
if v, ok := os.LookupEnv(k); ok && strings.TrimSpace(v) != "" {
|
||||
si.ArgEnv[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// slotPersistPlan : SLOT_PERSIST vaut-il pour ce lancement ? why dit pourquoi
|
||||
// non (vide = clé absente).
|
||||
func slotPersistPlan(cfg map[string]string, extra []string, si serveSysInfo) (ok bool, why string) {
|
||||
if !slotPersistOn(cfg) {
|
||||
return false, ""
|
||||
}
|
||||
switch {
|
||||
case isExternalConfig(cfg):
|
||||
return false, "preset externe"
|
||||
case !strings.Contains(si.Help, "--slot-save-path"):
|
||||
return false, "ce moteur ne connaît pas --slot-save-path"
|
||||
case hasAnyFlag(extra, "--slot-save-path") || si.ArgEnv["LLAMA_ARG_SLOT_SAVE_PATH"] != "":
|
||||
return false, "--slot-save-path est déjà réglé à la main"
|
||||
case si.SlotDir == "":
|
||||
return false, "dossier des slots indisponible"
|
||||
case specMode(cfg) != "off" || specUserSet(extra, si.ArgEnv) || si.ArgEnv["LLAMA_ARG_SPEC_TYPE"] != "":
|
||||
return false, "décodage spéculatif : la sauvegarde du moteur ne garde pas l'état du brouillon"
|
||||
}
|
||||
if !slotSaveSafe(cfg, extra, si) {
|
||||
return false, "moteur joignable par d'autres sans clé d'API (HOST=127.0.0.1 ou une clé d'API)"
|
||||
}
|
||||
blocks := 0
|
||||
if si.GGUF != nil {
|
||||
blocks = si.GGUF.BlockCount
|
||||
}
|
||||
if r := cpuWeights(cfg, extra, si.ArgEnv, blocks); r != "" {
|
||||
return false, r
|
||||
}
|
||||
return true, ""
|
||||
}
|
||||
|
||||
// slotSaveSafe : le moteur n'est joignable que par la machine, ou exige une
|
||||
// clé d'API. Sans ça, --slot-save-path donnerait à n'importe qui le droit
|
||||
// d'écrire des Gio sur le disque.
|
||||
func slotSaveSafe(cfg map[string]string, extra []string, si serveSysInfo) bool {
|
||||
host := flagValue(extra, "--host")
|
||||
if host == "" {
|
||||
host = cfg["HOST"]
|
||||
}
|
||||
if host == "" {
|
||||
host = "0.0.0.0"
|
||||
}
|
||||
return loopbackHost(host) || si.APIKey != ""
|
||||
}
|
||||
|
||||
// slotPersistNote : ce que le lancement dit de SLOT_PERSIST.
|
||||
func slotPersistNote(ok bool, why string) string {
|
||||
if ok || why == "" {
|
||||
return ""
|
||||
}
|
||||
return "SLOT_PERSIST refusé (" + why + ") : l'état du slot ne sera pas gardé à la bascule de preset"
|
||||
}
|
||||
|
||||
// slotPersistFileRe : les seuls fichiers que Loki écrit dans le dossier des
|
||||
// slots — clé du moteur, empreinte de la discussion.
|
||||
var slotPersistFileRe = regexp.MustCompile(`^persist-[0-9a-f]{16}-[0-9a-f]{12}\.bin$`)
|
||||
|
||||
// slotPersistMaxFiles : au plus deux états gardés (A→B→A, puis B→A→B).
|
||||
const slotPersistMaxFiles = 2
|
||||
|
||||
// slotPersistKey : l'empreinte de tout ce qui décide du calcul du cache.
|
||||
// args : la ligne de commande finale (la clé d'API n'y entre pas) ; env : ce
|
||||
// que buildServeArgs pose. Les fichiers sont pris par taille et date : un
|
||||
// modèle retéléchargé, un moteur mis à jour changent la clé.
|
||||
func slotPersistKey(args []string, env map[string]string, si serveSysInfo, bin string) string {
|
||||
h := sha256.New()
|
||||
w := func(s string) { h.Write([]byte(s)); h.Write([]byte{0}) }
|
||||
for i := 0; i < len(args); i++ {
|
||||
if args[i] == "--api-key" {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
w(args[i])
|
||||
}
|
||||
vars := map[string]string{}
|
||||
for _, kv := range os.Environ() {
|
||||
k, v, _ := strings.Cut(kv, "=")
|
||||
if strings.HasPrefix(k, "LLAMA_ARG_") || strings.HasPrefix(k, "GGML_") || strings.HasPrefix(k, "CUDA_") {
|
||||
vars[k] = v
|
||||
}
|
||||
}
|
||||
for k, v := range env {
|
||||
vars[k] = v
|
||||
}
|
||||
delete(vars, "LLAMA_ARG_API_KEY")
|
||||
keys := make([]string, 0, len(vars))
|
||||
for k := range vars {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
w(k + "=" + vars[k])
|
||||
}
|
||||
stat := func(p string) {
|
||||
if p == "" {
|
||||
return
|
||||
}
|
||||
if st, err := os.Stat(p); err == nil {
|
||||
w(fmt.Sprintf("%s|%d|%d", p, st.Size(), st.ModTime().UnixNano()))
|
||||
} else {
|
||||
w(p + "|?")
|
||||
}
|
||||
}
|
||||
if si.Model != "" {
|
||||
for _, n := range shardFamily(filepath.Base(si.Model)) {
|
||||
stat(filepath.Join(filepath.Dir(si.Model), n))
|
||||
}
|
||||
stat(si.Model)
|
||||
}
|
||||
stat(si.MMProj)
|
||||
stat(si.Draft)
|
||||
stat(bin)
|
||||
// Bibliothèques du moteur (libllama, ggml-cuda…) : une mise à jour peut
|
||||
// les changer sans toucher au binaire.
|
||||
if entries, err := os.ReadDir(filepath.Dir(bin)); err == nil {
|
||||
for _, e := range entries {
|
||||
n := strings.ToLower(e.Name())
|
||||
if strings.HasSuffix(n, ".so") || strings.Contains(n, ".so.") || strings.HasSuffix(n, ".dll") || strings.HasSuffix(n, ".dylib") {
|
||||
stat(filepath.Join(filepath.Dir(bin), e.Name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
w(fmt.Sprintf("build=%d", si.EngineBuild))
|
||||
return hex.EncodeToString(h.Sum(nil))[:16]
|
||||
}
|
||||
|
||||
// slotPersistMarker : la clé du moteur lancé, écrite par « loki serve » juste
|
||||
// avant de lui céder la place, lue par le process web. Hors du dossier des
|
||||
// slots : le moteur ne peut pas l'écraser par un save.
|
||||
func slotPersistMarker(home string) string { return filepath.Join(home, "slots-engine.key") }
|
||||
|
||||
// writeSlotPersistMarker pose (key non vide) ou retire la clé du moteur.
|
||||
func writeSlotPersistMarker(home, key string) {
|
||||
p := slotPersistMarker(home)
|
||||
if key == "" {
|
||||
_ = os.Remove(p)
|
||||
return
|
||||
}
|
||||
_ = os.WriteFile(p, []byte(key+"\n"), 0o600)
|
||||
}
|
||||
|
||||
// readSlotPersistMarker : la clé du moteur en service ; vide = SLOT_PERSIST
|
||||
// refusé ou absent à son lancement.
|
||||
func readSlotPersistMarker(home string) string {
|
||||
b, err := os.ReadFile(slotPersistMarker(home))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
k := strings.TrimSpace(string(b))
|
||||
if len(k) != 16 {
|
||||
return ""
|
||||
}
|
||||
if _, err := hex.DecodeString(k); err != nil {
|
||||
return ""
|
||||
}
|
||||
return k
|
||||
}
|
||||
|
||||
// slotPersistPrune garde les slotPersistMaxFiles états les plus récents.
|
||||
func slotPersistPrune(dir string) {
|
||||
type f struct {
|
||||
name string
|
||||
at int64
|
||||
}
|
||||
var fs []f
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !e.Type().IsRegular() || !slotPersistFileRe.MatchString(e.Name()) {
|
||||
continue
|
||||
}
|
||||
if info, err := e.Info(); err == nil {
|
||||
fs = append(fs, f{e.Name(), info.ModTime().UnixNano()})
|
||||
}
|
||||
}
|
||||
sort.Slice(fs, func(i, j int) bool { return fs[i].at > fs[j].at })
|
||||
for i := slotPersistMaxFiles; i < len(fs); i++ {
|
||||
_ = os.Remove(filepath.Join(dir, fs[i].name))
|
||||
}
|
||||
}
|
||||
|
||||
// slotPersistPurge retire tous les états gardés : la clé n'est plus là, ils
|
||||
// n'ont plus rien à faire sur le disque.
|
||||
func slotPersistPurge(dir string) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.Type().IsRegular() && slotPersistFileRe.MatchString(e.Name()) {
|
||||
_ = os.Remove(filepath.Join(dir, e.Name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -398,6 +398,9 @@ func (c *Conversation) prewarm(why string) {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// SLOT_PERSIST : l'état gardé de la discussion d'abord, s'il y en a un — le
|
||||
// préchauffage le prolonge au lieu de tout recalculer.
|
||||
slotPersistRestore(ep, convActiveID())
|
||||
ctx, cancel := context.WithTimeout(context.Background(), prewarmTimeout)
|
||||
defer cancel()
|
||||
run.cancel, run.done = cancel, make(chan struct{})
|
||||
|
||||
@@ -1454,6 +1454,12 @@ func runChatLoop(ctx context.Context, kt *keepImages, messages []Message, tools
|
||||
// s'il prépare exactement le début de CETTE requête d'un tour de chat.
|
||||
endReq := func() {}
|
||||
var reqSeq uint64
|
||||
// SLOT_PERSIST (llm_slotpersist.go) : l'état gardé à la dernière bascule
|
||||
// de preset est rechargé avant la première requête de la discussion,
|
||||
// si tout concorde. Sans la clé, rien.
|
||||
if !ep.External && ptag.kind == perfMain {
|
||||
slotPersistRestore(ep, ptag.conv)
|
||||
}
|
||||
if !ep.External {
|
||||
endReq, reqSeq = engineRequestBeginSide(prewarmKeeper(ptag.kind, sent, payload), payloadOnSideSlot(payload))
|
||||
}
|
||||
@@ -1645,7 +1651,9 @@ func runChatLoop(ctx context.Context, kt *keepImages, messages []Message, tools
|
||||
engineServed() // le slot porte désormais cette requête (llm_slots.go)
|
||||
// Étape d'un tour de discussion : une compaction en continuation
|
||||
// pourra la prolonger (chat_compact_cont.go). Sans la clé, rien.
|
||||
if ptag.kind == perfMain && compactContinuationOn(chatCfg) {
|
||||
// SLOT_PERSIST s'en sert aussi : seul un slot qui porte un tour de
|
||||
// discussion est gardé à la bascule de preset.
|
||||
if ptag.kind == perfMain && (compactContinuationOn(chatCfg) || slotPersistOn(chatCfg)) {
|
||||
engineMarkMain(reqSeq, ptag.conv)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SLOT_PERSIST, côté process web : garder l'état du slot de la discussion
|
||||
// avant une bascule de preset, le recharger au retour (backend_serve_persist.go
|
||||
// pour la clé et les refus au lancement).
|
||||
//
|
||||
// Sauver : seulement si la dernière requête de Loki passée par le slot 0 était
|
||||
// une étape d'un tour de la discussion (le tampon d'engineMarkMain) — un
|
||||
// vérificateur, une tâche, un titre ou un client /v1 passés depuis, et le slot
|
||||
// ne porte plus ce qu'on croit. Ni brouillon spéculatif actif, ni état trop
|
||||
// gros (plafond fixe, place libre vérifiée), ni contexte trop court pour valoir
|
||||
// une écriture. Le fichier est écrit sous un nom provisoire et ne prend son
|
||||
// nom définitif que si aucune requête n'est partie pendant l'écriture.
|
||||
//
|
||||
// Recharger : avant la première requête de la discussion sur le moteur relancé,
|
||||
// et seulement si le fichier porte la clé de CE moteur (même modèle, même
|
||||
// build, mêmes réglages — tout ce qui touche au cache) et l'empreinte de CETTE
|
||||
// discussion, que le slot 0 n'a encore servi aucune tâche (/slots sans
|
||||
// id_task) et qu'aucun brouillon spéculatif n'y tourne. Une seule chance : le
|
||||
// fichier est retiré après la tentative, réussie ou non. Toute erreur retombe
|
||||
// en silence sur un calcul normal — llama.cpp vide lui-même le slot sur un
|
||||
// rechargement raté.
|
||||
//
|
||||
// Sur un modèle hybride (Qwen3.5/3.6), le fichier n'a pas de points de reprise
|
||||
// : il ne sert que si la requête suivante prolonge exactement les jetons
|
||||
// gardés, ce qui suppose un gabarit qui rend le dernier tour à l'identique.
|
||||
|
||||
// slotPersistMaxBytes : au-delà, l'état n'est pas gardé. Un 27B dense en f16 à
|
||||
// 32k jetons pèse 8 Gio ; plus gros, l'écriture et la relecture coûtent autant
|
||||
// que le recalcul sur un disque lent.
|
||||
const slotPersistMaxBytes = 8 << 30
|
||||
|
||||
// slotPersistMinTokens : en dessous, le recalcul est trop court pour valoir une
|
||||
// écriture.
|
||||
const slotPersistMinTokens = 4096
|
||||
|
||||
// Bornes des appels au moteur : écrire ou relire 8 Gio sur un disque lent.
|
||||
var (
|
||||
slotPersistSaveTimeout = 3 * time.Minute
|
||||
slotPersistRestoreTimeout = 2 * time.Minute
|
||||
)
|
||||
|
||||
// slotPersistName : le fichier d'une discussion sous une clé de moteur.
|
||||
func slotPersistName(key, conv string) string {
|
||||
h := fnv.New64a()
|
||||
h.Write([]byte(conv))
|
||||
return fmt.Sprintf("persist-%s-%012x.bin", key, h.Sum64()&0xffffffffffff)
|
||||
}
|
||||
|
||||
func slotPersistDir() string { return filepath.Join(LokiHome(), "slots") }
|
||||
|
||||
func slotPersistLog(format string, args ...any) {
|
||||
fmt.Fprintf(os.Stderr, "[slot] "+format+"\n", args...)
|
||||
}
|
||||
|
||||
// engineMainConv : la discussion dont le slot porte plausiblement le tour
|
||||
// (engineSlotHolds), et le numéro de cette requête ; vide = aucune.
|
||||
func engineMainConv() (conv string, seq uint64) {
|
||||
model, window := engineMainNow()
|
||||
engineGate.mu.Lock()
|
||||
defer engineGate.mu.Unlock()
|
||||
m := engineGate.main
|
||||
if m.seq == 0 || m.seq != engineGate.seq || m.model != model || m.window != window {
|
||||
return "", 0
|
||||
}
|
||||
return m.conv, m.seq
|
||||
}
|
||||
|
||||
// slotPersistSnap : ce qu'une bascule doit savoir du moteur EN SERVICE, relevé
|
||||
// avant que la configuration ne change (le modèle et la fenêtre du tampon se
|
||||
// lisent dans la configuration).
|
||||
type slotPersistSnap struct {
|
||||
conv, key string
|
||||
seq uint64
|
||||
cfg map[string]string
|
||||
iso slotIsolator // port et clé d'API du moteur en service
|
||||
}
|
||||
|
||||
// slotPersistPrepare : nil = rien à garder (clé absente, moteur sans clé de
|
||||
// persistance, slot qui ne porte pas un tour de discussion).
|
||||
func slotPersistPrepare() *slotPersistSnap {
|
||||
cfg := ReadConfig()
|
||||
if !slotPersistOn(cfg) || isExternalConfig(cfg) {
|
||||
return nil
|
||||
}
|
||||
key := readSlotPersistMarker(LokiHome())
|
||||
if key == "" {
|
||||
return nil
|
||||
}
|
||||
conv, seq := engineMainConv()
|
||||
if conv == "" {
|
||||
return nil
|
||||
}
|
||||
return &slotPersistSnap{conv: conv, key: key, seq: seq, cfg: cfg, iso: localSlotIsolator(resolveChatEndpoint())}
|
||||
}
|
||||
|
||||
// slotState : ce que /slots dit du slot 0.
|
||||
type slotState struct {
|
||||
found, processing, speculative, used bool
|
||||
tokens int
|
||||
}
|
||||
|
||||
func (s slotIsolator) slot0(ctx context.Context) (slotState, error) {
|
||||
var slots []map[string]json.RawMessage
|
||||
if err := s.get(ctx, "/slots", &slots); err != nil {
|
||||
return slotState{}, err
|
||||
}
|
||||
for _, sl := range slots {
|
||||
var id int
|
||||
if json.Unmarshal(sl["id"], &id) != nil || id != 0 {
|
||||
continue
|
||||
}
|
||||
st := slotState{found: true}
|
||||
_ = json.Unmarshal(sl["is_processing"], &st.processing)
|
||||
_ = json.Unmarshal(sl["speculative"], &st.speculative)
|
||||
_ = json.Unmarshal(sl["n_prompt_tokens"], &st.tokens)
|
||||
_, st.used = sl["id_task"]
|
||||
return st, nil
|
||||
}
|
||||
return slotState{}, nil
|
||||
}
|
||||
|
||||
// action : POST /slots/0?action=…, borné par timeout. Rend le nombre d'octets
|
||||
// écrits ou lus d'après la réponse (0 = non dit).
|
||||
func (s slotIsolator) action(ctx context.Context, action, filename string, timeout time.Duration) (int64, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
body, _ := json.Marshal(map[string]string{"filename": filename})
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.base+"/slots/0?action="+action, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
s.auth(req.Header.Set)
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return 0, fmt.Errorf("%s : %s %s", action, resp.Status, strings.TrimSpace(string(raw[:min(len(raw), 300)])))
|
||||
}
|
||||
var out struct {
|
||||
Written int64 `json:"n_written"`
|
||||
Read int64 `json:"n_read"`
|
||||
}
|
||||
_ = json.Unmarshal(raw, &out)
|
||||
return max(out.Written, out.Read), nil
|
||||
}
|
||||
|
||||
func localSlotIsolator(ep chatEndpoint) slotIsolator {
|
||||
return slotIsolator{base: fmt.Sprintf("http://localhost:%d", LLMPort()), auth: ep.auth, client: http.DefaultClient}
|
||||
}
|
||||
|
||||
// slotPersistSize : slotPersistEstimate, remplaçable par les tests.
|
||||
var slotPersistSize = slotPersistEstimate
|
||||
|
||||
// slotPersistEstimate : octets de l'état de tokens jetons sous cfg ; 0 =
|
||||
// inconnu (et l'état n'est alors pas gardé : on ne borne pas l'inconnu).
|
||||
func slotPersistEstimate(cfg map[string]string, tokens int) int64 {
|
||||
p, err := resolveServeModelPath(strings.TrimSpace(cfg["MODEL"]))
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
g, err := ggufMeta(p)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
argEnv := map[string]string{}
|
||||
for _, k := range fidelityArgEnv {
|
||||
if v := os.Getenv(k); v != "" {
|
||||
argEnv[k] = v
|
||||
}
|
||||
}
|
||||
kt, vt, _ := effectiveKVTypes(cfg, splitArgs(cfg["EXTRA_ARGS"]), argEnv)
|
||||
perTok, rec := ggufStateBytes(g, kt, vt)
|
||||
if perTok <= 0 {
|
||||
return 0
|
||||
}
|
||||
return int64(math.Ceil(perTok*float64(tokens))) + rec
|
||||
}
|
||||
|
||||
// save garde l'état du slot de la discussion, juste avant l'arrêt du moteur.
|
||||
// Jamais d'erreur rendue : au pire, rien n'est gardé.
|
||||
func (s *slotPersistSnap) save() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
iso := s.iso
|
||||
ctx := context.Background()
|
||||
st, err := iso.slot0(ctx)
|
||||
switch {
|
||||
case err != nil:
|
||||
slotPersistLog("état non gardé : /slots illisible (%v)", err)
|
||||
return
|
||||
case !st.found || st.processing:
|
||||
return
|
||||
case st.speculative:
|
||||
slotPersistLog("état non gardé : décodage spéculatif actif (le brouillon ne serait pas sauvé)")
|
||||
return
|
||||
case st.tokens < slotPersistMinTokens:
|
||||
return
|
||||
}
|
||||
est := slotPersistSize(s.cfg, st.tokens)
|
||||
if est <= 0 || est > slotPersistMaxBytes {
|
||||
slotPersistLog("état non gardé : %d jetons, taille estimée %d Mio (plafond %d Mio)", st.tokens, est>>20, slotPersistMaxBytes>>20)
|
||||
return
|
||||
}
|
||||
dir := slotPersistDir()
|
||||
if free := diskFree(dir); free < 2*est+(1<<30) {
|
||||
slotPersistLog("état non gardé : place libre insuffisante ou inconnue dans %s", dir)
|
||||
return
|
||||
}
|
||||
name := slotPersistName(s.key, s.conv)
|
||||
tmp := "tmp-" + name
|
||||
end, seq := engineRequestBegin(nil)
|
||||
t0 := time.Now()
|
||||
n, err := iso.action(ctx, "save", tmp, slotPersistSaveTimeout)
|
||||
end()
|
||||
engineGate.mu.Lock()
|
||||
moved := seq != s.seq+1 || engineGate.seq != seq
|
||||
engineGate.mu.Unlock()
|
||||
if err != nil || moved {
|
||||
_ = os.Remove(filepath.Join(dir, tmp))
|
||||
if err != nil {
|
||||
slotPersistLog("état non gardé : %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := os.Rename(filepath.Join(dir, tmp), filepath.Join(dir, name)); err != nil {
|
||||
_ = os.Remove(filepath.Join(dir, tmp))
|
||||
slotPersistLog("état non gardé : %v", err)
|
||||
return
|
||||
}
|
||||
slotPersistPrune(dir)
|
||||
slotPersistLog("état de la discussion gardé : %d jetons, %d Mio en %s", st.tokens, n>>20, time.Since(t0).Round(time.Millisecond))
|
||||
}
|
||||
|
||||
// slotPersistTried : fichiers déjà tentés (nom et date), pour qu'un fichier
|
||||
// qu'on n'aurait pas pu retirer ne soit pas relu à chaque requête.
|
||||
var slotPersistTried struct {
|
||||
mu sync.Mutex
|
||||
m map[string]bool
|
||||
}
|
||||
|
||||
// slotPersistRestore recharge l'état gardé de conv dans le slot 0, si tout
|
||||
// concorde. Appelée juste avant une requête de la discussion vers le moteur
|
||||
// local ; sans la clé, une lecture de configuration et rien d'autre.
|
||||
func slotPersistRestore(ep chatEndpoint, conv string) {
|
||||
if ep.External || conv == "" {
|
||||
return
|
||||
}
|
||||
cfg := ReadConfig()
|
||||
if !slotPersistOn(cfg) || isExternalConfig(cfg) {
|
||||
return
|
||||
}
|
||||
key := readSlotPersistMarker(LokiHome())
|
||||
if key == "" {
|
||||
return
|
||||
}
|
||||
name := slotPersistName(key, conv)
|
||||
path := filepath.Join(slotPersistDir(), name)
|
||||
fi, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// Un fichier, une tentative : le même nom réécrit par une bascule suivante
|
||||
// (A→B→A→B→A) est un autre fichier.
|
||||
once := fmt.Sprintf("%s|%d", name, fi.ModTime().UnixNano())
|
||||
iso := localSlotIsolator(ep)
|
||||
ctx := context.Background()
|
||||
st, err := iso.slot0(ctx)
|
||||
if err != nil || !st.found {
|
||||
return // moteur pas encore prêt : la requête suivante retentera
|
||||
}
|
||||
slotPersistTried.mu.Lock()
|
||||
if slotPersistTried.m == nil {
|
||||
slotPersistTried.m = map[string]bool{}
|
||||
}
|
||||
tried := slotPersistTried.m[once]
|
||||
slotPersistTried.m[once] = true
|
||||
slotPersistTried.mu.Unlock()
|
||||
if tried {
|
||||
return
|
||||
}
|
||||
// Une seule chance, quoi qu'il arrive.
|
||||
defer os.Remove(path)
|
||||
switch {
|
||||
case st.used || st.processing:
|
||||
slotPersistLog("état gardé ignoré : le slot a déjà servi depuis le lancement")
|
||||
return
|
||||
case st.speculative:
|
||||
slotPersistLog("état gardé ignoré : décodage spéculatif actif")
|
||||
return
|
||||
}
|
||||
end, _ := engineRequestBegin(nil)
|
||||
t0 := time.Now()
|
||||
n, err := iso.action(ctx, "restore", name, slotPersistRestoreTimeout)
|
||||
end()
|
||||
if err != nil {
|
||||
slotPersistLog("état gardé non rechargé, calcul normal : %v", err)
|
||||
return
|
||||
}
|
||||
slotPersistLog("état de la discussion rechargé : %d Mio en %s", n>>20, time.Since(t0).Round(time.Millisecond))
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,11 @@ var configTemplate = []struct{ key, help string }{
|
||||
"sous-agents, tâches, bench, résumé sur transcription et clients /v1 passent par le 1 (id_slot) — l'état de la " +
|
||||
"discussion ne bouge plus. Cache KV et état récurrent en double en VRAM : refusé si le compte ne tient pas, si des " +
|
||||
"poids sont sur CPU, ou si PARALLEL≠2, -c, -np, -kvu, --cache-idle-slots… sont réglés à la main. Vide/off (défaut) = un slot"},
|
||||
{"SLOT_PERSIST", "on = garder sur disque (LOKI_HOME/slots, 2 fichiers au plus, 8 Gio chacun au plus) l'état du slot de " +
|
||||
"la discussion à la bascule de preset, et le recharger au retour avant son premier message : moteur, modèle et réglages " +
|
||||
"identiques à l'octet près (empreinte), slot encore vierge, sinon calcul normal. Refusé avec le décodage spéculatif, " +
|
||||
"des poids sur CPU, ou un moteur joignable sans clé d'API hors 127.0.0.1. Jamais à travers une mise à jour du moteur. " +
|
||||
"Réglage de machine, gardé à la bascule. Vide/off (défaut) = rien n'est écrit"},
|
||||
{"CTX_CHECKPOINTS", "points de reprise par slot d'un modèle hybride (--ctx-checkpoints) ; vide = défaut du moteur (32) ; " +
|
||||
"chacun pèse 70 à 200 Mio de RAM hôte : 8 à 16 si le modèle remplit déjà la RAM"},
|
||||
{"CKPT_MIN_STEP", "espacement minimal en jetons entre deux points de reprise (--checkpoint-min-step), > 0 ; " +
|
||||
|
||||
@@ -1310,6 +1310,9 @@ func handleSwitch(w http.ResponseWriter, r *http.Request) {
|
||||
// SwitchToPreset bloquait la réponse pendant tout l'arrêt de llama-server plus
|
||||
// les 2 s de vérification de checkStarted : côté UI, le clic paraissait mou et
|
||||
// la sélection ne bougeait qu'au bout de plusieurs secondes, pour rien.
|
||||
// SLOT_PERSIST : relevé avant que la configuration change, gardé juste
|
||||
// avant l'arrêt du moteur (llm_slotpersist.go). Sans la clé : nil.
|
||||
persist := slotPersistPrepare()
|
||||
if err := applyPresetFile(target.Path); err != nil {
|
||||
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
@@ -1323,6 +1326,7 @@ func handleSwitch(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Println(dim("[info] preset externe — arrêt du moteur local"))
|
||||
go func() {
|
||||
if serviceIsActive() {
|
||||
persist.save()
|
||||
if err := serviceAction("stop"); err != nil {
|
||||
fmt.Printf("%s arrêt du moteur après bascule externe : %v\n", red("[ERREUR]"), err)
|
||||
}
|
||||
@@ -1333,6 +1337,7 @@ func handleSwitch(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
fmt.Println(dim("[info] redémarrage du service..."))
|
||||
go func() {
|
||||
persist.save()
|
||||
if err := serviceAction("restart"); err != nil {
|
||||
fmt.Printf("%s redémarrage après bascule: %v\n", red("[ERREUR]"), err)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user