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:
MichaelandClaude Fable 5 committed 2026-08-29 17:53:14 +02:00
1 parent 009b585e75
commit 33de4a1144
8 files changed
+279 -32

No files matched your search

+19 -1
View File
@@ -539,7 +539,11 @@ func dlSourceError(resp *http.Response, dlURL string) error {
case 404: case 404:
switch code { switch code {
case "EntryNotFound": 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": case "RevisionNotFound":
return fmt.Errorf("révision introuvable dans le dépôt (HTTP 404)") 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) 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 // 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 à // (lien direct vers un autre hébergeur) : on ne parle alors pas de conditions à
// accepter, qui n'existent que là-bas. // accepter, qui n'existent que là-bas.
+9 -1
View File
@@ -172,7 +172,15 @@ func TestDLSourceErrorExplainsGatedRepo(t *testing.T) {
}, },
{ {
name: "fichier absent", resp: resp(404, "EntryNotFound"), url: gated, 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", name: "source hors Hugging Face", resp: resp(403, ""), url: "https://example.com/m.gguf",
+36 -1
View File
@@ -21,6 +21,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"time" "time"
) )
@@ -170,6 +171,13 @@ const whisperIdle = 10 * time.Minute
const whisperReady = 120 * time.Second const whisperReady = 120 * time.Second
var wsrvMu sync.Mutex 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 { var wsrv struct {
cmd *exec.Cmd cmd *exec.Cmd
port int port int
@@ -231,7 +239,10 @@ func whisperStartLocked(cfg DictateCfg) (int, error) {
} }
wsrv.cmd, wsrv.port, wsrv.cfg = cmd, port, dictateCfgLoad() wsrv.cmd, wsrv.port, wsrv.cfg = cmd, port, dictateCfgLoad()
wsrv.log, wsrv.note, wsrv.depuis = log, note, time.Now() 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() whisperStopLocked()
return 0, err return 0, err
} }
@@ -301,6 +312,30 @@ func whisperShutdown() {
whisperStopLocked() 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. // whisperInfer garantit le serveur puis lui soumet le WAV.
func whisperInfer(ctx context.Context, wav []byte) (string, error) { func whisperInfer(ctx context.Context, wav []byte) (string, error) {
port, err := whisperEnsure() port, err := whisperEnsure()
+26 -5
View File
@@ -3726,6 +3726,9 @@ async function loadStatus(){
// local) : un arrêt venu d'un autre onglet, du terminal ou d'un job doit se // 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. // lire ici aussi, sinon le bouton propose de libérer une carte déjà libre.
ENGINE_ACTIVE = !!s.active; 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(); paintVramBtn();
// Le bouton d'envoi suit l'état du moteur : inutile de pouvoir envoyer un // 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). // 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 // 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, // posé au clic : sinon un second onglet, ou un arrêt venu du terminal,
// afficherait un bouton qui ment. // afficherait un bouton qui ment.
let ENGINE_ACTIVE = true, VRAM_BUSY = false; let ENGINE_ACTIVE = true, VRAM_BUSY = false, LOAD_ERROR = '';
function paintVramBtn(){ function paintVramBtn(){
const b = document.getElementById('vram-btn'); const b = document.getElementById('vram-btn');
if(!b || VRAM_BUSY) return; // pendant l'opération, le libellé appartient à vramBusy 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 // 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). // 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){ async function vramUnload(force){
if(VRAM_BUSY) return;
if(!force){ if(!force){
const ok = await askConfirm( 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.', '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…'); vramBusy(true, 'libération…');
let r = null; let r = null;
try{ r = await jpost('/api/vram/unload', {force: !!force}); }catch(_){} 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); vramBusy(false);
if(r && r.generating){ if(r && r.generating){
const ok = await askConfirm('Une réponse est en cours d\'écriture : décharger maintenant l\'interrompt. Continuer ?', 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 // 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 // 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')); 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 if(!r.was_active) toast('le moteur était déjà arrêté');
else toast('modèle déchargé'); else toast('modèle déchargé');
loadAll(); loadAll();
} }
function fmtMo(mb){ return mb >= 1024 ? (mb/1024).toFixed(1) + ' Gio' : mb + ' Mo'; }
async function vramReload(){ async function vramReload(){
if(VRAM_BUSY) return;
vramBusy(true, 'démarrage…'); vramBusy(true, 'démarrage…');
let r = null; let r = null;
try{ r = await jpost('/api/vram/reload', {}); }catch(_){} try{ r = await jpost('/api/vram/reload', {}); }catch(_){}
if(r && r.ok) ENGINE_ACTIVE = true;
vramBusy(false); vramBusy(false);
if(!r || !r.ok){ toast('échec : ' + ((r && r.error) || 'réseau')); return; } if(!r || !r.ok){ toast('échec : ' + ((r && r.error) || 'réseau')); return; }
toast('modèle en cours de chargement…'); 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 // 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 // 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. // jamais. On nomme alors le geste qui remet le modèle en mémoire.
const unloaded = !ready && !ENGINE_ACTIVE; // Trois raisons de ne pas être prêt, trois messages : planté au chargement
sb.title = ready ? '' : (unloaded ? 'le modèle est déchargé — recharge-le pour écrire' // (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é'); : 'le modèle n\'est pas encore chargé');
const hint=document.getElementById('sendhint'); const hint=document.getElementById('sendhint');
if(hint){ if(hint){
hint.textContent = ready ? 'Entrée pour envoyer · Maj+Entrée = nouvelle ligne' 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.' : 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.'; : 'Le modèle charge — envoi possible dès qu\'il est prêt.';
hint.classList.toggle('waiting', !ready); hint.classList.toggle('waiting', !ready);
+18 -3
View File
@@ -22,6 +22,9 @@ async function loadStatus(){
// local) : un arrêt venu d'un autre onglet, du terminal ou d'un job doit se // 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. // lire ici aussi, sinon le bouton propose de libérer une carte déjà libre.
ENGINE_ACTIVE = !!s.active; 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(); paintVramBtn();
// Le bouton d'envoi suit l'état du moteur : inutile de pouvoir envoyer un // 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). // 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 // 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, // posé au clic : sinon un second onglet, ou un arrêt venu du terminal,
// afficherait un bouton qui ment. // afficherait un bouton qui ment.
let ENGINE_ACTIVE = true, VRAM_BUSY = false; let ENGINE_ACTIVE = true, VRAM_BUSY = false, LOAD_ERROR = '';
function paintVramBtn(){ function paintVramBtn(){
const b = document.getElementById('vram-btn'); const b = document.getElementById('vram-btn');
if(!b || VRAM_BUSY) return; // pendant l'opération, le libellé appartient à vramBusy 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 // 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). // 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){ async function vramUnload(force){
if(VRAM_BUSY) return;
if(!force){ if(!force){
const ok = await askConfirm( 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.', '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…'); vramBusy(true, 'libération…');
let r = null; let r = null;
try{ r = await jpost('/api/vram/unload', {force: !!force}); }catch(_){} 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); vramBusy(false);
if(r && r.generating){ if(r && r.generating){
const ok = await askConfirm('Une réponse est en cours d\'écriture : décharger maintenant l\'interrompt. Continuer ?', 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 // 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 // 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')); 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 if(!r.was_active) toast('le moteur était déjà arrêté');
else toast('modèle déchargé'); else toast('modèle déchargé');
loadAll(); loadAll();
} }
function fmtMo(mb){ return mb >= 1024 ? (mb/1024).toFixed(1) + ' Gio' : mb + ' Mo'; }
async function vramReload(){ async function vramReload(){
if(VRAM_BUSY) return;
vramBusy(true, 'démarrage…'); vramBusy(true, 'démarrage…');
let r = null; let r = null;
try{ r = await jpost('/api/vram/reload', {}); }catch(_){} try{ r = await jpost('/api/vram/reload', {}); }catch(_){}
if(r && r.ok) ENGINE_ACTIVE = true;
vramBusy(false); vramBusy(false);
if(!r || !r.ok){ toast('échec : ' + ((r && r.error) || 'réseau')); return; } if(!r || !r.ok){ toast('échec : ' + ((r && r.error) || 'réseau')); return; }
toast('modèle en cours de chargement…'); toast('modèle en cours de chargement…');
+8 -2
View File
@@ -280,12 +280,18 @@ function syncSendBtn(){
// Moteur ARRÊTÉ (VRAM libérée à la demande, ou service coupé) : dire « le // 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 // 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. // jamais. On nomme alors le geste qui remet le modèle en mémoire.
const unloaded = !ready && !ENGINE_ACTIVE; // Trois raisons de ne pas être prêt, trois messages : planté au chargement
sb.title = ready ? '' : (unloaded ? 'le modèle est déchargé — recharge-le pour écrire' // (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é'); : 'le modèle n\'est pas encore chargé');
const hint=document.getElementById('sendhint'); const hint=document.getElementById('sendhint');
if(hint){ if(hint){
hint.textContent = ready ? 'Entrée pour envoyer · Maj+Entrée = nouvelle ligne' 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.' : 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.'; : 'Le modèle charge — envoi possible dès qu\'il est prêt.';
hint.classList.toggle('waiting', !ready); hint.classList.toggle('waiting', !ready);
+91 -19
View File
@@ -70,27 +70,90 @@ func gpuUsedMB(gpus []gpuStat) int {
return used return used
} }
// gpuUsedSettled attend que la VRAM cesse de baisser avant de la relire : le // 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, et une // pilote ne rend pas la mémoire à l'instant où le processus meurt — taskkill et
// lecture immédiate rapporterait « 0 Mo libérés » juste après un déchargement // Process.Kill reviennent avant même que CUDA ait commencé son ménage — et une
// qui a pourtant marché. Au plus ~3 s, et rien du tout sans GPU. // lecture prise trop tôt rapporterait « 0 Mo libérés » après un déchargement
func gpuUsedSettled() int { // qui a pourtant marché. Trois règles, chacune née d'un faux bilan :
gpus := gpuStats() //
if gpus == nil { // - une mesure ratée (nvidia-smi absent, ou en pleine réinitialisation juste
return 0 // 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
last := gpuUsedMB(gpus) // libéré alors que rien n'a bougé ;
for i := 0; i < 6; i++ { // - un palier n'est concluant qu'une fois la baisse commencée : avant, c'est
time.Sleep(500 * time.Millisecond) // le pilote qui n'a pas encore réagi, pas la mémoire qui a fini de partir ;
cur := gpuUsedMB(gpuStats()) // - deux lectures égales de suite après la baisse, et on rend la valeur.
if cur >= last { // 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 return cur
} }
last = cur
} }
return last 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 // ansiRe / plainErr : les messages de preflightEngine sont écrits pour le
// terminal (bold() y glisse des séquences ANSI). Envoyés tels quels à // terminal (bold() y glisse des séquences ANSI). Envoyés tels quels à
// l'interface, ils y affichent des « [1m » au milieu de la phrase. // 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 // vidéo. Refuse pendant une génération — la couper perdrait la réponse en
// cours — sauf si l'appelant insiste avec {force:true}. // cours — sauf si l'appelant insiste avec {force:true}.
func handleVramUnload(w http.ResponseWriter, r *http.Request) { func handleVramUnload(w http.ResponseWriter, r *http.Request) {
if !postOnly(w, r) {
return
}
var req struct { var req struct {
Force bool `json:"force"` Force bool `json:"force"`
} }
@@ -118,7 +184,7 @@ func handleVramUnload(w http.ResponseWriter, r *http.Request) {
} }
gpus := gpuStats() gpus := gpuStats()
before := gpuUsedMB(gpus) before := gpuUsedMB(gpus)
wasActive := serviceIsActive() wasActive := engineNeedsStop()
var errs []string var errs []string
if wasActive { 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 // 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 // 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. // ce temps. Libérer la VRAM sans elle ne libérerait pas tout. La variante
whisperShutdown() // « 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 after := before
if gpus != nil { if gpus != nil {
after = gpuUsedSettled() after = gpuUsedSettled(before)
} }
freed := before - after freed := before - after
if freed < 0 { 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) : // 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. // mieux vaut la vraie raison tout de suite qu'un « chargement… » sans fin.
func handleVramReload(w http.ResponseWriter, r *http.Request) { func handleVramReload(w http.ResponseWriter, r *http.Request) {
if !postOnly(w, r) {
return
}
if serviceIsActive() { if serviceIsActive() {
sendJSON(w, 200, map[string]any{"ok": true, "already": true}) sendJSON(w, 200, map[string]any{"ok": true, "already": true})
return return
+72
View File
@@ -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())
}
}
}