diff --git a/README.md b/README.md index 2e11ef5..5619648 100644 --- a/README.md +++ b/README.md @@ -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 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. + 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` : 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 diff --git a/internal/loki/backend_serve.go b/internal/loki/backend_serve.go index 030d369..b7c11b0 100644 --- a/internal/loki/backend_serve.go +++ b/internal/loki/backend_serve.go @@ -410,7 +410,7 @@ func cmdServe(args []string) error { // 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. 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, diff --git a/internal/loki/backend_serve_moe.go b/internal/loki/backend_serve_moe.go index 00a3579..a9814e2 100644 --- a/internal/loki/backend_serve_moe.go +++ b/internal/loki/backend_serve_moe.go @@ -242,6 +242,53 @@ func loadGuardOff(cfg map[string]string) bool { 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. // 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. // // 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) { if isExternalConfig(cfg) { return "", "" diff --git a/internal/loki/backend_serve_moe_test.go b/internal/loki/backend_serve_moe_test.go index 373ca29..00114ec 100644 --- a/internal/loki/backend_serve_moe_test.go +++ b/internal/loki/backend_serve_moe_test.go @@ -1,6 +1,8 @@ package loki import ( + "errors" + "fmt" "reflect" "strings" "testing" @@ -283,3 +285,48 @@ func TestModelLoadErrorLoadGuard(t *testing.T) { 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) + } + } +} diff --git a/internal/loki/run.go b/internal/loki/run.go index b422803..42cba7c 100644 --- a/internal/loki/run.go +++ b/internal/loki/run.go @@ -4,6 +4,7 @@ package loki import ( + "errors" "fmt" "os" "path/filepath" @@ -202,10 +203,30 @@ Env: func mustExit(err error) { if err != nil { 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. // Précédence : $LOKI_HOME → /etc/default/loki (unix) → défaut plateforme. func LokiHome() string { diff --git a/internal/loki/sys_install_darwin.go b/internal/loki/sys_install_darwin.go index 74093dc..1447698 100644 --- a/internal/loki/sys_install_darwin.go +++ b/internal/loki/sys_install_darwin.go @@ -48,6 +48,15 @@ func unitArgs() map[string]string { 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 { exe := installedExePath() for name, arg := range unitArgs() { diff --git a/internal/loki/sys_install_linux.go b/internal/loki/sys_install_linux.go index fb2a3d1..aa1dfb9 100644 --- a/internal/loki/sys_install_linux.go +++ b/internal/loki/sys_install_linux.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "os/exec" + "path/filepath" ) // sys_install_linux.go — la part systemd de l'installation. Le parcours commun @@ -31,6 +32,9 @@ WorkingDirectory=%s ExecStart=%s Restart=on-failure 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 # 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 ` +// 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 { svc, exe := serviceName(), installedExePath() units := map[string]string{ diff --git a/internal/loki/sys_install_linux_test.go b/internal/loki/sys_install_linux_test.go new file mode 100644 index 0000000..e2dad6d --- /dev/null +++ b/internal/loki/sys_install_linux_test.go @@ -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") + } +} diff --git a/internal/loki/sys_install_windows.go b/internal/loki/sys_install_windows.go index 278e754..d2f2ce5 100644 --- a/internal/loki/sys_install_windows.go +++ b/internal/loki/sys_install_windows.go @@ -16,6 +16,10 @@ import ( // service itself is managed by the PID-file supervisor in sys_service_windows.go // (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 { lokiHome := LokiHome()