Moteur : un refus de LOAD_GUARD n'est plus relancé en boucle par systemd

Le refus d'un chargement résident trop gros pour la RAM faisait sortir
« loki serve » en erreur (code 1) ; l'unité loki-engine (Restart=on-failure,
RestartSec=3) le relançait toutes les trois secondes, sans fin, pour un échec
qui ne dépend que du preset et de la machine.

- Le refus sort avec un code dédié, 78 (EX_CONFIG) ; mustExit respecte le
  code porté par l'erreur, toutes les autres gardent 1.
- L'unité écrite par « loki install » porte RestartPreventExitStatus=78 ;
  les autres échecs restent relancés.
- Unité d'une version précédente (« loki update » ne réécrit pas les unités,
  il tourne sans droits root) : reconnue à coup sûr (parent systemd,
  INVOCATION_ID, unité et compléments lus sans la directive), le refus y sort
  sans erreur pour ne pas boucler, avec la marche à suivre au journal.
  Même chose sous launchd, qui relance toute sortie non nulle.
- Le moteur d'essai de l'optimiseur garde toujours le vrai code. Le
  conteneur ne relance jamais le moteur de lui-même : rien à y changer, la
  raison reste au journal et l'interface la montre (modelLoadError).
- Tests : directive reconnue (et seulement elle), code de sortie du refus,
  enveloppé ou non, unité générée (Linux).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5.5 committed 2026-10-04 14:29:13 +02:00
1 parent d50c8a56c6
commit a4db6d4da1
9 files changed
+194 -3

No files matched your search

