Files
Loki/internal/loki/web_vram_test.go
T
MichaelandClaude Opus 5.5 9b2e974942 Moniteur : corrections de relecture — un nvidia-smi pendu ne fige plus les jauges
gpuStatsCached tient son verrou pendant la requête : c'est ce qui fait
partager une seule lecture aux onglets. Mais la requête n'était pas bornée,
et le code le dit ailleurs (nvidiaGPUCount) : un pilote coincé peut faire
pendre nvidia-smi. Avant le cache, chaque nouvelle requête relançait son
propre nvidia-smi et les jauges revenaient avec le pilote ; avec le verrou,
un seul processus pendu bloquait /api/vram pour tous les onglets jusqu'au
redémarrage de Loki.

- nvidiaSmiQuery : CommandContext borné à 10 s (large pour une première
  lecture lente sans mode persistant), WaitDelay d'une seconde. Au-delà,
  une erreur ordinaire, retentée au délai normal du cache.
- Le déchargement profite de la même borne : une lecture pendue devient une
  mesure ratée, que gpuSettle sait déjà sauter.
- Test : la vraie requête, binaire absent du PATH, rend bien
  exec.ErrNotFound — la seule erreur que le cache garde une minute.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 02:01:11 +02:00

184 lines
6.0 KiB
Go

package loki
import (
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os/exec"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
// 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())
}
}
}
// gpuCache : les jauges de tous les onglets se partagent une lecture par
// fenêtre de deux secondes, même quand les requêtes arrivent en même temps.
func TestGpuCache(t *testing.T) {
type step struct {
at time.Duration // horloge depuis le départ
err error // erreur rendue si la requête part
calls int32 // requêtes cumulées attendues après l'appel
}
absent := &exec.Error{Name: "nvidia-smi", Err: exec.ErrNotFound}
for _, c := range []struct {
name string
steps []step
}{
{"lecture réutilisée puis rafraîchie", []step{
{0, nil, 1}, {time.Second, nil, 1}, {gpuCacheTTL, nil, 2},
}},
// Carte en réinitialisation : on retente au délai normal, pas plus tard.
{"erreur retentée au délai normal", []step{
{0, errors.New("exit status 9"), 1}, {time.Second, nil, 1}, {gpuCacheTTL, nil, 2},
}},
// Pas de nvidia-smi du tout : une recherche par minute suffit.
{"binaire absent mémorisé", []step{
{0, absent, 1}, {30 * time.Second, absent, 1}, {gpuCacheAbsent, absent, 2},
}},
} {
t.Run(c.name, func(t *testing.T) {
var cache gpuCache
var calls int32
t0 := time.Unix(1000, 0)
for i, st := range c.steps {
now := func() time.Time { return t0.Add(st.at) }
cache.get(now, func() ([]gpuStat, error) {
atomic.AddInt32(&calls, 1)
if st.err != nil {
return nil, st.err
}
return []gpuStat{{Name: "fake", Used: 1000}}, nil
})
if got := atomic.LoadInt32(&calls); got != st.calls {
t.Fatalf("étape %d : %d requêtes, attendu %d", i, got, st.calls)
}
}
})
}
t.Run("appels simultanés", func(t *testing.T) {
var cache gpuCache
var calls int32
var wg sync.WaitGroup
for i := 0; i < 16; i++ {
wg.Add(1)
go func() {
defer wg.Done()
g := cache.get(time.Now, func() ([]gpuStat, error) {
atomic.AddInt32(&calls, 1)
time.Sleep(20 * time.Millisecond) // nvidia-smi n'est pas instantané
return []gpuStat{{Name: "fake", Used: 1000}}, nil
})
if len(g) != 1 || g[0].Used != 1000 {
t.Errorf("lecture partagée %+v", g)
}
}()
}
wg.Wait()
if calls != 1 {
t.Errorf("%d nvidia-smi lancés, attendu 1", calls)
}
})
}
// Le bilan du déchargement doit voir la mémoire PARTIR : son échantillonneur
// ne passe jamais par le cache des jauges, même tout juste rempli.
func TestGpuSettleSampleFrais(t *testing.T) {
orig := nvidiaSmiQuery
t.Cleanup(func() { nvidiaSmiQuery = orig; gpuMonitor = gpuCache{} })
var calls int32
nvidiaSmiQuery = func() ([]byte, error) {
n := atomic.AddInt32(&calls, 1)
return []byte(fmt.Sprintf("Fake GPU, %d, 24000, 0, 40\n", 20000-int(n)*1000)), nil
}
gpuMonitor = gpuCache{}
if g := gpuStatsCached(); len(g) != 1 || g[0].Used != 19000 {
t.Fatalf("jauges : %+v", g)
}
for want := 18000; want >= 17000; want -= 1000 {
if used, ok := gpuSettleSample(); !ok || used != want {
t.Errorf("échantillon = %d (%v), attendu %d frais", used, ok, want)
}
}
if g := gpuStatsCached(); g[0].Used != 19000 {
t.Errorf("jauges : %d, attendu la lecture en cache 19000", g[0].Used)
}
}
// Le cache ne mémorise une minute que l'ABSENCE de nvidia-smi : il faut donc que
// la vraie requête (bornée dans le temps) rende toujours exec.ErrNotFound quand
// le binaire n'est pas dans le PATH — sinon Mac et CPU seul relanceraient une
// recherche toutes les deux secondes.
func TestNvidiaSmiQueryAbsent(t *testing.T) {
t.Setenv("PATH", t.TempDir())
if _, err := nvidiaSmiQuery(); !errors.Is(err, exec.ErrNotFound) {
t.Fatalf("erreur %v, attendu exec.ErrNotFound", err)
}
}