Files
Loki/internal/loki/backend_models_dl_test.go
T
MichaelandClaude Fable 5 33de4a1144 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
2026-08-29 17:53:14 +02:00

234 lines
7.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package loki
import (
"bytes"
"context"
"math/rand"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
)
// serveBlob serves data with (or without) byte-range support.
func serveBlob(data []byte, ranges bool) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !ranges {
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
_, _ = w.Write(data)
return
}
http.ServeContent(w, r, "m.gguf", time.Time{}, bytes.NewReader(data))
}))
}
// Un téléchargement annulé ne doit laisser NI le .gguf final NI le .part.
func TestRunDownloadCancelLeavesNothing(t *testing.T) {
t.Setenv("LOKI_DL_CONNS", "4")
release := make(chan struct{})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Range") == "bytes=0-0" { // sonde : réponse immédiate
w.Header().Set("Content-Range", "bytes 0-0/"+strconv.Itoa(64<<20))
w.WriteHeader(206)
_, _ = w.Write([]byte{0})
return
}
// Corps qui traîne : l'annulation doit l'interrompre.
w.WriteHeader(206)
w.(http.Flusher).Flush()
select {
case <-release:
case <-r.Context().Done():
}
}))
defer srv.Close()
defer close(release)
dir := t.TempDir()
dest := filepath.Join(dir, "m.gguf")
ctx, cancel := context.WithCancel(context.Background())
st := &dlState{Filename: "m.gguf", cancel: cancel}
done := make(chan struct{})
go func() { runDownloadSet(ctx, st, []string{srv.URL + "/m.gguf"}, []string{dest}); close(done) }()
time.Sleep(300 * time.Millisecond)
cancel()
select {
case <-done:
case <-time.After(10 * time.Second):
t.Fatal("runDownloadSet n a pas rendu la main apres annulation")
}
if !st.Canceled || !st.Finished {
t.Fatalf("état attendu annulé+terminé, got canceled=%v finished=%v err=%q", st.Canceled, st.Finished, st.Err)
}
if _, err := os.Stat(dest + ".part"); !os.IsNotExist(err) {
t.Fatal(".part laissé sur le disque après annulation")
}
if _, err := os.Stat(dest); !os.IsNotExist(err) {
t.Fatal("fichier final créé alors que le téléchargement a été annulé")
}
}
// cleanStalePartFiles doit balayer les .part orphelins sans toucher aux .gguf.
func TestCleanStalePartFiles(t *testing.T) {
testHome(t)
dir := modelsDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
keep := filepath.Join(dir, "bon.gguf")
stale := filepath.Join(dir, "coupe.gguf.part")
for _, p := range []string{keep, stale} {
if err := os.WriteFile(p, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
}
cleanStalePartFiles()
if _, err := os.Stat(stale); !os.IsNotExist(err) {
t.Fatal(".part orphelin non supprimé")
}
if _, err := os.Stat(keep); err != nil {
t.Fatal(".gguf valide supprimé par erreur")
}
}
func TestRunDownloadParallelAndFallback(t *testing.T) {
data := make([]byte, 48<<20) // > 3×dlMinChunk so the split actually kicks in
rand.New(rand.NewSource(1)).Read(data)
t.Setenv("LOKI_DL_CONNS", "4")
for _, ranges := range []bool{true, false} {
srv := serveBlob(data, ranges)
dir := t.TempDir()
dest := filepath.Join(dir, "m.gguf")
st := &dlState{Filename: "m.gguf"}
runDownloadSet(context.Background(), st, []string{srv.URL + "/m.gguf"}, []string{dest})
srv.Close()
if st.Err != "" {
t.Fatalf("ranges=%v: erreur %s", ranges, st.Err)
}
got, err := os.ReadFile(dest)
if err != nil {
t.Fatalf("ranges=%v: %v", ranges, err)
}
if len(got) != len(data) {
t.Fatalf("ranges=%v: taille %d != %d", ranges, len(got), len(data))
}
for i := range got {
if got[i] != data[i] {
t.Fatalf("ranges=%v: octet %d différent", ranges, i)
}
}
if st.Done != int64(len(data)) {
t.Fatalf("ranges=%v: done=%d", ranges, st.Done)
}
if ranges && st.Conns < 2 {
t.Fatalf("attendu du parallélisme, conns=%d", st.Conns)
}
if _, err := os.Stat(dest + ".part"); !os.IsNotExist(err) {
t.Fatalf("ranges=%v: .part laissé derrière", ranges)
}
}
}
// Un dépôt Hugging Face verrouillé répond 401 sur le .gguf alors que son
// arborescence se lit sans jeton. « HTTP 401 depuis la source » n'apprenait
// rien : le message doit nommer le verrou, le dépôt, et le geste à faire.
func TestDLSourceErrorExplainsGatedRepo(t *testing.T) {
const gated = "https://huggingface.co/orcarouter/Qwen3.8-27B-Uncensored-GGUF/resolve/main/m.gguf"
resp := func(status int, code string) *http.Response {
r := &http.Response{StatusCode: status, Header: http.Header{}}
if code != "" {
r.Header.Set("X-Error-Code", code)
}
return r
}
for _, c := range []struct {
name string
token string
resp *http.Response
url string
want []string
absent []string
}{
{
name: "gated sans jeton", resp: resp(401, "GatedRepo"), url: gated,
want: []string{"accès restreint", "orcarouter/Qwen3.8-27B-Uncensored-GGUF", "HF_TOKEN"},
},
{
name: "gated avec jeton refusé", token: "hf_xxx", resp: resp(401, "GatedRepo"), url: gated,
want: []string{"accès restreint", "n'y donne pas accès"},
absent: []string{"renseigne le jeton"},
},
{
name: "dépôt privé ou absent", resp: resp(401, "RepoNotFound"), url: gated,
want: []string{"privé ou inexistant", "HF_TOKEN"},
},
{
name: "fichier absent", resp: resp(404, "EntryNotFound"), url: gated,
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",
want: []string{"accès refusé par la source"},
absent: []string{"huggingface.co"},
},
{
name: "panne de la source", resp: resp(503, ""), url: gated,
want: []string{"en panne", "503"},
},
{
name: "code inconnu", resp: resp(418, ""), url: gated,
want: []string{"HTTP 418 depuis la source"},
},
} {
t.Run(c.name, func(t *testing.T) {
t.Setenv("HF_TOKEN", c.token)
got := dlSourceError(c.resp, c.url).Error()
for _, w := range c.want {
if !strings.Contains(got, w) {
t.Errorf("message %q ne contient pas %q", got, w)
}
}
for _, a := range c.absent {
if strings.Contains(got, a) {
t.Errorf("message %q contient %q alors qu'il ne devrait pas", got, a)
}
}
})
}
}
// La sonde est le premier appel réseau d'une installation : c'est elle qui doit
// remonter le refus expliqué, pas un code HTTP nu.
func TestDLProbeSurfacesExplainedError(t *testing.T) {
t.Setenv("HF_TOKEN", "")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Error-Code", "GatedRepo")
w.WriteHeader(401)
}))
defer srv.Close()
_, _, err := dlProbe(context.Background(), srv.URL+"/m.gguf")
if err == nil {
t.Fatal("un 401 doit faire échouer la sonde")
}
if !strings.Contains(err.Error(), "HF_TOKEN") {
t.Errorf("message %q sans indication sur le jeton", err)
}
}