+5
View File
@@ -646,6 +646,11 @@ Ajoutées par ce fork :
de 80 % (llama.cpp #26110 : swap, décodage de 25 à 7,5 t/s ; mlock : processus de 80 % (llama.cpp #26110 : swap, décodage de 25 à 7,5 t/s ; mlock : processus
tué) ; **refus** au lancement seulement quand la borne basse dépasse 90 % tué) ; **refus** au lancement seulement quand la borne basse dépasse 90 %
(échec certain), avec la raison en clair, aussi affichée par l'interface. (échec certain), avec la raison en clair, aussi affichée par l'interface.
Le refus sort avec le code 78, que l'unité systemd écrite par
`loki install` ne relance pas (`RestartPreventExitStatus=78`) ; une unité
d'une version précédente (ou launchd) reçoit une sortie sans erreur pour ne
pas boucler — `sudo systemctl edit loki-engine` (section `[Service]`,
`RestartPreventExitStatus=78`) met l'unité à jour.
VRAM inconnue (Mac Intel, AMD, Vulkan, `--device`) ou serveurs `--rpc` : VRAM inconnue (Mac Intel, AMD, Vulkan, `--device`) ou serveurs `--rpc` :
avertissement, jamais de refus. `mmap` et `auto` ne sont jamais concernés ; avertissement, jamais de refus. `mmap` et `auto` ne sont jamais concernés ;
`LLAMA_ARG_NO_MMAP`/`LLAMA_ARG_MLOCK` ne comptent que sur un moteur ancien `LLAMA_ARG_NO_MMAP`/`LLAMA_ARG_MLOCK` ne comptent que sur un moteur ancien
+1 -1
View File
@@ -410,7 +410,7 @@ func cmdServe(args []string) error {
// Chargement résident avec plus de poids en RAM que la machine n'en a : // Chargement résident avec plus de poids en RAM que la machine n'en a :
// échec certain (voir loadModeRisk), LOAD_GUARD=off pour passer outre. // échec certain (voir loadModeRisk), LOAD_GUARD=off pour passer outre.
if _, refuse := loadModeRisk(cfg, llmArgs, si); refuse != "" { if _, refuse := loadModeRisk(cfg, llmArgs, si); refuse != "" {
return fmt.Errorf("%s", refuse) return loadGuardRefusal(refuse, trialDir != "")
} }
// Essai : écoute sur 127.0.0.1 et le port libre choisi par l'optimiseur, // Essai : écoute sur 127.0.0.1 et le port libre choisi par l'optimiseur,
+49 -1
View File
@@ -242,6 +242,53 @@ func loadGuardOff(cfg map[string]string) bool {
return false return false
} }
// loadGuardExitStatus : le code de sortie de « loki serve » quand LOAD_GUARD
// refuse. Le refus est certain et ne dépend que du preset et de la machine :
// relancer ne changerait rien. Sous systemd, Restart=on-failure relançait
// pourtant toutes les trois secondes, sans fin ; l'unité écrite par
// « loki install » porte donc RestartPreventExitStatus pour ce code-là
// (EX_CONFIG de sysexits.h : erreur de configuration). Le conteneur, lui, ne
// relance jamais le moteur de lui-même.
const loadGuardExitStatus = 78
// unitPreventsRefusalRestart : l'unité systemd (son texte) s'abstient-elle de
// relancer sur loadGuardExitStatus ? Une unité écrite par une version
// d'avant ne le fait pas.
func unitPreventsRefusalRestart(unit string) bool {
for _, line := range strings.Split(unit, "\n") {
k, v, ok := strings.Cut(strings.TrimSpace(line), "=")
if !ok || strings.TrimSpace(k) != "RestartPreventExitStatus" {
continue
}
for _, f := range strings.Fields(v) {
if f == strconv.Itoa(loadGuardExitStatus) {
return true
}
}
}
return false
}
// loadGuardRefusal : l'erreur rendue par cmdServe pour un refus, avec son code
// de sortie. Un superviseur qui relancerait quand même sur ce code (unité
// systemd d'une version d'avant, launchd, qui relance toute sortie non nulle)
// reçoit 0 : une sortie « propre » n'est pas relancée, et la raison reste en
// clair dans le journal, où l'interface la reconnaît (modelLoadError). Le
// moteur d'essai de l'optimiseur garde le vrai code : c'est lui qui dit
// l'échec à « loki tune ».
func loadGuardRefusal(refuse string, trial bool) error {
code := loadGuardExitStatus
if !trial {
if restarts, hint := supervisorRestartsRefusal(); restarts {
code = 0
if hint != "" {
fmt.Fprintln(os.Stderr, "[loki serve] "+hint)
}
}
}
return &exitStatusError{code: code, err: fmt.Errorf("%s", refuse)}
}
// loadModeRisk : un mode de chargement résident avec trop de poids en RAM. // loadModeRisk : un mode de chargement résident avec trop de poids en RAM.
// Fonction pure ; args est la ligne finale (ou EXTRA_ARGS normalisé). // Fonction pure ; args est la ligne finale (ou EXTRA_ARGS normalisé).
// //
@@ -257,7 +304,8 @@ func loadGuardOff(cfg map[string]string) bool {
// - warn : l'estimation dépasse 80 % — on prévient, on lance. // - warn : l'estimation dépasse 80 % — on prévient, on lance.
// //
// Un refus fait sortir « loki serve » en erreur, comme un modèle introuvable : // Un refus fait sortir « loki serve » en erreur, comme un modèle introuvable :
// mieux vaut la raison en clair qu'un moteur tué en boucle par le noyau. // mieux vaut la raison en clair qu'un moteur tué en boucle par le noyau. Le code
// de sortie (loadGuardRefusal) empêche le superviseur de relancer en boucle.
func loadModeRisk(cfg map[string]string, args []string, si serveSysInfo) (warn, refuse string) { func loadModeRisk(cfg map[string]string, args []string, si serveSysInfo) (warn, refuse string) {
if isExternalConfig(cfg) { if isExternalConfig(cfg) {
return "", "" return "", ""
+47
View File
@@ -1,6 +1,8 @@
package loki package loki
import ( import (
"errors"
"fmt"
"reflect" "reflect"
"strings" "strings"
"testing" "testing"
@@ -283,3 +285,48 @@ func TestModelLoadErrorLoadGuard(t *testing.T) {
t.Errorf("refus ancien encore signalé : %q", got) t.Errorf("refus ancien encore signalé : %q", got)
} }
} }
// Un refus de LOAD_GUARD ne doit pas être relancé en boucle : l'unité systemd
// écrite par « loki install » exclut son code de sortie, une unité d'avant ne
// le fait pas.
func TestUnitPreventsRefusalRestart(t *testing.T) {
for _, c := range []struct {
unit string
want bool
}{
{"[Service]\nRestart=on-failure\nRestartSec=3\n", false},
{"[Service]\nRestart=on-failure\nRestartPreventExitStatus=78\n", true},
{"[Service]\n RestartPreventExitStatus = 1 78 SIGKILL\n", true},
{"[Service]\nRestartPreventExitStatus=178\n", false},
{"[Service]\n# RestartPreventExitStatus=78\n", false},
} {
if got := unitPreventsRefusalRestart(c.unit); got != c.want {
t.Errorf("%q : %v, attendu %v", c.unit, got, c.want)
}
}
}
// Le refus sort avec son propre code (pas 1), y compris enveloppé ; une erreur
// ordinaire garde 1. Le moteur d'essai garde toujours le vrai code.
func TestLoadGuardRefusalExitStatus(t *testing.T) {
err := loadGuardRefusal("refus ; "+loadGuardMarker, true)
if got := exitStatusOf(err); got != loadGuardExitStatus {
t.Errorf("essai : code %d, attendu %d", got, loadGuardExitStatus)
}
if !strings.Contains(err.Error(), loadGuardMarker) {
t.Errorf("raison perdue : %q", err)
}
if got := exitStatusOf(fmt.Errorf("enveloppé : %w", err)); got != loadGuardExitStatus {
t.Errorf("enveloppé : code %d", got)
}
if got := exitStatusOf(errors.New("modèle introuvable")); got != 1 {
t.Errorf("erreur ordinaire : code %d, attendu 1", got)
}
// Hors superviseur (les tests ne tournent ni sous systemd ni sous launchd) :
// le vrai code aussi.
if restarts, _ := supervisorRestartsRefusal(); !restarts {
if got := exitStatusOf(loadGuardRefusal("refus", false)); got != loadGuardExitStatus {
t.Errorf("hors superviseur : code %d", got)
}
}
}
+22 -1
View File
@@ -4,6 +4,7 @@
package loki package loki
import ( import (
"errors"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
@@ -202,10 +203,30 @@ Env:
func mustExit(err error) { func mustExit(err error) {
if err != nil { if err != nil {
fmt.Fprintln(os.Stderr, "[err]", err) fmt.Fprintln(os.Stderr, "[err]", err)
os.Exit(1) os.Exit(exitStatusOf(err))
} }
} }
// exitStatusError : une erreur qui choisit son code de sortie. Seul usage :
// le refus de LOAD_GUARD, que le superviseur du moteur ne doit pas relancer
// (voir loadGuardExitStatus).
type exitStatusError struct {
code int
err error
}
func (e *exitStatusError) Error() string { return e.err.Error() }
func (e *exitStatusError) Unwrap() error { return e.err }
// exitStatusOf : le code choisi par l'erreur, 1 sinon — comme toujours.
func exitStatusOf(err error) int {
var es *exitStatusError
if errors.As(err, &es) {
return es.code
}
return 1
}
// LokiHome resolves the Loki data directory. // LokiHome resolves the Loki data directory.
// Précédence : $LOKI_HOME → /etc/default/loki (unix) → défaut plateforme. // Précédence : $LOKI_HOME → /etc/default/loki (unix) → défaut plateforme.
func LokiHome() string { func LokiHome() string {
+9
View File
@@ -48,6 +48,15 @@ func unitArgs() map[string]string {
return map[string]string{serviceName(): "serve", uiUnitName: "web"} return map[string]string{serviceName(): "serve", uiUnitName: "web"}
} }
// supervisorRestartsRefusal : sous launchd (le LaunchDaemon du moteur, que
// launchd désigne par XPC_SERVICE_NAME), KeepAlive/SuccessfulExit=false relance
// TOUTE sortie non nulle — aucune exception par code n'existe. Un refus de
// LOAD_GUARD y sort donc sans erreur (voir loadGuardRefusal). L'app de bureau
// lance le moteur elle-même et ne le relance pas : code normal.
func supervisorRestartsRefusal() (bool, string) {
return os.Getenv("XPC_SERVICE_NAME") == "com.loki."+serviceName(), ""
}
func installServices(targetUser, lokiHome string) error { func installServices(targetUser, lokiHome string) error {
exe := installedExePath() exe := installedExePath()
for name, arg := range unitArgs() { for name, arg := range unitArgs() {
+38
View File
@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
"path/filepath"
) )
// sys_install_linux.go — la part systemd de l'installation. Le parcours commun // sys_install_linux.go — la part systemd de l'installation. Le parcours commun
@@ -31,6 +32,9 @@ WorkingDirectory=%s
ExecStart=%s ExecStart=%s
Restart=on-failure Restart=on-failure
RestartSec=3 RestartSec=3
# Lancement refusé d'avance par LOAD_GUARD (le mode de chargement ne tient pas
# en RAM) : relancer n'y changerait rien.
RestartPreventExitStatus=78
# Priorité CPU : on remonte le process pour qu'il ne soit pas dépriorisé face # Priorité CPU : on remonte le process pour qu'il ne soit pas dépriorisé face
# aux tâches de fond (sampling/orchestration côté CPU pèsent sur le débit même # aux tâches de fond (sampling/orchestration côté CPU pèsent sur le débit même
@@ -63,6 +67,40 @@ RestartSec=5
WantedBy=multi-user.target WantedBy=multi-user.target
` `
// engineUnitPath : l'unité du moteur telle que « loki install » l'écrit.
func engineUnitPath() string { return "/etc/systemd/system/" + serviceName() + ".service" }
// supervisorRestartsRefusal : « loki serve » tourne-t-il sous une unité
// systemd qui le relancerait sur loadGuardExitStatus ? C'est le cas d'une unité
// écrite avant que Loki ne pose RestartPreventExitStatus : « loki update » ne
// réécrit pas les unités (il tourne sans droits root), seul « loki install »
// le fait. Reconnu à coup sûr : systemd pour parent, INVOCATION_ID posé par
// systemd, et notre unité lisible sans la directive. Dans le doute, non : le
// code de sortie reste celui d'une erreur.
func supervisorRestartsRefusal() (bool, string) {
if !systemdAvailable() || os.Getenv("INVOCATION_ID") == "" || os.Getppid() != 1 {
return false, ""
}
b, err := os.ReadFile(engineUnitPath())
if err != nil {
return false, ""
}
// Les compléments (« systemctl edit ») comptent aussi.
unit := string(b)
drops, _ := filepath.Glob(engineUnitPath() + ".d/*.conf")
for _, p := range drops {
if d, err := os.ReadFile(p); err == nil {
unit += "\n" + string(d)
}
}
if unitPreventsRefusalRestart(unit) {
return false, ""
}
return true, fmt.Sprintf("unité %s d'une version précédente : sortie sans erreur pour que systemd ne relance pas "+
"en boucle — « sudo systemctl edit %s », section [Service], RestartPreventExitStatus=%d (ou « sudo loki install ») "+
"la met à jour", serviceName(), serviceName(), loadGuardExitStatus)
}
func installServices(targetUser, lokiHome string) error { func installServices(targetUser, lokiHome string) error {
svc, exe := serviceName(), installedExePath() svc, exe := serviceName(), installedExePath()
units := map[string]string{ units := map[string]string{
+19
View File
@@ -0,0 +1,19 @@
//go:build linux
package loki
import (
"strings"
"testing"
)
// L'unité du moteur écrite par « loki install » ne relance pas un refus de
// LOAD_GUARD : sinon systemd le relançait toutes les trois secondes, sans fin.
func TestEngineUnitPreventsRefusalRestart(t *testing.T) {
if !unitPreventsRefusalRestart(engineUnitTemplate) {
t.Fatal("RestartPreventExitStatus absent de l'unité du moteur")
}
if !strings.Contains(engineUnitTemplate, "Restart=on-failure") {
t.Fatal("les autres échecs doivent toujours être relancés")
}
}
+4
View File
@@ -16,6 +16,10 @@ import (
// service itself is managed by the PID-file supervisor in sys_service_windows.go // service itself is managed by the PID-file supervisor in sys_service_windows.go
// (loki start / stop / status), which needs no admin rights. // (loki start / stop / status), which needs no admin rights.
// supervisorRestartsRefusal : aucun superviseur ne relance le moteur sous
// Windows (fichier PID, voir sys_service_windows.go) — code de sortie normal.
func supervisorRestartsRefusal() (bool, string) { return false, "" }
func cmdInstall(args []string) error { func cmdInstall(args []string) error {
lokiHome := LokiHome() lokiHome := LokiHome()