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()) + } + } +}