Compare commits

...
Author SHA1 Message Date
LogiFlow 6ea661d705 Merge branch 'claude/bold-faraday-11i7lh' : Strata sur un SSD dédié (Unraid)
Strata sur un SSD dédié (Unraid)
2026-10-08 12:30:44 +02:00
Claude e236069c92 Strata sur un SSD dédié : place libre mesurée sur /data/strata, caches pip/HF hors de l'image
- strataDetect mesure l'espace libre de /data/strata (et non /data) : un SSD
  monté à cet endroit est celui qui compte pour l'installation.
- L'installeur reçoit XDG_CACHE_HOME=/data/strata/cache : les caches pip et
  Hugging Face ne remplissent plus /root, c.-à-d. l'image Docker d'Unraid.
- docker-compose.unraid.yml + README : monter un pool SSD en /data/strata.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XLbFcZj6mJjDpWvCpcChGA
2026-10-08 09:33:26 +00:00
LogiFlow 6afe07a823 Merge branch 'claude/wizardly-lamport-jfgrq5' : MTP sans recharger le modèle en brouillon
MTP : MODEL_DRAFT égal au modèle principal ignoré (poids chargés deux fois)
2026-10-08 11:18:00 +02:00
Claude 30ce3a4e86 MTP : MODEL_DRAFT égal au modèle principal ignoré (poids chargés deux fois)
Choisir le modèle lui-même comme brouillon dans l'éditeur de preset
écrivait MODEL_DRAFT=<le modèle>, et Loki passait -md vers ce fichier :
common_speculative_init_result charge alors un second exemplaire complet
du modèle, au lieu de greffer le contexte MTP sur le modèle déjà en VRAM.
Swift 27B : « allocating 9628.34 MiB on device 0 » pour le brouillon,
moteur mort ; quand ça passait, couches renvoyées sur le CPU et 5 t/s au
lieu de 22 (prefill 290 t/s au lieu de 890).

