mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
VRAM : le bilan mentait, un GET coupait le moteur, la dictée bloquait
Relecture du bouton « Libérer la VRAM » (009b585) — six défauts, tous
sur la même feature :
- gpuUsedSettled comptait une lecture ratée de nvidia-smi comme « 0 Mo »
(pilote en réinitialisation juste après l'arrêt) : freed = before, et
l'interface annonçait 14 Gio rendus alors que rien n'avait bougé. Elle
rendait aussi la main au premier palier, AVANT que le pilote ait réagi
(taskkill et Process.Kill reviennent avant le ménage CUDA) : « 0 Mo »
après un déchargement qui marchait. gpuSettle saute les lectures
ratées, n'accepte un palier qu'une fois la baisse observée, et est
testable (sampler injecté).
- /api/vram/unload et /reload acceptaient GET : sans clé de pilotage,
une balise <img> sur une page tierce suffisait à couper le moteur.
405 + Allow: POST.
- whisperShutdown prend wsrvMu, que whisperEnsure garde jusqu'à deux
minutes pendant un chargement de modèle : le geste dépassait le délai
du navigateur, moteur pourtant déjà arrêté. whisperShutdownVite tue le
processus en train de démarrer (poignée atomique hors verrou) au lieu
d'attendre derrière lui.
- Sous systemd, une unité en crash-loop répond « activating », pas
« active » : le stop était sauté et systemd relançait llama-server
toutes les trois secondes pendant que l'UI disait « déjà arrêté ».
engineNeedsStop élargit aux états transitoires.
- Un moteur planté au chargement était présenté comme « modèle
déchargé — recharge-le » : LOAD_ERROR distingue les deux, le conseil
renvoie vers l'erreur affichée dans le moniteur.
- Deux clics concurrents (moniteur + réglages) lançaient un stop au
milieu d'un start ; VRAM_BUSY fait verrou, et le bouton se repeint
depuis l'état renvoyé par le serveur, pas depuis l'ancien poll.
Quelques Mo de bruit entre deux lectures ne font plus « VRAM libérée :
0.0 Gio ».
Au passage, le 404 d'un téléchargement nomme le fichier manquant : sur
un dépôt qui publie six fragments sur sept (table PLE livrée à part),
« la révision a pu être réécrite » envoyait chercher au mauvais endroit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01961iDyM6pwn2dE2SW23gYX
This commit is contained in:
1 parent
009b585e75
commit
33de4a1144
8 files changed
+279
-32
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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…');
|
||||
|
||||
@@ -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);
|
||||
|
||||
+91
-19
@@ -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 <img> 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
|
||||
|
||||
@@ -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
|
||||
// <img> 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user