diff --git a/internal/loki/backend_models.go b/internal/loki/backend_models.go
index b388a45..8833513 100644
--- a/internal/loki/backend_models.go
+++ b/internal/loki/backend_models.go
@@ -539,7 +539,11 @@ func dlSourceError(resp *http.Response, dlURL string) error {
case 404:
switch code {
case "EntryNotFound":
- return fmt.Errorf("fichier absent du dépôt (HTTP 404) — la révision a pu être réécrite depuis que le lien a été copié")
+ // Nommer le fichier : sur un modèle en fragments, c'est souvent UN
+ // fragment qui manque — certains dépôts n'en publient que six sur
+ // sept et livrent la table PLE à part, à lier soi-même — et sans
+ // le nom, on cherche une révision réécrite qui n'existe pas.
+ return fmt.Errorf("fichier %s absent du dépôt (HTTP 404) — dépôt réorganisé depuis la copie du lien, ou fragment jamais publié (table PLE livrée à part ?)", dlFileLabel(dlURL))
case "RevisionNotFound":
return fmt.Errorf("révision introuvable dans le dépôt (HTTP 404)")
}
@@ -555,6 +559,20 @@ func dlSourceError(resp *http.Response, dlURL string) error {
return fmt.Errorf("HTTP %d depuis la source", resp.StatusCode)
}
+// dlFileLabel : « nom.gguf » entre guillemets pour un message, ou vide si le
+// lien n'a pas de nom de fichier lisible.
+func dlFileLabel(dlURL string) string {
+ u, err := url.Parse(dlURL)
+ if err != nil {
+ return ""
+ }
+ name := path.Base(u.Path)
+ if name == "" || name == "/" || name == "." {
+ return ""
+ }
+ return "« " + name + " »"
+}
+
// dlAccessReason nomme la raison du refus. repo vide = source hors Hugging Face
// (lien direct vers un autre hébergeur) : on ne parle alors pas de conditions à
// accepter, qui n'existent que là-bas.
diff --git a/internal/loki/backend_models_dl_test.go b/internal/loki/backend_models_dl_test.go
index 9223356..6f6a8c2 100644
--- a/internal/loki/backend_models_dl_test.go
+++ b/internal/loki/backend_models_dl_test.go
@@ -172,7 +172,15 @@ func TestDLSourceErrorExplainsGatedRepo(t *testing.T) {
},
{
name: "fichier absent", resp: resp(404, "EntryNotFound"), url: gated,
- want: []string{"absent du dépôt", "404"},
+ want: []string{"« m.gguf » absent du dépôt", "404"},
+ },
+ {
+ // Dépôt qui publie six fragments sur sept (table PLE livrée à part) :
+ // le message doit NOMMER le fragment manquant, sinon on cherche une
+ // révision réécrite qui n'existe pas.
+ name: "fragment absent", resp: resp(404, "EntryNotFound"),
+ url: "https://huggingface.co/x/y-GGUF/resolve/main/Q2_K/y-Q2_K-00007-of-00007.gguf",
+ want: []string{"« y-Q2_K-00007-of-00007.gguf » absent du dépôt", "fragment jamais publié"},
},
{
name: "source hors Hugging Face", resp: resp(403, ""), url: "https://example.com/m.gguf",
diff --git a/internal/loki/dictate_server.go b/internal/loki/dictate_server.go
index 2ff471c..d148ada 100644
--- a/internal/loki/dictate_server.go
+++ b/internal/loki/dictate_server.go
@@ -21,6 +21,7 @@ import (
"strconv"
"strings"
"sync"
+ "sync/atomic"
"time"
)
@@ -170,6 +171,13 @@ const whisperIdle = 10 * time.Minute
const whisperReady = 120 * time.Second
var wsrvMu sync.Mutex
+
+// wsrvDemarrage : le processus en cours de démarrage, visible SANS le verrou.
+// whisperStartLocked garde wsrvMu pendant tout le chargement du modèle ; c'est
+// la seule poignée qu'un autre appelant (whisperShutdownVite) a sur lui pour
+// l'interrompre. Nil hors démarrage.
+var wsrvDemarrage atomic.Pointer[exec.Cmd]
+
var wsrv struct {
cmd *exec.Cmd
port int
@@ -231,7 +239,10 @@ func whisperStartLocked(cfg DictateCfg) (int, error) {
}
wsrv.cmd, wsrv.port, wsrv.cfg = cmd, port, dictateCfgLoad()
wsrv.log, wsrv.note, wsrv.depuis = log, note, time.Now()
- if err := whisperAttendPret(cmd, port, log); err != nil {
+ wsrvDemarrage.Store(cmd)
+ err = whisperAttendPret(cmd, port, log)
+ wsrvDemarrage.Store(nil)
+ if err != nil {
whisperStopLocked()
return 0, err
}
@@ -301,6 +312,30 @@ func whisperShutdown() {
whisperStopLocked()
}
+// whisperShutdownVite arrête le serveur sans jamais attendre derrière un
+// démarrage. whisperShutdown prend wsrvMu, et whisperEnsure le garde pendant
+// tout le chargement du modèle — jusqu'à whisperReady (deux minutes) depuis un
+// disque lent. Appelé par un geste de l'interface (libérer la VRAM), ce blocage
+// ferait abandonner le navigateur alors que le moteur, lui, est déjà arrêté.
+//
+// Si le verrou est pris, on tue le processus en train de démarrer : sa mort
+// fait sortir whisperAttendPret, le verrou se libère, et on finit le ménage
+// par la voie normale. La dictée qui attendait reçoit une erreur de démarrage —
+// c'est l'utilisateur qui vient de demander la carte, pas une panne.
+func whisperShutdownVite() {
+ for i := 0; i < 20; i++ {
+ if wsrvMu.TryLock() {
+ whisperStopLocked()
+ wsrvMu.Unlock()
+ return
+ }
+ if cmd := wsrvDemarrage.Load(); cmd != nil && cmd.Process != nil {
+ _ = cmd.Process.Kill()
+ }
+ time.Sleep(100 * time.Millisecond)
+ }
+}
+
// whisperInfer garantit le serveur puis lui soumet le WAV.
func whisperInfer(ctx context.Context, wav []byte) (string, error) {
port, err := whisperEnsure()
diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html
index 0c1a92d..be9bc04 100644
--- a/internal/loki/ui/index.html
+++ b/internal/loki/ui/index.html
@@ -3726,6 +3726,9 @@ async function loadStatus(){
// local) : un arrêt venu d'un autre onglet, du terminal ou d'un job doit se
// lire ici aussi, sinon le bouton propose de libérer une carte déjà libre.
ENGINE_ACTIVE = !!s.active;
+ // Un moteur qui a PLANTÉ au chargement n'est pas « déchargé » : sans ce
+ // drapeau, syncSendBtn proposerait de recharger un modèle qui va replanter.
+ LOAD_ERROR = s.load_error || '';
paintVramBtn();
// Le bouton d'envoi suit l'état du moteur : inutile de pouvoir envoyer un
// message à un modèle qui n'est pas encore chargé (voir syncSendBtn).
@@ -3901,7 +3904,7 @@ async function loadVram(){
// ENGINE_ACTIVE vient de /api/status (relu toutes les 5 s), jamais d'un drapeau
// posé au clic : sinon un second onglet, ou un arrêt venu du terminal,
// afficherait un bouton qui ment.
-let ENGINE_ACTIVE = true, VRAM_BUSY = false;
+let ENGINE_ACTIVE = true, VRAM_BUSY = false, LOAD_ERROR = '';
function paintVramBtn(){
const b = document.getElementById('vram-btn');
if(!b || VRAM_BUSY) return; // pendant l'opération, le libellé appartient à vramBusy
@@ -3921,7 +3924,11 @@ function vramBusy(on, label){
}
// force=true : on a déjà demandé confirmation ET accepté de couper une réponse
// en cours d'écriture (le serveur refuse sans ça, voir handleVramUnload).
+// Les deux gestes sont exclusifs : un second clic (le bouton des réglages
+// pendant que celui du moniteur travaille) lancerait un « stop » au milieu d'un
+// démarrage, ou l'inverse. VRAM_BUSY tient lieu de verrou.
async function vramUnload(force){
+ if(VRAM_BUSY) return;
if(!force){
const ok = await askConfirm(
'Décharger le modèle ? Le moteur s\'arrête et rend la mémoire vidéo — le chat reste indisponible jusqu\'au rechargement.',
@@ -3931,6 +3938,10 @@ async function vramUnload(force){
vramBusy(true, 'libération…');
let r = null;
try{ r = await jpost('/api/vram/unload', {force: !!force}); }catch(_){}
+ // Le serveur dit l'état RÉEL après coup : le repeindre depuis l'ancien
+ // ENGINE_ACTIVE (relu seulement toutes les 5 s) afficherait « Libérer » sur
+ // un moteur déjà arrêté.
+ if(r && typeof r.active === 'boolean') ENGINE_ACTIVE = r.active;
vramBusy(false);
if(r && r.generating){
const ok = await askConfirm('Une réponse est en cours d\'écriture : décharger maintenant l\'interrompt. Continuer ?',
@@ -3940,17 +3951,21 @@ async function vramUnload(force){
}
// La mesure d'abord : la dictée peut avoir rendu de la VRAM alors même que le
// moteur était déjà arrêté — annoncer « déjà arrêté » nierait ce qui vient
- // d'être libéré.
+ // d'être libéré. Sous quelques dizaines de Mo, c'est le bruit du bureau entre
+ // deux lectures, pas une libération : on ne l'annonce pas.
if(!r || !r.ok) toast('échec : ' + ((r && r.error) || 'réseau'));
- else if(r.gpu && r.freed_mb > 0) toast('VRAM libérée : ' + (r.freed_mb/1024).toFixed(1) + ' Gio');
+ else if(r.gpu && r.freed_mb >= 64) toast('VRAM libérée : ' + fmtMo(r.freed_mb));
else if(!r.was_active) toast('le moteur était déjà arrêté');
else toast('modèle déchargé');
loadAll();
}
+function fmtMo(mb){ return mb >= 1024 ? (mb/1024).toFixed(1) + ' Gio' : mb + ' Mo'; }
async function vramReload(){
+ if(VRAM_BUSY) return;
vramBusy(true, 'démarrage…');
let r = null;
try{ r = await jpost('/api/vram/reload', {}); }catch(_){}
+ if(r && r.ok) ENGINE_ACTIVE = true;
vramBusy(false);
if(!r || !r.ok){ toast('échec : ' + ((r && r.error) || 'réseau')); return; }
toast('modèle en cours de chargement…');
@@ -6560,12 +6575,18 @@ function syncSendBtn(){
// Moteur ARRÊTÉ (VRAM libérée à la demande, ou service coupé) : dire « le
// modèle charge » serait faux, et on attendrait un chargement qui ne viendra
// jamais. On nomme alors le geste qui remet le modèle en mémoire.
- const unloaded = !ready && !ENGINE_ACTIVE;
- sb.title = ready ? '' : (unloaded ? 'le modèle est déchargé — recharge-le pour écrire'
+ // Trois raisons de ne pas être prêt, trois messages : planté au chargement
+ // (recharger ne ferait que replanter — la cause est affichée dans le
+ // moniteur), déchargé à la demande, ou simplement en train de charger.
+ const failed = !ready && !!LOAD_ERROR;
+ const unloaded = !ready && !failed && !ENGINE_ACTIVE;
+ sb.title = ready ? '' : failed ? 'le modèle n\'a pas pu charger — voir l\'erreur dans le moniteur'
+ : (unloaded ? 'le modèle est déchargé — recharge-le pour écrire'
: 'le modèle n\'est pas encore chargé');
const hint=document.getElementById('sendhint');
if(hint){
hint.textContent = ready ? 'Entrée pour envoyer · Maj+Entrée = nouvelle ligne'
+ : failed ? 'Le modèle n\'a pas pu charger — l\'erreur est affichée dans le moniteur, en bas de la barre latérale.'
: unloaded ? 'Modèle déchargé — « Recharger le modèle » dans le moniteur, en bas de la barre latérale.'
: 'Le modèle charge — envoi possible dès qu\'il est prêt.';
hint.classList.toggle('waiting', !ready);
diff --git a/internal/loki/ui/src/js/05-status.js b/internal/loki/ui/src/js/05-status.js
index 6616fce..d1ef222 100644
--- a/internal/loki/ui/src/js/05-status.js
+++ b/internal/loki/ui/src/js/05-status.js
@@ -22,6 +22,9 @@ async function loadStatus(){
// local) : un arrêt venu d'un autre onglet, du terminal ou d'un job doit se
// lire ici aussi, sinon le bouton propose de libérer une carte déjà libre.
ENGINE_ACTIVE = !!s.active;
+ // Un moteur qui a PLANTÉ au chargement n'est pas « déchargé » : sans ce
+ // drapeau, syncSendBtn proposerait de recharger un modèle qui va replanter.
+ LOAD_ERROR = s.load_error || '';
paintVramBtn();
// Le bouton d'envoi suit l'état du moteur : inutile de pouvoir envoyer un
// message à un modèle qui n'est pas encore chargé (voir syncSendBtn).
@@ -197,7 +200,7 @@ async function loadVram(){
// ENGINE_ACTIVE vient de /api/status (relu toutes les 5 s), jamais d'un drapeau
// posé au clic : sinon un second onglet, ou un arrêt venu du terminal,
// afficherait un bouton qui ment.
-let ENGINE_ACTIVE = true, VRAM_BUSY = false;
+let ENGINE_ACTIVE = true, VRAM_BUSY = false, LOAD_ERROR = '';
function paintVramBtn(){
const b = document.getElementById('vram-btn');
if(!b || VRAM_BUSY) return; // pendant l'opération, le libellé appartient à vramBusy
@@ -217,7 +220,11 @@ function vramBusy(on, label){
}
// force=true : on a déjà demandé confirmation ET accepté de couper une réponse
// en cours d'écriture (le serveur refuse sans ça, voir handleVramUnload).
+// Les deux gestes sont exclusifs : un second clic (le bouton des réglages
+// pendant que celui du moniteur travaille) lancerait un « stop » au milieu d'un
+// démarrage, ou l'inverse. VRAM_BUSY tient lieu de verrou.
async function vramUnload(force){
+ if(VRAM_BUSY) return;
if(!force){
const ok = await askConfirm(
'Décharger le modèle ? Le moteur s\'arrête et rend la mémoire vidéo — le chat reste indisponible jusqu\'au rechargement.',
@@ -227,6 +234,10 @@ async function vramUnload(force){
vramBusy(true, 'libération…');
let r = null;
try{ r = await jpost('/api/vram/unload', {force: !!force}); }catch(_){}
+ // Le serveur dit l'état RÉEL après coup : le repeindre depuis l'ancien
+ // ENGINE_ACTIVE (relu seulement toutes les 5 s) afficherait « Libérer » sur
+ // un moteur déjà arrêté.
+ if(r && typeof r.active === 'boolean') ENGINE_ACTIVE = r.active;
vramBusy(false);
if(r && r.generating){
const ok = await askConfirm('Une réponse est en cours d\'écriture : décharger maintenant l\'interrompt. Continuer ?',
@@ -236,17 +247,21 @@ async function vramUnload(force){
}
// La mesure d'abord : la dictée peut avoir rendu de la VRAM alors même que le
// moteur était déjà arrêté — annoncer « déjà arrêté » nierait ce qui vient
- // d'être libéré.
+ // d'être libéré. Sous quelques dizaines de Mo, c'est le bruit du bureau entre
+ // deux lectures, pas une libération : on ne l'annonce pas.
if(!r || !r.ok) toast('échec : ' + ((r && r.error) || 'réseau'));
- else if(r.gpu && r.freed_mb > 0) toast('VRAM libérée : ' + (r.freed_mb/1024).toFixed(1) + ' Gio');
+ else if(r.gpu && r.freed_mb >= 64) toast('VRAM libérée : ' + fmtMo(r.freed_mb));
else if(!r.was_active) toast('le moteur était déjà arrêté');
else toast('modèle déchargé');
loadAll();
}
+function fmtMo(mb){ return mb >= 1024 ? (mb/1024).toFixed(1) + ' Gio' : mb + ' Mo'; }
async function vramReload(){
+ if(VRAM_BUSY) return;
vramBusy(true, 'démarrage…');
let r = null;
try{ r = await jpost('/api/vram/reload', {}); }catch(_){}
+ if(r && r.ok) ENGINE_ACTIVE = true;
vramBusy(false);
if(!r || !r.ok){ toast('échec : ' + ((r && r.error) || 'réseau')); return; }
toast('modèle en cours de chargement…');
diff --git a/internal/loki/ui/src/js/09-stream.js b/internal/loki/ui/src/js/09-stream.js
index ae06599..350e3c9 100644
--- a/internal/loki/ui/src/js/09-stream.js
+++ b/internal/loki/ui/src/js/09-stream.js
@@ -280,12 +280,18 @@ function syncSendBtn(){
// Moteur ARRÊTÉ (VRAM libérée à la demande, ou service coupé) : dire « le
// modèle charge » serait faux, et on attendrait un chargement qui ne viendra
// jamais. On nomme alors le geste qui remet le modèle en mémoire.
- const unloaded = !ready && !ENGINE_ACTIVE;
- sb.title = ready ? '' : (unloaded ? 'le modèle est déchargé — recharge-le pour écrire'
+ // Trois raisons de ne pas être prêt, trois messages : planté au chargement
+ // (recharger ne ferait que replanter — la cause est affichée dans le
+ // moniteur), déchargé à la demande, ou simplement en train de charger.
+ const failed = !ready && !!LOAD_ERROR;
+ const unloaded = !ready && !failed && !ENGINE_ACTIVE;
+ sb.title = ready ? '' : failed ? 'le modèle n\'a pas pu charger — voir l\'erreur dans le moniteur'
+ : (unloaded ? 'le modèle est déchargé — recharge-le pour écrire'
: 'le modèle n\'est pas encore chargé');
const hint=document.getElementById('sendhint');
if(hint){
hint.textContent = ready ? 'Entrée pour envoyer · Maj+Entrée = nouvelle ligne'
+ : failed ? 'Le modèle n\'a pas pu charger — l\'erreur est affichée dans le moniteur, en bas de la barre latérale.'
: unloaded ? 'Modèle déchargé — « Recharger le modèle » dans le moniteur, en bas de la barre latérale.'
: 'Le modèle charge — envoi possible dès qu\'il est prêt.';
hint.classList.toggle('waiting', !ready);
diff --git a/internal/loki/web_vram.go b/internal/loki/web_vram.go
index 042a23e..4f76d4c 100644
--- a/internal/loki/web_vram.go
+++ b/internal/loki/web_vram.go
@@ -70,27 +70,90 @@ func gpuUsedMB(gpus []gpuStat) int {
return used
}
-// gpuUsedSettled attend que la VRAM cesse de baisser avant de la relire : le
-// pilote ne rend pas la mémoire à l'instant où le processus meurt, et une
-// lecture immédiate rapporterait « 0 Mo libérés » juste après un déchargement
-// qui a pourtant marché. Au plus ~3 s, et rien du tout sans GPU.
-func gpuUsedSettled() int {
- gpus := gpuStats()
- if gpus == nil {
- return 0
- }
- last := gpuUsedMB(gpus)
- for i := 0; i < 6; i++ {
- time.Sleep(500 * time.Millisecond)
- cur := gpuUsedMB(gpuStats())
- if cur >= last {
+// gpuSettle attend que la VRAM ait fini de redescendre avant de la relire. Le
+// pilote ne rend pas la mémoire à l'instant où le processus meurt — taskkill et
+// Process.Kill reviennent avant même que CUDA ait commencé son ménage — et une
+// lecture prise trop tôt rapporterait « 0 Mo libérés » après un déchargement
+// qui a pourtant marché. Trois règles, chacune née d'un faux bilan :
+//
+// - une mesure ratée (nvidia-smi absent, ou en pleine réinitialisation juste
+// après l'arrêt) n'est PAS « 0 Mo » : on la saute et on garde la dernière
+// lecture valide — la compter à zéro annoncerait tout le modèle comme
+// libéré alors que rien n'a bougé ;
+// - un palier n'est concluant qu'une fois la baisse commencée : avant, c'est
+// le pilote qui n'a pas encore réagi, pas la mémoire qui a fini de partir ;
+// - deux lectures égales de suite après la baisse, et on rend la valeur.
+// Sans baisse du tout, on rend la dernière lecture après ~4 s.
+//
+// sample renvoie (Mo occupés, lecture réussie). before : la lecture prise
+// avant l'arrêt, qui sert de repère à « la baisse a commencé ».
+func gpuSettle(before int, sample func() (int, bool), pause time.Duration) int {
+ last, lu := before, false
+ baisse, palier := false, 0
+ for i := 0; i < 8; i++ {
+ time.Sleep(pause)
+ cur, ok := sample()
+ if !ok {
+ continue
+ }
+ if cur < before {
+ baisse = true
+ }
+ if lu && cur == last {
+ palier++
+ } else {
+ palier = 0
+ }
+ last, lu = cur, true
+ if baisse && palier >= 1 {
return cur
}
- last = cur
}
return last
}
+func gpuUsedSettled(before int) int {
+ return gpuSettle(before, func() (int, bool) {
+ g := gpuStats()
+ return gpuUsedMB(g), g != nil
+ }, 500*time.Millisecond)
+}
+
+// engineNeedsStop : faut-il envoyer « stop » ? Actif, évidemment. Mais sous
+// systemd, « is-active » ne répond « active » qu'une fois le service établi :
+// une unité en train de (re)démarrer — Restart=on-failure sur un modèle qui
+// meurt au chargement — répond « activating », et la sauter laisserait systemd
+// relancer llama-server toutes les trois secondes, GPU repris à chaque tour,
+// pendant que l'interface affirme « le moteur était déjà arrêté ». On garde la
+// condition pour le bilan, mais élargie aux états transitoires.
+func engineNeedsStop() bool {
+ if serviceIsActive() {
+ return true
+ }
+ if !systemdAvailable() {
+ return false
+ }
+ out, _ := exec.Command("systemctl", "is-active", serviceName()).Output()
+ switch strings.TrimSpace(string(out)) {
+ case "activating", "reloading", "deactivating":
+ return true
+ }
+ return false
+}
+
+// postOnly refuse tout sauf POST. Ces routes changent l'état de la machine —
+// elles arrêtent des processus — et un simple GET ne doit pas y suffire : sans
+// clé de pilotage configurée, une balise
sur une page tierce visitée par
+// hasard suffirait sinon à couper le moteur.
+func postOnly(w http.ResponseWriter, r *http.Request) bool {
+ if r.Method == http.MethodPost {
+ return true
+ }
+ w.Header().Set("Allow", http.MethodPost)
+ sendJSON(w, 405, map[string]any{"ok": false, "error": "méthode " + r.Method + " refusée : POST attendu"})
+ return false
+}
+
// ansiRe / plainErr : les messages de preflightEngine sont écrits pour le
// terminal (bold() y glisse des séquences ANSI). Envoyés tels quels à
// l'interface, ils y affichent des « [1m » au milieu de la phrase.
@@ -107,6 +170,9 @@ func plainErr(err error) string {
// vidéo. Refuse pendant une génération — la couper perdrait la réponse en
// cours — sauf si l'appelant insiste avec {force:true}.
func handleVramUnload(w http.ResponseWriter, r *http.Request) {
+ if !postOnly(w, r) {
+ return
+ }
var req struct {
Force bool `json:"force"`
}
@@ -118,7 +184,7 @@ func handleVramUnload(w http.ResponseWriter, r *http.Request) {
}
gpus := gpuStats()
before := gpuUsedMB(gpus)
- wasActive := serviceIsActive()
+ wasActive := engineNeedsStop()
var errs []string
if wasActive {
@@ -128,12 +194,15 @@ func handleVramUnload(w http.ResponseWriter, r *http.Request) {
}
// La dictée tourne dans un processus séparé, allumé à la demande et éteint
// après dix minutes d'inactivité : sur GPU, elle occupe la carte pendant tout
- // ce temps. Libérer la VRAM sans elle ne libérerait pas tout.
- whisperShutdown()
+ // ce temps. Libérer la VRAM sans elle ne libérerait pas tout. La variante
+ // « vite » : ne jamais attendre derrière un démarrage de la dictée, qui
+ // peut tenir le verrou deux minutes — le navigateur aurait abandonné bien
+ // avant, moteur pourtant déjà arrêté.
+ whisperShutdownVite()
after := before
if gpus != nil {
- after = gpuUsedSettled()
+ after = gpuUsedSettled(before)
}
freed := before - after
if freed < 0 {
@@ -155,6 +224,9 @@ func handleVramUnload(w http.ResponseWriter, r *http.Request) {
// de démarrer un service condamné à mourir en boucle (BIN ou MODEL absents) :
// mieux vaut la vraie raison tout de suite qu'un « chargement… » sans fin.
func handleVramReload(w http.ResponseWriter, r *http.Request) {
+ if !postOnly(w, r) {
+ return
+ }
if serviceIsActive() {
sendJSON(w, 200, map[string]any{"ok": true, "already": true})
return
diff --git a/internal/loki/web_vram_test.go b/internal/loki/web_vram_test.go
new file mode 100644
index 0000000..50cf614
--- /dev/null
+++ b/internal/loki/web_vram_test.go
@@ -0,0 +1,72 @@
+package loki
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+// gpuSettle : le bilan « VRAM libérée » est calculé sur ces lectures. Chaque cas
+// est un faux bilan qu'on a vu — ou qu'on aurait vu — dans l'interface.
+func TestGpuSettle(t *testing.T) {
+ seq := func(vals ...int) func() (int, bool) {
+ i := 0
+ return func() (int, bool) {
+ if i >= len(vals) {
+ return vals[len(vals)-1], true
+ }
+ v := vals[i]
+ i++
+ if v < 0 { // lecture ratée (nvidia-smi absent ou en réinitialisation)
+ return 0, false
+ }
+ return v, true
+ }
+ }
+ for _, c := range []struct {
+ name string
+ seq []int
+ want int
+ }{
+ // Le pilote met un temps à réagir : palier AVANT la baisse, puis la
+ // mémoire part. L'ancienne version rendait 20000 dès le premier palier.
+ {"palier avant la baisse", []int{20000, 20000, 12000, 3000, 500, 500}, 500},
+ // Baisse immédiate et régulière, puis stable.
+ {"baisse puis stable", []int{15000, 8000, 400, 400}, 400},
+ // Une lecture ratée au milieu n'est pas « 0 Mo » : on la saute.
+ {"lecture ratée ignorée", []int{14000, -1, 600, 600}, 600},
+ // Toutes les lectures ratées : on rend « avant », donc 0 Mo libérés,
+ // plutôt que d'annoncer 14 Go rendus.
+ {"aucune lecture", []int{-1, -1, -1, -1, -1, -1, -1, -1}, 14000},
+ // Rien ne baisse (moteur déjà arrêté) : dernière lecture valide.
+ {"rien ne bouge", []int{14000, 14000, 14000, 14000, 14000, 14000, 14000, 14000}, 14000},
+ } {
+ t.Run(c.name, func(t *testing.T) {
+ if got := gpuSettle(14000, seq(c.seq...), 0); got != c.want {
+ t.Errorf("gpuSettle = %d, attendu %d", got, c.want)
+ }
+ })
+ }
+}
+
+// Les deux routes arrêtent ou relancent des processus : un GET — une balise
+//
sur une page tierce, sans clé de pilotage — ne doit rien déclencher.
+func TestVramRoutesRefusentGET(t *testing.T) {
+ for path, h := range map[string]http.HandlerFunc{
+ "/api/vram/unload": handleVramUnload,
+ "/api/vram/reload": handleVramReload,
+ } {
+ rec := httptest.NewRecorder()
+ h(rec, httptest.NewRequest(http.MethodGet, path, nil))
+ if rec.Code != 405 {
+ t.Errorf("%s GET : code %d, attendu 405", path, rec.Code)
+ }
+ if rec.Header().Get("Allow") != "POST" {
+ t.Errorf("%s GET : en-tête Allow %q, attendu POST", path, rec.Header().Get("Allow"))
+ }
+ if !strings.Contains(rec.Body.String(), "POST attendu") {
+ t.Errorf("%s GET : corps %q sans explication", path, rec.Body.String())
+ }
+ }
+}