probeSpec reconnaît ce cas (os.SameFile), specArgs l'ignore et le dit :
la tête MTP intégrée sert de brouillon sans -md. L'éditeur ne propose
plus le modèle principal dans la liste des brouillons.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wfx8uW1un1WwWJ6xzAbkV
2026-10-08 09:13:56 +00:00
LogiFlow a463ccbd9f Merge branch 'claude/wizardly-lamport-jfgrq5' : arrêt du moteur sans course avec la relance, VRAM rendue
Arrêt du moteur : attendre tout l'arbre (VRAM rendue avant la relance)
2026-10-08 10:08:13 +02:00
Claude 393004cb06 Arrêt du moteur : attendre tout l'arbre, pas seulement le chef
En conteneur, userSvcStop n'attendait que le processus `loki serve`. Avec
Strata, le moteur qui tient la VRAM (cache d'experts, ~9 Go) est un autre
processus qui finit de s'arrêter après le serveur Python : la relance
suivante démarrait llama-server sur une carte encore pleine (« cudaMalloc
failed: out of memory » à l'encodeur de vision) et Strata mesurait la RAM
disponible avec l'ancien moteur encore chargé (mode mmap choisi à tort).

stopProcTree relève l'arbre (session, groupe et descendants) avant tout
signal, SIGTERM puis SIGKILL aux survivants, et ne revient qu'une fois
tout parti.

Diagnostic : une pile d'appels citant load_model ne passe plus pour une
nouvelle tentative (la cause mémoire était perdue), et le chat donne
l'échec de chargement au lieu de « il démarre, réessaie ».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wfx8uW1un1WwWJ6xzAbkV
2026-10-08 07:18:53 +00:00
MichaelandClaude Opus 5.5 7d614324a0 Merge branch 'fix/tune-lock-race' : CI verte, verrou de l'optimiseur sans course
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:29:11 +02:00
MichaelandClaude Opus 5.5 853e59c085 Optimiseur : le ménage du verrou ne croise plus sa prise ni son rendu
TestTuneRecoverNeverReapsOwnLock échouait au hasard sous Linux (2 fois sur
5 déjà en v0.15.0, CI rouge). Le ménage (tuneReapStale) déplaçait un verrou
vivant, le relisait puis le remettait en place ; si release() passait entre
les deux, il ne trouvait plus le fichier et rendait l'inscription. Le verrou
remis en place devenait orphelin, et le ménage suivant le prenait pour une
optimisation interrompue : moteur relancé, preset « rétabli » à tort.

Un mutex de processus (tuneFileMu) sérialise désormais les opérations sur
le fichier — prise, réécriture, rendu, déplacement-relecture du ménage.
L'arrêt d'un essai orphelin et le retour du preset restent hors du mutex.
Vérifié sous Linux : 40 passages sans échec, suite complète verte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:29:08 +02:00
MichaelandClaude Opus 5.5 e7fb63b1dd Merge branch 'fix/strata-ram-mmap' : experts de Strata selon la RAM disponible, mode mmap
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:26:54 +02:00
15 changed files with 462 additions and 30 deletions

No files matched your search

+37
View File
@@ -120,6 +120,43 @@ verrouiller la mémoire : `ulimits: memlock: -1` (déjà présent dans les
fichiers compose fournis). Le benchmark et l'optimiseur ne s'appliquent qu'à
llama.cpp.
### Strata sur un SSD dédié (Unraid)
Strata range **tout** dans `/data/strata` : moteur, environnement Python (~10
Go), modèle (66 à 84 Go), et, quand la RAM ne suffit pas à garder les experts
(mode « lus depuis le disque »), il les relit en continu depuis ce dossier. Un
SSD de 250 Go dédié lui suffit largement (quelle que soit la qualité choisie)
et c'est lui qui fait la vitesse en mode disque : un NVMe vaut mieux qu'un SATA,
et un disque dur de l'array est à proscrire.
1. **Le SSD en pool Unraid.** Array arrêté : *Main → Add Pool*, nom `strata`,
1 emplacement, assigne le SSD. Démarre l'array, clique le pool, *File
system type* = `xfs` (ou `btrfs`), puis *Format* en bas de la page Main.
Le SSD est alors monté en `/mnt/strata`.
(Avec le plugin *Unassigned Devices* à la place : monte-le, il apparaît en
`/mnt/disks/<nom>` — même suite en remplaçant le chemin.)
2. **Le dossier** (terminal Unraid) : `mkdir -p /mnt/strata/loki`.
Vérifie avec `df -h /mnt/strata` que c'est bien le SSD (~230 Go) et pas
`rootfs` : un `/mnt/<pool>` qui n'existe pas est dans la RAM d'Unraid.
3. **Ce qui existe déjà.** Si une installation de Strata avait commencé dans
`/mnt/user/appdata/loki/data/strata`, *Compose Down* puis, au choix :
`rsync -a /mnt/user/appdata/loki/data/strata/ /mnt/strata/loki/` (on garde,
les presets restent valides : le chemin vu par le conteneur ne change pas),
ou supprime ce dossier pour repartir de zéro. Sinon il reste caché sous le
montage et occupe de la place pour rien.
4. **Le compose** : décommente dans `docker-compose.unraid.yml` la ligne
```yaml
- /mnt/strata/loki:/data/strata
```
(sous les deux volumes existants), puis *Compose Up*.
5. **Installer** : *Paramètres → Moteur → Strata*. La fenêtre affiche
maintenant l'espace libre **du SSD** ; choisis la version et la qualité
proposées, puis installe.
Pas de partage `/mnt/user/…` pour ce montage : la couche FUSE d'Unraid
ralentit fortement la lecture des experts en mmap. Et rien à faire côté mover
ou cache : le pool `strata` n'est lié à aucun partage.
## Installation sur Unraid
L'image est construite et publiée par GitHub Actions sur GHCR
+9
View File
@@ -64,6 +64,15 @@ services:
- /mnt/user/appdata/loki/data:/data
# /models : dossier de .gguf déposés à la main (gros fichiers)
- /mnt/user/appdata/loki/models:/models
# Strata sur un SSD dédié (optionnel) : tout ce que Strata installe
# (moteur, Python, modèle de 66 à 84 Go, experts lus en mmap) va dans
# /data/strata. Monter un SSD à cet endroit l'y envoie entier. Le SSD
# doit être un pool Unraid (Main -> Add Pool, ex. « strata ») : chemin
# /mnt/<pool>/…, PAS /mnt/user/… (FUSE ralentit le mmap des experts).
# Vérifie que /mnt/strata existe avant Compose Up : sinon Docker crée
# le dossier dans la RAM d'Unraid. Détails : README, « Strata sur un
# SSD dédié ».
# - /mnt/strata/loki:/data/strata
# Moteur Strata (Qwen3.8 Flash Next) : il verrouille ses experts en RAM
# (mlock, 30 à 50 Go). La limite Docker par défaut (64 Ko) l'en empêche.
ulimits:
+1
View File
@@ -527,6 +527,7 @@ type serveSysInfo struct {
Draft string // MODEL_DRAFT résolu et vérifié ; vide = absent ou introuvable
DraftErr string // pourquoi MODEL_DRAFT n'a pas été trouvé
DraftGGUF *GGUFInfo // métadonnées du brouillon ; nil = illisibles
DraftIsModel bool // MODEL_DRAFT désigne le modèle principal lui-même (voir specArgs)
SpecAutoBlocked string // un essai automatique précédent a échoué : la raison
// Cartes listées par le moteur (--list-devices), lues seulement pour
+22
View File
@@ -113,6 +113,11 @@ func probeSpec(cfg map[string]string, si *serveSysInfo) {
return
}
si.Draft = p
if a, err := os.Stat(p); err == nil {
if b, err := os.Stat(si.Model); err == nil && os.SameFile(a, b) {
si.DraftIsModel = true
}
}
if g, err := ggufMeta(p); err == nil {
si.DraftGGUF = &g
}
@@ -241,6 +246,23 @@ func specAutoBlocker(cfg map[string]string, extra []string, si serveSysInfo) str
// et s'ils viennent de SPEC=auto (cmdServe pose alors le jeton de tentative).
// Fonction pure ; cmdServe a déjà résolu MODEL_DRAFT et lu les GGUF.
func specArgs(cfg map[string]string, extra []string, si serveSysInfo) (args, notes []string, auto bool) {
// MODEL_DRAFT = le modèle principal lui-même (choisi dans la liste des
// brouillons de l'éditeur) : -md recharge alors les 27B une seconde fois
// (common_speculative_init_result charge tout fichier -md comme un modèle
// à part), quand la tête MTP intégrée se greffe sur le modèle déjà en VRAM.
// Vécu : « allocating 9628.34 MiB on device 0 » pour le brouillon, moteur
// mort ; ou, quand ça passait, des couches renvoyées sur le CPU et 5 t/s au
// lieu de 22. On l'ignore : même tête, sans les poids en double.
if si.DraftIsModel && strings.TrimSpace(cfg["MODEL_DRAFT"]) != "" {
c := make(map[string]string, len(cfg))
for k, v := range cfg {
c[k] = v
}
delete(c, "MODEL_DRAFT")
args, notes, auto = specArgs(c, extra, si)
return args, append([]string{"MODEL_DRAFT est le modèle principal lui-même : ignoré — la tête MTP intégrée " +
"sert de brouillon sans recharger les poids (retire MODEL_DRAFT du preset)"}, notes...), auto
}
mode := specMode(cfg)
draftKey := strings.TrimSpace(cfg["MODEL_DRAFT"])
switch mode {
+31
View File
@@ -1,6 +1,7 @@
package loki
import (
"os"
"reflect"
"slices"
"strings"
@@ -107,6 +108,13 @@ func TestSpecArgs(t *testing.T) {
s.Draft = "/models/mtp-Qwen3.6-27B-Q8_0.gguf"
s.DraftGGUF = &GGUFInfo{Arch: "qwen35", BlockCount: 65, HasNextNTensor: true}
}},
{name: "MODEL_DRAFT = le modèle lui-même : pas de -md, la tête intégrée, et on le dit",
cfg: with(forced, "MODEL_DRAFT", "Qwen3.6-27B-MTP.gguf"), notes: 2, want: mtp,
si: func(s *serveSysInfo) {
s.Draft = s.Model
s.DraftGGUF = s.GGUF
s.DraftIsModel = true
}},
{name: "MODEL_DRAFT petit modèle : -md + draft-simple, sinon chargé pour rien",
cfg: with(forced, "MODEL_DRAFT", "Qwen3-0.6B.gguf"), notes: 1,
want: []string{"-md", "/models/Qwen3-0.6B.gguf", "--spec-type", "draft-simple", "--spec-draft-sampling", "greedy"},
@@ -493,3 +501,26 @@ func TestSynthAcceptanceWarned(t *testing.T) {
t.Errorf("variable : %q", n)
}
}
// probeSpec reconnaît un MODEL_DRAFT qui désigne le modèle principal.
func TestProbeSpecDraftIsModel(t *testing.T) {
t.Setenv("LOKI_HOME", t.TempDir())
dir := t.TempDir()
model := dir + "/Swift-27B.gguf"
other := dir + "/mtp-Swift.gguf"
for _, p := range []string{model, other} {
if err := os.WriteFile(p, []byte("GGUF"), 0o644); err != nil {
t.Fatal(err)
}
}
si := serveSysInfo{Model: model}
probeSpec(map[string]string{"MODEL_DRAFT": model}, &si)
if !si.DraftIsModel {
t.Errorf("MODEL_DRAFT = le modèle, non reconnu (Draft=%q, err=%q)", si.Draft, si.DraftErr)
}
si = serveSysInfo{Model: model}
probeSpec(map[string]string{"MODEL_DRAFT": other}, &si)
if si.DraftIsModel || si.Draft != other {
t.Errorf("tête à part prise pour le modèle : %+v", si)
}
}
+7 -2
View File
@@ -284,7 +284,9 @@ const strataMinArch = 75
func strataDetect() strataEnv {
env := strataEnv{Main: -1, Helper: -1, RAMGB: totalRAMGB()}
env.AvailGB = min(memAvailableGB()+strataEngineRSSGB(), env.RAMGB)
if f := diskFree(LokiHome()); f > 0 {
// strataHome() et pas LokiHome() : /data/strata peut être un disque à part
// (SSD dédié monté dans le conteneur), c'est sa place libre qui compte.
if f := diskFree(strataHome()); f > 0 {
env.DiskFreeGB = float64(f) / 1e9
}
if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" {
@@ -774,7 +776,10 @@ func strataRunInstall(req strataInstallReq) {
// réserver sa propre variante (qui ne voit pas un experts.bin présent).
"--low-ram", "off",
}
extra := "CUDA_DEVICE_ORDER=PCI_BUS_ID\x00PYTHONUNBUFFERED=1"
// XDG_CACHE_HOME : les caches de pip et de Hugging Face (~/.cache, soit
// /root dans le conteneur, donc l'image Docker — 20 Go sur Unraid) suivent
// le reste de Strata dans /data/strata.
extra := "CUDA_DEVICE_ORDER=PCI_BUS_ID\x00PYTHONUNBUFFERED=1\x00XDG_CACHE_HOME=" + filepath.Join(strataHome(), "cache")
if err := runStepEnv("installation de Strata", strataSrcDir(), extra, "bash", args...); err != nil {
lcFail(fmt.Errorf("l'installation a échoué : %w (voir le journal)", err))
return
+15
View File
@@ -362,3 +362,18 @@ func TestStrataApplySettingsExperts(t *testing.T) {
t.Errorf("vide doit laisser le choix en place : %q", out)
}
}
// Vécu : bascule de Strata vers llama-server, l'encodeur de vision ne trouve
// plus de VRAM. La pile d'appels cite « load_model » : elle ne doit pas passer
// pour une nouvelle tentative qui effacerait la cause, écrite juste avant.
func TestModelLoadErrorOOMBeforeBacktrace(t *testing.T) {
log := "0.00.247.285 I srv load_model: loading model '/data/models/Swift.gguf'\n" +
"4.47.957.764 E ggml_backend_cuda_buffer_type_alloc_buffer: allocating 884.62 MiB on device 0: cudaMalloc failed: out of memory\n" +
"/app/ggml/src/ggml-backend.cpp:328: GGML_ASSERT(buffer) failed\n" +
"/data/engine/libmtmd.so.0(mtmd_init_from_file+0x31)[0x14bd4fea4c01]\n" +
"/data/engine/libllama-server-impl.so(_ZN19server_context_impl10load_modelER13common_params+0x7ff)[0x14bd50b9f1ff]\n" +
"/lib/x86_64-linux-gnu/libc.so.6(__libc_start_main+0x8b)[0x14bd5059a28b]\n"
if got := modelLoadErrorFrom(log); !strings.Contains(got, "mémoire GPU insuffisante") {
t.Errorf("cause mémoire perdue derrière la pile d'appels : %q", got)
}
}
+46 -11
View File
@@ -146,6 +146,15 @@ func tuneActive() (tuneOwner, bool) {
// tuneHeld : verrous pris par CE processus et pas encore rendus (tuneHeldKey).
var tuneHeld sync.Map
// tuneFileMu sérialise, DANS CE PROCESSUS, les opérations sur le fichier de
// verrou : prise, réécriture, rendu, et le déplacement-relecture du ménage
// (tuneReapStale). Sans lui, le ménage pouvait déplacer un verrou vivant
// pendant que release() le cherchait : release, ne le trouvant plus, rendait
// l'inscription ; le ménage le remettait ensuite en place, désormais orphelin,
// et le ménage suivant le prenait pour une optimisation interrompue (moteur
// relancé, preset « rétabli »). Vu en CI : TestTuneRecoverNeverReapsOwnLock.
var tuneFileMu sync.Mutex
func tuneHeldKey(o tuneOwner) string { return fmt.Sprintf("%s|%d|%d", o.Via, o.Since, o.Nonce) }
// tuneOwnerAlive : le propriétaire d'un verrou vit-il encore ? Un verrou qui
@@ -201,6 +210,7 @@ func tuneLockAcquire(via string, mainActive bool) (*tuneLock, error) {
key := tuneHeldKey(l.owner)
tuneHeld.Store(key, true)
for attempt := 0; attempt < 3; attempt++ {
tuneFileMu.Lock()
f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
if err == nil {
b, _ := json.Marshal(l.owner)
@@ -209,11 +219,14 @@ func tuneLockAcquire(via string, mainActive bool) (*tuneLock, error) {
if werr != nil || cerr != nil {
_ = os.Remove(path)
tuneHeld.Delete(key)
tuneFileMu.Unlock()
return nil, fmt.Errorf("verrou de l'optimiseur : %v", errors.Join(werr, cerr))
}
l.held = true
tuneFileMu.Unlock()
return l, nil
}
tuneFileMu.Unlock()
if !os.IsExist(err) {
tuneHeld.Delete(key)
return nil, err
@@ -232,6 +245,13 @@ func tuneLockAcquire(via string, mainActive bool) (*tuneLock, error) {
// write réécrit le verrou d'un bloc (fichier voisin puis renommage) : un
// lecteur ne voit jamais un JSON à moitié écrit.
func (l *tuneLock) write() error {
tuneFileMu.Lock()
defer tuneFileMu.Unlock()
return l.writeLocked()
}
// writeLocked : write, tuneFileMu déjà tenu par l'appelant.
func (l *tuneLock) writeLocked() error {
b, err := json.Marshal(l.owner)
if err != nil {
return err
@@ -291,6 +311,8 @@ func (l *tuneLock) setBackup(backup, id, name string) {
func (l *tuneLock) release() {
l.mu.Lock()
defer l.mu.Unlock()
tuneFileMu.Lock()
defer tuneFileMu.Unlock()
if l.held {
// Un fichier absent un instant (renommé par un ménage qui le remet en
// place, voir tuneReapStale) n'est pas un fichier rendu : on relit.
@@ -305,7 +327,7 @@ func (l *tuneLock) release() {
if ours {
l.owner.Phase, l.owner.Trial, l.owner.MainWasActive = "rendu", nil, false
l.owner.Backup, l.owner.PresetID, l.owner.PresetName = "", "", ""
_ = l.write()
_ = l.writeLocked()
for i := 0; i < 5; i++ {
if err := os.Remove(l.path); err == nil || os.IsNotExist(err) {
break
@@ -328,6 +350,24 @@ func (l *tuneLock) release() {
// périmé). Le renommage d'abord : de deux processus qui trouvent le même verrou
// périmé, un seul l'emporte, et aucun ne retire un verrou neuf posé entre-temps.
func tuneReapStale() (mainWasActive bool) {
o, ok := tuneReapClaim()
if !ok {
return false
}
if o.Trial != nil && tuneProcAlive(*o.Trial) {
fmt.Fprintf(os.Stderr, "[loki tune] essai orphelin (PID %d) d'une optimisation interrompue : arrêt\n", o.Trial.PID)
tuneKillTree(o.Trial.PID, nil)
}
tuneUndoApply(o)
return o.MainWasActive
}
// tuneReapClaim : la partie de tuneReapStale qui touche au fichier, sous
// tuneFileMu — lecture, déplacement, relecture, remise en place. Rend le
// verrou périmé effectivement retiré (ok=false : rien à écarter).
func tuneReapClaim() (tuneOwner, bool) {
tuneFileMu.Lock()
defer tuneFileMu.Unlock()
path := tuneLockPath()
o, ok := readTuneOwner(path)
if !ok {
@@ -338,14 +378,14 @@ func tuneReapStale() (mainWasActive bool) {
if fi, err := os.Stat(path); err == nil && time.Since(fi.ModTime()) > tuneLockFreshness {
_ = os.Remove(path)
}
return false
return tuneOwner{}, false
}
if tuneOwnerAlive(o) {
return false
return tuneOwner{}, false
}
stale := fmt.Sprintf("%s.stale-%d-%d", path, os.Getpid(), time.Now().UnixNano())
if os.Rename(path, stale) != nil {
return false
return tuneOwner{}, false
}
defer os.Remove(stale)
// Entre la lecture et le renommage, le verrou lu a pu être rendu et un
@@ -358,14 +398,9 @@ func tuneReapStale() (mainWasActive bool) {
if err := os.Link(stale, path); err != nil {
fmt.Fprintf(os.Stderr, "[loki tune] verrou vivant déplacé par erreur, remise en place impossible : %v\n", err)
}
return false
return tuneOwner{}, false
}
if o.Trial != nil && tuneProcAlive(*o.Trial) {
fmt.Fprintf(os.Stderr, "[loki tune] essai orphelin (PID %d) d'une optimisation interrompue : arrêt\n", o.Trial.PID)
tuneKillTree(o.Trial.PID, nil)
}
tuneUndoApply(o)
return o.MainWasActive
return o, true
}
// sameTuneLock : a et b désignent-ils la même prise de verrou (propriétaire et
+6
View File
@@ -1069,6 +1069,12 @@ func friendlyLLMError(err error) error {
}
func errEngineDown() error {
// Un refus de connexion ne veut pas toujours dire « il charge encore » : si
// le journal montre que le dernier lancement a échoué, c'est cela qu'il faut
// dire — réessayer dans quelques secondes n'y changera rien.
if msg := modelLoadError(); msg != "" {
return fmt.Errorf("⚠️ %s", msg)
}
return fmt.Errorf("⚠️ Le moteur (llama-server) ne répond pas sur le port %d. Il est probablement en train de démarrer ou de charger le modèle — réessaie dans quelques secondes.", LLMPort())
}
+166
View File
@@ -0,0 +1,166 @@
//go:build linux
package loki
import (
"os"
"strconv"
"strings"
"syscall"
"time"
)
// procRef : un processus repéré par son PID ET son instant de démarrage — un
// PID recyclé pendant l'attente ne passe pas pour le même processus.
type procRef struct {
pid int
start string
}
// procStat : les champs de /proc/<pid>/stat utiles ici.
type procStat struct {
state string
ppid, pgrp, sid int
start string
}
// readProcStat lit /proc/<pid>/stat. Le nom (champ 2, entre parenthèses) peut
// contenir des blancs et des parenthèses : on repart de la DERNIÈRE fermante.
func readProcStat(pid int) (procStat, bool) {
b, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat")
if err != nil {
return procStat{}, false
}
return parseProcStat(string(b))
}
func parseProcStat(s string) (procStat, bool) {
i := strings.LastIndexByte(s, ')')
if i < 0 {
return procStat{}, false
}
// f[0] est le champ 3 (état), f[1] ppid, f[2] groupe, f[3] session, f[19]
// l'instant de démarrage (champ 22).
f := strings.Fields(s[i+1:])
if len(f) < 20 {
return procStat{}, false
}
ppid, _ := strconv.Atoi(f[1])
pgrp, _ := strconv.Atoi(f[2])
sid, _ := strconv.Atoi(f[3])
return procStat{state: f[0], ppid: ppid, pgrp: pgrp, sid: sid, start: f[19]}, true
}
// procTable : tous les processus vivants (les zombies ont fini de tourner : leur
// mémoire, VRAM comprise, est déjà rendue).
func procTable() map[int]procStat {
ents, err := os.ReadDir("/proc")
if err != nil {
return nil
}
t := make(map[int]procStat, len(ents))
for _, e := range ents {
pid, err := strconv.Atoi(e.Name())
if err != nil {
continue
}
if st, ok := readProcStat(pid); ok && st.state != "Z" {
t[pid] = st
}
}
return t
}
// procTreeOf : le chef root et tout ce qui en descend — sa session et son
// groupe (Setsid au lancement du service), plus les descendants par la
// filiation, pour un enfant qui aurait pris sa propre session (le serveur
// Python de Strata lance son moteur dans un processus à part). À relever AVANT
// d'envoyer le moindre signal : une fois le chef mort, ses orphelins sont
// rattachés à PID 1 et la filiation est perdue.
func procTreeOf(t map[int]procStat, root, self int) []procRef {
in := map[int]bool{}
for pid, st := range t {
if pid != self && (pid == root || st.sid == root || st.pgrp == root) {
in[pid] = true
}
}
// Fermeture par la filiation, jusqu'à ce que plus rien ne s'ajoute.
for grew := true; grew; {
grew = false
for pid, st := range t {
if !in[pid] && pid != self && in[st.ppid] {
in[pid] = true
grew = true
}
}
}
refs := make([]procRef, 0, len(in))
for pid := range in {
refs = append(refs, procRef{pid: pid, start: t[pid].start})
}
return refs
}
// procRefAlive : le processus tourne encore (ni disparu, ni zombie, ni
// remplacé par un autre sous le même PID).
func procRefAlive(r procRef) bool {
st, ok := readProcStat(r.pid)
return ok && st.state != "Z" && st.start == r.start
}
func procRefsAlive(refs []procRef) []procRef {
var alive []procRef
for _, r := range refs {
if procRefAlive(r) {
alive = append(alive, r)
}
}
return alive
}
// stopProcTree arrête le service lancé avec Setsid (pid = chef de session) et
// N'EN REVIENT QU'UNE FOIS TOUT L'ARBRE PARTI.
//
// Attendre le seul chef ne suffit pas : avec Strata, le chef est le serveur
// Python, et le moteur qui tient la VRAM (cache d'experts, plusieurs Go) est un
// autre processus qui finit de s'arrêter après lui. La relance qui suivait
// 500 ms plus tard démarrait alors llama-server sur une carte encore pleine —
// « cudaMalloc failed: out of memory » sur un modèle qui tient pourtant — et
// Strata mesurait la RAM disponible avec l'ancien moteur encore chargé.
//
// SIGTERM à tous, grace pour finir proprement, SIGKILL aux survivants, puis
// settle au plus pour que le noyau les ait vraiment retirés (un processus
// coincé dans une lecture disque ne meurt qu'à la sortie de l'appel système).
// Renvoie ceux qui tournent encore au-delà (rien à faire de plus, mais le dire).
func stopProcTree(pid int, grace, settle time.Duration) []procRef {
refs := procTreeOf(procTable(), pid, os.Getpid())
if len(refs) == 0 {
return nil
}
if err := syscall.Kill(-pid, syscall.SIGTERM); err != nil {
_ = syscall.Kill(pid, syscall.SIGTERM)
}
for _, r := range refs {
_ = syscall.Kill(r.pid, syscall.SIGTERM)
}
if left := waitProcRefs(refs, grace); len(left) == 0 {
return nil
}
_ = syscall.Kill(-pid, syscall.SIGKILL)
for _, r := range procRefsAlive(refs) {
_ = syscall.Kill(r.pid, syscall.SIGKILL)
}
return waitProcRefs(refs, settle)
}
// waitProcRefs attend que tous aient disparu, au plus wait. Renvoie les survivants.
func waitProcRefs(refs []procRef, wait time.Duration) []procRef {
deadline := time.Now().Add(wait)
for {
left := procRefsAlive(refs)
if len(left) == 0 || time.Now().After(deadline) {
return left
}
time.Sleep(100 * time.Millisecond)
}
}
+83
View File
@@ -0,0 +1,83 @@
//go:build linux
package loki
import (
"os"
"os/exec"
"strconv"
"strings"
"syscall"
"testing"
"time"
)
func TestParseProcStat(t *testing.T) {
st, ok := parseProcStat("4242 (python3 (x) y) S 4200 4100 4000 0 -1 4194560 0 0 0 0 0 0 0 0 20 0 1 0 987654 0 0")
if !ok || st.state != "S" || st.ppid != 4200 || st.pgrp != 4100 || st.sid != 4000 || st.start != "987654" {
t.Fatalf("lecture fausse : %+v ok=%v", st, ok)
}
if _, ok := parseProcStat("4242 (tronqué"); ok {
t.Error("ligne sans parenthèse fermante acceptée")
}
}
func TestProcTreeOf(t *testing.T) {
tab := map[int]procStat{
100: {ppid: 1, pgrp: 100, sid: 100, start: "a"}, // chef (Setsid)
101: {ppid: 100, pgrp: 100, sid: 100, start: "b"},
200: {ppid: 101, pgrp: 200, sid: 200, start: "c"}, // moteur dans sa propre session
201: {ppid: 200, pgrp: 200, sid: 200, start: "d"},
300: {ppid: 1, pgrp: 300, sid: 300, start: "e"}, // étranger
400: {ppid: 1, pgrp: 100, sid: 100, start: "f"}, // orphelin rattaché à PID 1
500: {ppid: 100, pgrp: 100, sid: 100, start: "g"}, // nous-mêmes
}
got := map[int]bool{}
for _, r := range procTreeOf(tab, 100, 500) {
got[r.pid] = true
}
for _, pid := range []int{100, 101, 200, 201, 400} {
if !got[pid] {
t.Errorf("PID %d oublié", pid)
}
}
if got[300] || got[500] {
t.Errorf("hors de l'arbre pris : %v", got)
}
}
// Le chef meurt vite, son enfant (dans une autre session, comme le moteur de
// Strata) traîne à s'arrêter : stopProcTree ne revient qu'une fois tout parti.
func TestStopProcTreeWaitsForChildren(t *testing.T) {
// Le chef lance un enfant qui ignore SIGTERM dans sa propre session, écrit
// son PID, puis attend.
pidFile := t.TempDir() + "/child"
script := `setsid sh -c 'trap "" TERM; echo $$ > ` + pidFile + `; while :; do sleep 0.1; done' & wait`
cmd := exec.Command("sh", "-c", script)
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
if err := cmd.Start(); err != nil {
t.Skip("sh indisponible :", err)
}
go cmd.Wait() // pas de zombie pour le chef
var child int
for i := 0; i < 50 && child == 0; i++ {
time.Sleep(50 * time.Millisecond)
if b, err := os.ReadFile(pidFile); err == nil {
child, _ = strconv.Atoi(strings.TrimSpace(string(b)))
}
}
if child == 0 {
t.Fatal("l'enfant n'a pas démarré")
}
st, ok := readProcStat(child)
if !ok || st.sid == cmd.Process.Pid {
t.Fatalf("l'enfant devait avoir sa propre session : %+v", st)
}
ref := procRef{pid: child, start: st.start}
if left := stopProcTree(cmd.Process.Pid, 300*time.Millisecond, 5*time.Second); len(left) != 0 {
t.Fatalf("survivants : %v", left)
}
if procRefAlive(ref) {
t.Fatal("l'enfant tourne encore au retour de stopProcTree")
}
}
+12 -12
View File
@@ -125,25 +125,25 @@ func userCheckStarted(pid int) error {
func userSvcStop(verbose bool) error {
pid := readServicePID()
if pid <= 0 || !processAlive(pid) {
// Le chef parti, ses enfants peuvent tourner encore (moteur de Strata) :
// on ne conclut « rien à arrêter » que si l'arbre entier est vide.
if pid <= 0 || len(procTreeOf(procTable(), pid, os.Getpid())) == 0 {
_ = os.Remove(pidFilePath())
if verbose {
fmt.Println(yellow("[info]") + " aucun service en cours d'exécution")
}
return nil
}
// Setsid a fait de l'enfant un chef de groupe : le PID négatif vise le
// groupe entier, donc llama-server s'arrête avec lui.
if err := syscall.Kill(-pid, syscall.SIGTERM); err != nil {
_ = syscall.Kill(pid, syscall.SIGTERM)
}
for i := 0; i < 40 && processAlive(pid); i++ {
time.Sleep(100 * time.Millisecond)
}
if processAlive(pid) {
_ = syscall.Kill(-pid, syscall.SIGKILL)
}
// Setsid a fait de l'enfant un chef de session : tout ce qu'il a lancé
// (llama-server, serveur et moteur de Strata) s'arrête avec lui, et on
// attend qu'il soit VRAIMENT parti — la relance qui suit en a besoin pour
// trouver la VRAM et la RAM libres.
left := stopProcTree(pid, 4*time.Second, 20*time.Second)
_ = os.Remove(pidFilePath())
if len(left) > 0 {
fmt.Fprintf(os.Stderr, "%s %d processus du moteur toujours là après SIGKILL (PID %d…) : "+
"la mémoire du GPU peut ne pas être encore rendue\n", yellow("[attention]"), len(left), left[0].pid)
}
if verbose {
fmt.Println(green("[ok]") + " arrêté")
}
+7 -2
View File
@@ -6635,7 +6635,10 @@ async function populateDraft(){
if(!sel) return;
const list = await jget('/api/models');
const cur = readEnvKey(document.getElementById('m-content').value, 'MODEL_DRAFT');
const items = (list||[]).filter(m => !isMmprojName(m.name));
// Le modèle principal n'est pas un brouillon : le choisir rechargeait tous
// ses poids une seconde fois (-md), quand sa tête MTP s'utilise sans.
const main = matchModel(currentModelInTextarea(), list);
const items = (list||[]).filter(m => !isMmprojName(m.name) && m !== main);
const hit = matchModel(cur, items);
let html = '<option value="">— aucun —</option>';
let matched = false;
@@ -6645,7 +6648,9 @@ async function populateDraft(){
html += '<option value="'+escHtml(m.value)+'"'+on+'>'+escHtml(m.name)+' ('+fmtSize(m.size)+')</option>';
}
if(cur && !matched){
html += '<option value="'+escHtml(cur)+'" selected>'+escHtml(baseName(cur)||cur)+' (introuvable)</option>';
const self = main && matchModel(cur, [main]);
html += '<option value="'+escHtml(cur)+'" selected>'+escHtml(baseName(cur)||cur)+
(self ? ' (le modèle lui-même : ignoré, choisis « aucun »)' : ' (introuvable)')+'</option>';
}
sel.innerHTML = html;
syncSpecRow();
+7 -2
View File
@@ -1383,7 +1383,10 @@ async function populateDraft(){
if(!sel) return;
const list = await jget('/api/models');
const cur = readEnvKey(document.getElementById('m-content').value, 'MODEL_DRAFT');
const items = (list||[]).filter(m => !isMmprojName(m.name));
// Le modèle principal n'est pas un brouillon : le choisir rechargeait tous
// ses poids une seconde fois (-md), quand sa tête MTP s'utilise sans.
const main = matchModel(currentModelInTextarea(), list);
const items = (list||[]).filter(m => !isMmprojName(m.name) && m !== main);
const hit = matchModel(cur, items);
let html = '<option value="">— aucun —</option>';
let matched = false;
@@ -1393,7 +1396,9 @@ async function populateDraft(){
html += '<option value="'+escHtml(m.value)+'"'+on+'>'+escHtml(m.name)+' ('+fmtSize(m.size)+')</option>';
}
if(cur && !matched){
html += '<option value="'+escHtml(cur)+'" selected>'+escHtml(baseName(cur)||cur)+' (introuvable)</option>';
const self = main && matchModel(cur, [main]);
html += '<option value="'+escHtml(cur)+'" selected>'+escHtml(baseName(cur)||cur)+
(self ? ' (le modèle lui-même : ignoré, choisis « aucun »)' : ' (introuvable)')+'</option>';
}
sel.innerHTML = html;
syncSpecRow();
+13 -1
View File
@@ -107,6 +107,13 @@ func modelLoadErrorFrom(log string) string {
// On ne considère que ce qui suit la DERNIÈRE tentative de chargement.
start := 0
for i, l := range lines {
// Une pile d'appels cite les fonctions traversées
// (…server_context_impl10load_model…) : ce n'est pas une nouvelle
// tentative, et repartir de là perdait la cause écrite juste avant
// (« cudaMalloc failed: out of memory » devenait « a planté »).
if isBacktraceFrame(l) {
continue
}
// Un lancement refusé par LOAD_GUARD (backend_serve_moe.go) s'arrête
// avant tout chargement : c'est lui, la dernière tentative.
if strings.Contains(l, "loading model") || strings.Contains(l, "load_model") || strings.Contains(l, loadGuardMarker) ||
@@ -159,7 +166,8 @@ func modelLoadErrorFrom(log string) string {
strings.Contains(low, "cudamalloc failed"),
strings.Contains(low, "failed to allocate"):
// Prioritaire sur l'échec générique : la cause mémoire doit gagner.
reason = "mémoire GPU insuffisante (VRAM) — réduis le contexte (CTX) ou prends une quantification plus petite"
reason = "mémoire GPU insuffisante (VRAM) — réduis le contexte (CTX) ou prends une quantification plus petite, " +
"et vérifie qu'aucun autre programme n'occupe la carte (nvidia-smi)"
case strings.Contains(low, "ggml_cuda error"), strings.Contains(low, "cuda error"):
if reason == "" {
reason = "erreur CUDA — GPU indisponible ? (après un redémarrage du serveur : docker restart loki)"
@@ -196,6 +204,10 @@ func modelLoadErrorFrom(log string) string {
}
}
// isBacktraceFrame : une ligne de pile d'appels de glibc
// (« /chemin/lib.so(symbole+0x1c)[0x14bd…] »).
func isBacktraceFrame(l string) bool { return strings.Contains(l, ")[0x") }
// handleServiceLog (GET /api/service/log) : dernières lignes du journal du
// service, pour que l'UI puisse montrer POURQUOI le modèle ne se charge pas
// (fichier .gguf illisible, VRAM insuffisante, bibliothèque manquante…) au lieu